Compare commits
8 Commits
v0.1.0
..
775ba8a8d2
| Author | SHA1 | Date | |
|---|---|---|---|
| 775ba8a8d2 | |||
| c47efa876f | |||
| 48003ffea4 | |||
| d08bee1361 | |||
| 4f1e9a48d7 | |||
| d6cb9947b2 | |||
| e88a7e2bb1 | |||
| 9eead719b0 |
+1
-1
@@ -27,6 +27,6 @@ Thumbs.db
|
|||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
# Personal scratch notes (the engine documentation lives on the wiki, not here)
|
# Personal scratch notes (tracked docs live in docs/ and are NOT ignored)
|
||||||
*.local.md
|
*.local.md
|
||||||
/scratch/
|
/scratch/
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
# Changelog
|
|
||||||
|
|
||||||
Kept as the work happens, not assembled at release time.
|
|
||||||
|
|
||||||
Oxide is in its **alpha phase** — `0.x.x`, on `main`. Phase 1 runs to Stage 16
|
|
||||||
(game export to Linux and Windows), and that milestone is what `1.0.0` means.
|
|
||||||
Until then there is no `dev` channel: alpha *is* main.
|
|
||||||
|
|
||||||
The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
|
|
||||||
versions follow [SemVer](https://semver.org/spec/v2.0.0.html).
|
|
||||||
|
|
||||||
## Unreleased
|
|
||||||
|
|
||||||
## 0.1.0 — 2026-08-08
|
|
||||||
|
|
||||||
The first tagged version. It is not the first work: Stages 0–9 are complete and
|
|
||||||
Stage 10 (scripting, live reload, editor terminal) is complete on `main` bar the
|
|
||||||
editor-UX batch on `dev`. This entry marks the point the project started being
|
|
||||||
versioned, not the point it started.
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- Stages 0–4: workspace and build, math and core primitives, windowing and the
|
|
||||||
app loop, the render context, the scene graph, and forward rendering.
|
|
||||||
- Stage 5, the engine core framework: modules and scheduling, layers/tags with
|
|
||||||
the shared `LayerMask`, the reflection/type registry, the asset server, and
|
|
||||||
the data-driven render pass pipeline.
|
|
||||||
- Stage 6, the editor framework: docking shell, project system, settings
|
|
||||||
framework, file watching, and the module → editor extension API.
|
|
||||||
- Stage 7: the input system — `InputState`, named remappable actions, axes.
|
|
||||||
- Stage 8: the in-game UI system (widgets, layout, theming, text, routing), with
|
|
||||||
the GUI tail still open. Stage 8.5 prefabs and 8.7 editor play mode.
|
|
||||||
- Stage 9: physics on rapier3d, with `LayerMask`-filtered collision.
|
|
||||||
- Stage 10: scripting on rhai — the `Script` component, the sandboxed engine,
|
|
||||||
live reload, the editor console and PTY terminal, and the richer script API.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- The documentation left the repository. `docs/`, `PLAN.md`, `HANDOFF.md` and
|
|
||||||
`CLAUDE.md` are now the [wiki](https://git.houmeres.sk/Houmeres/Oxide/wiki),
|
|
||||||
and the repository's history was rewritten in the same pass so they are not
|
|
||||||
present at any commit. `README.md` and this file stay here, because they ship
|
|
||||||
with the software.
|
|
||||||
- Doc comments and `README.md` that pointed at those files now point at the
|
|
||||||
wiki.
|
|
||||||
|
|
||||||
### Note on history
|
|
||||||
|
|
||||||
Any clone of this repository taken before 2026-08-08 has a different history and
|
|
||||||
should be discarded rather than merged.
|
|
||||||
@@ -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
|
||||||
+5
-1
@@ -28,7 +28,7 @@ hecs = "0.10"
|
|||||||
# Linux is the primary target on BOTH Wayland and Xorg (X11): keep both winit
|
# 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
|
# backends explicitly enabled so neither can be dropped by a default-feature
|
||||||
# change. (On by default today; listing them makes the contract explicit —
|
# change. (On by default today; listing them makes the contract explicit —
|
||||||
# see the wiki Roadmap, "Platform & Target Strategy".)
|
# see PLAN.md "Platform & Target Strategy".)
|
||||||
winit = { version = "0.30", features = ["x11", "wayland", "serde"] }
|
winit = { version = "0.30", features = ["x11", "wayland", "serde"] }
|
||||||
wgpu = "29"
|
wgpu = "29"
|
||||||
pollster = "0.4"
|
pollster = "0.4"
|
||||||
@@ -45,6 +45,10 @@ gltf = { version = "1.4", features = ["utils"] }
|
|||||||
ab_glyph = "0.2"
|
ab_glyph = "0.2"
|
||||||
|
|
||||||
# Editor UI (egui — integrated into oxide-editor only)
|
# Editor UI (egui — integrated into oxide-editor only)
|
||||||
|
# Native file/folder dialogs (New/Open Project). The default `xdg-portal`
|
||||||
|
# backend is pure Rust and talks to xdg-desktop-portal over D-Bus, so one
|
||||||
|
# build serves both Wayland and X11 with no GTK link-time dependency.
|
||||||
|
rfd = "0.15"
|
||||||
egui = "0.34"
|
egui = "0.34"
|
||||||
egui-wgpu = "0.34"
|
egui-wgpu = "0.34"
|
||||||
egui-winit = "0.34"
|
egui-winit = "0.34"
|
||||||
|
|||||||
+291
@@ -0,0 +1,291 @@
|
|||||||
|
# Oxide — Session Handoff
|
||||||
|
|
||||||
|
Drop this file into a new session and say "continue from HANDOFF.md". It
|
||||||
|
captures where we are, how we work, and exactly what to do next. Authoritative
|
||||||
|
roadmap is `PLAN.md`; project rules are `CLAUDE.md`; this is the "current
|
||||||
|
state + how to continue" snapshot.
|
||||||
|
|
||||||
|
_Last updated: 2026-07-10. **`dev` is 4 GUI commits ahead of `main` — the whole
|
||||||
|
Stage-10 editor-UX batch is DONE and on `dev`, awaiting the maintainer's
|
||||||
|
eye-check** (New Script button, open-script-in-editor + External Editor
|
||||||
|
preference, native rfd folder picker on New/Open Project, Unity-style file
|
||||||
|
explorer in the Project panel). Pure-logic groundwork (AssetDatabase
|
||||||
|
uid-preserving file ops `move_asset`/`move_folder`/`delete_asset`, whole-tree
|
||||||
|
`scan`, script template/creation helpers, Rust-1.97 lint fixes) is already on
|
||||||
|
`main`. After the maintainer approves the batch:
|
||||||
|
`git checkout main -q && git merge --ff-only dev -q && git push origin main && git checkout dev -q`.
|
||||||
|
That closes Stage 10 (parked: editor-authored joint components need a
|
||||||
|
serializable entity-reference type). Next up per PLAN.md: Stage 11._
|
||||||
|
|
||||||
|
_Older status (2026-06-17): **Stage 9 — Physics
|
||||||
|
is ✅ COMPLETE on `main`.** **Stage 10 — Scripting, Live Reload & Editor Terminal:
|
||||||
|
core ✅ on `main`** (all eye-checked & approved 2026-06-17 in the `untitled`
|
||||||
|
project): the `oxide-script` crate (`Script` + `.rhai` loader + sandboxed
|
||||||
|
`ScriptEngine`), the `init`/`update(dt)` lifecycle via `ScriptHost`, the
|
||||||
|
`Vec3`/transform engine API, live reload (`examples/script_spin`), editor
|
||||||
|
integration (`Script` addable; play-loop shares the editor
|
||||||
|
`AssetServer`/`AssetDatabase` so a live `.rhai` edit updates a *playing* scene),
|
||||||
|
the **Console** (captures the `log` stream — script `print`/errors), a **command
|
||||||
|
terminal** (`$` → `sh -c`), and an **interactive PTY terminal** (`portable-pty` +
|
||||||
|
`vt100`; tabbed, auto-closes a tab when its program exits, Tab/arrows/Esc routed
|
||||||
|
to the program — runs shells / TUIs / `claude`), and the **richer script API**
|
||||||
|
(scripts now `spawn_entity`/`despawn` and `add_component`/`set_component`/
|
||||||
|
`remove_component` on any entity via RON through the reflection registry —
|
||||||
|
closing the last PLAN round-trip criterion; pure-logic + headless tests → on
|
||||||
|
`main`). **What's left in Stage 10:** the maintainer's editor-UX batch —
|
||||||
|
Unity-style file explorer, native New/Open-Project dialog (typing the path by
|
||||||
|
hand is too hard), a **New Script** button on the `Script` inspector,
|
||||||
|
open-script-in-editor. See §5 to start. Parked, not blockers: editor-authored
|
||||||
|
joint **components** need a serializable entity-reference type._
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Where we are
|
||||||
|
|
||||||
|
- **Phase 1 / Stages 5–8.7: ✅ on `main`.** Engine core, editor framework,
|
||||||
|
input + gizmos, comprehensive UI, Reflection v2 + asset database (Stage 8.5),
|
||||||
|
and editor Play Mode (Stage 8.7).
|
||||||
|
- **Phase 1 / Stage 9 — Physics: ✅ complete on `main`.** All pieces 1–8c done
|
||||||
|
and promoted (eye-checked & approved 2026-06-17).
|
||||||
|
- **Phase 1 / Stage 10 — Scripting: 🚧 core done on `main`** (crate, lifecycle,
|
||||||
|
live reload, editor integration, Console, command terminal, interactive PTY
|
||||||
|
terminal — all eye-checked & approved; **richer script API**
|
||||||
|
spawn/despawn/component-edit — pure-logic, on `main`). One follow-up remains
|
||||||
|
(the editor-UX batch — GUI, needs eye-check). Piece table + what's next in §5;
|
||||||
|
details in `docs/scripting.md`.
|
||||||
|
|
||||||
|
### Stage 9 — piece status (all on `main`)
|
||||||
|
|
||||||
|
| # | Piece | Where |
|
||||||
|
|---|-------|-------|
|
||||||
|
| 1 | Component data model + module wiring: `RigidBody`/`RigidBodyKind`, `Collider`/`ColliderShape`, `PhysicsModule`, `PhysicsSettings` | `physics/` (the `oxide-physics` crate) |
|
||||||
|
| 2 | Rapier-backed sim: build the world from components, step on `FixedUpdate`, write transforms back; by-entity forces/velocities/sleep | `physics/src/world.rs` |
|
||||||
|
| 3 | Collision groups/masks via `LayerMask`, sensors, collision/trigger events (enter/stay/exit) | `physics/src/world.rs` |
|
||||||
|
| 4 | Scene queries: raycast, sphere-cast, point/overlap with `LayerMask` filtering | `physics/src/world.rs` |
|
||||||
|
| 5 | Joints/constraints: fixed, spherical, revolute, prismatic (programmatic API) | `physics/src/world.rs` |
|
||||||
|
| 6 | Kinematic capsule character controller: move-and-slide, step offset, slope limit, grounded | `physics/src/{character,world}.rs` |
|
||||||
|
| 7 | `examples/physics_stack` + `examples/character_capsule` (headless console demos) | `examples/` |
|
||||||
|
| 8a | Editor integration: register RigidBody/Collider/CharacterController (addable, reflected) + enums; wire `PhysicsModule` into the play `App` (physics = first real consumer of Play; Stop reverts via snapshot) | `editor/src/state.rs`, `editor/src/main.rs` |
|
||||||
|
| 8b | **Collider wireframe gizmos**: box/sphere/capsule/cylinder outlines over the viewport; green=solid, amber=sensor, selected drawn thicker; matches the sim (ignores `Transform::scale`); **View ▸ Show Colliders** toggle (on by default) | `editor/src/shell.rs` |
|
||||||
|
| 8c | **Raycast debug probe**: `PhysicsWorld::sync_to_scene` (query the edited scene with no Play) + **View ▸ Raycast Probe** — click freezes a camera→cursor ray into the world (orbit to see it as a 3D line), cyan ray + magenta hit dot + normal whisker, status hint on cast. Overlay `view_proj` decoupled from selection (colliders/probe show with nothing selected) | `physics/src/world.rs`, `editor/src/{shell,main}.rs` |
|
||||||
|
| — | Bug fix: namespace every inspector field widget per component (`ui.push_id(component_name)`) so a field name shared by two components (e.g. both `MeshRenderer` and `Collider` have `shape`) can't collide egui ids and block edits | `editor/src/shell.rs` |
|
||||||
|
|
||||||
|
The `oxide-physics` crate: the **ECS is the source of truth**; the rapier world
|
||||||
|
is a transient resource rebuilt from components each step, so play-mode
|
||||||
|
snapshot/restore works for free. Full usage + internals in `docs/physics.md`.
|
||||||
|
|
||||||
|
### Key design decisions (also in memory + PLAN.md)
|
||||||
|
|
||||||
|
- **Layers = Unity "Model A"** ([[layer-group-model-decision]]): an entity is on
|
||||||
|
**one** `Layer` (single index, render/physics filter slot); multi-category
|
||||||
|
needs are served by **Groups** = the multi-valued `Tags` component + a
|
||||||
|
project-level `GroupRegistry`. **Do NOT reintroduce multi-valued `Layer`
|
||||||
|
membership.** Physics `Collider.membership`/`filter` are `LayerMask`s.
|
||||||
|
- **Component multiplicity = hybrid** ([[component-multiplicity-decision]]):
|
||||||
|
one component of a type per entity (`hecs` archetypal). For "multiple things on
|
||||||
|
one node": a **local-offset field** for naturally-single positioned things, and
|
||||||
|
**child entities** for genuine multiples. NOT internal multi-instance lists.
|
||||||
|
- **`AssetRef<T>` not `Handle<T>` in components**: a component stores a
|
||||||
|
serializable `AssetRef<T>` (= `Option<AssetUid>`); `AssetRef::resolve(db,
|
||||||
|
server)` yields the process-local `Handle<T>` at runtime. The inspector
|
||||||
|
recognises both spellings via `asset_ref_target`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. How we work (follow this exactly)
|
||||||
|
|
||||||
|
- **Build each stage as small, independently-tested pieces.** One piece = one
|
||||||
|
green commit. Split a piece into a pure-logic part (→ `main`) and a GUI part
|
||||||
|
(→ `dev`) when cleaner.
|
||||||
|
- **Local gate before every commit:**
|
||||||
|
`cargo fmt --check && cargo clippy --all-targets -- -D warnings && cargo test`.
|
||||||
|
(`#![deny(warnings)]` is on in the editor — clippy lints are hard errors, e.g.
|
||||||
|
`too_many_arguments` fires at >7 params; bundle args into tuples/structs.)
|
||||||
|
- **Branch promotion rule (from CLAUDE.md):**
|
||||||
|
- Pure-logic, fully proven by automated tests → commit to `dev` **and**
|
||||||
|
fast-forward `main`. Continue without asking. (Headless GPU pixel tests count
|
||||||
|
as proof — run them yourself.)
|
||||||
|
- Interactive GUI / rendering-feel / shell polish (anything a human must
|
||||||
|
**look at**) → push to `dev` only, **ask the maintainer to run + eye-check**,
|
||||||
|
promote after explicit approval.
|
||||||
|
- Decision rule: "Can an automated test prove this without a human looking?"
|
||||||
|
Yes → `main`-eligible. No → stop at `dev`.
|
||||||
|
- **Promotion commands** (from `dev`, after committing + pushing dev):
|
||||||
|
`git checkout main -q && git merge --ff-only dev -q && git push origin main && git checkout dev -q`
|
||||||
|
- **Commit messages** end with: `Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>`
|
||||||
|
- **Document as you build:** keep `PLAN.md`/`README.md`/`docs/` current in the
|
||||||
|
**same** change. Physics docs live in `docs/physics.md`.
|
||||||
|
|
||||||
|
### egui gotcha
|
||||||
|
egui's bundled font has a **limited glyph set**. Confirmed-rendering: `🗑 ➕ ⚙ ✏
|
||||||
|
▾ … ↺`. Confirmed-tofu (avoid): `✕` (U+2715), `⧉`, `⣿` (braille). Reuse an icon
|
||||||
|
already in the codebase or test it before shipping; prefer plain text if unsure.
|
||||||
|
Also: egui assigns widget ids from a hash of the call path + a salt — when the
|
||||||
|
same logical widget appears twice (two components with a same-named field), scope
|
||||||
|
it under `ui.push_id(unique_key)` or it errors and edits silently break.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Conventions (match the existing code)
|
||||||
|
|
||||||
|
- Heavy rustdoc: each public type/fn gets a one-line summary + the "why".
|
||||||
|
Module-level `//!` docs explain the piece's role.
|
||||||
|
- Unit tests in `#[cfg(test)] mod tests` at the bottom of each file; integration
|
||||||
|
tests in `tests/src/lib.rs`. Even for visual pieces, factor the math into pure
|
||||||
|
helpers and unit-test those (e.g. `collider_wire_segments`/`push_arc` in 8b),
|
||||||
|
so only the painting itself needs the eye-check.
|
||||||
|
- Serialization is `serde` + RON throughout. New optional fields use
|
||||||
|
`#[serde(default, skip_serializing_if = ...)]` so older documents still parse.
|
||||||
|
- Errors are `thiserror` enums with specific variants.
|
||||||
|
- Reflection: **public fields only**; `#[reflect(skip)]` drops a public field;
|
||||||
|
`#[reflect(min=, max=)]` on an f32 → slider. Register editor-visible types in
|
||||||
|
`oxide_editor::state::register_builtin_types`; addable ones via
|
||||||
|
`register_addable::<T>` (needs `Default`); enums via `register_enum::<E>`.
|
||||||
|
- New deferred ideas → `PLAN.md` backlog, not the current piece.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Key architecture facts
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Per-field reflection on any component (zero per-type editor code):
|
||||||
|
#[derive(Reflect, Serialize, Deserialize)]
|
||||||
|
struct Timer { pub repeating: bool, pub duration: f32, #[reflect(skip)] pub elapsed: f32 }
|
||||||
|
registry.register_reflected::<Timer>("Timer"); // editor + scripts
|
||||||
|
|
||||||
|
// Inspector renders generically: components_on -> field_infos -> get_field/set_field,
|
||||||
|
// edits routed through SetFieldCmd (undoable, drag-coalesced).
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`oxide_engine::reflect`**: whole-value (`get_ron`/`set_ron`) + per-field
|
||||||
|
(`Reflect` + `register_reflected`) layers. `register_addable` adds an "Add
|
||||||
|
Component" constructor; `register_enum` lists variants for combo widgets.
|
||||||
|
- **`oxide_engine::prefab`**: `Prefab { name, components }`,
|
||||||
|
`ComponentSpec { type_name, ron }`; editor seeds built-ins (Empty/Cube/…).
|
||||||
|
- **`oxide_engine::layer`**: single-valued `Layer { index }`; `LayerMask`
|
||||||
|
(filter); `LayerRegistry` (names); `Tags` + `GroupRegistry` (groups).
|
||||||
|
- **Editor inspector** (`oxide_editor::shell::ShellTabViewer`): node-baked
|
||||||
|
section (Layer/Groups/Transform) then modular components (drag-reorder,
|
||||||
|
enable/disable, remove, Add Component menu). `field_widget` dispatches on
|
||||||
|
`FieldInfo.type_name`.
|
||||||
|
- **Viewport overlay** (`editor/src/shell.rs`): the host feeds a
|
||||||
|
`ViewportOverlay { view_proj, gizmo_size }` each frame; the Viewport tab paints
|
||||||
|
2D over the 3D scene via `project(world, &view_proj, tab_rect) -> Pos2`.
|
||||||
|
Transform gizmo handles, the play-state border, **and the new collider
|
||||||
|
wireframes** all paint this way. Reuse `project` + `painter.line_segment` for
|
||||||
|
any new world-space overlay (e.g. piece 8c's raycast viz).
|
||||||
|
- **Play loop** (`oxide_editor::main::drive_play` + `oxide_engine::scene::
|
||||||
|
SceneSnapshot`): owns a play `App` (DefaultModules + PhysicsModule +
|
||||||
|
ScriptModule; **`app.assets` is set to a clone of the editor's `AssetServer`**
|
||||||
|
and the project `AssetDatabase` is inserted as a resource so scripts resolve +
|
||||||
|
live-reload reaches a playing scene), built on Play / dropped on Stop, **swaps
|
||||||
|
`state.scene` in/out per tick** so the editor scene stays the single source of
|
||||||
|
truth. Snapshot covers reflected components + `Tags`/`DisabledComponents`. Undo
|
||||||
|
cleared on Play/Stop.
|
||||||
|
- **Host input** (`oxide_editor::main`): viewport orbit/pan/zoom + WASD gated on
|
||||||
|
`cursor_over_viewport && !pointer_over_floating`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Exactly what to do next — finish Stage 10 (Scripting)
|
||||||
|
|
||||||
|
**Stage 10's core is done and on `main`** (all eye-checked & approved). The
|
||||||
|
`oxide-script` crate is the worked-out twin of `oxide-physics` (same
|
||||||
|
"ECS-as-truth, transient runtime resource, snapshot-for-free" shape). Full usage
|
||||||
|
+ internals: `docs/scripting.md`. What shipped (all on `main`):
|
||||||
|
|
||||||
|
| Piece | Where |
|
||||||
|
|-------|-------|
|
||||||
|
| `oxide-script` crate: `Script`, `ScriptAsset` + `.rhai` loader (`AssetKind::Script`), sandboxed `ScriptEngine` (compile/run, op cap, `ScriptError`), `ScriptModule` | `script/` |
|
||||||
|
| Lifecycle: `ScriptHost` + `run_scripts` (Update schedule); `init`/`update(dt)`; **engine API** (`bridge.rs`) — scripts read/write their `Transform` via a staged shared context (`position`/`translate`/`rotate_*`/`Vec3`), `rhai` `f32_float` | `script/src/{host,engine,bridge}.rs` |
|
||||||
|
| **Live reload**: `ScriptHost` holds each script's `Handle<ScriptAsset>` alive → watcher's in-place reload recompiles (no restart); `examples/script_spin` | `script/src/host.rs`, `examples/` |
|
||||||
|
| **Editor integration**: `Script` addable in `register_builtin_types`; `drive_play` adds `ScriptModule` + **shares the editor `AssetServer`** + a db snapshot → live `.rhai` edits update a *playing* scene | `editor/src/{state,main}.rs` |
|
||||||
|
| **Console**: capturing logger mirrors the `log` stream into a ring buffer the panel renders (script `print` + paused-script errors via `target:"oxide_script"`) | `editor/src/console.rs`, `shell.rs::console` |
|
||||||
|
| **Command terminal**: `$` prompt → `sh -c <cmd>` in the project root, streams stdout/stderr into the Console | `editor/src/terminal.rs` |
|
||||||
|
| **Interactive PTY terminal**: `PanelKind::Terminal` — tabbed, auto-closes a tab when its program exits, Tab/arrows/Esc routed to the program; runs shells / TUIs / `claude` | `editor/src/pty.rs`, `shell.rs::terminal_panel` |
|
||||||
|
|
||||||
|
### Remaining for Stage 10
|
||||||
|
|
||||||
|
**The editor-UX batch below is ✅ DONE (2026-07-10) — all four items are on
|
||||||
|
`dev` as separate commits awaiting one eye-check pass** (details in each
|
||||||
|
commit message + PLAN.md "Editor-UX follow-ups" + `docs/assets.md` /
|
||||||
|
`docs/scripting.md` / `docs/projects.md`):
|
||||||
|
|
||||||
|
| Piece | Commit | What to eye-check |
|
||||||
|
|-------|--------|-------------------|
|
||||||
|
| New Script button | `4f1e9a4` | Script inspector: name + ➕ creates/assigns a `.rhai`; Enter submits; undo detaches |
|
||||||
|
| Open script in editor | `d08bee1` | ✏ Edit + Project-panel double-click; External Editor pref in Preferences; $EDITOR opens in a Terminal tab |
|
||||||
|
| Native folder picker | `48003ff` | Browse… on New/Open Project — test on **both Wayland and Xorg**; UI keeps rendering while the dialog is up |
|
||||||
|
| File explorer | `c47efa8` | Breadcrumbs, New Folder, rename/delete menus, drag-to-move, OS drag-in import, double-click open |
|
||||||
|
|
||||||
|
Engine-side groundwork already on `main`: `AssetDatabase::move_asset`/
|
||||||
|
`move_folder`/`delete_asset` (uid-preserving, so renames/moves never break a
|
||||||
|
saved `AssetRef`), whole-tree `scan`, `editor/src/assets.rs` script
|
||||||
|
template/creation helpers, `editor/src/explorer.rs` (unit-tested behavior
|
||||||
|
layer). Note: the build machine's toolchain is Rust 1.97 (rustup, user-local
|
||||||
|
`~/.cargo/bin` — may need `export PATH="$HOME/.cargo/bin:$PATH"`).
|
||||||
|
|
||||||
|
**Historical notes (already on `main` before this batch):**
|
||||||
|
|
||||||
|
**1. Richer script API — ✅ DONE, on `main`.** Scripts now `spawn_entity()` /
|
||||||
|
`spawn_entity(name)`, `despawn(e)`, and `add_component`/`set_component`/
|
||||||
|
`remove_component` on any entity, plus `entity()` for their own. Implemented as a
|
||||||
|
**deferred command buffer** in `bridge.rs` (`EntityHandle` + `ScriptCommand`):
|
||||||
|
the `rhai` functions can't borrow the ECS (must be `Send + Sync`), so each call
|
||||||
|
buffers a command the host drains in `ScriptHost::apply_commands` and applies via
|
||||||
|
the reflection registry (`app.types`, `set_ron`/`add_default`/`remove`). `spawn_entity`
|
||||||
|
returns a **provisional** handle resolvable in the same frame (`spawn` is a rhai
|
||||||
|
reserved word, hence the longer name). Component edits go through RON, closing the
|
||||||
|
last PLAN round-trip criterion. Headless tests in `host.rs`/`bridge.rs`; docs in
|
||||||
|
`docs/scripting.md` ("Spawning entities and editing components").
|
||||||
|
|
||||||
|
**2. Maintainer's editor-UX batch — ✅ DONE 2026-07-10, on `dev` (see the
|
||||||
|
table above; awaiting eye-check).** Original ask, for context — details in
|
||||||
|
PLAN.md "Editor-UX follow-ups". The maintainer asked for these after the
|
||||||
|
terminal work:
|
||||||
|
- **"New Script" button** on the `Script` inspector — write a `.rhai` template
|
||||||
|
into `assets/scripts/`, register it in the db, auto-assign it to the component.
|
||||||
|
There is **no in-editor script creation today** (scripts are authored as
|
||||||
|
files). Good small first one to pull forward.
|
||||||
|
- **Open a script in an editor** — double-click / button to open a `.rhai` in an
|
||||||
|
in-editor text view or launch `$EDITOR` / a configured external editor; edits
|
||||||
|
flow back through live reload (you can also just run `$EDITOR file` in the new
|
||||||
|
PTY terminal).
|
||||||
|
- **Unity-style file explorer** in the Project panel (create/rename/move folders,
|
||||||
|
drag-in OS import, context menus) over the existing `AssetDatabase`.
|
||||||
|
- **Native New/Open-Project dialog** (`rfd`, must work on Wayland + X11) —
|
||||||
|
typing the project path by hand "is very hard to use".
|
||||||
|
|
||||||
|
**Reuse, don't reinvent:** `oxide-physics`/`oxide-script` are the template for
|
||||||
|
"new crate → `Module` + schedule → reflected/addable components → editor
|
||||||
|
registration → play-loop integration". The play loop
|
||||||
|
(`oxide_editor::main::drive_play`) runs scripts alongside physics and shares the
|
||||||
|
editor's asset server, so live reload reaches a playing scene.
|
||||||
|
|
||||||
|
**Terminal design note:** the embedded terminal is a general PTY widget
|
||||||
|
(`portable-pty` + `vt100`); run any program (incl. `claude`) from a *Shell* tab.
|
||||||
|
A "one-app launcher" was considered and rejected — it needs the same
|
||||||
|
PTY+VT+render+input stack, so the general widget is strictly better.
|
||||||
|
|
||||||
|
**Don't do these unless asked (parked in PLAN.md):** editor authoring of joint
|
||||||
|
**components** (needs a serializable entity-reference type); standalone game
|
||||||
|
"Launch" (deferred to Stage 16).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Quick orientation commands
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo test # full suite (all green at handoff)
|
||||||
|
cargo test -p oxide-script # the scripting crate (lifecycle, live reload)
|
||||||
|
cargo test -p oxide-editor --lib pty # PTY terminal pure helpers
|
||||||
|
cargo run -p oxide-examples --bin script_spin # headless live-reload demo
|
||||||
|
cargo run -p oxide-editor # the editor (Console + Terminal panels)
|
||||||
|
git log --oneline -15 # recent per-piece commits
|
||||||
|
sed -n '/## Stage 10/,/## Stage 11/p' PLAN.md # Stage 10 spec + status
|
||||||
|
```
|
||||||
|
|
||||||
|
Memory files (auto-loaded each session) track decisions: `MEMORY.md` index →
|
||||||
|
`layer-group-model-decision.md` → `component-multiplicity-decision.md` →
|
||||||
|
`run-on-target-pc.md` (headless-vs-eye-check rule) →
|
||||||
|
`workflow-auto-continue.md` (don't pause between pure-logic pieces).
|
||||||
@@ -13,7 +13,7 @@ stylized low-poly to realistic graphics, and shipping only what each game uses.
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Oxide is built in two phases (see the [Roadmap](https://git.houmeres.sk/Houmeres/Oxide/wiki/Roadmap)): **Phase 1 (Stages 0–16)**
|
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;
|
is the general-purpose engine — everything needed to build and export any game;
|
||||||
**Phase 2 (Stages 17+)** adds optional, feature-gated built-in modules.
|
**Phase 2 (Stages 17+)** adds optional, feature-gated built-in modules.
|
||||||
|
|
||||||
@@ -74,7 +74,7 @@ Available now:
|
|||||||
Runnable example: `cargo run -p oxide-examples --bin ui_menu` (Stage-8
|
Runnable example: `cargo run -p oxide-examples --bin ui_menu` (Stage-8
|
||||||
piece 7)
|
piece 7)
|
||||||
|
|
||||||
## Features (planned — see the [Roadmap](https://git.houmeres.sk/Houmeres/Oxide/wiki/Roadmap))
|
## Features (planned — see [PLAN.md](PLAN.md))
|
||||||
|
|
||||||
**Phase 1 — the general-purpose engine:**
|
**Phase 1 — the general-purpose engine:**
|
||||||
|
|
||||||
@@ -117,7 +117,7 @@ Available now:
|
|||||||
|
|
||||||
- Rust stable toolchain (`rustup` recommended)
|
- Rust stable toolchain (`rustup` recommended)
|
||||||
- Linux — primary target, on **both Wayland and Xorg (X11)**. Windows support and cross-platform
|
- Linux — primary target, on **both Wayland and Xorg (X11)**. Windows support and cross-platform
|
||||||
game export are planned (see the [Roadmap](https://git.houmeres.sk/Houmeres/Oxide/wiki/Roadmap), Stage 16); other platforms are not yet tested.
|
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`)
|
- A GPU with Vulkan or Metal support (for `wgpu`)
|
||||||
|
|
||||||
Install Rust if you don't have it:
|
Install Rust if you don't have it:
|
||||||
@@ -208,23 +208,18 @@ cargo fmt --check
|
|||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
The full documentation — usage guides, per-system API references, and
|
The full documentation — usage guides, per-system API references, and
|
||||||
explanations of the engine's inner workings — lives on the
|
explanations of the engine's inner workings — lives in [`docs/`](docs/README.md).
|
||||||
[wiki](https://git.houmeres.sk/Houmeres/Oxide/wiki). Start there for anything
|
Start there for anything beyond this overview:
|
||||||
beyond this overview:
|
|
||||||
|
|
||||||
- [Getting Started](https://git.houmeres.sk/Houmeres/Oxide/wiki/Getting-started) — build, run, test, install
|
- [Getting Started](docs/getting-started.md) — build, run, test, install
|
||||||
- [Architecture](https://git.houmeres.sk/Houmeres/Oxide/wiki/Architecture) — workspace and design overview
|
- [Architecture](docs/architecture.md) — workspace and design overview
|
||||||
- [Conventions](https://git.houmeres.sk/Houmeres/Oxide/wiki/Conventions) — coordinate system, units, color space
|
- [Conventions](docs/conventions.md) — coordinate system, units, color space
|
||||||
- [Development Workflow](https://git.houmeres.sk/Houmeres/Oxide/wiki/Development) — branches, testing, contributing
|
- [Development Workflow](docs/development.md) — branches, testing, contributing
|
||||||
- [Math & Core Primitives](https://git.houmeres.sk/Houmeres/Oxide/wiki/Math) — Stage 1 API reference
|
- [Math & Core Primitives](docs/math.md) — Stage 1 API reference
|
||||||
- [Windowing & App Loop](https://git.houmeres.sk/Houmeres/Oxide/wiki/Windowing) — Stage 2 window/event-loop reference
|
- [Windowing & App Loop](docs/windowing.md) — Stage 2 window/event-loop reference
|
||||||
- [Render Context](https://git.houmeres.sk/Houmeres/Oxide/wiki/Render-context) — Stage 2 GPU/surface reference
|
- [Render Context](docs/render-context.md) — Stage 2 GPU/surface reference
|
||||||
- [Scene Graph & Entities](https://git.houmeres.sk/Houmeres/Oxide/wiki/Scene) — Stage 3 scene/hierarchy/serialization reference
|
- [Scene Graph & Entities](docs/scene.md) — Stage 3 scene/hierarchy/serialization reference
|
||||||
- [Rendering](https://git.houmeres.sk/Houmeres/Oxide/wiki/Rendering) — Stage 4 mesh/material/camera/forward-renderer reference
|
- [Rendering](docs/rendering.md) — Stage 4 mesh/material/camera/forward-renderer reference
|
||||||
|
|
||||||
The [Roadmap](https://git.houmeres.sk/Houmeres/Oxide/wiki/Roadmap) is the staged
|
|
||||||
plan, and [Working notes](https://git.houmeres.sk/Houmeres/Oxide/wiki/Working-notes)
|
|
||||||
holds the project's rules. Both used to be files in this repository.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -233,11 +228,14 @@ holds the project's rules. Both used to be files in this repository.
|
|||||||
```
|
```
|
||||||
Oxide/
|
Oxide/
|
||||||
├── assets/ # Project logos and shared assets
|
├── assets/ # Project logos and shared assets
|
||||||
|
├── docs/ # Full engine documentation
|
||||||
├── engine/ # Core engine library (oxide-engine)
|
├── engine/ # Core engine library (oxide-engine)
|
||||||
├── editor/ # In-engine editor binary (oxide-editor)
|
├── editor/ # In-engine editor binary (oxide-editor)
|
||||||
├── examples/ # Runnable stage examples (oxide-examples)
|
├── examples/ # Runnable stage examples (oxide-examples)
|
||||||
├── tests/ # Integration test harness (oxide-tests)
|
├── tests/ # Integration test harness (oxide-tests)
|
||||||
└── install.sh # Build + system install script
|
├── install.sh # Build + system install script
|
||||||
|
├── PLAN.md # Staged development roadmap
|
||||||
|
└── CLAUDE.md # Context and rules for Claude Code
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -245,7 +243,7 @@ Oxide/
|
|||||||
## Development roadmap
|
## Development roadmap
|
||||||
|
|
||||||
Development follows a staged plan — each stage is fully tested before the next begins.
|
Development follows a staged plan — each stage is fully tested before the next begins.
|
||||||
See the [Roadmap](https://git.houmeres.sk/Houmeres/Oxide/wiki/Roadmap) for the complete plan.
|
See [PLAN.md](PLAN.md) for the complete roadmap.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# Oxide Engine Documentation
|
||||||
|
|
||||||
|
This directory is the canonical, in-depth documentation for the Oxide engine. The
|
||||||
|
top-level [`README.md`](../README.md) is a short overview; everything detailed —
|
||||||
|
usage guides, API references, and explanations of how the engine works
|
||||||
|
internally — lives here and grows with the engine at every stage.
|
||||||
|
|
||||||
|
## How this documentation is organized
|
||||||
|
|
||||||
|
| Document | What it covers |
|
||||||
|
|----------|----------------|
|
||||||
|
| [getting-started.md](getting-started.md) | Installing the toolchain, building, running examples, running tests and benchmarks |
|
||||||
|
| [architecture.md](architecture.md) | Workspace layout, crate responsibilities, design philosophy, staged development model |
|
||||||
|
| [conventions.md](conventions.md) | Coordinate system, handedness, units, color space, and other cross-cutting conventions |
|
||||||
|
| [development.md](development.md) | Branch workflow (`dev`/`main`), testing protocol, documentation policy, how to add a stage |
|
||||||
|
| [math.md](math.md) | Full reference for the `oxide_engine::math` module (Stage 1) |
|
||||||
|
| [windowing.md](windowing.md) | Window creation, the `App` trait and event loop, raw input events (Stage 2) |
|
||||||
|
| [render-context.md](render-context.md) | GPU acquisition, surface configuration, the clear-color frame loop (Stage 2) |
|
||||||
|
| [scene.md](scene.md) | Scene graph, entities, the transform hierarchy, and serialization (Stage 3) |
|
||||||
|
| [rendering.md](rendering.md) | Meshes, materials, camera, and the forward renderer (Stage 4) |
|
||||||
|
| [layers.md](layers.md) | Layers, groups & tags: single-valued `Layer` + multi-valued `Tags`/`GroupRegistry`, the shared `LayerMask` filter primitive (Stage 5) |
|
||||||
|
| [reflection.md](reflection.md) | Reflection / type registry: generic name-keyed component access for dual-editability (Stage 5) |
|
||||||
|
| [prefabs.md](prefabs.md) | Prefabs: data-driven named spawn templates (component specs applied via the registry) (Stage 8.5) |
|
||||||
|
| [assets.md](assets.md) | Asset server & handles: ref-counted loading, dedup, loaders, background load, reload (Stage 5) |
|
||||||
|
| [modules.md](modules.md) | App, modules & scheduling: composing the engine, system phases, fixed timestep, enable/disable/remove (Stage 5) |
|
||||||
|
| [render-pipeline.md](render-pipeline.md) | Data-driven render pass pipeline: composable passes, scalable fidelity, camera layer visibility (Stage 5) |
|
||||||
|
| [projects.md](projects.md) | Project system: project file, folder layout, create/open/save, recent projects (Stage 6) |
|
||||||
|
| [settings.md](settings.md) | Settings & preferences framework: typed sections, export/import, per-module/per-project settings (Stage 6) |
|
||||||
|
| [file-watching.md](file-watching.md) | File-watcher foundation: debounced/deduplicated change events; asset live-reload wiring (Stage 6) |
|
||||||
|
| [editor-extensions.md](editor-extensions.md) | Module → editor extension API: how a module contributes menus, panels, tools, inspectors, settings pages (Stage 6) |
|
||||||
|
| [editor-shell.md](editor-shell.md) | Editor docking shell: menu bar, dockable panels, status bar, Preferences window, command stack + file watcher wiring (Stage 6) |
|
||||||
|
| [input.md](input.md) | Input system: per-frame `InputState` with edge detection, named action mapping (`ActionMap`/`Binding`) with defaults + remapping + RON persistence, 1D/2D directional axes (Stage 7) |
|
||||||
|
| [ui.md](ui.md) | In-game UI: widget tree, layout (stack/grid/anchor), DPI-aware sizing, per-widget visual styles + named-style themes, RON dual-edit, ab_glyph-backed text shaping + R8 glyph atlas, screen-space + world-space `UiOverlayPass`, hit-test + hover/focus/press `Router`, immediate-mode event queries + typed `WidgetValue`s (Stage 8 — pieces 1–6, GUI tail coming) |
|
||||||
|
| [play-mode.md](play-mode.md) | Editor play mode: `PlayState`, registry-aware `SceneSnapshot` snapshot/restore, the `tick_for` decision + `App::step`, the scene-swap runner, toolbar/shortcuts/viewport tint (Stage 8.7) |
|
||||||
|
| [physics.md](physics.md) | Physics module (`oxide-physics`, rapier3d): `RigidBody`/`Collider` components, `LayerMask`-filtered collision, the ECS-as-source-of-truth model, module wiring (Stage 9) |
|
||||||
|
| [scripting.md](scripting.md) | Scripting module (`oxide-script`, rhai): the `Script` component, `ScriptAsset` + `.rhai` loader, the sandboxed `ScriptEngine` wrapper, module wiring; ECS-as-source-of-truth + live-reload model (Stage 10) |
|
||||||
|
|
||||||
|
## Documentation status by stage
|
||||||
|
|
||||||
|
Documentation is written alongside the code. A stage is not considered done until
|
||||||
|
its docs exist here.
|
||||||
|
|
||||||
|
| Stage | Subject | Docs |
|
||||||
|
|-------|---------|------|
|
||||||
|
| 0 | Project foundation | [architecture.md](architecture.md), [getting-started.md](getting-started.md) |
|
||||||
|
| 1 | Math & core primitives | [math.md](math.md) |
|
||||||
|
| 2 | Window & render context | [windowing.md](windowing.md), [render-context.md](render-context.md) |
|
||||||
|
| 3 | Scene graph & entity system | [scene.md](scene.md) |
|
||||||
|
| 4 | Basic 3D rendering | [rendering.md](rendering.md) |
|
||||||
|
| 5 | Engine core framework | [modules.md](modules.md), [layers.md](layers.md), [reflection.md](reflection.md), [assets.md](assets.md), [render-pipeline.md](render-pipeline.md) |
|
||||||
|
| 6 | Editor framework & project system | [projects.md](projects.md), [settings.md](settings.md), [file-watching.md](file-watching.md), [editor-extensions.md](editor-extensions.md), [editor-shell.md](editor-shell.md) |
|
||||||
|
| 7 | Input system | [input.md](input.md) |
|
||||||
|
| 8 | Comprehensive UI system (in progress) | [ui.md](ui.md) |
|
||||||
|
| 8.7 | Editor play mode | [play-mode.md](play-mode.md) |
|
||||||
|
| 9 | Physics integration (in progress) | [physics.md](physics.md) |
|
||||||
|
| 10 | Scripting, live reload & editor terminal (in progress) | [scripting.md](scripting.md) |
|
||||||
|
| 11+ | — | _added as each stage lands_ |
|
||||||
|
|
||||||
|
## Conventions for these files
|
||||||
|
|
||||||
|
- One topic per file; keep files focused and link between them rather than
|
||||||
|
duplicating content.
|
||||||
|
- Every public type or system gets: a one-line summary, when to use it, and at
|
||||||
|
least one runnable code snippet.
|
||||||
|
- Code snippets are written so they would compile against the current API. When
|
||||||
|
the API changes, update the snippet in the same change.
|
||||||
|
- Prefer explaining the *why* (design intent, trade-offs) over restating the
|
||||||
|
*what* (which the rustdoc comments already cover).
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
# Architecture
|
||||||
|
|
||||||
|
This document explains how Oxide is structured and the principles that govern how
|
||||||
|
it is built. For per-system detail, see the topic documents (e.g. [math.md](math.md)).
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
Oxide is a general-purpose 3D game engine written in Rust, built to make **any**
|
||||||
|
3D game — scaling from stylized low-poly to realistic graphics — and to **ship
|
||||||
|
only what each game uses**. It is built in two phases: a **general-purpose engine**
|
||||||
|
(Phase 1, scene graph, render pass pipeline, input, UI, physics, scripting,
|
||||||
|
animation, particles, shaders, audio, content kit, and game export) and a set of
|
||||||
|
optional, feature-gated **built-in modules** (Phase 2: ray-traced sound, developer
|
||||||
|
console, procedural toolkit, terrain, open world, pathfinding/AI, water). The
|
||||||
|
guiding idea is **build tools, not games**, all driven through a first-class
|
||||||
|
**in-engine editor**. See [`PLAN.md`](../PLAN.md) for the staged roadmap.
|
||||||
|
|
||||||
|
## Design philosophy
|
||||||
|
|
||||||
|
These principles are non-negotiable and shape every decision:
|
||||||
|
|
||||||
|
1. **Build in stages.** Each stage produces a usable, standalone artifact and
|
||||||
|
must be fully tested and stable before the next begins. See
|
||||||
|
[development.md](development.md) and [`PLAN.md`](../PLAN.md).
|
||||||
|
2. **Composability.** Every system should be usable independently of the others.
|
||||||
|
You should be able to pull in the math module, or the scene graph, without
|
||||||
|
dragging in the renderer.
|
||||||
|
3. **Correctness and clarity over premature optimization.** Optimize when a
|
||||||
|
benchmark says to, not before.
|
||||||
|
4. **Minimal public surface.** Internal complexity is fine; the external API
|
||||||
|
should be small and clean. Modules expose a curated set of types through
|
||||||
|
`pub use`, not their whole internal structure.
|
||||||
|
5. **No feature creep between stages.** New ideas go to the backlog, not into the
|
||||||
|
current stage.
|
||||||
|
6. **The editor grows with the engine.** `oxide-editor` is a first-class
|
||||||
|
deliverable, gaining panels and tools as each stage adds systems.
|
||||||
|
7. **Build tools, not games.** Ship composable building blocks; genre-specific
|
||||||
|
behavior belongs in game code or optional modules.
|
||||||
|
8. **Ship only what's used.** Subsystems are feature-gated **modules** (from
|
||||||
|
Stage 5); an exported game compiles in only the modules it registers.
|
||||||
|
9. **Scalable fidelity.** A data-driven render pass pipeline lets a project run
|
||||||
|
anything from a flat low-poly/stylized look to a full realistic stack, paying
|
||||||
|
only for the passes it enables.
|
||||||
|
10. **Modules are the primary extension point.** A module registers engine logic
|
||||||
|
*and* editor UI *and* its own settings through one documented API; anyone —
|
||||||
|
including AI agents — can write one.
|
||||||
|
|
||||||
|
## Workspace layout
|
||||||
|
|
||||||
|
Oxide is a single Cargo workspace. Crates share version, edition, license, and
|
||||||
|
dependency versions through `[workspace.package]` and `[workspace.dependencies]`
|
||||||
|
in the root `Cargo.toml`.
|
||||||
|
|
||||||
|
```
|
||||||
|
Oxide/
|
||||||
|
├── engine/ # oxide-engine — the core library (all engine systems)
|
||||||
|
├── editor/ # oxide-editor — the in-engine editor binary
|
||||||
|
├── examples/ # oxide-examples — runnable examples, one+ per stage
|
||||||
|
├── tests/ # oxide-tests — integration / end-to-end test harness
|
||||||
|
├── docs/ # this documentation
|
||||||
|
├── assets/ # logos and shared assets
|
||||||
|
├── install.sh # release build + system install
|
||||||
|
├── PLAN.md # authoritative staged roadmap
|
||||||
|
├── README.md # short project overview
|
||||||
|
└── CLAUDE.md # rules and context for AI-assisted development
|
||||||
|
```
|
||||||
|
|
||||||
|
### Crate responsibilities
|
||||||
|
|
||||||
|
- **`oxide-engine`** — the library that contains every engine system. It is
|
||||||
|
organized as one module per system (`math`, and later `scene`, `render`,
|
||||||
|
`physics`, …). Each module is independently usable and re-exports its public
|
||||||
|
types. A `prelude` module collects the most common imports.
|
||||||
|
- **`oxide-editor`** — the binary users run. It depends on `oxide-engine` and
|
||||||
|
builds a UI on top of engine systems (its own window with a placeholder
|
||||||
|
viewport since Stage 2; egui panels from Stage 3). It never contains engine
|
||||||
|
logic itself; it is a consumer of the engine.
|
||||||
|
- **`oxide-examples`** — small, focused programs that each demonstrate one
|
||||||
|
stage's capabilities. They double as manual-review artifacts and as living
|
||||||
|
documentation. `publish = false`; they are never installed.
|
||||||
|
- **`oxide-tests`** — integration tests that exercise the engine the way a real
|
||||||
|
consumer would, including cross-module scenarios and fuzz/property tests.
|
||||||
|
|
||||||
|
## Engine module structure
|
||||||
|
|
||||||
|
Inside `oxide-engine`, each system is a module under `engine/src/`. The pattern,
|
||||||
|
established by the math module, is:
|
||||||
|
|
||||||
|
```
|
||||||
|
engine/src/
|
||||||
|
├── lib.rs # declares modules, defines the prelude
|
||||||
|
├── math/
|
||||||
|
│ ├── mod.rs # module docs + curated `pub use` re-exports
|
||||||
|
│ ├── transform.rs # one type/concept per file, with its own tests
|
||||||
|
│ ├── aabb.rs
|
||||||
|
│ └── ...
|
||||||
|
├── render/ # Stage 2: GPU acquisition + surface clear loop
|
||||||
|
│ ├── mod.rs # RenderError, clear_view, re-exports
|
||||||
|
│ ├── gpu.rs # Gpu (instance/adapter/device/queue)
|
||||||
|
│ └── context.rs # RenderContext (surface, resize, render_frame)
|
||||||
|
└── window/ # Stage 2: window + event loop + App trait
|
||||||
|
├── mod.rs # WindowConfig, `event` re-export module
|
||||||
|
├── app.rs # App trait, AppCtx
|
||||||
|
└── runner.rs # winit ApplicationHandler internals
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules of thumb:
|
||||||
|
|
||||||
|
- **One concept per file.** Prefer many small focused files over a few large
|
||||||
|
ones.
|
||||||
|
- **Tests live with the code.** Each file has a `#[cfg(test)] mod tests` block
|
||||||
|
covering core behavior and edge cases.
|
||||||
|
- **The module root curates the API.** `mod.rs` decides what is public via
|
||||||
|
`pub use`; submodules are private (`mod foo;`, not `pub mod foo;`) unless there
|
||||||
|
is a reason to expose the path.
|
||||||
|
- **The prelude is the front door.** `oxide_engine::prelude::*` brings in the
|
||||||
|
types a typical consumer needs, including re-exported third-party math types so
|
||||||
|
downstream code needs only one dependency for everyday work.
|
||||||
|
|
||||||
|
## Key dependencies
|
||||||
|
|
||||||
|
Chosen for portability and a lightweight footprint:
|
||||||
|
|
||||||
|
| Concern | Crate | Notes |
|
||||||
|
|---------|-------|-------|
|
||||||
|
| Math | [`glam`](https://docs.rs/glam) | SIMD-friendly vectors/quats/matrices; `serde` feature enabled |
|
||||||
|
| Graphics | [`wgpu`](https://docs.rs/wgpu) | Portable across Vulkan/Metal/DX12 (since Stage 2; re-exported as `oxide_engine::wgpu`) |
|
||||||
|
| Windowing | [`winit`](https://docs.rs/winit) | Cross-platform windows and events (since Stage 2; re-exported as `oxide_engine::winit`) |
|
||||||
|
| ECS | [`hecs`](https://docs.rs/hecs) | Lightweight archetypal ECS (Stage 3+) |
|
||||||
|
| Physics | [`rapier3d`](https://docs.rs/rapier3d) | Rigid bodies and collision (Stage 6+) |
|
||||||
|
| Editor UI | [`egui`](https://docs.rs/egui) | Immediate-mode UI (Stage 3+) |
|
||||||
|
| Logging | `log` + `env_logger` | Facade + env-driven backend |
|
||||||
|
| Errors | `anyhow` + `thiserror` | Application vs. library error handling |
|
||||||
|
| Serialization | `serde` + `ron` | Scene/asset (de)serialization (Stage 3+) |
|
||||||
|
|
||||||
|
## Error handling and logging
|
||||||
|
|
||||||
|
- **Libraries (`oxide-engine`)** define their own error types with `thiserror`
|
||||||
|
so callers can match on failure modes.
|
||||||
|
- **Binaries (`oxide-editor`, examples)** use `anyhow` for ergonomic error
|
||||||
|
propagation at the top level.
|
||||||
|
- **Logging** uses the `log` facade throughout the engine; binaries initialize a
|
||||||
|
backend (`env_logger`). Control verbosity with `RUST_LOG`, e.g.
|
||||||
|
`RUST_LOG=oxide_engine=debug cargo run -p oxide-examples --bin math_demo`.
|
||||||
|
|
||||||
|
## Installability
|
||||||
|
|
||||||
|
Oxide must remain installable as a Linux package at all times. `install.sh`
|
||||||
|
builds in release mode and installs the `oxide-editor` binary and assets under a
|
||||||
|
prefix (`/usr/local` by default, overridable with `PREFIX`). Any new installed
|
||||||
|
binary or asset must be reflected in `install.sh` in the same change.
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
- [conventions.md](conventions.md) — coordinate system, units, color space
|
||||||
|
- [development.md](development.md) — workflow, testing, and how stages progress
|
||||||
|
- [`PLAN.md`](../PLAN.md) — the full staged roadmap
|
||||||
+265
@@ -0,0 +1,265 @@
|
|||||||
|
# Asset Server & Handles
|
||||||
|
|
||||||
|
`oxide_engine::asset` is the engine's central way to load and own external data
|
||||||
|
(meshes, and later textures, audio, fonts, …). It lands in Stage 5 and underpins
|
||||||
|
three later stages: live reload (Stage 10), open-world streaming (Stage 21), and
|
||||||
|
export packing (Stage 16). Putting it in early means later loaders plug into one
|
||||||
|
system instead of each inventing its own.
|
||||||
|
|
||||||
|
## Handles own assets
|
||||||
|
|
||||||
|
The unit of ownership is a [`Handle<T>`]: a typed, reference-counted reference to
|
||||||
|
a loaded asset. It is cheap to clone (an `Arc` bump), and the asset behind it
|
||||||
|
lives exactly as long as at least one handle does. The [`AssetServer`] keeps only
|
||||||
|
a `Weak` reference in its dedup cache, so it never keeps an otherwise-unused
|
||||||
|
asset alive — drop the last handle and the asset is freed.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::asset::{AssetServer, GltfModel, Handle};
|
||||||
|
|
||||||
|
let assets = AssetServer::new(); // built-in loaders (glTF) registered
|
||||||
|
let model: Handle<GltfModel> = assets.load("assets/models/cube.gltf");
|
||||||
|
|
||||||
|
if let Some(model) = model.get() { // Option<Arc<GltfModel>>, None until loaded
|
||||||
|
println!("{} meshes", model.meshes.len());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Key handle methods:
|
||||||
|
|
||||||
|
| Method | Meaning |
|
||||||
|
|--------|---------|
|
||||||
|
| `get()` | `Some(Arc<T>)` once loaded, else `None` (cheap clone of the value `Arc`) |
|
||||||
|
| `state()` / `is_loaded()` | lifecycle: `Loading` / `Loaded` / `Failed` |
|
||||||
|
| `wait()` | block until ready; `Some(value)` or `None` on failure |
|
||||||
|
| `error()` | the failure message, if any |
|
||||||
|
| `source()` | the path it was loaded from (in-memory assets have none) |
|
||||||
|
| `ref_count()` | number of live handles (the server holds none) |
|
||||||
|
|
||||||
|
## Deduplication and freeing
|
||||||
|
|
||||||
|
Loading the same path+type twice returns handles to **one** shared asset — the
|
||||||
|
loader runs once:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
# use oxide_engine::asset::{AssetServer, GltfModel, Handle};
|
||||||
|
# let assets = AssetServer::new();
|
||||||
|
let a: Handle<GltfModel> = assets.load("model.gltf");
|
||||||
|
let b: Handle<GltfModel> = assets.load("model.gltf");
|
||||||
|
assert_eq!(a.id(), b.id()); // same underlying asset
|
||||||
|
assert_eq!(assets.live_asset_count(), 1);
|
||||||
|
```
|
||||||
|
|
||||||
|
When the last handle drops, the weak cache entry dies and the asset is collected;
|
||||||
|
`live_asset_count()` prunes such entries as it counts.
|
||||||
|
|
||||||
|
## Synchronous vs background loading
|
||||||
|
|
||||||
|
`load` blocks until the asset is ready. `load_async` returns immediately with a
|
||||||
|
handle in the `Loading` state and fills it on a background thread:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
# use oxide_engine::asset::{AssetServer, GltfModel, Handle};
|
||||||
|
# let assets = AssetServer::new();
|
||||||
|
let handle: Handle<GltfModel> = assets.load_async("big.gltf");
|
||||||
|
// ... do other work while it loads ...
|
||||||
|
let model = handle.wait(); // or poll handle.state()
|
||||||
|
```
|
||||||
|
|
||||||
|
`add(value)` stores an already-constructed, in-memory asset (no path, not
|
||||||
|
deduplicated) and returns a handle to it — useful for procedurally generated or
|
||||||
|
test data.
|
||||||
|
|
||||||
|
## Writing a loader
|
||||||
|
|
||||||
|
Formats are pluggable via the [`AssetLoader`] trait: declare the output type and
|
||||||
|
the extensions, and implement `load`. Register it with
|
||||||
|
`server.register_loader(...)`; the server dispatches by extension and verifies
|
||||||
|
the loader's output type matches the requested `T`.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use std::path::Path;
|
||||||
|
use oxide_engine::asset::{AssetError, AssetLoader};
|
||||||
|
|
||||||
|
struct TextLoader;
|
||||||
|
impl AssetLoader for TextLoader {
|
||||||
|
type Asset = String;
|
||||||
|
fn extensions(&self) -> &'static [&'static str] { &["txt"] }
|
||||||
|
fn load(&self, path: &Path) -> Result<String, AssetError> {
|
||||||
|
std::fs::read_to_string(path).map_err(|e| AssetError::Load {
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
message: e.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let assets = oxide_engine::asset::AssetServer::empty(); // no built-ins
|
||||||
|
assets.register_loader(TextLoader);
|
||||||
|
```
|
||||||
|
|
||||||
|
The built-in [`GltfLoader`] is exactly this pattern wrapping the Stage-4
|
||||||
|
[`load_gltf`] importer; `AssetServer::new()` registers it for `.gltf`/`.glb`.
|
||||||
|
Errors are specific — `NoExtension`, `NoLoader`, `TypeMismatch`, and `Load` — so
|
||||||
|
callers can tell "no loader" from "the file is broken".
|
||||||
|
|
||||||
|
## Live reload foundation
|
||||||
|
|
||||||
|
`reload(path)` re-runs the loader and updates the existing asset **in place**, so
|
||||||
|
every live handle observes the new contents on its next `get()`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
# use oxide_engine::asset::{AssetServer, GltfModel, Handle};
|
||||||
|
# let assets = AssetServer::new();
|
||||||
|
let model: Handle<GltfModel> = assets.load("model.gltf");
|
||||||
|
// ... the file changes on disk ...
|
||||||
|
let same = assets.reload::<GltfModel>("model.gltf");
|
||||||
|
assert_eq!(same.id(), model.id()); // same asset, new data
|
||||||
|
```
|
||||||
|
|
||||||
|
Stage 10 wires this to the file watcher to hot-reload changed assets while the
|
||||||
|
editor runs.
|
||||||
|
|
||||||
|
## Asset database — stable, project-relative references
|
||||||
|
|
||||||
|
The [`AssetServer`] loads by *path*, but a scene or UI document must not bake an
|
||||||
|
**absolute** system path into its saved data — that breaks the moment the
|
||||||
|
project moves to another directory or machine, and it is the chief obstacle to a
|
||||||
|
clean game export (Stage 16). The [`AssetDatabase`] is the bridge.
|
||||||
|
|
||||||
|
Every imported asset lives under a **typed subfolder** of the project's
|
||||||
|
`assets/` directory and gets a stable [`AssetUid`]. The database records, per
|
||||||
|
project, the mapping **`AssetUid` ↔ assets-relative path** (e.g.
|
||||||
|
`"fonts/Inter-Regular.ttf"`) in a manifest at the project root
|
||||||
|
(`assets.manifest`). Saved documents reference assets by `AssetUid`; resolving a
|
||||||
|
uid yields the relative path, which combined with the *current* project root
|
||||||
|
gives an absolute path the server loads and deduplicates.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::asset::{AssetDatabase, AssetServer, GltfModel};
|
||||||
|
|
||||||
|
let mut db = AssetDatabase::open(project_root); // reads assets.manifest if present
|
||||||
|
db.scan(); // walk assets/ — register new files, prune missing
|
||||||
|
db.save().unwrap(); // persist any newly-assigned uids
|
||||||
|
|
||||||
|
let uid = db.uid_of("models/cube.glb").unwrap();
|
||||||
|
let server = AssetServer::new();
|
||||||
|
let model = db.load::<GltfModel>(&server, uid); // Option<Handle<GltfModel>>
|
||||||
|
```
|
||||||
|
|
||||||
|
Because the stored mapping is purely relative, a reference resolves to the **same
|
||||||
|
handle** across save/load *and* after the whole project directory moves — open
|
||||||
|
the database from the new location (or call `set_root`) and every uid keeps
|
||||||
|
resolving. The uid layer (rather than referencing by relative path directly)
|
||||||
|
also lets an asset be renamed or moved *within* the project later without
|
||||||
|
breaking references, since the uid travels with the file in the manifest.
|
||||||
|
|
||||||
|
### Typed folders
|
||||||
|
|
||||||
|
[`AssetKind`] fixes each asset's subfolder and the extensions that belong to it:
|
||||||
|
|
||||||
|
| Kind | Folder | Extensions |
|
||||||
|
|------|--------|-----------|
|
||||||
|
| `Font` | `fonts/` | `ttf`, `otf` |
|
||||||
|
| `Texture` | `textures/` | `png`, `jpg`, `jpeg`, `tga`, `bmp`, `dds`, `ktx2` |
|
||||||
|
| `Model` | `models/` | `gltf`, `glb`, `obj` |
|
||||||
|
| `Audio` | `audio/` | `wav`, `ogg`, `mp3`, `flac` |
|
||||||
|
| `Ui` | `ui/` | (recognised by folder — shares `.ron` with scenes) |
|
||||||
|
| `Script` | `scripts/` | `rhai` |
|
||||||
|
|
||||||
|
`AssetKind::classify(path)` infers the kind: the leading folder wins, with the
|
||||||
|
file extension as a fallback for files dropped directly in `assets/`.
|
||||||
|
|
||||||
|
### File operations — rename, move, delete
|
||||||
|
|
||||||
|
The database also *performs* file reorganisation, so the editor's file explorer
|
||||||
|
(and any tool) can rename or move assets **without breaking saved references**
|
||||||
|
— the disk operation and the uid map are updated together:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
# use oxide_engine::asset::{AssetDatabase, AssetUid};
|
||||||
|
# fn demo(db: &mut AssetDatabase, uid: AssetUid) -> Result<(), oxide_engine::asset::AssetDbError> {
|
||||||
|
db.move_asset(uid, "textures/env/brick.png")?; // rename/move; uid unchanged
|
||||||
|
db.move_folder("textures/env", "textures/world")?; // every entry under it follows
|
||||||
|
db.delete_asset(uid)?; // file + entry; uid never reused
|
||||||
|
db.save()?; // persist the new paths
|
||||||
|
# Ok(())
|
||||||
|
# }
|
||||||
|
```
|
||||||
|
|
||||||
|
All three refuse to touch anything outside `assets/` (`..` is rejected) and
|
||||||
|
refuse to overwrite an existing destination ([`AssetDbError`] has a variant per
|
||||||
|
failure). Kinds are re-classified from the new path, since a move can change
|
||||||
|
the typed folder. Deleting drops the entry but never reuses its uid — a
|
||||||
|
dangling reference stays dangling instead of silently pointing at a new file.
|
||||||
|
|
||||||
|
### Asset-reference fields in the inspector
|
||||||
|
|
||||||
|
A component points at an asset with an [`AssetRef<T>`] field — **not** a live
|
||||||
|
`Handle<T>`. A handle is process-local and not serializable, so persisting one
|
||||||
|
would be wrong; an `AssetRef<T>` is a thin, serializable wrapper over
|
||||||
|
`Option<AssetUid>` that resolves to a handle on demand:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::asset::{AssetRef, AssetServer, AssetDatabase};
|
||||||
|
use oxide_engine::ui::Font;
|
||||||
|
|
||||||
|
#[derive(serde::Serialize, serde::Deserialize)]
|
||||||
|
struct Label { font: AssetRef<Font> } // serializes as just the uid
|
||||||
|
|
||||||
|
# fn demo(label: &Label, db: &AssetDatabase, server: &AssetServer) {
|
||||||
|
let handle = label.font.resolve(db, server); // Option<Handle<Font>>
|
||||||
|
# }
|
||||||
|
```
|
||||||
|
|
||||||
|
Because `AssetRef<T>` round-trips through reflection's RON path, the field is
|
||||||
|
editable in the inspector with **no per-type code**. Its
|
||||||
|
[`type_name`](../engine/src/reflect.rs) is the syntactic spelling
|
||||||
|
`"AssetRef < Font >"`; [`asset_ref_target`] unwraps that to the target type name
|
||||||
|
(`"Font"`), and [`AssetKind::for_handle_target`] maps it to the kind the editor's
|
||||||
|
asset picker filters by — so selecting a UI element and picking a font lists only
|
||||||
|
the assets under `fonts/`. A bare `Handle<T>` field (e.g. on a non-persisted
|
||||||
|
type) is recognised the same way.
|
||||||
|
|
||||||
|
### In the editor
|
||||||
|
|
||||||
|
`EditorState` holds an `asset_db` whenever a project is open: the editor opens
|
||||||
|
and scans it on project open/create and rescans when the file watcher reports
|
||||||
|
changes under `assets/`, so importing an asset is as simple as **dropping the
|
||||||
|
file anywhere under `assets/`** — no separate import step. An asset-reference
|
||||||
|
field in the inspector renders as a **picker** populated from the database,
|
||||||
|
filtered to the field's target kind. New projects are seeded with a bundled
|
||||||
|
default UI font (Inter, SIL OFL) under `fonts/`, referenced by its
|
||||||
|
project-relative path like any other asset.
|
||||||
|
|
||||||
|
The **Project panel** hosts a Unity-style **file explorer** over `assets/`
|
||||||
|
(`oxide_editor::explorer` holds the egui-free behavior layer; the shell only
|
||||||
|
renders it):
|
||||||
|
|
||||||
|
- **breadcrumbs + folder navigation** (double-click a folder, click a crumb);
|
||||||
|
- **➕ New Folder**, and per-row context menus with **Rename** and **Delete**
|
||||||
|
(folders delete only when empty — recursive asset deletion is deliberately
|
||||||
|
not offered);
|
||||||
|
- **drag a row onto a folder** (or the `..` row) to move it;
|
||||||
|
- **drag files in from the OS** to import them into the current folder
|
||||||
|
(copied in under a collision-free name, registered, manifest saved);
|
||||||
|
- **double-click a file** to open it — scripts via the external-editor flow
|
||||||
|
(see [scripting.md](scripting.md)), everything else via `xdg-open`.
|
||||||
|
|
||||||
|
Renames and moves go through the database's uid-preserving file ops, so saved
|
||||||
|
`AssetRef`s keep resolving after any reorganisation; unregistered files
|
||||||
|
(licenses, notes) fall back to plain filesystem operations. File operations
|
||||||
|
act immediately and bypass the undo stack, like the hierarchy's structural
|
||||||
|
edits.
|
||||||
|
|
||||||
|
[`AssetDatabase`]: ../engine/src/asset/database.rs
|
||||||
|
[`AssetDbError`]: ../engine/src/asset/database.rs
|
||||||
|
[`AssetKind`]: ../engine/src/asset/database.rs
|
||||||
|
[`AssetUid`]: ../engine/src/asset/database.rs
|
||||||
|
[`AssetRef<T>`]: ../engine/src/asset/database.rs
|
||||||
|
[`asset_ref_target`]: ../engine/src/asset/database.rs
|
||||||
|
[`AssetKind::for_handle_target`]: ../engine/src/asset/database.rs
|
||||||
|
[`Handle<T>`]: ../engine/src/asset/handle.rs
|
||||||
|
[`AssetServer`]: ../engine/src/asset/server.rs
|
||||||
|
[`AssetLoader`]: ../engine/src/asset/server.rs
|
||||||
|
[`GltfLoader`]: ../engine/src/asset/gltf.rs
|
||||||
|
[`load_gltf`]: ../engine/src/asset/gltf.rs
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# Conventions
|
||||||
|
|
||||||
|
Cross-cutting conventions every Oxide system follows. These are decided once,
|
||||||
|
here, so individual systems don't each invent their own.
|
||||||
|
|
||||||
|
## Coordinate system
|
||||||
|
|
||||||
|
Oxide uses a **right-handed** coordinate system, consistent with `glam`'s
|
||||||
|
`*_rh` matrix constructors and the glTF asset format the engine will load.
|
||||||
|
|
||||||
|
In a default (identity) orientation:
|
||||||
|
|
||||||
|
| Axis | Direction | Local accessor on `Transform` |
|
||||||
|
|------|-----------|-------------------------------|
|
||||||
|
| `+X` | right | `Transform::right()` |
|
||||||
|
| `+Y` | up | `Transform::up()` |
|
||||||
|
| `-Z` | forward (the direction a camera/object looks) | `Transform::forward()` |
|
||||||
|
|
||||||
|
So **forward is `-Z`**. This matches the convention used by glTF, OpenGL, and
|
||||||
|
`glam`'s view-matrix helpers, which keeps asset import and camera math
|
||||||
|
consistent.
|
||||||
|
|
||||||
|
`Transform::looking_at(eye, target, up)` produces an orientation whose
|
||||||
|
`forward()` points from `eye` toward `target`.
|
||||||
|
|
||||||
|
## Rotations
|
||||||
|
|
||||||
|
- Rotations are stored as **unit quaternions** (`glam::Quat`), not Euler angles
|
||||||
|
or matrices, to avoid gimbal lock and accumulate cleanly under composition.
|
||||||
|
- Euler-angle helpers (`Quat::from_euler`) are available for authoring, but the
|
||||||
|
canonical stored form is always a quaternion.
|
||||||
|
- Angles are in **radians**. Convert from degrees explicitly at the boundary
|
||||||
|
(`90_f32.to_radians()`).
|
||||||
|
|
||||||
|
## Transform composition
|
||||||
|
|
||||||
|
- Transforms compose **parent-first**: `parent.mul_transform(&child)` yields the
|
||||||
|
child resolved in the parent's space, matching `parent_matrix * child_matrix`.
|
||||||
|
- The effective matrix order is `T * R * S` — scale is applied first, then
|
||||||
|
rotation, then translation.
|
||||||
|
- Composition is **exact** for uniform scale. With non-uniform scale plus
|
||||||
|
rotation the true product is not representable as a single translation /
|
||||||
|
rotation / scale triple, so the result is the closest TRS approximation
|
||||||
|
(re-decomposed from the matrix). Prefer uniform scale in deep hierarchies.
|
||||||
|
|
||||||
|
See [math.md](math.md#transform) for details.
|
||||||
|
|
||||||
|
## Units
|
||||||
|
|
||||||
|
- **Length:** meters. Physics (`rapier3d`, Stage 6) tunes its solver for
|
||||||
|
meter-scale geometry, so the whole engine adopts meters to avoid conversions.
|
||||||
|
- **Time:** seconds (`f32` for per-frame deltas; a fixed timestep drives physics
|
||||||
|
from Stage 6).
|
||||||
|
- **Angles:** radians (see above).
|
||||||
|
- **Mass:** kilograms (Stage 6+).
|
||||||
|
|
||||||
|
## Numeric type
|
||||||
|
|
||||||
|
- The engine is **`f32`-first**. `glam`'s `f32` types are the default throughout;
|
||||||
|
`f64` is used only where a specific algorithm demands it.
|
||||||
|
- Comparisons use explicit epsilons rather than `==` on floats. Helpers and tests
|
||||||
|
use a small tolerance (commonly `1e-4`–`1e-6`) appropriate to the operation.
|
||||||
|
|
||||||
|
## Color and color space
|
||||||
|
|
||||||
|
- `Color` stores **linear** RGBA as `f32`. Lighting and blending math is correct
|
||||||
|
only in linear space, so that is the engine's working space.
|
||||||
|
- Values are nominally in `[0, 1]` but are **not clamped** — values above `1.0`
|
||||||
|
represent HDR / emissive intensity.
|
||||||
|
- Conversions to and from 8-bit **sRGB** (the space of color pickers, image
|
||||||
|
files, and `#RRGGBB` hex) are explicit: `Color::from_srgb_u8`,
|
||||||
|
`Color::from_hex`, `Color::to_srgb_u8`. Never treat raw 8-bit values as linear.
|
||||||
|
|
||||||
|
## Geometry primitives
|
||||||
|
|
||||||
|
- An [`Aabb`](math.md#aabb) is *empty* when any `min` component exceeds the
|
||||||
|
corresponding `max`; `Aabb::EMPTY` is the identity for `union`.
|
||||||
|
- A [`Ray`](math.md#ray) always stores a **normalized** direction, so its
|
||||||
|
parameter `t` is a true distance.
|
||||||
|
- A [`Plane`](math.md#plane) is stored in **Hessian normal form** (`normal·p + d
|
||||||
|
= 0`) with a unit normal; the positive half-space is the side the normal points
|
||||||
|
toward.
|
||||||
|
- A [`Frustum`](math.md#frustum) stores six planes with **inward-facing**
|
||||||
|
normals; a point is inside when it is in the positive half-space of all six.
|
||||||
|
|
||||||
|
## Determinism
|
||||||
|
|
||||||
|
Procedural systems (Stage 11+) must be **deterministic**: the same seed always
|
||||||
|
produces the same output. Tests that need randomness use a small, explicit,
|
||||||
|
seeded PRNG rather than a system RNG, so failures are reproducible.
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
# Development Workflow
|
||||||
|
|
||||||
|
How work flows through the Oxide project: branches, testing gates, documentation,
|
||||||
|
and how a stage progresses from start to sign-off.
|
||||||
|
|
||||||
|
## Branch model
|
||||||
|
|
||||||
|
Oxide uses two long-lived branches:
|
||||||
|
|
||||||
|
| Branch | Meaning |
|
||||||
|
|--------|---------|
|
||||||
|
| `dev` | Integration branch. Everything that builds and passes automated tests lands here first. |
|
||||||
|
| `main` | Stable branch. Only contains work that has been verified — automatically *and*, where relevant, manually approved. |
|
||||||
|
|
||||||
|
### The flow
|
||||||
|
|
||||||
|
1. **Work lands on `dev`.** Any change that can be **fully verified by automated
|
||||||
|
means** (it builds, and its unit / integration / fuzz tests and benchmarks
|
||||||
|
pass) is committed and pushed to `dev` as soon as it is complete and green.
|
||||||
|
|
||||||
|
2. **Manual-test gate before `main`.** If a change *cannot* be fully verified
|
||||||
|
automatically — anything involving the GUI, rendering output, audio, input
|
||||||
|
feel, or otherwise "you have to actually run the engine and look at it" — it
|
||||||
|
stops at `dev`. The maintainer runs it, reviews the behavior, and reports
|
||||||
|
back. Only after explicit approval is that version promoted to `main`.
|
||||||
|
|
||||||
|
3. **Fully-automated changes can go straight to both.** When a change is
|
||||||
|
completely covered by automated tests (e.g. the math module — pure CPU logic
|
||||||
|
with full unit/fuzz/benchmark coverage), it may be pushed to `dev` and `main`
|
||||||
|
together, because the automated suite *is* the sign-off. No manual gate is
|
||||||
|
needed.
|
||||||
|
|
||||||
|
### Deciding which path a change takes
|
||||||
|
|
||||||
|
Ask: **"Can a test prove this works without a human looking at it?"**
|
||||||
|
|
||||||
|
- **Yes** → it can go to `main` as soon as tests pass (via `dev`).
|
||||||
|
- **No** (needs eyes/ears on a running engine) → push to `dev`, request manual
|
||||||
|
testing, wait for approval, then promote to `main`.
|
||||||
|
|
||||||
|
When in doubt, treat it as needing manual testing and leave it on `dev`.
|
||||||
|
|
||||||
|
### Promoting `dev` to `main`
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git checkout main
|
||||||
|
git merge --ff-only dev # or a regular merge if histories diverged
|
||||||
|
git push origin main
|
||||||
|
git checkout dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing protocol
|
||||||
|
|
||||||
|
Every stage must pass all of the following before it is considered done (see also
|
||||||
|
[`PLAN.md`](../PLAN.md)):
|
||||||
|
|
||||||
|
1. **Unit tests** — every module has tests for core behavior and edge cases,
|
||||||
|
living in a `#[cfg(test)] mod tests` block beside the code.
|
||||||
|
2. **Integration tests** — the `oxide-tests` crate runs end-to-end and
|
||||||
|
cross-module scenarios, including fuzz/property tests where appropriate.
|
||||||
|
3. **Example review** — each stage ships at least one runnable example in
|
||||||
|
`oxide-examples`; both the maintainer and the implementer review it.
|
||||||
|
4. **Benchmarks** — performance-sensitive systems have `criterion` benchmarks;
|
||||||
|
regressions against a stage's stated budget block sign-off.
|
||||||
|
5. **Clippy + fmt** — `cargo clippy --all-targets -- -D warnings` and
|
||||||
|
`cargo fmt --check` must be clean. Engine/editor crates use
|
||||||
|
`#![deny(warnings)]`.
|
||||||
|
|
||||||
|
Quick local gate before any commit:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo fmt --check && \
|
||||||
|
cargo clippy --all-targets -- -D warnings && \
|
||||||
|
cargo test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Documentation policy
|
||||||
|
|
||||||
|
Documentation is written **as the work is done**, not after:
|
||||||
|
|
||||||
|
- Detailed docs live in [`docs/`](README.md), one topic per file. The top-level
|
||||||
|
`README.md` stays a short overview.
|
||||||
|
- A stage is not complete until its systems are documented in `docs/` — usage
|
||||||
|
(how to call it) **and** inner workings (how/why it works).
|
||||||
|
- When an API changes, update the affected doc and its code snippets in the
|
||||||
|
**same change**, so docs never drift from the code.
|
||||||
|
- Keep the maintained project files current whenever structure, goals, or
|
||||||
|
process change: `CLAUDE.md`, `PLAN.md`, `README.md`, `.gitignore`, and the
|
||||||
|
relevant files in `docs/`.
|
||||||
|
|
||||||
|
## Adding a new stage
|
||||||
|
|
||||||
|
1. Read [`PLAN.md`](../PLAN.md) for the stage's deliverables and test criteria.
|
||||||
|
2. Implement the system as one or more focused modules under `engine/src/`
|
||||||
|
(one concept per file, tests beside the code). Add editor support if the
|
||||||
|
stage calls for it.
|
||||||
|
3. Add at least one example under `examples/src/bin/`.
|
||||||
|
4. Write the stage's documentation under `docs/` and link it from
|
||||||
|
[`docs/README.md`](README.md).
|
||||||
|
5. Ensure the full testing protocol passes.
|
||||||
|
6. Update `PLAN.md` (mark the stage complete), `README.md` (status/features),
|
||||||
|
and — if installed binaries or assets changed — `install.sh`.
|
||||||
|
7. Land on `dev`; promote to `main` per the branch model above.
|
||||||
|
|
||||||
|
## Commit conventions
|
||||||
|
|
||||||
|
- Commit messages are written in the imperative mood and describe *what* and
|
||||||
|
*why*.
|
||||||
|
- Group related changes; keep a commit focused on one logical change where
|
||||||
|
practical.
|
||||||
|
- Co-authorship trailers are added when a commit is produced with AI assistance.
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
# Editor Extension API
|
||||||
|
|
||||||
|
`oxide_editor::extension` is the editor-side companion to the engine's
|
||||||
|
[`Module`](modules.md) trait. It is the mechanism through which a module
|
||||||
|
contributes the UI it needs the editor to host on its behalf — menu items,
|
||||||
|
dockable panels, viewport tools, component inspectors, and Preferences
|
||||||
|
pages — **without editing the editor's source**.
|
||||||
|
|
||||||
|
This is *the* extension surface for both first-party modules (Stage-7 input
|
||||||
|
binding pages, Stage-9 physics inspectors, Stage-17 ray-traced-audio panels…)
|
||||||
|
and any third-party or AI-authored module.
|
||||||
|
|
||||||
|
## Why a separate trait
|
||||||
|
|
||||||
|
The engine doesn't depend on egui — putting the editor hook on
|
||||||
|
`oxide_engine::app::Module` would pull egui into the engine. Instead a module
|
||||||
|
that wants to participate in the editor implements **two** traits on the same
|
||||||
|
struct:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
struct AudioPreviewModule;
|
||||||
|
|
||||||
|
impl oxide_engine::app::Module for AudioPreviewModule {
|
||||||
|
fn name(&self) -> &'static str { "audio_preview" }
|
||||||
|
fn build(&self, app: &mut oxide_engine::app::App) {
|
||||||
|
// … register systems, types, asset loaders
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl oxide_editor::extension::EditorModule for AudioPreviewModule {
|
||||||
|
fn name(&self) -> &'static str { "audio_preview" }
|
||||||
|
fn build_editor(&self, ext: &mut oxide_editor::extension::EditorExtensions) {
|
||||||
|
// … register menu items, panels, inspectors, settings pages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The shared name is how enable/disable in Preferences stays consistent across
|
||||||
|
the two halves: toggling `"audio_preview"` hides the editor contributions and
|
||||||
|
disables the engine systems together.
|
||||||
|
|
||||||
|
## What a module can contribute
|
||||||
|
|
||||||
|
| Kind | Helper | Stage-6 criterion |
|
||||||
|
|------|--------|-------------------|
|
||||||
|
| **Menu items** (`File/New`, `Help/About`, …) | `add_menu_item` | ✔ required |
|
||||||
|
| **Dockable panels** | `add_panel` | ✔ required |
|
||||||
|
| **Viewport tools** (gizmos, brushes) | `add_viewport_tool` | future stages |
|
||||||
|
| **Component inspectors** (by reflection name) | `add_inspector` | future stages |
|
||||||
|
| **Settings pages** (by section name) | `add_settings_page` | ✔ required |
|
||||||
|
|
||||||
|
```rust,no_run
|
||||||
|
use oxide_editor::extension::{DockLocation, EditorExtensions, EditorModule};
|
||||||
|
|
||||||
|
struct DemoModule;
|
||||||
|
impl EditorModule for DemoModule {
|
||||||
|
fn name(&self) -> &'static str { "demo" }
|
||||||
|
fn build_editor(&self, ext: &mut EditorExtensions) {
|
||||||
|
ext.add_menu_item("File/Demo…", || { /* open the demo dialog */ });
|
||||||
|
ext.add_panel("Demo Panel", DockLocation::Right, |ui| {
|
||||||
|
ui.label("hello from a module-owned panel");
|
||||||
|
});
|
||||||
|
ext.add_inspector("DemoComponent", |ui| {
|
||||||
|
ui.label("custom editor for DemoComponent");
|
||||||
|
});
|
||||||
|
ext.add_settings_page("demo", "Demo", |ui| {
|
||||||
|
ui.label("module preferences here");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The render closures take only `&mut egui::Ui` in Piece 5 (registration). Piece
|
||||||
|
6 — the docking shell — refines the signatures to pass through the editor's
|
||||||
|
runtime context (scene, selection, asset server, settings). Modules that need
|
||||||
|
shared state today can capture it through interior mutability
|
||||||
|
(`Rc<RefCell<...>>`).
|
||||||
|
|
||||||
|
## How the shell consumes the registry
|
||||||
|
|
||||||
|
```rust
|
||||||
|
# use oxide_editor::extension::{EditorExtensions, EditorModule, DockLocation};
|
||||||
|
# struct M; impl EditorModule for M {
|
||||||
|
# fn name(&self) -> &'static str { "m" }
|
||||||
|
# fn build_editor(&self, ext: &mut EditorExtensions) {
|
||||||
|
# ext.add_menu_item("File/Open", || {});
|
||||||
|
# ext.add_panel("Inspector", DockLocation::Right, |_| {});
|
||||||
|
# }
|
||||||
|
# }
|
||||||
|
let mut ext = EditorExtensions::new();
|
||||||
|
ext.add_module(M);
|
||||||
|
|
||||||
|
// What the shell will do in piece 6:
|
||||||
|
for item in ext.iter_menu_items() {
|
||||||
|
let _ = &item.path; // build the menu tree
|
||||||
|
}
|
||||||
|
for panel in ext.iter_panels() {
|
||||||
|
let _ = (&panel.name, panel.default_dock); // place in dock layout
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Lookups by name are also provided (`has_inspector_for("Transform")`,
|
||||||
|
`has_settings_page_for("audio")`) so the Inspector and Preferences windows can
|
||||||
|
ask "is there a custom editor for this thing?" before rendering a fallback.
|
||||||
|
|
||||||
|
## Attribution and lifecycle
|
||||||
|
|
||||||
|
Every contribution remembers its source module. That gives three lifecycle
|
||||||
|
operations the engine `App` already has and the editor needs to mirror:
|
||||||
|
|
||||||
|
| Operation | Effect |
|
||||||
|
|-----------|--------|
|
||||||
|
| `add_module` | Runs `build_editor`, attributes every contribution to the module, marks enabled. Re-adding replaces the old registration cleanly. |
|
||||||
|
| `set_module_enabled(name, false)` | Contributions stay registered but vanish from every `iter_*` / `has_*` lookup — toggling Preferences is reversible without rebuilding state. |
|
||||||
|
| `remove_module(name)` | Drops every contribution attributed to the module in one shot. |
|
||||||
|
|
||||||
|
Adding contributions outside a module's `build_editor` panics: every entry
|
||||||
|
must be attributable to *some* module, otherwise removal would leave orphans.
|
||||||
|
|
||||||
|
## Inspector / settings-page resolution
|
||||||
|
|
||||||
|
The Inspector panel renders custom editors for components whose type has a
|
||||||
|
registered inspector — keyed by the same name the component is registered
|
||||||
|
under in the [reflection registry](reflection.md). Settings pages plug into the
|
||||||
|
[Preferences framework](settings.md) by matching their `section_name` to the
|
||||||
|
section the module registered. Both lookups respect the enabled flag, so a
|
||||||
|
disabled module's inspector / page disappears even if the underlying section
|
||||||
|
or type is still registered.
|
||||||
|
|
||||||
|
## Testing strategy
|
||||||
|
|
||||||
|
The whole API is purely about *registration*, so it's directly unit-testable
|
||||||
|
without bringing up egui — tests construct an `EditorExtensions`, add a
|
||||||
|
demo module, and assert via the lookup helpers. The actual rendering of the
|
||||||
|
contributed UI is exercised by the Piece-6 docking shell with a maintainer
|
||||||
|
manual pass; the Stage-6 criterion ("a trivial test module adds a menu item,
|
||||||
|
a panel, and a settings page through the API with no editor-core edits") is
|
||||||
|
covered by the integration test in `tests/src/lib.rs::stage6`.
|
||||||
|
|
||||||
|
[`extension`]: ../editor/src/extension.rs
|
||||||
|
[`EditorExtensions`]: ../editor/src/extension.rs
|
||||||
|
[`EditorModule`]: ../editor/src/extension.rs
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
# Editor Shell
|
||||||
|
|
||||||
|
The Stage-6 docking **shell** is the editor's host frame: the top menu bar,
|
||||||
|
the bottom status bar, the dockable panel area, the Preferences window, and
|
||||||
|
the wiring between every Stage-6 framework piece — command stack, project
|
||||||
|
system, settings, file watcher, and the
|
||||||
|
[module → editor extension API](editor-extensions.md).
|
||||||
|
|
||||||
|
Lives in [`oxide_editor::shell`](../editor/src/shell.rs) (the library) with
|
||||||
|
[`oxide-editor`](../editor/src/main.rs) (the binary) acting as glue: open a
|
||||||
|
window, run the egui paint pump, run the 3D viewport, hand events to the
|
||||||
|
shell. Splitting the shell into the library lets it be unit-tested without
|
||||||
|
spinning up a window.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────┐
|
||||||
|
│ File Edit View Project Modules Help ⚙ Prefs │ ← menu bar
|
||||||
|
├────────────┬───────────────────────────┬────────────────────┤
|
||||||
|
│ │ │ │
|
||||||
|
│ Hierarchy │ Viewport │ Inspector │
|
||||||
|
│ │ │ │
|
||||||
|
│ ├───────────────────────────┤ │
|
||||||
|
│ │ Project │ Console │ │
|
||||||
|
│ │ │ │
|
||||||
|
├────────────┴───────────────────────────┴────────────────────┤
|
||||||
|
│ Reloaded 3 asset(s) modules: 0 undo: 2 │ ← status bar
|
||||||
|
└──────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Panels are dockable: drag a tab to re-dock, resize splits, or pop it out as a
|
||||||
|
floating window — provided by [`egui_dock`](https://docs.rs/egui_dock). The
|
||||||
|
default layout is built once in [`Shell::default_dock`]; persisting the user's
|
||||||
|
layout across restarts is a later refinement.
|
||||||
|
|
||||||
|
## What's wired to what
|
||||||
|
|
||||||
|
| Shell surface | Backing system |
|
||||||
|
|---------------|----------------|
|
||||||
|
| File menu → New / Open / Save / Recent / Quit | [`Project`](projects.md) + `RecentProjects` + `Shell::take_quit_request` |
|
||||||
|
| Edit menu → Undo / Redo (Ctrl+Z / Ctrl+Y) | [`CommandStack`](../editor/src/command.rs) |
|
||||||
|
| View menu → Show/Hide panel toggles | dock state |
|
||||||
|
| Project menu → Open project info | `EditorState::project` |
|
||||||
|
| Modules menu → module-contributed items | [`EditorExtensions`](editor-extensions.md) |
|
||||||
|
| Help → About | static info |
|
||||||
|
| Status bar → live hint (last action, errors) | `StatusLine` (timed TTL) |
|
||||||
|
| Status bar → module / undo counters | `EditorExtensions` + `CommandStack` |
|
||||||
|
| Preferences window → settings sections + module on/off | [`Settings`](settings.md) + `EditorExtensions` |
|
||||||
|
| File watcher (on project open) → `AssetServer::reload_path` | [`FileWatcher`](file-watching.md) |
|
||||||
|
|
||||||
|
## Commands and undo
|
||||||
|
|
||||||
|
`Edit` menu shows the labels of the next undo / redo entry; `Ctrl+Z` /
|
||||||
|
`Ctrl+Y` (also `Ctrl+Shift+Z`) drive them. The shortcut router is
|
||||||
|
[`Shell::try_consume_shortcut`] — same path the menu uses, so the unit tests
|
||||||
|
exercise the real flow.
|
||||||
|
|
||||||
|
The first wired commands ([`SetTransformCmd`], [`RenameCmd`]) live in
|
||||||
|
[`oxide_editor::commands`](../editor/src/commands.rs).
|
||||||
|
[`SetTransformCmd::merge`] coalesces consecutive edits to the same entity, so
|
||||||
|
a slider drag (or a future gizmo drag) is **one** undo entry instead of one
|
||||||
|
per frame.
|
||||||
|
|
||||||
|
Structural edits (spawn / despawn / reparent / change mesh) still bypass the
|
||||||
|
stack today — round-tripping a despawn through undo needs stable entity ids,
|
||||||
|
a Stage-7 design step alongside the gizmos.
|
||||||
|
|
||||||
|
## File watcher
|
||||||
|
|
||||||
|
[`Shell::open_project`] (or `create_project`) attaches a
|
||||||
|
[`FileWatcher`](file-watching.md) over the project's `assets/`, `scenes/`, and
|
||||||
|
`scripts/` directories (~150 ms debounce window). The shell's
|
||||||
|
[`frame_tick`](../editor/src/shell.rs) pumps events through
|
||||||
|
[`reload_changed_assets`](file-watching.md) each frame, so editing a file
|
||||||
|
externally hot-reloads any handle that was already loaded. Closing the
|
||||||
|
project tears the watcher down.
|
||||||
|
|
||||||
|
Backends that don't deliver events (some sandboxed CI environments) log a
|
||||||
|
warning and let the editor keep running — the watcher is best-effort.
|
||||||
|
|
||||||
|
## Module integration
|
||||||
|
|
||||||
|
Anything a module registers via the [extension API](editor-extensions.md) is
|
||||||
|
hosted by the shell with no editor-source edits:
|
||||||
|
|
||||||
|
- **Menu items** appear under the `Modules` top menu (shown only when at
|
||||||
|
least one item is registered).
|
||||||
|
- **Panels** appear in the dock as `PanelKind::Custom(name)` tabs, rendered
|
||||||
|
through the module's `FnMut(&mut egui::Ui)` closure.
|
||||||
|
- **Settings pages** + module on/off checkboxes are surfaced in the
|
||||||
|
Preferences window's sidebar.
|
||||||
|
- **Component inspectors** (Stage 7+) will be looked up by reflection name
|
||||||
|
when the Inspector encounters a selection holding that component.
|
||||||
|
|
||||||
|
Disabling a module in Preferences hides every contribution at once but keeps
|
||||||
|
it registered — re-enabling restores it instantly, no shell rebuild.
|
||||||
|
|
||||||
|
## Why a `Shell` library
|
||||||
|
|
||||||
|
A few reasons it lives in `editor/src/shell.rs` rather than `main.rs`:
|
||||||
|
|
||||||
|
- **Unit-testable behavior.** Shortcut routing, project open/close, recent
|
||||||
|
list updates, command stack lifecycle — all exercised without a window.
|
||||||
|
The maintainer's manual pass focuses on what tests *can't* prove: how the
|
||||||
|
UI looks and feels.
|
||||||
|
- **Reusable in tests and future hosts.** A headless reproducer for a UI bug
|
||||||
|
can drive the shell directly; an alternate front-end (web, embedded) could
|
||||||
|
reuse it.
|
||||||
|
- **Separation of concerns.** The binary stays a thin runner — window event
|
||||||
|
loop, 3D viewport, egui paint pump — while the shell owns editor state,
|
||||||
|
layout, and the framework wiring.
|
||||||
|
|
||||||
|
## Viewport camera modes (Stage 7)
|
||||||
|
|
||||||
|
The viewport has two camera schemes; the active one is toggled with
|
||||||
|
**F** (the default binding for the `editor.camera.toggle_flythrough`
|
||||||
|
action) while the cursor is over the Viewport tab. Bindings live in
|
||||||
|
`EditorState::actions` ([`ActionMap`](input.md)) registered with editor
|
||||||
|
defaults at startup; the [Input Bindings](#input-bindings-preferences-page)
|
||||||
|
preferences page exposes them for remapping.
|
||||||
|
|
||||||
|
| Mode | Controls |
|
||||||
|
|------|----------|
|
||||||
|
| **Orbit** (default) | L-drag = orbit · R-drag = pan · scroll = zoom · click = pick |
|
||||||
|
| **Flythrough** | WASD = forward/back + strafe · QE = down/up · Shift = sprint · R-drag = look · scroll = adjust move speed · click = pick |
|
||||||
|
|
||||||
|
Toggling preserves pose: the new camera lands looking at the same view
|
||||||
|
the previous one was showing, so the scene doesn't snap.
|
||||||
|
|
||||||
|
## Input Bindings preferences page (Stage 7 piece 5)
|
||||||
|
|
||||||
|
The Preferences window's `input.bindings` section renders a rich page
|
||||||
|
listing every registered editor action — buttons, 1D axes, 2D axes — with
|
||||||
|
its current bindings. Each binding cell:
|
||||||
|
|
||||||
|
- Clicking it arms a **capture** for that slot. The page shows
|
||||||
|
"Press a key…"; the next non-`Escape` key or mouse button press
|
||||||
|
becomes the binding. `Escape` cancels.
|
||||||
|
- `✕` removes that binding.
|
||||||
|
- `+` (per direction or per action) starts an append capture so the user
|
||||||
|
can add a binding without replacing one.
|
||||||
|
- `↺` (per action) restores that action to its code-defined defaults.
|
||||||
|
- A global **Restore all defaults** button at the top resets every
|
||||||
|
action.
|
||||||
|
|
||||||
|
Edits flow through [`Shell`](../editor/src/shell.rs)'s
|
||||||
|
`try_complete_capture`, which mutates `EditorState::actions`, syncs the
|
||||||
|
new bindings into the `input.bindings` settings section via
|
||||||
|
`sync_action_overrides_to_settings`, and flips a `bindings_dirty` flag.
|
||||||
|
The host runner reads-and-clears the flag each frame and writes the
|
||||||
|
preferences file to `$XDG_CONFIG_HOME/oxide/editor.ron` (or
|
||||||
|
`$HOME/.config/oxide/editor.ron`). On startup the editor reads that
|
||||||
|
file, calls `Settings::import`, and `apply_action_overrides_from_settings`
|
||||||
|
layers the user's remap on top of the defaults — so a remap survives a
|
||||||
|
restart, and removing an action from code never breaks an old file
|
||||||
|
(unknown sections are silently skipped).
|
||||||
|
|
||||||
|
The page is intentionally a built-in Shell feature rather than going
|
||||||
|
through `EditorExtensions::add_settings_page`: it needs to mutate
|
||||||
|
`EditorState::actions` while a capture is in flight, which is more
|
||||||
|
direct from the Shell than through the extension API's `FnMut(&mut Ui)`
|
||||||
|
contract.
|
||||||
|
|
||||||
|
## What's not yet here
|
||||||
|
|
||||||
|
| Feature | Where it lands |
|
||||||
|
|---------|---------------|
|
||||||
|
| Native New/Open dialogs (`rfd` or similar) | Polish; the in-app text-path modals fill the gap today |
|
||||||
|
| ~~3D viewport with its own projection sized to the Viewport tab~~ | ✅ Landed in Stage 7 piece 6b: `FrameContext::viewport_rect` restricts the wgpu viewport and drives the projection aspect; `Viewport::pick` rebases the cursor to tab-local NDC. |
|
||||||
|
| Layout persistence across restarts | After settings sections are richer (Preferences-driven) |
|
||||||
|
| Inspector via reflection-keyed component editors | Stage 7 alongside the gizmos |
|
||||||
|
| Undo/redo for spawn/despawn/reparent | Stage 7 — needs stable entity ids |
|
||||||
|
| Console wired to a real log feed / Stage-10 terminal | Stage 10 |
|
||||||
|
|
||||||
|
[`Shell::default_dock`]: ../editor/src/shell.rs
|
||||||
|
[`Shell::try_consume_shortcut`]: ../editor/src/shell.rs
|
||||||
|
[`Shell::open_project`]: ../editor/src/shell.rs
|
||||||
|
[`SetTransformCmd`]: ../editor/src/commands.rs
|
||||||
|
[`SetTransformCmd::merge`]: ../editor/src/commands.rs
|
||||||
|
[`RenameCmd`]: ../editor/src/commands.rs
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
# File Watching
|
||||||
|
|
||||||
|
`oxide_engine::watch` watches directories on disk and emits **debounced**,
|
||||||
|
**deduplicated** change events. It is the Stage-6 foundation for the engine's
|
||||||
|
live-reload story:
|
||||||
|
|
||||||
|
- **Stage 6** — reload changed assets (via [`AssetServer`](assets.md)) so a
|
||||||
|
texture or model edited in an external tool reappears in the running editor
|
||||||
|
without restarting.
|
||||||
|
- **Stage 10** — recompile and hot-swap game scripts using the same event
|
||||||
|
stream and the same debounce logic.
|
||||||
|
- **Editor** — drives the Project panel's "files appeared / disappeared"
|
||||||
|
refresh.
|
||||||
|
|
||||||
|
The same module covers all of these because the hard part — "wait until the
|
||||||
|
filesystem stops twitching, then emit one event per path" — is identical in
|
||||||
|
every case.
|
||||||
|
|
||||||
|
## Why debounce
|
||||||
|
|
||||||
|
Filesystem events are noisy:
|
||||||
|
|
||||||
|
- Most editors save in several syscalls (write the file, rename a temp file
|
||||||
|
into place, chmod) — that is one logical change but several events.
|
||||||
|
- Recursive watches re-fire while a directory's children are being created.
|
||||||
|
- Backends collapse or split events differently across Linux, macOS, and
|
||||||
|
Windows.
|
||||||
|
|
||||||
|
If the engine reloaded on every raw event, one save could re-parse a model many
|
||||||
|
times over. The watcher gathers raw events into a **pending set** keyed by
|
||||||
|
path, then emits one event per path once that path has been **quiet** for a
|
||||||
|
configurable window.
|
||||||
|
|
||||||
|
## Architecture (two layers)
|
||||||
|
|
||||||
|
The module is intentionally split so most behavior is unit-testable without
|
||||||
|
touching real files.
|
||||||
|
|
||||||
|
### `Debouncer` — the pure core
|
||||||
|
|
||||||
|
A plain struct that takes `Instant`s from the caller. Tests drive it through a
|
||||||
|
deterministic timeline; no `sleep`, no flaky timing dependence on the OS event
|
||||||
|
queue.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
use oxide_engine::watch::{ChangeKind, Debouncer};
|
||||||
|
|
||||||
|
let mut d = Debouncer::new(Duration::from_millis(100));
|
||||||
|
let t0 = Instant::now();
|
||||||
|
|
||||||
|
d.record("assets/cube.gltf".into(), ChangeKind::Modified, t0);
|
||||||
|
d.record("assets/cube.gltf".into(), ChangeKind::Modified,
|
||||||
|
t0 + Duration::from_millis(20));
|
||||||
|
|
||||||
|
// Still hot — nothing fires.
|
||||||
|
assert!(d.drain_ready(t0 + Duration::from_millis(80)).is_empty());
|
||||||
|
|
||||||
|
// After 100 ms of quiet, one event fires for the path.
|
||||||
|
let ready = d.drain_ready(t0 + Duration::from_millis(130));
|
||||||
|
assert_eq!(ready.len(), 1);
|
||||||
|
```
|
||||||
|
|
||||||
|
Coalescing rules (chosen to match what a reloader downstream cares about):
|
||||||
|
|
||||||
|
| Earlier kind | Newer kind | Emitted kind |
|
||||||
|
|--------------|-----------|--------------|
|
||||||
|
| `Created` | `Modified` | `Created` |
|
||||||
|
| `Removed` | `Modified` | `Created` (file came back) |
|
||||||
|
| anything | `Removed` | `Removed` |
|
||||||
|
| anything else | newer | newer |
|
||||||
|
|
||||||
|
### `FileWatcher` — the real-world wrapper
|
||||||
|
|
||||||
|
Wraps a `notify::RecommendedWatcher` plus a worker thread that drives the
|
||||||
|
debouncer with real time and forwards settled events through an `mpsc` channel.
|
||||||
|
|
||||||
|
```rust,no_run
|
||||||
|
use std::time::Duration;
|
||||||
|
use oxide_engine::watch::FileWatcher;
|
||||||
|
|
||||||
|
let (mut watcher, events) = FileWatcher::new(Duration::from_millis(150))?;
|
||||||
|
watcher.watch("path/to/project/assets")?;
|
||||||
|
|
||||||
|
// In the editor's per-frame tick, drain whatever has settled:
|
||||||
|
while let Ok(event) = events.try_recv() {
|
||||||
|
println!("{:?} at {}", event.kind, event.path.display());
|
||||||
|
}
|
||||||
|
# Ok::<(), oxide_engine::watch::WatchError>(())
|
||||||
|
```
|
||||||
|
|
||||||
|
`FileWatcher` watches recursively. Dropping it stops the worker thread and
|
||||||
|
disconnects the receiver — no manual cleanup.
|
||||||
|
|
||||||
|
The quiet window is a knob: too short and you get repeated events from one
|
||||||
|
save; too long and the editor feels laggy. The default Stage-6 wiring uses
|
||||||
|
~150 ms.
|
||||||
|
|
||||||
|
## Asset reload
|
||||||
|
|
||||||
|
`reload_changed_assets` is the wiring between the watcher and the
|
||||||
|
[asset server](assets.md). For each `Created` or `Modified` event it calls
|
||||||
|
`AssetServer::reload_path`, which re-runs the loader for every cached asset at
|
||||||
|
that path and updates the existing handle **in place** — gameplay code holding
|
||||||
|
the handle sees the new contents on its next read.
|
||||||
|
|
||||||
|
```rust,no_run
|
||||||
|
use std::time::Duration;
|
||||||
|
use oxide_engine::asset::AssetServer;
|
||||||
|
use oxide_engine::watch::{reload_changed_assets, FileWatcher};
|
||||||
|
|
||||||
|
let assets = AssetServer::new();
|
||||||
|
let (mut watcher, events) = FileWatcher::new(Duration::from_millis(150))?;
|
||||||
|
watcher.watch("path/to/project/assets")?;
|
||||||
|
|
||||||
|
// Per frame:
|
||||||
|
let batch: Vec<_> = std::iter::from_fn(|| events.try_recv().ok()).collect();
|
||||||
|
let reloaded = reload_changed_assets(&assets, batch);
|
||||||
|
if reloaded > 0 {
|
||||||
|
log::info!("hot-reloaded {} asset(s)", reloaded);
|
||||||
|
}
|
||||||
|
# Ok::<(), oxide_engine::watch::WatchError>(())
|
||||||
|
```
|
||||||
|
|
||||||
|
`AssetServer::reload_path` is type-erased on purpose. The cache records, per
|
||||||
|
entry, a function pointer that re-runs the loader for that entry's concrete
|
||||||
|
type, so the watcher can react to a disk change without knowing every asset
|
||||||
|
type at compile time. Paths that are not currently cached return zero work;
|
||||||
|
the next `load` picks up the fresh contents anyway. `Removed` events do **not**
|
||||||
|
invalidate cached handles — gameplay code may want the last-loaded copy to
|
||||||
|
keep working.
|
||||||
|
|
||||||
|
## What this groundwork enables
|
||||||
|
|
||||||
|
| Stage | Builds on |
|
||||||
|
|-------|-----------|
|
||||||
|
| 6 | Editor live-reload of assets; Project panel refresh |
|
||||||
|
| 7 | Watch input-binding config for changes during a session |
|
||||||
|
| 10 | Script hot-reload (same watcher; the reloader recompiles + swaps the module) |
|
||||||
|
| 11 | WGSL shader hot-reload |
|
||||||
|
|
||||||
|
## Testing strategy
|
||||||
|
|
||||||
|
- **Unit tests** drive `Debouncer` directly with fixed `Instant`s — fast,
|
||||||
|
deterministic, and they cover the coalescing rules exhaustively.
|
||||||
|
- **One tolerant smoke test** writes to a temp dir and polls for an event with
|
||||||
|
a generous deadline (seconds, not milliseconds). On containerized CI without
|
||||||
|
a usable event backend the test prints `SKIP:` and passes — the unit tests
|
||||||
|
already prove the logic is correct, this only checks the OS wiring is
|
||||||
|
connected.
|
||||||
|
|
||||||
|
[`watch`]: ../engine/src/watch.rs
|
||||||
|
[`Debouncer`]: ../engine/src/watch.rs
|
||||||
|
[`FileWatcher`]: ../engine/src/watch.rs
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
# Getting Started
|
||||||
|
|
||||||
|
This guide takes you from a clean machine to building Oxide, running an example,
|
||||||
|
and running the test and benchmark suites.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- **Rust stable toolchain.** Install via [rustup](https://rustup.rs):
|
||||||
|
```sh
|
||||||
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||||
|
```
|
||||||
|
Oxide tracks the stable channel and sets a minimum supported Rust version
|
||||||
|
(MSRV) in the workspace `Cargo.toml` (`rust-version`).
|
||||||
|
- **Linux** is the primary target. Other platforms are not yet tested.
|
||||||
|
- **A GPU with Vulkan support** (`wgpu` is integrated since Stage 2; on Linux
|
||||||
|
the Vulkan backend is the default). Backend selection can be overridden with
|
||||||
|
the `WGPU_BACKEND` environment variable.
|
||||||
|
|
||||||
|
## Cloning
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git clone https://git.houmeres.sk/Houmeres/Oxide.git
|
||||||
|
cd Oxide
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
Build the whole workspace (engine, editor, examples, tests):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo build # debug
|
||||||
|
cargo build --release # optimized
|
||||||
|
```
|
||||||
|
|
||||||
|
The workspace is a Cargo workspace with these members:
|
||||||
|
|
||||||
|
- `oxide-engine` — the core library
|
||||||
|
- `oxide-editor` — the in-engine editor binary
|
||||||
|
- `oxide-examples` — runnable examples (one or more per stage)
|
||||||
|
- `oxide-tests` — the integration test harness
|
||||||
|
|
||||||
|
## Running the editor
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo run -p oxide-editor --release
|
||||||
|
```
|
||||||
|
|
||||||
|
The editor opens its own window with a placeholder viewport (Stage 2); editor
|
||||||
|
panels arrive with the systems they edit in later stages. Quit with `Ctrl+Q`.
|
||||||
|
Raw input logging is visible with `RUST_LOG=debug`.
|
||||||
|
|
||||||
|
## Running examples
|
||||||
|
|
||||||
|
Examples live in `examples/src/bin/` and each is a standalone binary:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo run -p oxide-examples --bin math_demo
|
||||||
|
```
|
||||||
|
|
||||||
|
| Example | Stage | Description |
|
||||||
|
|---------|-------|-------------|
|
||||||
|
| `math_demo` | 1 | Prints a tour of transforms, bounds, ray/plane queries, frustum culling, color, and value ranges |
|
||||||
|
| `hello_window` | 2 | Opens a window cleared to a configurable color — keys `1`–`5` pick presets, `Space` cycles, `Esc` quits; FPS logged once per second |
|
||||||
|
|
||||||
|
## Running tests
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo test # the whole workspace
|
||||||
|
cargo test -p oxide-engine # engine unit tests only
|
||||||
|
cargo test -p oxide-tests # integration tests only
|
||||||
|
```
|
||||||
|
|
||||||
|
Unit tests live next to the code they test (a `#[cfg(test)] mod tests` block in
|
||||||
|
each module). End-to-end and cross-cutting tests live in the `oxide-tests` crate.
|
||||||
|
|
||||||
|
## Running benchmarks
|
||||||
|
|
||||||
|
Performance-sensitive systems use [criterion](https://github.com/bheisler/criterion.rs):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo bench -p oxide-engine
|
||||||
|
```
|
||||||
|
|
||||||
|
Stage 1 ships the `transform` benchmark, which includes `compose_1m` (the Stage 1
|
||||||
|
budget is 1,000,000 transform compositions in under 10 ms).
|
||||||
|
|
||||||
|
## Lint and format
|
||||||
|
|
||||||
|
Both must be clean before any change is committed:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo clippy --all-targets -- -D warnings
|
||||||
|
cargo fmt --check
|
||||||
|
```
|
||||||
|
|
||||||
|
The engine and editor crates are compiled with `#![deny(warnings)]`, so warnings
|
||||||
|
are hard errors.
|
||||||
|
|
||||||
|
## Installing to the system (Linux)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
chmod +x install.sh
|
||||||
|
./install.sh # installs to /usr/local
|
||||||
|
PREFIX=$HOME/.local ./install.sh # custom prefix
|
||||||
|
```
|
||||||
|
|
||||||
|
This builds in release mode and installs the `oxide-editor` binary plus assets.
|
||||||
|
To uninstall:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo rm /usr/local/bin/oxide-editor
|
||||||
|
sudo rm -rf /usr/local/share/oxide
|
||||||
|
```
|
||||||
+403
@@ -0,0 +1,403 @@
|
|||||||
|
# Input
|
||||||
|
|
||||||
|
Stage 7 reference for `oxide_engine::input` — the engine's input
|
||||||
|
abstraction. Three layers live here today:
|
||||||
|
|
||||||
|
- **Piece 1: [`InputState`](#raw-input-state)** — the raw per-frame
|
||||||
|
snapshot (keyboard / mouse / cursor / scroll, with edge detection).
|
||||||
|
- **Piece 2: [`Binding`] + [`ActionMap`](#named-actions-and-remapping)** —
|
||||||
|
named actions (e.g. `"Jump"`) bound to one or more physical inputs, with
|
||||||
|
defaults, runtime remapping, and RON-persistable user overrides.
|
||||||
|
- **Piece 3: [`AxisBinding`] + [`Axis2DBinding`](#directional-axes)** —
|
||||||
|
directional inputs composed from `Binding` direction sets (e.g. `WASD` →
|
||||||
|
`Vec2 "Move"`), stored alongside button actions in the same `ActionMap`
|
||||||
|
and persisted through the same `ActionOverrides` payload.
|
||||||
|
|
||||||
|
The editor pieces (flythrough camera, bindings preferences page, transform
|
||||||
|
gizmos) layer on top of these.
|
||||||
|
|
||||||
|
For the raw [`WindowEvent`](windowing.md) vocabulary the runner pumps from,
|
||||||
|
see [windowing.md](windowing.md).
|
||||||
|
|
||||||
|
## Raw input state
|
||||||
|
|
||||||
|
### Why a separate layer
|
||||||
|
|
||||||
|
Game code wants three distinct things from a physical key:
|
||||||
|
|
||||||
|
- **The press edge** — fires *once* on the frame a key first goes down. A
|
||||||
|
jump fires here.
|
||||||
|
- **The release edge** — fires *once* on the frame a key comes back up. A
|
||||||
|
charged shot fires here.
|
||||||
|
- **The held state** — true every frame between press and release. A sprint
|
||||||
|
modifier reads this.
|
||||||
|
|
||||||
|
Reading these straight off `WindowEvent::KeyboardInput` is doable but error-
|
||||||
|
prone: OS key auto-repeat re-sends `Pressed` on every repeat, focus loss can
|
||||||
|
leave keys "held" with no matching release, and a `CursorMoved` carries no
|
||||||
|
delta unless the consumer remembers the previous position. `InputState`
|
||||||
|
solves all of that in one place, and its semantics are unit-tested.
|
||||||
|
|
||||||
|
## How the runner uses it
|
||||||
|
|
||||||
|
The windowing [`run`](windowing.md) loop owns one `InputState` and:
|
||||||
|
|
||||||
|
1. Pumps every incoming [`WindowEvent`](windowing.md) into it via
|
||||||
|
`InputState::handle_event` **before** any callback sees the event, so
|
||||||
|
`ctx.input()` in `WindowApp::event` already reflects the event being
|
||||||
|
delivered.
|
||||||
|
2. Calls `WindowApp::update` — game logic reads `ctx.input()` to query the
|
||||||
|
accumulated state for the frame.
|
||||||
|
3. After `update` returns, calls `InputState::end_frame` to roll edges and
|
||||||
|
per-frame deltas off. Held state and the cursor anchor persist.
|
||||||
|
|
||||||
|
The result: in `update`, edges describe what happened "since the previous
|
||||||
|
frame" and held state is "right now".
|
||||||
|
|
||||||
|
## Reading input from a `WindowApp`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::prelude::*;
|
||||||
|
use oxide_engine::winit::event::MouseButton;
|
||||||
|
use oxide_engine::winit::keyboard::KeyCode;
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct MyApp;
|
||||||
|
|
||||||
|
impl WindowApp for MyApp {
|
||||||
|
fn update(&mut self, ctx: &mut AppCtx<'_>) {
|
||||||
|
let input = ctx.input();
|
||||||
|
|
||||||
|
if input.pressed(KeyCode::Space) {
|
||||||
|
// Fires once, on the frame Space went down.
|
||||||
|
}
|
||||||
|
if input.held(KeyCode::ShiftLeft) {
|
||||||
|
// True every frame Shift is down.
|
||||||
|
}
|
||||||
|
if input.released(KeyCode::Escape) {
|
||||||
|
ctx.request_exit();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Right-drag pans by the mouse delta accumulated this frame.
|
||||||
|
if input.mouse_held(MouseButton::Right) {
|
||||||
|
let _delta = input.mouse_delta(); // physical pixels
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scroll is in line-equivalent units (touchpad pixels are normalized
|
||||||
|
// so wheels and trackpads report on the same scale).
|
||||||
|
let _zoom_amount = input.scroll().y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Edge semantics, in detail
|
||||||
|
|
||||||
|
`InputState` keeps three sets per device (held / pressed / released) and
|
||||||
|
applies these rules:
|
||||||
|
|
||||||
|
- `press_key(k)` — if `k` was **not** already held, both `held` and
|
||||||
|
`pressed` add it. If it was already held (OS auto-repeat), `pressed` is
|
||||||
|
unchanged. The one-shot press edge fires exactly once per real keypress.
|
||||||
|
- `release_key(k)` — `held` removes `k`; `released` adds `k`. The release
|
||||||
|
edge fires whether or not the key was previously tracked as held, so the
|
||||||
|
occasional "release without matching press" the OS delivers (focus
|
||||||
|
changes, alt-tab) still produces a usable signal.
|
||||||
|
- `end_frame()` — clears `pressed` and `released` (and the per-frame mouse
|
||||||
|
delta + scroll). `held` and the cursor anchor are untouched.
|
||||||
|
- `WindowEvent::Focused(false)` — every currently-held key and mouse button
|
||||||
|
is force-released (released-edge fires for each), so a key held when the
|
||||||
|
user alt-tabbed away cannot remain stuck after the window comes back.
|
||||||
|
|
||||||
|
Mouse buttons mirror the keyboard rules exactly. Cursor + delta and scroll
|
||||||
|
use the same end-of-frame reset.
|
||||||
|
|
||||||
|
## Cursor and mouse delta
|
||||||
|
|
||||||
|
Cursor position is stored as physical pixels relative to the window. The
|
||||||
|
**delta** is the sum of the segment vectors between `set_cursor` calls
|
||||||
|
*within the frame*, not the gross displacement from the first event. The
|
||||||
|
first `set_cursor` after construction (or after `forget_cursor` /
|
||||||
|
`WindowEvent::CursorLeft`) seeds the anchor without contributing to the
|
||||||
|
delta — so the first frame the cursor enters the window never produces a
|
||||||
|
phantom jump.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Frame 1: cursor enters at (100, 100) → delta = (0, 0)
|
||||||
|
Frame 2: moves (100,100)→(105,98)→(108,95) → delta = (8, -5)
|
||||||
|
Frame 3: no movement → delta = (0, 0)
|
||||||
|
(cursor still at (108, 95))
|
||||||
|
```
|
||||||
|
|
||||||
|
`add_mouse_delta(dx, dy)` exists for relative-motion sources that don't
|
||||||
|
go through `CursorMoved` (a future `DeviceEvent::MouseMotion` pump, a
|
||||||
|
pointer-lock toggle, or a synthesized test). It layers on top of the
|
||||||
|
cursor-based delta.
|
||||||
|
|
||||||
|
## Scroll
|
||||||
|
|
||||||
|
Scroll is reported in **line-equivalent units**: wheel notches arrive as
|
||||||
|
`LineDelta` and pass through unchanged; trackpad pixel deltas are divided
|
||||||
|
by a fixed pixels-per-line constant (40) so a touchpad gesture and a wheel
|
||||||
|
notch produce comparable numbers.
|
||||||
|
|
||||||
|
## Testing inputs directly
|
||||||
|
|
||||||
|
The mutator API (`press_key`, `release_mouse`, `set_cursor`,
|
||||||
|
`add_mouse_delta`, `add_scroll`, `forget_cursor`, `release_all_held`) is
|
||||||
|
the same path `handle_event` uses, and is intentionally public. Tests
|
||||||
|
should call it directly rather than try to fabricate `WindowEvent`s —
|
||||||
|
winit 0.30's `DeviceId` cannot be constructed outside a real event loop,
|
||||||
|
so most input variants are unreachable from synthesized events. The
|
||||||
|
mutators are unit-tested and exercised end-to-end by `stage7` integration
|
||||||
|
tests in the [`tests`](../tests) crate.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::prelude::*;
|
||||||
|
use oxide_engine::winit::keyboard::KeyCode;
|
||||||
|
|
||||||
|
let mut input = InputState::new();
|
||||||
|
input.press_key(KeyCode::Space);
|
||||||
|
assert!(input.pressed(KeyCode::Space));
|
||||||
|
assert!(input.held(KeyCode::Space));
|
||||||
|
|
||||||
|
input.end_frame();
|
||||||
|
assert!(!input.pressed(KeyCode::Space));
|
||||||
|
assert!(input.held(KeyCode::Space));
|
||||||
|
```
|
||||||
|
|
||||||
|
## Named actions and remapping
|
||||||
|
|
||||||
|
`InputState` answers "is `KeyCode::Space` down?". Game code shouldn't ask
|
||||||
|
that question: physical keys are user-settings territory, and querying
|
||||||
|
them directly couples gameplay to a fixed keyboard layout. `ActionMap`
|
||||||
|
adds the indirection — game code asks "is `\"Jump\"` engaged?" and the
|
||||||
|
map resolves it to whatever the user (or the program's default) has
|
||||||
|
bound.
|
||||||
|
|
||||||
|
### The data model
|
||||||
|
|
||||||
|
An action carries two binding lists:
|
||||||
|
|
||||||
|
- **`defaults`** — the bindings registered from code at startup. They
|
||||||
|
never change at runtime.
|
||||||
|
- **`current`** — the bindings actually queried each frame. Initially a
|
||||||
|
clone of `defaults`; remapped by the settings screen; restored by the
|
||||||
|
"Restore defaults" button.
|
||||||
|
|
||||||
|
Persistence saves only `current`. On reload, the program first registers
|
||||||
|
actions from code (defaults reappear from source), then applies the saved
|
||||||
|
overrides on top. Actions that vanished from code never break an old
|
||||||
|
settings file — they're silently skipped.
|
||||||
|
|
||||||
|
### Setting up actions
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::prelude::*;
|
||||||
|
use oxide_engine::winit::event::MouseButton;
|
||||||
|
use oxide_engine::winit::keyboard::KeyCode;
|
||||||
|
|
||||||
|
let mut actions = ActionMap::new();
|
||||||
|
actions
|
||||||
|
.register("Jump", [Binding::Key(KeyCode::Space)])
|
||||||
|
.register(
|
||||||
|
"Sprint",
|
||||||
|
[
|
||||||
|
Binding::Key(KeyCode::ShiftLeft),
|
||||||
|
Binding::Key(KeyCode::ShiftRight),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.register("Fire", [Binding::Mouse(MouseButton::Left)]);
|
||||||
|
```
|
||||||
|
|
||||||
|
Multi-bind on either axis is supported: an action can list several
|
||||||
|
bindings (the `Sprint` example), and one physical key can drive several
|
||||||
|
actions (e.g. `Space` → both `"Jump"` and `"Confirm"`).
|
||||||
|
|
||||||
|
### Querying actions
|
||||||
|
|
||||||
|
```rust
|
||||||
|
# use oxide_engine::prelude::*;
|
||||||
|
# use oxide_engine::winit::keyboard::KeyCode;
|
||||||
|
# let mut actions = ActionMap::new();
|
||||||
|
# actions.register("Jump", [Binding::Key(KeyCode::Space)]);
|
||||||
|
# let input = InputState::new();
|
||||||
|
if actions.action_pressed("Jump", &input) {
|
||||||
|
// Fires once, on the frame Jump becomes engaged.
|
||||||
|
}
|
||||||
|
if actions.action_held("Jump", &input) {
|
||||||
|
// True every frame Jump is engaged (at least one binding held).
|
||||||
|
}
|
||||||
|
if actions.action_released("Jump", &input) {
|
||||||
|
// Fires once, when the last engaged binding releases.
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Action edges have **hysteresis at the action level**, not the binding
|
||||||
|
level: pressing a second binding while the action is already engaged does
|
||||||
|
not retrigger `action_pressed`, and releasing one binding while another
|
||||||
|
is still held does not fire `action_released`. The edge fires only on
|
||||||
|
the action's transition between engaged and disengaged. (See the
|
||||||
|
[`action_pressed`](../engine/src/input/action.rs) rustdoc for the
|
||||||
|
precise definition.)
|
||||||
|
|
||||||
|
Querying an unregistered action returns `false` everywhere — never a
|
||||||
|
panic — so typo'd action names are graceful.
|
||||||
|
|
||||||
|
### Runtime remap
|
||||||
|
|
||||||
|
```rust
|
||||||
|
# use oxide_engine::prelude::*;
|
||||||
|
# use oxide_engine::winit::keyboard::KeyCode;
|
||||||
|
# let mut actions = ActionMap::new();
|
||||||
|
# actions.register("Jump", [Binding::Key(KeyCode::Space)]);
|
||||||
|
// A bindings preferences page calls these — game code is untouched.
|
||||||
|
actions.set_bindings("Jump", vec![Binding::Key(KeyCode::KeyW)]);
|
||||||
|
actions.add_binding("Jump", Binding::Key(KeyCode::Space)); // restore as alt
|
||||||
|
actions.remove_binding("Jump", Binding::Key(KeyCode::KeyW));
|
||||||
|
actions.clear_bindings("Jump"); // make Jump temporarily unbindable
|
||||||
|
actions.restore_defaults("Jump"); // ↩ user's defaults
|
||||||
|
actions.restore_all_defaults(); // ↩ everything
|
||||||
|
```
|
||||||
|
|
||||||
|
### Persistence via the Stage-6 settings framework
|
||||||
|
|
||||||
|
`ActionOverrides` is the serializable projection of an `ActionMap`'s
|
||||||
|
current bindings, and it derives `Default + Serialize + Deserialize` so
|
||||||
|
it plugs straight into `Settings::register::<ActionOverrides>(name)` —
|
||||||
|
no framework code changes needed. The whole cycle:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::prelude::*;
|
||||||
|
use oxide_engine::winit::keyboard::KeyCode;
|
||||||
|
|
||||||
|
// One-time setup at startup.
|
||||||
|
let mut actions = ActionMap::new();
|
||||||
|
actions.register("Jump", [Binding::Key(KeyCode::Space)]);
|
||||||
|
|
||||||
|
let mut settings = Settings::new();
|
||||||
|
settings.register::<ActionOverrides>("input.bindings");
|
||||||
|
|
||||||
|
// User remap → write into Settings → export to disk.
|
||||||
|
actions.set_bindings("Jump", vec![Binding::Key(KeyCode::KeyW)]);
|
||||||
|
*settings.get_mut::<ActionOverrides>("input.bindings").unwrap() =
|
||||||
|
actions.overrides();
|
||||||
|
let on_disk = settings.export(); // RON map, persist however you like
|
||||||
|
|
||||||
|
// On the next launch, after re-registering defaults from code:
|
||||||
|
settings.import(&on_disk);
|
||||||
|
actions.apply_overrides(settings.get::<ActionOverrides>("input.bindings").unwrap());
|
||||||
|
// Jump is now bound to W again.
|
||||||
|
```
|
||||||
|
|
||||||
|
The `apply_overrides` step is order-independent with respect to which
|
||||||
|
actions the file knows about: unknown names are skipped, and registered
|
||||||
|
actions absent from the file keep their defaults.
|
||||||
|
|
||||||
|
## Directional axes
|
||||||
|
|
||||||
|
Buttons answer "is this engaged?". Movement and camera control want a
|
||||||
|
**direction with magnitude**. `AxisBinding` (1D, returns `f32`) and
|
||||||
|
`Axis2DBinding` (2D, returns `Vec2`) compose direction sets of
|
||||||
|
`Binding`s into those values. They live in the same [`ActionMap`] as
|
||||||
|
button actions but in **separate name spaces**, so `"Move"` can be a 2D
|
||||||
|
axis and `"MoveSlower"` a button without conflict — and a `"Move"`
|
||||||
|
button can coexist with a `"Move"` axis if a project wants it to.
|
||||||
|
|
||||||
|
### 1D axes
|
||||||
|
|
||||||
|
An `AxisBinding` is a pair of binding sets (one for the +1 direction,
|
||||||
|
one for −1). Any binding held on a side contributes a full unit; if
|
||||||
|
both sides are held simultaneously they cancel to 0 — a "soft brake"
|
||||||
|
the player gets for free.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::prelude::*;
|
||||||
|
use oxide_engine::winit::keyboard::KeyCode;
|
||||||
|
|
||||||
|
let mut actions = ActionMap::new();
|
||||||
|
actions.register_axis(
|
||||||
|
"MoveX",
|
||||||
|
AxisBinding::new(
|
||||||
|
[Binding::Key(KeyCode::KeyD)],
|
||||||
|
[Binding::Key(KeyCode::KeyA)],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut input = InputState::new();
|
||||||
|
input.press_key(KeyCode::KeyD);
|
||||||
|
assert_eq!(actions.axis("MoveX", &input), 1.0);
|
||||||
|
```
|
||||||
|
|
||||||
|
Multiple bindings on the same direction do **not** stack (`D` and `→`
|
||||||
|
both held still reads as `1.0`, not `2.0`) — the axis reports
|
||||||
|
direction, not accumulated input.
|
||||||
|
|
||||||
|
### 2D axes
|
||||||
|
|
||||||
|
`Axis2DBinding::new(right, left, up, down)` composes four direction
|
||||||
|
sets into a `Vec2`. Diagonals are intentionally **not normalized** — a
|
||||||
|
game that wants unit-length movement normalizes at the call site; a
|
||||||
|
game that wants diagonal-faster gets it for free. The cancel-on-both
|
||||||
|
rule applies independently on each axis.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
# use oxide_engine::prelude::*;
|
||||||
|
# use oxide_engine::winit::keyboard::KeyCode;
|
||||||
|
let mut actions = ActionMap::new();
|
||||||
|
actions.register_axis_2d(
|
||||||
|
"Move",
|
||||||
|
Axis2DBinding::new(
|
||||||
|
[Binding::Key(KeyCode::KeyD)],
|
||||||
|
[Binding::Key(KeyCode::KeyA)],
|
||||||
|
[Binding::Key(KeyCode::KeyW)],
|
||||||
|
[Binding::Key(KeyCode::KeyS)],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut input = InputState::new();
|
||||||
|
input.press_key(KeyCode::KeyW);
|
||||||
|
input.press_key(KeyCode::KeyA);
|
||||||
|
let v = actions.axis_2d("Move", &input);
|
||||||
|
assert_eq!(v, oxide_engine::math::Vec2::new(-1.0, 1.0));
|
||||||
|
// If you want unit-length: `if v != Vec2::ZERO { v.normalize() } else { v }`
|
||||||
|
```
|
||||||
|
|
||||||
|
### Remap, restore, and persistence
|
||||||
|
|
||||||
|
`set_axis_bindings` / `set_axis_2d_bindings` swap the current bindings
|
||||||
|
without renaming the action. `restore_axis_defaults` /
|
||||||
|
`restore_axis_2d_defaults` revert to the code-defined bindings.
|
||||||
|
`restore_all_defaults` covers every action across all three kinds in
|
||||||
|
one call.
|
||||||
|
|
||||||
|
[`ActionOverrides`] carries axis overrides alongside button overrides
|
||||||
|
in three sub-maps. The settings round-trip is identical to the button
|
||||||
|
case — `ActionOverrides` is the same settings-section type:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
# use oxide_engine::prelude::*;
|
||||||
|
# let mut actions = ActionMap::new();
|
||||||
|
# let mut settings = Settings::new();
|
||||||
|
settings.register::<ActionOverrides>("input.bindings");
|
||||||
|
*settings.get_mut::<ActionOverrides>("input.bindings").unwrap() =
|
||||||
|
actions.overrides();
|
||||||
|
let on_disk = settings.export(); // axes, axes_2d, and buttons all persist
|
||||||
|
```
|
||||||
|
|
||||||
|
Older settings files written before axes existed (i.e. with no `axes` or
|
||||||
|
`axes_2d` field in the RON) load cleanly — the missing sub-maps
|
||||||
|
deserialize as empty, and registered axes keep their code-defined
|
||||||
|
defaults.
|
||||||
|
|
||||||
|
## Status and what's next
|
||||||
|
|
||||||
|
- **Piece 1 (✅).** Raw `InputState` + runner integration.
|
||||||
|
- **Piece 2 (✅).** Named button action mapping with defaults, multi-bind
|
||||||
|
in either direction, runtime remap, and RON persistence through the
|
||||||
|
Stage-6 settings framework.
|
||||||
|
- **Piece 3 (✅ — this section).** 1D and 2D directional axes composed
|
||||||
|
from `Binding` direction sets, sharing `ActionMap` storage and the
|
||||||
|
same `ActionOverrides` persistence payload.
|
||||||
|
- **Editor pieces (planned).** Flythrough camera using the action map,
|
||||||
|
bindings page in Preferences contributed via the Stage-6 extension API,
|
||||||
|
transform gizmos.
|
||||||
+155
@@ -0,0 +1,155 @@
|
|||||||
|
# Layers, Groups & Tags
|
||||||
|
|
||||||
|
`oxide_engine::layer` is the engine's shared answer to *"which things interact
|
||||||
|
with which?"*. Instead of every subsystem inventing its own notion of
|
||||||
|
collision groups, render masks, or query filters, they all reference one
|
||||||
|
primitive — the [`LayerMask`] — so a layer named once is honored everywhere.
|
||||||
|
|
||||||
|
This system lands in Stage 5 (Engine Core Framework). The consumers below are
|
||||||
|
wired up in their own stages, but they all build on the types here.
|
||||||
|
|
||||||
|
## The model: one Layer, many Groups
|
||||||
|
|
||||||
|
Oxide follows the **Unity model**, separating two concepts:
|
||||||
|
|
||||||
|
- **[`Layer`]** — *single-valued* membership. Every entity is on exactly **one**
|
||||||
|
of 32 logical layers (its index `0..32`). This is the fast filter slot used by
|
||||||
|
rendering, physics, and queries.
|
||||||
|
- **[`Tags`]** — *multi-valued* gameplay grouping. An entity can be in **any
|
||||||
|
number** of named groups (`"Enemies"`, `"Interactables"`, `"SaveOnExit"`) that
|
||||||
|
game code and scripts look up by name.
|
||||||
|
|
||||||
|
So: *what kind of thing is this, for fast filtering?* → one **Layer**. *What
|
||||||
|
gameplay categories does it belong to?* → many **Groups** (tags). The two never
|
||||||
|
fight over the same slot.
|
||||||
|
|
||||||
|
## `LayerMask`: the filter primitive
|
||||||
|
|
||||||
|
A `LayerMask` is a 32-slot bitset packed into a `u32`. An entity's `Layer` is a
|
||||||
|
single index, but the things that *select* entities carry a **mask** — a
|
||||||
|
camera's visibility, a physics collision filter, a raycast's query mask — so a
|
||||||
|
filter can target several layers at once. An entity is selected when its layer
|
||||||
|
is one of the bits in the filter:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::layer::{Layer, LayerMask};
|
||||||
|
|
||||||
|
let entity = Layer::on(2); // membership: layer 2
|
||||||
|
let filter = LayerMask::NONE.with(1).with(2); // a filter selecting 1 or 2
|
||||||
|
assert!(entity.matches(filter)); // layer 2 is in the mask → selected
|
||||||
|
```
|
||||||
|
|
||||||
|
`a.intersects(b)` (equivalently `(a & b) != 0`) is the universal mask
|
||||||
|
interaction test. Everything else (`with`/`without`/`union`/`intersection`/
|
||||||
|
`complement`, the `|`/`&`/`^`/`!` operators, `iter`) is convenience around that.
|
||||||
|
|
||||||
|
Layer indices run `0..32`. Passing an index `>= 32` panics in every build — a
|
||||||
|
mistake worth catching loudly rather than wrapping silently.
|
||||||
|
|
||||||
|
`Layer` is **node-baked**: `Scene::spawn` auto-attaches `Layer::DEFAULT` (index
|
||||||
|
0) to every entity, so a fresh entity is visible to broad "see everything"
|
||||||
|
filters out of the box.
|
||||||
|
|
||||||
|
## Naming layers: `LayerRegistry`
|
||||||
|
|
||||||
|
Bits are not self-documenting, so a project keeps a [`LayerRegistry`] mapping
|
||||||
|
indices to names. Index 0 is seeded as `"Default"`; the rest are unnamed until
|
||||||
|
assigned. The registry is project-level data (serialized with the project in a
|
||||||
|
later stage), and renaming a layer never moves an entity — only the label
|
||||||
|
changes.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::layer::LayerRegistry;
|
||||||
|
|
||||||
|
let mut registry = LayerRegistry::new();
|
||||||
|
registry.set(1, "Player");
|
||||||
|
registry.set(2, "NPC");
|
||||||
|
|
||||||
|
// Author a filter by name instead of by magic number:
|
||||||
|
let visible_to_camera = registry.mask_of(["Player", "NPC"]);
|
||||||
|
assert_eq!(registry.index_of("Player"), Some(1));
|
||||||
|
```
|
||||||
|
|
||||||
|
The editor seeds a small, generally-useful starter set — `Default`, `UI`,
|
||||||
|
`Player`, `World` — and exposes the registry through its **Layer Names** editor
|
||||||
|
(opened from the inspector's `Layer` dropdown). Because both a camera's
|
||||||
|
visibility mask and a raycast's filter can be built from the same names, the
|
||||||
|
*named layer* is the single source of truth.
|
||||||
|
|
||||||
|
## Gameplay grouping: `Tags` + `GroupRegistry`
|
||||||
|
|
||||||
|
[`Tags`] is the per-entity, string-keyed set that holds an entity's **group**
|
||||||
|
membership — the multi-valued counterpart to its single `Layer`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::layer::Tags;
|
||||||
|
|
||||||
|
let mut tags = Tags::single("Enemy");
|
||||||
|
tags.insert("Flying");
|
||||||
|
assert!(tags.contains("Enemy"));
|
||||||
|
```
|
||||||
|
|
||||||
|
The set of *valid* group names is project-level data in a [`GroupRegistry`], so
|
||||||
|
the editor offers a fixed vocabulary to pick from (predefined, like layers)
|
||||||
|
rather than free-typed strings. Defining or deleting a group only changes that
|
||||||
|
vocabulary — it never touches the tags already on entities:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::layer::GroupRegistry;
|
||||||
|
|
||||||
|
let mut groups = GroupRegistry::new();
|
||||||
|
groups.define("Enemies");
|
||||||
|
groups.define("Interactables");
|
||||||
|
assert!(groups.contains("Enemies"));
|
||||||
|
```
|
||||||
|
|
||||||
|
In the editor, the inspector's **Groups** dropdown is a multi-select of the
|
||||||
|
defined groups (each a checkbox toggling membership in the entity's `Tags`), with
|
||||||
|
an **Edit groups…** entry opening the Groups editor to manage the vocabulary.
|
||||||
|
|
||||||
|
Use a **layer** when something must filter quickly and en masse (rendering,
|
||||||
|
physics, queries). Use a **group** when you need to ask "what gameplay
|
||||||
|
categories is this in?" and an entity may be in several at once.
|
||||||
|
|
||||||
|
## Attaching to entities
|
||||||
|
|
||||||
|
`Layer` is auto-attached; `Tags` is an ordinary ECS component — attach it through
|
||||||
|
the scene's world and query it like anything else:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::prelude::*;
|
||||||
|
use oxide_engine::layer::{LayerMask, Layer, Tags};
|
||||||
|
|
||||||
|
let mut scene = Scene::new();
|
||||||
|
let guard = scene.spawn("guard", Transform::IDENTITY); // Layer::DEFAULT auto-attached
|
||||||
|
scene.world_mut().insert_one(guard, Layer::on(2)).unwrap(); // move to layer 2
|
||||||
|
scene.world_mut().insert_one(guard, Tags::single("Enemy")).unwrap();
|
||||||
|
|
||||||
|
// Find everything a layer-2 query would hit:
|
||||||
|
let filter = LayerMask::layer(2);
|
||||||
|
for (entity, layer) in scene.world().query::<&Layer>().iter() {
|
||||||
|
if layer.matches(filter) {
|
||||||
|
// ... this entity is selected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
All five types (`LayerMask`, `LayerRegistry`, `Layer`, `Tags`, `GroupRegistry`)
|
||||||
|
are `serde`-serializable, so layer/group data round-trips through RON and is
|
||||||
|
dual-editable from the editor and from scripts/AI agents like every other engine
|
||||||
|
component.
|
||||||
|
|
||||||
|
## Who consumes this
|
||||||
|
|
||||||
|
| Consumer | Stage | How it uses layers |
|
||||||
|
|----------|-------|--------------------|
|
||||||
|
| Render pass pipeline | 5 | A camera holds a visibility `LayerMask`; only entities whose `Layer` is in it are drawn |
|
||||||
|
| Physics | 9 | A collider's membership + filter masks drive collision groups and sensor/trigger filtering |
|
||||||
|
| Scene queries | 9 | A raycast/shape-cast carries a filter mask tested against candidates' layer |
|
||||||
|
| Editor | 6+ | Single-select `Layer` dropdown + Layer Names editor; multi-select `Groups` dropdown + Groups editor |
|
||||||
|
|
||||||
|
[`LayerMask`]: ../engine/src/layer/mask.rs
|
||||||
|
[`LayerRegistry`]: ../engine/src/layer/registry.rs
|
||||||
|
[`Layer`]: ../engine/src/layer/components.rs
|
||||||
|
[`Tags`]: ../engine/src/layer/components.rs
|
||||||
|
[`GroupRegistry`]: ../engine/src/layer/groups.rs
|
||||||
+353
@@ -0,0 +1,353 @@
|
|||||||
|
# Math & Core Primitives
|
||||||
|
|
||||||
|
The `oxide_engine::math` module is the foundation every other system depends on.
|
||||||
|
It builds on [`glam`](https://docs.rs/glam) for vectors, quaternions, and
|
||||||
|
matrices, and adds the engine's higher-level geometric and utility types.
|
||||||
|
|
||||||
|
This document is the usage reference for the module as delivered in **Stage 1**.
|
||||||
|
For the conventions these types follow (handedness, units, color space), see
|
||||||
|
[conventions.md](conventions.md).
|
||||||
|
|
||||||
|
## Importing
|
||||||
|
|
||||||
|
Everything is available through the module path or the prelude:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::math::{Transform, Aabb, Ray, Plane, Frustum, Color, Rect, Range3};
|
||||||
|
// or, more commonly:
|
||||||
|
use oxide_engine::prelude::*;
|
||||||
|
```
|
||||||
|
|
||||||
|
The prelude also re-exports the `glam` types you need for everyday work — `Vec2`,
|
||||||
|
`Vec3`, `Vec4`, `Quat`, `Mat3`, `Mat4`, and `EulerRot` — so downstream crates
|
||||||
|
need no direct `glam` dependency.
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
| Type | Role |
|
||||||
|
|------|------|
|
||||||
|
| [`Transform`](#transform) | Placement: translation + rotation + scale |
|
||||||
|
| [`Aabb`](#aabb) | Axis-aligned bounding box (geometry/bounds/culling) |
|
||||||
|
| [`Ray`](#ray) | Origin + normalized direction (picking, queries) |
|
||||||
|
| [`Plane`](#plane) | Infinite plane in Hessian normal form |
|
||||||
|
| [`Frustum`](#frustum) | Six-plane view volume for visibility culling |
|
||||||
|
| [`Color`](#color) | Linear RGBA color with sRGB conversion |
|
||||||
|
| [`Rect`](#rect) | 2D rectangle (UI, viewports, texture regions) |
|
||||||
|
| [`Range3`](#range3) | 3D value range (clamp, lerp, remap) |
|
||||||
|
|
||||||
|
All types are `Copy`, `PartialEq`, and `serde`-(de)serializable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Transform
|
||||||
|
|
||||||
|
A 3D affine transform stored in **decomposed** form — `translation` (`Vec3`),
|
||||||
|
`rotation` (`Quat`), and `scale` (`Vec3`) — so each channel stays editable
|
||||||
|
without matrix round-trips. The effective matrix is `T * R * S`.
|
||||||
|
|
||||||
|
### Construction
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::prelude::*;
|
||||||
|
|
||||||
|
let a = Transform::IDENTITY;
|
||||||
|
let b = Transform::from_translation(Vec3::new(0.0, 1.0, 0.0));
|
||||||
|
let c = Transform::from_rotation(Quat::from_rotation_y(90_f32.to_radians()));
|
||||||
|
let d = Transform::from_scale(Vec3::splat(2.0));
|
||||||
|
let e = Transform::from_trs(
|
||||||
|
Vec3::new(1.0, 2.0, 3.0),
|
||||||
|
Quat::from_euler(EulerRot::XYZ, 0.1, 0.2, 0.3),
|
||||||
|
Vec3::splat(1.0),
|
||||||
|
);
|
||||||
|
|
||||||
|
// From / to a 4x4 matrix:
|
||||||
|
let m = e.to_matrix(); // glam::Mat4
|
||||||
|
let back = Transform::from_matrix(m);
|
||||||
|
let affine = e.to_affine(); // glam::Affine3A (cheaper to compose)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Composition and hierarchy
|
||||||
|
|
||||||
|
`mul_transform` composes parent-first, so this is how you resolve a child into
|
||||||
|
its parent's space:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let parent = Transform::from_trs(
|
||||||
|
Vec3::new(10.0, 0.0, 0.0),
|
||||||
|
Quat::from_rotation_y(90_f32.to_radians()),
|
||||||
|
Vec3::splat(2.0),
|
||||||
|
);
|
||||||
|
let child_local = Transform::from_translation(Vec3::new(0.0, 0.0, 1.0));
|
||||||
|
let child_world = parent.mul_transform(&child_local);
|
||||||
|
// child_world.translation == (12, 0, 0)
|
||||||
|
```
|
||||||
|
|
||||||
|
Composition is exact for uniform scale and a closest-fit approximation for
|
||||||
|
non-uniform scale combined with rotation (see
|
||||||
|
[conventions](conventions.md#transform-composition)).
|
||||||
|
|
||||||
|
### Applying a transform
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let t = Transform::from_trs(
|
||||||
|
Vec3::new(1.0, 0.0, 0.0),
|
||||||
|
Quat::from_rotation_z(90_f32.to_radians()),
|
||||||
|
Vec3::splat(2.0),
|
||||||
|
);
|
||||||
|
|
||||||
|
let p = t.transform_point(Vec3::new(1.0, 0.0, 0.0)); // affected by T, R, S
|
||||||
|
let v = t.transform_vector(Vec3::new(1.0, 0.0, 0.0)); // R and S only (no translation)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Inverse
|
||||||
|
|
||||||
|
`inverse()` returns a transform that undoes this one. It is exact for uniform
|
||||||
|
scale; with any zero scale component the transform is not invertible and the
|
||||||
|
inverse scale will contain infinities (check with `is_finite()`).
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let undo = t.inverse();
|
||||||
|
let identity = t.mul_transform(&undo); // ≈ Transform::IDENTITY
|
||||||
|
```
|
||||||
|
|
||||||
|
### Direction and orientation helpers
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let dir_forward = t.forward(); // local -Z
|
||||||
|
let dir_up = t.up(); // local +Y
|
||||||
|
let dir_right = t.right(); // local +X
|
||||||
|
|
||||||
|
// Build an orientation that looks from `eye` toward `target`:
|
||||||
|
let cam = Transform::looking_at(Vec3::new(0.0, 0.0, 5.0), Vec3::ZERO, Vec3::Y);
|
||||||
|
// cam.forward() points at the target. Degenerate (eye == target) → identity rotation.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Edge cases handled
|
||||||
|
|
||||||
|
- **Zero scale** → non-invertible; the forward transform is still finite.
|
||||||
|
- **Gimbal-lock orientations** (e.g. ±90° pitch) round-trip through a matrix
|
||||||
|
without losing orthonormality of the basis vectors.
|
||||||
|
- **Degenerate `looking_at`** (eye == target) returns identity rotation rather
|
||||||
|
than producing NaNs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Aabb
|
||||||
|
|
||||||
|
An axis-aligned bounding box defined by `min` and `max` corners. Used for bounds,
|
||||||
|
broad-phase overlap, and frustum culling. A box is *empty* when any `min`
|
||||||
|
component exceeds its `max`; `Aabb::EMPTY` is the identity for `union`.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::prelude::*;
|
||||||
|
|
||||||
|
let a = Aabb::new(Vec3::new(1.0, 5.0, -2.0), Vec3::new(-1.0, 0.0, 3.0)); // corners auto-sorted
|
||||||
|
let b = Aabb::from_center_half_extents(Vec3::ZERO, Vec3::splat(1.0));
|
||||||
|
let c = Aabb::from_points([Vec3::ZERO, Vec3::new(2.0, -1.0, 4.0), Vec3::new(-3.0, 5.0, 1.0)]);
|
||||||
|
|
||||||
|
// Queries:
|
||||||
|
let center = b.center();
|
||||||
|
let size = b.size();
|
||||||
|
let inside = b.contains_point(Vec3::ZERO);
|
||||||
|
let near = b.closest_point(Vec3::new(5.0, 0.0, 0.0));
|
||||||
|
|
||||||
|
// Set operations:
|
||||||
|
let u = a.union(&b);
|
||||||
|
let i = a.intersection(&b); // Aabb::EMPTY if disjoint
|
||||||
|
let hit = a.intersects(&b); // bool (touching counts)
|
||||||
|
|
||||||
|
// Acceleration-structure metrics:
|
||||||
|
let area = b.surface_area(); // for SAH
|
||||||
|
let vol = b.volume();
|
||||||
|
let pts = b.corners(); // [Vec3; 8]
|
||||||
|
|
||||||
|
// Ray test (slab method): returns entry distance t, or None.
|
||||||
|
let ray = Ray::new(Vec3::new(-5.0, 0.0, 0.0), Vec3::X);
|
||||||
|
if let Some(t) = b.ray_intersection(&ray) {
|
||||||
|
let point = ray.at(t);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A ray whose origin is inside the box returns `Some(0.0)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ray
|
||||||
|
|
||||||
|
A half-line with an `origin` and a **normalized** `direction`. Because the
|
||||||
|
direction is unit-length, the parameter `t` in `at(t)` is a true distance.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::prelude::*;
|
||||||
|
|
||||||
|
let r = Ray::new(Vec3::ZERO, Vec3::new(0.0, 5.0, 0.0)); // direction normalized to +Y
|
||||||
|
let r2 = Ray::from_to(Vec3::ZERO, Vec3::new(0.0, 0.0, 10.0));
|
||||||
|
|
||||||
|
let p = r.at(4.0); // origin + direction * 4
|
||||||
|
let valid = r.is_valid(); // false if direction was zero-length
|
||||||
|
let near = r.closest_point(target); // clamped to t >= 0
|
||||||
|
let dist = r.distance_to_point(target);
|
||||||
|
```
|
||||||
|
|
||||||
|
If you pass a zero-length direction, the ray is left degenerate; check
|
||||||
|
`is_valid()` before relying on it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Plane
|
||||||
|
|
||||||
|
An infinite plane in Hessian normal form: a unit `normal` and a signed distance
|
||||||
|
`d`, such that every point on the plane satisfies `normal·p + d = 0`. The
|
||||||
|
positive half-space is the side the normal points toward.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::prelude::*;
|
||||||
|
|
||||||
|
let p1 = Plane::from_point_normal(Vec3::new(0.0, 2.0, 0.0), Vec3::Y);
|
||||||
|
let p2 = Plane::from_points(Vec3::ZERO, Vec3::X, Vec3::Y); // normal via right-hand rule → +Z
|
||||||
|
let p3 = Plane::new(Vec3::new(0.0, 0.0, 4.0), 8.0); // normalized on construction
|
||||||
|
|
||||||
|
let sd = p1.signed_distance(Vec3::new(0.0, 5.0, 0.0)); // +3.0 (in front)
|
||||||
|
let proj = p1.project_point(Vec3::new(3.0, 7.0, -2.0)); // orthogonal projection onto plane
|
||||||
|
let flipped = p1.flipped(); // same plane, reversed normal
|
||||||
|
|
||||||
|
// Ray test: None if parallel or pointing away.
|
||||||
|
let ray = Ray::new(Vec3::new(0.0, 5.0, 0.0), Vec3::NEG_Y);
|
||||||
|
if let Some(t) = p1.ray_intersection(&ray) {
|
||||||
|
let hit = ray.at(t);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frustum
|
||||||
|
|
||||||
|
A view volume represented by six planes (left, right, bottom, top, near, far),
|
||||||
|
each with its normal pointing **inward**. A point is inside when it lies in the
|
||||||
|
positive half-space of every plane. Built from a combined view-projection matrix
|
||||||
|
via the Gribb–Hartmann method; works for perspective and orthographic
|
||||||
|
projections alike.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::prelude::*;
|
||||||
|
|
||||||
|
let proj = Mat4::perspective_rh(60_f32.to_radians(), 16.0 / 9.0, 0.1, 100.0);
|
||||||
|
let view = Mat4::look_at_rh(Vec3::new(0.0, 0.0, 5.0), Vec3::ZERO, Vec3::Y);
|
||||||
|
let frustum = Frustum::from_view_projection(proj * view);
|
||||||
|
|
||||||
|
let visible_point = frustum.contains_point(Vec3::ZERO);
|
||||||
|
|
||||||
|
let bb = Aabb::from_center_half_extents(Vec3::ZERO, Vec3::splat(1.0));
|
||||||
|
let visible_box = frustum.intersects_aabb(&bb);
|
||||||
|
|
||||||
|
let visible_sphere = frustum.intersects_sphere(Vec3::ZERO, 1.0);
|
||||||
|
```
|
||||||
|
|
||||||
|
`intersects_aabb` is **conservative**: it never culls a box that is actually
|
||||||
|
visible, though it may very rarely keep one that is just outside a corner. That
|
||||||
|
is the correct trade-off for rendering, where false positives cost a wasted draw
|
||||||
|
but false negatives cause visible pop-out.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Color
|
||||||
|
|
||||||
|
Linear RGBA color with `f32` channels. The engine works in **linear** space;
|
||||||
|
conversions to/from 8-bit sRGB are explicit. Values may exceed `1.0` to represent
|
||||||
|
HDR/emissive intensity and are not clamped in storage.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::prelude::*;
|
||||||
|
|
||||||
|
let white = Color::WHITE;
|
||||||
|
let custom = Color::rgba(0.2, 0.4, 0.6, 1.0);
|
||||||
|
let from_pdf = Color::from_srgb_u8(135, 206, 235); // sRGB bytes → linear
|
||||||
|
let from_hex = Color::from_hex(0x87CEEB); // #87CEEB → linear
|
||||||
|
|
||||||
|
let bytes = custom.to_srgb_u8(); // [u8; 4] sRGB, clamped to [0,1]
|
||||||
|
let v4 = custom.to_vec4(); // Vec4 [r,g,b,a]
|
||||||
|
let v3 = custom.to_vec3(); // Vec3 rgb
|
||||||
|
let faded = custom.with_alpha(0.5);
|
||||||
|
let mid = Color::BLACK.lerp(Color::WHITE, 0.5); // t clamped to [0,1]
|
||||||
|
```
|
||||||
|
|
||||||
|
Constants: `BLACK`, `WHITE`, `RED`, `GREEN`, `BLUE`, `TRANSPARENT`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rect
|
||||||
|
|
||||||
|
A 2D axis-aligned rectangle for UI, viewports, and texture regions.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::prelude::*;
|
||||||
|
|
||||||
|
let r1 = Rect::new(Vec2::new(4.0, 1.0), Vec2::new(0.0, 5.0)); // corners auto-sorted
|
||||||
|
let r2 = Rect::from_min_size(Vec2::ZERO, Vec2::new(3.0, 4.0));
|
||||||
|
let r3 = Rect::from_center_size(Vec2::ZERO, Vec2::splat(2.0));
|
||||||
|
|
||||||
|
let (w, h) = (r2.width(), r2.height());
|
||||||
|
let size = r2.size();
|
||||||
|
let area = r2.area();
|
||||||
|
let c = r2.center();
|
||||||
|
let empty = Rect::ZERO.is_empty();
|
||||||
|
|
||||||
|
let inside = r2.contains_point(Vec2::ONE);
|
||||||
|
let hit = r2.intersects(&r3);
|
||||||
|
let i = r2.intersection(&r3); // Rect::ZERO if disjoint
|
||||||
|
let u = r2.union(&r3);
|
||||||
|
let near = r2.closest_point(Vec2::new(5.0, -1.0));
|
||||||
|
let grown = r2.expanded(1.0); // negative shrinks
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Range3
|
||||||
|
|
||||||
|
A 3D **value** range — an inclusive `[min, max]` interval per axis. Unlike
|
||||||
|
`Aabb` (which models geometry), `Range3` models a *value range* for clamping,
|
||||||
|
interpolation, and remapping. It deliberately provides `lerp`/`inverse_lerp`/
|
||||||
|
`remap`, which an `Aabb` does not.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::prelude::*;
|
||||||
|
|
||||||
|
let r = Range3::new(Vec3::ZERO, Vec3::splat(100.0));
|
||||||
|
let unit = Range3::UNIT; // [0, 1] per axis
|
||||||
|
let sym = Range3::symmetric(Vec3::splat(2.0)); // [-2, 2] per axis
|
||||||
|
|
||||||
|
let span = r.span();
|
||||||
|
let center = r.center();
|
||||||
|
let clamped = r.clamp(Vec3::new(-5.0, 50.0, 200.0)); // → (0, 50, 100)
|
||||||
|
let inside = r.contains(Vec3::splat(50.0));
|
||||||
|
|
||||||
|
let v = r.lerp(Vec3::splat(0.25)); // NOT clamped — extrapolates outside [0,1]
|
||||||
|
let t = r.inverse_lerp(Vec3::splat(25.0)); // → 0.25 per axis (0.0 on zero-span axes)
|
||||||
|
let remapped = r.remap(Vec3::splat(50.0), &unit); // 50 in [0,100] → 0.5 in [0,1]
|
||||||
|
```
|
||||||
|
|
||||||
|
`inverse_lerp` guards against division by zero: an axis with zero span yields
|
||||||
|
`0.0` rather than `NaN`/`inf`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing and performance
|
||||||
|
|
||||||
|
- **Unit tests** for every type live in that type's source file
|
||||||
|
(`engine/src/math/*.rs`), covering core behavior and edge cases (zero scale,
|
||||||
|
gimbal lock, degenerate rays/planes, empty boxes, zero-span ranges).
|
||||||
|
- **Integration / fuzz tests** live in the `oxide-tests` crate
|
||||||
|
(`stage1::fuzz_transform_chains_stay_stable` builds long random transform
|
||||||
|
chains and verifies stability and inverse round-trips).
|
||||||
|
- **Benchmark:** `cargo bench -p oxide-engine` runs the `transform` benchmark.
|
||||||
|
The Stage 1 budget — 1,000,000 transform compositions in under 10 ms — is met
|
||||||
|
with margin (~5 ms on a typical desktop).
|
||||||
|
|
||||||
|
## Runnable example
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo run -p oxide-examples --bin math_demo
|
||||||
|
```
|
||||||
|
|
||||||
|
`examples/src/bin/math_demo.rs` exercises every type above and prints the
|
||||||
|
results — a good place to see the API in use end to end.
|
||||||
+160
@@ -0,0 +1,160 @@
|
|||||||
|
# App, Modules & Scheduling
|
||||||
|
|
||||||
|
`oxide_engine::app` is where the Stage-5 core framework comes together. An
|
||||||
|
[`App`] owns the shared engine state and a [`Schedule`] of systems; functionality
|
||||||
|
is added by **modules**. This is the spine the rest of the engine plugs into: the
|
||||||
|
engine is *composed* rather than hard-wired, and an exported game compiles in
|
||||||
|
only the modules it registers.
|
||||||
|
|
||||||
|
## The App
|
||||||
|
|
||||||
|
An `App` owns:
|
||||||
|
|
||||||
|
- the active [`Scene`](scene.md),
|
||||||
|
- the shared [`AssetServer`](assets.md),
|
||||||
|
- the [`TypeRegistry`](reflection.md) (dual-editable components),
|
||||||
|
- the project's [`LayerRegistry`](layers.md),
|
||||||
|
- frame [`Time`], and
|
||||||
|
- arbitrary user **resources** (a type-keyed store).
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::app::{App, DefaultModules};
|
||||||
|
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_modules(DefaultModules);
|
||||||
|
app.update(1.0 / 60.0); // advance one frame
|
||||||
|
```
|
||||||
|
|
||||||
|
> Note: the application core is `oxide_engine::app::App`. It is intentionally
|
||||||
|
> *not* in the prelude, to avoid clashing with the windowing
|
||||||
|
> [`App`](windowing.md) trait (the per-window event handler). Import it directly.
|
||||||
|
|
||||||
|
### Resources
|
||||||
|
|
||||||
|
Resources are shared singletons addressed by type — the home for state that
|
||||||
|
isn't per-entity (an input map, a physics world, game settings):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
# use oxide_engine::app::App;
|
||||||
|
# let mut app = App::new();
|
||||||
|
app.insert_resource(0u32);
|
||||||
|
*app.get_resource_mut::<u32>().unwrap() += 1;
|
||||||
|
assert_eq!(app.get_resource::<u32>(), Some(&1));
|
||||||
|
```
|
||||||
|
|
||||||
|
## Systems and the schedule
|
||||||
|
|
||||||
|
A **system** is any `FnMut(&mut App)` attached to a [`Schedule`] phase. Phases run
|
||||||
|
in a fixed order each frame; within a phase, systems run in registration order,
|
||||||
|
so behavior is fully deterministic.
|
||||||
|
|
||||||
|
| Phase | Purpose |
|
||||||
|
|-------|---------|
|
||||||
|
| `First` | start-of-frame bookkeeping |
|
||||||
|
| `Input` | gather input (Stage 7) |
|
||||||
|
| `PreUpdate` | engine work before game logic |
|
||||||
|
| `FixedUpdate` | fixed-timestep work; runs **0..n** times per frame (physics, Stage 9) |
|
||||||
|
| `Update` | per-frame game logic |
|
||||||
|
| `PostUpdate` | engine work after game logic |
|
||||||
|
| `Render` | drawing (Stage 5 pipeline onward) |
|
||||||
|
| `Last` | end-of-frame cleanup |
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::app::{App, Schedule};
|
||||||
|
use oxide_engine::prelude::*;
|
||||||
|
|
||||||
|
let mut app = App::new();
|
||||||
|
app.scene.spawn("spinner", Transform::IDENTITY);
|
||||||
|
app.add_system(Schedule::Update, |app| {
|
||||||
|
let dt = app.time.delta;
|
||||||
|
for e in app.scene.entities().collect::<Vec<_>>() {
|
||||||
|
if let Some(mut t) = app.scene.get_mut::<Transform>(e) {
|
||||||
|
t.translation.x += dt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Systems get exclusive `&mut App` while running (the schedule is moved out of the
|
||||||
|
app for the duration), so a system can freely read and mutate the scene,
|
||||||
|
resources, and assets.
|
||||||
|
|
||||||
|
### Fixed timestep
|
||||||
|
|
||||||
|
`FixedUpdate` is driven by an accumulator so simulation is frame-rate
|
||||||
|
independent: each `update(dt)` runs as many whole `fixed_delta` steps as the
|
||||||
|
accumulated time allows. The number of steps per frame is **capped** so a long
|
||||||
|
stall (a breakpoint, a hitch) cannot trigger an unbounded catch-up "spiral of
|
||||||
|
death". Set the rate with `app.set_fixed_timestep(seconds)` (default 1/60).
|
||||||
|
|
||||||
|
The whole frame's scheduling overhead is a few dozen nanoseconds even with
|
||||||
|
several systems registered (see `cargo bench -p oxide-engine --bench app`), so it
|
||||||
|
is lost in the noise next to any real per-frame work.
|
||||||
|
|
||||||
|
## Modules
|
||||||
|
|
||||||
|
A [`Module`] is the unit of engine extension. Its [`build`](Module::build) method
|
||||||
|
registers systems, component types, asset loaders, and resources through the
|
||||||
|
`App` facade. Everything registered during `build` is **attributed to the
|
||||||
|
module**, so it can be enabled, disabled, or removed as one unit.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::app::{App, Module, Schedule};
|
||||||
|
|
||||||
|
struct HeartbeatModule;
|
||||||
|
impl Module for HeartbeatModule {
|
||||||
|
fn name(&self) -> &'static str { "heartbeat" }
|
||||||
|
fn build(&self, app: &mut App) {
|
||||||
|
app.insert_resource(0u64);
|
||||||
|
app.add_system(Schedule::Update, |app| {
|
||||||
|
*app.get_resource_mut::<u64>().unwrap() += 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_module(HeartbeatModule);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Enable / disable / remove
|
||||||
|
|
||||||
|
These power the editor's module management (Stage 6) and the "ship only what you
|
||||||
|
use" principle:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
# use oxide_engine::app::{App, Module, Schedule};
|
||||||
|
# struct HeartbeatModule;
|
||||||
|
# impl Module for HeartbeatModule {
|
||||||
|
# fn name(&self) -> &'static str { "heartbeat" }
|
||||||
|
# fn build(&self, app: &mut App) {}
|
||||||
|
# }
|
||||||
|
# let mut app = App::new();
|
||||||
|
# app.add_module(HeartbeatModule);
|
||||||
|
app.set_module_enabled("heartbeat", false); // systems skipped, nothing removed
|
||||||
|
app.set_module_enabled("heartbeat", true); // resumes
|
||||||
|
app.remove_module("heartbeat"); // systems, types, loaders, resources gone
|
||||||
|
```
|
||||||
|
|
||||||
|
`remove_module` undoes every registration the module made — its systems,
|
||||||
|
reflected types, asset loaders, and resources — leaving no dangling references.
|
||||||
|
Disabling is the cheap, reversible version (systems are skipped but kept).
|
||||||
|
|
||||||
|
### Built-in modules
|
||||||
|
|
||||||
|
`DefaultModules` bundles the engine's standard set:
|
||||||
|
|
||||||
|
- **`core`** — registers the always-present scene component types (`Transform`,
|
||||||
|
`Node`, `Layer`, `Tags`) for reflection, exposing them to the editor and
|
||||||
|
scripts.
|
||||||
|
- **`render`** — registers the renderable `MeshRenderer` component and (as the
|
||||||
|
data-driven render pipeline grows) the render-phase systems.
|
||||||
|
|
||||||
|
Subsystems from Stage 9 on (physics, audio, …) are built as their own
|
||||||
|
feature-gated crates, each exposing a `Module`, so a project pays for them only
|
||||||
|
by registering them.
|
||||||
|
|
||||||
|
[`App`]: ../engine/src/app/mod.rs
|
||||||
|
[`Time`]: ../engine/src/app/mod.rs
|
||||||
|
[`Schedule`]: ../engine/src/app/schedule.rs
|
||||||
|
[`Module`]: ../engine/src/app/module.rs
|
||||||
|
[`Scene`]: ../engine/src/scene/graph.rs
|
||||||
+299
@@ -0,0 +1,299 @@
|
|||||||
|
# Physics (`oxide-physics`) — Stage 9
|
||||||
|
|
||||||
|
Oxide's physics is a **feature-gated module** (`oxide-physics`) built on
|
||||||
|
[`rapier3d`](https://rapier.rs), added to an app through the Stage-5 module
|
||||||
|
system. It is a comprehensive rigid-body system — collision, scene queries,
|
||||||
|
joints, and a kinematic character controller — not a thin wrapper. This document
|
||||||
|
grows piece by piece as Stage 9 lands; it currently covers the **data model and
|
||||||
|
module wiring** (piece 1).
|
||||||
|
|
||||||
|
## Design: the ECS is the source of truth
|
||||||
|
|
||||||
|
A physics object is described by two plain, serializable, reflected components on
|
||||||
|
a scene entity:
|
||||||
|
|
||||||
|
- [`RigidBody`](#rigidbody) — *how* it moves (or that it doesn't).
|
||||||
|
- [`Collider`](#collider) — *what shape* it is, its material, and the
|
||||||
|
[`LayerMask`](layers.md) filtering of what it collides with.
|
||||||
|
|
||||||
|
The rapier simulation world is a **transient resource rebuilt from these
|
||||||
|
components**, never the authoritative store. That has a deliberate payoff: the
|
||||||
|
Stage-8.7 [play-mode snapshot](play-mode.md) captures these components like any
|
||||||
|
other, so **Play** runs the simulation, **Stop** reverts the authored components,
|
||||||
|
and the next **Play** rebuilds the rapier world fresh — with no special-casing.
|
||||||
|
The [`Transform`](scene.md) is the authoritative pose; the simulation writes it
|
||||||
|
back each fixed step (a later piece).
|
||||||
|
|
||||||
|
## Adding physics to an app
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::app::{App, DefaultModules};
|
||||||
|
use oxide_physics::PhysicsModule;
|
||||||
|
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_modules(DefaultModules);
|
||||||
|
app.add_module(PhysicsModule); // registers RigidBody/Collider + PhysicsSettings
|
||||||
|
```
|
||||||
|
|
||||||
|
`PhysicsModule` registers the component types for reflection (so they are
|
||||||
|
dual-editable from the inspector and from scripts/RON, and captured by the play
|
||||||
|
snapshot) and inserts a [`PhysicsSettings`] resource holding the global gravity
|
||||||
|
vector. Like every module it can be enabled, disabled, or removed as a unit, so a
|
||||||
|
game that never uses physics never compiles it in.
|
||||||
|
|
||||||
|
## RigidBody
|
||||||
|
|
||||||
|
The dynamics half of a physics object — attach alongside a `Collider`.
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
|-------|---------|
|
||||||
|
| `kind` | `Dynamic` (simulated), `Kinematic` (game-moved, unaffected by forces), or `Static` (immovable world geometry) |
|
||||||
|
| `mass` | Mass in kg; `0` derives it from the collider's `density` |
|
||||||
|
| `linear_damping` / `angular_damping` | Velocity drag (`0` = none) |
|
||||||
|
| `gravity_scale` | Per-body gravity multiplier (`1` normal, `0` floats) |
|
||||||
|
| `ccd` | Continuous collision detection for fast bodies (off by default) |
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_physics::{RigidBody, RigidBodyKind};
|
||||||
|
|
||||||
|
let dynamic = RigidBody::default(); // a fully-simulated body
|
||||||
|
let floor = RigidBody::static_body(); // immovable
|
||||||
|
let platform = RigidBody::kinematic(); // moved by the game
|
||||||
|
```
|
||||||
|
|
||||||
|
## Collider
|
||||||
|
|
||||||
|
The shape + material half — attach on its own for static geometry, or with a
|
||||||
|
`RigidBody` for a moving body. The shape is a flat selector plus dimension fields
|
||||||
|
(mirroring `MeshRenderer`'s `PrimitiveShape`), so the inspector renders a clean
|
||||||
|
combo + drag-values:
|
||||||
|
|
||||||
|
| `shape` | Dimensions used |
|
||||||
|
|---------|-----------------|
|
||||||
|
| `Box` | `half_extents` (per-axis half sizes) |
|
||||||
|
| `Sphere` | `radius` |
|
||||||
|
| `Capsule` | `radius` + `half_height` (axis = local `+Y`) |
|
||||||
|
| `Cylinder` | `radius` + `half_height` (axis = local `+Y`) |
|
||||||
|
|
||||||
|
Material/filter fields: `friction`, `restitution`, `density`, `sensor` (a trigger
|
||||||
|
that reports overlap without resolving contact), and the `membership` / `filter`
|
||||||
|
[`LayerMask`](layers.md)s. Two colliders interact only when each one's
|
||||||
|
`membership` intersects the other's `filter`, so collision groups, triggers, and
|
||||||
|
(later) scene queries all use the engine's one shared filtering primitive.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::math::Vec3;
|
||||||
|
use oxide_engine::layer::LayerMask;
|
||||||
|
use oxide_physics::Collider;
|
||||||
|
|
||||||
|
let ground = Collider::cuboid(Vec3::new(10.0, 0.5, 10.0));
|
||||||
|
let ball = Collider::ball(0.5);
|
||||||
|
let trigger = Collider::cuboid(Vec3::splat(1.0)).as_sensor()
|
||||||
|
.with_layers(LayerMask::layer(0), LayerMask::layer(1)); // only fires for layer 1
|
||||||
|
```
|
||||||
|
|
||||||
|
Convex-hull and triangle-mesh colliders (which need mesh data) are a later
|
||||||
|
Stage-9 piece.
|
||||||
|
|
||||||
|
## Simulation
|
||||||
|
|
||||||
|
`PhysicsModule` installs a [`PhysicsWorld`] resource — the rapier simulation plus
|
||||||
|
an entity ↔ body map — and a `FixedUpdate` system that, each fixed step:
|
||||||
|
|
||||||
|
1. **syncs** the rapier world to the scene (inserts a body+collider for each new
|
||||||
|
physics entity, removes bodies for despawned ones, pushes kinematic targets),
|
||||||
|
2. **steps** rapier by one [`fixed_delta`](modules.md), then
|
||||||
|
3. **writes back** each moved body's pose onto its entity's `Transform`.
|
||||||
|
|
||||||
|
So physics runs whenever the app advances its fixed timestep — including the
|
||||||
|
editor's **Play** mode, which is the first real consumer (Play drops a body,
|
||||||
|
Stop reverts it). Nothing extra is wired: the play snapshot already captures the
|
||||||
|
components.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
# use oxide_engine::app::{App, DefaultModules};
|
||||||
|
# use oxide_engine::math::{Transform, Vec3};
|
||||||
|
# use oxide_physics::{PhysicsModule, RigidBody, Collider};
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_modules(DefaultModules);
|
||||||
|
app.add_module(PhysicsModule);
|
||||||
|
|
||||||
|
let ball = app.scene.spawn("ball", Transform::from_translation(Vec3::new(0.0, 5.0, 0.0)));
|
||||||
|
app.scene.world_mut().insert_one(ball, RigidBody::default()).unwrap();
|
||||||
|
app.scene.world_mut().insert_one(ball, Collider::ball(0.5)).unwrap();
|
||||||
|
|
||||||
|
for _ in 0..120 { app.step(); } // one fixed tick each
|
||||||
|
// the ball has fallen; its Transform.translation.y is now lower
|
||||||
|
```
|
||||||
|
|
||||||
|
### Forces & control
|
||||||
|
|
||||||
|
[`PhysicsWorld`] exposes by-entity control so game code never touches rapier
|
||||||
|
handles: `set_linear_velocity` / `linear_velocity`, `apply_impulse`,
|
||||||
|
`apply_force`, `apply_torque_impulse`, and `wake`. Reach the resource with
|
||||||
|
`app.get_resource_mut::<PhysicsWorld>()`.
|
||||||
|
|
||||||
|
### Collision & trigger events
|
||||||
|
|
||||||
|
When two colliders start or stop touching, the step records a `CollisionEvent`
|
||||||
|
`{ a, b, started, sensor }`. `started` distinguishes **enter** (`true`) from
|
||||||
|
**exit** (`false`); `sensor` distinguishes a **trigger** overlap (one collider is
|
||||||
|
a sensor — nothing was resolved, things pass through) from a **solid contact**.
|
||||||
|
Events accumulate across every fixed sub-step and are cleared at the first step
|
||||||
|
of each frame, so a system reading after `Update` sees them all:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
# use oxide_physics::PhysicsWorld;
|
||||||
|
# fn read(app: &oxide_engine::app::App, player: oxide_engine::scene::Entity) {
|
||||||
|
let physics = app.get_resource::<PhysicsWorld>().unwrap();
|
||||||
|
for ev in physics.trigger_events() {
|
||||||
|
if let Some(other) = ev.other(player) {
|
||||||
|
if ev.started { /* player entered a trigger zone */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
# }
|
||||||
|
```
|
||||||
|
|
||||||
|
"Stay" (ongoing overlap) is not an event — query the current state with
|
||||||
|
`is_intersecting(a, b)` or `intersecting_pairs()`. Collision filtering uses the
|
||||||
|
collider `membership`/`filter` `LayerMask`s: two colliders interact only when
|
||||||
|
each one's membership intersects the other's filter, so a trigger can be made to
|
||||||
|
fire only for, say, the Player layer.
|
||||||
|
|
||||||
|
### Scene queries
|
||||||
|
|
||||||
|
[`PhysicsWorld`] answers spatial questions against the simulated colliders, all
|
||||||
|
filtered by a `LayerMask` (pass `LayerMask::ALL` to hit anything):
|
||||||
|
|
||||||
|
| Query | Returns |
|
||||||
|
|-------|---------|
|
||||||
|
| `raycast(origin, dir, max_distance, mask)` | first `RayHit { entity, toi, point, normal }` |
|
||||||
|
| `sphere_cast(origin, radius, dir, max_distance, mask)` | first hit of a swept sphere (a "thick raycast") |
|
||||||
|
| `overlap_sphere(center, radius, mask)` | every entity whose collider overlaps the sphere |
|
||||||
|
| `point_overlap(point, mask)` | every entity whose collider contains the point |
|
||||||
|
|
||||||
|
```rust
|
||||||
|
# use oxide_engine::math::Vec3;
|
||||||
|
# use oxide_engine::layer::LayerMask;
|
||||||
|
# use oxide_physics::PhysicsWorld;
|
||||||
|
# fn pick(physics: &PhysicsWorld) {
|
||||||
|
if let Some(hit) = physics.raycast(Vec3::new(0.0, 5.0, 0.0), Vec3::NEG_Y, 100.0, LayerMask::ALL) {
|
||||||
|
// hit.entity / hit.point / hit.normal
|
||||||
|
}
|
||||||
|
# }
|
||||||
|
```
|
||||||
|
|
||||||
|
During simulation the step keeps the query world in sync. To query the **edited**
|
||||||
|
scene outside Play (e.g. the editor's raycast probe), build a transient world and
|
||||||
|
sync it once: `sync_to_scene(scene)` rebuilds the rapier world from the scene's
|
||||||
|
`Collider`/`RigidBody` components and refreshes the query pipeline **without
|
||||||
|
stepping**, so a following `raycast`/`overlap_*` reflects the current colliders.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
# use oxide_engine::math::Vec3;
|
||||||
|
# use oxide_engine::layer::LayerMask;
|
||||||
|
# use oxide_engine::scene::Scene;
|
||||||
|
# use oxide_physics::PhysicsWorld;
|
||||||
|
# fn probe(scene: &Scene) {
|
||||||
|
let mut world = PhysicsWorld::new();
|
||||||
|
world.sync_to_scene(scene); // query-able, no Play / no step
|
||||||
|
let _ = world.raycast(Vec3::new(0.0, 5.0, 0.0), Vec3::NEG_Y, 100.0, LayerMask::ALL);
|
||||||
|
# }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Joints / constraints
|
||||||
|
|
||||||
|
`add_joint(a, b, kind, anchor_a, anchor_b)` connects two entities' bodies and
|
||||||
|
returns a `JointId` (`remove_joint` undoes it). Both bodies must already be in
|
||||||
|
the simulation (true after the first step in which their components exist). The
|
||||||
|
`JointKind`s and the DOF they leave:
|
||||||
|
|
||||||
|
| Kind | Constraint |
|
||||||
|
|------|-----------|
|
||||||
|
| `Fixed` | a rigid weld — zero relative DOF |
|
||||||
|
| `Spherical` | ball-and-socket — anchors stay coincident, free rotation (3 rot. DOF) |
|
||||||
|
| `Revolute { axis }` | hinge — 1 rotational DOF about `axis` |
|
||||||
|
| `Prismatic { axis }` | slider — 1 translational DOF along `axis` |
|
||||||
|
|
||||||
|
Joints live in the `PhysicsWorld` (not the ECS), so — unlike components — they
|
||||||
|
are not captured by the play snapshot; game/setup code recreates them on each
|
||||||
|
Play. Editor-authored joint *components* await a serializable entity-reference
|
||||||
|
type (backlog).
|
||||||
|
|
||||||
|
### Character controller
|
||||||
|
|
||||||
|
A `CharacterController` component is a **kinematic capsule** — it never reacts to
|
||||||
|
forces. The game asks it to move and the controller resolves that against the
|
||||||
|
world (move-and-slide along walls, auto-step small ledges, snap to ground on
|
||||||
|
slopes, refuse slopes steeper than `max_slope_degrees`), reporting whether the
|
||||||
|
character ended up grounded. The entity carries only a `CharacterController` (no
|
||||||
|
`RigidBody`/`Collider`), so it is never simulated and never self-collides.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
# use oxide_engine::math::Vec3;
|
||||||
|
# use oxide_physics::PhysicsWorld;
|
||||||
|
# fn tick(app: &mut oxide_engine::app::App, player: oxide_engine::scene::Entity, dt: f32) {
|
||||||
|
let desired = Vec3::new(input_x, -9.81 * dt, input_z); // move + gravity
|
||||||
|
let movement = {
|
||||||
|
let physics = app.get_resource::<PhysicsWorld>().unwrap();
|
||||||
|
physics.move_character(&app.scene, player, desired, dt).unwrap()
|
||||||
|
};
|
||||||
|
let mut t = app.scene.local_transform(player).unwrap();
|
||||||
|
t.translation += movement.translation; // apply the resolved move
|
||||||
|
app.scene.set_local_transform(player, t);
|
||||||
|
// movement.grounded → e.g. allow jump
|
||||||
|
# let (input_x, input_z) = (0.0, 0.0);
|
||||||
|
# }
|
||||||
|
```
|
||||||
|
|
||||||
|
Tune the capsule (`radius`, `half_height`) and the rules (`max_slope_degrees`,
|
||||||
|
`step_offset`, `snap_to_ground`, `skin_width`) on the component. This is the
|
||||||
|
basis reused by the Stage-15 prototyping kit's character.
|
||||||
|
|
||||||
|
### Limitations (current)
|
||||||
|
|
||||||
|
`Transform::scale` is not yet applied to collider dimensions (author the shape at
|
||||||
|
its true size), and bodies are simulated in world space — keep physics bodies at
|
||||||
|
the scene root (or under unscaled parents) for now. Both are lifted in later
|
||||||
|
pieces.
|
||||||
|
|
||||||
|
## Roadmap (Stage 9 pieces)
|
||||||
|
|
||||||
|
1. **Component data model + module wiring** — `RigidBody`, `Collider`,
|
||||||
|
`PhysicsModule`, `PhysicsSettings`. ✅
|
||||||
|
2. **Rapier-backed simulation** — build the world from components, step on
|
||||||
|
`FixedUpdate`, write transforms back, by-entity forces/velocities. ✅
|
||||||
|
3. **Collision groups/masks via `LayerMask`, sensors, collision/trigger
|
||||||
|
events** (enter/exit + stay queries). ✅
|
||||||
|
4. **Scene queries** — raycast, sphere-cast, sphere/point overlap, all
|
||||||
|
`LayerMask`-filtered. ✅
|
||||||
|
5. **Joints/constraints** — fixed, spherical, revolute, prismatic (programmatic
|
||||||
|
API). ✅
|
||||||
|
6. **Kinematic character controller** — capsule move-and-slide, step offset,
|
||||||
|
slope limit, grounded. ✅
|
||||||
|
7. **Examples** — `physics_stack` (boxes settle + ball lands) and
|
||||||
|
`character_capsule` (walk/climb/jump/wall), headless console demos. ✅ (this
|
||||||
|
piece)
|
||||||
|
8. Editor: **(8a)** RigidBody/Collider/CharacterController are addable and
|
||||||
|
editable in the inspector (no per-type code), and `PhysicsModule` runs in
|
||||||
|
**Play** — drop a body, watch it fall, Stop reverts it (on `dev`, awaiting
|
||||||
|
eye-check). **(8b)** collider shape wireframe gizmos in the viewport — box,
|
||||||
|
sphere, capsule, and cylinder outlines, **green** for solid colliders and
|
||||||
|
**amber** for sensors (triggers), with the selected entity drawn thicker.
|
||||||
|
The outline mirrors the simulation, which ignores `Transform::scale`, so it
|
||||||
|
shows the exact shape rapier builds (translation + rotation only). Toggle it
|
||||||
|
from **View ▸ Show Colliders** (on by default). **(8c)** raycast debug viz —
|
||||||
|
a **View ▸ Raycast Probe** toggle (off by default, a debug aid to verify the
|
||||||
|
raycast API and inspect colliders): with it on, a viewport **click** casts the
|
||||||
|
editor camera→cursor ray against the edited scene's colliders (via
|
||||||
|
[`PhysicsWorld::sync_to_scene`](#scene-queries), so it needs no Play) and
|
||||||
|
**freezes** the ray into the world. The viewport then redraws it every frame —
|
||||||
|
the ray in **cyan**, plus on a hit a **magenta** dot at the surface point and
|
||||||
|
a short whisker along the surface normal — so **orbiting the camera reveals it
|
||||||
|
as a real 3D line** (a ray cast from the live camera is otherwise just a point
|
||||||
|
in that same camera's view). ✅
|
||||||
|
|
||||||
|
**Stage 9 is complete** — all pieces are on `main`.
|
||||||
|
|
||||||
|
[`PhysicsSettings`]: #adding-physics-to-an-app
|
||||||
|
[`PhysicsWorld`]: #simulation
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
# Editor Play Mode (Stage 8.7)
|
||||||
|
|
||||||
|
Play mode lets the editor **run the open scene in place** — play, pause, single-
|
||||||
|
step, and stop — driving the same fixed-timestep
|
||||||
|
[`Schedule`](modules.md) a shipped game uses, so what you see while playing
|
||||||
|
behaves like the real runtime. Mutations made while playing (physics moving
|
||||||
|
bodies, scripts spawning entities) are reverted on **Stop**, so the authored
|
||||||
|
scene is never corrupted.
|
||||||
|
|
||||||
|
This page covers the play-state model, the snapshot/restore that makes Stop
|
||||||
|
safe, and how the host runner drives the engine. The standalone **"Launch"**
|
||||||
|
button (running the *real* exported runtime in a separate process) is a Stage 16
|
||||||
|
follow-up and is **not** part of play mode.
|
||||||
|
|
||||||
|
## The play states
|
||||||
|
|
||||||
|
`oxide_editor::state::PlayState` is a three-state machine held on `EditorState`:
|
||||||
|
|
||||||
|
| State | Meaning | Schedule ticked? |
|
||||||
|
|-------|---------|------------------|
|
||||||
|
| `Editing` | Normal authoring | no |
|
||||||
|
| `Playing` | Running | every frame (`App::update`) |
|
||||||
|
| `Paused` | Frozen, still live | only on **Step** (one fixed tick) |
|
||||||
|
|
||||||
|
Transitions are methods on `EditorState`:
|
||||||
|
|
||||||
|
- `enter_play()` — snapshots the scene and switches to `Playing`. No-op if
|
||||||
|
already running (re-entering must not clobber the original snapshot).
|
||||||
|
- `toggle_pause()` — `Playing` ⇄ `Paused`; no-op while `Editing`.
|
||||||
|
- `stop()` — restores the snapshot, clears the selection and any in-flight gizmo
|
||||||
|
drag (entity handles change on restore), and returns to `Editing`.
|
||||||
|
|
||||||
|
The shell wraps these with status messages and undo-history clearing (see
|
||||||
|
[Controls](#controls)); `is_in_play()` is the "running or paused" predicate the
|
||||||
|
host uses to gate the runtime.
|
||||||
|
|
||||||
|
## Snapshot / restore
|
||||||
|
|
||||||
|
Pressing Play captures a [`SceneSnapshot`](scene.md) of the current scene;
|
||||||
|
pressing Stop restores it. Unlike `Scene::to_ron` (which records only the node-
|
||||||
|
baked `Node`/`Transform`/hierarchy), a snapshot is **registry-aware**: it also
|
||||||
|
serializes every reflected component on each entity, plus the engine-intrinsic
|
||||||
|
non-reflected components (`Tags` and `DisabledComponents`). That makes Stop a
|
||||||
|
**bit-for-bit** revert across the full component set, not just transforms.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::scene::SceneSnapshot;
|
||||||
|
|
||||||
|
let snap = scene.snapshot(®istry); // capture
|
||||||
|
// … play-mode mutations …
|
||||||
|
let scene = snap.restore(®istry)?; // revert (fresh entity handles)
|
||||||
|
```
|
||||||
|
|
||||||
|
Fidelity is bounded by what the [`TypeRegistry`](reflection.md) knows: a
|
||||||
|
component that is neither registered nor one of the two intrinsics is invisible
|
||||||
|
to capture. Modules register their components anyway (that is what makes them
|
||||||
|
editable), so they survive play mode automatically.
|
||||||
|
|
||||||
|
`SceneSnapshot` also exposes `to_ron`/`from_ron`, so the same capture format
|
||||||
|
seeds full scene files in a later stage.
|
||||||
|
|
||||||
|
## Driving the schedule
|
||||||
|
|
||||||
|
The editor holds the live scene in `EditorState.scene`, not in an
|
||||||
|
[`App`](modules.md). On Play the host runner (`oxide_editor::main`) builds a play
|
||||||
|
`App` (currently just `DefaultModules`; Stage 9's physics module and the
|
||||||
|
project's modules will register here too) and, each frame:
|
||||||
|
|
||||||
|
1. **swaps** `state.scene` into `app.scene` (an O(1) move),
|
||||||
|
2. advances the engine, then
|
||||||
|
3. **swaps** the scene back out.
|
||||||
|
|
||||||
|
So the `App` only "holds" the editor scene for the duration of a tick, and
|
||||||
|
`state.scene` stays the single source of truth the inspector, hierarchy, and
|
||||||
|
viewport read between frames. The `App` persists its `Time` and resources across
|
||||||
|
frames (so a physics world accumulates correctly) and is dropped on Stop.
|
||||||
|
|
||||||
|
How far to advance is decided by a small pure function,
|
||||||
|
`oxide_editor::play::tick_for`, kept separate from the (un-testable) GUI runner
|
||||||
|
so the contract is pinned in a unit test:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_editor::play::{tick_for, Tick};
|
||||||
|
|
||||||
|
match tick_for(state.play, step_requested) {
|
||||||
|
Tick::Frame => app.update(dt), // Playing
|
||||||
|
Tick::FixedStep => app.step(), // Paused + Step
|
||||||
|
Tick::Idle => {} // Editing, or Paused with no Step
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`App::step()` (engine) advances **exactly one fixed timestep** — one
|
||||||
|
`FixedUpdate` plus the per-frame phases once, bypassing the accumulator — which
|
||||||
|
is the Step primitive. `App::update(dt)` runs a normal frame, fixed steps driven
|
||||||
|
by the accumulator as usual (see [modules.md](modules.md)).
|
||||||
|
|
||||||
|
## Controls
|
||||||
|
|
||||||
|
A toolbar below the menu bar exposes **Play / Pause / Step / Stop**, gated by the
|
||||||
|
play state (Pause/Step/Stop enable only while running) with an
|
||||||
|
`Editing`/`PLAYING`/`PAUSED` badge. The viewport additionally draws a coloured
|
||||||
|
border + corner label while running (green = playing, amber = paused) so
|
||||||
|
edit-vs-play is unmistakable.
|
||||||
|
|
||||||
|
Shortcuts:
|
||||||
|
|
||||||
|
- **Ctrl+P** — Play when editing; Pause ⇄ Resume while running.
|
||||||
|
- **Ctrl+.** — Step one fixed tick (while paused).
|
||||||
|
|
||||||
|
Entering Play and pressing Stop both **clear the undo history**: the scene is
|
||||||
|
restored wholesale on Stop, so play-mode edits are deliberately not part of
|
||||||
|
edit-mode undo. The reflection inspector and gizmos stay live while paused (and
|
||||||
|
playing), so a field can be tweaked and the result observed immediately — the
|
||||||
|
payoff of the Stage-8.5 reflection work.
|
||||||
|
|
||||||
|
## Not in play mode (Stage 16)
|
||||||
|
|
||||||
|
A **"Launch standalone"** button that runs the real exported runtime in a
|
||||||
|
separate window/process — the truest-to-ship check — is deferred to Stage 16,
|
||||||
|
where it reuses the export builder rather than the in-editor loop.
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# Prefabs
|
||||||
|
|
||||||
|
`oxide_engine::prefab` provides **named spawn templates**. A prefab is a thing
|
||||||
|
you can "drop into the scene" that already carries a set of components — a
|
||||||
|
`Cube`, a `Camera`, a `Directional Light` — without the editor or game code
|
||||||
|
hard-coding one spawn path per kind of object.
|
||||||
|
|
||||||
|
## An entity is its components — a prefab is just data
|
||||||
|
|
||||||
|
Oxide has **no parallel "object type" system**. An entity *is* its set of
|
||||||
|
components (see [scene.md](scene.md) and [reflection.md](reflection.md)). A
|
||||||
|
[`Prefab`] is therefore nothing more than a **name** plus a list of
|
||||||
|
**(component name, RON value)** specs, applied on spawn through the
|
||||||
|
[`TypeRegistry`](reflection.md):
|
||||||
|
|
||||||
|
> "Spawn a Cube" = spawn an entity, then set its `MeshRenderer` to a cube.
|
||||||
|
|
||||||
|
Because a spec is the same name-keyed RON the editor and scripts already use,
|
||||||
|
prefabs are **pure data**: serializable, dual-editable, and free of bespoke
|
||||||
|
code. This is what lets the editor's add-menu be **data-driven** — it lists the
|
||||||
|
prefabs in a [`PrefabRegistry`] instead of one hard-coded button per type.
|
||||||
|
|
||||||
|
## The types
|
||||||
|
|
||||||
|
- [`ComponentSpec`] — `{ type_name, ron }`: one component to attach. Build it
|
||||||
|
from a value with [`ComponentSpec::of`] (serializes to RON) or from a raw
|
||||||
|
string with [`ComponentSpec::new`].
|
||||||
|
- [`Prefab`] — `{ name, components }`: the node name plus the specs to apply.
|
||||||
|
Built fluently with [`Prefab::new`] + [`Prefab::with`].
|
||||||
|
- [`PrefabRegistry`] — prefabs keyed by name; the source for an add-menu.
|
||||||
|
|
||||||
|
Every spawned entity already carries the node-baked `Node`, `Transform`, and
|
||||||
|
`Layer` (auto-attached by [`Scene::spawn`](scene.md)); a prefab's specs are
|
||||||
|
layered on top. A spec named `"Transform"` overrides the identity transform
|
||||||
|
`spawn` starts with, so a prefab can place itself.
|
||||||
|
|
||||||
|
## Spawning
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::prelude::*;
|
||||||
|
use oxide_engine::prefab::{ComponentSpec, Prefab, PrefabRegistry};
|
||||||
|
use oxide_engine::reflect::TypeRegistry;
|
||||||
|
|
||||||
|
// A registry that knows how to round-trip MeshRenderer by name.
|
||||||
|
let mut types = TypeRegistry::new();
|
||||||
|
types.register_reflected::<MeshRenderer>("MeshRenderer");
|
||||||
|
|
||||||
|
// A "Cube" prefab: a default MeshRenderer (shape = Cube).
|
||||||
|
let mut prefabs = PrefabRegistry::new();
|
||||||
|
prefabs.register(
|
||||||
|
Prefab::new("Cube")
|
||||||
|
.with(ComponentSpec::of("MeshRenderer", &MeshRenderer::default()).unwrap()),
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut scene = Scene::new();
|
||||||
|
let cube = prefabs.spawn("Cube", &mut scene, &types).unwrap(); // root entity
|
||||||
|
let child = prefabs.spawn_child("Cube", cube, &mut scene, &types); // parented
|
||||||
|
```
|
||||||
|
|
||||||
|
[`PrefabRegistry::spawn`] returns the new entity, or `None` if the name isn't
|
||||||
|
registered. Application is **best-effort**: a spec whose type isn't registered
|
||||||
|
or whose RON doesn't parse is skipped (the entity is still created with whatever
|
||||||
|
applied). Validate a prefab against a registry up front with
|
||||||
|
[`PrefabRegistry::unknown_specs`], which lists the type names the registry
|
||||||
|
doesn't know — handy for catching authoring typos.
|
||||||
|
|
||||||
|
## Relationship to "multiple of the same component"
|
||||||
|
|
||||||
|
Prefabs spawn **one entity**. Because an archetypal ECS allows only one
|
||||||
|
component of a given type per entity, a prefab that needs several of a thing
|
||||||
|
(e.g. multiple meshes) composes them as **child entities** — spawn the prefab,
|
||||||
|
then `spawn_child` the extras (each a real, gizmo-movable node). See the
|
||||||
|
component-multiplicity notes in `PLAN.md`.
|
||||||
|
|
||||||
|
[`ComponentSpec`]: ../engine/src/prefab.rs
|
||||||
|
[`ComponentSpec::of`]: ../engine/src/prefab.rs
|
||||||
|
[`ComponentSpec::new`]: ../engine/src/prefab.rs
|
||||||
|
[`Prefab`]: ../engine/src/prefab.rs
|
||||||
|
[`Prefab::new`]: ../engine/src/prefab.rs
|
||||||
|
[`Prefab::with`]: ../engine/src/prefab.rs
|
||||||
|
[`PrefabRegistry`]: ../engine/src/prefab.rs
|
||||||
|
[`PrefabRegistry::spawn`]: ../engine/src/prefab.rs
|
||||||
|
[`PrefabRegistry::unknown_specs`]: ../engine/src/prefab.rs
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# Project System
|
||||||
|
|
||||||
|
`oxide_engine::project` defines what a game is, on disk: a **project**. A project
|
||||||
|
is a root directory containing a project file plus a defined folder layout. The
|
||||||
|
format lives in the engine (not the editor) because the exported runtime and the
|
||||||
|
Stage-16 packer read it too — the editor just adds the create/open/save UI.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
my-game/
|
||||||
|
├── project.oxide # the project file (RON)
|
||||||
|
├── scenes/ # scene files
|
||||||
|
├── assets/ # meshes, textures, audio, …
|
||||||
|
└── scripts/ # game scripts
|
||||||
|
```
|
||||||
|
|
||||||
|
The project file records the project name, the engine version it was saved with,
|
||||||
|
the enabled [modules](modules.md), and per-project settings.
|
||||||
|
|
||||||
|
## Create, open, save
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::project::Project;
|
||||||
|
|
||||||
|
// Scaffold a new project (creates the folders + project file).
|
||||||
|
let mut project = Project::create("/path/to/my-game", "My Game")?;
|
||||||
|
|
||||||
|
project.enable_module("render");
|
||||||
|
project.save()?;
|
||||||
|
|
||||||
|
// Reopen later — by directory or by the project file path.
|
||||||
|
let project = Project::open("/path/to/my-game")?;
|
||||||
|
assert_eq!(project.name(), "My Game");
|
||||||
|
assert!(project.is_module_enabled("render"));
|
||||||
|
# Ok::<(), oxide_engine::project::ProjectError>(())
|
||||||
|
```
|
||||||
|
|
||||||
|
Path helpers (`scenes_dir()`, `assets_dir()`, `scripts_dir()`,
|
||||||
|
`project_file_path()`) resolve locations against the root. `create` refuses to
|
||||||
|
overwrite an existing project; `open` reports `NotFound` when there is no project
|
||||||
|
file.
|
||||||
|
|
||||||
|
## Settings storage
|
||||||
|
|
||||||
|
Per-project settings are stored as **opaque per-section RON blobs** keyed by
|
||||||
|
section name, which keeps the project format independent of any particular
|
||||||
|
settings schema:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
# use oxide_engine::project::Project;
|
||||||
|
# let mut project = Project::create(std::env::temp_dir().join("oxide_doc_proj"), "x").unwrap();
|
||||||
|
project.set_settings_section("editor", "(theme:\"dark\")");
|
||||||
|
assert_eq!(project.settings_section("editor"), Some("(theme:\"dark\")"));
|
||||||
|
```
|
||||||
|
|
||||||
|
The typed settings framework serializes its sections to and from these strings,
|
||||||
|
so a section round-trips through the project file without this module knowing the
|
||||||
|
section's shape.
|
||||||
|
|
||||||
|
## The editor's New / Open dialogs
|
||||||
|
|
||||||
|
The editor's **File ▸ New Project… / Open Project…** dialogs wrap
|
||||||
|
`Project::create`/`Project::open`. Each path field has a **Browse…** button
|
||||||
|
opening the **native folder picker** (`rfd` with the `xdg-portal` backend — one
|
||||||
|
pure-Rust build serves both Wayland and X11 through `xdg-desktop-portal`). The
|
||||||
|
dialog runs on a helper thread so the editor keeps rendering while it is up;
|
||||||
|
the picked folder lands back in the field, which stays hand-editable — if no
|
||||||
|
portal service is running the picker simply doesn't appear and the typed path
|
||||||
|
still works.
|
||||||
|
|
||||||
|
## Recent projects
|
||||||
|
|
||||||
|
`RecentProjects` is a small most-recently-used list, persisted globally as an
|
||||||
|
editor preference (not inside any project). It de-duplicates and caps:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::project::RecentProjects;
|
||||||
|
|
||||||
|
let mut recent = RecentProjects::new(10);
|
||||||
|
recent.record("/path/to/my-game");
|
||||||
|
// recent.save("~/.config/oxide/recent.ron")?; / RecentProjects::load(...)
|
||||||
|
assert_eq!(recent.entries().len(), 1);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Who consumes this
|
||||||
|
|
||||||
|
| Consumer | Stage | Use |
|
||||||
|
|----------|-------|-----|
|
||||||
|
| Editor | 6 | New/Open/Save Project UI, recent list, Project panel/asset browser |
|
||||||
|
| File watcher | 6 | watches `scenes/`, `assets/`, `scripts/` for live reload |
|
||||||
|
| Settings framework | 6 | persists per-project sections into the project file |
|
||||||
|
| Exported runtime / packer | 16 | reads the layout + enabled modules to bundle the game |
|
||||||
|
|
||||||
|
[`Project`]: ../engine/src/project.rs
|
||||||
|
[`RecentProjects`]: ../engine/src/project.rs
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
# Reflection / Type Registry
|
||||||
|
|
||||||
|
`oxide_engine::reflect` is the backbone of the engine's **dual-editable types**
|
||||||
|
principle: every component should be readable and writable from the editor, from
|
||||||
|
scripts, and from external tools through *one* representation — without each of
|
||||||
|
those callers knowing the concrete Rust type.
|
||||||
|
|
||||||
|
The [`TypeRegistry`] is that bridge. It lands in Stage 5; the editor inspector
|
||||||
|
(Stage 6) and the scripting layer (Stage 10) are its first real consumers.
|
||||||
|
|
||||||
|
## The idea
|
||||||
|
|
||||||
|
ECS components are concrete Rust types. An inspector panel or a script engine,
|
||||||
|
however, only has a *name* (`"Transform"`) and some text — they cannot name the
|
||||||
|
type at the call site. The registry closes that gap: register a type once, and
|
||||||
|
afterwards address it generically by name.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::reflect::TypeRegistry;
|
||||||
|
use oxide_engine::prelude::*;
|
||||||
|
|
||||||
|
let mut registry = TypeRegistry::new();
|
||||||
|
registry.register::<Transform>("Transform");
|
||||||
|
registry.register::<Node>("Node");
|
||||||
|
```
|
||||||
|
|
||||||
|
`register::<T>(name)` requires `T: Component + Serialize + DeserializeOwned`.
|
||||||
|
Internally it stores a small set of monomorphized function pointers, so there is
|
||||||
|
no per-call generic dispatch and no `dyn Any` downcasting at the boundary.
|
||||||
|
|
||||||
|
## Generic read / write
|
||||||
|
|
||||||
|
Once registered, any holder of the name can round-trip a component on an entity
|
||||||
|
as RON text — exactly what a generic inspector or a script needs:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
# use oxide_engine::reflect::TypeRegistry;
|
||||||
|
# use oxide_engine::prelude::*;
|
||||||
|
# let mut registry = TypeRegistry::new();
|
||||||
|
# registry.register::<Transform>("Transform");
|
||||||
|
let mut scene = Scene::new();
|
||||||
|
let e = scene.spawn("thing", Transform::IDENTITY);
|
||||||
|
|
||||||
|
// Read generically...
|
||||||
|
let text = registry.get_ron(scene.world(), e, "Transform").unwrap();
|
||||||
|
// ...edit the text (a script or the inspector would)...
|
||||||
|
// ...and write it back — no concrete type at the call site.
|
||||||
|
registry.set_ron(scene.world_mut(), e, "Transform", &text).unwrap();
|
||||||
|
```
|
||||||
|
|
||||||
|
`set_ron` inserts the component if absent or replaces it if present, so the same
|
||||||
|
call covers "add component" and "edit component".
|
||||||
|
|
||||||
|
## Enumerating an entity's components
|
||||||
|
|
||||||
|
A generic inspector renders an entity by asking the registry which *registered*
|
||||||
|
component types it currently carries — sorted, and again with no concrete types
|
||||||
|
in hand:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
# use oxide_engine::reflect::TypeRegistry;
|
||||||
|
# use oxide_engine::prelude::*;
|
||||||
|
# let mut registry = TypeRegistry::new();
|
||||||
|
# registry.register::<Transform>("Transform");
|
||||||
|
# registry.register::<Node>("Node");
|
||||||
|
# let mut scene = Scene::new();
|
||||||
|
# let e = scene.spawn("thing", Transform::IDENTITY);
|
||||||
|
for name in registry.components_on(scene.world(), e) {
|
||||||
|
let _ron = registry.get_ron(scene.world(), e, name).unwrap();
|
||||||
|
// ... render an editor for `name` from its RON text
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`has` and `remove` round out the surface (check for / detach a component by
|
||||||
|
name). Errors are specific — [`UnknownType`], [`NoSuchEntity`], [`Missing`], and
|
||||||
|
[`Parse`] — so callers can tell "no such type" from "bad text".
|
||||||
|
|
||||||
|
## Per-field reflection — `#[derive(Reflect)]` (Stage 8.5)
|
||||||
|
|
||||||
|
Whole-value reflection is enough for serialization and scripts, but a
|
||||||
|
Unity/Godot-style inspector needs to see a component's **named fields** so it
|
||||||
|
can render one widget per field. The [`Reflect`] trait provides that, and
|
||||||
|
`#[derive(Reflect)]` generates it:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::reflect::Reflect;
|
||||||
|
|
||||||
|
#[derive(Reflect, serde::Serialize, serde::Deserialize)]
|
||||||
|
struct Timer {
|
||||||
|
pub repeating: bool,
|
||||||
|
pub duration: f32,
|
||||||
|
#[reflect(skip)]
|
||||||
|
pub elapsed: f32, // runtime state — not an authored field
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut t = Timer { repeating: true, duration: 2.5, elapsed: 0.0 };
|
||||||
|
|
||||||
|
// Enumerate fields (name + syntactic type) — what an inspector iterates.
|
||||||
|
for field in t.fields() {
|
||||||
|
let _value_ron = t.get_field(field.name); // Some("true"), Some("2.5"), …
|
||||||
|
// pick a widget from `field.type_name`: "bool" → checkbox, "f32" → drag, …
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edit one field without touching the rest; round-trips as RON.
|
||||||
|
t.set_field("duration", "9.0").unwrap();
|
||||||
|
```
|
||||||
|
|
||||||
|
Selection rules (deliberate, matching the engine's *public-fields-are-the-
|
||||||
|
editable-surface* convention):
|
||||||
|
|
||||||
|
- **Only `pub` fields** are reflected. Private fields are implementation detail.
|
||||||
|
- `#[reflect(skip)]` excludes a public field (e.g. runtime-only state).
|
||||||
|
- Each reflected field must be `serde`-serializable — get/set round-trip through
|
||||||
|
RON, the same representation the whole-value path uses.
|
||||||
|
|
||||||
|
[`FieldInfo::type_name`] is the field type's *syntactic* spelling (`"f32"`,
|
||||||
|
`"bool"`, `"Vec3"`, `"Handle < Font >"`). The generic inspector dispatches a
|
||||||
|
widget on it and falls back to a raw RON editor for types it does not recognize.
|
||||||
|
Per-field errors are [`UnknownField`] and [`FieldParse`].
|
||||||
|
|
||||||
|
The derive lives in the small `oxide-engine-derive` proc-macro crate and is
|
||||||
|
re-exported as `oxide_engine::reflect::Reflect` (the macro shares its name with
|
||||||
|
the trait, the same way `serde`'s `Serialize` does). This is the spine of the
|
||||||
|
reflection-driven editor inspector and the dual-editable-types principle — a new
|
||||||
|
component becomes editor- and script-editable with `#[derive(Reflect)]` plus one
|
||||||
|
registration line, no per-type editor code.
|
||||||
|
|
||||||
|
### Through the registry: fields by type name + entity
|
||||||
|
|
||||||
|
A type registered with `register_reflected::<T>("Name")` (instead of plain
|
||||||
|
`register`) exposes its fields through the [`TypeRegistry`] too, so the editor
|
||||||
|
can reach a field given only a **type name + entity** — no concrete type at the
|
||||||
|
call site:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
# use oxide_engine::reflect::TypeRegistry;
|
||||||
|
# use oxide_engine::prelude::*;
|
||||||
|
# let mut registry = TypeRegistry::new();
|
||||||
|
registry.register_reflected::<Transform>("Transform");
|
||||||
|
# let mut scene = Scene::new();
|
||||||
|
# let e = scene.spawn("thing", Transform::IDENTITY);
|
||||||
|
for field in registry.field_infos(scene.world(), e, "Transform").unwrap() {
|
||||||
|
let _ron = registry.get_field(scene.world(), e, "Transform", field.name).unwrap();
|
||||||
|
// render a widget from field.type_name, write edits back with set_field(...)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`Transform` and `Node` are registered reflected by default. Field access on a
|
||||||
|
whole-value-only type returns [`NotReflected`]. Whole-value (`get_ron`/`set_ron`)
|
||||||
|
and per-field (`field_infos`/`get_field`/`set_field`) coexist: the registry
|
||||||
|
addresses *types* by name; per-field reaches *fields* within a value.
|
||||||
|
|
||||||
|
### Enum fields — `#[derive(ReflectEnum)]`
|
||||||
|
|
||||||
|
Per-field reflection tells the inspector a field's *type name* but not, for an
|
||||||
|
enum-typed field, which values it may take. `#[derive(ReflectEnum)]` (unit
|
||||||
|
variants only) exposes the variant list so the inspector renders a dropdown
|
||||||
|
instead of a free-text RON box:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::reflect::{ReflectEnum, TypeRegistry};
|
||||||
|
|
||||||
|
#[derive(ReflectEnum, serde::Serialize, serde::Deserialize)]
|
||||||
|
enum Facing { North, East, South, West }
|
||||||
|
|
||||||
|
let mut registry = TypeRegistry::new();
|
||||||
|
registry.register_enum::<Facing>("Facing");
|
||||||
|
assert_eq!(registry.enum_variants("Facing"), Some(["North","East","South","West"].as_slice()));
|
||||||
|
```
|
||||||
|
|
||||||
|
Each variant name is valid RON for that unit variant, so a chosen name writes
|
||||||
|
straight back through `set_field`. An enum is a field *type*, not a component, so
|
||||||
|
`register_enum` is independent of component registration.
|
||||||
|
|
||||||
|
## Who owns the registry
|
||||||
|
|
||||||
|
The registry is owned by the app / module system (Stage 5): each module
|
||||||
|
registers the component types it introduces, so the editor and scripts can reach
|
||||||
|
every type any module added. Because names are the identity used in serialized
|
||||||
|
data and UI, keep them stable across versions.
|
||||||
|
|
||||||
|
[`TypeRegistry`]: ../engine/src/reflect.rs
|
||||||
|
[`UnknownType`]: ../engine/src/reflect.rs
|
||||||
|
[`NoSuchEntity`]: ../engine/src/reflect.rs
|
||||||
|
[`Missing`]: ../engine/src/reflect.rs
|
||||||
|
[`Parse`]: ../engine/src/reflect.rs
|
||||||
|
[`Reflect`]: ../engine/src/reflect.rs
|
||||||
|
[`FieldInfo::type_name`]: ../engine/src/reflect.rs
|
||||||
|
[`UnknownField`]: ../engine/src/reflect.rs
|
||||||
|
[`FieldParse`]: ../engine/src/reflect.rs
|
||||||
|
[`NotReflected`]: ../engine/src/reflect.rs
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# Render Context & GPU Setup
|
||||||
|
|
||||||
|
Stage 2 reference for `oxide_engine::render` — how the engine acquires the
|
||||||
|
GPU and drives a window surface. For the event loop that calls into this each
|
||||||
|
frame, see [windowing.md](windowing.md).
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Stage 2 rendering is deliberately minimal: acquire the GPU, configure the
|
||||||
|
window surface, and clear it to a configurable color every frame. Meshes,
|
||||||
|
materials, and passes arrive in Stage 4+. The module still establishes the
|
||||||
|
two long-lived types every later stage builds on:
|
||||||
|
|
||||||
|
- **`Gpu`** — instance, adapter, and the device/queue pair. Everything that
|
||||||
|
touches the GPU goes through these four objects.
|
||||||
|
- **`RenderContext`** — a `Gpu` plus a window's surface and its
|
||||||
|
configuration; owns the per-frame acquire → clear → present cycle.
|
||||||
|
|
||||||
|
Both are created for you by [`run()`](windowing.md); applications normally
|
||||||
|
reach them through `AppCtx::render()`.
|
||||||
|
|
||||||
|
## `Gpu`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::prelude::*;
|
||||||
|
|
||||||
|
let gpu = Gpu::headless()?; // offscreen / tests
|
||||||
|
let device: &wgpu::Device = gpu.device();
|
||||||
|
let queue: &wgpu::Queue = gpu.queue();
|
||||||
|
# Ok::<(), oxide_engine::render::RenderError>(())
|
||||||
|
```
|
||||||
|
|
||||||
|
Acquisition asks for a high-performance adapter (compatible with the window
|
||||||
|
surface in the windowed path) and a default-limits device. The chosen adapter
|
||||||
|
and backend are logged at `info` level on startup.
|
||||||
|
|
||||||
|
`Gpu::headless()` skips the surface entirely — used by offscreen rendering
|
||||||
|
and the automated Stage 2 integration test. Backend selection and debug flags
|
||||||
|
remain overridable through wgpu's standard `WGPU_*` environment variables
|
||||||
|
(e.g. `WGPU_BACKEND=vulkan`).
|
||||||
|
|
||||||
|
## `RenderContext`
|
||||||
|
|
||||||
|
Owns the surface lifecycle:
|
||||||
|
|
||||||
|
- **Creation** — builds the wgpu instance (the window doubles as the display
|
||||||
|
handle), creates the surface, acquires the `Gpu`, and configures the
|
||||||
|
surface with `get_default_config` (the platform's preferred format and
|
||||||
|
present mode).
|
||||||
|
- **`resize(width, height)`** — reconfigures the surface. Zero dimensions
|
||||||
|
(minimized windows) are clamped to 1 so the surface stays valid. Called
|
||||||
|
automatically by the event loop on `Resized`.
|
||||||
|
- **`set_clear_color(color)` / `clear_color()`** — the color the next frame
|
||||||
|
is cleared to. The engine's `Color` is linear f32 RGBA, matching what the
|
||||||
|
surface expects (conversion to `wgpu::Color` is `render::to_wgpu_color`).
|
||||||
|
- **`render_frame()`** — one frame: acquire the next surface texture, record
|
||||||
|
a clear pass, submit, present.
|
||||||
|
- **`size()`, `gpu()`** — current surface size (physical pixels) and the
|
||||||
|
underlying `Gpu`.
|
||||||
|
|
||||||
|
### Frame acquisition and transient failures
|
||||||
|
|
||||||
|
`get_current_texture` can fail for reasons that are *normal* during resizes
|
||||||
|
and window-manager activity. `render_frame()` maps them as follows:
|
||||||
|
|
||||||
|
| Surface state | Behavior |
|
||||||
|
|---------------|----------|
|
||||||
|
| `Success` / `Suboptimal` | Clear and present (a suboptimal frame is still presentable; the next resize reconfigures anyway) |
|
||||||
|
| `Lost` / `Outdated` | Reconfigure the surface, skip the frame |
|
||||||
|
| `Timeout` / `Occluded` | Skip the frame |
|
||||||
|
| `Validation` | Returned as `RenderError::SurfaceValidation` — a real bug, not transient |
|
||||||
|
|
||||||
|
Skipped frames are invisible in practice: the next `RedrawRequested` arrives
|
||||||
|
within milliseconds.
|
||||||
|
|
||||||
|
## `clear_view`
|
||||||
|
|
||||||
|
The single render operation Stage 2 owns:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
oxide_engine::render::clear_view(device, queue, &texture_view, Color::RED);
|
||||||
|
```
|
||||||
|
|
||||||
|
Records and submits a render pass whose only work is a load-op clear. Both
|
||||||
|
the windowed path (`render_frame`) and offscreen targets go through it, which
|
||||||
|
is what makes the GPU path automatically testable: the integration test
|
||||||
|
`stage2::headless_clear_fills_texture_with_clear_color` clears an offscreen
|
||||||
|
texture headless, reads the pixels back, and asserts the exact clear color —
|
||||||
|
no window or human needed. (It self-skips on machines with no GPU adapter.)
|
||||||
|
|
||||||
|
## Errors
|
||||||
|
|
||||||
|
`RenderError` (a `thiserror` enum) distinguishes the failure modes callers
|
||||||
|
might handle: `NoAdapter`, `Device`, `CreateSurface`, `UnsupportedSurface`,
|
||||||
|
and `SurfaceValidation`. Binaries typically just propagate it via `anyhow`
|
||||||
|
out of `run()`.
|
||||||
|
|
||||||
|
## Design notes
|
||||||
|
|
||||||
|
- The window is held as `Arc<winit::window::Window>` so the surface, which
|
||||||
|
borrows the window, can be `'static` — winit hands windows out from inside
|
||||||
|
its event loop, and wgpu surfaces must outlive every frame.
|
||||||
|
- `Gpu` and `RenderContext` are separate types on purpose: later stages (and
|
||||||
|
tests today) need the device/queue without any window, and composability is
|
||||||
|
a core project principle.
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
# Render Pass Pipeline
|
||||||
|
|
||||||
|
Stage 4 drew everything in one hardcoded pass. Stage 5 generalizes that into a
|
||||||
|
[`RenderPipeline`]: an ordered, named list of composable [`RenderPass`]es that
|
||||||
|
share one frame's targets. A project enables only the passes it needs — this is
|
||||||
|
the mechanism behind **scalable fidelity**: a flat unlit/low-poly look (or a
|
||||||
|
stylized post effect like a VCR filter) versus a full realistic stack with
|
||||||
|
shadows and post-processing, paying only for the passes turned on.
|
||||||
|
|
||||||
|
The Stage-4 forward renderer is retrofitted onto this as [`ForwardPass`], so the
|
||||||
|
default pipeline is just `[Clear, Forward]` and produces pixel-identical output.
|
||||||
|
Later stages (shadows, post-process, overlay UI) add passes **without touching
|
||||||
|
the renderer core** — they register a pass.
|
||||||
|
|
||||||
|
## The pieces
|
||||||
|
|
||||||
|
- [`RenderPass`] — a trait with one method, `run(&mut self, frame)`. Implement it
|
||||||
|
to add a stage of the frame.
|
||||||
|
- [`FrameContext`] — everything a pass operates on for one frame: the shared
|
||||||
|
`color` target, size, clear color, camera + its world transform, lighting, and
|
||||||
|
the (already culled) drawables.
|
||||||
|
- [`RenderPipeline`] — owns the passes and runs every *enabled* one in order.
|
||||||
|
- Built-in passes: [`ClearPass`] (clears the color target) and [`ForwardPass`]
|
||||||
|
(the lit forward draw).
|
||||||
|
|
||||||
|
## Composing a frame
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::render::{RenderPipeline, FrameContext, ForwardPass};
|
||||||
|
# use oxide_engine::prelude::*;
|
||||||
|
# fn demo(device: &oxide_engine::wgpu::Device, queue: &oxide_engine::wgpu::Queue,
|
||||||
|
# target: &oxide_engine::wgpu::TextureView, cube: &GpuMesh) {
|
||||||
|
// The default pipeline: Clear then Forward (pixel-identical to Stage 4).
|
||||||
|
let mut pipeline = RenderPipeline::forward(device, oxide_engine::wgpu::TextureFormat::Rgba8Unorm);
|
||||||
|
|
||||||
|
let camera = Camera::default();
|
||||||
|
let view = Transform::looking_at(Vec3::new(0.0, 0.0, 5.0), Vec3::ZERO, Vec3::Y);
|
||||||
|
let lighting = Lighting::default();
|
||||||
|
let objects = [RenderObject { mesh: cube, material: Material::diffuse(Color::RED), transform: Transform::IDENTITY }];
|
||||||
|
|
||||||
|
pipeline.render(&mut FrameContext {
|
||||||
|
device, queue,
|
||||||
|
color: target,
|
||||||
|
size: (1280, 720),
|
||||||
|
clear_color: Color::rgb(0.05, 0.06, 0.09),
|
||||||
|
camera: &camera,
|
||||||
|
view_transform: &view,
|
||||||
|
lighting: &lighting,
|
||||||
|
objects: &objects,
|
||||||
|
});
|
||||||
|
# }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data-driven: add, toggle, remove
|
||||||
|
|
||||||
|
Passes are addressed by name and managed without touching any pass's code:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
# use oxide_engine::render::RenderPipeline;
|
||||||
|
# struct Bloom; impl oxide_engine::render::RenderPass for Bloom {
|
||||||
|
# fn run(&mut self, _f: &mut oxide_engine::render::FrameContext<'_>) {} }
|
||||||
|
# let mut pipeline = RenderPipeline::new();
|
||||||
|
pipeline.add_pass("forward", /* ForwardPass */
|
||||||
|
# { struct F; impl oxide_engine::render::RenderPass for F { fn run(&mut self, _f: &mut oxide_engine::render::FrameContext<'_>) {} } F }
|
||||||
|
);
|
||||||
|
pipeline.add_pass("bloom", Bloom); // a post effect (Stage 13)
|
||||||
|
pipeline.set_enabled("bloom", false); // turn it off, keep it registered
|
||||||
|
pipeline.insert_before("forward", "shadows",
|
||||||
|
# { struct S; impl oxide_engine::render::RenderPass for S { fn run(&mut self, _f: &mut oxide_engine::render::FrameContext<'_>) {} } S }
|
||||||
|
); // slot a pass into a fixed position
|
||||||
|
pipeline.remove("bloom"); // drop it entirely
|
||||||
|
```
|
||||||
|
|
||||||
|
A stylized game ships a pipeline with no post passes (and pays nothing for them);
|
||||||
|
a realistic game enables shadows, SSAO, bloom, tone-mapping. Same engine, same
|
||||||
|
renderer core — different pass list.
|
||||||
|
|
||||||
|
## Windowed vs offscreen clearing
|
||||||
|
|
||||||
|
The window runner already clears the surface to the configured clear color before
|
||||||
|
`App::render` runs, so the **editor and windowed examples use a forward-only
|
||||||
|
pipeline** (no `ClearPass`) and let the runner clear. `RenderPipeline::forward`
|
||||||
|
(Clear + Forward) is for offscreen/standalone rendering where nothing else
|
||||||
|
clears the target — e.g. the headless render tests.
|
||||||
|
|
||||||
|
## Camera layer visibility
|
||||||
|
|
||||||
|
A [`Camera`](rendering.md) carries a `visibility` [`LayerMask`](layers.md): it
|
||||||
|
renders an entity only if the entity's [`Layer`] is in that mask (default
|
||||||
|
[`LayerMask::ALL`] — sees everything). The host applies it while gathering
|
||||||
|
drawables:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
# use oxide_engine::prelude::*;
|
||||||
|
# use oxide_engine::layer::Layer;
|
||||||
|
# let scene = Scene::new();
|
||||||
|
# let camera = Camera::default();
|
||||||
|
# let entity = scene.entities().next();
|
||||||
|
# if let Some(entity) = entity {
|
||||||
|
let layer = scene.get::<Layer>(entity).map(|l| *l).unwrap_or_default();
|
||||||
|
if camera.sees(layer) {
|
||||||
|
// include this entity in the draw list
|
||||||
|
}
|
||||||
|
# }
|
||||||
|
```
|
||||||
|
|
||||||
|
This is how a minimap camera, a first-person view-model camera, or editor-only
|
||||||
|
gizmo layers are kept to their own cameras.
|
||||||
|
|
||||||
|
[`RenderPipeline`]: ../engine/src/render/pipeline.rs
|
||||||
|
[`RenderPass`]: ../engine/src/render/pipeline.rs
|
||||||
|
[`FrameContext`]: ../engine/src/render/pipeline.rs
|
||||||
|
[`ClearPass`]: ../engine/src/render/pipeline.rs
|
||||||
|
[`ForwardPass`]: ../engine/src/render/pipeline.rs
|
||||||
|
[`Layer`]: ../engine/src/layer/components.rs
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
# Rendering (Stage 4 — Basic 3D Rendering)
|
||||||
|
|
||||||
|
Stage 4 turns the clear-color surface from Stage 2 into a 3D renderer: it draws
|
||||||
|
**meshes**, placed by **transforms**, shaded by **materials**, as seen through a
|
||||||
|
**camera**, lit by a directional light — all through a single-pass
|
||||||
|
**forward renderer**.
|
||||||
|
|
||||||
|
> Status: the rendering core (this document), the glTF importer, and the
|
||||||
|
> editor's 3D viewport (orbit/pan/zoom + material inspector) are all implemented.
|
||||||
|
> The engine paths are covered by headless GPU tests; the editor viewport is on
|
||||||
|
> `dev` awaiting the maintainer's manual sign-off before Stage 4 is marked done
|
||||||
|
> (tracked in [PLAN.md](../PLAN.md)).
|
||||||
|
|
||||||
|
All of these types live in `oxide_engine::render` and are re-exported from the
|
||||||
|
[prelude](getting-started.md).
|
||||||
|
|
||||||
|
## The pieces
|
||||||
|
|
||||||
|
| Type | Role |
|
||||||
|
|------|------|
|
||||||
|
| [`Vertex`] | One vertex: `position`, `normal`, `uv` (GPU-ready, `repr(C)`) |
|
||||||
|
| [`Mesh`] | CPU-side indexed triangle geometry + primitive builders |
|
||||||
|
| [`GpuMesh`] | A `Mesh` uploaded into GPU vertex/index buffers |
|
||||||
|
| [`Material`] | PBR-lite surface: `albedo`, `metallic`, `roughness` |
|
||||||
|
| [`Camera`] | Perspective projection; the *view* comes from a `Transform` |
|
||||||
|
| [`DirectionalLight`] / [`Lighting`] | One sun light + an ambient term |
|
||||||
|
| [`RenderObject`] | A drawable: `&GpuMesh` + `Material` + `Transform` |
|
||||||
|
| [`ForwardRenderer`] | Owns the pipeline + depth buffer; draws a list of objects |
|
||||||
|
|
||||||
|
[`Vertex`]: ../engine/src/render/mesh.rs
|
||||||
|
[`Mesh`]: ../engine/src/render/mesh.rs
|
||||||
|
[`GpuMesh`]: ../engine/src/render/mesh.rs
|
||||||
|
[`Material`]: ../engine/src/render/material.rs
|
||||||
|
[`Camera`]: ../engine/src/render/camera.rs
|
||||||
|
[`DirectionalLight`]: ../engine/src/render/forward.rs
|
||||||
|
[`Lighting`]: ../engine/src/render/forward.rs
|
||||||
|
[`RenderObject`]: ../engine/src/render/forward.rs
|
||||||
|
[`ForwardRenderer`]: ../engine/src/render/forward.rs
|
||||||
|
|
||||||
|
## Building geometry
|
||||||
|
|
||||||
|
Meshes are built on the CPU and uploaded once. Built-in primitives cover the
|
||||||
|
common prototyping shapes:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::prelude::*;
|
||||||
|
|
||||||
|
let cube = Mesh::cube(); // unit cube, per-face normals
|
||||||
|
let plane = Mesh::plane(10.0); // 10×10 ground on XZ, facing +Y
|
||||||
|
let sphere = Mesh::uv_sphere(0.8, 32, 16); // radius, sectors, stacks
|
||||||
|
|
||||||
|
// Upload to the GPU (needs a `&wgpu::Device`, e.g. from `RenderCtx`/`Gpu`).
|
||||||
|
let gpu_cube: GpuMesh = cube.upload(device, "cube");
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also build a mesh directly from `Vertex` + index data, and query its
|
||||||
|
object-space bounds with `Mesh::bounds()` (used later for culling).
|
||||||
|
|
||||||
|
### Importing glTF
|
||||||
|
|
||||||
|
Static meshes load from glTF/GLB via `oxide_engine::asset`. The node hierarchy is
|
||||||
|
flattened into world space and each primitive becomes a `GltfMesh` (geometry +
|
||||||
|
PBR-lite material + transform); missing normals are generated, missing UVs default
|
||||||
|
to zero. Skinning/animation are deferred to the animation stage.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::prelude::*;
|
||||||
|
|
||||||
|
let model = load_gltf("assets/models/cube.gltf")?;
|
||||||
|
let drawables: Vec<_> = model
|
||||||
|
.meshes
|
||||||
|
.iter()
|
||||||
|
.map(|m| (m.mesh.upload(device, "gltf"), m.material, m.transform))
|
||||||
|
.collect();
|
||||||
|
// Build `RenderObject`s from `drawables` and hand them to `ForwardRenderer::render`.
|
||||||
|
```
|
||||||
|
|
||||||
|
`load_gltf_slice(&bytes)` is the in-memory variant (buffers must be embedded),
|
||||||
|
used for tests and bundled assets.
|
||||||
|
|
||||||
|
## Camera
|
||||||
|
|
||||||
|
A `Camera` holds only projection parameters (`fov_y`, `z_near`, `z_far`); its
|
||||||
|
*position and orientation* are a [`Transform`](scene.md) given at render time, so
|
||||||
|
a camera can live in the scene as an entity. Use `Transform::looking_at` to aim
|
||||||
|
it:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let camera = Camera::default(); // 60° FOV, 0.1–1000 range
|
||||||
|
let view = Transform::looking_at(Vec3::new(4.0, 2.5, 5.0), Vec3::ZERO, Vec3::Y);
|
||||||
|
```
|
||||||
|
|
||||||
|
The projection uses a `0..1` NDC depth range (the wgpu/Vulkan/DX/Metal
|
||||||
|
convention), matching the depth buffer the forward renderer clears to `1.0`.
|
||||||
|
|
||||||
|
## Drawing a frame
|
||||||
|
|
||||||
|
The `ForwardRenderer` is built once for a given **color target format** — the
|
||||||
|
window surface format for on-screen rendering, or e.g. `Rgba8Unorm` offscreen.
|
||||||
|
Then each frame you hand it a list of `RenderObject`s:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Once (e.g. lazily on the first frame, when the surface format is known):
|
||||||
|
let mut renderer = ForwardRenderer::new(device, ctx.surface_format);
|
||||||
|
|
||||||
|
// Each frame, inside `App::render`:
|
||||||
|
renderer.render(
|
||||||
|
device,
|
||||||
|
queue,
|
||||||
|
ctx.view, // the target view (already cleared to the clear color)
|
||||||
|
ctx.size, // (width, height) in physical pixels
|
||||||
|
&camera,
|
||||||
|
&view, // the camera's world transform
|
||||||
|
&Lighting::default(),
|
||||||
|
&[
|
||||||
|
RenderObject { mesh: &gpu_plane, material: Material::diffuse(Color::WHITE), transform: ground },
|
||||||
|
RenderObject { mesh: &gpu_cube, material: Material::diffuse(Color::RED), transform: spin },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
The color attachment is **loaded, not cleared**, so whatever cleared the surface
|
||||||
|
beforehand (the window's clear color from Stage 2, or a `clear_view` call) shows
|
||||||
|
through as the background. The depth buffer is owned by the renderer, resized to
|
||||||
|
match the target, and cleared to `1.0` every call.
|
||||||
|
|
||||||
|
See the full runnable example:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo run -p oxide-examples --bin hello_mesh # spinning cube + sphere + ground
|
||||||
|
```
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
- **One pipeline, one pass.** Geometry is drawn front-to-back-agnostic; a
|
||||||
|
`Depth32Float` depth buffer with `Less` compare resolves occlusion, so draw
|
||||||
|
order does not affect the result.
|
||||||
|
- **Per-object data via dynamic uniform offsets.** Globals (view-projection,
|
||||||
|
camera position, light) live in one uniform buffer (bind group 0). Each
|
||||||
|
object's model matrix, normal matrix, and material live in a second uniform
|
||||||
|
buffer addressed with a dynamic offset (bind group 1), so an arbitrary number
|
||||||
|
of objects draw from one buffer that grows as needed.
|
||||||
|
- **PBR-lite shading.** [`shaders/lit.wgsl`](../engine/src/render/shaders/lit.wgsl)
|
||||||
|
does Lambert diffuse + ambient + a Blinn-Phong specular term whose sharpness
|
||||||
|
comes from `roughness` and whose color comes from `metallic`. It outputs linear
|
||||||
|
color; an sRGB surface converts on write.
|
||||||
|
|
||||||
|
## In the editor
|
||||||
|
|
||||||
|
The editor renders the active scene in a 3D viewport beneath its egui panels.
|
||||||
|
Entities become visible by carrying a `MeshRenderer` component (`oxide_engine::render`):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::prelude::*;
|
||||||
|
|
||||||
|
// Make an entity render a cube with a custom material.
|
||||||
|
let e = scene.spawn("crate", Transform::from_translation(Vec3::new(2.0, 0.5, 0.0)));
|
||||||
|
scene.world_mut().insert_one(
|
||||||
|
e,
|
||||||
|
MeshRenderer::with_material(PrimitiveShape::Cube, Material::diffuse(Color::RED)),
|
||||||
|
).unwrap();
|
||||||
|
```
|
||||||
|
|
||||||
|
`MeshRenderer` names a built-in `PrimitiveShape` (cube/sphere/plane) rather than
|
||||||
|
embedding geometry, so it is tiny and serializable (RON) — editable from both the
|
||||||
|
inspector and, later, scripts/AI agents. The editor caches one GPU mesh per shape
|
||||||
|
and draws every `MeshRenderer` entity through the `ForwardRenderer`, with an
|
||||||
|
orbit camera (drag to orbit, right-drag to pan, scroll to zoom).
|
||||||
|
|
||||||
|
Entities are selected by **clicking them in the viewport** (a ray is cast against
|
||||||
|
each renderable's world-space bounds) or from the hierarchy panel. The inspector
|
||||||
|
edits the selection's **transform** (position, rotation as euler degrees, and
|
||||||
|
scale) and its **material** (albedo / metallic / roughness — roughness controls
|
||||||
|
specular-highlight sharpness, most visible on glossy/metallic surfaces).
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
The window/viewport halves need a human eye, but the render path itself is
|
||||||
|
verified headlessly (`tests/` `stage4`): render to an offscreen texture and read
|
||||||
|
the pixels back to assert that lit geometry appears, the background shows through
|
||||||
|
elsewhere, and a near object occludes a farther one through the depth buffer.
|
||||||
|
Camera projection/view math has unit tests in `render/camera.rs`.
|
||||||
|
|
||||||
|
See also: [render-context.md](render-context.md) (surface/clear loop),
|
||||||
|
[conventions.md](conventions.md) (handedness, color space), [scene.md](scene.md)
|
||||||
|
(transforms and the hierarchy that feeds object placement).
|
||||||
+232
@@ -0,0 +1,232 @@
|
|||||||
|
# Scene Graph & Entity System
|
||||||
|
|
||||||
|
The `oxide_engine::scene` module is the world model every later system plugs
|
||||||
|
into. It pairs a lightweight ECS ([`hecs`](https://docs.rs/hecs)) with a
|
||||||
|
parent/child [`Transform`](math.md) hierarchy, so entities can hold arbitrary
|
||||||
|
components *and* live in a spatial tree.
|
||||||
|
|
||||||
|
This document is the usage reference for the module as delivered in **Stage 3**.
|
||||||
|
For the math types it builds on, see [math.md](math.md); for coordinate and
|
||||||
|
units conventions, see [conventions.md](conventions.md).
|
||||||
|
|
||||||
|
## Importing
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::scene::{Scene, Node, Entity, DespawnPolicy, SceneError};
|
||||||
|
// or, for the common types, via the prelude:
|
||||||
|
use oxide_engine::prelude::*; // Scene, Node, Entity, DespawnPolicy, SceneError, Transform, …
|
||||||
|
```
|
||||||
|
|
||||||
|
The full ECS is re-exported as `oxide_engine::hecs` so you share one copy of
|
||||||
|
`Entity` and the query API with the engine.
|
||||||
|
|
||||||
|
## Mental model
|
||||||
|
|
||||||
|
- An **entity** is a `hecs::Entity` handle — a small `Copy` id.
|
||||||
|
- Every entity created through the scene carries a [`Node`](#node) (name +
|
||||||
|
enabled flag) and a **local** [`Transform`](math.md).
|
||||||
|
- The **hierarchy** (which entity parents which) is owned by the `Scene`, not
|
||||||
|
stored as components. This keeps child ordering deterministic and makes
|
||||||
|
reparenting cheap.
|
||||||
|
- A **local** transform is what you author. A **world** transform is the local
|
||||||
|
composed with every ancestor: `world = parent_world * local`. The scene
|
||||||
|
resolves these on demand; it does not cache them.
|
||||||
|
|
||||||
|
| Type | Role |
|
||||||
|
|------|------|
|
||||||
|
| [`Scene`](#scene) | Owns entities + hierarchy; spawn, despawn, reparent, query, resolve transforms |
|
||||||
|
| [`Node`](#node) | Per-entity metadata: `name`, `enabled` |
|
||||||
|
| [`DespawnPolicy`](#despawning) | Whether despawn takes the subtree or detaches children |
|
||||||
|
| [`SceneError`](#errors) | Reparent / (de)serialization failures |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `Scene`
|
||||||
|
|
||||||
|
### Building a hierarchy
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::prelude::*;
|
||||||
|
|
||||||
|
let mut scene = Scene::new();
|
||||||
|
|
||||||
|
// A root entity (no parent).
|
||||||
|
let sun = scene.spawn("sun", Transform::IDENTITY);
|
||||||
|
|
||||||
|
// Children. `spawn_child` panics if the parent is not a live entity.
|
||||||
|
let planet = scene.spawn_child(
|
||||||
|
sun,
|
||||||
|
"planet",
|
||||||
|
Transform::from_translation(Vec3::new(10.0, 0.0, 0.0)),
|
||||||
|
);
|
||||||
|
let moon = scene.spawn_child(
|
||||||
|
planet,
|
||||||
|
"moon",
|
||||||
|
Transform::from_translation(Vec3::new(0.0, 0.0, 2.0)),
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
`spawn`/`spawn_child` take anything that converts into a `Node`, so a bare
|
||||||
|
`&str` works as a name (`"planet"` ≡ `Node::new("planet")`); pass a `Node`
|
||||||
|
directly when you need to set `enabled`.
|
||||||
|
|
||||||
|
### Resolving world transforms
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// One entity (walks up the parent chain):
|
||||||
|
let moon_world = scene.world_transform(moon).unwrap();
|
||||||
|
|
||||||
|
// Every entity at once (single top-down pass — prefer this in bulk):
|
||||||
|
let worlds = scene.world_transforms(); // HashMap<Entity, Transform>
|
||||||
|
```
|
||||||
|
|
||||||
|
`world_transforms()` is the path the renderer will use; it resolves a
|
||||||
|
10,000-entity, 5-level scene in well under a millisecond (see the
|
||||||
|
`world_transforms_10k_depth5` benchmark).
|
||||||
|
|
||||||
|
### Reparenting
|
||||||
|
|
||||||
|
```rust
|
||||||
|
scene.set_parent(moon, Some(sun))?; // moon now orbits the sun directly
|
||||||
|
scene.set_parent(moon, None)?; // moon becomes a root
|
||||||
|
```
|
||||||
|
|
||||||
|
Reparenting preserves the **local** transform (it does not compensate to keep
|
||||||
|
the world transform fixed). Cycles are rejected: parenting an entity to itself
|
||||||
|
or to one of its descendants returns [`SceneError::WouldCycle`], leaving the
|
||||||
|
hierarchy untouched.
|
||||||
|
|
||||||
|
### Despawning
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::scene::DespawnPolicy;
|
||||||
|
|
||||||
|
// Remove the entity and its entire subtree:
|
||||||
|
scene.despawn(planet, DespawnPolicy::Recursive);
|
||||||
|
|
||||||
|
// Remove only the entity; its children move up to its parent (or become roots
|
||||||
|
// if it was a root):
|
||||||
|
scene.despawn(planet, DespawnPolicy::DetachChildren);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Editing nodes
|
||||||
|
|
||||||
|
```rust
|
||||||
|
scene.set_name(planet, "earth");
|
||||||
|
scene.set_enabled(moon, false); // later systems skip disabled subtrees
|
||||||
|
scene.set_local_transform(moon, Transform::IDENTITY);
|
||||||
|
|
||||||
|
let name = scene.name(planet); // Option<String>
|
||||||
|
let on = scene.is_enabled(moon); // Option<bool>
|
||||||
|
let local = scene.local_transform(moon); // Option<Transform>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Querying
|
||||||
|
|
||||||
|
```rust
|
||||||
|
for &root in scene.roots() { /* … */ }
|
||||||
|
for &child in scene.children(planet) { /* … */ }
|
||||||
|
let parent = scene.parent(moon); // Option<Entity>
|
||||||
|
let n = scene.len();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Extra components (it's a real ECS)
|
||||||
|
|
||||||
|
Entities are full `hecs` entities, so later stages attach their own components
|
||||||
|
(meshes, rigid bodies, …) alongside the `Node`/`Transform`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
scene.world_mut().insert_one(planet, /* e.g. */ 0u32).unwrap();
|
||||||
|
let value = scene.get::<u32>(planet); // Option<hecs::Ref<u32>>
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `world()` for read-only queries and `world_mut()` for adding/removing
|
||||||
|
*non-hierarchy* components. Drive lifecycle and parenting through the `Scene`
|
||||||
|
methods so the hierarchy bookkeeping stays consistent — spawning or despawning
|
||||||
|
directly on the world bypasses it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `Node`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct Node {
|
||||||
|
pub name: String, // display name; not required to be unique
|
||||||
|
pub enabled: bool, // honored by later systems, not by transform resolution
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`Node::new(name)` builds an enabled node. `enabled` is a declaration of intent:
|
||||||
|
Stage 3 only stores and toggles it; rendering/physics/audio will skip disabled
|
||||||
|
subtrees in later stages. It deliberately does **not** affect
|
||||||
|
`world_transform`, which is purely geometric.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Serialization
|
||||||
|
|
||||||
|
A scene round-trips through RON. Because `hecs::Entity` handles are not stable
|
||||||
|
across a save/load, the scene is flattened to an indexed node list in a
|
||||||
|
deterministic pre-order walk, so serialize → deserialize → serialize is
|
||||||
|
byte-for-byte stable.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let ron: String = scene.to_ron()?;
|
||||||
|
let restored = Scene::from_ron(&ron)?;
|
||||||
|
assert_eq!(ron, restored.to_ron()?); // identical
|
||||||
|
```
|
||||||
|
|
||||||
|
Corrupt input (out-of-range child indices, a node listed as both root and
|
||||||
|
child) is rejected with [`SceneError::Deserialize`].
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Errors
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub enum SceneError {
|
||||||
|
NoSuchEntity, // operation referenced a dead entity
|
||||||
|
WouldCycle, // reparent would make an entity its own ancestor
|
||||||
|
Serialize(String), // encoding to RON failed
|
||||||
|
Deserialize(String), // decoding failed or data was inconsistent
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
A complete, runnable tour lives in
|
||||||
|
[`examples/src/bin/scene_basic.rs`](../examples/src/bin/scene_basic.rs):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo run -p oxide-examples --bin scene_basic
|
||||||
|
```
|
||||||
|
|
||||||
|
It builds a sun/planet/moon hierarchy, prints local vs. world transforms,
|
||||||
|
reparents a node, round-trips through RON, and despawns with a detach policy.
|
||||||
|
|
||||||
|
## In the editor
|
||||||
|
|
||||||
|
`oxide-editor` renders a **Scene Hierarchy** panel (left) and an **Inspector**
|
||||||
|
(right) over the viewport, driving this same API: select a node, rename it,
|
||||||
|
toggle its `enabled` flag, reparent it via the Inspector's parent dropdown, and
|
||||||
|
add/delete nodes from the toolbar. The egui integration is editor-only — the
|
||||||
|
engine exposes a generic post-clear draw hook ([`App::render`]) and keeps egui
|
||||||
|
out of its own dependency tree.
|
||||||
|
|
||||||
|
[`App::render`]: ../engine/src/window/app.rs
|
||||||
|
|
||||||
|
## Design notes
|
||||||
|
|
||||||
|
- **Why hierarchy outside the ECS?** Storing `Parent`/`Children` as components
|
||||||
|
is idiomatic but makes ordered iteration and reparenting awkward (archetype
|
||||||
|
moves, borrow juggling) and gives no ordering guarantee. Keeping the tree in
|
||||||
|
the `Scene` yields deterministic child order — which serialization and the
|
||||||
|
editor both rely on — and O(1) link edits. The ECS still owns all entity
|
||||||
|
*data*.
|
||||||
|
- **World transforms are resolved, not stored.** There is no dirty-flag cache
|
||||||
|
yet; `world_transforms()` recomputes in one pass. A cache can be added later
|
||||||
|
behind the same API without changing callers.
|
||||||
|
- **Despawn policies** map onto the two things callers actually want: delete a
|
||||||
|
whole subtree, or remove one node and keep its children.
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
# Scripting (`oxide-script`, rhai) — Stage 10
|
||||||
|
|
||||||
|
The scripting module makes game logic live in **watched `.rhai` scripts** that
|
||||||
|
hot-reload while the editor runs. It is a feature-gated module built on
|
||||||
|
[`rhai`](https://rhai.rs) — an embeddable, sandboxed, Rust-friendly scripting
|
||||||
|
language — and plugs into the engine through the Stage-5 module system exactly
|
||||||
|
like [physics](physics.md): add [`ScriptModule`](#scriptmodule) to an `App` and
|
||||||
|
the [`Script`](#the-script-component) component becomes live.
|
||||||
|
|
||||||
|
> **Status.** This page tracks Stage 10 as it lands piece by piece. **Done so
|
||||||
|
> far:** the component data model, the script asset + `.rhai` loader, the engine
|
||||||
|
> wrapper, the module wiring, the per-frame lifecycle (`init` / `update(dt)`)
|
||||||
|
> driven by the [`ScriptHost`](#the-scripthost-lifecycle), the engine API
|
||||||
|
> (`Vec3` + ambient transform functions) scripts use to read/write their
|
||||||
|
> entity's `Transform`, **headless [live reload](#live-reload)** (edit a
|
||||||
|
> `.rhai` file → the running script recompiles, no restart), and **[editor
|
||||||
|
> integration](#editor-integration)** (Script is addable in the inspector; Play
|
||||||
|
> runs scripts and live-reload reaches a *playing* scene), and **[error/output
|
||||||
|
> surfacing](#errors-and-output-in-the-console)** (a paused script's error and
|
||||||
|
> its `print` output show in the editor Console panel), and a **[command
|
||||||
|
> terminal](#the-command-terminal)** in that panel, plus an **[interactive PTY
|
||||||
|
> terminal](#interactive-terminal-pty)** that runs shells / TUIs / AI-agent CLIs
|
||||||
|
> like `claude`. **Remaining for the stage:** a richer script API (spawn/despawn
|
||||||
|
> + component add/edit, beyond `Transform`).
|
||||||
|
|
||||||
|
## The model: ECS is the source of truth
|
||||||
|
|
||||||
|
As with physics, the **ECS owns the truth**. An entity opts into scripting with
|
||||||
|
one serializable, reflected component — [`Script`](#the-script-component) — that
|
||||||
|
carries only *authoring* inputs (which script, enabled or not). The script's
|
||||||
|
behaviour is **not** stored on the component:
|
||||||
|
|
||||||
|
- The source lives on disk as a `.rhai` file, loaded as a
|
||||||
|
[`ScriptAsset`](#scriptasset-the-loaded-source) (kept as plain text so a live
|
||||||
|
edit just recompiles).
|
||||||
|
- The compiled AST and any per-entity runtime state live in the host (a later
|
||||||
|
piece), keyed by entity.
|
||||||
|
|
||||||
|
Because the authored component carries no runtime state, **play-mode
|
||||||
|
snapshot/restore works for free**: Stop reverts the authored `Script` components
|
||||||
|
and the next Play recompiles fresh.
|
||||||
|
|
||||||
|
## The `Script` component
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::asset::AssetRef;
|
||||||
|
use oxide_script::{Script, ScriptAsset};
|
||||||
|
|
||||||
|
// Empty + disabled by default; `new` points at a source and enables it.
|
||||||
|
let script = Script::new(AssetRef::new(uid)); // uid from the asset database
|
||||||
|
assert!(script.enabled);
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Meaning |
|
||||||
|
|-------|------|---------|
|
||||||
|
| `source` | `AssetRef<ScriptAsset>` | Which `.rhai` script the entity runs. Empty until assigned. |
|
||||||
|
| `enabled` | `bool` | Whether the script runs; clear to suspend it without detaching. |
|
||||||
|
|
||||||
|
`source` is an [`AssetRef<T>`](assets.md), not a live `Handle<T>`, so it is
|
||||||
|
serializable and stable across runs; the inspector recognises the
|
||||||
|
`AssetRef<ScriptAsset>` spelling and offers a picker filtered to the `scripts/`
|
||||||
|
folder (`AssetKind::Script`). The component derives `Reflect`, so it is
|
||||||
|
dual-editable from the inspector, from scripts, and from external tools with no
|
||||||
|
per-type editor code.
|
||||||
|
|
||||||
|
## `ScriptAsset`: the loaded source
|
||||||
|
|
||||||
|
A `ScriptAsset` is just the script's source text plus a diagnostic name (the
|
||||||
|
file stem). It is deliberately inert — holding *source*, not behaviour — so the
|
||||||
|
same file can be recompiled on every live reload with no engine-specific data
|
||||||
|
baked into the asset cache.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_script::ScriptAsset;
|
||||||
|
|
||||||
|
let asset = ScriptAsset::from_source("spin", "let t = 0.0;");
|
||||||
|
assert_eq!(asset.name, "spin");
|
||||||
|
```
|
||||||
|
|
||||||
|
The `ScriptLoader` reads `.rhai` files; it is registered by `ScriptModule`, so
|
||||||
|
`assets.load::<ScriptAsset>("scripts/spin.rhai")` works once the module is added.
|
||||||
|
`.rhai` files map to the new `AssetKind::Script` (folder `scripts/`).
|
||||||
|
|
||||||
|
## The `ScriptEngine` wrapper
|
||||||
|
|
||||||
|
`ScriptEngine` owns one configured `rhai` interpreter the host reuses to compile
|
||||||
|
and run every script, so sandbox configuration lives in one place:
|
||||||
|
|
||||||
|
- `print` / `debug` output is routed to the `log` crate (so the editor console
|
||||||
|
can surface it rather than leaking to stdout);
|
||||||
|
- an operation cap (`set_max_operations`) turns a runaway loop into a **runtime
|
||||||
|
error** instead of hanging the editor.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_script::{ScriptAsset, ScriptEngine};
|
||||||
|
|
||||||
|
let engine = ScriptEngine::new();
|
||||||
|
let asset = ScriptAsset::from_source("ok", "let x = 1 + 2; print(x);");
|
||||||
|
let compiled = engine.compile(&asset)?; // -> CompiledScript (reusable AST)
|
||||||
|
engine.run(&compiled)?; // evaluate the top level
|
||||||
|
```
|
||||||
|
|
||||||
|
Errors are a `thiserror` enum, [`ScriptError`], with two variants that both name
|
||||||
|
the offending script so the console can attribute the failure:
|
||||||
|
|
||||||
|
- `ScriptError::Compile` — the source failed to parse/compile;
|
||||||
|
- `ScriptError::Runtime` — it compiled but raised an error (or hit the operation
|
||||||
|
cap) while running.
|
||||||
|
|
||||||
|
A runtime error is **returned, never panicked**, so the host can pause just that
|
||||||
|
one script rather than crash the editor — the foundation for Stage 10's
|
||||||
|
error-isolation goal.
|
||||||
|
|
||||||
|
## `ScriptModule`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::app::App;
|
||||||
|
use oxide_script::ScriptModule;
|
||||||
|
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_module(ScriptModule);
|
||||||
|
assert!(app.has_module("script"));
|
||||||
|
assert!(app.types.is_registered("Script"));
|
||||||
|
```
|
||||||
|
|
||||||
|
`ScriptModule::build` registers the `Script` component type for reflection
|
||||||
|
(making it dual-editable and snapshot-captured), installs the `.rhai`
|
||||||
|
`ScriptLoader`, inserts the [`ScriptHost`](#the-scripthost-lifecycle), and adds
|
||||||
|
the `run_scripts` system on the `Update` schedule (after `FixedUpdate`, so
|
||||||
|
scripts observe post-physics poses). Removing the module drops everything it
|
||||||
|
contributed, as for any module.
|
||||||
|
|
||||||
|
## The `ScriptHost` (lifecycle)
|
||||||
|
|
||||||
|
`ScriptHost` is the scripting counterpart to physics' `PhysicsWorld`: a transient
|
||||||
|
`App` resource holding the **per-entity runtime state** — the compiled AST and a
|
||||||
|
persistent `rhai` `Scope` — that the authored `Script` component deliberately
|
||||||
|
does not. The `run_scripts` system drives it each frame:
|
||||||
|
|
||||||
|
1. find every entity with an **enabled** `Script` that names a source;
|
||||||
|
2. resolve each one's `.rhai` text through the `AssetDatabase` + `AssetServer`;
|
||||||
|
3. **(re)compile + `start`** any script that is new or whose source text changed
|
||||||
|
(this content check is the hook live reload builds on);
|
||||||
|
4. stage the entity's `Transform` into the engine, call `update(dt)`, and write
|
||||||
|
the (possibly mutated) transform back to the scene.
|
||||||
|
|
||||||
|
Because runtime state lives in the host keyed by entity — never on the component
|
||||||
|
— play-mode snapshot/restore is unaffected. A script that fails to compile or
|
||||||
|
raises a runtime error is **paused** (its error remembered, reported via
|
||||||
|
`ScriptHost::error_of`) rather than retried every frame or allowed to crash the
|
||||||
|
host — the start of Stage 10's error-isolation goal.
|
||||||
|
|
||||||
|
## Live reload
|
||||||
|
|
||||||
|
Editing a script while the app runs takes effect with **no restart**. The host
|
||||||
|
keeps each running script's `Handle<ScriptAsset>` alive, so the asset stays in
|
||||||
|
the server's cache. When the Stage-6 file watcher sees a `.rhai` file change it
|
||||||
|
calls
|
||||||
|
[`reload_changed_assets`](file-watching.md), which reruns the loader **in place**
|
||||||
|
on the live handle; the next frame the host reads the new source through that
|
||||||
|
handle, sees it differs from what it compiled, and recompiles + restarts that one
|
||||||
|
script. The entity keeps its current transform and the rest of the scene is
|
||||||
|
untouched.
|
||||||
|
|
||||||
|
`examples/script_spin` proves this headlessly: it spins an entity at 1 rad/s,
|
||||||
|
rewrites the script to 3 rad/s, reloads it the way the watcher does, and the spin
|
||||||
|
rate jumps mid-run while the orientation carries over.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo run -p oxide-examples --bin script_spin
|
||||||
|
```
|
||||||
|
|
||||||
|
> Wiring this into the editor's **play loop** (so editing a script in an external
|
||||||
|
> editor or via an AI agent updates a *playing* scene) is a following piece; the
|
||||||
|
> reload mechanism itself is done and tested.
|
||||||
|
|
||||||
|
### Lifecycle hooks
|
||||||
|
|
||||||
|
A script may define either or both of these functions; top-level statements run
|
||||||
|
once at start (a constructor for defining functions and one-shot setup):
|
||||||
|
|
||||||
|
```rhai
|
||||||
|
// scripts/spin.rhai — rotate this entity around Y at a constant rate.
|
||||||
|
let speed = 1.5; // top-level state, set once at start
|
||||||
|
|
||||||
|
fn init() { // optional: called once, after the top level
|
||||||
|
print("spin starting");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(dt) { // optional: called every frame with the frame delta
|
||||||
|
rotate_y(dt * 1.5);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Hook | When | Signature |
|
||||||
|
|------|------|-----------|
|
||||||
|
| top level | once, when the script (re)starts | statements at file scope |
|
||||||
|
| `init()` | once, right after the top level | `fn init()` |
|
||||||
|
| `update(dt)` | every frame | `fn update(dt)` — `dt` is seconds |
|
||||||
|
|
||||||
|
## Editor integration
|
||||||
|
|
||||||
|
Scripting plugs into the editor the same way physics does:
|
||||||
|
|
||||||
|
- **Add Component.** `register_builtin_types` registers `Script` as an *addable*
|
||||||
|
reflected component, so it appears in the inspector's Add Component menu and is
|
||||||
|
rendered generically from its fields. The `source` field is an
|
||||||
|
`AssetRef<ScriptAsset>`, which the inspector shows as an asset picker filtered
|
||||||
|
to the project's `scripts/` folder (`AssetKind::Script`).
|
||||||
|
- **New Script button.** The `Script` section of the inspector ends with a name
|
||||||
|
field + **➕ New Script**: it writes a fresh `.rhai` from the built-in template
|
||||||
|
into `assets/scripts/` (name sanitized, collisions suffixed `_2`, `_3`, …),
|
||||||
|
registers it in the asset database, and assigns it to the component through
|
||||||
|
the undo stack — no round-trip through an external file manager. The template
|
||||||
|
is `oxide_editor::assets::script_template` (a unit test compiles it under the
|
||||||
|
sandboxed engine so it can never ship a syntax error).
|
||||||
|
- **Open in editor.** Next to New Script sits **✏ Edit** (enabled once a script
|
||||||
|
is assigned), and double-clicking a script in the **Project panel** does the
|
||||||
|
same: the file opens in your editor. Resolution order: the **External Editor**
|
||||||
|
command from Preferences (`editor.external_editor`, run as `<command> <file>`,
|
||||||
|
flags allowed — e.g. `code -g`); else `$VISUAL`/`$EDITOR` in a new
|
||||||
|
**Terminal-panel tab** (TUI editors work in-editor); else `xdg-open`. However
|
||||||
|
it opens, saving feeds the file watcher's live reload — including into a
|
||||||
|
playing scene. Double-clicking a non-script asset opens it via `xdg-open`.
|
||||||
|
- **Play loop.** When Play starts, the editor's play `App` adds `ScriptModule`
|
||||||
|
alongside `PhysicsModule`, **shares the editor's `AssetServer`**, and is handed
|
||||||
|
a snapshot of the project `AssetDatabase`. Sharing the server is what lets the
|
||||||
|
file watcher's in-place reloads — which target the editor server — reach a
|
||||||
|
**playing** scene: edit a `.rhai` file (by hand, an external editor, or an AI
|
||||||
|
agent) and the running script recompiles without leaving Play.
|
||||||
|
- **Snapshot/restore.** Because `Script` is a reflected component, the play-mode
|
||||||
|
snapshot captures it; a script attached or detached *during* play is reverted
|
||||||
|
on Stop, like any other component.
|
||||||
|
|
||||||
|
## Errors and output in the Console
|
||||||
|
|
||||||
|
A script that fails to compile or raises a runtime error is **paused** — it stops
|
||||||
|
running but the editor stays alive (the error is caught, never panicked). The
|
||||||
|
host logs the failure once via `log::warn!(target: "oxide_script", …)` and
|
||||||
|
remembers it (`ScriptHost::error_of`), so it is not retried until the source
|
||||||
|
changes.
|
||||||
|
|
||||||
|
The editor's **Console panel** captures the `log` stream into a ring buffer and
|
||||||
|
renders it, coloured by severity. Because script `print`/`debug` and the
|
||||||
|
"script paused: …" errors all flow through `log` under the `oxide_script` target,
|
||||||
|
they appear in the Console automatically — so you see a script's output and its
|
||||||
|
failures without leaving the editor.
|
||||||
|
|
||||||
|
## The command terminal
|
||||||
|
|
||||||
|
The Console panel doubles as a **command terminal**: a `$` prompt runs a shell
|
||||||
|
command (`sh -c`) with the working directory set to the open project's root, and
|
||||||
|
its stdout/stderr stream back into the same panel line by line as they arrive
|
||||||
|
(stdout at info, stderr at warn, plus the echoed command and an exit-status
|
||||||
|
line). A long-running command — a build, a watcher, an AI-agent CLI — streams
|
||||||
|
rather than blocking the editor: reader threads push each line to the shared
|
||||||
|
console buffer and the panel re-renders next frame.
|
||||||
|
|
||||||
|
This is the surface for non-interactive dev tools. Running arbitrary commands
|
||||||
|
from the editor is intentional (a developer tool, compiled out of an exported
|
||||||
|
game).
|
||||||
|
|
||||||
|
### Interactive terminal (PTY)
|
||||||
|
|
||||||
|
The command console pipes output and can't run programs that need a real
|
||||||
|
terminal. The separate **Terminal panel** can: it opens a pseudo-terminal with
|
||||||
|
[`portable-pty`] (Linux now, Windows later), parses the program's byte stream
|
||||||
|
with [`vt100`] into a screen grid, renders that grid in egui, and routes
|
||||||
|
keystrokes back — so it runs **interactive / full-screen programs**: a shell, a
|
||||||
|
REPL, `vim`, or an **AI-agent CLI like `claude`**. Click *Shell* to start your
|
||||||
|
`$SHELL` in the project directory, then run whatever you need inside it
|
||||||
|
(`claude`, an editor, a build watcher). Tab, the arrow keys, and Escape are
|
||||||
|
delivered to the program (not used for egui focus navigation) while the panel is
|
||||||
|
focused, so completion, history, and full-screen apps work. Sessions are
|
||||||
|
**tabbed** — `+ Shell` opens another, each tab has a close button, and a tab
|
||||||
|
**auto-closes when its program exits** (type `exit` and the tab disappears).
|
||||||
|
This is what hosts agents that edit the watched scripts live — their edits flow
|
||||||
|
back through [live reload](#live-reload).
|
||||||
|
|
||||||
|
The two pieces compose: use the **Console** for builds/git/log output, the
|
||||||
|
**Terminal** for interactive sessions.
|
||||||
|
|
||||||
|
[`portable-pty`]: https://docs.rs/portable-pty
|
||||||
|
[`vt100`]: https://docs.rs/vt100
|
||||||
|
|
||||||
|
## The engine API scripts call
|
||||||
|
|
||||||
|
A script does not get a raw ECS pointer. The host stages the active entity's
|
||||||
|
`Transform` into a shared context before each call; the script reads and mutates
|
||||||
|
it through **ambient functions**, and the host writes the result back. This is
|
||||||
|
the `Transform` half of dual-editability: a script and the inspector edit the
|
||||||
|
**same** transform.
|
||||||
|
|
||||||
|
| Function | Effect |
|
||||||
|
|----------|--------|
|
||||||
|
| `position() -> Vec3` | the entity's local translation |
|
||||||
|
| `set_position(Vec3)` | set the translation |
|
||||||
|
| `translate(Vec3)` / `translate(x, y, z)` | add to the translation |
|
||||||
|
| `scale() -> Vec3` / `set_scale(Vec3)` | get/set the local scale |
|
||||||
|
| `rotate_x/rotate_y/rotate_z(radians)` | spin about an axis (accumulates) |
|
||||||
|
| `dt() -> f32` | the current frame delta (also passed to `update`) |
|
||||||
|
|
||||||
|
The `Vec3` type is registered with `vec3(x, y, z)` / `vec3()`, `.x`/`.y`/`.z`
|
||||||
|
get/set, `+ - *` (and scalar `*`), `length()`, `normalize()`, and `to_string()`.
|
||||||
|
The `rhai` float type is configured to `f32` (the `f32_float` feature), so engine
|
||||||
|
math values bridge into scripts with no casts.
|
||||||
|
|
||||||
|
### Spawning entities and editing components
|
||||||
|
|
||||||
|
Beyond its own transform, a script can mutate the whole scene graph: spawn and
|
||||||
|
despawn entities, and add, edit, or remove **any registered component** on any
|
||||||
|
entity. The same "stage in, read back out" discipline applies — a script cannot
|
||||||
|
borrow the ECS directly (the `rhai` functions must be `Send + Sync`), so each of
|
||||||
|
these calls **buffers a command** that the host drains and applies against the
|
||||||
|
scene + reflection registry after the script returns. Component edits go through
|
||||||
|
**RON**, so a script authors data exactly like the inspector or an AI agent does
|
||||||
|
— the same dual-editable representation.
|
||||||
|
|
||||||
|
| Function | Effect |
|
||||||
|
|----------|--------|
|
||||||
|
| `entity() -> Entity` | the entity this script runs on |
|
||||||
|
| `spawn_entity() -> Entity` / `spawn_entity(name)` | create a root entity, returns a handle usable immediately |
|
||||||
|
| `despawn(Entity)` | remove an entity (and its subtree) |
|
||||||
|
| `add_component(Entity, type_name)` | add a default-constructed component if the type is *addable* and absent |
|
||||||
|
| `set_component(Entity, type_name, ron)` | insert or replace a component from its RON form |
|
||||||
|
| `remove_component(Entity, type_name)` | remove the named component if present |
|
||||||
|
|
||||||
|
`spawn_entity` returns a **provisional** `Entity` handle: the entity does not
|
||||||
|
exist in the ECS yet, but the script can configure it in the same frame
|
||||||
|
(`set_component(e, …)`, `despawn(e)`) — the host resolves the provisional id to
|
||||||
|
the real entity when it applies the buffered commands, in issue order. (`spawn`
|
||||||
|
is a reserved word in `rhai`, hence the longer name.)
|
||||||
|
|
||||||
|
`type_name` is the name the component was registered under (e.g. `"MeshRenderer"`,
|
||||||
|
`"RigidBody"`, or your own `register_type::<T>("…")`). A command that names an
|
||||||
|
unregistered type, or whose RON fails to parse, is logged to the Console and
|
||||||
|
skipped — one bad call never aborts the rest or crashes the host.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Spawn a pickup and configure it in one frame:
|
||||||
|
fn init() {
|
||||||
|
let pickup = spawn_entity("Coin");
|
||||||
|
set_component(pickup, "MeshRenderer", "(mesh: Some(\"coin\"), ...)");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
[`ScriptError`]: #the-scriptengine-wrapper
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# Settings & Preferences Framework
|
||||||
|
|
||||||
|
`oxide_engine::settings` is the engine's unified, serialized configuration store.
|
||||||
|
The engine, the editor, and every module register typed **sections**; the
|
||||||
|
framework persists them all to RON and restores them — without any central code
|
||||||
|
knowing the sections' shapes. It is the backbone of the editor's Preferences
|
||||||
|
window and of per-module settings.
|
||||||
|
|
||||||
|
## Sections
|
||||||
|
|
||||||
|
A section is any plain `serde`-serializable struct with a `Default`. Register it
|
||||||
|
once under a name and the store owns a typed instance:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::settings::Settings;
|
||||||
|
use serde::{Serialize, Deserialize};
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Default)]
|
||||||
|
struct EditorPrefs { theme: String, grid: bool }
|
||||||
|
|
||||||
|
let mut settings = Settings::new();
|
||||||
|
settings.register::<EditorPrefs>("editor");
|
||||||
|
|
||||||
|
// Typed read/write.
|
||||||
|
settings.get_mut::<EditorPrefs>("editor").unwrap().theme = "dark".into();
|
||||||
|
assert_eq!(settings.get::<EditorPrefs>("editor").unwrap().theme, "dark");
|
||||||
|
```
|
||||||
|
|
||||||
|
`set` replaces a section's value (only if the registered type matches), and
|
||||||
|
`reset` returns it to `Default`. Accessing a section as the wrong type returns
|
||||||
|
`None` rather than panicking.
|
||||||
|
|
||||||
|
## Persisting
|
||||||
|
|
||||||
|
Every section serializes to a `name → RON` map via `export`, and `import` loads
|
||||||
|
matching sections back. This map is exactly the shape a
|
||||||
|
[`Project`](projects.md) stores, so per-project settings round-trip through the
|
||||||
|
project file:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
# use oxide_engine::settings::Settings;
|
||||||
|
# use serde::{Serialize, Deserialize};
|
||||||
|
# #[derive(Serialize, Deserialize, Default)] struct EditorPrefs { theme: String }
|
||||||
|
# let mut settings = Settings::new();
|
||||||
|
# settings.register::<EditorPrefs>("editor");
|
||||||
|
let saved = settings.export(); // BTreeMap<String, String>
|
||||||
|
|
||||||
|
let mut restored = Settings::new();
|
||||||
|
restored.register::<EditorPrefs>("editor");
|
||||||
|
restored.import(&saved); // matching sections restored
|
||||||
|
```
|
||||||
|
|
||||||
|
`import` is deliberately lenient: an **unknown** section (e.g. one owned by a
|
||||||
|
disabled module) is ignored, and a **malformed** section is skipped, leaving its
|
||||||
|
current value. This means a project saved with a module enabled still opens
|
||||||
|
cleanly with that module disabled, and vice-versa.
|
||||||
|
|
||||||
|
## How the layers fit together
|
||||||
|
|
||||||
|
| Scope | Lives where | Persisted to |
|
||||||
|
|-------|-------------|--------------|
|
||||||
|
| Engine preferences (render/quality defaults) | a `Settings` section | global prefs file / project |
|
||||||
|
| Editor preferences (theme, layout, shortcuts) | a `Settings` section | global editor prefs file |
|
||||||
|
| Per-module settings | each module registers a section | the [project](projects.md) it's enabled in |
|
||||||
|
|
||||||
|
`Project` stores the per-project subset (`set_settings_section` /
|
||||||
|
`settings_section` hold the same RON blobs `export`/`import` produce). The editor
|
||||||
|
keeps its global preferences in a separate file using the same `Settings` API.
|
||||||
|
|
||||||
|
[`Settings`]: ../engine/src/settings.rs
|
||||||
+860
@@ -0,0 +1,860 @@
|
|||||||
|
# UI System
|
||||||
|
|
||||||
|
The `oxide_engine::ui` module is the engine's **in-game** UI system — what an
|
||||||
|
exported game uses to draw menus, HUDs, and tools. It is intentionally
|
||||||
|
separate from the editor's `egui` (which stays editor-only): a shipped game
|
||||||
|
cannot link `egui`, so the runtime owns its own widget tree, lays it out,
|
||||||
|
batches it through the Stage-5 render pipeline, and routes input through the
|
||||||
|
Stage-7 model.
|
||||||
|
|
||||||
|
Stage 8 ships in pieces. This document covers what is live today and tells
|
||||||
|
you where the rest is going.
|
||||||
|
|
||||||
|
## What's live today
|
||||||
|
|
||||||
|
- **Piece 1 — widget tree + layout** (data structures, three layout modes,
|
||||||
|
pure-logic layout function). See [below](#whats-in-piece-1--widget-tree--layout).
|
||||||
|
- **Piece 2 — styling & theming** (per-widget visual overrides, named-style
|
||||||
|
themes, RON cascade). See [below](#whats-in-piece-2--styling--theming).
|
||||||
|
- **Piece 3 — text shaping & glyph atlas** (TTF loading via `ab_glyph`,
|
||||||
|
shelf-packed R8 atlas, multi-font line wrapping with alignment + DPI
|
||||||
|
scaling). See [below](#whats-in-piece-3--text-shaping--glyph-atlas).
|
||||||
|
- **Piece 4a — screen-space overlay render pass** (`paint` turns a laid-out
|
||||||
|
tree into draw commands; `UiOverlayPass` batches them through wgpu with one
|
||||||
|
R8 atlas and one alpha-blended pipeline). See [below](#whats-in-piece-4a--screen-space-overlay-render-pass).
|
||||||
|
- **Piece 4b — world-space UI panels** (`UiPanel` carries a `Widget` tree +
|
||||||
|
pixel/world sizes; `UiBatch::world_space(...)` composes the MVP that
|
||||||
|
places the UI on a 3D quad through a perspective camera). See [below](#whats-in-piece-4b--world-space-ui-panels).
|
||||||
|
- **Piece 5 — input routing** (`Router` walks the `LayoutTree` against the
|
||||||
|
Stage-7 `InputState`, tracks hover / press / focus per widget, and emits
|
||||||
|
events plus capture flags the host uses to decide whether the game also
|
||||||
|
receives the input). See [below](#whats-in-piece-5--input-routing).
|
||||||
|
- **Piece 6 — events + data binding** (immediate-mode queries on
|
||||||
|
`RouterFrame` — `clicked_left("play")` etc. — plus typed `WidgetValue`s
|
||||||
|
on the tree so game state and widget state round-trip each frame). See
|
||||||
|
[below](#whats-in-piece-6--events--data-binding).
|
||||||
|
- **Piece 7 — `examples/ui_menu`** (runnable main menu + settings panel
|
||||||
|
built entirely from the Stage-8 stack: themed buttons, a draggable
|
||||||
|
volume slider, a clickable invert-Y checkbox, Back/Quit navigation).
|
||||||
|
Run with `cargo run -p oxide-examples --bin ui_menu`.
|
||||||
|
- **Piece 8 — `examples/ui_hud`** (a game HUD composited on top of a live
|
||||||
|
3D scene: the Stage-4 `ForwardPass` renders the spinning cube/sphere/
|
||||||
|
plane, then a screen-space `UiOverlayPass` draws corner-anchored HP/Ammo
|
||||||
|
chips, a minimap stand-in with an orbiting dot, and a centre crosshair —
|
||||||
|
with animated digits that demonstrate the glyph-atlas cache reaching
|
||||||
|
steady state). Run with `cargo run -p oxide-examples --bin ui_hud`. See
|
||||||
|
[below](#whats-in-piece-8--examplesui_hud).
|
||||||
|
- **Editor UI canvas** (Stage 8.5 piece 7) — the editor's **UI Canvas** panel
|
||||||
|
authors a `UiPanel` document visually: a widget-tree view (positional
|
||||||
|
[`WidgetPath`](../engine/src/ui/widget.rs) addressing), an Add palette
|
||||||
|
(Leaf/Row/Column/Grid/Anchor), a scaled live preview, and a property panel
|
||||||
|
(id, text, colors, font size, **font-asset picker**, layout sizing). Edits are
|
||||||
|
undoable and the document saves as a `ui/` asset — the same RON the runtime
|
||||||
|
loads. The picker writes [`VisualStyle::font_asset`](../engine/src/ui/visual.rs),
|
||||||
|
resolved through the [asset database](assets.md).
|
||||||
|
|
||||||
|
## What's in piece 1 — widget tree + layout
|
||||||
|
|
||||||
|
Piece 1 is pure-logic: data structures + a deterministic layout function. No
|
||||||
|
GPU, no input, no async. Every test runs headlessly.
|
||||||
|
|
||||||
|
- **`Widget`** — one node in a tree. Holds an [`id`](#widget-ids), a
|
||||||
|
[`LayoutStyle`](#layoutstyle), and a [`WidgetKind`](#widgetkinds).
|
||||||
|
- **`WidgetKind`** — what the node is:
|
||||||
|
- `Leaf { intrinsic: Vec2 }` — childless node sized by an intrinsic logical
|
||||||
|
extent. Interactive widgets (label, button, image, slider, …) layer on
|
||||||
|
top of this in later pieces.
|
||||||
|
- `Stack(Stack)` — row or column container with a per-stack `gap`,
|
||||||
|
`direction`, and `main_align`.
|
||||||
|
- `Grid(Grid)` — equal-cell `cols × rows` container with a `gap: Vec2`.
|
||||||
|
- `Anchor(AnchorGroup)` — container that positions each child via the
|
||||||
|
**child's** own [`Anchor`](#anchor).
|
||||||
|
- **`LayoutStyle`** — sizing, padding, margin, alignment, and (for anchor
|
||||||
|
children) the anchor itself. The same flat struct on every widget.
|
||||||
|
- **`layout(root, viewport, scale) -> LayoutTree`** — the layout function.
|
||||||
|
Returns a `LayoutTree` of `LayoutNode`s (one per widget, root at index 0)
|
||||||
|
with each node's resolved `rect`, `content_rect` (padding-inset), and the
|
||||||
|
indices of its direct children.
|
||||||
|
|
||||||
|
The whole module lives under
|
||||||
|
[`engine/src/ui/`](../engine/src/ui/) and is re-exported through the engine
|
||||||
|
prelude under disambiguated names (`UiSizing`, `UiAnchor`, `Widget`, …) so it
|
||||||
|
doesn't collide with the Stage-1 math types.
|
||||||
|
|
||||||
|
## Building a widget tree
|
||||||
|
|
||||||
|
The `Widget::row()`, `Widget::column()`, `Widget::grid(cols, rows)`,
|
||||||
|
`Widget::anchor()`, and `Widget::leaf(intrinsic)` constructors plus the
|
||||||
|
`with_*` builder methods produce trees declaratively. Builder methods that
|
||||||
|
only make sense on certain kinds (`with_gap` on a stack, `with_grid_gap` on a
|
||||||
|
grid, `with_child` on any container) panic with a clear message when called
|
||||||
|
on the wrong kind — catching author mistakes during construction instead of
|
||||||
|
producing a silently misshapen UI at layout time.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::math::Vec2;
|
||||||
|
use oxide_engine::ui::{Insets, LayoutStyle, Sizing, Widget};
|
||||||
|
|
||||||
|
let toolbar = Widget::row()
|
||||||
|
.with_id("toolbar")
|
||||||
|
.with_gap(8.0)
|
||||||
|
.with_style(LayoutStyle {
|
||||||
|
width: Sizing::Grow(1.0),
|
||||||
|
height: Sizing::Fixed(32.0),
|
||||||
|
padding: Insets::all(4.0),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.with_child(Widget::leaf(Vec2::new(64.0, 24.0)).with_id("file"))
|
||||||
|
.with_child(Widget::leaf(Vec2::new(64.0, 24.0)).with_id("edit"));
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sizing
|
||||||
|
|
||||||
|
`Sizing` controls how a widget asks to be sized along one axis.
|
||||||
|
|
||||||
|
| Variant | Behavior |
|
||||||
|
|---------|----------|
|
||||||
|
| `Fixed(f32)` | Fixed logical size; multiplied by the layout scale factor. |
|
||||||
|
| `Grow(f32)` | Take a share of the parent's leftover space, weighted by `f32`. Two siblings with `Grow(1.0)` split evenly; `Grow(2.0)` next to `Grow(1.0)` takes 2/3. A non-positive weight contributes nothing. |
|
||||||
|
| `FitContent` (default) | Fit the widget's intrinsic content size — leaves use their `intrinsic`, containers use the recursive content extent. |
|
||||||
|
|
||||||
|
The defaults of `FitContent × FitContent` are intentional: leaves are sized
|
||||||
|
by what they contain, containers are sized by what they wrap. A root widget
|
||||||
|
that wants to **fill the viewport** must opt in with
|
||||||
|
`Sizing::Grow(_)` on both axes (or set `Fixed` extents) — the layout function
|
||||||
|
makes no special root case.
|
||||||
|
|
||||||
|
## Padding, margin, alignment
|
||||||
|
|
||||||
|
- **`padding`** shrinks a widget's `content_rect`, the area inside which
|
||||||
|
children are arranged. Multiplied by the scale factor.
|
||||||
|
- **`margin`** reserves space *outside* the widget's rect, so siblings don't
|
||||||
|
touch it. In a stack, margin is added to the child's main-axis footprint
|
||||||
|
before grow accounting.
|
||||||
|
- **`align_horizontal` / `align_vertical`** position a widget within its
|
||||||
|
parent's slot when the widget's resolved size is **smaller** than the slot.
|
||||||
|
In a stack, cross-axis alignment lets a short child dock to the top,
|
||||||
|
middle, or bottom of its row. (The stack-level `main_align` does the
|
||||||
|
analogous thing on the main axis when there's no `Grow` child to absorb
|
||||||
|
leftover space.)
|
||||||
|
|
||||||
|
## Layout modes
|
||||||
|
|
||||||
|
### Stack (`StackDirection::Row` / `Column`)
|
||||||
|
|
||||||
|
1. Allocate each child's **main-axis** size:
|
||||||
|
- `Fixed(v)` → `v * scale`,
|
||||||
|
- `FitContent` → recursive intrinsic measurement,
|
||||||
|
- `Grow(w)` → reserved (zero first), then assigned a share of leftover
|
||||||
|
space proportional to `w`.
|
||||||
|
2. **Cross-axis** sizing happens during the child's own `arrange_in_slot`
|
||||||
|
pass: `Grow` fills the parent's cross extent; the other variants leave
|
||||||
|
space the child's `align_*` consumes.
|
||||||
|
3. With no `Grow` child, the stack's `main_align` (Start / Center / End)
|
||||||
|
positions the children's combined footprint inside the content rect.
|
||||||
|
|
||||||
|
### Grid
|
||||||
|
|
||||||
|
Equal-cell `cols × rows` layout. Cell size is computed from the parent's
|
||||||
|
content rect after subtracting `(cols - 1) * gap.x` and `(rows - 1) * gap.y`.
|
||||||
|
Children fill cells left-to-right, top-to-bottom; extras past `cols * rows`
|
||||||
|
are ignored. Within a cell the child's own `align_*` and sizing decide how it
|
||||||
|
positions itself — `Grow` fills the cell, `Fixed`/`FitContent` aligns inside
|
||||||
|
it.
|
||||||
|
|
||||||
|
More flexible grids (auto-sized rows/columns, spans) are a follow-up; the
|
||||||
|
equal-cell case covers the Stage-7 bindings preferences page and the Stage-8
|
||||||
|
settings examples.
|
||||||
|
|
||||||
|
### Anchor
|
||||||
|
|
||||||
|
Each child specifies its own `Anchor` in `LayoutStyle::anchor`. The anchor is
|
||||||
|
two normalized points in `[0, 1]²` (the anchor rectangle) plus per-corner
|
||||||
|
offsets in logical pixels:
|
||||||
|
|
||||||
|
```text
|
||||||
|
rect.min = parent.content.min + parent.content.size * anchor.min + offset_min * scale
|
||||||
|
rect.max = parent.content.min + parent.content.size * anchor.max + offset_max * scale
|
||||||
|
```
|
||||||
|
|
||||||
|
The Unity/Godot convention applies: the anchor is **authoritative**. An
|
||||||
|
anchor child's `width`, `height`, `margin`, and `align_*` are ignored along
|
||||||
|
the axes the anchor constrains; padding still applies (it's an
|
||||||
|
inside-the-rect concern). The
|
||||||
|
`Anchor::FILL`, `Anchor::TOP`, `Anchor::TOP_LEFT`, `Anchor::BOTTOM_RIGHT`, …
|
||||||
|
constants cover the common cases, and `Anchor::between(min, max)` +
|
||||||
|
`with_offsets(min, max)` is the escape hatch.
|
||||||
|
|
||||||
|
## DPI
|
||||||
|
|
||||||
|
All linear inputs (sizing, padding, margin, gap, anchor offsets) are in
|
||||||
|
**logical pixels** and multiplied by the `scale` factor passed to
|
||||||
|
[`layout`]. The widget tree is DPI-independent; the layout call is where the
|
||||||
|
display's scale factor enters. The same widget tree laid out at `scale=1.0`
|
||||||
|
inside a 800 × 600 viewport and at `scale=2.0` inside a 1600 × 1200 viewport
|
||||||
|
produces identically *proportioned* rects, with every dimension doubled —
|
||||||
|
verified by an integration test.
|
||||||
|
|
||||||
|
## Widget ids and lookups
|
||||||
|
|
||||||
|
`WidgetId(pub String)` is the author-facing identifier. UI documents ship
|
||||||
|
their string ids straight through RON (`"play"`, `"volume-slider"`), so a
|
||||||
|
visual editor, a hand-edited file, and game code all refer to the same
|
||||||
|
widget. The empty id (`""`) is the default and means "anonymous"; multiple
|
||||||
|
anonymous widgets are allowed and `LayoutTree::find` rejects lookups by empty
|
||||||
|
id.
|
||||||
|
|
||||||
|
`LayoutTree::find(id)` is a linear scan — fine for the dozens-of-widgets
|
||||||
|
trees Stage 8 currently targets; a hash-map index can be added if a profile
|
||||||
|
ever says it's hot.
|
||||||
|
|
||||||
|
## RON dual-edit
|
||||||
|
|
||||||
|
Every type in the module derives `Serialize + Deserialize` and round-trips
|
||||||
|
through RON. `Widget::to_ron()` produces the pretty-printed canonical form
|
||||||
|
the editor's UI canvas saves and the runtime loads; `Widget::from_ron(text)`
|
||||||
|
parses it. The Stage-8 integration suite verifies that the round-trip
|
||||||
|
**preserves layout** — the laid-out trees match — so an external editor or AI
|
||||||
|
agent can edit the same file the runtime loads.
|
||||||
|
|
||||||
|
## What's in piece 2 — styling & theming
|
||||||
|
|
||||||
|
Visual styling is intentionally **orthogonal** to layout — layout decides
|
||||||
|
where a widget is; visual styling decides what it looks like. Adding a
|
||||||
|
`VisualStyle` or `theme_style` to a widget never changes its laid-out rect.
|
||||||
|
The integration suite verifies this with a paired `layout()` call before and
|
||||||
|
after styling.
|
||||||
|
|
||||||
|
The data:
|
||||||
|
|
||||||
|
- **`VisualStyle`** — a flat struct of `Option<T>` fields: `background`,
|
||||||
|
`foreground`, `border` (color + width), `corner_radius`, `font`, and
|
||||||
|
`font_size`. `None` means *inherit*; `Some` means *override*. Every field
|
||||||
|
serializes via `skip_serializing_if = "Option::is_none"`, so an empty
|
||||||
|
visual style vanishes from RON entirely.
|
||||||
|
- **`Theme`** — `default: VisualStyle` plus `styles: BTreeMap<String,
|
||||||
|
VisualStyle>`. The `BTreeMap` (not `HashMap`) gives deterministic RON
|
||||||
|
output, important for diff-friendly UI documents and reproducible test
|
||||||
|
snapshots.
|
||||||
|
- **`Widget`** gains two fields: `visual: VisualStyle` (per-instance
|
||||||
|
overrides) and `theme_style: Option<String>` (opt-in name into the
|
||||||
|
theme's named map).
|
||||||
|
|
||||||
|
The cascade — implemented by `Theme::resolve(style_ref, override_with)` and
|
||||||
|
exposed on the widget as `Widget::resolve_visual(&theme)`:
|
||||||
|
|
||||||
|
1. Start with `theme.default`.
|
||||||
|
2. If the widget specifies `theme_style: Some(name)` and the theme has a
|
||||||
|
matching entry, merge it on top (a missing name is treated as "no
|
||||||
|
contribution", not an error).
|
||||||
|
3. Merge the widget's per-instance `visual` on top.
|
||||||
|
|
||||||
|
Each merge is field-by-field via `VisualStyle::merged(self, override_with)`:
|
||||||
|
right-hand `Some` wins, otherwise the left-hand value is kept. The same
|
||||||
|
primitive will drive runtime state overlays in piece 5 (hover, focus,
|
||||||
|
press).
|
||||||
|
|
||||||
|
`FontRef` carries `family`, `weight: FontWeight`, and `italic: bool`. The
|
||||||
|
descriptor stores **names**, not paths: portable across machines, and the
|
||||||
|
runtime (piece 3) is free to pick the platform's best match. `FontWeight`
|
||||||
|
exposes `opentype_value()` returning the OpenType 100–900 weight scale.
|
||||||
|
|
||||||
|
`VisualStyle` also has a `font_asset: Option<AssetRef<Font>>` (Stage 8.5 piece
|
||||||
|
7): a reference to a **specific project font asset** under `assets/fonts/`,
|
||||||
|
chosen in the editor's UI canvas from the asset browser. When set it takes
|
||||||
|
precedence over the `font` descriptor — the renderer resolves the [`AssetRef`]
|
||||||
|
to a loaded face through the [asset database](assets.md) (a default-registered
|
||||||
|
`FontLoader` makes `.ttf`/`.otf` loadable via the `AssetServer`). `None` falls
|
||||||
|
back to the descriptor / theme path. This is the engine's first `AssetRef<T>`
|
||||||
|
field and the asset-picker's end-to-end target.
|
||||||
|
|
||||||
|
[`AssetRef`]: ../engine/src/asset/database.rs
|
||||||
|
|
||||||
|
### Quick example
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::math::{Color, Vec2};
|
||||||
|
use oxide_engine::ui::{Border, FontRef, Theme, VisualStyle, Widget};
|
||||||
|
|
||||||
|
let theme = Theme::new()
|
||||||
|
.with_default(VisualStyle {
|
||||||
|
foreground: Some(Color::BLACK),
|
||||||
|
background: Some(Color::WHITE),
|
||||||
|
font: Some(FontRef::regular("Inter")),
|
||||||
|
font_size: Some(14.0),
|
||||||
|
..VisualStyle::EMPTY
|
||||||
|
})
|
||||||
|
.with_style(
|
||||||
|
"button",
|
||||||
|
VisualStyle {
|
||||||
|
background: Some(Color::rgb(0.85, 0.85, 0.9)),
|
||||||
|
border: Some(Border::new(Color::rgb(0.6, 0.6, 0.7), 1.0)),
|
||||||
|
corner_radius: Some(4.0),
|
||||||
|
..VisualStyle::EMPTY
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let play = Widget::leaf(Vec2::new(80.0, 24.0))
|
||||||
|
.with_id("play")
|
||||||
|
.with_theme_style("button")
|
||||||
|
.with_visual(VisualStyle {
|
||||||
|
background: Some(Color::rgb(0.2, 0.4, 0.8)), // primary-button accent
|
||||||
|
foreground: Some(Color::WHITE),
|
||||||
|
..VisualStyle::EMPTY
|
||||||
|
});
|
||||||
|
|
||||||
|
let resolved = play.resolve_visual(&theme);
|
||||||
|
assert_eq!(resolved.foreground, Some(Color::WHITE)); // per-instance wins
|
||||||
|
assert_eq!(resolved.corner_radius, Some(4.0)); // inherited from "button"
|
||||||
|
assert_eq!(resolved.font, Some(FontRef::regular("Inter"))); // inherited from default
|
||||||
|
```
|
||||||
|
|
||||||
|
### RON dual-edit
|
||||||
|
|
||||||
|
`Theme::to_ron` / `Theme::from_ron` round-trip themes through pretty-printed
|
||||||
|
RON, matching `Widget::to_ron` from piece 1. `BTreeMap`-ordered output keeps
|
||||||
|
named styles alphabetised so diffs are stable. Empty fields (`None` options,
|
||||||
|
empty maps, `FontWeight::Regular`, `italic: false`) skip serializing — the
|
||||||
|
default form of any of these structs is `()` in RON.
|
||||||
|
|
||||||
|
## What's in piece 3 — text shaping & glyph atlas
|
||||||
|
|
||||||
|
The text subsystem lives at `oxide_engine::ui::text` and splits into three
|
||||||
|
sub-modules that compose, but each is testable on its own:
|
||||||
|
|
||||||
|
- **`font`** — owns `Font` (a thin wrapper around `ab_glyph::FontVec`),
|
||||||
|
`FontId`, and `FontStore`. `Font::rasterize(glyph, size_px)` returns a
|
||||||
|
`RasterizedGlyph` with an alpha mask + per-glyph bearings + advance.
|
||||||
|
`FontStore::insert_with_descriptor(FontRef, Font)` indexes a font under a
|
||||||
|
piece-2 `FontRef`, so a theme's `font: Some(FontRef::bold("Inter"))`
|
||||||
|
resolves to a `FontId` the shaper can use.
|
||||||
|
- **`atlas`** — `GlyphAtlas::new(width, height)` allocates a single R8
|
||||||
|
(alpha-only) buffer; `get_or_rasterize(GlyphKey, &FontStore)` returns the
|
||||||
|
glyph's `AtlasEntry` (UV rect + size + bearing + advance), rasterizing
|
||||||
|
and packing on first miss and serving the cache forever after. The
|
||||||
|
packer is a **best-fit shelf packer** — simple, deterministic, and
|
||||||
|
near-optimal density for the typically-uniform glyph heights of one font
|
||||||
|
at one size. The `dirty()` flag tells the piece-4 render pass when the
|
||||||
|
texture needs re-upload.
|
||||||
|
- **`shape`** — `shape(text, style, ¶ms, &fonts)` turns a string into
|
||||||
|
a `ShapedText { lines, size }` of positioned `ShapedGlyph`s. Each glyph
|
||||||
|
carries a `GlyphKey` the renderer feeds back into the atlas, and a
|
||||||
|
`position` at the **baseline** (not the top-left). Algorithm:
|
||||||
|
greedy line-break at ASCII whitespace, multi-font runs supported via
|
||||||
|
`shape_runs(&[TextRun])`, alignment within `max_width` (Left / Center /
|
||||||
|
Right), DPI scaling via `ShapeParams::scale`.
|
||||||
|
|
||||||
|
### The atlas is the cache
|
||||||
|
|
||||||
|
`GlyphAtlas` keys entries by `(FontId, GlyphId, size_px rounded to nearest
|
||||||
|
integer)`. Every glyph is rasterized **exactly once** per (font, glyph,
|
||||||
|
size) triple — a HUD that repaints `"HP: 1234 / 1500"` every frame
|
||||||
|
rasterizes the ten ASCII characters one time at startup and then runs
|
||||||
|
purely on textured quads. The integration suite verifies this:
|
||||||
|
`shaped_hud_text_is_cached_after_one_frame` shapes a three-line HUD,
|
||||||
|
walks every glyph through the atlas twice, and asserts the atlas's
|
||||||
|
`dirty` flag stays false on the second pass — i.e., zero new
|
||||||
|
rasterizations. The library choice (ab_glyph vs fontdue) only affects
|
||||||
|
the one-time miss cost, not steady-state.
|
||||||
|
|
||||||
|
### Quick example
|
||||||
|
|
||||||
|
```no_run
|
||||||
|
use oxide_engine::math::Vec2;
|
||||||
|
use oxide_engine::ui::text::{
|
||||||
|
shape, Font, FontStore, GlyphAtlas, ShapeParams, TextAlign, TextStyle,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut fonts = FontStore::new();
|
||||||
|
let id = fonts.insert(Font::from_path("/usr/share/fonts/.../Inter-Regular.ttf").unwrap());
|
||||||
|
let style = TextStyle { font: id, size_px: 14.0 };
|
||||||
|
let params = ShapeParams {
|
||||||
|
max_width: Some(300.0),
|
||||||
|
align: TextAlign::Center,
|
||||||
|
line_height: 1.4,
|
||||||
|
scale: 1.0,
|
||||||
|
};
|
||||||
|
let shaped = shape("Press F to pay respects", style, ¶ms, &fonts);
|
||||||
|
|
||||||
|
let mut atlas = GlyphAtlas::new(1024, 1024);
|
||||||
|
for line in &shaped.lines {
|
||||||
|
for glyph in &line.glyphs {
|
||||||
|
// Render with the atlas's bearing offset; this is exactly the
|
||||||
|
// call piece 4's overlay pass will make per glyph per frame.
|
||||||
|
if let Some(entry) = atlas.get_or_rasterize(glyph.key, &fonts) {
|
||||||
|
let quad_top_left: Vec2 = glyph.position + entry.bearing;
|
||||||
|
let _quad_size: Vec2 = entry.size_px;
|
||||||
|
let _ = (quad_top_left, entry.uv_min, entry.uv_max);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Limitations (deliberate, scoped to piece 3)
|
||||||
|
|
||||||
|
- One glyph per `char` — no ligatures, no combining marks, no complex-
|
||||||
|
script shaping (Arabic, Devanagari, Thai). The data path is ready for
|
||||||
|
a future `rustybuzz`-shaped intermediate; the current shaper just
|
||||||
|
doesn't invoke one.
|
||||||
|
- No BiDi or RTL — text flows left-to-right.
|
||||||
|
- No hyphenation or character-level break inside an over-wide word.
|
||||||
|
- ASCII whitespace only (`\t` and `\r` are treated as spaces).
|
||||||
|
- No bold/italic synthesis — each face is a separately-loaded `Font`.
|
||||||
|
|
||||||
|
### Font choice
|
||||||
|
|
||||||
|
The engine doesn't bundle a font; piece-3 tests use whichever sans-serif
|
||||||
|
they find on `/usr/share/fonts/` (or `/System/Library/Fonts` on macOS) via
|
||||||
|
`common_system_font_paths()`, skipping with `eprintln!("SKIP: …")` when no
|
||||||
|
candidate is present. The default UI font shipped with examples is a
|
||||||
|
piece-7 decision.
|
||||||
|
|
||||||
|
### Why ab_glyph
|
||||||
|
|
||||||
|
`ab_glyph` is a TTF parser + rasterizer only. It does not do layout —
|
||||||
|
which is fine because the shaper above already owns that. With
|
||||||
|
`fontdue` we would have gotten line wrapping for free at the cost of
|
||||||
|
living inside a fixed layout model; with `ab_glyph` we own every line-
|
||||||
|
break, kerning, and alignment decision. That control buys us a clean
|
||||||
|
path to richer features later: rich-text markup, per-character
|
||||||
|
animation, in-canvas editor caret positioning, and **SDF font
|
||||||
|
rendering** — a future follow-up where each glyph is rasterized once
|
||||||
|
as a signed-distance field and the shader scales it to any size for
|
||||||
|
free. SDF is on the Stage-8 backlog in [PLAN.md](../PLAN.md); it would
|
||||||
|
slot in beside `ab_glyph` without rewriting the shaper.
|
||||||
|
|
||||||
|
## What's in piece 4a — screen-space overlay render pass
|
||||||
|
|
||||||
|
Piece 4 splits the GPU work into two commits — **4a (screen-space, this
|
||||||
|
piece)** and **4b (world-space UI panels in 3D)**. Both share one render
|
||||||
|
pass, one shader, one R8 glyph atlas. The split is purely for review
|
||||||
|
size; the same `UiOverlayPass` handles both modes via per-batch MVP
|
||||||
|
matrices.
|
||||||
|
|
||||||
|
Two new pieces, both pure-CPU but the second one talks to wgpu:
|
||||||
|
|
||||||
|
- **`oxide_engine::ui::paint`** — `paint(&Widget, &LayoutTree, &Theme,
|
||||||
|
&FontStore, scale) -> PaintedFrame`. Walks the laid-out tree in
|
||||||
|
parent-then-children order; for each node, resolves the cascaded
|
||||||
|
[`VisualStyle`](#whats-in-piece-2--styling--theming) under the theme,
|
||||||
|
emits one `DrawCommand::Quad` if a background was resolved, and shapes
|
||||||
|
the widget's `text: Option<String>` inside its `content_rect` to emit
|
||||||
|
one `DrawCommand::Glyph` per laid-out glyph. Pure-logic; tests run
|
||||||
|
without a GPU and most without a font.
|
||||||
|
- **`oxide_engine::render::UiOverlayPass`** — implements
|
||||||
|
[`RenderPass`](render-pipeline.md) and slots into the Stage-5 pipeline
|
||||||
|
*after* the `ForwardPass`. Consumes `Vec<UiBatch>` per frame; each batch
|
||||||
|
pairs an MVP matrix with a `PaintedFrame`. For piece 4a the host builds
|
||||||
|
one batch with `UiBatch::screen_space(painted, target_size)` — an
|
||||||
|
orthographic projection from window pixels to NDC with y-down (origin at
|
||||||
|
the top-left).
|
||||||
|
|
||||||
|
### Vertex format and shader
|
||||||
|
|
||||||
|
One vertex format, one fragment path:
|
||||||
|
|
||||||
|
```text
|
||||||
|
struct UiVertex { position: vec2, uv: vec2, color: vec4 } // 32 bytes
|
||||||
|
```
|
||||||
|
|
||||||
|
The shader (`engine/src/render/shaders/ui.wgsl`) discriminates "solid quad
|
||||||
|
vs. glyph quad" by a sentinel UV: `uv.x < 0.0` skips the atlas sample. So
|
||||||
|
a solid red rectangle and a glyph from "Inter" pass through identical
|
||||||
|
pipeline state and live in the same vertex buffer — no state changes per
|
||||||
|
primitive, no separate textures. Alpha-blending is on; UI never reads
|
||||||
|
depth (it overlays).
|
||||||
|
|
||||||
|
### Atlas lifecycle
|
||||||
|
|
||||||
|
Each frame's `run`:
|
||||||
|
|
||||||
|
1. Walk every glyph in every batch, calling
|
||||||
|
`GlyphAtlas::get_or_rasterize(key, &fonts)` to ensure the entry is
|
||||||
|
cached. Misses rasterize once; hits do nothing.
|
||||||
|
2. If the atlas's `dirty` flag is set, re-upload the whole R8 buffer to
|
||||||
|
the GPU texture and clear the flag. Re-uploading the whole atlas (vs.
|
||||||
|
tracking dirty sub-rects) keeps the code simple; the buffer is small
|
||||||
|
(1 MB at 1024×1024) so this is fine. A dirty-region upload is a
|
||||||
|
straightforward follow-up if a profile says it's hot.
|
||||||
|
3. For each batch: serialize draw commands into vertices, write the MVP
|
||||||
|
uniform, set the viewport from `FrameContext::resolved_viewport()`,
|
||||||
|
and submit one draw call.
|
||||||
|
|
||||||
|
### Test strategy
|
||||||
|
|
||||||
|
The piece-4 tests live in three places:
|
||||||
|
|
||||||
|
- `engine/src/ui/paint.rs` — 5 lib tests verify the CPU paint logic: a
|
||||||
|
solid widget emits one quad at its rect, a text widget emits one glyph
|
||||||
|
command per visible char at the same baseline, layered widgets draw
|
||||||
|
parent-before-child, etc. No GPU required.
|
||||||
|
- `engine/src/render/ui_pass.rs` — 4 headless GPU pixel-readback tests:
|
||||||
|
a 20×20 red quad shows red at its centre and clear-color outside; an
|
||||||
|
empty batch list is a no-op; two quads in one batch both render to
|
||||||
|
their respective rects; the vertex buffer grows when a batch exceeds
|
||||||
|
the initial 4096-vertex capacity.
|
||||||
|
- `tests/src/lib.rs` mod stage8 — 1 integration test runs the whole
|
||||||
|
pipeline: `Widget` → `layout` → `paint` → `UiOverlayPass::run` →
|
||||||
|
pixel-readback, then asserts the centred 48×48 red panel is red in the
|
||||||
|
middle and clear-color in the gutter.
|
||||||
|
|
||||||
|
Both lib and integration GPU tests skip with `eprintln!("SKIP: ...")` if
|
||||||
|
no adapter is available, matching the Stage-4 pattern.
|
||||||
|
|
||||||
|
### Wiring it into an app
|
||||||
|
|
||||||
|
```rust,no_run
|
||||||
|
use oxide_engine::math::Color;
|
||||||
|
use oxide_engine::render::{RenderPipeline, UiBatch, UiOverlayPass};
|
||||||
|
use oxide_engine::ui::{layout, paint, FontStore, Theme, Widget};
|
||||||
|
# fn build_pipeline(device: &wgpu::Device, format: wgpu::TextureFormat) {
|
||||||
|
let mut pipeline = RenderPipeline::forward(device, format);
|
||||||
|
let ui_pass = UiOverlayPass::new(device, format);
|
||||||
|
pipeline.add_pass("ui", ui_pass);
|
||||||
|
# }
|
||||||
|
# fn each_frame(
|
||||||
|
# ui_pass: &mut UiOverlayPass,
|
||||||
|
# document: &Widget,
|
||||||
|
# theme: &Theme,
|
||||||
|
# fonts: &FontStore,
|
||||||
|
# viewport: oxide_engine::math::Rect,
|
||||||
|
# target_size: (u32, u32),
|
||||||
|
# ) {
|
||||||
|
let tree = layout(document, viewport, 1.0);
|
||||||
|
let painted = paint(document, &tree, theme, fonts, 1.0);
|
||||||
|
ui_pass.set_batches(vec![UiBatch::screen_space(painted, target_size)]);
|
||||||
|
// pipeline.render(&mut frame); — at next frame.
|
||||||
|
# }
|
||||||
|
```
|
||||||
|
|
||||||
|
## What's in piece 4b — world-space UI panels
|
||||||
|
|
||||||
|
`oxide_engine::ui::UiPanel` is a pure-data holder: a `Widget` tree plus two
|
||||||
|
sizes — `pixel_size` (the resolution the UI is laid out at) and
|
||||||
|
`world_size` (the panel's physical dimensions in world units). It does
|
||||||
|
*not* own the panel's `Transform`; that lives on the entity that hosts
|
||||||
|
the panel (eventually a hecs component), so the same panel can be
|
||||||
|
duplicated across many entities with different placements.
|
||||||
|
|
||||||
|
`UiBatch::world_space(painted, pixel_size, world_size, &panel_transform,
|
||||||
|
view_projection)` composes a single MVP that the existing piece-4a pass
|
||||||
|
uses unchanged:
|
||||||
|
|
||||||
|
```text
|
||||||
|
mvp = view_projection
|
||||||
|
* panel_transform // world placement
|
||||||
|
* scale(world.x / pixel.x, -world.y / pixel.y, 1) // pixels → world (y-flip)
|
||||||
|
* translate(-pixel.x / 2, -pixel.y / 2, 0) // recentre pixel origin
|
||||||
|
```
|
||||||
|
|
||||||
|
A pixel at `(0, 0)` in the painted frame lands at the panel's top-left
|
||||||
|
corner in world space; a pixel at `pixel_size` lands at the bottom-right.
|
||||||
|
Same pipeline, same shader, same atlas — only the MVP differs.
|
||||||
|
|
||||||
|
`UiPanel::build_batch(theme, fonts, &panel_transform, view_projection)`
|
||||||
|
is the convenience that lays out + paints + builds the batch in one call.
|
||||||
|
Hosts that want finer control compose the same three steps by hand.
|
||||||
|
|
||||||
|
### Overlay semantics
|
||||||
|
|
||||||
|
World-space panels in piece 4b render as **overlays**: no depth test, no
|
||||||
|
depth write — they draw on top of whatever's in the colour target. That
|
||||||
|
keeps the implementation simple and matches the common "always-visible"
|
||||||
|
use case (player nameplates, mission markers, editor canvas previews).
|
||||||
|
|
||||||
|
A future **depth-aware mode** (where a panel behind a wall is properly
|
||||||
|
hidden) is in PLAN.md's Stage-8 backlog and slots in by attaching the
|
||||||
|
depth target to a second pass of the same pipeline.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- 4 lib tests on `UiPanel`: `build_batch` returns `None` on zero
|
||||||
|
`pixel_size`, succeeds on a valid panel, the panel round-trips through
|
||||||
|
RON for dual-edit, and the identity-MVP sanity check maps pixel
|
||||||
|
`(0, 0)` to world `(-world.x/2, +world.y/2)` (verifying the y-flip).
|
||||||
|
- 1 GPU pixel-readback lib test (`world_space_panel_renders_inside_its_projected_region`):
|
||||||
|
a 2 m × 2 m red panel at the origin under a 60° camera 3 m away,
|
||||||
|
asserts the framebuffer centre is red and corners stay clear.
|
||||||
|
- 1 integration test (`ui_panel_in_3d_renders_under_perspective_camera`):
|
||||||
|
exercises the full `UiPanel::build_batch` → `UiOverlayPass` path end
|
||||||
|
to end with a real perspective camera and a pixel-readback assertion.
|
||||||
|
|
||||||
|
### Quick example
|
||||||
|
|
||||||
|
```rust,no_run
|
||||||
|
use oxide_engine::math::{Color, Transform, Vec2, Vec3};
|
||||||
|
use oxide_engine::render::{Camera, UiBatch, UiOverlayPass};
|
||||||
|
use oxide_engine::ui::{FontStore, Theme, UiPanel, VisualStyle, Widget};
|
||||||
|
|
||||||
|
# fn each_frame(pass: &mut UiOverlayPass, panel: &UiPanel) {
|
||||||
|
let camera = Camera::perspective(60_f32.to_radians(), 0.1, 100.0);
|
||||||
|
let view_transform = Transform::looking_at(Vec3::new(0.0, 1.5, 4.0), Vec3::ZERO, Vec3::Y);
|
||||||
|
let view_projection = camera.view_projection(16.0 / 9.0, &view_transform);
|
||||||
|
|
||||||
|
// Where the panel sits in the world. Treat as if it were a Transform
|
||||||
|
// component on the entity hosting the panel.
|
||||||
|
let panel_transform = Transform::default();
|
||||||
|
|
||||||
|
let theme = Theme::new();
|
||||||
|
let fonts = FontStore::new();
|
||||||
|
let batch = panel
|
||||||
|
.build_batch(&theme, &fonts, &panel_transform, view_projection)
|
||||||
|
.expect("valid panel");
|
||||||
|
pass.set_batches(vec![batch]);
|
||||||
|
// pipeline.render(&mut frame); — at next frame.
|
||||||
|
# }
|
||||||
|
```
|
||||||
|
|
||||||
|
## What's in piece 5 — input routing
|
||||||
|
|
||||||
|
The UI must consume input *before* the game (PLAN.md): clicking a button
|
||||||
|
shouldn't also fire the game action bound to the same mouse button.
|
||||||
|
`oxide_engine::ui::routing` gives the host one object that does this
|
||||||
|
end-to-end:
|
||||||
|
|
||||||
|
```rust,no_run
|
||||||
|
use oxide_engine::prelude::*;
|
||||||
|
use oxide_engine::ui::Router;
|
||||||
|
|
||||||
|
# fn each_frame(router: &mut Router, tree: &UiLayoutTree, input: &InputState) {
|
||||||
|
let frame = router.process(tree, input);
|
||||||
|
if !frame.captured_mouse {
|
||||||
|
// game receives mouse this frame
|
||||||
|
}
|
||||||
|
if !frame.captured_keyboard {
|
||||||
|
// game receives keys this frame
|
||||||
|
}
|
||||||
|
for event in &frame.events {
|
||||||
|
// piece 6 will dispatch each event to the matching widget's callback
|
||||||
|
}
|
||||||
|
# }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Hit-test
|
||||||
|
|
||||||
|
`hit_test(&LayoutTree, point) -> Option<&LayoutNode>` walks the laid-out
|
||||||
|
nodes in **reverse order** — the same order as paint (parents-then-
|
||||||
|
children, earlier siblings before later ones), so the topmost-drawn
|
||||||
|
widget is the first one tested. Anonymous widgets
|
||||||
|
(`WidgetId::default()`) are skipped so a decorative container doesn't
|
||||||
|
block clicks reaching the button inside it.
|
||||||
|
|
||||||
|
### State machine
|
||||||
|
|
||||||
|
The `Router` persists three pieces of state across frames:
|
||||||
|
|
||||||
|
- **hovered** — recomputed each frame from the cursor + hit-test.
|
||||||
|
- **focused** — set when the cursor presses over a widget; cleared when
|
||||||
|
the cursor presses outside any widget. Survives subsequent hover
|
||||||
|
changes so a focused text input keeps focus while the cursor moves.
|
||||||
|
- **pending presses** — per-button, the widget that received the
|
||||||
|
most-recent unreleased press. A press → release on the **same**
|
||||||
|
widget emits `Clicked`. Drag-off then release cancels the click.
|
||||||
|
|
||||||
|
### Events
|
||||||
|
|
||||||
|
`RouterFrame.events: Vec<RouterEvent>` collects everything that
|
||||||
|
happened: `Hovered` / `Unhovered`, `Pressed` / `Released` / `Clicked`
|
||||||
|
(per mouse button), `FocusGained` / `FocusLost`. Piece 6 will dispatch
|
||||||
|
each event to per-widget callbacks; piece 5 is purely the state machine
|
||||||
|
producing the event list.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- 13 lib tests cover hit-test (topmost wins, anonymous skipped,
|
||||||
|
outside-root → None, padding gutter resolves to parent), hover/
|
||||||
|
unhover/swap-on-move, press → focus, press + release on the same
|
||||||
|
widget → click, drag-off cancels click, press outside clears focus,
|
||||||
|
captured-flag transitions, and cursor-unset → no hover.
|
||||||
|
- 1 integration test exercises the full hover → press → release →
|
||||||
|
click → move → press-outside-loses-focus sequence end-to-end with a
|
||||||
|
synthetic `InputState`.
|
||||||
|
|
||||||
|
All tests are pure-CPU; no GPU, no font, no window.
|
||||||
|
|
||||||
|
### What's deliberately not in piece 5
|
||||||
|
|
||||||
|
- **Keyboard focus navigation** (Tab / arrow keys to move focus) — a
|
||||||
|
small follow-up on top of the existing focus state.
|
||||||
|
- **Per-widget callbacks** — piece 6.
|
||||||
|
- **World-space hit-test** — clicking through a 3D panel needs a
|
||||||
|
ray-cast and an inverse-MVP. A follow-up that slots in by adding a
|
||||||
|
`Router::hit_test_world(ray, &UiPanel, &Transform)` helper.
|
||||||
|
|
||||||
|
## What's in piece 6 — events + data binding
|
||||||
|
|
||||||
|
Piece 6 takes the **immediate-mode** stance (same as Bevy UI and egui):
|
||||||
|
no callback storage, no `Rc<RefCell<...>>` for state, no lifetime
|
||||||
|
gymnastics — the host reads the `RouterFrame` each frame and acts
|
||||||
|
directly.
|
||||||
|
|
||||||
|
### Events: immediate-mode queries on `RouterFrame`
|
||||||
|
|
||||||
|
The piece-5 `RouterFrame` already carries the event list. Piece 6 adds
|
||||||
|
typed query methods that game code calls directly:
|
||||||
|
|
||||||
|
```rust,no_run
|
||||||
|
# fn each_frame(frame: oxide_engine::ui::RouterFrame) {
|
||||||
|
use oxide_engine::winit::event::MouseButton;
|
||||||
|
if frame.clicked_left("play") {
|
||||||
|
// start_game();
|
||||||
|
}
|
||||||
|
if frame.clicked("save", MouseButton::Right) {
|
||||||
|
// open_save_menu();
|
||||||
|
}
|
||||||
|
if frame.hovered_in("tooltip-target") {
|
||||||
|
// show_tooltip();
|
||||||
|
}
|
||||||
|
if frame.focus_gained("volume_slider") {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
# }
|
||||||
|
```
|
||||||
|
|
||||||
|
The seven query methods — `clicked`, `clicked_left`, `pressed`,
|
||||||
|
`released`, `hovered_in`, `hovered_out`, `focus_gained`, `focus_lost`
|
||||||
|
— each take a widget id and (where applicable) a `MouseButton`, and
|
||||||
|
return `bool`. They scan the frame's event list, so the cost is linear
|
||||||
|
in the number of events emitted that frame — typically a handful.
|
||||||
|
|
||||||
|
### Data binding: `Widget::value: Option<WidgetValue>`
|
||||||
|
|
||||||
|
Every widget can carry typed state — a checkbox's bool, a slider's
|
||||||
|
float, a text input's string — independent of its `kind`. The
|
||||||
|
`WidgetValue` enum has variants `Bool(bool)` / `Int(i64)` /
|
||||||
|
`Float(f64)` / `Text(String)`, plus `From`-impls for `bool`, `i32`,
|
||||||
|
`i64`, `f32`, `f64`, `&str`, and `String`.
|
||||||
|
|
||||||
|
Per-widget access uses `Widget::value(&id)` and `Widget::set_value(&id,
|
||||||
|
v)` — both walk the subtree to find the widget by id:
|
||||||
|
|
||||||
|
```rust,no_run
|
||||||
|
use oxide_engine::ui::{Widget, WidgetValue};
|
||||||
|
# fn pull_then_push(root: &mut Widget, audio_volume: &mut f32) {
|
||||||
|
// Pull game state into the widget tree (typically at the start of frame).
|
||||||
|
root.set_value(&"volume".into(), *audio_volume);
|
||||||
|
|
||||||
|
// ... user interacts, slider widget updates its own value ...
|
||||||
|
|
||||||
|
// Push the widget tree's value back into game state (at end of frame).
|
||||||
|
if let Some(v) = root.value(&"volume".into()).and_then(|v| v.as_float()) {
|
||||||
|
*audio_volume = v as f32;
|
||||||
|
}
|
||||||
|
# }
|
||||||
|
```
|
||||||
|
|
||||||
|
For values that don't change between frames (e.g., a label's string),
|
||||||
|
no binding is needed — set it once.
|
||||||
|
|
||||||
|
### Why immediate-mode
|
||||||
|
|
||||||
|
The persistent-callback alternative (each widget owns a
|
||||||
|
`Box<dyn FnMut(...)>`) forces every callback to either:
|
||||||
|
|
||||||
|
- own its game state via `Rc<RefCell<...>>` (verbose, costs every
|
||||||
|
read), or
|
||||||
|
- borrow game state for `'static` (impossible), or
|
||||||
|
- defer to a queue (the same shape as immediate-mode, but indirected).
|
||||||
|
|
||||||
|
Immediate-mode skips all three: the widget tree is **data**, not a
|
||||||
|
network of callbacks. The host's main loop is the dispatcher; the
|
||||||
|
piece-6 queries are just convenient predicates over the event list.
|
||||||
|
|
||||||
|
### What's deliberately not in piece 6
|
||||||
|
|
||||||
|
- **Typed bindings helper** (`Bindings<T>` that registers per-field
|
||||||
|
getter/setter pairs and runs them automatically) — adds a `Box<dyn>`
|
||||||
|
abstraction over what's currently two lines of host code. Will land
|
||||||
|
alongside piece-7's settings example if the boilerplate becomes
|
||||||
|
painful.
|
||||||
|
- **Per-widget keyboard event delivery** (text input handling, hotkey
|
||||||
|
registration) — needs a focused-widget event-routing pass on top of
|
||||||
|
the piece-5 focus state. Either piece-7 or a follow-up.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- 6 lib tests on `WidgetValue` cover accessor matching, `From`
|
||||||
|
conversions for every primitive, and RON round-trip for each variant.
|
||||||
|
- 4 lib tests on `Widget`: `find_by_id` / `find_by_id_mut` walk the
|
||||||
|
subtree, `set_value` updates a descendant by id, `with_value` builder
|
||||||
|
works, the value round-trips through `Widget`'s own RON.
|
||||||
|
- 2 lib tests on `RouterFrame`: query methods return true for matching
|
||||||
|
events, false for non-matching, across every event variant.
|
||||||
|
- 1 integration test (`settings_widget_tree_round_trips_game_state_each_frame`):
|
||||||
|
pulls game state into a settings panel, simulates user interaction +
|
||||||
|
an Apply click, pushes the widget values back into game state, and
|
||||||
|
asserts the round-trip is exact.
|
||||||
|
|
||||||
|
## What's in piece 8 — `examples/ui_hud`
|
||||||
|
|
||||||
|
`examples/src/bin/ui_hud.rs` is the second runnable Stage-8 example and
|
||||||
|
the first to **composite the UI over a 3D scene**. It reuses the
|
||||||
|
`hello_mesh` scene (spinning cube + sphere + ground plane through the
|
||||||
|
Stage-4 `ForwardPass`) and draws a HUD on top with a screen-space
|
||||||
|
`UiOverlayPass`.
|
||||||
|
|
||||||
|
### Compositing two passes on one surface
|
||||||
|
|
||||||
|
The window runner clears the surface to the configured clear color
|
||||||
|
*before* `render`. Both the forward pass and the UI overlay then use
|
||||||
|
`LoadOp::Load` for their color attachment, so each draws over whatever
|
||||||
|
is already there:
|
||||||
|
|
||||||
|
1. `pipeline.render(&mut frame)` runs the forward pass — 3D geometry
|
||||||
|
plus its own depth buffer (cleared each call).
|
||||||
|
2. `ui_pass.run(&mut frame)` runs the overlay — no depth, alpha
|
||||||
|
blending — so the HUD sits on top of the 3D image.
|
||||||
|
|
||||||
|
The host owns the `UiOverlayPass` separately from the `RenderPipeline`
|
||||||
|
(rather than `add_pass`-ing it) because the overlay needs `set_batches`
|
||||||
|
mutated every frame and the pipeline consumes pass ownership. Both
|
||||||
|
passes share the same `FrameContext`, so the example builds the 3D
|
||||||
|
objects and the painted HUD, then calls the two `run`s back to back.
|
||||||
|
|
||||||
|
### Corner anchoring
|
||||||
|
|
||||||
|
Each HUD element is an anchor child of a full-screen anchor root. A
|
||||||
|
corner-pinned, fixed-size widget is expressed as a corner `Anchor`
|
||||||
|
constant plus offsets that define its box — e.g. a top-left chip is
|
||||||
|
`Anchor::TOP_LEFT.with_offsets((M, M), (M + W, M + H))`, and a centred
|
||||||
|
crosshair is `Anchor::between((0.5, 0.5), (0.5, 0.5)).with_offsets(...)`.
|
||||||
|
The crosshair's two bars are themselves anchor children spanning one
|
||||||
|
axis and pinned thin on the other.
|
||||||
|
|
||||||
|
### Demonstrating the atlas cache
|
||||||
|
|
||||||
|
The HP and Ammo values animate every frame (HP oscillates down then up;
|
||||||
|
Ammo counts down as if firing, reloading at 0). The digits change
|
||||||
|
constantly, but the glyph atlas only ever rasterizes each character
|
||||||
|
**once** — after the digits `0`–`9` and the static label text have been
|
||||||
|
seen, the atlas stops growing and every later frame is a pure cache hit
|
||||||
|
(no rasterize, no GPU re-upload). The example logs each atlas growth and
|
||||||
|
the moment it reaches steady state, via two accessors added to the pass:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pass.atlas_glyph_count(); // distinct glyphs cached so far
|
||||||
|
pass.atlas_dirty(); // grew-this-run flag (false in steady state)
|
||||||
|
```
|
||||||
|
|
||||||
|
The `atlas_caches_glyphs_and_reaches_steady_state` GPU test in
|
||||||
|
`render::ui_pass` proves this property automatically: it draws the ten
|
||||||
|
digits one per frame (asserting the count grows by one each time), then
|
||||||
|
re-draws a cached digit and asserts the count holds and the dirty flag
|
||||||
|
stays clear.
|
||||||
|
|
||||||
|
Run it: `cargo run -p oxide-examples --bin ui_hud` (Esc quits).
|
||||||
|
|
||||||
|
## What's coming in the rest of Stage 8
|
||||||
|
- **Piece 9 — Editor UI canvas.** A new editor panel for visually
|
||||||
|
authoring `Widget` / `UiPanel` documents: a drag-from widget palette,
|
||||||
|
a canvas showing the document at target size with drag-resize handles,
|
||||||
|
a property inspector for `LayoutStyle` / `VisualStyle` / `text` /
|
||||||
|
`value`, RON save/load round-tripping the same format the runtime
|
||||||
|
loads, and a live preview rendered through the actual `UiOverlayPass`
|
||||||
|
(not egui). Likely splits into 9a (canvas + palette + inspector) and
|
||||||
|
9b (live preview + drag/resize handles).
|
||||||
|
|
||||||
|
The piece-1 data structures already accommodate the editor canvas: a
|
||||||
|
document is just a `Widget` tree, the inspector edits the same reflected
|
||||||
|
style structs the runtime uses, and the preview reuses the exact paint +
|
||||||
|
overlay pipeline the game ships with.
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
# Windowing & the Application Loop
|
||||||
|
|
||||||
|
Stage 2 reference for `oxide_engine::window` — opening a window, running the
|
||||||
|
event loop, and receiving raw input. For what happens *inside* a frame (GPU
|
||||||
|
setup, clearing, resize handling) see [render-context.md](render-context.md).
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The window module wraps [`winit`](https://docs.rs/winit) so applications never
|
||||||
|
talk to the event loop directly. You implement the `WindowApp` trait, hand it
|
||||||
|
to `run()` together with a `WindowConfig`, and the engine:
|
||||||
|
|
||||||
|
1. creates the window and the GPU [`RenderContext`](render-context.md),
|
||||||
|
2. calls `WindowApp::init` once,
|
||||||
|
3. then loops: forwards every raw window event to `WindowApp::event`, calls
|
||||||
|
`WindowApp::update` once per frame, and clears + presents the surface.
|
||||||
|
|
||||||
|
The loop runs in `Poll` mode (continuous rendering, as a game expects), not
|
||||||
|
event-driven `Wait` mode (as a desktop utility would use).
|
||||||
|
|
||||||
|
> **Stage 6 rename.** This trait was originally `App`. Stage 6 renamed it to
|
||||||
|
> `WindowApp` so the engine's [`oxide_engine::app::App`](modules.md) container
|
||||||
|
> (scene, assets, scheduled systems) could live in the prelude unambiguously.
|
||||||
|
> The two cover different roles: this trait is the per-frame window/event
|
||||||
|
> handler; the container is engine state your handler typically wraps around.
|
||||||
|
|
||||||
|
## Minimal application
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use oxide_engine::prelude::*;
|
||||||
|
use oxide_engine::window::event::{Key, NamedKey, ElementState, WindowEvent};
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct MyApp;
|
||||||
|
|
||||||
|
impl WindowApp for MyApp {
|
||||||
|
fn init(&mut self, ctx: &mut AppCtx<'_>) {
|
||||||
|
ctx.set_clear_color(Color::rgb(0.39, 0.58, 0.93));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn event(&mut self, ctx: &mut AppCtx<'_>, event: &WindowEvent) {
|
||||||
|
if let WindowEvent::KeyboardInput { event: key, .. } = event {
|
||||||
|
if key.state == ElementState::Pressed
|
||||||
|
&& key.logical_key == Key::Named(NamedKey::Escape)
|
||||||
|
{
|
||||||
|
ctx.request_exit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(&mut self, ctx: &mut AppCtx<'_>) {
|
||||||
|
let _seconds_since_last_frame = ctx.dt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> anyhow::Result<()> {
|
||||||
|
run(WindowConfig::default(), MyApp)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`run()` blocks the calling thread until the app exits — an OS requirement (the
|
||||||
|
event loop must own the main thread), not an engine choice.
|
||||||
|
|
||||||
|
## `WindowConfig`
|
||||||
|
|
||||||
|
Initial window settings. All fields are plain data:
|
||||||
|
|
||||||
|
| Field | Default | Meaning |
|
||||||
|
|-------|---------|---------|
|
||||||
|
| `title` | `"Oxide"` | Window title |
|
||||||
|
| `width`, `height` | 1280 × 720 | Initial inner size, logical pixels |
|
||||||
|
| `resizable` | `true` | Whether the user can resize |
|
||||||
|
| `clear_color` | `Color::BLACK` | Initial per-frame clear color |
|
||||||
|
|
||||||
|
## The `WindowApp` trait
|
||||||
|
|
||||||
|
Three callbacks, all optional (empty default bodies):
|
||||||
|
|
||||||
|
- **`init(ctx)`** — once, after the window and GPU exist, before the first
|
||||||
|
frame. Set the title, clear color, load resources.
|
||||||
|
- **`event(ctx, event)`** — for *every* raw `WindowEvent`, including ones the
|
||||||
|
engine also reacts to (close request, resize), so apps can observe
|
||||||
|
everything. Stage 2 exposes events untranslated; the
|
||||||
|
[Stage-7 input system](input.md) layers per-key edge detection and
|
||||||
|
remappable named actions on top, surfaced through `ctx.input()`.
|
||||||
|
- **`update(ctx)`** — once per frame, before the frame is cleared and
|
||||||
|
presented. `ctx.dt` is the seconds elapsed since the previous frame (`0.0`
|
||||||
|
on the first).
|
||||||
|
|
||||||
|
Per frame the order is: pending `event` calls → `update` → render.
|
||||||
|
|
||||||
|
## `AppCtx`
|
||||||
|
|
||||||
|
Every callback receives `&mut AppCtx`, the engine state an app may touch:
|
||||||
|
|
||||||
|
| Member | Purpose |
|
||||||
|
|--------|---------|
|
||||||
|
| `dt` | Frame delta time in seconds (field) |
|
||||||
|
| `set_clear_color(color)` / `clear_color()` | Per-frame clear color; changes apply on the next frame |
|
||||||
|
| `size()` | Current surface size in physical pixels |
|
||||||
|
| `set_title(title)` | Change the window title |
|
||||||
|
| `request_exit()` | Leave the event loop after the current callback |
|
||||||
|
| `render()` | Direct access to the [`RenderContext`](render-context.md) |
|
||||||
|
| `input()` | The per-frame [`InputState`](input.md) snapshot |
|
||||||
|
|
||||||
|
## Raw event types
|
||||||
|
|
||||||
|
`oxide_engine::window::event` re-exports the `winit` event vocabulary
|
||||||
|
(`WindowEvent`, `KeyEvent`, `MouseButton`, `ElementState`, `KeyCode`,
|
||||||
|
`PhysicalKey`, `Key`, `NamedKey`, `ModifiersState`, …) so applications don't
|
||||||
|
need their own `winit` dependency. The whole crates are also available as
|
||||||
|
`oxide_engine::winit` and `oxide_engine::wgpu` for anything not curated.
|
||||||
|
|
||||||
|
Two keyboard representations matter:
|
||||||
|
|
||||||
|
- `KeyEvent::physical_key` (`PhysicalKey::Code(KeyCode::KeyW)`) — the physical
|
||||||
|
key position, layout-independent. Use for game-style controls.
|
||||||
|
- `KeyEvent::logical_key` (`Key::Named(NamedKey::Escape)` or
|
||||||
|
`Key::Character(…)`) — what the key means under the user's layout. Use for
|
||||||
|
shortcuts and text.
|
||||||
|
|
||||||
|
## Engine-handled events
|
||||||
|
|
||||||
|
The runner reacts to these before forwarding them:
|
||||||
|
|
||||||
|
| Event | Engine behavior |
|
||||||
|
|-------|-----------------|
|
||||||
|
| `CloseRequested` | Exits the loop (apps can't veto it in Stage 2) |
|
||||||
|
| `Resized` | Reconfigures the surface (see [render-context.md](render-context.md)) |
|
||||||
|
| `RedrawRequested` | Computes `dt`, calls `update`, renders the frame |
|
||||||
|
|
||||||
|
Errors during window/GPU creation or rendering are returned from `run()`;
|
||||||
|
winit callbacks can't propagate `Result`, so the runner stashes the first
|
||||||
|
error and exits the loop.
|
||||||
|
|
||||||
|
## Trying it
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo run -p oxide-examples --bin hello_window
|
||||||
|
```
|
||||||
|
|
||||||
|
Keys `1`–`5` switch clear-color presets, `Space` cycles, `Esc` quits; average
|
||||||
|
FPS is logged once per second. The editor (`cargo run -p oxide-editor`) uses
|
||||||
|
the same infrastructure and quits with `Ctrl+Q`.
|
||||||
@@ -22,9 +22,15 @@ egui.workspace = true
|
|||||||
egui-wgpu.workspace = true
|
egui-wgpu.workspace = true
|
||||||
egui-winit.workspace = true
|
egui-winit.workspace = true
|
||||||
egui_dock.workspace = true
|
egui_dock.workspace = true
|
||||||
|
# Native folder picker for New/Open Project, run on a helper thread so the
|
||||||
|
# UI keeps redrawing while the dialog is up (see Shell::poll_folder_pick).
|
||||||
|
rfd.workspace = true
|
||||||
# Editor preferences file I/O reads/writes the same RON shape `Settings`
|
# Editor preferences file I/O reads/writes the same RON shape `Settings`
|
||||||
# exports; the engine already pulls `ron` in, the editor now does too.
|
# exports; the engine already pulls `ron` in, the editor now does too.
|
||||||
ron.workspace = true
|
ron.workspace = true
|
||||||
|
# Editor-owned settings sections (e.g. the External Editor preference) derive
|
||||||
|
# their own Serialize/Deserialize for the Settings store.
|
||||||
|
serde.workspace = true
|
||||||
|
|
||||||
# PTY-backed terminal panel (Stage 10): run interactive/TUI programs — shells,
|
# PTY-backed terminal panel (Stage 10): run interactive/TUI programs — shells,
|
||||||
# REPLs, and AI-agent CLIs like `claude` — inside the editor. `portable-pty`
|
# REPLs, and AI-agent CLIs like `claude` — inside the editor. `portable-pty`
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ pub fn seed_default_font(project_assets_dir: &Path) -> std::io::Result<bool> {
|
|||||||
/// The `.rhai` source the "New Script" button writes, personalised with the
|
/// The `.rhai` source the "New Script" button writes, personalised with the
|
||||||
/// script's file stem so the Console output identifies which script speaks.
|
/// script's file stem so the Console output identifies which script speaks.
|
||||||
///
|
///
|
||||||
/// Kept to the two lifecycle hooks the wiki's Scripting page teaches first; the
|
/// Kept to the two lifecycle hooks `docs/scripting.md` teaches first; the
|
||||||
/// `update` body ships commented out so a freshly created script visibly runs
|
/// `update` body ships commented out so a freshly created script visibly runs
|
||||||
/// (the `init` print) without moving anything until the author opts in.
|
/// (the `init` print) without moving anything until the author opts in.
|
||||||
pub fn script_template(stem: &str) -> String {
|
pub fn script_template(stem: &str) -> String {
|
||||||
|
|||||||
@@ -0,0 +1,524 @@
|
|||||||
|
//! The Project panel's file explorer — state and file/database operations.
|
||||||
|
//!
|
||||||
|
//! Stage-10 editor-UX: a Unity-style explorer over the project's `assets/`
|
||||||
|
//! tree. This module holds everything that does **not** touch egui — the
|
||||||
|
//! navigation state, directory listing, and the create/rename/move/delete/
|
||||||
|
//! import operations — so the whole behavior layer is unit-testable and the
|
||||||
|
//! shell only renders it (`ShellTabViewer::assets_explorer`).
|
||||||
|
//!
|
||||||
|
//! Every operation goes through the [`AssetDatabase`] file ops
|
||||||
|
//! (`move_asset`/`move_folder`/`delete_asset`) whenever the touched file is
|
||||||
|
//! registered, so an asset keeps its [`AssetUid`] — and every saved
|
||||||
|
//! `AssetRef` keeps resolving — across any reorganisation. Files the
|
||||||
|
//! database does not know (licenses, notes, …) fall back to plain
|
||||||
|
//! filesystem operations.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use oxide_engine::asset::{AssetDatabase, AssetDbError, AssetKind, AssetUid};
|
||||||
|
|
||||||
|
/// The explorer's persistent UI state (lives on the `Shell`, survives frames).
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct ExplorerState {
|
||||||
|
/// The folder being viewed, relative to `assets/` (`""` = the root).
|
||||||
|
pub cwd: String,
|
||||||
|
/// An in-progress rename, if any.
|
||||||
|
pub rename: Option<RenameEdit>,
|
||||||
|
/// The in-progress "New Folder" name, `Some` while the inline row shows.
|
||||||
|
pub new_folder: Option<String>,
|
||||||
|
/// One-shot: the next inline text field rendered requests focus (set when
|
||||||
|
/// a rename / new-folder edit starts, taken by the first frame).
|
||||||
|
pub focus_field: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ExplorerState {
|
||||||
|
/// Navigates to `cwd`, dropping any in-progress inline edits.
|
||||||
|
pub fn navigate(&mut self, cwd: impl Into<String>) {
|
||||||
|
self.cwd = cwd.into();
|
||||||
|
self.rename = None;
|
||||||
|
self.new_folder = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An in-progress rename of one entry: what is being renamed + the buffer.
|
||||||
|
pub struct RenameEdit {
|
||||||
|
/// The entry's current assets-relative path.
|
||||||
|
pub rel: String,
|
||||||
|
/// Whether it is a folder.
|
||||||
|
pub is_dir: bool,
|
||||||
|
/// The name being typed.
|
||||||
|
pub buf: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One row of the explorer listing.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Entry {
|
||||||
|
/// The leaf name shown in the panel.
|
||||||
|
pub name: String,
|
||||||
|
/// Assets-relative path (forward slashes).
|
||||||
|
pub rel: String,
|
||||||
|
/// Whether this is a folder.
|
||||||
|
pub is_dir: bool,
|
||||||
|
/// The database uid, when the file is registered.
|
||||||
|
pub uid: Option<AssetUid>,
|
||||||
|
/// The registered kind, when the file is registered.
|
||||||
|
pub kind: Option<AssetKind>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lists the folder `cwd` (relative to `assets/`): folders first, then files,
|
||||||
|
/// each group sorted by name. Files are annotated with their database
|
||||||
|
/// uid/kind when registered. A missing folder yields an empty list (the
|
||||||
|
/// assets root may not exist yet in a fresh project).
|
||||||
|
pub fn list_dir(db: &AssetDatabase, cwd: &str) -> Vec<Entry> {
|
||||||
|
let dir = abs_of(db, cwd);
|
||||||
|
let Ok(read) = std::fs::read_dir(&dir) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let mut folders: Vec<Entry> = Vec::new();
|
||||||
|
let mut files: Vec<Entry> = Vec::new();
|
||||||
|
for item in read.flatten() {
|
||||||
|
let name = item.file_name().to_string_lossy().into_owned();
|
||||||
|
let rel = join_rel(cwd, &name);
|
||||||
|
if item.path().is_dir() {
|
||||||
|
folders.push(Entry {
|
||||||
|
name,
|
||||||
|
rel,
|
||||||
|
is_dir: true,
|
||||||
|
uid: None,
|
||||||
|
kind: None,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
let uid = db.uid_of(&rel);
|
||||||
|
let kind = uid.and_then(|u| db.entry(u)).map(|e| e.kind);
|
||||||
|
files.push(Entry {
|
||||||
|
name,
|
||||||
|
rel,
|
||||||
|
is_dir: false,
|
||||||
|
uid,
|
||||||
|
kind,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
folders.sort_by(|a, b| a.name.cmp(&b.name));
|
||||||
|
files.sort_by(|a, b| a.name.cmp(&b.name));
|
||||||
|
folders.extend(files);
|
||||||
|
folders
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The breadcrumb trail for `cwd`: `(label, cwd-to-navigate-to)` pairs,
|
||||||
|
/// starting at the assets root. `"textures/env"` yields
|
||||||
|
/// `[("assets",""), ("textures","textures"), ("env","textures/env")]`.
|
||||||
|
pub fn breadcrumbs(cwd: &str) -> Vec<(String, String)> {
|
||||||
|
let mut crumbs = vec![("assets".to_owned(), String::new())];
|
||||||
|
let mut path = String::new();
|
||||||
|
for seg in cwd.split('/').filter(|s| !s.is_empty()) {
|
||||||
|
path = join_rel(&path, seg);
|
||||||
|
crumbs.push((seg.to_owned(), path.clone()));
|
||||||
|
}
|
||||||
|
crumbs
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Joins a folder path and a leaf name into an assets-relative path.
|
||||||
|
pub fn join_rel(dir: &str, name: &str) -> String {
|
||||||
|
if dir.is_empty() {
|
||||||
|
name.to_owned()
|
||||||
|
} else {
|
||||||
|
format!("{dir}/{name}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The parent folder of an assets-relative path (`""` at the top).
|
||||||
|
pub fn parent_of(rel: &str) -> String {
|
||||||
|
rel.rsplit_once('/')
|
||||||
|
.map(|(p, _)| p.to_owned())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `name` is usable as a single new file/folder name: non-empty and
|
||||||
|
/// free of path separators / traversal.
|
||||||
|
pub fn valid_name(name: &str) -> bool {
|
||||||
|
!name.is_empty() && name != "." && name != ".." && !name.contains('/') && !name.contains('\\')
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A name that does not exist in `dir` yet, derived from `wanted` by
|
||||||
|
/// suffixing `_2`, `_3`, … before the extension (`wall.png` → `wall_2.png`).
|
||||||
|
pub fn unique_name(dir: &Path, wanted: &str) -> String {
|
||||||
|
if !dir.join(wanted).exists() {
|
||||||
|
return wanted.to_owned();
|
||||||
|
}
|
||||||
|
let (stem, ext) = match wanted.rsplit_once('.') {
|
||||||
|
// A leading dot (".gitignore") is a hidden name, not an extension.
|
||||||
|
Some((s, e)) if !s.is_empty() => (s, Some(e)),
|
||||||
|
_ => (wanted, None),
|
||||||
|
};
|
||||||
|
let mut n = 2;
|
||||||
|
loop {
|
||||||
|
let candidate = match ext {
|
||||||
|
Some(ext) => format!("{stem}_{n}.{ext}"),
|
||||||
|
None => format!("{stem}_{n}"),
|
||||||
|
};
|
||||||
|
if !dir.join(&candidate).exists() {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
n += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a new folder in `cwd` named `wanted` (unique-ified), returning its
|
||||||
|
/// assets-relative path.
|
||||||
|
pub fn create_folder(db: &AssetDatabase, cwd: &str, wanted: &str) -> std::io::Result<String> {
|
||||||
|
if !valid_name(wanted) {
|
||||||
|
return Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidInput,
|
||||||
|
format!("invalid folder name: {wanted:?}"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let dir = abs_of(db, cwd);
|
||||||
|
std::fs::create_dir_all(&dir)?;
|
||||||
|
let name = unique_name(&dir, wanted);
|
||||||
|
std::fs::create_dir(dir.join(&name))?;
|
||||||
|
Ok(join_rel(cwd, &name))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renames the entry at `rel` to `new_name` (same folder), returning the new
|
||||||
|
/// relative path. Registered files keep their uid via
|
||||||
|
/// [`AssetDatabase::move_asset`]; folders move every registered entry under
|
||||||
|
/// them via [`AssetDatabase::move_folder`]; unregistered files fall back to a
|
||||||
|
/// plain `fs::rename` (refusing to overwrite).
|
||||||
|
pub fn rename_entry(
|
||||||
|
db: &mut AssetDatabase,
|
||||||
|
rel: &str,
|
||||||
|
is_dir: bool,
|
||||||
|
new_name: &str,
|
||||||
|
) -> Result<String, AssetDbError> {
|
||||||
|
if !valid_name(new_name) {
|
||||||
|
return Err(AssetDbError::InvalidPath(new_name.to_owned()));
|
||||||
|
}
|
||||||
|
let new_rel = join_rel(&parent_of(rel), new_name);
|
||||||
|
if new_rel == rel {
|
||||||
|
return Ok(new_rel);
|
||||||
|
}
|
||||||
|
move_to(db, rel, is_dir, &new_rel)?;
|
||||||
|
Ok(new_rel)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Moves the entry at `rel` into the folder `dest_dir`, returning the new
|
||||||
|
/// relative path. Same uid-preserving rules as [`rename_entry`].
|
||||||
|
pub fn move_entry(
|
||||||
|
db: &mut AssetDatabase,
|
||||||
|
rel: &str,
|
||||||
|
is_dir: bool,
|
||||||
|
dest_dir: &str,
|
||||||
|
) -> Result<String, AssetDbError> {
|
||||||
|
let name = rel.rsplit('/').next().unwrap_or(rel);
|
||||||
|
let new_rel = join_rel(dest_dir, name);
|
||||||
|
if new_rel == rel {
|
||||||
|
return Ok(new_rel);
|
||||||
|
}
|
||||||
|
move_to(db, rel, is_dir, &new_rel)?;
|
||||||
|
Ok(new_rel)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes the entry: registered files through the database (entry dropped,
|
||||||
|
/// uid retired), unregistered files from disk, and folders **only when
|
||||||
|
/// empty** — recursive delete of assets is deliberately not offered.
|
||||||
|
pub fn delete_entry(db: &mut AssetDatabase, entry: &Entry) -> Result<(), AssetDbError> {
|
||||||
|
if entry.is_dir {
|
||||||
|
let dir = abs_of(db, &entry.rel);
|
||||||
|
if std::fs::read_dir(&dir)?.next().is_some() {
|
||||||
|
// (`ErrorKind::DirectoryNotEmpty` needs Rust 1.83; the workspace
|
||||||
|
// MSRV is older, so this stays a generic I/O error.)
|
||||||
|
return Err(AssetDbError::Io(std::io::Error::other(format!(
|
||||||
|
"folder not empty: {} (delete its contents first)",
|
||||||
|
entry.rel
|
||||||
|
))));
|
||||||
|
}
|
||||||
|
std::fs::remove_dir(&dir)?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
match entry.uid {
|
||||||
|
Some(uid) => delete_and_save(db, uid),
|
||||||
|
None => Ok(std::fs::remove_file(abs_of(db, &entry.rel))?),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Imports files dropped from the OS into `cwd`: each is copied in under a
|
||||||
|
/// collision-free name and registered (kind from the folder, else extension).
|
||||||
|
/// Directories and unreadable sources are skipped with a log line. Returns
|
||||||
|
/// how many files were imported.
|
||||||
|
pub fn import_files(db: &mut AssetDatabase, cwd: &str, sources: &[PathBuf]) -> usize {
|
||||||
|
let dir = abs_of(db, cwd);
|
||||||
|
if std::fs::create_dir_all(&dir).is_err() {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
let mut imported = 0;
|
||||||
|
for src in sources {
|
||||||
|
if src.is_dir() {
|
||||||
|
log::warn!("skipping folder drop {} (import files)", src.display());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(file_name) = src.file_name().map(|n| n.to_string_lossy().into_owned()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let name = unique_name(&dir, &file_name);
|
||||||
|
match std::fs::copy(src, dir.join(&name)) {
|
||||||
|
Ok(_) => {
|
||||||
|
let rel = join_rel(cwd, &name);
|
||||||
|
db.register(&rel);
|
||||||
|
log::info!("imported {rel}");
|
||||||
|
imported += 1;
|
||||||
|
}
|
||||||
|
Err(err) => log::warn!("could not import {}: {err}", src.display()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if imported > 0 {
|
||||||
|
if let Err(err) = db.save() {
|
||||||
|
log::warn!("could not write asset manifest: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
imported
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- internals -----------------------------------------------------------
|
||||||
|
|
||||||
|
/// The absolute path of an assets-relative path under the database's root.
|
||||||
|
fn abs_of(db: &AssetDatabase, rel: &str) -> PathBuf {
|
||||||
|
let assets = db.assets_dir();
|
||||||
|
if rel.is_empty() {
|
||||||
|
assets
|
||||||
|
} else {
|
||||||
|
assets.join(rel.replace('/', std::path::MAIN_SEPARATOR_STR))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Routes a rename/move to the right primitive: `move_folder` for folders,
|
||||||
|
/// `move_asset` for registered files, `fs::rename` (no overwrite) for
|
||||||
|
/// unregistered ones. Saves the manifest after a database change.
|
||||||
|
fn move_to(
|
||||||
|
db: &mut AssetDatabase,
|
||||||
|
rel: &str,
|
||||||
|
is_dir: bool,
|
||||||
|
new_rel: &str,
|
||||||
|
) -> Result<(), AssetDbError> {
|
||||||
|
if is_dir {
|
||||||
|
db.move_folder(rel, new_rel)?;
|
||||||
|
save_manifest(db);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
match db.uid_of(rel) {
|
||||||
|
Some(uid) => {
|
||||||
|
db.move_asset(uid, new_rel)?;
|
||||||
|
save_manifest(db);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let to = abs_of(db, new_rel);
|
||||||
|
if to.exists() {
|
||||||
|
return Err(AssetDbError::DestinationExists(new_rel.to_owned()));
|
||||||
|
}
|
||||||
|
if let Some(parent) = to.parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
Ok(std::fs::rename(abs_of(db, rel), to)?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes a registered asset and persists the manifest.
|
||||||
|
fn delete_and_save(db: &mut AssetDatabase, uid: AssetUid) -> Result<(), AssetDbError> {
|
||||||
|
db.delete_asset(uid)?;
|
||||||
|
save_manifest(db);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best-effort manifest save after a mutation (failure → Console, not fatal).
|
||||||
|
fn save_manifest(db: &AssetDatabase) {
|
||||||
|
if let Err(err) = db.save() {
|
||||||
|
log::warn!("could not write asset manifest: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::sync::atomic::{AtomicU32, Ordering};
|
||||||
|
|
||||||
|
static COUNTER: AtomicU32 = AtomicU32::new(0);
|
||||||
|
|
||||||
|
/// A fresh project root with an `assets/` tree and an open database.
|
||||||
|
fn scratch_db(files: &[&str]) -> (PathBuf, AssetDatabase) {
|
||||||
|
let root = std::env::temp_dir().join(format!(
|
||||||
|
"oxide_explorer_test_{}_{}",
|
||||||
|
std::process::id(),
|
||||||
|
COUNTER.fetch_add(1, Ordering::SeqCst),
|
||||||
|
));
|
||||||
|
let assets = root.join("assets");
|
||||||
|
std::fs::create_dir_all(&assets).unwrap();
|
||||||
|
for rel in files {
|
||||||
|
let full = assets.join(rel);
|
||||||
|
std::fs::create_dir_all(full.parent().unwrap()).unwrap();
|
||||||
|
std::fs::write(full, b"x").unwrap();
|
||||||
|
}
|
||||||
|
let mut db = AssetDatabase::new(&root);
|
||||||
|
db.scan();
|
||||||
|
(root, db)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn breadcrumbs_and_path_helpers() {
|
||||||
|
assert_eq!(breadcrumbs(""), vec![("assets".to_owned(), String::new())]);
|
||||||
|
assert_eq!(
|
||||||
|
breadcrumbs("textures/env"),
|
||||||
|
vec![
|
||||||
|
("assets".to_owned(), String::new()),
|
||||||
|
("textures".to_owned(), "textures".to_owned()),
|
||||||
|
("env".to_owned(), "textures/env".to_owned()),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(join_rel("", "a"), "a");
|
||||||
|
assert_eq!(join_rel("a/b", "c"), "a/b/c");
|
||||||
|
assert_eq!(parent_of("a/b/c"), "a/b");
|
||||||
|
assert_eq!(parent_of("a"), "");
|
||||||
|
assert!(valid_name("wall.png"));
|
||||||
|
assert!(!valid_name(""));
|
||||||
|
assert!(!valid_name("a/b"));
|
||||||
|
assert!(!valid_name(".."));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn list_dir_sorts_folders_first_and_annotates_registered_files() {
|
||||||
|
let (root, db) = scratch_db(&["textures/wall.png", "textures/env/sky.png", "notes.md"]);
|
||||||
|
|
||||||
|
let top = list_dir(&db, "");
|
||||||
|
let names: Vec<&str> = top.iter().map(|e| e.name.as_str()).collect();
|
||||||
|
assert_eq!(names, ["textures", "notes.md"]);
|
||||||
|
assert!(top[0].is_dir && top[0].uid.is_none());
|
||||||
|
assert_eq!(top[1].kind, Some(AssetKind::Other));
|
||||||
|
|
||||||
|
let textures = list_dir(&db, "textures");
|
||||||
|
let names: Vec<&str> = textures.iter().map(|e| e.name.as_str()).collect();
|
||||||
|
assert_eq!(names, ["env", "wall.png"]);
|
||||||
|
assert_eq!(textures[1].kind, Some(AssetKind::Texture));
|
||||||
|
assert_eq!(textures[1].uid, db.uid_of("textures/wall.png"));
|
||||||
|
|
||||||
|
// A folder that does not exist lists as empty, not an error.
|
||||||
|
assert!(list_dir(&db, "nope").is_empty());
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(root).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unique_name_suffixes_before_the_extension() {
|
||||||
|
let (root, db) = scratch_db(&["textures/wall.png"]);
|
||||||
|
let dir = db.assets_dir().join("textures");
|
||||||
|
assert_eq!(unique_name(&dir, "new.png"), "new.png");
|
||||||
|
assert_eq!(unique_name(&dir, "wall.png"), "wall_2.png");
|
||||||
|
std::fs::write(dir.join("wall_2.png"), b"x").unwrap();
|
||||||
|
assert_eq!(unique_name(&dir, "wall.png"), "wall_3.png");
|
||||||
|
std::fs::remove_dir_all(root).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_folder_is_unique_and_validated() {
|
||||||
|
let (root, db) = scratch_db(&[]);
|
||||||
|
assert_eq!(create_folder(&db, "", "props").unwrap(), "props");
|
||||||
|
assert_eq!(create_folder(&db, "", "props").unwrap(), "props_2");
|
||||||
|
assert_eq!(create_folder(&db, "props", "env").unwrap(), "props/env");
|
||||||
|
assert!(create_folder(&db, "", "a/b").is_err());
|
||||||
|
std::fs::remove_dir_all(root).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rename_and_move_preserve_uids() {
|
||||||
|
let (root, mut db) = scratch_db(&["textures/wall.png", "textures/env/sky.png"]);
|
||||||
|
let wall = db.uid_of("textures/wall.png").unwrap();
|
||||||
|
let sky = db.uid_of("textures/env/sky.png").unwrap();
|
||||||
|
|
||||||
|
// Rename a file in place.
|
||||||
|
let new_rel = rename_entry(&mut db, "textures/wall.png", false, "brick.png").unwrap();
|
||||||
|
assert_eq!(new_rel, "textures/brick.png");
|
||||||
|
assert_eq!(db.relative_path(wall), Some("textures/brick.png"));
|
||||||
|
|
||||||
|
// Move it into a sibling folder.
|
||||||
|
let new_rel = move_entry(&mut db, "textures/brick.png", false, "textures/env").unwrap();
|
||||||
|
assert_eq!(new_rel, "textures/env/brick.png");
|
||||||
|
assert_eq!(db.relative_path(wall), Some("textures/env/brick.png"));
|
||||||
|
|
||||||
|
// Rename the folder: both entries follow, uids intact.
|
||||||
|
let new_rel = rename_entry(&mut db, "textures/env", true, "world").unwrap();
|
||||||
|
assert_eq!(new_rel, "textures/world");
|
||||||
|
assert_eq!(db.relative_path(sky), Some("textures/world/sky.png"));
|
||||||
|
assert_eq!(db.relative_path(wall), Some("textures/world/brick.png"));
|
||||||
|
|
||||||
|
// Invalid target name is refused.
|
||||||
|
assert!(rename_entry(&mut db, "textures/world", true, "a/b").is_err());
|
||||||
|
|
||||||
|
// The manifest was persisted along the way.
|
||||||
|
let reloaded = AssetDatabase::open(&root);
|
||||||
|
assert_eq!(reloaded.relative_path(sky), Some("textures/world/sky.png"));
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(root).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unregistered_files_rename_through_the_filesystem() {
|
||||||
|
let (root, mut db) = scratch_db(&[]);
|
||||||
|
// A file the database does not track (e.g. a license dropped next to
|
||||||
|
// a font). Note scratch_db scans, so create it *after*.
|
||||||
|
let assets = db.assets_dir();
|
||||||
|
std::fs::write(assets.join("OFL.txt"), b"x").unwrap();
|
||||||
|
assert!(db.uid_of("OFL.txt").is_none());
|
||||||
|
|
||||||
|
let new_rel = rename_entry(&mut db, "OFL.txt", false, "LICENSE.txt").unwrap();
|
||||||
|
assert_eq!(new_rel, "LICENSE.txt");
|
||||||
|
assert!(assets.join("LICENSE.txt").is_file());
|
||||||
|
assert!(!assets.join("OFL.txt").exists());
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(root).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delete_rules_files_yes_folders_only_when_empty() {
|
||||||
|
let (root, mut db) = scratch_db(&["textures/wall.png"]);
|
||||||
|
let wall_entry = list_dir(&db, "textures")
|
||||||
|
.into_iter()
|
||||||
|
.find(|e| e.name == "wall.png")
|
||||||
|
.unwrap();
|
||||||
|
let folder_entry = list_dir(&db, "")
|
||||||
|
.into_iter()
|
||||||
|
.find(|e| e.name == "textures")
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Non-empty folder refused; file deletes (entry + disk); empty folder ok.
|
||||||
|
assert!(delete_entry(&mut db, &folder_entry).is_err());
|
||||||
|
delete_entry(&mut db, &wall_entry).unwrap();
|
||||||
|
assert!(db.uid_of("textures/wall.png").is_none());
|
||||||
|
delete_entry(&mut db, &folder_entry).unwrap();
|
||||||
|
assert!(list_dir(&db, "").is_empty());
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(root).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn import_copies_registers_and_dodges_collisions() {
|
||||||
|
let (root, mut db) = scratch_db(&["textures/wall.png"]);
|
||||||
|
// Two outside files, one colliding with an existing asset name.
|
||||||
|
let outside = root.join("outside");
|
||||||
|
std::fs::create_dir_all(&outside).unwrap();
|
||||||
|
std::fs::write(outside.join("wall.png"), b"new").unwrap();
|
||||||
|
std::fs::write(outside.join("tree.glb"), b"tree").unwrap();
|
||||||
|
|
||||||
|
let n = import_files(
|
||||||
|
&mut db,
|
||||||
|
"textures",
|
||||||
|
&[outside.join("wall.png"), outside.join("tree.glb")],
|
||||||
|
);
|
||||||
|
assert_eq!(n, 2);
|
||||||
|
assert!(db.uid_of("textures/wall_2.png").is_some());
|
||||||
|
// Kind follows the *folder* it was dropped into.
|
||||||
|
let tree = db.uid_of("textures/tree.glb").unwrap();
|
||||||
|
assert_eq!(db.entry(tree).unwrap().kind, AssetKind::Texture);
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(root).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ pub mod bindings;
|
|||||||
pub mod command;
|
pub mod command;
|
||||||
pub mod commands;
|
pub mod commands;
|
||||||
pub mod console;
|
pub mod console;
|
||||||
|
pub mod explorer;
|
||||||
pub mod extension;
|
pub mod extension;
|
||||||
pub mod gizmo;
|
pub mod gizmo;
|
||||||
pub mod play;
|
pub mod play;
|
||||||
|
|||||||
+664
-43
@@ -39,7 +39,7 @@ use std::path::PathBuf;
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use egui_dock::{DockArea, DockState, NodeIndex, Style};
|
use egui_dock::{DockArea, DockState, NodeIndex, Style};
|
||||||
use oxide_engine::asset::{asset_ref_target, AssetDatabase, AssetEntry};
|
use oxide_engine::asset::{asset_ref_target, AssetDatabase};
|
||||||
use oxide_engine::input::{Binding, InputState};
|
use oxide_engine::input::{Binding, InputState};
|
||||||
use oxide_engine::prelude::*;
|
use oxide_engine::prelude::*;
|
||||||
use oxide_engine::project::{Project, ProjectError};
|
use oxide_engine::project::{Project, ProjectError};
|
||||||
@@ -52,7 +52,7 @@ use crate::command::{Command, CommandStack};
|
|||||||
use crate::commands::{RenameCmd, SetFieldCmd, SetUiPanelCmd};
|
use crate::commands::{RenameCmd, SetFieldCmd, SetUiPanelCmd};
|
||||||
use crate::extension::EditorExtensions;
|
use crate::extension::EditorExtensions;
|
||||||
use crate::gizmo::{self, Axis3, GizmoHandle, GizmoMode, PlaneAxis};
|
use crate::gizmo::{self, Axis3, GizmoHandle, GizmoMode, PlaneAxis};
|
||||||
use crate::state::{EditorState, PlayState};
|
use crate::state::{EditorState, ExternalEditorPrefs, PlayState, EXTERNAL_EDITOR_SECTION};
|
||||||
use oxide_engine::math::{Mat4, Vec4};
|
use oxide_engine::math::{Mat4, Vec4};
|
||||||
use oxide_engine::reflect::FieldInfo;
|
use oxide_engine::reflect::FieldInfo;
|
||||||
|
|
||||||
@@ -104,6 +104,26 @@ impl PanelKind {
|
|||||||
/// despawn / reparent). Tracked here only because the hierarchy panel builds
|
/// despawn / reparent). Tracked here only because the hierarchy panel builds
|
||||||
/// these while the UI closure runs and applies them after, the same idiom
|
/// these while the UI closure runs and applies them after, the same idiom
|
||||||
/// the Stage-5 main.rs used.
|
/// the Stage-5 main.rs used.
|
||||||
|
/// Drag payload for a Project-panel explorer row: what is being moved.
|
||||||
|
/// Dropping it on a folder row (or the ".." row) moves the file/folder there
|
||||||
|
/// through the database's uid-preserving ops.
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct ExplorerDragPayload {
|
||||||
|
/// Assets-relative path of the dragged entry.
|
||||||
|
rel: String,
|
||||||
|
/// Whether it is a folder.
|
||||||
|
is_dir: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which path field a finished native folder pick fills in.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum FolderPickTarget {
|
||||||
|
/// The "New Project" dialog's project-folder field.
|
||||||
|
NewProject,
|
||||||
|
/// The "Open Project" dialog's path field.
|
||||||
|
OpenProject,
|
||||||
|
}
|
||||||
|
|
||||||
enum PendingAction {
|
enum PendingAction {
|
||||||
/// Spawn a named prefab as a root entity (data-driven add-menu). The
|
/// Spawn a named prefab as a root entity (data-driven add-menu). The
|
||||||
/// `Empty` prefab is a bare node; the rest carry components.
|
/// `Empty` prefab is a bare node; the rest carry components.
|
||||||
@@ -523,8 +543,15 @@ pub struct Shell {
|
|||||||
new_project_name: String,
|
new_project_name: String,
|
||||||
/// Text entered in the "Open Project" modal.
|
/// Text entered in the "Open Project" modal.
|
||||||
open_project_path: String,
|
open_project_path: String,
|
||||||
|
/// An in-flight native folder pick (Browse… in New/Open Project): which
|
||||||
|
/// field the result lands in + the channel the dialog thread reports on.
|
||||||
|
/// The dialog runs on its own thread so the UI keeps redrawing; `None`
|
||||||
|
/// means no pick is up (and gates the Browse buttons to one at a time).
|
||||||
|
folder_pick: Option<(FolderPickTarget, std::sync::mpsc::Receiver<Option<PathBuf>>)>,
|
||||||
/// Text entered in the "Groups" editor's "add group" field.
|
/// Text entered in the "Groups" editor's "add group" field.
|
||||||
new_group_name: String,
|
new_group_name: String,
|
||||||
|
/// Text entered in the Script inspector's "New Script" name field.
|
||||||
|
new_script_name: String,
|
||||||
/// The Console panel's command-input buffer (the terminal prompt).
|
/// The Console panel's command-input buffer (the terminal prompt).
|
||||||
terminal_input: String,
|
terminal_input: String,
|
||||||
|
|
||||||
@@ -539,6 +566,10 @@ pub struct Shell {
|
|||||||
/// pick) versus over an opaque panel.
|
/// pick) versus over an opaque panel.
|
||||||
viewport_rect_px: Option<(f32, f32, f32, f32)>,
|
viewport_rect_px: Option<(f32, f32, f32, f32)>,
|
||||||
|
|
||||||
|
/// The Project panel's file-explorer state (current folder, in-progress
|
||||||
|
/// rename / new-folder edits). Behavior lives in [`crate::explorer`].
|
||||||
|
explorer: crate::explorer::ExplorerState,
|
||||||
|
|
||||||
// --- per-frame UI scratch -----------------------------------------
|
// --- per-frame UI scratch -----------------------------------------
|
||||||
pending: Vec<PendingAction>,
|
pending: Vec<PendingAction>,
|
||||||
rename_buf: String,
|
rename_buf: String,
|
||||||
@@ -623,7 +654,10 @@ impl Shell {
|
|||||||
new_project_path: String::new(),
|
new_project_path: String::new(),
|
||||||
new_project_name: String::new(),
|
new_project_name: String::new(),
|
||||||
open_project_path: String::new(),
|
open_project_path: String::new(),
|
||||||
|
folder_pick: None,
|
||||||
|
explorer: crate::explorer::ExplorerState::default(),
|
||||||
new_group_name: String::new(),
|
new_group_name: String::new(),
|
||||||
|
new_script_name: String::new(),
|
||||||
terminal_input: String::new(),
|
terminal_input: String::new(),
|
||||||
quit_requested: false,
|
quit_requested: false,
|
||||||
viewport_rect_px: None,
|
viewport_rect_px: None,
|
||||||
@@ -1100,6 +1134,9 @@ impl Shell {
|
|||||||
/// closure more than once during layout.
|
/// closure more than once during layout.
|
||||||
pub fn build(&mut self, ui: &mut egui::Ui) {
|
pub fn build(&mut self, ui: &mut egui::Ui) {
|
||||||
self.pending.clear();
|
self.pending.clear();
|
||||||
|
// Collect a native folder pick before the project dialogs render, so
|
||||||
|
// a chosen path appears in its field the same frame.
|
||||||
|
self.poll_folder_pick();
|
||||||
|
|
||||||
self.menu_bar(ui);
|
self.menu_bar(ui);
|
||||||
self.play_toolbar(ui);
|
self.play_toolbar(ui);
|
||||||
@@ -1125,6 +1162,8 @@ impl Shell {
|
|||||||
extensions: &mut self.extensions,
|
extensions: &mut self.extensions,
|
||||||
pending: &mut self.pending,
|
pending: &mut self.pending,
|
||||||
rename_buf: &mut self.rename_buf,
|
rename_buf: &mut self.rename_buf,
|
||||||
|
explorer: &mut self.explorer,
|
||||||
|
new_script_name: &mut self.new_script_name,
|
||||||
terminal_input: &mut self.terminal_input,
|
terminal_input: &mut self.terminal_input,
|
||||||
rot_euler: &mut self.rot_euler,
|
rot_euler: &mut self.rot_euler,
|
||||||
euler_for: &mut self.euler_for,
|
euler_for: &mut self.euler_for,
|
||||||
@@ -1459,6 +1498,8 @@ impl Shell {
|
|||||||
// section editor lands.
|
// section editor lands.
|
||||||
if name == crate::bindings::SETTINGS_SECTION {
|
if name == crate::bindings::SETTINGS_SECTION {
|
||||||
ui.collapsing("Input Bindings", |ui| self.input_bindings_page(ui));
|
ui.collapsing("Input Bindings", |ui| self.input_bindings_page(ui));
|
||||||
|
} else if name == EXTERNAL_EDITOR_SECTION {
|
||||||
|
ui.collapsing("External Editor", |ui| self.external_editor_page(ui));
|
||||||
} else {
|
} else {
|
||||||
let ron = self.state.settings.section_ron(name);
|
let ron = self.state.settings.section_ron(name);
|
||||||
ui.collapsing(name, |ui| match ron {
|
ui.collapsing(name, |ui| match ron {
|
||||||
@@ -1482,6 +1523,37 @@ impl Shell {
|
|||||||
self.show_preferences = open;
|
self.show_preferences = open;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The External Editor preferences page: the command used when opening a
|
||||||
|
/// script from the editor. Edits mark the preferences dirty so the host
|
||||||
|
/// persists them like a binding remap.
|
||||||
|
fn external_editor_page(&mut self, ui: &mut egui::Ui) {
|
||||||
|
let Some(prefs) = self
|
||||||
|
.state
|
||||||
|
.settings
|
||||||
|
.get_mut::<ExternalEditorPrefs>(EXTERNAL_EDITOR_SECTION)
|
||||||
|
else {
|
||||||
|
ui.weak("(section unavailable)");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("Open scripts with");
|
||||||
|
if ui
|
||||||
|
.add(
|
||||||
|
egui::TextEdit::singleline(&mut prefs.command)
|
||||||
|
.hint_text("auto — $VISUAL/$EDITOR, else xdg-open"),
|
||||||
|
)
|
||||||
|
.changed()
|
||||||
|
{
|
||||||
|
self.bindings_dirty = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ui.weak(
|
||||||
|
"Launched as `<command> <file>` (the command may carry flags, e.g. `code -g`). \
|
||||||
|
Leave empty for auto: $VISUAL/$EDITOR runs in a built-in Terminal tab, \
|
||||||
|
otherwise the file opens via xdg-open.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Renders the input-bindings preferences page: every registered editor
|
/// Renders the input-bindings preferences page: every registered editor
|
||||||
/// action with its current bindings, a Change/Add/Clear control per
|
/// action with its current bindings, a Change/Add/Clear control per
|
||||||
/// slot, a per-action "Restore defaults" button, and a global
|
/// slot, a per-action "Restore defaults" button, and a global
|
||||||
@@ -1853,22 +1925,80 @@ impl Shell {
|
|||||||
self.show_group_editor = open;
|
self.show_group_editor = open;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// In-app New Project dialog: a path field, a name field, and Create / Cancel.
|
/// Launches the native folder picker on a helper thread, reporting into
|
||||||
/// A native OS file dialog (`rfd` or similar) is a piece-6 polish item — for
|
/// [`Shell::folder_pick`]. One pick at a time; the Browse buttons are
|
||||||
/// now the path is typed, which is enough to exercise the flow end-to-end and
|
/// disabled while one is up. `rfd`'s portal backend serves both Wayland
|
||||||
/// keeps the editor dependency-light.
|
/// and X11; if no portal service is running the thread reports `None`
|
||||||
|
/// (same as cancel) and the typed path field still works.
|
||||||
|
fn launch_folder_pick(&mut self, target: FolderPickTarget, start_dir: Option<PathBuf>) {
|
||||||
|
if self.folder_pick.is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let mut dialog = rfd::FileDialog::new().set_title("Choose a project folder");
|
||||||
|
if let Some(dir) = start_dir.filter(|d| d.is_dir()) {
|
||||||
|
dialog = dialog.set_directory(dir);
|
||||||
|
}
|
||||||
|
// A dropped receiver (shell already gone) is fine to ignore.
|
||||||
|
let _ = tx.send(dialog.pick_folder());
|
||||||
|
});
|
||||||
|
self.folder_pick = Some((target, rx));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collects a finished native folder pick into its target path field.
|
||||||
|
/// Called once per frame from [`Shell::build`]; does nothing while the
|
||||||
|
/// dialog is still up (the thread hasn't reported).
|
||||||
|
fn poll_folder_pick(&mut self) {
|
||||||
|
use std::sync::mpsc::TryRecvError;
|
||||||
|
let Some((target, rx)) = self.folder_pick.take() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
match rx.try_recv() {
|
||||||
|
// Picked: fill the field the dialog was opened for.
|
||||||
|
Ok(Some(path)) => {
|
||||||
|
let text = path.display().to_string();
|
||||||
|
match target {
|
||||||
|
FolderPickTarget::NewProject => self.new_project_path = text,
|
||||||
|
FolderPickTarget::OpenProject => self.open_project_path = text,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Cancelled (or the portal is unavailable): keep the typed text.
|
||||||
|
Ok(None) | Err(TryRecvError::Disconnected) => {}
|
||||||
|
// Still up: put it back and check again next frame.
|
||||||
|
Err(TryRecvError::Empty) => self.folder_pick = Some((target, rx)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// In-app New Project dialog: a folder field with a native Browse… picker,
|
||||||
|
/// a name field, and Create / Cancel. The path can still be typed by hand
|
||||||
|
/// (the picker needs a running xdg-desktop-portal to appear).
|
||||||
fn new_project_window(&mut self, ui: &mut egui::Ui) {
|
fn new_project_window(&mut self, ui: &mut egui::Ui) {
|
||||||
let ctx = ui.ctx().clone();
|
let ctx = ui.ctx().clone();
|
||||||
let mut open = self.show_new_project;
|
let mut open = self.show_new_project;
|
||||||
let mut create_now = false;
|
let mut create_now = false;
|
||||||
let mut cancel_now = false;
|
let mut cancel_now = false;
|
||||||
|
let mut browse_now = false;
|
||||||
|
let picking = self.folder_pick.is_some();
|
||||||
egui::Window::new("New Project")
|
egui::Window::new("New Project")
|
||||||
.open(&mut open)
|
.open(&mut open)
|
||||||
.default_size([520.0, 160.0])
|
.default_size([520.0, 160.0])
|
||||||
.resizable(true)
|
.resizable(true)
|
||||||
.show(&ctx, |ui| {
|
.show(&ctx, |ui| {
|
||||||
ui.label("Project folder");
|
ui.label("Project folder");
|
||||||
ui.text_edit_singleline(&mut self.new_project_path);
|
ui.horizontal(|ui| {
|
||||||
|
ui.text_edit_singleline(&mut self.new_project_path);
|
||||||
|
if ui
|
||||||
|
.add_enabled(!picking, egui::Button::new("Browse…"))
|
||||||
|
.on_hover_text(
|
||||||
|
"Pick the project folder with the system dialog \
|
||||||
|
(it can create a new folder too)",
|
||||||
|
)
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
browse_now = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
ui.label("Display name");
|
ui.label("Display name");
|
||||||
ui.text_edit_singleline(&mut self.new_project_name);
|
ui.text_edit_singleline(&mut self.new_project_name);
|
||||||
ui.add_space(6.0);
|
ui.add_space(6.0);
|
||||||
@@ -1887,6 +2017,12 @@ impl Shell {
|
|||||||
project.oxide.",
|
project.oxide.",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
if browse_now {
|
||||||
|
let start = Some(PathBuf::from(self.new_project_path.trim()))
|
||||||
|
.filter(|p| p.is_dir())
|
||||||
|
.or_else(|| std::env::var_os("HOME").map(PathBuf::from));
|
||||||
|
self.launch_folder_pick(FolderPickTarget::NewProject, start);
|
||||||
|
}
|
||||||
if create_now {
|
if create_now {
|
||||||
let path = PathBuf::from(self.new_project_path.trim());
|
let path = PathBuf::from(self.new_project_path.trim());
|
||||||
let name = self.new_project_name.trim().to_owned();
|
let name = self.new_project_name.trim().to_owned();
|
||||||
@@ -1906,19 +2042,31 @@ impl Shell {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// In-app Open Project dialog: one path field, Open / Cancel.
|
/// In-app Open Project dialog: a path field with a native Browse… picker,
|
||||||
|
/// Open / Cancel.
|
||||||
fn open_project_window(&mut self, ui: &mut egui::Ui) {
|
fn open_project_window(&mut self, ui: &mut egui::Ui) {
|
||||||
let ctx = ui.ctx().clone();
|
let ctx = ui.ctx().clone();
|
||||||
let mut open = self.show_open_project;
|
let mut open = self.show_open_project;
|
||||||
let mut open_now = false;
|
let mut open_now = false;
|
||||||
let mut cancel_now = false;
|
let mut cancel_now = false;
|
||||||
|
let mut browse_now = false;
|
||||||
|
let picking = self.folder_pick.is_some();
|
||||||
egui::Window::new("Open Project")
|
egui::Window::new("Open Project")
|
||||||
.open(&mut open)
|
.open(&mut open)
|
||||||
.default_size([520.0, 140.0])
|
.default_size([520.0, 140.0])
|
||||||
.resizable(true)
|
.resizable(true)
|
||||||
.show(&ctx, |ui| {
|
.show(&ctx, |ui| {
|
||||||
ui.label("Project folder (or path to project.oxide)");
|
ui.label("Project folder (or path to project.oxide)");
|
||||||
ui.text_edit_singleline(&mut self.open_project_path);
|
ui.horizontal(|ui| {
|
||||||
|
ui.text_edit_singleline(&mut self.open_project_path);
|
||||||
|
if ui
|
||||||
|
.add_enabled(!picking, egui::Button::new("Browse…"))
|
||||||
|
.on_hover_text("Pick the project folder with the system dialog")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
browse_now = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
ui.add_space(6.0);
|
ui.add_space(6.0);
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
let valid = !self.open_project_path.trim().is_empty();
|
let valid = !self.open_project_path.trim().is_empty();
|
||||||
@@ -1930,6 +2078,16 @@ impl Shell {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
if browse_now {
|
||||||
|
let start = Some(PathBuf::from(self.open_project_path.trim()))
|
||||||
|
.filter(|p| p.is_dir())
|
||||||
|
.or_else(|| {
|
||||||
|
let first = self.state.recent.entries().first()?;
|
||||||
|
Some(first.parent()?.to_path_buf())
|
||||||
|
})
|
||||||
|
.or_else(|| std::env::var_os("HOME").map(PathBuf::from));
|
||||||
|
self.launch_folder_pick(FolderPickTarget::OpenProject, start);
|
||||||
|
}
|
||||||
if open_now {
|
if open_now {
|
||||||
let path = PathBuf::from(self.open_project_path.trim());
|
let path = PathBuf::from(self.open_project_path.trim());
|
||||||
match self.open_project(path) {
|
match self.open_project(path) {
|
||||||
@@ -2154,6 +2312,12 @@ struct ShellTabViewer<'a> {
|
|||||||
extensions: &'a mut EditorExtensions,
|
extensions: &'a mut EditorExtensions,
|
||||||
pending: &'a mut Vec<PendingAction>,
|
pending: &'a mut Vec<PendingAction>,
|
||||||
rename_buf: &'a mut String,
|
rename_buf: &'a mut String,
|
||||||
|
/// The Project panel's file-explorer state. Borrowed from
|
||||||
|
/// [`Shell::explorer`].
|
||||||
|
explorer: &'a mut crate::explorer::ExplorerState,
|
||||||
|
/// The Script inspector's "New Script" name field. Borrowed from
|
||||||
|
/// [`Shell::new_script_name`].
|
||||||
|
new_script_name: &'a mut String,
|
||||||
/// The Console command-input buffer (the terminal prompt).
|
/// The Console command-input buffer (the terminal prompt).
|
||||||
terminal_input: &'a mut String,
|
terminal_input: &'a mut String,
|
||||||
rot_euler: &'a mut Vec3,
|
rot_euler: &'a mut Vec3,
|
||||||
@@ -3364,6 +3528,21 @@ impl<'a> ShellTabViewer<'a> {
|
|||||||
edits.push((comp.name, row.info.name, new_ron));
|
edits.push((comp.name, row.info.name, new_ron));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Stage-10 UX: author and open scripts without leaving the
|
||||||
|
// editor. The assignment goes through `edits` → SetFieldCmd
|
||||||
|
// like any field change, so it is undoable (the created file
|
||||||
|
// itself stays — harmless).
|
||||||
|
if comp.name == "Script" {
|
||||||
|
let source = comp
|
||||||
|
.fields
|
||||||
|
.iter()
|
||||||
|
.find(|r| r.info.name == "source")
|
||||||
|
.and_then(|r| ron::from_str::<Option<AssetUid>>(&r.value).ok())
|
||||||
|
.flatten();
|
||||||
|
if let Some(ron) = self.new_script_row(ui, source) {
|
||||||
|
edits.push((comp.name, "source", ron));
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3409,6 +3588,126 @@ impl<'a> ShellTabViewer<'a> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The script-tools row at the bottom of a `Script` component's section:
|
||||||
|
/// a name field + "New Script" button that writes a `.rhai` template into
|
||||||
|
/// `assets/scripts/`, registers it in the asset database, and returns the
|
||||||
|
/// RON for the component's `source` field — the caller routes it through
|
||||||
|
/// the normal edit path so the assignment is undoable (undo detaches the
|
||||||
|
/// script; the created file stays, which is harmless) — plus an "Edit"
|
||||||
|
/// button opening the currently assigned script (`source`) in the user's
|
||||||
|
/// editor. Disabled until a project is open. Errors go to the log, i.e.
|
||||||
|
/// the Console panel.
|
||||||
|
fn new_script_row(&mut self, ui: &mut egui::Ui, source: Option<AssetUid>) -> Option<String> {
|
||||||
|
let mut created = None;
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
let open = self.state.asset_db.is_some();
|
||||||
|
ui.add_enabled_ui(open, |ui| {
|
||||||
|
let name = ui.add(
|
||||||
|
egui::TextEdit::singleline(self.new_script_name)
|
||||||
|
.hint_text("new_script")
|
||||||
|
.desired_width(120.0),
|
||||||
|
);
|
||||||
|
let submitted = name.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||||
|
let clicked = ui
|
||||||
|
.small_button("➕ New Script")
|
||||||
|
.on_hover_text(
|
||||||
|
"Create a .rhai file from the template in assets/scripts/ \
|
||||||
|
and assign it to this component",
|
||||||
|
)
|
||||||
|
.clicked();
|
||||||
|
if clicked || submitted {
|
||||||
|
let db = self
|
||||||
|
.state
|
||||||
|
.asset_db
|
||||||
|
.as_mut()
|
||||||
|
.expect("row is enabled only with a project open");
|
||||||
|
match crate::assets::create_script_file(&db.assets_dir(), self.new_script_name)
|
||||||
|
{
|
||||||
|
Ok(rel) => {
|
||||||
|
let uid = db.register(&rel);
|
||||||
|
if let Err(err) = db.save() {
|
||||||
|
log::warn!("could not write asset manifest: {err}");
|
||||||
|
}
|
||||||
|
log::info!("created {rel}");
|
||||||
|
self.new_script_name.clear();
|
||||||
|
created = ron::to_string(&Some(uid)).ok();
|
||||||
|
}
|
||||||
|
Err(err) => log::warn!("could not create script: {err}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let edit = ui
|
||||||
|
.add_enabled(source.is_some(), egui::Button::new("✏ Edit").small())
|
||||||
|
.on_hover_text(
|
||||||
|
"Open the assigned script in your editor (External Editor \
|
||||||
|
preference, else $VISUAL/$EDITOR in a Terminal tab, else \
|
||||||
|
xdg-open). Saved edits live-reload.",
|
||||||
|
);
|
||||||
|
if edit.clicked() {
|
||||||
|
if let Some(uid) = source {
|
||||||
|
self.open_script_in_editor(uid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if !open {
|
||||||
|
ui.label(egui::RichText::new("(open a project to create scripts)").weak());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
created
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens the script asset `uid` in the user's editor. Resolution order:
|
||||||
|
///
|
||||||
|
/// 1. the **External Editor** preference command, spawned detached as
|
||||||
|
/// `<command> <file>`;
|
||||||
|
/// 2. `$VISUAL` / `$EDITOR`, run in a new **Terminal-panel tab** (so TUI
|
||||||
|
/// editors like vim/nano work in-editor);
|
||||||
|
/// 3. `xdg-open` (the desktop's default handler).
|
||||||
|
///
|
||||||
|
/// Whichever way, saved edits flow back through the file watcher's live
|
||||||
|
/// reload — including into a playing scene.
|
||||||
|
fn open_script_in_editor(&mut self, uid: AssetUid) {
|
||||||
|
let Some(abs) = self
|
||||||
|
.state
|
||||||
|
.asset_db
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|db| db.absolute_path(uid))
|
||||||
|
else {
|
||||||
|
log::warn!("script asset {uid:?} has no file to open");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let configured = self
|
||||||
|
.state
|
||||||
|
.settings
|
||||||
|
.get::<ExternalEditorPrefs>(EXTERNAL_EDITOR_SECTION)
|
||||||
|
.map(|p| p.command.trim().to_owned())
|
||||||
|
.unwrap_or_default();
|
||||||
|
if !configured.is_empty() {
|
||||||
|
spawn_detached(&configured, &abs);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let not_blank = |v: String| (!v.trim().is_empty()).then_some(v);
|
||||||
|
let terminal_editor = std::env::var("VISUAL")
|
||||||
|
.ok()
|
||||||
|
.and_then(not_blank)
|
||||||
|
.or_else(|| std::env::var("EDITOR").ok().and_then(not_blank));
|
||||||
|
match terminal_editor {
|
||||||
|
Some(editor) => {
|
||||||
|
let leaf = abs
|
||||||
|
.file_name()
|
||||||
|
.map(|n| n.to_string_lossy().into_owned())
|
||||||
|
.unwrap_or_else(|| "script".to_owned());
|
||||||
|
// $EDITOR may carry flags — whitespace-split it like the
|
||||||
|
// configured command, then append the file path.
|
||||||
|
let mut parts = editor.split_whitespace().map(str::to_owned);
|
||||||
|
let program = parts.next().unwrap_or_else(|| "vi".to_owned());
|
||||||
|
let owned: Vec<String> = parts.chain([abs.display().to_string()]).collect();
|
||||||
|
let args: Vec<&str> = owned.iter().map(String::as_str).collect();
|
||||||
|
self.launch_terminal(&format!("edit {leaf}"), &program, &args);
|
||||||
|
}
|
||||||
|
None => spawn_detached("xdg-open", &abs),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Renders one reflected field as a typed widget chosen from its
|
/// Renders one reflected field as a typed widget chosen from its
|
||||||
/// `type_name`, returning the field's new RON if the user changed it.
|
/// `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
|
/// Unknown types fall back to an editable RON text box, so the inspector is
|
||||||
@@ -3705,10 +4004,11 @@ impl<'a> ShellTabViewer<'a> {
|
|||||||
ui.monospace(project.root().display().to_string());
|
ui.monospace(project.root().display().to_string());
|
||||||
ui.separator();
|
ui.separator();
|
||||||
|
|
||||||
// Asset browser: typed folders grouped by kind, listed from the
|
// File explorer over assets/ (Stage-10 editor-UX): breadcrumbs,
|
||||||
// database (the source of truth for what the picker can reference).
|
// folder navigation, rename/delete/move, and OS drag-in import — all
|
||||||
// "Importing" an asset is just dropping the file into the right folder;
|
// over the AssetDatabase so every operation keeps uids (and saved
|
||||||
// the file watcher rescans, or the user can rescan on demand.
|
// AssetRefs) intact. Importing also still works by dropping a file
|
||||||
|
// into assets/ on disk; the watcher or Rescan registers it.
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.label("Assets");
|
ui.label("Assets");
|
||||||
if ui
|
if ui
|
||||||
@@ -3721,13 +4021,16 @@ impl<'a> ShellTabViewer<'a> {
|
|||||||
let _ = db.save();
|
let _ = db.save();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
if ui
|
||||||
match &self.state.asset_db {
|
.small_button("➕ New Folder")
|
||||||
Some(db) => asset_browser(ui, db),
|
.on_hover_text("Create a folder in the current directory")
|
||||||
None => {
|
.clicked()
|
||||||
ui.weak("(asset database unavailable)");
|
{
|
||||||
|
self.explorer.new_folder = Some(String::new());
|
||||||
|
self.explorer.focus_field = true;
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
self.assets_explorer(ui);
|
||||||
|
|
||||||
ui.separator();
|
ui.separator();
|
||||||
let project = self.state.project.as_ref().unwrap();
|
let project = self.state.project.as_ref().unwrap();
|
||||||
@@ -3735,6 +4038,325 @@ impl<'a> ShellTabViewer<'a> {
|
|||||||
show_project_tree(ui, "scripts/", project.scripts_dir());
|
show_project_tree(ui, "scripts/", project.scripts_dir());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The Unity-style file explorer over `assets/`: breadcrumbs, folder
|
||||||
|
/// navigation (double-click), inline rename / new-folder rows, per-row
|
||||||
|
/// context menus, drag-to-move between folders, and OS drag-in import
|
||||||
|
/// into the current folder. All behavior lives in [`crate::explorer`];
|
||||||
|
/// this only renders it and collects intents (applied after the render
|
||||||
|
/// pass, so no closure ever borrows `self.state` and the explorer
|
||||||
|
/// buffers at once). File operations act immediately and bypass the undo
|
||||||
|
/// stack — like the hierarchy's structural edits — but every move goes
|
||||||
|
/// through the database's uid-preserving ops, so saved `AssetRef`s keep
|
||||||
|
/// resolving.
|
||||||
|
fn assets_explorer(&mut self, ui: &mut egui::Ui) {
|
||||||
|
use crate::explorer::{self as exp, Entry};
|
||||||
|
|
||||||
|
let Some(db) = &self.state.asset_db else {
|
||||||
|
ui.weak("(asset database unavailable)");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let cwd = self.explorer.cwd.clone();
|
||||||
|
let entries = exp::list_dir(db, &cwd);
|
||||||
|
let crumbs = exp::breadcrumbs(&cwd);
|
||||||
|
|
||||||
|
// Intents collected during render, applied afterwards.
|
||||||
|
let mut nav: Option<String> = None;
|
||||||
|
let mut open: Option<Entry> = None;
|
||||||
|
let mut start_rename: Option<Entry> = None;
|
||||||
|
let mut delete: Option<Entry> = None;
|
||||||
|
// (what to move: rel + is_dir, destination folder)
|
||||||
|
let mut moved: Option<(String, bool, String)> = None;
|
||||||
|
let mut commit_rename = false;
|
||||||
|
let mut cancel_rename = false;
|
||||||
|
let mut commit_new_folder = false;
|
||||||
|
let mut cancel_new_folder = false;
|
||||||
|
|
||||||
|
// Breadcrumbs: assets / textures / env — click a segment to jump.
|
||||||
|
ui.horizontal_wrapped(|ui| {
|
||||||
|
ui.spacing_mut().item_spacing.x = 4.0;
|
||||||
|
for (i, (label, target)) in crumbs.iter().enumerate() {
|
||||||
|
if i > 0 {
|
||||||
|
ui.weak("/");
|
||||||
|
}
|
||||||
|
let here = *target == cwd;
|
||||||
|
if ui.selectable_label(here, label).clicked() && !here {
|
||||||
|
nav = Some(target.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Drag state shared by every drop target this frame.
|
||||||
|
let dragged = egui::DragAndDrop::payload::<ExplorerDragPayload>(ui.ctx());
|
||||||
|
let pointer = ui.ctx().pointer_interact_pos();
|
||||||
|
let released = ui.input(|i| i.pointer.any_released());
|
||||||
|
let accent = ui.visuals().selection.bg_fill;
|
||||||
|
let escape = ui.input(|i| i.key_pressed(egui::Key::Escape));
|
||||||
|
let focus_now = std::mem::take(&mut self.explorer.focus_field);
|
||||||
|
|
||||||
|
egui::ScrollArea::vertical()
|
||||||
|
.id_salt("oxide.explorer")
|
||||||
|
.auto_shrink([false, true])
|
||||||
|
.show(ui, |ui| {
|
||||||
|
// Inline "New Folder" row.
|
||||||
|
if let Some(buf) = &mut self.explorer.new_folder {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("▸");
|
||||||
|
let resp = ui.add(
|
||||||
|
egui::TextEdit::singleline(buf)
|
||||||
|
.hint_text("folder name")
|
||||||
|
.desired_width(160.0),
|
||||||
|
);
|
||||||
|
if focus_now {
|
||||||
|
resp.request_focus();
|
||||||
|
}
|
||||||
|
if resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
|
||||||
|
commit_new_folder = true;
|
||||||
|
}
|
||||||
|
if ui.small_button("Create").clicked() {
|
||||||
|
commit_new_folder = true;
|
||||||
|
}
|
||||||
|
if escape {
|
||||||
|
cancel_new_folder = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ".." row: double-click to go up; also a drop target that
|
||||||
|
// moves items into the parent folder.
|
||||||
|
if !cwd.is_empty() {
|
||||||
|
let up = exp::parent_of(&cwd);
|
||||||
|
let resp = ui
|
||||||
|
.add(
|
||||||
|
egui::Label::new("▸ ..")
|
||||||
|
.selectable(false)
|
||||||
|
.sense(egui::Sense::click()),
|
||||||
|
)
|
||||||
|
.on_hover_text("Double-click to go up — drop items here to move them up");
|
||||||
|
if resp.double_clicked() {
|
||||||
|
nav = Some(up.clone());
|
||||||
|
}
|
||||||
|
if let (Some(payload), Some(pos)) = (&dragged, pointer) {
|
||||||
|
if resp.rect.contains(pos) {
|
||||||
|
stroke_rect(
|
||||||
|
ui.painter(),
|
||||||
|
resp.rect,
|
||||||
|
egui::Stroke::new(1.5_f32, accent),
|
||||||
|
);
|
||||||
|
if released {
|
||||||
|
moved = Some((payload.rel.clone(), payload.is_dir, up.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if entries.is_empty() && self.explorer.new_folder.is_none() {
|
||||||
|
ui.weak("(empty — drag files in from your file manager to import)");
|
||||||
|
}
|
||||||
|
|
||||||
|
for entry in &entries {
|
||||||
|
// An entry mid-rename renders as a text field instead.
|
||||||
|
let renaming = self
|
||||||
|
.explorer
|
||||||
|
.rename
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|r| r.rel == entry.rel);
|
||||||
|
if renaming {
|
||||||
|
let buf = &mut self.explorer.rename.as_mut().expect("checked").buf;
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
let resp = ui.add(egui::TextEdit::singleline(buf).desired_width(180.0));
|
||||||
|
if focus_now {
|
||||||
|
resp.request_focus();
|
||||||
|
}
|
||||||
|
if resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
|
||||||
|
commit_rename = true;
|
||||||
|
}
|
||||||
|
if ui.small_button("Rename").clicked() {
|
||||||
|
commit_rename = true;
|
||||||
|
}
|
||||||
|
if escape {
|
||||||
|
cancel_rename = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let display = if entry.is_dir {
|
||||||
|
format!("▸ {}/", entry.name)
|
||||||
|
} else {
|
||||||
|
entry.name.clone()
|
||||||
|
};
|
||||||
|
let hover = match (entry.is_dir, entry.kind) {
|
||||||
|
(true, _) => format!(
|
||||||
|
"{}/\ndouble-click to open · drag to move · right-click for actions",
|
||||||
|
entry.rel
|
||||||
|
),
|
||||||
|
(false, Some(kind)) => format!(
|
||||||
|
"{}\n{kind:?} asset · double-click to open · drag to move",
|
||||||
|
entry.rel
|
||||||
|
),
|
||||||
|
(false, None) => {
|
||||||
|
format!("{}\nunregistered file · drag to move", entry.rel)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// One widget senses click + drag (same pattern as the
|
||||||
|
// hierarchy rows): still press = click, press+move = drag.
|
||||||
|
let resp = ui
|
||||||
|
.add(
|
||||||
|
egui::Label::new(display)
|
||||||
|
.selectable(false)
|
||||||
|
.sense(egui::Sense::click_and_drag()),
|
||||||
|
)
|
||||||
|
.on_hover_text(hover);
|
||||||
|
if resp.drag_started() {
|
||||||
|
egui::DragAndDrop::set_payload(
|
||||||
|
ui.ctx(),
|
||||||
|
ExplorerDragPayload {
|
||||||
|
rel: entry.rel.clone(),
|
||||||
|
is_dir: entry.is_dir,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if resp.dragged() {
|
||||||
|
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
|
||||||
|
}
|
||||||
|
if resp.double_clicked() {
|
||||||
|
if entry.is_dir {
|
||||||
|
nav = Some(entry.rel.clone());
|
||||||
|
} else {
|
||||||
|
open = Some(entry.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Folder rows accept drops (not from themselves).
|
||||||
|
if entry.is_dir {
|
||||||
|
if let (Some(payload), Some(pos)) = (&dragged, pointer) {
|
||||||
|
if payload.rel != entry.rel && resp.rect.contains(pos) {
|
||||||
|
stroke_rect(
|
||||||
|
ui.painter(),
|
||||||
|
resp.rect,
|
||||||
|
egui::Stroke::new(1.5_f32, accent),
|
||||||
|
);
|
||||||
|
if released {
|
||||||
|
moved = Some((
|
||||||
|
payload.rel.clone(),
|
||||||
|
payload.is_dir,
|
||||||
|
entry.rel.clone(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resp.context_menu(|ui| {
|
||||||
|
if ui.button("Open").clicked() {
|
||||||
|
if entry.is_dir {
|
||||||
|
nav = Some(entry.rel.clone());
|
||||||
|
} else {
|
||||||
|
open = Some(entry.clone());
|
||||||
|
}
|
||||||
|
ui.close();
|
||||||
|
}
|
||||||
|
if ui.button("✏ Rename").clicked() {
|
||||||
|
start_rename = Some(entry.clone());
|
||||||
|
ui.close();
|
||||||
|
}
|
||||||
|
ui.separator();
|
||||||
|
if ui.button("🗑 Delete").clicked() {
|
||||||
|
delete = Some(entry.clone());
|
||||||
|
ui.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// OS drag-in import: while files hover the window, hint; when they
|
||||||
|
// drop (and the Project panel is showing), copy + register them into
|
||||||
|
// the current folder.
|
||||||
|
if ui.input(|i| !i.raw.hovered_files.is_empty()) {
|
||||||
|
ui.colored_label(accent, format!("Drop to import into assets/{cwd}"));
|
||||||
|
}
|
||||||
|
let dropped: Vec<PathBuf> = ui.input(|i| {
|
||||||
|
i.raw
|
||||||
|
.dropped_files
|
||||||
|
.iter()
|
||||||
|
.filter_map(|f| f.path.clone())
|
||||||
|
.collect()
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- apply the collected intents ---------------------------------
|
||||||
|
if cancel_new_folder {
|
||||||
|
self.explorer.new_folder = None;
|
||||||
|
} else if commit_new_folder {
|
||||||
|
let wanted = self.explorer.new_folder.take().unwrap_or_default();
|
||||||
|
if let Some(db) = &self.state.asset_db {
|
||||||
|
match exp::create_folder(db, &cwd, wanted.trim()) {
|
||||||
|
Ok(rel) => log::info!("created folder assets/{rel}"),
|
||||||
|
Err(err) => log::warn!("could not create folder: {err}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cancel_rename {
|
||||||
|
self.explorer.rename = None;
|
||||||
|
} else if commit_rename {
|
||||||
|
if let Some(edit) = self.explorer.rename.take() {
|
||||||
|
if let Some(db) = self.state.asset_db.as_mut() {
|
||||||
|
match exp::rename_entry(db, &edit.rel, edit.is_dir, edit.buf.trim()) {
|
||||||
|
Ok(new_rel) => log::info!("renamed {} -> {new_rel}", edit.rel),
|
||||||
|
Err(err) => log::warn!("could not rename {}: {err}", edit.rel),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(entry) = start_rename {
|
||||||
|
self.explorer.rename = Some(crate::explorer::RenameEdit {
|
||||||
|
rel: entry.rel,
|
||||||
|
is_dir: entry.is_dir,
|
||||||
|
buf: entry.name,
|
||||||
|
});
|
||||||
|
self.explorer.focus_field = true;
|
||||||
|
}
|
||||||
|
if let Some((rel, is_dir, dest)) = moved {
|
||||||
|
if let Some(db) = self.state.asset_db.as_mut() {
|
||||||
|
match exp::move_entry(db, &rel, is_dir, &dest) {
|
||||||
|
Ok(new_rel) => log::info!("moved {rel} -> {new_rel}"),
|
||||||
|
Err(err) => log::warn!("could not move {rel}: {err}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(entry) = delete {
|
||||||
|
if let Some(db) = self.state.asset_db.as_mut() {
|
||||||
|
match exp::delete_entry(db, &entry) {
|
||||||
|
Ok(()) => log::info!("deleted {}", entry.rel),
|
||||||
|
Err(err) => log::warn!("could not delete {}: {err}", entry.rel),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(entry) = open {
|
||||||
|
if entry.kind == Some(AssetKind::Script) {
|
||||||
|
if let Some(uid) = entry.uid {
|
||||||
|
self.open_script_in_editor(uid);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let abs = self.state.asset_db.as_ref().map(|db| {
|
||||||
|
db.assets_dir()
|
||||||
|
.join(entry.rel.replace('/', std::path::MAIN_SEPARATOR_STR))
|
||||||
|
});
|
||||||
|
if let Some(abs) = abs {
|
||||||
|
spawn_detached("xdg-open", &abs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !dropped.is_empty() {
|
||||||
|
if let Some(db) = self.state.asset_db.as_mut() {
|
||||||
|
let n = exp::import_files(db, &cwd, &dropped);
|
||||||
|
if n > 0 {
|
||||||
|
log::info!("imported {n} file(s) into assets/{cwd}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(target) = nav {
|
||||||
|
self.explorer.navigate(target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The UI canvas: edit a [`UiPanel`] document — widget tree, live preview,
|
/// The UI canvas: edit a [`UiPanel`] document — widget tree, live preview,
|
||||||
/// and a property panel (including the font-asset picker) — saved as a
|
/// and a property panel (including the font-asset picker) — saved as a
|
||||||
/// `ui/` asset. Structural and property edits route through
|
/// `ui/` asset. Structural and property edits route through
|
||||||
@@ -4315,6 +4937,29 @@ impl<'a> ShellTabViewer<'a> {
|
|||||||
|
|
||||||
// ---- helpers -----------------------------------------------------------
|
// ---- helpers -----------------------------------------------------------
|
||||||
|
|
||||||
|
/// Spawns `command` (whitespace-split, so it may carry flags) with `file`
|
||||||
|
/// appended as the final argument, detached from the editor; a reaper thread
|
||||||
|
/// waits on the child so it never lingers as a zombie. Failures are logged
|
||||||
|
/// (→ the Console), never fatal.
|
||||||
|
fn spawn_detached(command: &str, file: &std::path::Path) {
|
||||||
|
let mut parts = command.split_whitespace();
|
||||||
|
let Some(program) = parts.next() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
match std::process::Command::new(program)
|
||||||
|
.args(parts)
|
||||||
|
.arg(file)
|
||||||
|
.spawn()
|
||||||
|
{
|
||||||
|
Ok(mut child) => {
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let _ = child.wait();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(err) => log::warn!("could not launch {command}: {err}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns whether the rendered control changed any axis this frame.
|
/// Returns whether the rendered control changed any axis this frame.
|
||||||
/// Projects a world-space point through `view_proj` to screen pixels
|
/// Projects a world-space point through `view_proj` to screen pixels
|
||||||
/// inside `tab_rect` (egui logical points). Returns `None` when the point
|
/// inside `tab_rect` (egui logical points). Returns `None` when the point
|
||||||
@@ -4719,10 +5364,6 @@ fn snapshot_node(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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
|
/// The colour for a console log line of the given severity: errors red, warnings
|
||||||
/// amber, info the normal text colour, debug/trace dimmed.
|
/// amber, info the normal text colour, debug/trace dimmed.
|
||||||
fn level_color(ui: &egui::Ui, level: log::Level) -> egui::Color32 {
|
fn level_color(ui: &egui::Ui, level: log::Level) -> egui::Color32 {
|
||||||
@@ -4793,26 +5434,6 @@ fn build_terminal_job(
|
|||||||
job
|
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,
|
/// A short label for a widget in the tree / property header: its id if set,
|
||||||
/// else its kind, with the root marked.
|
/// else its kind, with the root marked.
|
||||||
fn widget_label(widget: &Widget, path: &WidgetPath) -> String {
|
fn widget_label(widget: &Widget, path: &WidgetPath) -> String {
|
||||||
|
|||||||
@@ -209,6 +209,20 @@ impl Default for UiDoc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The settings section holding [`ExternalEditorPrefs`].
|
||||||
|
pub const EXTERNAL_EDITOR_SECTION: &str = "editor.external_editor";
|
||||||
|
|
||||||
|
/// Preferences for opening a script (or other text asset) in an editor —
|
||||||
|
/// registered as the [`EXTERNAL_EDITOR_SECTION`] settings section, editable in
|
||||||
|
/// Preferences, persisted to `~/.config/oxide/editor.ron` like the bindings.
|
||||||
|
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct ExternalEditorPrefs {
|
||||||
|
/// Command to launch, invoked as `<command> <file>` (whitespace-split; may
|
||||||
|
/// carry its own flags, e.g. `"code -g"`). **Empty (the default) = auto**:
|
||||||
|
/// run `$VISUAL`/`$EDITOR` in an editor Terminal tab, else `xdg-open`.
|
||||||
|
pub command: String,
|
||||||
|
}
|
||||||
|
|
||||||
impl EditorState {
|
impl EditorState {
|
||||||
/// A blank state with an empty scene, no open project, and the editor's
|
/// A blank state with an empty scene, no open project, and the editor's
|
||||||
/// default action bindings registered (`F` toggle, WASD/QE move, Shift
|
/// default action bindings registered (`F` toggle, WASD/QE move, Shift
|
||||||
@@ -224,6 +238,7 @@ impl EditorState {
|
|||||||
bindings::register_defaults(&mut actions);
|
bindings::register_defaults(&mut actions);
|
||||||
let mut settings = Settings::new();
|
let mut settings = Settings::new();
|
||||||
settings.register::<ActionOverrides>(bindings::SETTINGS_SECTION);
|
settings.register::<ActionOverrides>(bindings::SETTINGS_SECTION);
|
||||||
|
settings.register::<ExternalEditorPrefs>(EXTERNAL_EDITOR_SECTION);
|
||||||
let mut registry = TypeRegistry::new();
|
let mut registry = TypeRegistry::new();
|
||||||
register_builtin_types(&mut registry);
|
register_builtin_types(&mut registry);
|
||||||
// Seed a small, generally-useful set of named layers (besides the
|
// Seed a small, generally-useful set of named layers (besides the
|
||||||
@@ -590,4 +605,17 @@ mod tests {
|
|||||||
.get::<oxide_physics::RigidBody>(restored)
|
.get::<oxide_physics::RigidBody>(restored)
|
||||||
.is_some());
|
.is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn external_editor_section_is_registered_and_defaults_to_auto() {
|
||||||
|
let state = EditorState::new();
|
||||||
|
let prefs = state
|
||||||
|
.settings
|
||||||
|
.get::<ExternalEditorPrefs>(EXTERNAL_EDITOR_SECTION)
|
||||||
|
.expect("external-editor settings section must be registered");
|
||||||
|
assert!(
|
||||||
|
prefs.command.is_empty(),
|
||||||
|
"default is empty = auto ($VISUAL/$EDITOR terminal tab, else xdg-open)"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -440,18 +440,21 @@ impl AssetDatabase {
|
|||||||
uid
|
uid
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Scans the typed asset folders under `assets/` and reconciles the
|
/// Scans everything under `assets/` and reconciles the database with what
|
||||||
/// database with what is on disk: existing files keep their uid, new files
|
/// is on disk: existing files keep their uid, new files are
|
||||||
/// are [registered](Self::register), and entries whose files no longer exist
|
/// [registered](Self::register), and entries whose files no longer exist
|
||||||
/// are dropped. Returns the number of newly registered assets.
|
/// are dropped. Returns the number of newly registered assets.
|
||||||
///
|
///
|
||||||
|
/// The walk covers the **whole** assets tree, not just the typed folders —
|
||||||
|
/// the editor's file explorer lets a project organise assets in arbitrary
|
||||||
|
/// folders, and a scan must never prune them. Files outside a typed folder
|
||||||
|
/// classify by extension (else [`Other`](AssetKind::Other)), as always.
|
||||||
|
///
|
||||||
/// Call [`save`](Self::save) afterwards to persist any new uids.
|
/// Call [`save`](Self::save) afterwards to persist any new uids.
|
||||||
pub fn scan(&mut self) -> usize {
|
pub fn scan(&mut self) -> usize {
|
||||||
let assets_dir = self.assets_dir();
|
let assets_dir = self.assets_dir();
|
||||||
let mut found: Vec<String> = Vec::new();
|
let mut found: Vec<String> = Vec::new();
|
||||||
for kind in AssetKind::TYPED {
|
collect_files(&assets_dir, &assets_dir, &mut found);
|
||||||
collect_files(&assets_dir.join(kind.folder()), &assets_dir, &mut found);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Drop entries whose backing file disappeared.
|
// Drop entries whose backing file disappeared.
|
||||||
let present: std::collections::HashSet<&String> = found.iter().collect();
|
let present: std::collections::HashSet<&String> = found.iter().collect();
|
||||||
@@ -811,12 +814,27 @@ mod tests {
|
|||||||
assert_eq!(db.len(), 3);
|
assert_eq!(db.len(), 3);
|
||||||
assert_eq!(db.entries_of_kind(AssetKind::Font).count(), 1);
|
assert_eq!(db.entries_of_kind(AssetKind::Font).count(), 1);
|
||||||
|
|
||||||
|
// The walk covers the whole tree: a custom folder and a loose root
|
||||||
|
// file both register (kind from extension, else Other) and survive
|
||||||
|
// subsequent scans.
|
||||||
|
touch_asset(&root, "props/crate.glb");
|
||||||
|
touch_asset(&root, "notes.md");
|
||||||
|
assert_eq!(db.scan(), 2);
|
||||||
|
let crate_uid = db.uid_of("props/crate.glb").unwrap();
|
||||||
|
assert_eq!(db.entry(crate_uid).unwrap().kind, AssetKind::Model);
|
||||||
|
assert_eq!(
|
||||||
|
db.entry(db.uid_of("notes.md").unwrap()).unwrap().kind,
|
||||||
|
AssetKind::Other
|
||||||
|
);
|
||||||
|
assert_eq!(db.scan(), 0);
|
||||||
|
assert_eq!(db.uid_of("props/crate.glb"), Some(crate_uid));
|
||||||
|
|
||||||
// Remove one file and rescan: it is pruned, the rest keep their uids.
|
// Remove one file and rescan: it is pruned, the rest keep their uids.
|
||||||
let font_uid = db.uid_of("fonts/Inter.ttf").unwrap();
|
let font_uid = db.uid_of("fonts/Inter.ttf").unwrap();
|
||||||
let wall_uid = db.uid_of("textures/wall.png").unwrap();
|
let wall_uid = db.uid_of("textures/wall.png").unwrap();
|
||||||
std::fs::remove_file(root.join(ASSETS_DIR).join("fonts/Inter.ttf")).unwrap();
|
std::fs::remove_file(root.join(ASSETS_DIR).join("fonts/Inter.ttf")).unwrap();
|
||||||
assert_eq!(db.scan(), 0);
|
assert_eq!(db.scan(), 0);
|
||||||
assert_eq!(db.len(), 2);
|
assert_eq!(db.len(), 4, "wall + cube + crate + notes remain");
|
||||||
assert!(db.entry(font_uid).is_none());
|
assert!(db.entry(font_uid).is_none());
|
||||||
assert_eq!(db.uid_of("textures/wall.png"), Some(wall_uid));
|
assert_eq!(db.uid_of("textures/wall.png"), Some(wall_uid));
|
||||||
|
|
||||||
@@ -922,12 +940,11 @@ mod tests {
|
|||||||
db.move_asset(uid, "misc/brick.dat").unwrap();
|
db.move_asset(uid, "misc/brick.dat").unwrap();
|
||||||
assert_eq!(db.entry(uid).unwrap().kind, AssetKind::Other);
|
assert_eq!(db.entry(uid).unwrap().kind, AssetKind::Other);
|
||||||
|
|
||||||
// A rescan does not disturb the moved entry's uid... (misc/ is not a
|
// A rescan (which walks the whole tree) does not disturb the moved
|
||||||
// typed folder, so the entry survives only because scan never saw it —
|
// entry's uid, even outside the typed folders.
|
||||||
// move back first to prove the typed-folder case.)
|
|
||||||
db.move_asset(uid, "textures/brick.png").unwrap();
|
|
||||||
db.scan();
|
db.scan();
|
||||||
assert_eq!(db.uid_of("textures/brick.png"), Some(uid));
|
assert_eq!(db.uid_of("misc/brick.dat"), Some(uid));
|
||||||
|
db.move_asset(uid, "textures/brick.png").unwrap();
|
||||||
|
|
||||||
// No-op and error cases.
|
// No-op and error cases.
|
||||||
db.move_asset(uid, "textures/brick.png").unwrap(); // unchanged path: Ok
|
db.move_asset(uid, "textures/brick.png").unwrap(); // unchanged path: Ok
|
||||||
|
|||||||
@@ -55,7 +55,7 @@
|
|||||||
//! editor canvas previews).
|
//! editor canvas previews).
|
||||||
//!
|
//!
|
||||||
//! A future depth-aware mode (where a panel behind a wall is properly
|
//! A future depth-aware mode (where a panel behind a wall is properly
|
||||||
//! hidden) is in the wiki Roadmap's Stage-8 backlog and
|
//! 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
|
//! slots in by attaching a depth attachment to a second pass of the
|
||||||
//! same pipeline.
|
//! same pipeline.
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
//! focus per widget, and tell the host whether the UI captured the frame's
|
//! 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.
|
//! input so the game can decide whether to also handle it.
|
||||||
//!
|
//!
|
||||||
//! Stage 8's UI must *consume input before the game* (see the wiki Roadmap): if the
|
//! 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
|
//! 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
|
//! action bound to that mouse button. The [`Router`] gives the host one
|
||||||
//! object to drive each frame:
|
//! object to drive each frame:
|
||||||
|
|||||||
Reference in New Issue
Block a user