Compare commits
7 Commits
775ba8a8d2
...
bad780de9d
| Author | SHA1 | Date | |
|---|---|---|---|
| bad780de9d | |||
| 3c59faa506 | |||
| 608898411a | |||
| fd25677631 | |||
| 1f3a034215 | |||
| b13e40770e | |||
| f56a1eea3b |
+32
@@ -0,0 +1,32 @@
|
||||
# Rust / Cargo
|
||||
/target/
|
||||
Cargo.lock
|
||||
|
||||
# Editor and IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.iml
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Environment / secrets
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# Build artifacts and packages
|
||||
*.deb
|
||||
*.rpm
|
||||
*.tar.gz
|
||||
*.zip
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Personal scratch notes (tracked docs live in docs/ and are NOT ignored)
|
||||
*.local.md
|
||||
/scratch/
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = [
|
||||
"engine",
|
||||
"engine-derive",
|
||||
"physics",
|
||||
"script",
|
||||
"editor",
|
||||
"examples",
|
||||
"tests",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
authors = ["Jaroslav Beneš"]
|
||||
license = "MIT"
|
||||
rust-version = "1.75"
|
||||
|
||||
[workspace.dependencies]
|
||||
# Math
|
||||
glam = { version = "0.28", features = ["serde"] }
|
||||
|
||||
# ECS
|
||||
hecs = "0.10"
|
||||
|
||||
# Windowing & graphics
|
||||
# Linux is the primary target on BOTH Wayland and Xorg (X11): keep both winit
|
||||
# backends explicitly enabled so neither can be dropped by a default-feature
|
||||
# change. (On by default today; listing them makes the contract explicit —
|
||||
# see PLAN.md "Platform & Target Strategy".)
|
||||
winit = { version = "0.30", features = ["x11", "wayland", "serde"] }
|
||||
wgpu = "29"
|
||||
pollster = "0.4"
|
||||
# Plain-old-data casting for GPU vertex/uniform buffers.
|
||||
bytemuck = { version = "1", features = ["derive"] }
|
||||
# glTF import (static meshes). `utils` enables the attribute reader helpers.
|
||||
gltf = { version = "1.4", features = ["utils"] }
|
||||
|
||||
# TrueType / OpenType font parsing + glyph outline rasterization for the
|
||||
# Stage-8 in-game UI text system. Chosen over `fontdue` for its minimal
|
||||
# scope (parsing + rasterization only) — the engine writes its own atlas,
|
||||
# layout, wrapping, and alignment on top, which keeps the door open for
|
||||
# richer text features in later pieces (editor caret, rich markup, SDF).
|
||||
ab_glyph = "0.2"
|
||||
|
||||
# Editor UI (egui — integrated into oxide-editor only)
|
||||
# Native file/folder dialogs (New/Open Project). The default `xdg-portal`
|
||||
# backend is pure Rust and talks to xdg-desktop-portal over D-Bus, so one
|
||||
# build serves both Wayland and X11 with no GTK link-time dependency.
|
||||
rfd = "0.15"
|
||||
egui = "0.34"
|
||||
egui-wgpu = "0.34"
|
||||
egui-winit = "0.34"
|
||||
egui_dock = "0.19"
|
||||
|
||||
# Logging
|
||||
log = "0.4"
|
||||
env_logger = "0.11"
|
||||
|
||||
# Error handling
|
||||
anyhow = "1"
|
||||
thiserror = "2"
|
||||
|
||||
# Serialization
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
ron = "0.8"
|
||||
|
||||
# Proc-macro toolkit for `oxide-engine-derive` (the `#[derive(Reflect)]` macro
|
||||
# behind the reflection-driven editor inspector — Stage 8.5).
|
||||
syn = { version = "2", features = ["full"] }
|
||||
quote = "1"
|
||||
proc-macro2 = "1"
|
||||
|
||||
# Filesystem change events (Stage 6 file-watcher foundation; Stage 10 hot-reload).
|
||||
notify = "8"
|
||||
|
||||
# Benchmarking
|
||||
criterion = "0.5"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
strip = true
|
||||
@@ -1,3 +1,252 @@
|
||||
# Oxide
|
||||
# Oxide Engine
|
||||
|
||||
Oxide general purpose 3D game engine
|
||||

|
||||
|
||||
> **Notice:** This project was developed with the assistance of [Claude Code](https://claude.ai/code) (Anthropic's AI coding assistant).
|
||||
> All generated code, configuration, and documentation has been reviewed and tested by the author.
|
||||
> Claude Code was used as a development tool; all design decisions, requirements, and sign-offs are the author's own.
|
||||
|
||||
A general-purpose 3D game engine written in Rust — built to make **any** 3D game, scaling from
|
||||
stylized low-poly to realistic graphics, and shipping only what each game uses.
|
||||
|
||||
---
|
||||
|
||||
## Status
|
||||
|
||||
Oxide is built in two phases (see [PLAN.md](PLAN.md)): **Phase 1 (Stages 0–16)**
|
||||
is the general-purpose engine — everything needed to build and export any game;
|
||||
**Phase 2 (Stages 17+)** adds optional, feature-gated built-in modules.
|
||||
|
||||
**Stages 0–9 are complete on `main`.** Stage 8 shipped the engine's full
|
||||
in-game UI stack — widget tree, layout, themed styling, `ab_glyph`-backed text
|
||||
shaping + R8 glyph atlas, screen-space *and* world-space render passes,
|
||||
hit-test + hover/focus/press router, immediate-mode event queries with typed
|
||||
`WidgetValue` data binding, and the editor's visual UI canvas. **Stage 8.5**
|
||||
added reflection v2 (public fields auto-appear in the inspector, no per-type
|
||||
editor code), prefabs, the asset database, and layers/groups. **Stage 8.7**
|
||||
added **editor play mode** — Play/Pause/Step/Stop the open scene in the
|
||||
viewport (Ctrl+P / Ctrl+.), with snapshot-on-Play / bit-for-bit restore-on-Stop.
|
||||
**Stage 9** added **comprehensive physics** (`oxide-physics` on `rapier3d`) —
|
||||
rigid bodies, colliders, collision/trigger events, scene queries (raycast/
|
||||
shape-cast/overlap), joints, a kinematic character controller, and editor
|
||||
integration (addable components, collider wireframe gizmos, a freeze-on-click
|
||||
raycast debug probe). **Stage 10 — Scripting, Live Reload & Editor Terminal**
|
||||
(`rhai`, the `oxide-script` crate) is now **in progress**: the `Script`
|
||||
component, the `.rhai` asset loader, and the sandboxed script engine have
|
||||
landed; lifecycle execution, live reload, and the editor terminal follow.
|
||||
|
||||
Available now:
|
||||
|
||||
- `oxide_engine::math` — `Transform`, `Aabb`, `Ray`, `Plane`, `Frustum`, `Color`, `Rect`, `Range3`
|
||||
- `oxide_engine::window` — window creation, `WindowApp` trait + event loop, raw input events (`winit`)
|
||||
- `oxide_engine::render` — GPU setup (`wgpu`), surface management, clear loop, and a forward
|
||||
renderer: `Mesh`/`Vertex` (+ cube/plane/sphere primitives), `Material`, `Camera`, `ForwardRenderer`;
|
||||
data-driven `RenderPipeline` (`RenderPass`/`ClearPass`/`ForwardPass`)
|
||||
- `oxide_engine::scene` — `Scene`, `Node`, entity hierarchy with world-transform resolution, RON serialization (`hecs`)
|
||||
- `oxide_engine::app` — the `App` core, `Module`/`DefaultModules`, and the `Schedule` (system phases + fixed timestep)
|
||||
- `oxide_engine::layer` — `LayerMask`, `LayerRegistry`, and `Layers`/`Tags` components (shared filtering primitive)
|
||||
- `oxide_engine::reflect` — `TypeRegistry` for generic, name-keyed component access (dual-editability)
|
||||
- `oxide_engine::asset` — `AssetServer` + ref-counted `Handle<T>` (dedup, background load, reload by path)
|
||||
- `oxide_engine::project` — `Project` (create/open/save, folder layout, enabled modules, per-project
|
||||
settings) + `RecentProjects` MRU list
|
||||
- `oxide_engine::settings` — typed `Settings` sections (engine/editor/per-module), export/import (RON)
|
||||
- `oxide_engine::watch` — `FileWatcher` with a debounced/deduplicated change-event stream and
|
||||
`reload_changed_assets` helper that drives `AssetServer::reload_path`
|
||||
- `oxide_editor::shell::Shell` — docking shell (menu bar, dock area, status bar, Preferences window);
|
||||
`oxide_editor::command` / `commands` — `CommandStack` + `SetTransformCmd` (drag-coalesce) /
|
||||
`RenameCmd`; `oxide_editor::extension` — module → editor `EditorModule` extension API
|
||||
- `oxide_engine::input` — per-frame `InputState` (keyboard / mouse / cursor / scroll, edge
|
||||
detection), remappable `ActionMap` with `Binding` / `AxisBinding` / `Axis2DBinding`, RON-persistable
|
||||
`ActionOverrides` (Stage-7 piece 1–3)
|
||||
- `oxide_editor::gizmo` — pure-logic transform-gizmo math (hit testing, drag projection, snap);
|
||||
`oxide_editor::bindings` — default editor action set (camera + gizmo hotkeys);
|
||||
`oxide_editor::preferences` — `~/.config/oxide/editor.ron` load/save. The viewport ships a
|
||||
flythrough camera (F-toggle), translate / rotate / scale gizmos with Ctrl-snap and undo, and
|
||||
an Input Bindings preferences page (Stage-7 pieces 4–6)
|
||||
- `oxide_engine::ui` — in-game UI system (Stage-8 pieces 1–6): `Widget` tree
|
||||
with stack / grid / anchor layouts, DPI-aware sizing, per-widget visual
|
||||
styles with named-style `Theme` cascade, `ab_glyph`-backed text shaping +
|
||||
shelf-packed R8 `GlyphAtlas`, `paint()` → `DrawCommand`s consumed by
|
||||
screen-space and world-space (`UiPanel`) `UiOverlayPass` in
|
||||
`oxide_engine::render`, hit-test + hover/focus/press `Router` with
|
||||
immediate-mode `RouterFrame::clicked_left(...)` queries, typed
|
||||
`WidgetValue` (Bool / Int / Float / Text) for game-data round-tripping.
|
||||
Runnable example: `cargo run -p oxide-examples --bin ui_menu` (Stage-8
|
||||
piece 7)
|
||||
|
||||
## Features (planned — see [PLAN.md](PLAN.md))
|
||||
|
||||
**Phase 1 — the general-purpose engine:**
|
||||
|
||||
- ✅ Math & core primitives (transforms, bounds, rays, frustum culling)
|
||||
- ✅ Window, GPU context & clear-color render loop (`winit` + `wgpu`)
|
||||
- ✅ Scene graph and entity management (ECS-based, `hecs`)
|
||||
- ✅ Basic 3D rendering (meshes, PBR-lite materials, camera, GLTF import, editor viewport)
|
||||
- ✅ Engine core framework: module/plugin system, layers & tags, asset server, reflection registry,
|
||||
data-driven render pass pipeline
|
||||
- ✅ Editor framework & project system: top menu, dockable panels, undo/redo, module extension API,
|
||||
settings/preferences, create/open/save projects with live file watching
|
||||
- ✅ Input system with remappable named actions (per-key edges + button/axis actions; RON-persisted
|
||||
remap surfaced through the editor's Input Bindings preferences page) and editor transform gizmos
|
||||
(translate / rotate / scale with Ctrl-snap and undo, W/E/R hotkeys, flythrough viewport camera)
|
||||
- ✅ Comprehensive in-game UI system (widgets, layout, theming, text) authored in a visual editor canvas
|
||||
- ✅ Reflection-driven inspector, prefabs, asset database, layers/groups (Stage 8.5)
|
||||
- ✅ Editor play mode: Play/Pause/Step/Stop with snapshot-on-Play / restore-on-Stop (Stage 8.7)
|
||||
- ✅ Comprehensive rigid-body physics (`rapier3d`): colliders, joints, scene queries, collision/
|
||||
trigger events, kinematic character controller, editor integration + collider/raycast gizmos (Stage 9)
|
||||
- Scripting with live reload + an in-editor terminal (host tools/AI agents that edit game code live)
|
||||
- Skeletal animation · GPU-driven particles · shader hot-reload & scalable post-processing
|
||||
- Standard audio (mixer/spatial)
|
||||
- Built-in content kit: prototyping primitives, shaders, and a character controller
|
||||
- Game export to standalone **Linux and Windows** binaries
|
||||
- **In-engine editor** (`oxide-editor`) built alongside the engine
|
||||
|
||||
**Phase 2 — optional built-in modules (feature-gated):**
|
||||
|
||||
- Ray-traced spatial audio (wave propagation, occlusion, reverb)
|
||||
- Developer console & cheats
|
||||
- Procedural toolkit (noise + composable modifier stack)
|
||||
- Terrain: generate, sculpt & paint with brushes, scatter grass/trees/objects
|
||||
- Open world streaming (chunking, async asset loading, LOD)
|
||||
- Pathfinding & NPC AI (navmesh, agents, behavior trees, perception)
|
||||
- Water (rendering + buoyancy/swim/flow mechanics)
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
- Rust stable toolchain (`rustup` recommended)
|
||||
- Linux — primary target, on **both Wayland and Xorg (X11)**. Windows support and cross-platform
|
||||
game export are planned (see [PLAN.md](PLAN.md), Stage 16); other platforms are not yet tested.
|
||||
- A GPU with Vulkan or Metal support (for `wgpu`)
|
||||
|
||||
Install Rust if you don't have it:
|
||||
|
||||
```sh
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Building
|
||||
|
||||
```sh
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
To build and run the editor directly:
|
||||
|
||||
```sh
|
||||
cargo run -p oxide-editor --release
|
||||
```
|
||||
|
||||
### Examples
|
||||
|
||||
Each stage ships at least one runnable example. List and run them with:
|
||||
|
||||
```sh
|
||||
cargo run -p oxide-examples --bin math_demo # Stage 1: math primitives tour
|
||||
cargo run -p oxide-examples --bin hello_window # Stage 2: window + clear color (1–5/Space to recolor, Esc quits)
|
||||
cargo run -p oxide-examples --bin scene_basic # Stage 3: build a hierarchy, print world transforms, round-trip RON
|
||||
cargo run -p oxide-examples --bin hello_mesh # Stage 4: lit 3D meshes (spinning cube + sphere + ground), Esc quits
|
||||
cargo run -p oxide-examples --bin ui_menu # Stage 8: themed main menu + settings (draggable slider, checkbox), Esc quits
|
||||
cargo run -p oxide-examples --bin ui_hud # Stage 8: HUD (HP/ammo/minimap/crosshair) over a 3D scene, Esc quits
|
||||
cargo run -p oxide-examples --bin physics_stack # Stage 9: a stack of boxes settles + a ball lands (headless console)
|
||||
cargo run -p oxide-examples --bin character_capsule # Stage 9: a capsule walks, climbs a step, jumps, hits a wall (headless console)
|
||||
cargo run -p oxide-examples --bin script_spin # Stage 10: a rhai script spins an entity; the script is edited live and the spin rate jumps (headless console)
|
||||
```
|
||||
|
||||
### Benchmarks
|
||||
|
||||
Performance-sensitive systems have `criterion` benchmarks:
|
||||
|
||||
```sh
|
||||
cargo bench -p oxide-engine
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Installing (Linux)
|
||||
|
||||
The `install.sh` script compiles the project and installs it to your system (`/usr/local`):
|
||||
|
||||
```sh
|
||||
chmod +x install.sh
|
||||
./install.sh
|
||||
```
|
||||
|
||||
After installation the editor is available as:
|
||||
|
||||
```sh
|
||||
oxide-editor
|
||||
```
|
||||
|
||||
To uninstall:
|
||||
|
||||
```sh
|
||||
sudo rm /usr/local/bin/oxide-editor
|
||||
sudo rm -rf /usr/local/share/oxide
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Running tests
|
||||
|
||||
```sh
|
||||
cargo test
|
||||
```
|
||||
|
||||
Lint and format checks:
|
||||
|
||||
```sh
|
||||
cargo clippy -- -D warnings
|
||||
cargo fmt --check
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
The full documentation — usage guides, per-system API references, and
|
||||
explanations of the engine's inner workings — lives in [`docs/`](docs/README.md).
|
||||
Start there for anything beyond this overview:
|
||||
|
||||
- [Getting Started](docs/getting-started.md) — build, run, test, install
|
||||
- [Architecture](docs/architecture.md) — workspace and design overview
|
||||
- [Conventions](docs/conventions.md) — coordinate system, units, color space
|
||||
- [Development Workflow](docs/development.md) — branches, testing, contributing
|
||||
- [Math & Core Primitives](docs/math.md) — Stage 1 API reference
|
||||
- [Windowing & App Loop](docs/windowing.md) — Stage 2 window/event-loop reference
|
||||
- [Render Context](docs/render-context.md) — Stage 2 GPU/surface reference
|
||||
- [Scene Graph & Entities](docs/scene.md) — Stage 3 scene/hierarchy/serialization reference
|
||||
- [Rendering](docs/rendering.md) — Stage 4 mesh/material/camera/forward-renderer reference
|
||||
|
||||
---
|
||||
|
||||
## Project layout
|
||||
|
||||
```
|
||||
Oxide/
|
||||
├── assets/ # Project logos and shared assets
|
||||
├── docs/ # Full engine documentation
|
||||
├── engine/ # Core engine library (oxide-engine)
|
||||
├── editor/ # In-engine editor binary (oxide-editor)
|
||||
├── examples/ # Runnable stage examples (oxide-examples)
|
||||
├── tests/ # Integration test harness (oxide-tests)
|
||||
├── install.sh # Build + system install script
|
||||
├── PLAN.md # Staged development roadmap
|
||||
└── CLAUDE.md # Context and rules for Claude Code
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Development roadmap
|
||||
|
||||
Development follows a staged plan — each stage is fully tested before the next begins.
|
||||
See [PLAN.md](PLAN.md) for the complete roadmap.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE](LICENSE).
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,92 @@
|
||||
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION AND CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,93 @@
|
||||
{
|
||||
"asset": {
|
||||
"version": "2.0",
|
||||
"generator": "oxide cube generator"
|
||||
},
|
||||
"scene": 0,
|
||||
"scenes": [
|
||||
{
|
||||
"nodes": [
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"nodes": [
|
||||
{
|
||||
"mesh": 0,
|
||||
"name": "Cube"
|
||||
}
|
||||
],
|
||||
"meshes": [
|
||||
{
|
||||
"name": "Cube",
|
||||
"primitives": [
|
||||
{
|
||||
"attributes": {
|
||||
"POSITION": 0
|
||||
},
|
||||
"indices": 1,
|
||||
"material": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"materials": [
|
||||
{
|
||||
"name": "CubeMat",
|
||||
"pbrMetallicRoughness": {
|
||||
"baseColorFactor": [
|
||||
0.9,
|
||||
0.45,
|
||||
0.12,
|
||||
1.0
|
||||
],
|
||||
"metallicFactor": 0.0,
|
||||
"roughnessFactor": 0.7
|
||||
}
|
||||
}
|
||||
],
|
||||
"buffers": [
|
||||
{
|
||||
"byteLength": 168,
|
||||
"uri": "data:application/octet-stream;base64,AAAAvwAAAL8AAAC/AAAAPwAAAL8AAAC/AAAAPwAAAD8AAAC/AAAAvwAAAD8AAAC/AAAAvwAAAL8AAAA/AAAAPwAAAL8AAAA/AAAAPwAAAD8AAAA/AAAAvwAAAD8AAAA/BAAFAAYABAAGAAcAAQAAAAMAAQADAAIABQABAAIABQACAAYAAAAEAAcAAAAHAAMAAwACAAYAAwAGAAcAAAABAAUAAAAFAAQA"
|
||||
}
|
||||
],
|
||||
"bufferViews": [
|
||||
{
|
||||
"buffer": 0,
|
||||
"byteOffset": 0,
|
||||
"byteLength": 96,
|
||||
"target": 34962
|
||||
},
|
||||
{
|
||||
"buffer": 0,
|
||||
"byteOffset": 96,
|
||||
"byteLength": 72,
|
||||
"target": 34963
|
||||
}
|
||||
],
|
||||
"accessors": [
|
||||
{
|
||||
"bufferView": 0,
|
||||
"componentType": 5126,
|
||||
"count": 8,
|
||||
"type": "VEC3",
|
||||
"min": [
|
||||
-0.5,
|
||||
-0.5,
|
||||
-0.5
|
||||
],
|
||||
"max": [
|
||||
0.5,
|
||||
0.5,
|
||||
0.5
|
||||
]
|
||||
},
|
||||
{
|
||||
"bufferView": 1,
|
||||
"componentType": 5123,
|
||||
"count": 36,
|
||||
"type": "SCALAR"
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.6 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.8 MiB |
@@ -0,0 +1,40 @@
|
||||
[package]
|
||||
name = "oxide-editor"
|
||||
description = "Oxide Engine — in-engine editor"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "oxide-editor"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
oxide-engine = { path = "../engine" }
|
||||
oxide-physics = { path = "../physics" }
|
||||
oxide-script = { path = "../script" }
|
||||
log.workspace = true
|
||||
env_logger.workspace = true
|
||||
anyhow.workspace = true
|
||||
egui.workspace = true
|
||||
egui-wgpu.workspace = true
|
||||
egui-winit.workspace = true
|
||||
egui_dock.workspace = true
|
||||
# Native folder picker for New/Open Project, run on a helper thread so the
|
||||
# UI keeps redrawing while the dialog is up (see Shell::poll_folder_pick).
|
||||
rfd.workspace = true
|
||||
# Editor preferences file I/O reads/writes the same RON shape `Settings`
|
||||
# exports; the engine already pulls `ron` in, the editor now does too.
|
||||
ron.workspace = true
|
||||
# Editor-owned settings sections (e.g. the External Editor preference) derive
|
||||
# their own Serialize/Deserialize for the Settings store.
|
||||
serde.workspace = true
|
||||
|
||||
# PTY-backed terminal panel (Stage 10): run interactive/TUI programs — shells,
|
||||
# REPLs, and AI-agent CLIs like `claude` — inside the editor. `portable-pty`
|
||||
# opens a real pseudo-terminal (cross-platform: Linux now, Windows later);
|
||||
# `vt100` parses the program's byte stream into a screen grid the panel renders.
|
||||
portable-pty = "0.9"
|
||||
vt100 = "0.16"
|
||||
@@ -0,0 +1,248 @@
|
||||
//! Bundled editor assets — locating the shared `assets/` tree, seeding a new
|
||||
//! project's default content (currently the default UI font), and creating new
|
||||
//! script files from the built-in template (the inspector's "New Script"
|
||||
//! button).
|
||||
//!
|
||||
//! The editor ships a small set of shared assets (icons, the default UI font, …)
|
||||
//! installed by `install.sh` to `$PREFIX/share/oxide/assets`. At runtime we have
|
||||
//! to find that tree whether the editor is *installed* or run from a *dev*
|
||||
//! checkout, so [`bundled_assets_dir`] resolves it in priority order:
|
||||
//!
|
||||
//! 1. the `OXIDE_ASSETS_DIR` environment variable, if set (explicit override);
|
||||
//! 2. `<exe>/../share/oxide/assets` — the install layout (`bin/` next to
|
||||
//! `share/`);
|
||||
//! 3. `<crate>/../assets` — the repo's top-level `assets/` for `cargo run`.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// The default UI font's path, relative to the bundled `assets/` directory and
|
||||
/// to a project's `assets/` directory (they share the typed-folder layout).
|
||||
///
|
||||
/// Inter (SIL Open Font License) — the variable font's default instance is the
|
||||
/// Regular weight. The license travels next to it as `fonts/OFL.txt`.
|
||||
pub const DEFAULT_UI_FONT_REL: &str = "fonts/InterVariable.ttf";
|
||||
|
||||
/// The default UI font's license file, copied alongside the font so a project
|
||||
/// (and any game exported from it) carries the attribution the OFL requires.
|
||||
pub const DEFAULT_UI_FONT_LICENSE_REL: &str = "fonts/OFL.txt";
|
||||
|
||||
/// Locates the editor's bundled `assets/` directory, or `None` if no candidate
|
||||
/// exists (e.g. a stripped install missing its share tree).
|
||||
pub fn bundled_assets_dir() -> Option<PathBuf> {
|
||||
// 1. Explicit override.
|
||||
if let Some(dir) = std::env::var_os("OXIDE_ASSETS_DIR") {
|
||||
let dir = PathBuf::from(dir);
|
||||
if dir.is_dir() {
|
||||
return Some(dir);
|
||||
}
|
||||
}
|
||||
// 2. Installed layout: <prefix>/bin/oxide-editor + <prefix>/share/oxide/assets.
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
if let Some(bin_dir) = exe.parent() {
|
||||
if let Some(prefix) = bin_dir.parent() {
|
||||
let installed = prefix.join("share/oxide/assets");
|
||||
if installed.is_dir() {
|
||||
return Some(installed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 3. Dev checkout: the repo's top-level `assets/` sits one level above this
|
||||
// crate (`editor/`).
|
||||
let dev = Path::new(env!("CARGO_MANIFEST_DIR")).join("../assets");
|
||||
dev.is_dir().then_some(dev)
|
||||
}
|
||||
|
||||
/// The absolute path of the bundled default UI font, if the assets tree was
|
||||
/// found and the font is present.
|
||||
pub fn default_ui_font_source() -> Option<PathBuf> {
|
||||
let path = bundled_assets_dir()?.join(DEFAULT_UI_FONT_REL);
|
||||
path.is_file().then_some(path)
|
||||
}
|
||||
|
||||
/// Copies the bundled default UI font (and its license) into `project_assets_dir`
|
||||
/// under the same relative path, unless a file is already there. Returns whether
|
||||
/// the font was newly copied. A missing bundle is a no-op (returns `false`).
|
||||
///
|
||||
/// Called when a project is created so the asset browser has a usable font to
|
||||
/// pick from immediately, referenced by the project-relative path the
|
||||
/// [`AssetDatabase`](oxide_engine::asset::AssetDatabase) records.
|
||||
pub fn seed_default_font(project_assets_dir: &Path) -> std::io::Result<bool> {
|
||||
let Some(src) = default_ui_font_source() else {
|
||||
return Ok(false);
|
||||
};
|
||||
let dst = project_assets_dir.join(DEFAULT_UI_FONT_REL);
|
||||
if dst.exists() {
|
||||
return Ok(false);
|
||||
}
|
||||
if let Some(parent) = dst.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::copy(&src, &dst)?;
|
||||
// Best-effort: carry the license next to the font (don't fail the seed if
|
||||
// only the license is missing from the bundle).
|
||||
if let Some(bundle) = bundled_assets_dir() {
|
||||
let lic_src = bundle.join(DEFAULT_UI_FONT_LICENSE_REL);
|
||||
if lic_src.is_file() {
|
||||
let _ = std::fs::copy(
|
||||
lic_src,
|
||||
project_assets_dir.join(DEFAULT_UI_FONT_LICENSE_REL),
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// The `.rhai` source the "New Script" button writes, personalised with the
|
||||
/// script's file stem so the Console output identifies which script speaks.
|
||||
///
|
||||
/// Kept to the two lifecycle hooks `docs/scripting.md` teaches first; the
|
||||
/// `update` body ships commented out so a freshly created script visibly runs
|
||||
/// (the `init` print) without moving anything until the author opts in.
|
||||
pub fn script_template(stem: &str) -> String {
|
||||
format!(
|
||||
r#"// {stem}.rhai — attached via a Script component.
|
||||
//
|
||||
// Top-level statements run once when the script (re)starts. `init()` runs
|
||||
// once after them; `update(dt)` runs every frame (dt = seconds).
|
||||
|
||||
fn init() {{
|
||||
print("{stem}: init");
|
||||
}}
|
||||
|
||||
fn update(dt) {{
|
||||
// e.g. rotate_y(dt * 1.5);
|
||||
}}
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
/// Reduces a typed script name to a safe file stem: keeps ASCII alphanumerics,
|
||||
/// `-` and `_`, folds anything else (spaces, punctuation, Unicode) to `_`,
|
||||
/// collapses runs, trims the ends, and drops a trailing `.rhai` the user may
|
||||
/// have typed. An unusable input yields `"new_script"`.
|
||||
pub fn sanitize_script_stem(name: &str) -> String {
|
||||
let trimmed = name.trim();
|
||||
let trimmed = trimmed.strip_suffix(".rhai").unwrap_or(trimmed);
|
||||
let mut stem = String::with_capacity(trimmed.len());
|
||||
for c in trimmed.chars() {
|
||||
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
|
||||
stem.push(c);
|
||||
} else if !stem.ends_with('_') {
|
||||
stem.push('_');
|
||||
}
|
||||
}
|
||||
let stem = stem.trim_matches('_');
|
||||
if stem.is_empty() {
|
||||
"new_script".to_owned()
|
||||
} else {
|
||||
stem.to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a new script file under `<assets_dir>/scripts/` from the template,
|
||||
/// returning its assets-relative path (e.g. `"scripts/my_script.rhai"`) for
|
||||
/// registration in the [`AssetDatabase`](oxide_engine::asset::AssetDatabase).
|
||||
///
|
||||
/// The desired name is [sanitized](sanitize_script_stem); a taken name gets a
|
||||
/// numeric suffix (`stem_2`, `stem_3`, …) instead of failing or overwriting,
|
||||
/// so the button always succeeds on a writable project.
|
||||
pub fn create_script_file(assets_dir: &Path, desired_name: &str) -> std::io::Result<String> {
|
||||
use oxide_engine::asset::AssetKind;
|
||||
|
||||
let stem = sanitize_script_stem(desired_name);
|
||||
let dir = assets_dir.join(AssetKind::Script.folder());
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
let mut candidate = stem.clone();
|
||||
let mut n = 1;
|
||||
while dir.join(format!("{candidate}.rhai")).exists() {
|
||||
n += 1;
|
||||
candidate = format!("{stem}_{n}");
|
||||
}
|
||||
std::fs::write(
|
||||
dir.join(format!("{candidate}.rhai")),
|
||||
script_template(&candidate),
|
||||
)?;
|
||||
Ok(format!("{}/{candidate}.rhai", AssetKind::Script.folder()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bundle_resolves_in_dev_checkout() {
|
||||
// Running tests from the workspace, the dev-checkout fallback (3) finds
|
||||
// the repo's top-level assets/ with the bundled font.
|
||||
let dir = bundled_assets_dir().expect("bundled assets dir should resolve in dev");
|
||||
assert!(
|
||||
dir.join(DEFAULT_UI_FONT_REL).is_file(),
|
||||
"default font present"
|
||||
);
|
||||
assert!(default_ui_font_source().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_copies_font_once() {
|
||||
let mut tmp = std::env::temp_dir();
|
||||
tmp.push(format!("oxide_seedfont_{}", std::process::id()));
|
||||
let assets = tmp.join("assets");
|
||||
std::fs::create_dir_all(&assets).unwrap();
|
||||
|
||||
assert!(seed_default_font(&assets).unwrap(), "first seed copies");
|
||||
assert!(assets.join(DEFAULT_UI_FONT_REL).is_file());
|
||||
// Idempotent: a second seed finds the file already present.
|
||||
assert!(
|
||||
!seed_default_font(&assets).unwrap(),
|
||||
"second seed is a no-op"
|
||||
);
|
||||
|
||||
std::fs::remove_dir_all(tmp).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_covers_typed_names() {
|
||||
assert_eq!(sanitize_script_stem("spin"), "spin");
|
||||
assert_eq!(sanitize_script_stem(" My Cool Script! "), "My_Cool_Script");
|
||||
assert_eq!(sanitize_script_stem("door.rhai"), "door");
|
||||
assert_eq!(sanitize_script_stem("a//b\\c"), "a_b_c");
|
||||
assert_eq!(sanitize_script_stem("čárka"), "rka");
|
||||
// Unusable inputs fall back rather than producing "" or "_".
|
||||
assert_eq!(sanitize_script_stem(""), "new_script");
|
||||
assert_eq!(sanitize_script_stem("!!!"), "new_script");
|
||||
assert_eq!(sanitize_script_stem(".rhai"), "new_script");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_script_writes_template_and_dodges_collisions() {
|
||||
let mut tmp = std::env::temp_dir();
|
||||
tmp.push(format!("oxide_newscript_{}", std::process::id()));
|
||||
let assets = tmp.join("assets");
|
||||
std::fs::create_dir_all(&assets).unwrap();
|
||||
|
||||
let rel = create_script_file(&assets, "door opener").unwrap();
|
||||
assert_eq!(rel, "scripts/door_opener.rhai");
|
||||
let text = std::fs::read_to_string(assets.join(&rel)).unwrap();
|
||||
assert!(text.contains("fn update(dt)"));
|
||||
|
||||
// Same name again: suffixed, nothing overwritten.
|
||||
let rel2 = create_script_file(&assets, "door opener").unwrap();
|
||||
assert_eq!(rel2, "scripts/door_opener_2.rhai");
|
||||
let rel3 = create_script_file(&assets, "door opener").unwrap();
|
||||
assert_eq!(rel3, "scripts/door_opener_3.rhai");
|
||||
|
||||
std::fs::remove_dir_all(tmp).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_compiles_in_the_script_engine() {
|
||||
// The template must never ship a syntax error: compile it exactly as
|
||||
// the runtime would.
|
||||
let asset = oxide_script::ScriptAsset::from_source(
|
||||
"new_script.rhai",
|
||||
script_template("new_script"),
|
||||
);
|
||||
let engine = oxide_script::ScriptEngine::new();
|
||||
engine.compile(&asset).expect("template compiles");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
//! Default editor input bindings + the action-name constants the bindings
|
||||
//! UI and any debug overlay address.
|
||||
//!
|
||||
//! Living in the editor library (not the binary) so the Stage-7
|
||||
//! [`InputBindings`](crate::shell::Shell) preferences page and any future
|
||||
//! editor module can re-register or remap the same actions without
|
||||
//! depending on the binary's private module.
|
||||
|
||||
use oxide_engine::input::{ActionMap, AxisBinding, Binding};
|
||||
use oxide_engine::winit::keyboard::KeyCode;
|
||||
|
||||
/// The settings-section name under which the editor's
|
||||
/// [`ActionOverrides`](oxide_engine::input::ActionOverrides) are persisted.
|
||||
///
|
||||
/// The shell registers this section automatically in
|
||||
/// [`EditorState::new`](crate::state::EditorState::new); UI code that wants
|
||||
/// to refresh the section after a binding change addresses it by this name.
|
||||
pub const SETTINGS_SECTION: &str = "input.bindings";
|
||||
|
||||
/// Stable action names addressed throughout the editor — the bindings
|
||||
/// preferences page, the camera input poll in the runner, and any future
|
||||
/// debug overlay all reference these strings.
|
||||
pub mod action {
|
||||
/// Button: toggle between orbit and flythrough viewport cameras.
|
||||
pub const TOGGLE_FLYTHROUGH: &str = "editor.camera.toggle_flythrough";
|
||||
/// Button (held): accelerate flythrough translation while engaged.
|
||||
pub const SPRINT: &str = "editor.camera.sprint";
|
||||
/// 1D axis: strafe right (+) / strafe left (−) in flythrough mode.
|
||||
pub const MOVE_RIGHT: &str = "editor.camera.move_right";
|
||||
/// 1D axis: forward (+) / back (−) in flythrough mode.
|
||||
pub const MOVE_FORWARD: &str = "editor.camera.move_forward";
|
||||
/// 1D axis: ascend (+) / descend (−) in flythrough mode.
|
||||
pub const MOVE_UP: &str = "editor.camera.move_up";
|
||||
|
||||
/// Button: switch the transform gizmo to Translate mode (orbit camera only).
|
||||
pub const GIZMO_TRANSLATE: &str = "editor.gizmo.translate";
|
||||
/// Button: switch the transform gizmo to Rotate mode (orbit camera only).
|
||||
pub const GIZMO_ROTATE: &str = "editor.gizmo.rotate";
|
||||
/// Button: switch the transform gizmo to Scale mode (orbit camera only).
|
||||
pub const GIZMO_SCALE: &str = "editor.gizmo.scale";
|
||||
}
|
||||
|
||||
/// Registers the editor's default action set on `actions`. Defaults follow
|
||||
/// the DCC-tools convention (WASD + QE, Shift sprint, F toggles the
|
||||
/// flythrough camera) so users coming from Blender / Maya / Unity feel at
|
||||
/// home.
|
||||
///
|
||||
/// Idempotent on the action names — re-registering preserves any user-
|
||||
/// remapped current bindings while refreshing the defaults that the
|
||||
/// "Restore defaults" button reverts to.
|
||||
pub fn register_defaults(actions: &mut ActionMap) {
|
||||
actions
|
||||
.register(action::TOGGLE_FLYTHROUGH, [Binding::Key(KeyCode::KeyF)])
|
||||
.register(
|
||||
action::SPRINT,
|
||||
[
|
||||
Binding::Key(KeyCode::ShiftLeft),
|
||||
Binding::Key(KeyCode::ShiftRight),
|
||||
],
|
||||
)
|
||||
.register_axis(
|
||||
action::MOVE_RIGHT,
|
||||
AxisBinding::new([Binding::Key(KeyCode::KeyD)], [Binding::Key(KeyCode::KeyA)]),
|
||||
)
|
||||
.register_axis(
|
||||
action::MOVE_FORWARD,
|
||||
AxisBinding::new([Binding::Key(KeyCode::KeyW)], [Binding::Key(KeyCode::KeyS)]),
|
||||
)
|
||||
.register_axis(
|
||||
action::MOVE_UP,
|
||||
AxisBinding::new([Binding::Key(KeyCode::KeyE)], [Binding::Key(KeyCode::KeyQ)]),
|
||||
)
|
||||
// Gizmo tool hotkeys (W/E/R). These share physical keys with
|
||||
// flythrough movement, so the host gates them on the camera being
|
||||
// in orbit mode — in flythrough W/E move the camera, in orbit
|
||||
// they switch the gizmo tool.
|
||||
.register(action::GIZMO_TRANSLATE, [Binding::Key(KeyCode::KeyW)])
|
||||
.register(action::GIZMO_ROTATE, [Binding::Key(KeyCode::KeyE)])
|
||||
.register(action::GIZMO_SCALE, [Binding::Key(KeyCode::KeyR)]);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn defaults_register_every_advertised_action() {
|
||||
let mut actions = ActionMap::new();
|
||||
register_defaults(&mut actions);
|
||||
|
||||
assert!(actions.has(action::TOGGLE_FLYTHROUGH));
|
||||
assert!(actions.has(action::SPRINT));
|
||||
assert!(actions.has_axis(action::MOVE_RIGHT));
|
||||
assert!(actions.has_axis(action::MOVE_FORWARD));
|
||||
assert!(actions.has_axis(action::MOVE_UP));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_are_idempotent_and_preserve_remaps() {
|
||||
let mut actions = ActionMap::new();
|
||||
register_defaults(&mut actions);
|
||||
|
||||
// User remaps Toggle to Tab.
|
||||
actions.set_bindings(action::TOGGLE_FLYTHROUGH, vec![Binding::Key(KeyCode::Tab)]);
|
||||
|
||||
// Re-running register_defaults must not stomp the user's remap.
|
||||
register_defaults(&mut actions);
|
||||
assert_eq!(
|
||||
actions.bindings(action::TOGGLE_FLYTHROUGH),
|
||||
&[Binding::Key(KeyCode::Tab)]
|
||||
);
|
||||
// But the defaults — what "Restore defaults" reverts to — are still F.
|
||||
assert_eq!(
|
||||
actions.defaults(action::TOGGLE_FLYTHROUGH),
|
||||
&[Binding::Key(KeyCode::KeyF)]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
//! The editor's central undo/redo command stack.
|
||||
//!
|
||||
//! Every editor mutation that should be undoable — a transform edit, a rename, a
|
||||
//! spawn/despawn, and later sculpt/paint/scatter brush strokes — is expressed as
|
||||
//! a [`Command`] and pushed onto a [`CommandStack`]. Routing *all* edits through
|
||||
//! one stack is what makes undo/redo consistent across the whole editor, and it
|
||||
//! is why the Stage-7 gizmos and every later tool get undo "for free".
|
||||
//!
|
||||
//! The stack is generic over the context `C` a command mutates (in the editor
|
||||
//! that is the scene + editor state), which keeps it decoupled and unit-testable
|
||||
//! against a trivial context.
|
||||
|
||||
use std::any::Any;
|
||||
|
||||
/// A reversible editor action over a context `C`.
|
||||
///
|
||||
/// A command must be able to [`apply`](Self::apply) its effect and exactly
|
||||
/// [`undo`](Self::undo) it. Commands are stored boxed on the [`CommandStack`].
|
||||
pub trait Command<C>: 'static {
|
||||
/// Performs the action, mutating `ctx`.
|
||||
fn apply(&mut self, ctx: &mut C);
|
||||
|
||||
/// Reverses the action, restoring `ctx` to its pre-[`apply`](Self::apply) state.
|
||||
fn undo(&mut self, ctx: &mut C);
|
||||
|
||||
/// A short human-readable label (shown in the Edit menu / history).
|
||||
fn label(&self) -> String;
|
||||
|
||||
/// Upcast for [`merge`](Self::merge) to downcast a following command.
|
||||
/// Implement as `self`.
|
||||
fn as_any_mut(&mut self) -> &mut dyn Any;
|
||||
|
||||
/// Tries to fold the immediately-following command `next` into this one so
|
||||
/// they share a single undo entry (e.g. every frame of a gizmo drag becomes
|
||||
/// one undoable move). Return `true` if absorbed; the default never merges.
|
||||
///
|
||||
/// When merging, update `self` so that undoing it reverses *both* effects.
|
||||
fn merge(&mut self, next: &mut dyn Command<C>) -> bool {
|
||||
let _ = next;
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// A composite command: several commands grouped into one undo entry.
|
||||
///
|
||||
/// Applied front-to-back and undone back-to-front, so a multi-step operation
|
||||
/// (e.g. "duplicate and offset") is a single, atomic undo.
|
||||
pub struct Group<C> {
|
||||
label: String,
|
||||
commands: Vec<Box<dyn Command<C>>>,
|
||||
}
|
||||
|
||||
impl<C: 'static> Group<C> {
|
||||
/// A new, empty group with the given label.
|
||||
pub fn new(label: impl Into<String>) -> Self {
|
||||
Self {
|
||||
label: label.into(),
|
||||
commands: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds a command to the group (not yet applied).
|
||||
pub fn push(&mut self, command: impl Command<C> + 'static) {
|
||||
self.commands.push(Box::new(command));
|
||||
}
|
||||
|
||||
/// Whether the group has no commands.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.commands.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl<C: 'static> Command<C> for Group<C> {
|
||||
fn apply(&mut self, ctx: &mut C) {
|
||||
for command in &mut self.commands {
|
||||
command.apply(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
fn undo(&mut self, ctx: &mut C) {
|
||||
for command in self.commands.iter_mut().rev() {
|
||||
command.undo(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
fn label(&self) -> String {
|
||||
self.label.clone()
|
||||
}
|
||||
|
||||
fn as_any_mut(&mut self) -> &mut dyn Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// A bounded undo/redo stack of [`Command`]s over a context `C`.
|
||||
///
|
||||
/// Pushing a command applies it and clears the redo history. Capacity caps how
|
||||
/// many undo entries are retained (oldest dropped first) so the history cannot
|
||||
/// grow without bound.
|
||||
pub struct CommandStack<C> {
|
||||
undo: Vec<Box<dyn Command<C>>>,
|
||||
redo: Vec<Box<dyn Command<C>>>,
|
||||
capacity: usize,
|
||||
}
|
||||
|
||||
impl<C: 'static> CommandStack<C> {
|
||||
/// The default maximum number of retained undo entries.
|
||||
pub const DEFAULT_CAPACITY: usize = 256;
|
||||
|
||||
/// A stack with the [default capacity](Self::DEFAULT_CAPACITY).
|
||||
pub fn new() -> Self {
|
||||
Self::with_capacity(Self::DEFAULT_CAPACITY)
|
||||
}
|
||||
|
||||
/// A stack retaining at most `capacity` undo entries (minimum 1).
|
||||
pub fn with_capacity(capacity: usize) -> Self {
|
||||
Self {
|
||||
undo: Vec::new(),
|
||||
redo: Vec::new(),
|
||||
capacity: capacity.max(1),
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies `command` and records it, clearing the redo history.
|
||||
///
|
||||
/// If the previous top entry [`merge`](Command::merge)s this command, the two
|
||||
/// share one undo entry instead of pushing a new one.
|
||||
pub fn push(&mut self, command: impl Command<C> + 'static, ctx: &mut C) {
|
||||
self.push_boxed(Box::new(command), ctx);
|
||||
}
|
||||
|
||||
/// Applies and records an already-boxed command (e.g. a [`Group`]).
|
||||
pub fn push_boxed(&mut self, mut command: Box<dyn Command<C>>, ctx: &mut C) {
|
||||
command.apply(ctx);
|
||||
self.redo.clear();
|
||||
if let Some(top) = self.undo.last_mut() {
|
||||
if top.merge(command.as_mut()) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
self.undo.push(command);
|
||||
while self.undo.len() > self.capacity {
|
||||
self.undo.remove(0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Undoes the most recent command, moving it to the redo history. Returns its
|
||||
/// label, or `None` if there was nothing to undo.
|
||||
pub fn undo(&mut self, ctx: &mut C) -> Option<String> {
|
||||
let mut command = self.undo.pop()?;
|
||||
command.undo(ctx);
|
||||
let label = command.label();
|
||||
self.redo.push(command);
|
||||
Some(label)
|
||||
}
|
||||
|
||||
/// Redoes the most recently undone command. Returns its label, or `None`.
|
||||
pub fn redo(&mut self, ctx: &mut C) -> Option<String> {
|
||||
let mut command = self.redo.pop()?;
|
||||
command.apply(ctx);
|
||||
let label = command.label();
|
||||
self.undo.push(command);
|
||||
Some(label)
|
||||
}
|
||||
|
||||
/// Whether there is anything to undo.
|
||||
pub fn can_undo(&self) -> bool {
|
||||
!self.undo.is_empty()
|
||||
}
|
||||
|
||||
/// Whether there is anything to redo.
|
||||
pub fn can_redo(&self) -> bool {
|
||||
!self.redo.is_empty()
|
||||
}
|
||||
|
||||
/// The label of the next undo, if any (for the Edit menu).
|
||||
pub fn undo_label(&self) -> Option<String> {
|
||||
self.undo.last().map(|c| c.label())
|
||||
}
|
||||
|
||||
/// The label of the next redo, if any.
|
||||
pub fn redo_label(&self) -> Option<String> {
|
||||
self.redo.last().map(|c| c.label())
|
||||
}
|
||||
|
||||
/// The number of retained undo entries.
|
||||
pub fn undo_depth(&self) -> usize {
|
||||
self.undo.len()
|
||||
}
|
||||
|
||||
/// Clears all history (e.g. on project close).
|
||||
pub fn clear(&mut self) {
|
||||
self.undo.clear();
|
||||
self.redo.clear();
|
||||
}
|
||||
}
|
||||
|
||||
impl<C: 'static> Default for CommandStack<C> {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A trivial context: a single integer the test commands mutate.
|
||||
type Ctx = i32;
|
||||
|
||||
/// Adds `amount` to the context; undo subtracts it. Consecutive `Add`s merge
|
||||
/// into one undo entry (modeling a continuous drag).
|
||||
struct Add {
|
||||
amount: i32,
|
||||
mergeable: bool,
|
||||
}
|
||||
|
||||
impl Add {
|
||||
fn new(amount: i32) -> Self {
|
||||
Self {
|
||||
amount,
|
||||
mergeable: true,
|
||||
}
|
||||
}
|
||||
fn standalone(amount: i32) -> Self {
|
||||
Self {
|
||||
amount,
|
||||
mergeable: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Command<Ctx> for Add {
|
||||
fn apply(&mut self, ctx: &mut Ctx) {
|
||||
*ctx += self.amount;
|
||||
}
|
||||
fn undo(&mut self, ctx: &mut Ctx) {
|
||||
*ctx -= self.amount;
|
||||
}
|
||||
fn label(&self) -> String {
|
||||
format!("Add {}", self.amount)
|
||||
}
|
||||
fn as_any_mut(&mut self) -> &mut dyn Any {
|
||||
self
|
||||
}
|
||||
fn merge(&mut self, next: &mut dyn Command<Ctx>) -> bool {
|
||||
if !self.mergeable {
|
||||
return false;
|
||||
}
|
||||
if let Some(other) = next.as_any_mut().downcast_mut::<Add>() {
|
||||
if other.mergeable {
|
||||
// Fold next's effect into this entry: undoing reverses both.
|
||||
self.amount += other.amount;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_undo_redo_round_trip() {
|
||||
let mut ctx: Ctx = 0;
|
||||
let mut stack = CommandStack::new();
|
||||
stack.push(Add::standalone(5), &mut ctx);
|
||||
stack.push(Add::standalone(3), &mut ctx);
|
||||
assert_eq!(ctx, 8);
|
||||
assert_eq!(stack.undo_depth(), 2);
|
||||
|
||||
assert_eq!(stack.undo(&mut ctx).as_deref(), Some("Add 3"));
|
||||
assert_eq!(ctx, 5);
|
||||
assert_eq!(stack.undo(&mut ctx).as_deref(), Some("Add 5"));
|
||||
assert_eq!(ctx, 0);
|
||||
assert!(!stack.can_undo());
|
||||
|
||||
assert_eq!(stack.redo(&mut ctx).as_deref(), Some("Add 5"));
|
||||
assert_eq!(ctx, 5);
|
||||
assert!(stack.can_redo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pushing_clears_redo() {
|
||||
let mut ctx: Ctx = 0;
|
||||
let mut stack = CommandStack::new();
|
||||
stack.push(Add::standalone(1), &mut ctx);
|
||||
stack.undo(&mut ctx);
|
||||
assert!(stack.can_redo());
|
||||
stack.push(Add::standalone(10), &mut ctx); // new edit invalidates redo
|
||||
assert!(!stack.can_redo());
|
||||
assert_eq!(ctx, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consecutive_mergeable_commands_share_one_entry() {
|
||||
let mut ctx: Ctx = 0;
|
||||
let mut stack = CommandStack::new();
|
||||
// Simulate a drag: many small mergeable adds.
|
||||
for _ in 0..5 {
|
||||
stack.push(Add::new(2), &mut ctx);
|
||||
}
|
||||
assert_eq!(ctx, 10);
|
||||
assert_eq!(stack.undo_depth(), 1, "drag should be one undo entry");
|
||||
// A single undo reverses the whole drag.
|
||||
stack.undo(&mut ctx);
|
||||
assert_eq!(ctx, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_is_atomic() {
|
||||
let mut ctx: Ctx = 0;
|
||||
let mut stack = CommandStack::new();
|
||||
let mut group = Group::new("Duplicate+Offset");
|
||||
group.push(Add::standalone(4));
|
||||
group.push(Add::standalone(6));
|
||||
stack.push_boxed(Box::new(group), &mut ctx);
|
||||
assert_eq!(ctx, 10);
|
||||
assert_eq!(stack.undo_depth(), 1);
|
||||
assert_eq!(stack.undo(&mut ctx).as_deref(), Some("Duplicate+Offset"));
|
||||
assert_eq!(ctx, 0, "group undoes as one atomic step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capacity_drops_oldest_entries() {
|
||||
let mut ctx: Ctx = 0;
|
||||
let mut stack = CommandStack::with_capacity(3);
|
||||
for i in 1..=5 {
|
||||
stack.push(Add::standalone(i), &mut ctx);
|
||||
}
|
||||
// Only the last 3 entries are retained for undo.
|
||||
assert_eq!(stack.undo_depth(), 3);
|
||||
// Undoing all retained entries removes 3+4+5 = 12 from the final 15.
|
||||
while stack.undo(&mut ctx).is_some() {}
|
||||
assert_eq!(ctx, 1 + 2); // the dropped 1 and 2 can't be undone
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
//! Concrete editor commands that mutate the [`EditorState`](crate::state::EditorState).
|
||||
//!
|
||||
//! Routed through the [`CommandStack`](crate::command::CommandStack) so every
|
||||
//! one is undoable through the Edit menu, `Ctrl+Z`/`Ctrl+Y`, and the same path
|
||||
//! that future tools (transform gizmos, sculpt, paint) will use.
|
||||
//!
|
||||
//! Piece 6 ships the **first** commands so the undo plumbing is exercised
|
||||
//! end-to-end:
|
||||
//!
|
||||
//! - [`SetTransformCmd`] — change an entity's local [`Transform`]. Consecutive
|
||||
//! edits to the same entity coalesce via [`Command::merge`] so a slider drag
|
||||
//! or a (future) gizmo drag becomes one undo entry.
|
||||
//! - [`RenameCmd`] — rename an entity.
|
||||
//!
|
||||
//! Spawn/despawn aren't wired yet: round-tripping a despawn would need stable
|
||||
//! entity ids across re-spawn (the scene reuses ids), which is a Stage-7
|
||||
//! design step. The hierarchy panel still offers Add/Delete; they bypass the
|
||||
//! stack today and are clearly labeled as "not undoable" in the shell.
|
||||
|
||||
use std::any::Any;
|
||||
|
||||
use oxide_engine::prelude::*;
|
||||
|
||||
use crate::command::Command;
|
||||
use crate::state::EditorState;
|
||||
|
||||
/// Replaces the open UI document's panel wholesale (widget tree + sizes).
|
||||
///
|
||||
/// The UI canvas snapshots the panel before an edit and again after, so any
|
||||
/// structural change (add / remove / move a widget) or property change goes
|
||||
/// through one undoable command without per-operation bookkeeping. A panel is a
|
||||
/// small data tree, so cloning it for the snapshots is cheap.
|
||||
pub struct SetUiPanelCmd {
|
||||
/// The panel before the edit.
|
||||
pub before: UiPanel,
|
||||
/// The panel after the edit.
|
||||
pub after: UiPanel,
|
||||
/// Human-readable description for the Edit menu.
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
impl Command<EditorState> for SetUiPanelCmd {
|
||||
fn apply(&mut self, state: &mut EditorState) {
|
||||
if let Some(doc) = &mut state.ui_doc {
|
||||
doc.panel = self.after.clone();
|
||||
doc.dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn undo(&mut self, state: &mut EditorState) {
|
||||
if let Some(doc) = &mut state.ui_doc {
|
||||
doc.panel = self.before.clone();
|
||||
doc.dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn label(&self) -> String {
|
||||
self.label.clone()
|
||||
}
|
||||
|
||||
fn as_any_mut(&mut self) -> &mut dyn Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Replaces an entity's local [`Transform`]. Coalesces consecutive edits to
|
||||
/// the same entity so an interactive drag is one undo entry.
|
||||
pub struct SetTransformCmd {
|
||||
pub entity: Entity,
|
||||
/// The transform before the first apply — preserved through merges so
|
||||
/// undo reverses the whole drag at once.
|
||||
pub before: Transform,
|
||||
/// The transform after the most recent apply.
|
||||
pub after: Transform,
|
||||
}
|
||||
|
||||
impl SetTransformCmd {
|
||||
/// Builds the command, snapshotting the entity's current transform as the
|
||||
/// pre-edit state. Returns `None` if the entity has no transform (e.g. it
|
||||
/// was just despawned).
|
||||
pub fn new(state: &EditorState, entity: Entity, after: Transform) -> Option<Self> {
|
||||
let before = state.scene.local_transform(entity)?;
|
||||
Some(Self {
|
||||
entity,
|
||||
before,
|
||||
after,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Command<EditorState> for SetTransformCmd {
|
||||
fn apply(&mut self, state: &mut EditorState) {
|
||||
state.scene.set_local_transform(self.entity, self.after);
|
||||
}
|
||||
|
||||
fn undo(&mut self, state: &mut EditorState) {
|
||||
state.scene.set_local_transform(self.entity, self.before);
|
||||
}
|
||||
|
||||
fn label(&self) -> String {
|
||||
"Edit Transform".to_owned()
|
||||
}
|
||||
|
||||
fn as_any_mut(&mut self) -> &mut dyn Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn merge(&mut self, next: &mut dyn Command<EditorState>) -> bool {
|
||||
let Some(next) = next.as_any_mut().downcast_mut::<SetTransformCmd>() else {
|
||||
return false;
|
||||
};
|
||||
if next.entity != self.entity {
|
||||
return false;
|
||||
}
|
||||
// Absorb `next` by extending our `after` while preserving `before`,
|
||||
// so a long drag remains a single undo step.
|
||||
self.after = next.after;
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets a single **reflected field** of a component on an entity, addressed by
|
||||
/// type name + field name and carried as RON.
|
||||
///
|
||||
/// This is the generic counterpart to [`SetTransformCmd`]: the
|
||||
/// reflection-driven inspector emits one of these for *any* registered
|
||||
/// component's field, so a new component type becomes undoably editable with no
|
||||
/// new command type. Consecutive edits to the same `(entity, type, field)`
|
||||
/// coalesce via [`Command::merge`], so dragging a value slider is one undo
|
||||
/// entry.
|
||||
pub struct SetFieldCmd {
|
||||
pub entity: Entity,
|
||||
/// The registered type name (e.g. `"Transform"`).
|
||||
pub type_name: &'static str,
|
||||
/// The reflected field name (e.g. `"translation"`).
|
||||
pub field: &'static str,
|
||||
/// The field's RON before the first apply — preserved through merges.
|
||||
pub before: String,
|
||||
/// The field's RON after the most recent apply.
|
||||
pub after: String,
|
||||
}
|
||||
|
||||
impl SetFieldCmd {
|
||||
/// Builds the command, snapshotting the field's current RON as the
|
||||
/// pre-edit state. Returns `None` if the field can't be read (unknown
|
||||
/// type/field, or the entity lacks the component).
|
||||
pub fn new(
|
||||
state: &EditorState,
|
||||
entity: Entity,
|
||||
type_name: &'static str,
|
||||
field: &'static str,
|
||||
after: String,
|
||||
) -> Option<Self> {
|
||||
let before = state
|
||||
.registry
|
||||
.get_field(state.scene.world(), entity, type_name, field)
|
||||
.ok()?;
|
||||
Some(Self {
|
||||
entity,
|
||||
type_name,
|
||||
field,
|
||||
before,
|
||||
after,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Command<EditorState> for SetFieldCmd {
|
||||
fn apply(&mut self, state: &mut EditorState) {
|
||||
// Disjoint borrows of EditorState: ®istry (receiver) + &mut scene
|
||||
// (the world). A write only fails if the entity/component vanished
|
||||
// between snapshot and apply, in which case there's nothing to do.
|
||||
let _ = state.registry.set_field(
|
||||
state.scene.world_mut(),
|
||||
self.entity,
|
||||
self.type_name,
|
||||
self.field,
|
||||
&self.after,
|
||||
);
|
||||
}
|
||||
|
||||
fn undo(&mut self, state: &mut EditorState) {
|
||||
let _ = state.registry.set_field(
|
||||
state.scene.world_mut(),
|
||||
self.entity,
|
||||
self.type_name,
|
||||
self.field,
|
||||
&self.before,
|
||||
);
|
||||
}
|
||||
|
||||
fn label(&self) -> String {
|
||||
format!("Edit {}.{}", self.type_name, self.field)
|
||||
}
|
||||
|
||||
fn as_any_mut(&mut self) -> &mut dyn Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn merge(&mut self, next: &mut dyn Command<EditorState>) -> bool {
|
||||
let Some(next) = next.as_any_mut().downcast_mut::<SetFieldCmd>() else {
|
||||
return false;
|
||||
};
|
||||
// Only coalesce edits to the *same* field of the same component on the
|
||||
// same entity; preserve `before` so undo reverses the whole drag.
|
||||
if next.entity != self.entity
|
||||
|| next.type_name != self.type_name
|
||||
|| next.field != self.field
|
||||
{
|
||||
return false;
|
||||
}
|
||||
self.after = std::mem::take(&mut next.after);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Renames an entity.
|
||||
pub struct RenameCmd {
|
||||
pub entity: Entity,
|
||||
pub before: String,
|
||||
pub after: String,
|
||||
}
|
||||
|
||||
impl RenameCmd {
|
||||
/// Snapshots the entity's current name as the pre-edit state.
|
||||
pub fn new(state: &EditorState, entity: Entity, after: String) -> Self {
|
||||
let before = state.scene.name(entity).unwrap_or_default();
|
||||
Self {
|
||||
entity,
|
||||
before,
|
||||
after,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Command<EditorState> for RenameCmd {
|
||||
fn apply(&mut self, state: &mut EditorState) {
|
||||
state.scene.set_name(self.entity, self.after.clone());
|
||||
}
|
||||
|
||||
fn undo(&mut self, state: &mut EditorState) {
|
||||
state.scene.set_name(self.entity, self.before.clone());
|
||||
}
|
||||
|
||||
fn label(&self) -> String {
|
||||
format!("Rename to '{}'", self.after)
|
||||
}
|
||||
|
||||
fn as_any_mut(&mut self) -> &mut dyn Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::command::CommandStack;
|
||||
|
||||
fn state_with_entity() -> (EditorState, Entity) {
|
||||
let mut state = EditorState::new();
|
||||
let e = state
|
||||
.scene
|
||||
.spawn("alpha", Transform::from_translation(Vec3::ZERO));
|
||||
(state, e)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transform_undo_redo_round_trips() {
|
||||
let (mut state, e) = state_with_entity();
|
||||
let target = Transform::from_translation(Vec3::new(1.0, 2.0, 3.0));
|
||||
let cmd = SetTransformCmd::new(&state, e, target).expect("transform present");
|
||||
|
||||
let mut stack: CommandStack<EditorState> = CommandStack::with_capacity(32);
|
||||
stack.push(cmd, &mut state);
|
||||
assert_eq!(
|
||||
state.scene.local_transform(e).unwrap().translation,
|
||||
target.translation
|
||||
);
|
||||
|
||||
assert!(stack.undo(&mut state).is_some());
|
||||
assert_eq!(
|
||||
state.scene.local_transform(e).unwrap().translation,
|
||||
Vec3::ZERO
|
||||
);
|
||||
|
||||
assert!(stack.redo(&mut state).is_some());
|
||||
assert_eq!(
|
||||
state.scene.local_transform(e).unwrap().translation,
|
||||
target.translation
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consecutive_transform_edits_coalesce_into_one_undo() {
|
||||
// Mirrors the "interactive drag" case: dozens of per-frame edits, one
|
||||
// undo step that returns to the pre-drag state.
|
||||
let (mut state, e) = state_with_entity();
|
||||
let mut stack: CommandStack<EditorState> = CommandStack::with_capacity(32);
|
||||
|
||||
for step in 1..=5 {
|
||||
let target = Transform::from_translation(Vec3::splat(step as f32));
|
||||
let cmd = SetTransformCmd::new(&state, e, target).unwrap();
|
||||
stack.push(cmd, &mut state);
|
||||
}
|
||||
assert_eq!(
|
||||
state.scene.local_transform(e).unwrap().translation,
|
||||
Vec3::splat(5.0)
|
||||
);
|
||||
|
||||
// A single undo wipes the whole drag — that's the merge contract.
|
||||
assert!(stack.undo(&mut state).is_some());
|
||||
assert_eq!(
|
||||
state.scene.local_transform(e).unwrap().translation,
|
||||
Vec3::ZERO
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_field_undo_redo_round_trips() {
|
||||
let (mut state, e) = state_with_entity();
|
||||
let cmd = SetFieldCmd::new(
|
||||
&state,
|
||||
e,
|
||||
"Transform",
|
||||
"translation",
|
||||
"(1.0,2.0,3.0)".into(),
|
||||
)
|
||||
.expect("transform field readable");
|
||||
|
||||
let mut stack: CommandStack<EditorState> = CommandStack::with_capacity(32);
|
||||
stack.push(cmd, &mut state);
|
||||
assert_eq!(
|
||||
state.scene.local_transform(e).unwrap().translation,
|
||||
Vec3::new(1.0, 2.0, 3.0)
|
||||
);
|
||||
|
||||
assert!(stack.undo(&mut state).is_some());
|
||||
assert_eq!(
|
||||
state.scene.local_transform(e).unwrap().translation,
|
||||
Vec3::ZERO
|
||||
);
|
||||
|
||||
assert!(stack.redo(&mut state).is_some());
|
||||
assert_eq!(
|
||||
state.scene.local_transform(e).unwrap().translation,
|
||||
Vec3::new(1.0, 2.0, 3.0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consecutive_field_edits_to_same_field_coalesce() {
|
||||
// A value-slider drag: many per-frame edits, one undo back to start.
|
||||
let (mut state, e) = state_with_entity();
|
||||
let mut stack: CommandStack<EditorState> = CommandStack::with_capacity(32);
|
||||
for step in 1..=5 {
|
||||
let ron = format!("({0}.0,{0}.0,{0}.0)", step);
|
||||
let cmd = SetFieldCmd::new(&state, e, "Transform", "translation", ron).unwrap();
|
||||
stack.push(cmd, &mut state);
|
||||
}
|
||||
assert_eq!(
|
||||
state.scene.local_transform(e).unwrap().translation,
|
||||
Vec3::splat(5.0)
|
||||
);
|
||||
assert!(stack.undo(&mut state).is_some());
|
||||
assert_eq!(
|
||||
state.scene.local_transform(e).unwrap().translation,
|
||||
Vec3::ZERO
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_field_edits_to_different_fields_do_not_coalesce() {
|
||||
// Editing translation then scale must be two undo steps, not one.
|
||||
let (mut state, e) = state_with_entity();
|
||||
let mut stack: CommandStack<EditorState> = CommandStack::with_capacity(32);
|
||||
stack.push(
|
||||
SetFieldCmd::new(
|
||||
&state,
|
||||
e,
|
||||
"Transform",
|
||||
"translation",
|
||||
"(1.0,0.0,0.0)".into(),
|
||||
)
|
||||
.unwrap(),
|
||||
&mut state,
|
||||
);
|
||||
stack.push(
|
||||
SetFieldCmd::new(&state, e, "Transform", "scale", "(2.0,2.0,2.0)".into()).unwrap(),
|
||||
&mut state,
|
||||
);
|
||||
// Undo reverses scale only.
|
||||
assert!(stack.undo(&mut state).is_some());
|
||||
let t = state.scene.local_transform(e).unwrap();
|
||||
assert_eq!(t.scale, Vec3::ONE);
|
||||
assert_eq!(t.translation, Vec3::new(1.0, 0.0, 0.0));
|
||||
// A second undo reverses translation.
|
||||
assert!(stack.undo(&mut state).is_some());
|
||||
assert_eq!(
|
||||
state.scene.local_transform(e).unwrap().translation,
|
||||
Vec3::ZERO
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_undo_restores_previous_name() {
|
||||
let (mut state, e) = state_with_entity();
|
||||
let cmd = RenameCmd::new(&state, e, "beta".into());
|
||||
let mut stack: CommandStack<EditorState> = CommandStack::with_capacity(32);
|
||||
stack.push(cmd, &mut state);
|
||||
assert_eq!(state.scene.name(e).as_deref(), Some("beta"));
|
||||
|
||||
assert!(stack.undo(&mut state).is_some());
|
||||
assert_eq!(state.scene.name(e).as_deref(), Some("alpha"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
//! Editor console: captures `log` records into a ring buffer the Console panel
|
||||
//! renders.
|
||||
//!
|
||||
//! The engine and modules already speak through the `log` crate — in particular
|
||||
//! the scripting layer routes script `print`/`debug` and "script paused: …"
|
||||
//! errors to `target: "oxide_script"` (see `oxide-script`). This module installs
|
||||
//! a logger that mirrors every record into an in-memory ring buffer *and* still
|
||||
//! forwards it to `env_logger` for the terminal, so the editor's Console panel
|
||||
//! can show script output and errors without the engine knowing about the editor.
|
||||
//!
|
||||
//! The buffer is a process global (the `log` facade allows only one logger, set
|
||||
//! once at startup), reached by the panel through [`log_buffer`] — so wiring it
|
||||
//! in touches neither `Shell::new` nor its many test call sites.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
use log::{Level, Log, Metadata, Record};
|
||||
|
||||
/// How many recent log lines the console keeps. Older lines are dropped.
|
||||
const CAPACITY: usize = 2000;
|
||||
|
||||
/// One captured log record, flattened to what the panel renders.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LogLine {
|
||||
/// Severity, used to colour the line.
|
||||
pub level: Level,
|
||||
/// The record's target (e.g. `oxide_script`), shown dimmed before the text.
|
||||
pub target: String,
|
||||
/// The formatted message.
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// A bounded ring buffer of the most recent [`LogLine`]s.
|
||||
#[derive(Default)]
|
||||
pub struct LogBuffer {
|
||||
lines: VecDeque<LogLine>,
|
||||
}
|
||||
|
||||
impl LogBuffer {
|
||||
/// Appends a line, evicting the oldest if at capacity.
|
||||
fn push(&mut self, line: LogLine) {
|
||||
if self.lines.len() == CAPACITY {
|
||||
self.lines.pop_front();
|
||||
}
|
||||
self.lines.push_back(line);
|
||||
}
|
||||
|
||||
/// Iterates the buffered lines, oldest first.
|
||||
pub fn iter(&self) -> impl Iterator<Item = &LogLine> {
|
||||
self.lines.iter()
|
||||
}
|
||||
|
||||
/// The number of buffered lines.
|
||||
pub fn len(&self) -> usize {
|
||||
self.lines.len()
|
||||
}
|
||||
|
||||
/// Whether the buffer is empty.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.lines.is_empty()
|
||||
}
|
||||
|
||||
/// Drops all buffered lines (the panel's Clear button).
|
||||
pub fn clear(&mut self) {
|
||||
self.lines.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// The process-wide capture buffer, set by [`init`].
|
||||
static LOG_BUFFER: OnceLock<Arc<Mutex<LogBuffer>>> = OnceLock::new();
|
||||
|
||||
/// The shared capture buffer, if logging has been initialised.
|
||||
pub fn log_buffer() -> Option<&'static Arc<Mutex<LogBuffer>>> {
|
||||
LOG_BUFFER.get()
|
||||
}
|
||||
|
||||
/// Appends a line to the console from outside the `log` stream — used by the
|
||||
/// command terminal to echo commands and stream a process's output into the
|
||||
/// same panel. No-op if logging is not initialised.
|
||||
pub fn append(level: Level, target: &str, message: impl Into<String>) {
|
||||
if let Some(buffer) = LOG_BUFFER.get() {
|
||||
if let Ok(mut buffer) = buffer.lock() {
|
||||
buffer.push(LogLine {
|
||||
level,
|
||||
target: target.to_string(),
|
||||
message: message.into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A logger that mirrors records into [`LOG_BUFFER`] and forwards them to an
|
||||
/// inner `env_logger` for the terminal.
|
||||
struct CaptureLogger {
|
||||
inner: env_logger::Logger,
|
||||
buffer: Arc<Mutex<LogBuffer>>,
|
||||
}
|
||||
|
||||
impl Log for CaptureLogger {
|
||||
fn enabled(&self, metadata: &Metadata) -> bool {
|
||||
self.inner.enabled(metadata)
|
||||
}
|
||||
|
||||
fn log(&self, record: &Record) {
|
||||
// Honour the env filter for both the terminal and the buffer, so
|
||||
// RUST_LOG controls the console too.
|
||||
if !self.inner.enabled(record.metadata()) {
|
||||
return;
|
||||
}
|
||||
if let Ok(mut buffer) = self.buffer.lock() {
|
||||
buffer.push(LogLine {
|
||||
level: record.level(),
|
||||
target: record.target().to_string(),
|
||||
message: record.args().to_string(),
|
||||
});
|
||||
}
|
||||
self.inner.log(record);
|
||||
}
|
||||
|
||||
fn flush(&self) {
|
||||
self.inner.flush();
|
||||
}
|
||||
}
|
||||
|
||||
/// Installs the capturing logger and returns the shared buffer. Mirrors the old
|
||||
/// `env_logger` setup (honours `RUST_LOG`, default `info`) but also feeds the
|
||||
/// editor Console. Call once at startup, before any logging.
|
||||
pub fn init() {
|
||||
let inner =
|
||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).build();
|
||||
let max = inner.filter();
|
||||
let buffer = Arc::new(Mutex::new(LogBuffer::default()));
|
||||
let _ = LOG_BUFFER.set(buffer.clone());
|
||||
|
||||
if log::set_boxed_logger(Box::new(CaptureLogger { inner, buffer })).is_ok() {
|
||||
log::set_max_level(max);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ring_buffer_evicts_oldest_past_capacity() {
|
||||
let mut buf = LogBuffer::default();
|
||||
for i in 0..(CAPACITY + 10) {
|
||||
buf.push(LogLine {
|
||||
level: Level::Info,
|
||||
target: "t".into(),
|
||||
message: format!("line {i}"),
|
||||
});
|
||||
}
|
||||
assert_eq!(buf.len(), CAPACITY);
|
||||
// The oldest 10 were evicted, so the first surviving line is "line 10".
|
||||
assert_eq!(buf.iter().next().unwrap().message, "line 10");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_empties_the_buffer() {
|
||||
let mut buf = LogBuffer::default();
|
||||
buf.push(LogLine {
|
||||
level: Level::Warn,
|
||||
target: "t".into(),
|
||||
message: "x".into(),
|
||||
});
|
||||
assert!(!buf.is_empty());
|
||||
buf.clear();
|
||||
assert!(buf.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
//! egui ⇄ engine glue for the editor.
|
||||
//!
|
||||
//! The engine core stays UI-agnostic; all egui wiring lives here in the editor.
|
||||
//! [`EguiLayer`] owns the [`egui_winit`] input state and the [`egui_wgpu`]
|
||||
//! renderer, translates window events, and paints a built UI into the frame's
|
||||
//! surface view (recorded with `LoadOp::Load`, so it composites on top of the
|
||||
//! engine's clear).
|
||||
|
||||
use egui_wgpu::{Renderer, RendererOptions, ScreenDescriptor};
|
||||
use egui_winit::State;
|
||||
use oxide_engine::wgpu;
|
||||
use oxide_engine::winit::event::WindowEvent;
|
||||
use oxide_engine::winit::window::Window;
|
||||
|
||||
/// Holds the egui input state and GPU renderer for one window.
|
||||
pub struct EguiLayer {
|
||||
state: State,
|
||||
renderer: Renderer,
|
||||
}
|
||||
|
||||
impl EguiLayer {
|
||||
/// Creates the layer for `window`, building a renderer that targets the
|
||||
/// given surface format.
|
||||
pub fn new(
|
||||
window: &Window,
|
||||
device: &wgpu::Device,
|
||||
surface_format: wgpu::TextureFormat,
|
||||
) -> Self {
|
||||
let context = egui::Context::default();
|
||||
let state = State::new(
|
||||
context,
|
||||
egui::ViewportId::ROOT,
|
||||
window,
|
||||
Some(window.scale_factor() as f32),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
// Defaults: no MSAA, no depth/stencil, dithering on — matches the
|
||||
// editor's flat clear-color surface.
|
||||
let renderer = Renderer::new(device, surface_format, RendererOptions::default());
|
||||
Self { state, renderer }
|
||||
}
|
||||
|
||||
/// Feeds a window event to egui. Returns `true` if egui consumed it (e.g.
|
||||
/// a click landed on a panel), so the caller can suppress its own handling.
|
||||
pub fn on_window_event(&mut self, window: &Window, event: &WindowEvent) -> bool {
|
||||
self.state.on_window_event(window, event).consumed
|
||||
}
|
||||
|
||||
/// Whether the pointer is currently over a **floating** egui area — a
|
||||
/// `Window` (Preferences, Layer Names, Groups, …) or other non-background
|
||||
/// layer — rather than empty space or the background dock.
|
||||
///
|
||||
/// The viewport is painted under a transparent dock area (background
|
||||
/// order), so a geometric "cursor inside the viewport rect" test can't tell
|
||||
/// that a floating panel is sitting on top of it. The host uses this to
|
||||
/// suppress viewport orbit/pan/zoom (and stray WASD while typing in a panel
|
||||
/// that overlaps the viewport).
|
||||
pub fn pointer_over_floating(&self) -> bool {
|
||||
let ctx = self.state.egui_ctx();
|
||||
let Some(pos) = ctx.pointer_latest_pos() else {
|
||||
return false;
|
||||
};
|
||||
ctx.layer_id_at(pos)
|
||||
.map(|layer| layer.order > egui::Order::Background)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Builds the UI via `build_ui` and paints it into `view`.
|
||||
///
|
||||
/// `build_ui` receives the root [`egui::Ui`]; panels are shown inside it
|
||||
/// (egui 0.34's `show_inside` model). It may be called more than once per
|
||||
/// frame if egui needs an extra layout pass, so it must be idempotent.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn paint(
|
||||
&mut self,
|
||||
window: &Window,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
view: &wgpu::TextureView,
|
||||
size: (u32, u32),
|
||||
build_ui: impl FnMut(&mut egui::Ui),
|
||||
) {
|
||||
let raw_input = self.state.take_egui_input(window);
|
||||
let context = self.state.egui_ctx().clone();
|
||||
let output = context.run_ui(raw_input, build_ui);
|
||||
self.state
|
||||
.handle_platform_output(window, output.platform_output);
|
||||
|
||||
let primitives = context.tessellate(output.shapes, output.pixels_per_point);
|
||||
let screen = ScreenDescriptor {
|
||||
size_in_pixels: [size.0.max(1), size.1.max(1)],
|
||||
pixels_per_point: output.pixels_per_point,
|
||||
};
|
||||
|
||||
for (id, delta) in &output.textures_delta.set {
|
||||
self.renderer.update_texture(device, queue, *id, delta);
|
||||
}
|
||||
|
||||
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
||||
label: Some("oxide.editor.egui.encoder"),
|
||||
});
|
||||
// egui may emit its own command buffers (for paint callbacks); submit
|
||||
// those ahead of our pass.
|
||||
let user_buffers =
|
||||
self.renderer
|
||||
.update_buffers(device, queue, &mut encoder, &primitives, &screen);
|
||||
{
|
||||
let pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("oxide.editor.egui.pass"),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
// Load: keep the engine's clear; draw the UI over it.
|
||||
load: wgpu::LoadOp::Load,
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
depth_stencil_attachment: None,
|
||||
timestamp_writes: None,
|
||||
occlusion_query_set: None,
|
||||
multiview_mask: None,
|
||||
});
|
||||
// egui-wgpu wants a 'static pass; the encoder outlives it here.
|
||||
let mut pass = pass.forget_lifetime();
|
||||
self.renderer.render(&mut pass, &primitives, &screen);
|
||||
}
|
||||
|
||||
for id in &output.textures_delta.free {
|
||||
self.renderer.free_texture(id);
|
||||
}
|
||||
queue.submit(
|
||||
user_buffers
|
||||
.into_iter()
|
||||
.chain(std::iter::once(encoder.finish())),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,524 @@
|
||||
//! The Project panel's file explorer — state and file/database operations.
|
||||
//!
|
||||
//! Stage-10 editor-UX: a Unity-style explorer over the project's `assets/`
|
||||
//! tree. This module holds everything that does **not** touch egui — the
|
||||
//! navigation state, directory listing, and the create/rename/move/delete/
|
||||
//! import operations — so the whole behavior layer is unit-testable and the
|
||||
//! shell only renders it (`ShellTabViewer::assets_explorer`).
|
||||
//!
|
||||
//! Every operation goes through the [`AssetDatabase`] file ops
|
||||
//! (`move_asset`/`move_folder`/`delete_asset`) whenever the touched file is
|
||||
//! registered, so an asset keeps its [`AssetUid`] — and every saved
|
||||
//! `AssetRef` keeps resolving — across any reorganisation. Files the
|
||||
//! database does not know (licenses, notes, …) fall back to plain
|
||||
//! filesystem operations.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use oxide_engine::asset::{AssetDatabase, AssetDbError, AssetKind, AssetUid};
|
||||
|
||||
/// The explorer's persistent UI state (lives on the `Shell`, survives frames).
|
||||
#[derive(Default)]
|
||||
pub struct ExplorerState {
|
||||
/// The folder being viewed, relative to `assets/` (`""` = the root).
|
||||
pub cwd: String,
|
||||
/// An in-progress rename, if any.
|
||||
pub rename: Option<RenameEdit>,
|
||||
/// The in-progress "New Folder" name, `Some` while the inline row shows.
|
||||
pub new_folder: Option<String>,
|
||||
/// One-shot: the next inline text field rendered requests focus (set when
|
||||
/// a rename / new-folder edit starts, taken by the first frame).
|
||||
pub focus_field: bool,
|
||||
}
|
||||
|
||||
impl ExplorerState {
|
||||
/// Navigates to `cwd`, dropping any in-progress inline edits.
|
||||
pub fn navigate(&mut self, cwd: impl Into<String>) {
|
||||
self.cwd = cwd.into();
|
||||
self.rename = None;
|
||||
self.new_folder = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// An in-progress rename of one entry: what is being renamed + the buffer.
|
||||
pub struct RenameEdit {
|
||||
/// The entry's current assets-relative path.
|
||||
pub rel: String,
|
||||
/// Whether it is a folder.
|
||||
pub is_dir: bool,
|
||||
/// The name being typed.
|
||||
pub buf: String,
|
||||
}
|
||||
|
||||
/// One row of the explorer listing.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Entry {
|
||||
/// The leaf name shown in the panel.
|
||||
pub name: String,
|
||||
/// Assets-relative path (forward slashes).
|
||||
pub rel: String,
|
||||
/// Whether this is a folder.
|
||||
pub is_dir: bool,
|
||||
/// The database uid, when the file is registered.
|
||||
pub uid: Option<AssetUid>,
|
||||
/// The registered kind, when the file is registered.
|
||||
pub kind: Option<AssetKind>,
|
||||
}
|
||||
|
||||
/// Lists the folder `cwd` (relative to `assets/`): folders first, then files,
|
||||
/// each group sorted by name. Files are annotated with their database
|
||||
/// uid/kind when registered. A missing folder yields an empty list (the
|
||||
/// assets root may not exist yet in a fresh project).
|
||||
pub fn list_dir(db: &AssetDatabase, cwd: &str) -> Vec<Entry> {
|
||||
let dir = abs_of(db, cwd);
|
||||
let Ok(read) = std::fs::read_dir(&dir) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut folders: Vec<Entry> = Vec::new();
|
||||
let mut files: Vec<Entry> = Vec::new();
|
||||
for item in read.flatten() {
|
||||
let name = item.file_name().to_string_lossy().into_owned();
|
||||
let rel = join_rel(cwd, &name);
|
||||
if item.path().is_dir() {
|
||||
folders.push(Entry {
|
||||
name,
|
||||
rel,
|
||||
is_dir: true,
|
||||
uid: None,
|
||||
kind: None,
|
||||
});
|
||||
} else {
|
||||
let uid = db.uid_of(&rel);
|
||||
let kind = uid.and_then(|u| db.entry(u)).map(|e| e.kind);
|
||||
files.push(Entry {
|
||||
name,
|
||||
rel,
|
||||
is_dir: false,
|
||||
uid,
|
||||
kind,
|
||||
});
|
||||
}
|
||||
}
|
||||
folders.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
files.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
folders.extend(files);
|
||||
folders
|
||||
}
|
||||
|
||||
/// The breadcrumb trail for `cwd`: `(label, cwd-to-navigate-to)` pairs,
|
||||
/// starting at the assets root. `"textures/env"` yields
|
||||
/// `[("assets",""), ("textures","textures"), ("env","textures/env")]`.
|
||||
pub fn breadcrumbs(cwd: &str) -> Vec<(String, String)> {
|
||||
let mut crumbs = vec![("assets".to_owned(), String::new())];
|
||||
let mut path = String::new();
|
||||
for seg in cwd.split('/').filter(|s| !s.is_empty()) {
|
||||
path = join_rel(&path, seg);
|
||||
crumbs.push((seg.to_owned(), path.clone()));
|
||||
}
|
||||
crumbs
|
||||
}
|
||||
|
||||
/// Joins a folder path and a leaf name into an assets-relative path.
|
||||
pub fn join_rel(dir: &str, name: &str) -> String {
|
||||
if dir.is_empty() {
|
||||
name.to_owned()
|
||||
} else {
|
||||
format!("{dir}/{name}")
|
||||
}
|
||||
}
|
||||
|
||||
/// The parent folder of an assets-relative path (`""` at the top).
|
||||
pub fn parent_of(rel: &str) -> String {
|
||||
rel.rsplit_once('/')
|
||||
.map(|(p, _)| p.to_owned())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Whether `name` is usable as a single new file/folder name: non-empty and
|
||||
/// free of path separators / traversal.
|
||||
pub fn valid_name(name: &str) -> bool {
|
||||
!name.is_empty() && name != "." && name != ".." && !name.contains('/') && !name.contains('\\')
|
||||
}
|
||||
|
||||
/// A name that does not exist in `dir` yet, derived from `wanted` by
|
||||
/// suffixing `_2`, `_3`, … before the extension (`wall.png` → `wall_2.png`).
|
||||
pub fn unique_name(dir: &Path, wanted: &str) -> String {
|
||||
if !dir.join(wanted).exists() {
|
||||
return wanted.to_owned();
|
||||
}
|
||||
let (stem, ext) = match wanted.rsplit_once('.') {
|
||||
// A leading dot (".gitignore") is a hidden name, not an extension.
|
||||
Some((s, e)) if !s.is_empty() => (s, Some(e)),
|
||||
_ => (wanted, None),
|
||||
};
|
||||
let mut n = 2;
|
||||
loop {
|
||||
let candidate = match ext {
|
||||
Some(ext) => format!("{stem}_{n}.{ext}"),
|
||||
None => format!("{stem}_{n}"),
|
||||
};
|
||||
if !dir.join(&candidate).exists() {
|
||||
return candidate;
|
||||
}
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a new folder in `cwd` named `wanted` (unique-ified), returning its
|
||||
/// assets-relative path.
|
||||
pub fn create_folder(db: &AssetDatabase, cwd: &str, wanted: &str) -> std::io::Result<String> {
|
||||
if !valid_name(wanted) {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("invalid folder name: {wanted:?}"),
|
||||
));
|
||||
}
|
||||
let dir = abs_of(db, cwd);
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
let name = unique_name(&dir, wanted);
|
||||
std::fs::create_dir(dir.join(&name))?;
|
||||
Ok(join_rel(cwd, &name))
|
||||
}
|
||||
|
||||
/// Renames the entry at `rel` to `new_name` (same folder), returning the new
|
||||
/// relative path. Registered files keep their uid via
|
||||
/// [`AssetDatabase::move_asset`]; folders move every registered entry under
|
||||
/// them via [`AssetDatabase::move_folder`]; unregistered files fall back to a
|
||||
/// plain `fs::rename` (refusing to overwrite).
|
||||
pub fn rename_entry(
|
||||
db: &mut AssetDatabase,
|
||||
rel: &str,
|
||||
is_dir: bool,
|
||||
new_name: &str,
|
||||
) -> Result<String, AssetDbError> {
|
||||
if !valid_name(new_name) {
|
||||
return Err(AssetDbError::InvalidPath(new_name.to_owned()));
|
||||
}
|
||||
let new_rel = join_rel(&parent_of(rel), new_name);
|
||||
if new_rel == rel {
|
||||
return Ok(new_rel);
|
||||
}
|
||||
move_to(db, rel, is_dir, &new_rel)?;
|
||||
Ok(new_rel)
|
||||
}
|
||||
|
||||
/// Moves the entry at `rel` into the folder `dest_dir`, returning the new
|
||||
/// relative path. Same uid-preserving rules as [`rename_entry`].
|
||||
pub fn move_entry(
|
||||
db: &mut AssetDatabase,
|
||||
rel: &str,
|
||||
is_dir: bool,
|
||||
dest_dir: &str,
|
||||
) -> Result<String, AssetDbError> {
|
||||
let name = rel.rsplit('/').next().unwrap_or(rel);
|
||||
let new_rel = join_rel(dest_dir, name);
|
||||
if new_rel == rel {
|
||||
return Ok(new_rel);
|
||||
}
|
||||
move_to(db, rel, is_dir, &new_rel)?;
|
||||
Ok(new_rel)
|
||||
}
|
||||
|
||||
/// Deletes the entry: registered files through the database (entry dropped,
|
||||
/// uid retired), unregistered files from disk, and folders **only when
|
||||
/// empty** — recursive delete of assets is deliberately not offered.
|
||||
pub fn delete_entry(db: &mut AssetDatabase, entry: &Entry) -> Result<(), AssetDbError> {
|
||||
if entry.is_dir {
|
||||
let dir = abs_of(db, &entry.rel);
|
||||
if std::fs::read_dir(&dir)?.next().is_some() {
|
||||
// (`ErrorKind::DirectoryNotEmpty` needs Rust 1.83; the workspace
|
||||
// MSRV is older, so this stays a generic I/O error.)
|
||||
return Err(AssetDbError::Io(std::io::Error::other(format!(
|
||||
"folder not empty: {} (delete its contents first)",
|
||||
entry.rel
|
||||
))));
|
||||
}
|
||||
std::fs::remove_dir(&dir)?;
|
||||
return Ok(());
|
||||
}
|
||||
match entry.uid {
|
||||
Some(uid) => delete_and_save(db, uid),
|
||||
None => Ok(std::fs::remove_file(abs_of(db, &entry.rel))?),
|
||||
}
|
||||
}
|
||||
|
||||
/// Imports files dropped from the OS into `cwd`: each is copied in under a
|
||||
/// collision-free name and registered (kind from the folder, else extension).
|
||||
/// Directories and unreadable sources are skipped with a log line. Returns
|
||||
/// how many files were imported.
|
||||
pub fn import_files(db: &mut AssetDatabase, cwd: &str, sources: &[PathBuf]) -> usize {
|
||||
let dir = abs_of(db, cwd);
|
||||
if std::fs::create_dir_all(&dir).is_err() {
|
||||
return 0;
|
||||
}
|
||||
let mut imported = 0;
|
||||
for src in sources {
|
||||
if src.is_dir() {
|
||||
log::warn!("skipping folder drop {} (import files)", src.display());
|
||||
continue;
|
||||
}
|
||||
let Some(file_name) = src.file_name().map(|n| n.to_string_lossy().into_owned()) else {
|
||||
continue;
|
||||
};
|
||||
let name = unique_name(&dir, &file_name);
|
||||
match std::fs::copy(src, dir.join(&name)) {
|
||||
Ok(_) => {
|
||||
let rel = join_rel(cwd, &name);
|
||||
db.register(&rel);
|
||||
log::info!("imported {rel}");
|
||||
imported += 1;
|
||||
}
|
||||
Err(err) => log::warn!("could not import {}: {err}", src.display()),
|
||||
}
|
||||
}
|
||||
if imported > 0 {
|
||||
if let Err(err) = db.save() {
|
||||
log::warn!("could not write asset manifest: {err}");
|
||||
}
|
||||
}
|
||||
imported
|
||||
}
|
||||
|
||||
// --- internals -----------------------------------------------------------
|
||||
|
||||
/// The absolute path of an assets-relative path under the database's root.
|
||||
fn abs_of(db: &AssetDatabase, rel: &str) -> PathBuf {
|
||||
let assets = db.assets_dir();
|
||||
if rel.is_empty() {
|
||||
assets
|
||||
} else {
|
||||
assets.join(rel.replace('/', std::path::MAIN_SEPARATOR_STR))
|
||||
}
|
||||
}
|
||||
|
||||
/// Routes a rename/move to the right primitive: `move_folder` for folders,
|
||||
/// `move_asset` for registered files, `fs::rename` (no overwrite) for
|
||||
/// unregistered ones. Saves the manifest after a database change.
|
||||
fn move_to(
|
||||
db: &mut AssetDatabase,
|
||||
rel: &str,
|
||||
is_dir: bool,
|
||||
new_rel: &str,
|
||||
) -> Result<(), AssetDbError> {
|
||||
if is_dir {
|
||||
db.move_folder(rel, new_rel)?;
|
||||
save_manifest(db);
|
||||
return Ok(());
|
||||
}
|
||||
match db.uid_of(rel) {
|
||||
Some(uid) => {
|
||||
db.move_asset(uid, new_rel)?;
|
||||
save_manifest(db);
|
||||
Ok(())
|
||||
}
|
||||
None => {
|
||||
let to = abs_of(db, new_rel);
|
||||
if to.exists() {
|
||||
return Err(AssetDbError::DestinationExists(new_rel.to_owned()));
|
||||
}
|
||||
if let Some(parent) = to.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
Ok(std::fs::rename(abs_of(db, rel), to)?)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes a registered asset and persists the manifest.
|
||||
fn delete_and_save(db: &mut AssetDatabase, uid: AssetUid) -> Result<(), AssetDbError> {
|
||||
db.delete_asset(uid)?;
|
||||
save_manifest(db);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Best-effort manifest save after a mutation (failure → Console, not fatal).
|
||||
fn save_manifest(db: &AssetDatabase) {
|
||||
if let Err(err) = db.save() {
|
||||
log::warn!("could not write asset manifest: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
static COUNTER: AtomicU32 = AtomicU32::new(0);
|
||||
|
||||
/// A fresh project root with an `assets/` tree and an open database.
|
||||
fn scratch_db(files: &[&str]) -> (PathBuf, AssetDatabase) {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"oxide_explorer_test_{}_{}",
|
||||
std::process::id(),
|
||||
COUNTER.fetch_add(1, Ordering::SeqCst),
|
||||
));
|
||||
let assets = root.join("assets");
|
||||
std::fs::create_dir_all(&assets).unwrap();
|
||||
for rel in files {
|
||||
let full = assets.join(rel);
|
||||
std::fs::create_dir_all(full.parent().unwrap()).unwrap();
|
||||
std::fs::write(full, b"x").unwrap();
|
||||
}
|
||||
let mut db = AssetDatabase::new(&root);
|
||||
db.scan();
|
||||
(root, db)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn breadcrumbs_and_path_helpers() {
|
||||
assert_eq!(breadcrumbs(""), vec![("assets".to_owned(), String::new())]);
|
||||
assert_eq!(
|
||||
breadcrumbs("textures/env"),
|
||||
vec![
|
||||
("assets".to_owned(), String::new()),
|
||||
("textures".to_owned(), "textures".to_owned()),
|
||||
("env".to_owned(), "textures/env".to_owned()),
|
||||
]
|
||||
);
|
||||
assert_eq!(join_rel("", "a"), "a");
|
||||
assert_eq!(join_rel("a/b", "c"), "a/b/c");
|
||||
assert_eq!(parent_of("a/b/c"), "a/b");
|
||||
assert_eq!(parent_of("a"), "");
|
||||
assert!(valid_name("wall.png"));
|
||||
assert!(!valid_name(""));
|
||||
assert!(!valid_name("a/b"));
|
||||
assert!(!valid_name(".."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_dir_sorts_folders_first_and_annotates_registered_files() {
|
||||
let (root, db) = scratch_db(&["textures/wall.png", "textures/env/sky.png", "notes.md"]);
|
||||
|
||||
let top = list_dir(&db, "");
|
||||
let names: Vec<&str> = top.iter().map(|e| e.name.as_str()).collect();
|
||||
assert_eq!(names, ["textures", "notes.md"]);
|
||||
assert!(top[0].is_dir && top[0].uid.is_none());
|
||||
assert_eq!(top[1].kind, Some(AssetKind::Other));
|
||||
|
||||
let textures = list_dir(&db, "textures");
|
||||
let names: Vec<&str> = textures.iter().map(|e| e.name.as_str()).collect();
|
||||
assert_eq!(names, ["env", "wall.png"]);
|
||||
assert_eq!(textures[1].kind, Some(AssetKind::Texture));
|
||||
assert_eq!(textures[1].uid, db.uid_of("textures/wall.png"));
|
||||
|
||||
// A folder that does not exist lists as empty, not an error.
|
||||
assert!(list_dir(&db, "nope").is_empty());
|
||||
|
||||
std::fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unique_name_suffixes_before_the_extension() {
|
||||
let (root, db) = scratch_db(&["textures/wall.png"]);
|
||||
let dir = db.assets_dir().join("textures");
|
||||
assert_eq!(unique_name(&dir, "new.png"), "new.png");
|
||||
assert_eq!(unique_name(&dir, "wall.png"), "wall_2.png");
|
||||
std::fs::write(dir.join("wall_2.png"), b"x").unwrap();
|
||||
assert_eq!(unique_name(&dir, "wall.png"), "wall_3.png");
|
||||
std::fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_folder_is_unique_and_validated() {
|
||||
let (root, db) = scratch_db(&[]);
|
||||
assert_eq!(create_folder(&db, "", "props").unwrap(), "props");
|
||||
assert_eq!(create_folder(&db, "", "props").unwrap(), "props_2");
|
||||
assert_eq!(create_folder(&db, "props", "env").unwrap(), "props/env");
|
||||
assert!(create_folder(&db, "", "a/b").is_err());
|
||||
std::fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_and_move_preserve_uids() {
|
||||
let (root, mut db) = scratch_db(&["textures/wall.png", "textures/env/sky.png"]);
|
||||
let wall = db.uid_of("textures/wall.png").unwrap();
|
||||
let sky = db.uid_of("textures/env/sky.png").unwrap();
|
||||
|
||||
// Rename a file in place.
|
||||
let new_rel = rename_entry(&mut db, "textures/wall.png", false, "brick.png").unwrap();
|
||||
assert_eq!(new_rel, "textures/brick.png");
|
||||
assert_eq!(db.relative_path(wall), Some("textures/brick.png"));
|
||||
|
||||
// Move it into a sibling folder.
|
||||
let new_rel = move_entry(&mut db, "textures/brick.png", false, "textures/env").unwrap();
|
||||
assert_eq!(new_rel, "textures/env/brick.png");
|
||||
assert_eq!(db.relative_path(wall), Some("textures/env/brick.png"));
|
||||
|
||||
// Rename the folder: both entries follow, uids intact.
|
||||
let new_rel = rename_entry(&mut db, "textures/env", true, "world").unwrap();
|
||||
assert_eq!(new_rel, "textures/world");
|
||||
assert_eq!(db.relative_path(sky), Some("textures/world/sky.png"));
|
||||
assert_eq!(db.relative_path(wall), Some("textures/world/brick.png"));
|
||||
|
||||
// Invalid target name is refused.
|
||||
assert!(rename_entry(&mut db, "textures/world", true, "a/b").is_err());
|
||||
|
||||
// The manifest was persisted along the way.
|
||||
let reloaded = AssetDatabase::open(&root);
|
||||
assert_eq!(reloaded.relative_path(sky), Some("textures/world/sky.png"));
|
||||
|
||||
std::fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unregistered_files_rename_through_the_filesystem() {
|
||||
let (root, mut db) = scratch_db(&[]);
|
||||
// A file the database does not track (e.g. a license dropped next to
|
||||
// a font). Note scratch_db scans, so create it *after*.
|
||||
let assets = db.assets_dir();
|
||||
std::fs::write(assets.join("OFL.txt"), b"x").unwrap();
|
||||
assert!(db.uid_of("OFL.txt").is_none());
|
||||
|
||||
let new_rel = rename_entry(&mut db, "OFL.txt", false, "LICENSE.txt").unwrap();
|
||||
assert_eq!(new_rel, "LICENSE.txt");
|
||||
assert!(assets.join("LICENSE.txt").is_file());
|
||||
assert!(!assets.join("OFL.txt").exists());
|
||||
|
||||
std::fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_rules_files_yes_folders_only_when_empty() {
|
||||
let (root, mut db) = scratch_db(&["textures/wall.png"]);
|
||||
let wall_entry = list_dir(&db, "textures")
|
||||
.into_iter()
|
||||
.find(|e| e.name == "wall.png")
|
||||
.unwrap();
|
||||
let folder_entry = list_dir(&db, "")
|
||||
.into_iter()
|
||||
.find(|e| e.name == "textures")
|
||||
.unwrap();
|
||||
|
||||
// Non-empty folder refused; file deletes (entry + disk); empty folder ok.
|
||||
assert!(delete_entry(&mut db, &folder_entry).is_err());
|
||||
delete_entry(&mut db, &wall_entry).unwrap();
|
||||
assert!(db.uid_of("textures/wall.png").is_none());
|
||||
delete_entry(&mut db, &folder_entry).unwrap();
|
||||
assert!(list_dir(&db, "").is_empty());
|
||||
|
||||
std::fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_copies_registers_and_dodges_collisions() {
|
||||
let (root, mut db) = scratch_db(&["textures/wall.png"]);
|
||||
// Two outside files, one colliding with an existing asset name.
|
||||
let outside = root.join("outside");
|
||||
std::fs::create_dir_all(&outside).unwrap();
|
||||
std::fs::write(outside.join("wall.png"), b"new").unwrap();
|
||||
std::fs::write(outside.join("tree.glb"), b"tree").unwrap();
|
||||
|
||||
let n = import_files(
|
||||
&mut db,
|
||||
"textures",
|
||||
&[outside.join("wall.png"), outside.join("tree.glb")],
|
||||
);
|
||||
assert_eq!(n, 2);
|
||||
assert!(db.uid_of("textures/wall_2.png").is_some());
|
||||
// Kind follows the *folder* it was dropped into.
|
||||
let tree = db.uid_of("textures/tree.glb").unwrap();
|
||||
assert_eq!(db.entry(tree).unwrap().kind, AssetKind::Texture);
|
||||
|
||||
std::fs::remove_dir_all(root).ok();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,655 @@
|
||||
//! Module → editor extension API.
|
||||
//!
|
||||
//! The engine's [`Module`](oxide_engine::app::Module) trait registers systems,
|
||||
//! component types, asset loaders, and resources on an
|
||||
//! [`App`](oxide_engine::app::App). This module is its **editor-side companion**:
|
||||
//! one trait — [`EditorModule`] — through which a module contributes the UI it
|
||||
//! needs the editor to host on its behalf.
|
||||
//!
|
||||
//! Specifically, a module can add:
|
||||
//!
|
||||
//! - **Menu items** in the top menu bar (e.g. `"File/Open Recent"`),
|
||||
//! - **Dockable panels** in the docking shell (e.g. an "Audio Mixer"),
|
||||
//! - **Viewport tools** that take over input on the 3D viewport (gizmos,
|
||||
//! measurement, paint),
|
||||
//! - **Component inspectors** that render rich editors for the module's
|
||||
//! component types (keyed by their
|
||||
//! [`TypeRegistry`](oxide_engine::reflect::TypeRegistry) name), and
|
||||
//! - **Settings pages** that drive the module's
|
||||
//! [`Settings`](oxide_engine::settings::Settings) section in the Preferences
|
||||
//! window.
|
||||
//!
|
||||
//! All five plug into the editor through one registry — [`EditorExtensions`] —
|
||||
//! consumed by the docking shell. The shell never edits its own source to host
|
||||
//! a new module's UI; this is *the* mechanism by which "anyone can write a
|
||||
//! module" that extends both engine logic and the editor.
|
||||
//!
|
||||
//! ## Why a separate trait
|
||||
//!
|
||||
//! The engine has no egui dependency, so the editor hook can't live on the
|
||||
//! engine's `Module` trait without dragging UI types into the engine. Two
|
||||
//! traits implemented on the same struct keeps the engine GUI-free and lets the
|
||||
//! editor binary register the same module on both sides:
|
||||
//!
|
||||
//! ```ignore
|
||||
//! struct MyModule;
|
||||
//! impl oxide_engine::app::Module for MyModule { /* … systems, types */ }
|
||||
//! impl oxide_editor::extension::EditorModule for MyModule { /* … panels */ }
|
||||
//! ```
|
||||
//!
|
||||
//! ## Attribution
|
||||
//!
|
||||
//! Every contribution remembers which module added it. Removing a module
|
||||
//! ([`EditorExtensions::remove_module`]) removes all of its contributions in
|
||||
//! one shot — the same lifecycle the engine's
|
||||
//! [`App::remove_module`](oxide_engine::app::App::remove_module) gives systems,
|
||||
//! types, and loaders. Disabling a module
|
||||
//! ([`set_module_enabled`](EditorExtensions::set_module_enabled)) keeps the
|
||||
//! contributions registered but hides them from the shell, so toggling a
|
||||
//! module in Preferences is reversible without rebuilding the registry.
|
||||
//!
|
||||
//! ## Render closures
|
||||
//!
|
||||
//! Panel / inspector / settings-page closures take only `&mut egui::Ui` in
|
||||
//! Stage 6 piece 5 (registration). Piece 6 — the docking shell — refines the
|
||||
//! signatures to pass through the editor's runtime context. Modules that need
|
||||
//! shared state today should capture it through interior mutability
|
||||
//! (`Rc<RefCell<...>>`).
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// Where a panel prefers to be docked the first time the user opens it.
|
||||
///
|
||||
/// The shell may override this when restoring a saved layout; it is only a
|
||||
/// hint, not a guarantee.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum DockLocation {
|
||||
/// Pinned to the left side of the main area (hierarchies, project browser).
|
||||
Left,
|
||||
/// Pinned to the right side (properties / inspector).
|
||||
Right,
|
||||
/// Pinned to the bottom (console, logs, timeline).
|
||||
Bottom,
|
||||
/// The main central tab area (viewport, code, asset preview).
|
||||
Center,
|
||||
/// A floating window outside the dock layout.
|
||||
Floating,
|
||||
}
|
||||
|
||||
/// One top-menu-bar item contributed by a module.
|
||||
///
|
||||
/// `path` uses `/` as a separator and identifies the menu tree, e.g.
|
||||
/// `"File/New Project"` or `"View/Layout/Default"`. The shell groups items by
|
||||
/// their leading segments.
|
||||
pub struct MenuItem {
|
||||
/// Slash-separated path through the menu tree.
|
||||
pub path: String,
|
||||
/// Optional human-readable shortcut hint (e.g. `"Ctrl+N"`). Not bound by
|
||||
/// this API — the actual key binding lives in the Stage-7 input map.
|
||||
pub shortcut: Option<String>,
|
||||
/// Invoked when the item is clicked. The shell decides when to call it.
|
||||
pub action: Box<dyn FnMut()>,
|
||||
}
|
||||
|
||||
/// A dockable panel contributed by a module.
|
||||
pub struct Panel {
|
||||
/// Stable name; doubles as the tab title and the lookup key.
|
||||
pub name: String,
|
||||
/// Where the panel prefers to dock initially.
|
||||
pub default_dock: DockLocation,
|
||||
/// Renders the panel's contents into `ui` each frame the panel is visible.
|
||||
pub render: Box<dyn FnMut(&mut egui::Ui)>,
|
||||
}
|
||||
|
||||
/// A viewport tool — usually a gizmo or a brush — that takes over the 3D
|
||||
/// viewport's input while active.
|
||||
pub struct ViewportTool {
|
||||
/// Stable name (e.g. `"Translate"`, `"Sculpt"`); identifies the tool in
|
||||
/// menus, toolbars, and shortcut tables.
|
||||
pub name: String,
|
||||
/// Called once when the tool becomes the active viewport tool. Use it to
|
||||
/// reset transient state or hook into the editor's command stack.
|
||||
pub on_activate: Box<dyn FnMut()>,
|
||||
}
|
||||
|
||||
/// An editor for one reflected component type, keyed by the same name the
|
||||
/// component is registered under in the
|
||||
/// [`TypeRegistry`](oxide_engine::reflect::TypeRegistry). The shell calls
|
||||
/// `render` from the Inspector panel when a selected entity has the component.
|
||||
pub struct ComponentInspector {
|
||||
/// Matches the `name` passed to
|
||||
/// [`App::register_type`](oxide_engine::app::App::register_type).
|
||||
pub type_name: String,
|
||||
/// Renders an editor for the component into `ui`.
|
||||
pub render: Box<dyn FnMut(&mut egui::Ui)>,
|
||||
}
|
||||
|
||||
/// A page in the Preferences window driving one
|
||||
/// [`Settings`](oxide_engine::settings::Settings) section.
|
||||
pub struct SettingsPage {
|
||||
/// Matches the `name` passed to
|
||||
/// [`Settings::register`](oxide_engine::settings::Settings::register).
|
||||
pub section_name: String,
|
||||
/// Title shown in the Preferences sidebar (defaults to `section_name` when
|
||||
/// the contributor leaves it empty).
|
||||
pub title: String,
|
||||
/// Renders the page's controls into `ui`.
|
||||
pub render: Box<dyn FnMut(&mut egui::Ui)>,
|
||||
}
|
||||
|
||||
/// Editor-side companion to the engine's
|
||||
/// [`Module`](oxide_engine::app::Module) trait.
|
||||
///
|
||||
/// Implement on the same type that implements `Module` (or on a separate
|
||||
/// editor-only struct) and pass it to
|
||||
/// [`EditorExtensions::add_module`]. Everything `build_editor` registers is
|
||||
/// attributed to this module and can be removed atomically with
|
||||
/// [`EditorExtensions::remove_module`].
|
||||
pub trait EditorModule: 'static {
|
||||
/// A stable, unique name — should match the paired engine `Module::name`
|
||||
/// when both halves describe the same module, so the editor and engine
|
||||
/// agree on enable/disable.
|
||||
fn name(&self) -> &'static str;
|
||||
|
||||
/// Registers UI contributions on `ext`.
|
||||
fn build_editor(&self, ext: &mut EditorExtensions);
|
||||
}
|
||||
|
||||
/// Internal record tying any contribution to its source module and an
|
||||
/// enabled/disabled flag inherited from the module.
|
||||
struct Entry<T> {
|
||||
module: &'static str,
|
||||
value: T,
|
||||
}
|
||||
|
||||
/// Registry of every UI contribution made by every editor module. The docking
|
||||
/// shell reads this in Piece 6 to assemble the menu bar, dock layout, viewport
|
||||
/// toolbox, inspector, and Preferences window.
|
||||
#[derive(Default)]
|
||||
pub struct EditorExtensions {
|
||||
menu_items: Vec<Entry<MenuItem>>,
|
||||
panels: Vec<Entry<Panel>>,
|
||||
viewport_tools: Vec<Entry<ViewportTool>>,
|
||||
inspectors: BTreeMap<String, Entry<ComponentInspector>>,
|
||||
settings_pages: BTreeMap<String, Entry<SettingsPage>>,
|
||||
modules: Vec<&'static str>,
|
||||
enabled: BTreeMap<&'static str, bool>,
|
||||
/// Set only while a module's `build_editor` is running, so individual
|
||||
/// `add_*` helpers can attribute the contribution without taking the
|
||||
/// module name as an argument.
|
||||
current_module: Option<&'static str>,
|
||||
}
|
||||
|
||||
impl EditorExtensions {
|
||||
/// A fresh, empty registry.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Registers `module` and runs its
|
||||
/// [`build_editor`](EditorModule::build_editor). Re-adding a module with
|
||||
/// the same name first removes the old one, so callers don't have to dance
|
||||
/// around stale contributions when reloading.
|
||||
pub fn add_module<M: EditorModule>(&mut self, module: M) {
|
||||
let name = module.name();
|
||||
if self.modules.contains(&name) {
|
||||
self.remove_module(name);
|
||||
}
|
||||
self.modules.push(name);
|
||||
self.enabled.insert(name, true);
|
||||
self.current_module = Some(name);
|
||||
module.build_editor(self);
|
||||
self.current_module = None;
|
||||
}
|
||||
|
||||
/// Removes every contribution registered by the named module. Returns
|
||||
/// whether the module was present.
|
||||
pub fn remove_module(&mut self, name: &str) -> bool {
|
||||
if !self.modules.contains(&name) {
|
||||
return false;
|
||||
}
|
||||
self.menu_items.retain(|e| e.module != name);
|
||||
self.panels.retain(|e| e.module != name);
|
||||
self.viewport_tools.retain(|e| e.module != name);
|
||||
self.inspectors.retain(|_, e| e.module != name);
|
||||
self.settings_pages.retain(|_, e| e.module != name);
|
||||
self.modules.retain(|m| *m != name);
|
||||
self.enabled.remove(name);
|
||||
true
|
||||
}
|
||||
|
||||
/// Whether the named module is currently registered (independent of
|
||||
/// enabled-state).
|
||||
pub fn has_module(&self, name: &str) -> bool {
|
||||
self.modules.contains(&name)
|
||||
}
|
||||
|
||||
/// Toggles whether contributions from the named module are visible to the
|
||||
/// shell. The contributions stay registered so re-enabling is instant.
|
||||
/// Returns whether the module was present.
|
||||
pub fn set_module_enabled(&mut self, name: &str, enabled: bool) -> bool {
|
||||
if let Some(slot) = self.enabled.get_mut(name) {
|
||||
*slot = enabled;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the named module's contributions are currently enabled. Returns
|
||||
/// `false` for unknown modules.
|
||||
pub fn is_module_enabled(&self, name: &str) -> bool {
|
||||
self.enabled.get(name).copied().unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Registered module names, in insertion order.
|
||||
pub fn modules(&self) -> impl Iterator<Item = &'static str> + '_ {
|
||||
self.modules.iter().copied()
|
||||
}
|
||||
|
||||
// --- contribution helpers (called from `build_editor`) -----------------
|
||||
|
||||
/// Adds a menu item. Panics if called outside a module's `build_editor` —
|
||||
/// every contribution must be attributable to some module.
|
||||
pub fn add_menu_item(
|
||||
&mut self,
|
||||
path: impl Into<String>,
|
||||
action: impl FnMut() + 'static,
|
||||
) -> &mut Self {
|
||||
self.add_menu_item_full(MenuItem {
|
||||
path: path.into(),
|
||||
shortcut: None,
|
||||
action: Box::new(action),
|
||||
})
|
||||
}
|
||||
|
||||
/// Adds a menu item with a fully-specified [`MenuItem`] (lets the caller
|
||||
/// set a shortcut hint).
|
||||
pub fn add_menu_item_full(&mut self, item: MenuItem) -> &mut Self {
|
||||
let module = self.expect_module("add_menu_item");
|
||||
self.menu_items.push(Entry {
|
||||
module,
|
||||
value: item,
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
/// Adds a dockable panel. `default_dock` is a placement hint; the shell
|
||||
/// may override when restoring a saved layout.
|
||||
pub fn add_panel(
|
||||
&mut self,
|
||||
name: impl Into<String>,
|
||||
default_dock: DockLocation,
|
||||
render: impl FnMut(&mut egui::Ui) + 'static,
|
||||
) -> &mut Self {
|
||||
let module = self.expect_module("add_panel");
|
||||
self.panels.push(Entry {
|
||||
module,
|
||||
value: Panel {
|
||||
name: name.into(),
|
||||
default_dock,
|
||||
render: Box::new(render),
|
||||
},
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
/// Adds a viewport tool (gizmo, brush, …).
|
||||
pub fn add_viewport_tool(
|
||||
&mut self,
|
||||
name: impl Into<String>,
|
||||
on_activate: impl FnMut() + 'static,
|
||||
) -> &mut Self {
|
||||
let module = self.expect_module("add_viewport_tool");
|
||||
self.viewport_tools.push(Entry {
|
||||
module,
|
||||
value: ViewportTool {
|
||||
name: name.into(),
|
||||
on_activate: Box::new(on_activate),
|
||||
},
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
/// Adds a component inspector keyed by the type's reflection name.
|
||||
/// Re-registering a name overwrites the previous inspector (most-recently-
|
||||
/// added module wins; this lets a project override a base module's
|
||||
/// inspector if it has reason to).
|
||||
pub fn add_inspector(
|
||||
&mut self,
|
||||
type_name: impl Into<String>,
|
||||
render: impl FnMut(&mut egui::Ui) + 'static,
|
||||
) -> &mut Self {
|
||||
let module = self.expect_module("add_inspector");
|
||||
let type_name = type_name.into();
|
||||
self.inspectors.insert(
|
||||
type_name.clone(),
|
||||
Entry {
|
||||
module,
|
||||
value: ComponentInspector {
|
||||
type_name,
|
||||
render: Box::new(render),
|
||||
},
|
||||
},
|
||||
);
|
||||
self
|
||||
}
|
||||
|
||||
/// Adds a Preferences page driving the named settings section.
|
||||
pub fn add_settings_page(
|
||||
&mut self,
|
||||
section_name: impl Into<String>,
|
||||
title: impl Into<String>,
|
||||
render: impl FnMut(&mut egui::Ui) + 'static,
|
||||
) -> &mut Self {
|
||||
let module = self.expect_module("add_settings_page");
|
||||
let section_name = section_name.into();
|
||||
let title = title.into();
|
||||
let title = if title.is_empty() {
|
||||
section_name.clone()
|
||||
} else {
|
||||
title
|
||||
};
|
||||
self.settings_pages.insert(
|
||||
section_name.clone(),
|
||||
Entry {
|
||||
module,
|
||||
value: SettingsPage {
|
||||
section_name,
|
||||
title,
|
||||
render: Box::new(render),
|
||||
},
|
||||
},
|
||||
);
|
||||
self
|
||||
}
|
||||
|
||||
// --- shell-facing lookups ---------------------------------------------
|
||||
|
||||
/// Slash-separated paths of every currently-enabled menu item, in the
|
||||
/// order they were contributed.
|
||||
pub fn menu_item_paths(&self) -> impl Iterator<Item = &str> {
|
||||
self.iter_menu_items().map(|i| i.path.as_str())
|
||||
}
|
||||
|
||||
/// Names of every currently-enabled panel.
|
||||
pub fn panel_names(&self) -> impl Iterator<Item = &str> {
|
||||
self.iter_panels().map(|p| p.name.as_str())
|
||||
}
|
||||
|
||||
/// Names of every currently-enabled viewport tool.
|
||||
pub fn viewport_tool_names(&self) -> impl Iterator<Item = &str> {
|
||||
self.iter_viewport_tools().map(|t| t.name.as_str())
|
||||
}
|
||||
|
||||
/// Reflection-keyed type names that currently have an inspector
|
||||
/// registered.
|
||||
pub fn inspector_type_names(&self) -> impl Iterator<Item = &str> {
|
||||
self.iter_inspectors().map(|i| i.type_name.as_str())
|
||||
}
|
||||
|
||||
/// Settings-section names that currently have a Preferences page
|
||||
/// registered.
|
||||
pub fn settings_page_names(&self) -> impl Iterator<Item = &str> {
|
||||
self.iter_settings_pages().map(|p| p.section_name.as_str())
|
||||
}
|
||||
|
||||
/// Whether an inspector is registered for the given reflection name and
|
||||
/// the contributing module is enabled.
|
||||
pub fn has_inspector_for(&self, type_name: &str) -> bool {
|
||||
self.inspectors
|
||||
.get(type_name)
|
||||
.map(|e| self.is_enabled(e.module))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Whether a Preferences page is registered for the given section name
|
||||
/// and the contributing module is enabled.
|
||||
pub fn has_settings_page_for(&self, section_name: &str) -> bool {
|
||||
self.settings_pages
|
||||
.get(section_name)
|
||||
.map(|e| self.is_enabled(e.module))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Iterates the enabled menu items themselves (gives the shell direct
|
||||
/// access to actions/shortcuts when rendering).
|
||||
pub fn iter_menu_items(&self) -> impl Iterator<Item = &MenuItem> {
|
||||
self.menu_items
|
||||
.iter()
|
||||
.filter(|e| self.is_enabled(e.module))
|
||||
.map(|e| &e.value)
|
||||
}
|
||||
|
||||
/// Mutably iterates the enabled menu items so the shell can invoke each
|
||||
/// item's `FnMut` action when the user clicks it.
|
||||
pub fn iter_menu_items_mut(&mut self) -> impl Iterator<Item = &mut MenuItem> {
|
||||
let enabled = &self.enabled;
|
||||
self.menu_items
|
||||
.iter_mut()
|
||||
.filter(move |e| enabled.get(e.module).copied().unwrap_or(false))
|
||||
.map(|e| &mut e.value)
|
||||
}
|
||||
|
||||
/// Iterates the enabled panels.
|
||||
pub fn iter_panels(&self) -> impl Iterator<Item = &Panel> {
|
||||
self.panels
|
||||
.iter()
|
||||
.filter(|e| self.is_enabled(e.module))
|
||||
.map(|e| &e.value)
|
||||
}
|
||||
|
||||
/// Mutably iterates the enabled panels so the shell can call each panel's
|
||||
/// `FnMut` render closure each frame.
|
||||
pub fn iter_panels_mut(&mut self) -> impl Iterator<Item = &mut Panel> {
|
||||
let enabled = &self.enabled;
|
||||
self.panels
|
||||
.iter_mut()
|
||||
.filter(move |e| enabled.get(e.module).copied().unwrap_or(false))
|
||||
.map(|e| &mut e.value)
|
||||
}
|
||||
|
||||
/// Iterates the enabled viewport tools.
|
||||
pub fn iter_viewport_tools(&self) -> impl Iterator<Item = &ViewportTool> {
|
||||
self.viewport_tools
|
||||
.iter()
|
||||
.filter(|e| self.is_enabled(e.module))
|
||||
.map(|e| &e.value)
|
||||
}
|
||||
|
||||
/// Iterates the enabled component inspectors (in stable name order).
|
||||
pub fn iter_inspectors(&self) -> impl Iterator<Item = &ComponentInspector> {
|
||||
self.inspectors
|
||||
.values()
|
||||
.filter(|e| self.is_enabled(e.module))
|
||||
.map(|e| &e.value)
|
||||
}
|
||||
|
||||
/// Iterates the enabled settings pages (in stable section-name order).
|
||||
pub fn iter_settings_pages(&self) -> impl Iterator<Item = &SettingsPage> {
|
||||
self.settings_pages
|
||||
.values()
|
||||
.filter(|e| self.is_enabled(e.module))
|
||||
.map(|e| &e.value)
|
||||
}
|
||||
|
||||
/// The total number of contributions of every kind, across every
|
||||
/// registered module. Mostly for tests and diagnostics.
|
||||
pub fn contribution_count(&self) -> usize {
|
||||
self.menu_items.len()
|
||||
+ self.panels.len()
|
||||
+ self.viewport_tools.len()
|
||||
+ self.inspectors.len()
|
||||
+ self.settings_pages.len()
|
||||
}
|
||||
|
||||
fn is_enabled(&self, module: &str) -> bool {
|
||||
self.enabled.get(module).copied().unwrap_or(false)
|
||||
}
|
||||
|
||||
fn expect_module(&self, helper: &str) -> &'static str {
|
||||
self.current_module.unwrap_or_else(|| {
|
||||
panic!("EditorExtensions::{helper} called outside a module's build_editor")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A minimal module that exercises every contribution kind. Used both by
|
||||
/// the unit tests here and by the integration test in `tests/src/lib.rs`
|
||||
/// (where it proves the Stage-6 criterion: a module adds a menu item, a
|
||||
/// panel, and a settings page through the public API with no editor-core
|
||||
/// edits).
|
||||
struct DemoModule;
|
||||
impl EditorModule for DemoModule {
|
||||
fn name(&self) -> &'static str {
|
||||
"demo"
|
||||
}
|
||||
fn build_editor(&self, ext: &mut EditorExtensions) {
|
||||
ext.add_menu_item("Demo/Hello", || {});
|
||||
ext.add_panel("Demo Panel", DockLocation::Right, |_ui| {});
|
||||
ext.add_viewport_tool("Demo Tool", || {});
|
||||
ext.add_inspector("DemoComponent", |_ui| {});
|
||||
ext.add_settings_page("demo", "Demo", |_ui| {});
|
||||
}
|
||||
}
|
||||
|
||||
struct OverlapModule;
|
||||
impl EditorModule for OverlapModule {
|
||||
fn name(&self) -> &'static str {
|
||||
"overlap"
|
||||
}
|
||||
fn build_editor(&self, ext: &mut EditorExtensions) {
|
||||
ext.add_menu_item("File/Quit", || {});
|
||||
ext.add_inspector("DemoComponent", |_ui| {});
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_module_registers_each_contribution_kind() {
|
||||
let mut ext = EditorExtensions::new();
|
||||
ext.add_module(DemoModule);
|
||||
|
||||
assert!(ext.has_module("demo"));
|
||||
assert!(ext.is_module_enabled("demo"));
|
||||
assert_eq!(ext.modules().collect::<Vec<_>>(), vec!["demo"]);
|
||||
|
||||
assert_eq!(
|
||||
ext.menu_item_paths().collect::<Vec<_>>(),
|
||||
vec!["Demo/Hello"]
|
||||
);
|
||||
assert_eq!(ext.panel_names().collect::<Vec<_>>(), vec!["Demo Panel"]);
|
||||
assert_eq!(
|
||||
ext.viewport_tool_names().collect::<Vec<_>>(),
|
||||
vec!["Demo Tool"]
|
||||
);
|
||||
assert!(ext.has_inspector_for("DemoComponent"));
|
||||
assert!(ext.has_settings_page_for("demo"));
|
||||
assert_eq!(ext.contribution_count(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_module_drops_every_contribution() {
|
||||
let mut ext = EditorExtensions::new();
|
||||
ext.add_module(DemoModule);
|
||||
assert_eq!(ext.contribution_count(), 5);
|
||||
|
||||
assert!(ext.remove_module("demo"));
|
||||
assert!(!ext.has_module("demo"));
|
||||
assert_eq!(ext.contribution_count(), 0);
|
||||
assert!(!ext.has_inspector_for("DemoComponent"));
|
||||
assert!(!ext.has_settings_page_for("demo"));
|
||||
|
||||
// Removing twice is a no-op.
|
||||
assert!(!ext.remove_module("demo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabling_a_module_hides_its_contributions_without_removing() {
|
||||
let mut ext = EditorExtensions::new();
|
||||
ext.add_module(DemoModule);
|
||||
assert!(ext.set_module_enabled("demo", false));
|
||||
assert!(!ext.is_module_enabled("demo"));
|
||||
|
||||
// Hidden from every shell-facing lookup…
|
||||
assert_eq!(ext.menu_item_paths().count(), 0);
|
||||
assert_eq!(ext.panel_names().count(), 0);
|
||||
assert!(!ext.has_inspector_for("DemoComponent"));
|
||||
assert!(!ext.has_settings_page_for("demo"));
|
||||
// …but still registered, so re-enabling is instant.
|
||||
assert_eq!(ext.contribution_count(), 5);
|
||||
|
||||
assert!(ext.set_module_enabled("demo", true));
|
||||
assert_eq!(ext.panel_names().count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn re_adding_a_module_replaces_its_contributions() {
|
||||
let mut ext = EditorExtensions::new();
|
||||
ext.add_module(DemoModule);
|
||||
ext.add_module(DemoModule);
|
||||
// Still one module, contributions are not duplicated.
|
||||
assert_eq!(ext.modules().collect::<Vec<_>>(), vec!["demo"]);
|
||||
assert_eq!(ext.contribution_count(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn later_module_overrides_inspector_for_same_type() {
|
||||
// Both modules register an inspector for "DemoComponent". The
|
||||
// last-registered wins, but attribution remains correct: removing the
|
||||
// override exposes nothing (the original was overwritten, not
|
||||
// stacked), which is the simple-and-predictable behavior to ship for
|
||||
// piece 5. Stacking would let a project layer multiple inspectors on
|
||||
// one type — possible future refinement, not needed now.
|
||||
let mut ext = EditorExtensions::new();
|
||||
ext.add_module(DemoModule);
|
||||
ext.add_module(OverlapModule);
|
||||
|
||||
assert!(ext.has_inspector_for("DemoComponent"));
|
||||
let owners: Vec<&'static str> = ext.inspectors.values().map(|e| e.module).collect();
|
||||
assert_eq!(owners, vec!["overlap"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modules_dont_see_each_others_contributions_when_disabled() {
|
||||
let mut ext = EditorExtensions::new();
|
||||
ext.add_module(DemoModule);
|
||||
ext.add_module(OverlapModule);
|
||||
|
||||
// Two menu items total; disabling overlap hides only its item.
|
||||
assert_eq!(ext.menu_item_paths().count(), 2);
|
||||
ext.set_module_enabled("overlap", false);
|
||||
let visible: Vec<&str> = ext.menu_item_paths().collect();
|
||||
assert_eq!(visible, vec!["Demo/Hello"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "outside a module's build_editor")]
|
||||
fn contributing_outside_build_editor_panics() {
|
||||
// Catches the easy mistake of calling add_panel on a bare
|
||||
// EditorExtensions — every contribution must be attributable to a
|
||||
// module, otherwise remove_module would leave orphans behind.
|
||||
let mut ext = EditorExtensions::new();
|
||||
ext.add_panel("Orphan", DockLocation::Center, |_ui| {});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_page_defaults_title_to_section_name() {
|
||||
struct M;
|
||||
impl EditorModule for M {
|
||||
fn name(&self) -> &'static str {
|
||||
"m"
|
||||
}
|
||||
fn build_editor(&self, ext: &mut EditorExtensions) {
|
||||
ext.add_settings_page("audio", "", |_ui| {});
|
||||
}
|
||||
}
|
||||
let mut ext = EditorExtensions::new();
|
||||
ext.add_module(M);
|
||||
let page = ext.iter_settings_pages().next().unwrap();
|
||||
assert_eq!(page.title, "audio");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,886 @@
|
||||
//! Transform gizmo math: hit testing, drag projection, and snap rounding.
|
||||
//!
|
||||
//! Stage 7 piece 6 (a): rays in, transforms out. The viewport piece
|
||||
//! renders the handles and feeds rays into [`hit_test`] and
|
||||
//! [`apply_drag`]; this module owns the geometry so all of it can be
|
||||
//! unit-tested without a window.
|
||||
//!
|
||||
//! Three modes ([`GizmoMode`]) each expose a small set of [`GizmoHandle`]s:
|
||||
//!
|
||||
//! - **Translate** — one axis arrow per world axis, plus three "plane
|
||||
//! quads" (XY/XZ/YZ) that drag along two axes at once.
|
||||
//! - **Rotate** — one circle per world axis, dragged around its normal.
|
||||
//! - **Scale** — one axis cube per world axis (non-uniform along that
|
||||
//! axis) plus one center handle for uniform scale.
|
||||
//!
|
||||
//! Holding the snap modifier rounds the drag result to a configurable
|
||||
//! step ([`SnapSettings`]): grid distance for translate, angle for
|
||||
//! rotate, factor step for scale. Snap is applied to the *delta* from
|
||||
//! the drag's starting transform, never to the starting transform
|
||||
//! itself, so the result lines up with a fresh selection that already
|
||||
//! sits between grid points.
|
||||
//!
|
||||
//! The gizmo lives at the entity's translation (its rotation and scale
|
||||
//! do not transform the handles — they always point along world axes).
|
||||
//! The shipped viewport renders this "world-space" gizmo; a future
|
||||
//! "local-space" toggle would orient the handles by the entity rotation
|
||||
//! before hit testing, which is a small change in [`world_axis`] /
|
||||
//! [`world_plane`].
|
||||
|
||||
use oxide_engine::math::{Plane, Quat, Ray, Transform, Vec3};
|
||||
|
||||
/// Which transform tool the gizmo is showing.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum GizmoMode {
|
||||
/// Axis arrows + plane quads. Hotkey **W**.
|
||||
Translate,
|
||||
/// Axis circles. Hotkey **E**.
|
||||
Rotate,
|
||||
/// Axis cubes + center uniform. Hotkey **R**.
|
||||
Scale,
|
||||
}
|
||||
|
||||
impl GizmoMode {
|
||||
/// The label shown in the status bar / toolbar.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
GizmoMode::Translate => "Translate",
|
||||
GizmoMode::Rotate => "Rotate",
|
||||
GizmoMode::Scale => "Scale",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One of the three world axes.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Axis3 {
|
||||
X,
|
||||
Y,
|
||||
Z,
|
||||
}
|
||||
|
||||
impl Axis3 {
|
||||
/// All three axes in stable order.
|
||||
pub const ALL: [Axis3; 3] = [Axis3::X, Axis3::Y, Axis3::Z];
|
||||
|
||||
/// Unit vector along this axis.
|
||||
pub fn unit(self) -> Vec3 {
|
||||
match self {
|
||||
Axis3::X => Vec3::X,
|
||||
Axis3::Y => Vec3::Y,
|
||||
Axis3::Z => Vec3::Z,
|
||||
}
|
||||
}
|
||||
|
||||
/// Zero-based index for indexing into per-component arrays.
|
||||
pub fn index(self) -> usize {
|
||||
match self {
|
||||
Axis3::X => 0,
|
||||
Axis3::Y => 1,
|
||||
Axis3::Z => 2,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One of the three world-aligned planes (XY = plane whose normal is Z, …).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PlaneAxis {
|
||||
XY,
|
||||
XZ,
|
||||
YZ,
|
||||
}
|
||||
|
||||
impl PlaneAxis {
|
||||
/// All three planes in stable order.
|
||||
pub const ALL: [PlaneAxis; 3] = [PlaneAxis::XY, PlaneAxis::XZ, PlaneAxis::YZ];
|
||||
|
||||
/// Unit normal to the plane.
|
||||
pub fn normal(self) -> Vec3 {
|
||||
match self {
|
||||
PlaneAxis::XY => Vec3::Z,
|
||||
PlaneAxis::XZ => Vec3::Y,
|
||||
PlaneAxis::YZ => Vec3::X,
|
||||
}
|
||||
}
|
||||
|
||||
/// The two axes that lie in this plane (in stable order).
|
||||
pub fn axes(self) -> (Vec3, Vec3) {
|
||||
match self {
|
||||
PlaneAxis::XY => (Vec3::X, Vec3::Y),
|
||||
PlaneAxis::XZ => (Vec3::X, Vec3::Z),
|
||||
PlaneAxis::YZ => (Vec3::Y, Vec3::Z),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One interactive gizmo handle.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum GizmoHandle {
|
||||
TranslateAxis(Axis3),
|
||||
TranslatePlane(PlaneAxis),
|
||||
RotateAxis(Axis3),
|
||||
ScaleAxis(Axis3),
|
||||
/// The center "uniform scale" cube.
|
||||
ScaleUniform,
|
||||
}
|
||||
|
||||
impl GizmoHandle {
|
||||
/// The mode this handle belongs to.
|
||||
pub fn mode(self) -> GizmoMode {
|
||||
match self {
|
||||
GizmoHandle::TranslateAxis(_) | GizmoHandle::TranslatePlane(_) => GizmoMode::Translate,
|
||||
GizmoHandle::RotateAxis(_) => GizmoMode::Rotate,
|
||||
GizmoHandle::ScaleAxis(_) | GizmoHandle::ScaleUniform => GizmoMode::Scale,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Snap step sizes applied during a drag while the snap modifier is held.
|
||||
///
|
||||
/// Each step is applied to the **delta** the drag has accumulated — never
|
||||
/// to the starting transform — so a selection that already sits between
|
||||
/// grid points keeps its starting offset.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct SnapSettings {
|
||||
/// Translation grid in world units (default `0.25`).
|
||||
pub distance: f32,
|
||||
/// Rotation step in degrees (default `15`).
|
||||
pub angle_deg: f32,
|
||||
/// Scale step (default `0.1` — factors round to the nearest `0.1`).
|
||||
pub scale: f32,
|
||||
}
|
||||
|
||||
impl Default for SnapSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
distance: 0.25,
|
||||
angle_deg: 15.0,
|
||||
scale: 0.1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One in-progress gizmo drag.
|
||||
///
|
||||
/// Created by the viewport when the user clicks a handle, kept alive while
|
||||
/// the button is held, and dropped on release. Each frame the viewport
|
||||
/// calls [`apply_drag`] with the new pointer ray to compute the new
|
||||
/// transform.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct GizmoDrag {
|
||||
/// The handle the user grabbed.
|
||||
pub handle: GizmoHandle,
|
||||
/// The entity's transform when the drag started — never mutated; the
|
||||
/// drag computes a delta from this and applies it fresh each frame.
|
||||
pub start_transform: Transform,
|
||||
/// The world-space point where the drag began. For an axis handle
|
||||
/// this is the closest point on the axis to the click ray; for a
|
||||
/// plane handle, the ray-plane intersection; for a circle handle,
|
||||
/// the projection of the ray hit onto the rotation plane.
|
||||
pub start_anchor: Vec3,
|
||||
/// Handle-specific reference scalar set at drag start. For
|
||||
/// [`GizmoHandle::ScaleUniform`] it is the world-space distance that
|
||||
/// corresponds to one *factor* of change — the gizmo size — so a
|
||||
/// drag away from the entity by that much grows the scale by ~1.0.
|
||||
/// Unused (set to `1.0`) for every other handle.
|
||||
pub reference: f32,
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Hit testing
|
||||
// =====================================================================
|
||||
|
||||
/// Tries every handle the given mode exposes and returns the one closest
|
||||
/// to `ray`, or `None` if none are within `pixel_tolerance_world` of any
|
||||
/// handle. `gizmo_size` is the per-axis world length of the arrow / cube
|
||||
/// handles; both inputs are computed by the viewport based on the
|
||||
/// camera's distance to the gizmo origin (so the gizmo stays the same
|
||||
/// pixel size at any zoom).
|
||||
pub fn hit_test(
|
||||
ray: &Ray,
|
||||
transform: &Transform,
|
||||
mode: GizmoMode,
|
||||
gizmo_size: f32,
|
||||
pixel_tolerance_world: f32,
|
||||
) -> Option<GizmoHandle> {
|
||||
let origin = transform.translation;
|
||||
let mut best: Option<(f32, GizmoHandle)> = None;
|
||||
let mut consider = |dist_sq: f32, handle: GizmoHandle| {
|
||||
if dist_sq.is_finite() && dist_sq < pixel_tolerance_world * pixel_tolerance_world {
|
||||
match best {
|
||||
Some((b, _)) if b <= dist_sq => {}
|
||||
_ => best = Some((dist_sq, handle)),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
match mode {
|
||||
GizmoMode::Translate => {
|
||||
for axis in Axis3::ALL {
|
||||
let d = axis_segment_distance_sq(origin, axis.unit(), gizmo_size, ray);
|
||||
consider(d, GizmoHandle::TranslateAxis(axis));
|
||||
}
|
||||
for plane in PlaneAxis::ALL {
|
||||
if let Some(d) = plane_quad_distance_sq(origin, plane, gizmo_size, ray) {
|
||||
consider(d, GizmoHandle::TranslatePlane(plane));
|
||||
}
|
||||
}
|
||||
}
|
||||
GizmoMode::Rotate => {
|
||||
for axis in Axis3::ALL {
|
||||
if let Some(d) = circle_distance_sq(origin, axis.unit(), gizmo_size, ray) {
|
||||
consider(d, GizmoHandle::RotateAxis(axis));
|
||||
}
|
||||
}
|
||||
}
|
||||
GizmoMode::Scale => {
|
||||
for axis in Axis3::ALL {
|
||||
let d = axis_segment_distance_sq(origin, axis.unit(), gizmo_size, ray);
|
||||
consider(d, GizmoHandle::ScaleAxis(axis));
|
||||
}
|
||||
// Uniform handle: the center cube.
|
||||
let d = ray.distance_to_point(origin).powi(2);
|
||||
consider(d, GizmoHandle::ScaleUniform);
|
||||
}
|
||||
}
|
||||
|
||||
best.map(|(_, h)| h)
|
||||
}
|
||||
|
||||
/// Squared distance from `ray` to the segment from `origin + axis * inner`
|
||||
/// to `origin + axis * length`, with the closest point clamped to the
|
||||
/// segment. Used for axis arrows.
|
||||
///
|
||||
/// The leading `inner` offset (~20% of length) keeps the segment clear of
|
||||
/// the central cube area, so a ray that pierces the gizmo's center is
|
||||
/// claimed by the uniform / center handle rather than by every axis at
|
||||
/// once.
|
||||
fn axis_segment_distance_sq(origin: Vec3, axis: Vec3, length: f32, ray: &Ray) -> f32 {
|
||||
let inner = length * 0.2;
|
||||
let pt_on_axis = closest_point_on_line(origin, axis, ray);
|
||||
let along = (pt_on_axis - origin).dot(axis).clamp(inner, length);
|
||||
let clamped = origin + axis * along;
|
||||
ray.distance_to_point(clamped).powi(2)
|
||||
}
|
||||
|
||||
/// Distance² from `ray` to a square plane quad at `origin` (size × size),
|
||||
/// or `None` when the ray is parallel to the plane. Used for translate
|
||||
/// plane handles.
|
||||
fn plane_quad_distance_sq(origin: Vec3, plane: PlaneAxis, size: f32, ray: &Ray) -> Option<f32> {
|
||||
let p = Plane::from_point_normal(origin, plane.normal());
|
||||
let t = p.ray_intersection(ray)?;
|
||||
let hit = ray.at(t);
|
||||
let (a, b) = plane.axes();
|
||||
// The plane quad spans roughly the *outer* part of the gizmo: from
|
||||
// ~0.3*size to ~0.7*size on each axis, away from the central cube
|
||||
// and clear of the axis arrows.
|
||||
let inner = size * 0.3;
|
||||
let outer = size * 0.7;
|
||||
let da = (hit - origin).dot(a);
|
||||
let db = (hit - origin).dot(b);
|
||||
if da >= inner && da <= outer && db >= inner && db <= outer {
|
||||
// Inside the quad — perfect hit, no distance penalty.
|
||||
Some(0.0)
|
||||
} else {
|
||||
// Outside — penalize by distance from the nearest edge so handle
|
||||
// priority degrades smoothly with miss distance.
|
||||
let clamped = origin + a * da.clamp(inner, outer) + b * db.clamp(inner, outer);
|
||||
Some(ray.distance_to_point(clamped).powi(2))
|
||||
}
|
||||
}
|
||||
|
||||
/// Distance² from `ray` to the circle of radius `r` lying in the plane
|
||||
/// through `origin` with the given `axis` as normal, or `None` when the
|
||||
/// ray is parallel to the plane. Used for rotate circles.
|
||||
fn circle_distance_sq(origin: Vec3, axis: Vec3, r: f32, ray: &Ray) -> Option<f32> {
|
||||
let p = Plane::from_point_normal(origin, axis);
|
||||
let t = p.ray_intersection(ray)?;
|
||||
let hit = ray.at(t);
|
||||
// Project onto the plane and find the closest circle point.
|
||||
let v = hit - origin;
|
||||
let in_plane = v - axis * v.dot(axis);
|
||||
let len = in_plane.length();
|
||||
if len < 1e-6 {
|
||||
// Right at the center — distance to circle is `r` itself.
|
||||
return Some(r * r);
|
||||
}
|
||||
let on_circle = origin + in_plane * (r / len);
|
||||
Some(ray.distance_to_point(on_circle).powi(2))
|
||||
}
|
||||
|
||||
/// Closest point on the line through `origin` along the unit `dir`
|
||||
/// vector to `ray`. Result is unconstrained — clamping to a segment is
|
||||
/// the caller's job.
|
||||
pub fn closest_point_on_line(origin: Vec3, dir: Vec3, ray: &Ray) -> Vec3 {
|
||||
let r = ray.direction;
|
||||
let w = origin - ray.origin;
|
||||
let d = dir.dot(r);
|
||||
let denom = 1.0 - d * d;
|
||||
if denom.abs() < 1e-6 {
|
||||
// Ray parallel to line — closest point on the line is the origin.
|
||||
return origin;
|
||||
}
|
||||
let s = (dir.dot(-w) - r.dot(-w) * d) / denom;
|
||||
origin + dir * s
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Drag application
|
||||
// =====================================================================
|
||||
|
||||
/// Applies the in-progress `drag` to its starting transform using the
|
||||
/// pointer's current ray, returning the new transform. Pure: same inputs
|
||||
/// always yield the same output.
|
||||
///
|
||||
/// When `snap` is `Some`, the per-mode delta is rounded to the appropriate
|
||||
/// step before being applied (so the snap modifier can be toggled mid-
|
||||
/// drag and the result lines up to the grid regardless of how the user
|
||||
/// got there).
|
||||
pub fn apply_drag(drag: &GizmoDrag, current_ray: &Ray, snap: Option<&SnapSettings>) -> Transform {
|
||||
match drag.handle {
|
||||
GizmoHandle::TranslateAxis(axis) => translate_along_axis(drag, current_ray, axis, snap),
|
||||
GizmoHandle::TranslatePlane(plane) => translate_in_plane(drag, current_ray, plane, snap),
|
||||
GizmoHandle::RotateAxis(axis) => rotate_around_axis(drag, current_ray, axis, snap),
|
||||
GizmoHandle::ScaleAxis(axis) => scale_along_axis(drag, current_ray, axis, snap),
|
||||
GizmoHandle::ScaleUniform => scale_uniform(drag, current_ray, snap),
|
||||
}
|
||||
}
|
||||
|
||||
/// Rounds `value` to the nearest integer multiple of `step`. Returns
|
||||
/// `value` unchanged when `step` is non-positive.
|
||||
pub fn snap_round(value: f32, step: f32) -> f32 {
|
||||
if step <= 0.0 {
|
||||
return value;
|
||||
}
|
||||
(value / step).round() * step
|
||||
}
|
||||
|
||||
fn translate_along_axis(
|
||||
drag: &GizmoDrag,
|
||||
current_ray: &Ray,
|
||||
axis: Axis3,
|
||||
snap: Option<&SnapSettings>,
|
||||
) -> Transform {
|
||||
let dir = axis.unit();
|
||||
let now = closest_point_on_line(drag.start_transform.translation, dir, current_ray);
|
||||
let mut delta = (now - drag.start_anchor).dot(dir);
|
||||
if let Some(s) = snap {
|
||||
delta = snap_round(delta, s.distance);
|
||||
}
|
||||
let mut t = drag.start_transform;
|
||||
t.translation += dir * delta;
|
||||
t
|
||||
}
|
||||
|
||||
fn translate_in_plane(
|
||||
drag: &GizmoDrag,
|
||||
current_ray: &Ray,
|
||||
plane: PlaneAxis,
|
||||
snap: Option<&SnapSettings>,
|
||||
) -> Transform {
|
||||
let p = Plane::from_point_normal(drag.start_transform.translation, plane.normal());
|
||||
let Some(t) = p.ray_intersection(current_ray) else {
|
||||
return drag.start_transform;
|
||||
};
|
||||
let now = current_ray.at(t);
|
||||
let (a, b) = plane.axes();
|
||||
let mut da = (now - drag.start_anchor).dot(a);
|
||||
let mut db = (now - drag.start_anchor).dot(b);
|
||||
if let Some(s) = snap {
|
||||
da = snap_round(da, s.distance);
|
||||
db = snap_round(db, s.distance);
|
||||
}
|
||||
let mut out = drag.start_transform;
|
||||
out.translation += a * da + b * db;
|
||||
out
|
||||
}
|
||||
|
||||
fn rotate_around_axis(
|
||||
drag: &GizmoDrag,
|
||||
current_ray: &Ray,
|
||||
axis: Axis3,
|
||||
snap: Option<&SnapSettings>,
|
||||
) -> Transform {
|
||||
let axis_dir = axis.unit();
|
||||
let origin = drag.start_transform.translation;
|
||||
let plane = Plane::from_point_normal(origin, axis_dir);
|
||||
let Some(t) = plane.ray_intersection(current_ray) else {
|
||||
return drag.start_transform;
|
||||
};
|
||||
let now = current_ray.at(t);
|
||||
// Vectors from origin to start / current points, both already lying
|
||||
// in the rotation plane.
|
||||
let from = (drag.start_anchor - origin).normalize_or_zero();
|
||||
let to = (now - origin).normalize_or_zero();
|
||||
if from.length_squared() < 1e-6 || to.length_squared() < 1e-6 {
|
||||
return drag.start_transform;
|
||||
}
|
||||
// Signed angle around `axis_dir`.
|
||||
let cross = from.cross(to);
|
||||
let sin = cross.dot(axis_dir);
|
||||
let cos = from.dot(to).clamp(-1.0, 1.0);
|
||||
let mut angle = sin.atan2(cos);
|
||||
if let Some(s) = snap {
|
||||
let step = s.angle_deg.to_radians();
|
||||
angle = snap_round(angle, step);
|
||||
}
|
||||
let rotation = Quat::from_axis_angle(axis_dir, angle);
|
||||
let mut out = drag.start_transform;
|
||||
out.rotation = rotation * drag.start_transform.rotation;
|
||||
out
|
||||
}
|
||||
|
||||
fn scale_along_axis(
|
||||
drag: &GizmoDrag,
|
||||
current_ray: &Ray,
|
||||
axis: Axis3,
|
||||
snap: Option<&SnapSettings>,
|
||||
) -> Transform {
|
||||
let dir = axis.unit();
|
||||
let origin = drag.start_transform.translation;
|
||||
let now = closest_point_on_line(origin, dir, current_ray);
|
||||
let start_along = (drag.start_anchor - origin).dot(dir);
|
||||
if start_along.abs() < 1e-4 {
|
||||
return drag.start_transform;
|
||||
}
|
||||
let now_along = (now - origin).dot(dir);
|
||||
let mut factor = now_along / start_along;
|
||||
if let Some(s) = snap {
|
||||
factor = snap_round(factor, s.scale);
|
||||
}
|
||||
// Clamp to a small positive floor so a runaway drag can't flip scale
|
||||
// to zero / negative (which crashes inverse-transform math elsewhere).
|
||||
factor = factor.max(0.001);
|
||||
let mut out = drag.start_transform;
|
||||
let mut s = drag.start_transform.scale.to_array();
|
||||
s[axis.index()] *= factor;
|
||||
out.scale = Vec3::from_array(s);
|
||||
out
|
||||
}
|
||||
|
||||
fn scale_uniform(drag: &GizmoDrag, current_ray: &Ray, snap: Option<&SnapSettings>) -> Transform {
|
||||
let origin = drag.start_transform.translation;
|
||||
// Perpendicular distance from the current ray to the entity, in world
|
||||
// units. The *delta* from the click's perpendicular distance, divided
|
||||
// by `drag.reference` (the gizmo size), is the additive change in
|
||||
// scale factor. Avoids the previous `now_dist / start_dist` formula's
|
||||
// blow-up when the click landed near the gizmo center (start_dist
|
||||
// ≈ 0) and the divide spiked the factor.
|
||||
let start_perp = (drag.start_anchor - origin).length();
|
||||
let now_perp = current_ray.distance_to_point(origin);
|
||||
let reference = drag.reference.max(1e-4);
|
||||
let mut factor = 1.0 + (now_perp - start_perp) / reference;
|
||||
if let Some(s) = snap {
|
||||
factor = snap_round(factor, s.scale);
|
||||
}
|
||||
// Floor at a small positive value so a runaway drag past the origin
|
||||
// can't flip scale negative (which crashes inverse-transform math).
|
||||
factor = factor.max(0.001);
|
||||
let mut out = drag.start_transform;
|
||||
out.scale = drag.start_transform.scale * factor;
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use oxide_engine::math::Vec3;
|
||||
use std::f32::consts::FRAC_PI_2;
|
||||
|
||||
fn id_transform_at(p: Vec3) -> Transform {
|
||||
Transform {
|
||||
translation: p,
|
||||
rotation: Quat::IDENTITY,
|
||||
scale: Vec3::ONE,
|
||||
}
|
||||
}
|
||||
|
||||
// --- Hit testing ----------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn hit_test_picks_translate_axis_under_cursor() {
|
||||
// Camera looking straight down -Z at origin.
|
||||
let ray = Ray::new(Vec3::new(0.5, 0.0, 5.0), Vec3::new(0.0, 0.0, -1.0));
|
||||
let hit = hit_test(
|
||||
&ray,
|
||||
&id_transform_at(Vec3::ZERO),
|
||||
GizmoMode::Translate,
|
||||
1.0,
|
||||
0.1,
|
||||
);
|
||||
assert_eq!(hit, Some(GizmoHandle::TranslateAxis(Axis3::X)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hit_test_picks_translate_plane_inside_quad() {
|
||||
let ray = Ray::new(Vec3::new(0.5, 0.5, 5.0), Vec3::new(0.0, 0.0, -1.0));
|
||||
let hit = hit_test(
|
||||
&ray,
|
||||
&id_transform_at(Vec3::ZERO),
|
||||
GizmoMode::Translate,
|
||||
1.0,
|
||||
0.1,
|
||||
);
|
||||
assert_eq!(hit, Some(GizmoHandle::TranslatePlane(PlaneAxis::XY)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hit_test_picks_rotate_circle_on_radius() {
|
||||
// Camera looking down +X, so the rotate-X circle is in YZ plane.
|
||||
// Aim at a point on that circle of radius 1.
|
||||
let ray = Ray::new(Vec3::new(5.0, 1.0, 0.0), Vec3::new(-1.0, 0.0, 0.0));
|
||||
let hit = hit_test(
|
||||
&ray,
|
||||
&id_transform_at(Vec3::ZERO),
|
||||
GizmoMode::Rotate,
|
||||
1.0,
|
||||
0.1,
|
||||
);
|
||||
assert_eq!(hit, Some(GizmoHandle::RotateAxis(Axis3::X)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hit_test_misses_when_ray_far_from_handles() {
|
||||
let ray = Ray::new(Vec3::new(50.0, 50.0, 5.0), Vec3::new(0.0, 0.0, -1.0));
|
||||
let hit = hit_test(
|
||||
&ray,
|
||||
&id_transform_at(Vec3::ZERO),
|
||||
GizmoMode::Translate,
|
||||
1.0,
|
||||
0.1,
|
||||
);
|
||||
assert!(hit.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hit_test_picks_scale_uniform_at_center() {
|
||||
let ray = Ray::new(Vec3::ZERO + Vec3::Z * 5.0, -Vec3::Z);
|
||||
let hit = hit_test(
|
||||
&ray,
|
||||
&id_transform_at(Vec3::ZERO),
|
||||
GizmoMode::Scale,
|
||||
1.0,
|
||||
0.1,
|
||||
);
|
||||
assert_eq!(hit, Some(GizmoHandle::ScaleUniform));
|
||||
}
|
||||
|
||||
// --- Translate drag -------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn translate_axis_drag_moves_along_axis_only() {
|
||||
let start = id_transform_at(Vec3::ZERO);
|
||||
let drag = GizmoDrag {
|
||||
handle: GizmoHandle::TranslateAxis(Axis3::X),
|
||||
start_transform: start,
|
||||
start_anchor: Vec3::ZERO,
|
||||
reference: 1.0,
|
||||
};
|
||||
// Ray that closest-approaches X at x = 3.
|
||||
let cur = Ray::new(Vec3::new(3.0, 0.0, 5.0), Vec3::new(0.0, 0.0, -1.0));
|
||||
let out = apply_drag(&drag, &cur, None);
|
||||
assert!((out.translation.x - 3.0).abs() < 1e-4);
|
||||
assert!(out.translation.y.abs() < 1e-4);
|
||||
assert!(out.translation.z.abs() < 1e-4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn translate_axis_snap_rounds_to_distance_step() {
|
||||
let start = id_transform_at(Vec3::ZERO);
|
||||
let drag = GizmoDrag {
|
||||
handle: GizmoHandle::TranslateAxis(Axis3::X),
|
||||
start_transform: start,
|
||||
start_anchor: Vec3::ZERO,
|
||||
reference: 1.0,
|
||||
};
|
||||
let cur = Ray::new(Vec3::new(0.74, 0.0, 5.0), Vec3::new(0.0, 0.0, -1.0));
|
||||
let snap = SnapSettings {
|
||||
distance: 0.25,
|
||||
..SnapSettings::default()
|
||||
};
|
||||
let out = apply_drag(&drag, &cur, Some(&snap));
|
||||
// 0.74 rounds to 0.75.
|
||||
assert!((out.translation.x - 0.75).abs() < 1e-4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn translate_plane_drag_moves_in_both_axes() {
|
||||
let start = id_transform_at(Vec3::ZERO);
|
||||
let drag = GizmoDrag {
|
||||
handle: GizmoHandle::TranslatePlane(PlaneAxis::XY),
|
||||
start_transform: start,
|
||||
start_anchor: Vec3::ZERO,
|
||||
reference: 1.0,
|
||||
};
|
||||
let cur = Ray::new(Vec3::new(2.0, 3.0, 5.0), Vec3::new(0.0, 0.0, -1.0));
|
||||
let out = apply_drag(&drag, &cur, None);
|
||||
assert!((out.translation.x - 2.0).abs() < 1e-4);
|
||||
assert!((out.translation.y - 3.0).abs() < 1e-4);
|
||||
assert!(out.translation.z.abs() < 1e-4);
|
||||
}
|
||||
|
||||
// --- Rotate drag ----------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn rotate_around_x_axis_produces_quarter_turn() {
|
||||
let start = id_transform_at(Vec3::ZERO);
|
||||
// Click at the +Y point on the YZ circle.
|
||||
let drag = GizmoDrag {
|
||||
handle: GizmoHandle::RotateAxis(Axis3::X),
|
||||
start_transform: start,
|
||||
start_anchor: Vec3::new(0.0, 1.0, 0.0),
|
||||
reference: 1.0,
|
||||
};
|
||||
// Drag to the +Z point — 90° around +X (right-hand rule from +Y → +Z).
|
||||
let cur = Ray::new(Vec3::new(5.0, 0.0, 1.0), Vec3::new(-1.0, 0.0, 0.0));
|
||||
let out = apply_drag(&drag, &cur, None);
|
||||
// Apply the rotation to Y and confirm it lands on Z.
|
||||
let rotated = out.rotation * Vec3::Y;
|
||||
assert!((rotated - Vec3::Z).length() < 1e-4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotate_snap_rounds_to_angle_step() {
|
||||
let start = id_transform_at(Vec3::ZERO);
|
||||
let drag = GizmoDrag {
|
||||
handle: GizmoHandle::RotateAxis(Axis3::X),
|
||||
start_transform: start,
|
||||
start_anchor: Vec3::new(0.0, 1.0, 0.0),
|
||||
reference: 1.0,
|
||||
};
|
||||
// Drag to ~89°: should snap to 90° with a 15° step.
|
||||
let cur = Ray::new(Vec3::new(5.0, 0.0175, 0.9998), Vec3::new(-1.0, 0.0, 0.0));
|
||||
let snap = SnapSettings {
|
||||
angle_deg: 15.0,
|
||||
..SnapSettings::default()
|
||||
};
|
||||
let out = apply_drag(&drag, &cur, Some(&snap));
|
||||
let rotated = out.rotation * Vec3::Y;
|
||||
// A 90° rotation around X maps Y → Z exactly.
|
||||
assert!(
|
||||
(rotated - Vec3::Z).length() < 1e-3,
|
||||
"expected snap to 90°, got {rotated:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotate_no_movement_returns_start_transform() {
|
||||
let start = id_transform_at(Vec3::ZERO);
|
||||
let drag = GizmoDrag {
|
||||
handle: GizmoHandle::RotateAxis(Axis3::Y),
|
||||
start_transform: start,
|
||||
start_anchor: Vec3::new(1.0, 0.0, 0.0),
|
||||
reference: 1.0,
|
||||
};
|
||||
// Ray pointing back at the anchor (no rotation).
|
||||
let cur = Ray::new(Vec3::new(5.0, 0.0, 0.0), Vec3::new(-1.0, 0.0, 0.0));
|
||||
let out = apply_drag(&drag, &cur, None);
|
||||
// Quaternion should be ~identity.
|
||||
let rotated = out.rotation * Vec3::Z;
|
||||
assert!((rotated - Vec3::Z).length() < 1e-3);
|
||||
}
|
||||
|
||||
// --- Scale drag -----------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn scale_axis_doubles_when_pointer_moves_to_2x_anchor() {
|
||||
let start = id_transform_at(Vec3::ZERO);
|
||||
let drag = GizmoDrag {
|
||||
handle: GizmoHandle::ScaleAxis(Axis3::X),
|
||||
start_transform: start,
|
||||
start_anchor: Vec3::new(1.0, 0.0, 0.0),
|
||||
reference: 1.0,
|
||||
};
|
||||
let cur = Ray::new(Vec3::new(2.0, 0.0, 5.0), Vec3::new(0.0, 0.0, -1.0));
|
||||
let out = apply_drag(&drag, &cur, None);
|
||||
assert!((out.scale.x - 2.0).abs() < 1e-4);
|
||||
assert!((out.scale.y - 1.0).abs() < 1e-4);
|
||||
assert!((out.scale.z - 1.0).abs() < 1e-4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_axis_floors_at_small_positive_value() {
|
||||
let start = id_transform_at(Vec3::ZERO);
|
||||
let drag = GizmoDrag {
|
||||
handle: GizmoHandle::ScaleAxis(Axis3::Y),
|
||||
start_transform: start,
|
||||
start_anchor: Vec3::new(0.0, 1.0, 0.0),
|
||||
reference: 1.0,
|
||||
};
|
||||
// Drag well past the origin — would naively give factor = -3.
|
||||
let cur = Ray::new(Vec3::new(0.0, -3.0, 5.0), Vec3::new(0.0, 0.0, -1.0));
|
||||
let out = apply_drag(&drag, &cur, None);
|
||||
// Clamped to a small positive floor — never negative.
|
||||
assert!(out.scale.y > 0.0);
|
||||
assert!(out.scale.y < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_uniform_doubles_along_every_axis() {
|
||||
let mut start = id_transform_at(Vec3::ZERO);
|
||||
start.scale = Vec3::new(1.0, 2.0, 3.0);
|
||||
// With reference = 1.0, dragging the perpendicular distance from
|
||||
// 1.0 (the start anchor) to 2.0 grows the factor by exactly 1.0.
|
||||
let drag = GizmoDrag {
|
||||
handle: GizmoHandle::ScaleUniform,
|
||||
start_transform: start,
|
||||
start_anchor: Vec3::new(1.0, 0.0, 0.0),
|
||||
reference: 1.0,
|
||||
};
|
||||
let cur = Ray::new(Vec3::new(2.0, 0.0, 5.0), Vec3::new(0.0, 0.0, -1.0));
|
||||
let out = apply_drag(&drag, &cur, None);
|
||||
assert!((out.scale.x - 2.0).abs() < 1e-4);
|
||||
assert!((out.scale.y - 4.0).abs() < 1e-4);
|
||||
assert!((out.scale.z - 6.0).abs() < 1e-4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_uniform_is_not_supersensitive_when_click_lands_near_center() {
|
||||
// The old `now_dist / start_dist` formula blew up when a click
|
||||
// landed near the gizmo center (start_dist ≈ 0). The new formula
|
||||
// is additive in the perpendicular delta, so a tiny start_dist
|
||||
// does not amplify the factor.
|
||||
let start = id_transform_at(Vec3::ZERO);
|
||||
let drag = GizmoDrag {
|
||||
handle: GizmoHandle::ScaleUniform,
|
||||
start_transform: start,
|
||||
// Click landed near the center (perp distance 0.05).
|
||||
start_anchor: Vec3::new(0.05, 0.0, 0.0),
|
||||
reference: 1.0,
|
||||
};
|
||||
// Drag the pointer to a new perp distance of 0.5 (so delta = 0.45).
|
||||
let cur = Ray::new(Vec3::new(0.5, 0.0, 5.0), Vec3::new(0.0, 0.0, -1.0));
|
||||
let out = apply_drag(&drag, &cur, None);
|
||||
// factor = 1.0 + 0.45 / 1.0 = 1.45 — gentle. The old formula would
|
||||
// give 0.5 / 0.05 = 10.0, which is what the maintainer reported.
|
||||
assert!(
|
||||
(out.scale.x - 1.45).abs() < 1e-3,
|
||||
"expected gentle factor 1.45, got scale {:?}",
|
||||
out.scale
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_uniform_snap_rounds_factor() {
|
||||
// Reported by the maintainer: uniform-scale snap did nothing. The
|
||||
// old formula's runaway factor swamped the snap step; the new
|
||||
// additive formula puts the factor in a sane range so snap_round
|
||||
// can hit a sensible step.
|
||||
let start = id_transform_at(Vec3::ZERO);
|
||||
let drag = GizmoDrag {
|
||||
handle: GizmoHandle::ScaleUniform,
|
||||
start_transform: start,
|
||||
start_anchor: Vec3::new(0.5, 0.0, 0.0),
|
||||
reference: 1.0,
|
||||
};
|
||||
// Pointer at perp distance ~1.32 → factor 1 + (1.32 - 0.5) = 1.82
|
||||
// → snaps to 1.8 (step 0.1).
|
||||
let cur = Ray::new(Vec3::new(1.32, 0.0, 5.0), Vec3::new(0.0, 0.0, -1.0));
|
||||
let snap = SnapSettings {
|
||||
scale: 0.1,
|
||||
..SnapSettings::default()
|
||||
};
|
||||
let out = apply_drag(&drag, &cur, Some(&snap));
|
||||
assert!(
|
||||
(out.scale.x - 1.8).abs() < 1e-3,
|
||||
"uniform-scale snap should round 1.82 to 1.8, got {:?}",
|
||||
out.scale
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_snap_rounds_factor_to_step() {
|
||||
let start = id_transform_at(Vec3::ZERO);
|
||||
let drag = GizmoDrag {
|
||||
handle: GizmoHandle::ScaleAxis(Axis3::X),
|
||||
start_transform: start,
|
||||
start_anchor: Vec3::new(1.0, 0.0, 0.0),
|
||||
reference: 1.0,
|
||||
};
|
||||
// Pointer at 1.83 → factor 1.83 → snaps to 1.8 (step 0.1).
|
||||
let cur = Ray::new(Vec3::new(1.83, 0.0, 5.0), Vec3::new(0.0, 0.0, -1.0));
|
||||
let snap = SnapSettings {
|
||||
scale: 0.1,
|
||||
..SnapSettings::default()
|
||||
};
|
||||
let out = apply_drag(&drag, &cur, Some(&snap));
|
||||
assert!((out.scale.x - 1.8).abs() < 1e-4);
|
||||
}
|
||||
|
||||
// --- Helpers --------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn closest_point_on_axis_recovers_perpendicular_drop() {
|
||||
let ray = Ray::new(Vec3::new(3.0, 4.0, 5.0), Vec3::new(0.0, 0.0, -1.0));
|
||||
let pt = closest_point_on_line(Vec3::ZERO, Vec3::X, &ray);
|
||||
// Drop a perpendicular onto the X axis — should land at (3, 0, 0).
|
||||
assert!((pt - Vec3::new(3.0, 0.0, 0.0)).length() < 1e-4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closest_point_on_axis_handles_parallel_ray() {
|
||||
// Ray along X overlaps the X axis exactly — returns the axis origin.
|
||||
let ray = Ray::new(Vec3::new(0.0, 2.0, 0.0), Vec3::X);
|
||||
let pt = closest_point_on_line(Vec3::ZERO, Vec3::X, &ray);
|
||||
assert_eq!(pt, Vec3::ZERO);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snap_round_to_step() {
|
||||
assert_eq!(snap_round(0.74, 0.25), 0.75);
|
||||
assert_eq!(snap_round(0.12, 0.25), 0.0);
|
||||
assert_eq!(snap_round(-0.74, 0.25), -0.75);
|
||||
// Zero / negative step disables snapping.
|
||||
assert_eq!(snap_round(0.74, 0.0), 0.74);
|
||||
assert_eq!(snap_round(0.74, -0.5), 0.74);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gizmo_handle_maps_to_mode() {
|
||||
assert_eq!(
|
||||
GizmoHandle::TranslateAxis(Axis3::X).mode(),
|
||||
GizmoMode::Translate
|
||||
);
|
||||
assert_eq!(
|
||||
GizmoHandle::TranslatePlane(PlaneAxis::XY).mode(),
|
||||
GizmoMode::Translate
|
||||
);
|
||||
assert_eq!(GizmoHandle::RotateAxis(Axis3::Z).mode(), GizmoMode::Rotate);
|
||||
assert_eq!(GizmoHandle::ScaleAxis(Axis3::Y).mode(), GizmoMode::Scale);
|
||||
assert_eq!(GizmoHandle::ScaleUniform.mode(), GizmoMode::Scale);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn axis3_unit_and_index_align() {
|
||||
for axis in Axis3::ALL {
|
||||
let unit = axis.unit();
|
||||
let idx = axis.index();
|
||||
let mut expected = [0.0; 3];
|
||||
expected[idx] = 1.0;
|
||||
assert_eq!(unit.to_array(), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plane_axis_normal_is_orthogonal_to_its_axes() {
|
||||
for plane in PlaneAxis::ALL {
|
||||
let n = plane.normal();
|
||||
let (a, b) = plane.axes();
|
||||
assert!(n.dot(a).abs() < 1e-6);
|
||||
assert!(n.dot(b).abs() < 1e-6);
|
||||
// The two axes within the plane are also orthogonal.
|
||||
assert!(a.dot(b).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
// The rotation around X used FRAC_PI_2 indirectly via 90° axis drag.
|
||||
// This second test just confirms a clean 90° around Y matches the
|
||||
// expected matrix-applied direction.
|
||||
#[test]
|
||||
fn rotate_y_90_maps_x_to_minus_z() {
|
||||
// Manually construct a 90° Y rotation and confirm orientation.
|
||||
let q = Quat::from_axis_angle(Vec3::Y, FRAC_PI_2);
|
||||
let v = q * Vec3::X;
|
||||
assert!((v - Vec3::new(0.0, 0.0, -1.0)).length() < 1e-4);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
//! Oxide Editor — framework library.
|
||||
//!
|
||||
//! The editor is built as a library of reusable, testable framework pieces plus
|
||||
//! a thin binary (`src/main.rs`) that wires them into a window. Stage 6 grows
|
||||
//! this library into the editor *framework*: an undo/redo command stack, a
|
||||
//! project system, a settings/preferences framework, a module→editor extension
|
||||
//! API, and the docking shell.
|
||||
//!
|
||||
//! Keeping the framework here (rather than in the binary) means each piece is
|
||||
//! unit-tested in isolation, and the binary stays a small amount of glue.
|
||||
|
||||
#![deny(warnings)]
|
||||
|
||||
pub mod assets;
|
||||
pub mod bindings;
|
||||
pub mod command;
|
||||
pub mod commands;
|
||||
pub mod console;
|
||||
pub mod explorer;
|
||||
pub mod extension;
|
||||
pub mod gizmo;
|
||||
pub mod play;
|
||||
pub mod preferences;
|
||||
pub mod pty;
|
||||
pub mod shell;
|
||||
pub mod state;
|
||||
pub mod terminal;
|
||||
@@ -0,0 +1,720 @@
|
||||
//! Oxide Editor — entry point.
|
||||
//!
|
||||
//! The in-engine editor is built as a first-class part of the Oxide project.
|
||||
//! It grows alongside the engine, gaining new panels and tools at each stage.
|
||||
//!
|
||||
//! Stage 6 wires the framework pieces (command stack, project system, settings
|
||||
//! framework, extension API, file watcher) into a docking
|
||||
//! [`Shell`](oxide_editor::shell::Shell). The shell hosts the hierarchy,
|
||||
//! inspector, viewport, project browser, and console as resizable dockable
|
||||
//! panels under a top menu bar + bottom status bar, with a Preferences window
|
||||
//! driven by `Settings`. This binary is glue: window/event loop, the 3D
|
||||
//! viewport renderer + camera, and the egui paint pump.
|
||||
|
||||
#![deny(warnings)]
|
||||
|
||||
mod egui_layer;
|
||||
mod viewport;
|
||||
|
||||
use egui_layer::EguiLayer;
|
||||
use oxide_editor::bindings::action;
|
||||
use oxide_editor::commands::SetTransformCmd;
|
||||
use oxide_editor::gizmo::{self, GizmoDrag, GizmoMode};
|
||||
use oxide_editor::play::{self, Tick};
|
||||
use oxide_editor::{preferences, shell::Shell};
|
||||
use oxide_engine::app::{App, DefaultModules};
|
||||
use oxide_engine::prelude::*;
|
||||
use oxide_engine::window::event::{
|
||||
ElementState, KeyCode, ModifiersState, MouseButton, MouseScrollDelta, PhysicalKey, WindowEvent,
|
||||
};
|
||||
use oxide_engine::window::RenderCtx;
|
||||
use viewport::{CameraMode, Viewport};
|
||||
|
||||
/// World-space length of the gizmo arrows / handles, scaled per-frame by
|
||||
/// camera distance so the gizmo stays roughly the same pixel size at any
|
||||
/// zoom level. The pure-logic gizmo math is agnostic to this scale — it
|
||||
/// just takes whatever value the host passes.
|
||||
const GIZMO_SCREEN_HEIGHT_FRACTION: f32 = 0.13;
|
||||
|
||||
/// Pixel-distance threshold for a gizmo handle to count as "hit" by a
|
||||
/// click. Converted to world units per-frame using the camera distance so
|
||||
/// the same screen tolerance applies at any zoom.
|
||||
const GIZMO_HIT_PIXEL_TOLERANCE: f32 = 10.0;
|
||||
|
||||
/// Background color of the 3D viewport (dark neutral gray).
|
||||
const VIEWPORT_CLEAR: Color = Color::rgb(0.08, 0.08, 0.10);
|
||||
|
||||
struct EditorApp {
|
||||
shell: Shell,
|
||||
egui_layer: Option<EguiLayer>,
|
||||
viewport: Option<Viewport>,
|
||||
/// The play-mode runtime (Stage 8.7). `Some` exactly while the editor is
|
||||
/// playing or paused: built when Play starts (engine `App` + default
|
||||
/// modules), ticked each frame, and dropped when Stop returns to editing.
|
||||
/// The editor's scene is swapped into it for each tick and back out again,
|
||||
/// so `shell.state.scene` stays the single source of truth between frames.
|
||||
play_app: Option<App>,
|
||||
modifiers: ModifiersState,
|
||||
/// Last cursor position (physical px), for computing drag deltas.
|
||||
last_cursor: Option<(f32, f32)>,
|
||||
/// Left mouse held over the viewport — orbit (or pick on release).
|
||||
orbiting: bool,
|
||||
/// Right/middle mouse held over the viewport — pan (orbit mode) or
|
||||
/// look around (flythrough mode); the camera-mode dispatch happens in
|
||||
/// the cursor-moved handler.
|
||||
panning: bool,
|
||||
/// Accumulated cursor travel since the left press, to tell a click (select)
|
||||
/// from a drag (orbit).
|
||||
left_drag_dist: f32,
|
||||
}
|
||||
|
||||
impl EditorApp {
|
||||
fn new() -> Self {
|
||||
let mut shell = Shell::new();
|
||||
// Defaults are registered by EditorState::new; layer any saved user
|
||||
// remap from `~/.config/oxide/editor.ron` on top before the first
|
||||
// input poll runs.
|
||||
if let Some(saved) = preferences::load() {
|
||||
shell.state.settings.import(&saved);
|
||||
shell.state.apply_action_overrides_from_settings();
|
||||
}
|
||||
Self {
|
||||
shell,
|
||||
egui_layer: None,
|
||||
viewport: None,
|
||||
play_app: None,
|
||||
modifiers: ModifiersState::empty(),
|
||||
last_cursor: None,
|
||||
orbiting: false,
|
||||
panning: false,
|
||||
left_drag_dist: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Constructs the world-space cursor ray using the active viewport
|
||||
/// camera + the Viewport tab's sub-rect. Returns `None` if the viewport
|
||||
/// hasn't been initialized yet or the last cursor is unknown.
|
||||
fn cursor_ray(&self, size: (u32, u32)) -> Option<oxide_engine::math::Ray> {
|
||||
let cursor = self.last_cursor?;
|
||||
let vp = self.viewport.as_ref()?;
|
||||
Some(vp.ray_from_cursor(cursor, size, self.shell.viewport_rect()))
|
||||
}
|
||||
|
||||
/// World-space gizmo size that the maths uses for both rendering and
|
||||
/// hit testing. Scaled by the camera's distance to the selection so the
|
||||
/// gizmo keeps a stable pixel size at any zoom level.
|
||||
fn gizmo_world_size(&self, target: oxide_engine::math::Vec3) -> f32 {
|
||||
let Some(vp) = self.viewport.as_ref() else {
|
||||
return 1.0;
|
||||
};
|
||||
let eye = match vp.mode {
|
||||
CameraMode::Orbit => vp.orbit.view_transform().translation,
|
||||
CameraMode::Flythrough => vp.flythrough.position,
|
||||
};
|
||||
let d = (target - eye).length().max(0.1);
|
||||
d * GIZMO_SCREEN_HEIGHT_FRACTION
|
||||
}
|
||||
|
||||
/// Tries to start a gizmo drag at the cursor. Returns `true` if a
|
||||
/// handle was hit (so the caller can skip orbit/look for this click).
|
||||
fn try_begin_gizmo_drag(&mut self, size: (u32, u32)) -> bool {
|
||||
let Some(selected) = self.shell.state.selected else {
|
||||
return false;
|
||||
};
|
||||
let Some(transform) = self.shell.state.scene.world_transform(selected) else {
|
||||
return false;
|
||||
};
|
||||
let Some(ray) = self.cursor_ray(size) else {
|
||||
return false;
|
||||
};
|
||||
let world_size = self.gizmo_world_size(transform.translation);
|
||||
// Hit tolerance is a fixed pixel size; convert to world units the
|
||||
// same way the gizmo size is scaled (the math is approximate but
|
||||
// good enough for the few-pixel target zone).
|
||||
let tolerance = world_size * (GIZMO_HIT_PIXEL_TOLERANCE / 100.0);
|
||||
let mode = self.shell.state.gizmo.mode;
|
||||
let Some(handle) = gizmo::hit_test(&ray, &transform, mode, world_size, tolerance) else {
|
||||
return false;
|
||||
};
|
||||
// Compute the drag's start anchor — the point on the engaged
|
||||
// handle the click corresponds to. Mirrors what `apply_drag`
|
||||
// expects on subsequent frames.
|
||||
let start_anchor = match handle {
|
||||
gizmo::GizmoHandle::TranslateAxis(axis) | gizmo::GizmoHandle::ScaleAxis(axis) => {
|
||||
gizmo::closest_point_on_line(transform.translation, axis.unit(), &ray)
|
||||
}
|
||||
gizmo::GizmoHandle::TranslatePlane(plane) => {
|
||||
let p = oxide_engine::math::Plane::from_point_normal(
|
||||
transform.translation,
|
||||
plane.normal(),
|
||||
);
|
||||
p.ray_intersection(&ray)
|
||||
.map(|t| ray.at(t))
|
||||
.unwrap_or(transform.translation)
|
||||
}
|
||||
gizmo::GizmoHandle::RotateAxis(axis) => {
|
||||
let p = oxide_engine::math::Plane::from_point_normal(
|
||||
transform.translation,
|
||||
axis.unit(),
|
||||
);
|
||||
p.ray_intersection(&ray)
|
||||
.map(|t| ray.at(t))
|
||||
.unwrap_or(transform.translation)
|
||||
}
|
||||
gizmo::GizmoHandle::ScaleUniform => ray.closest_point(transform.translation),
|
||||
};
|
||||
// The uniform-scale handle uses `reference` as the world distance
|
||||
// corresponding to one factor of change — match it to the gizmo
|
||||
// size so dragging by ~one arm's length doubles the scale.
|
||||
let reference = if matches!(handle, gizmo::GizmoHandle::ScaleUniform) {
|
||||
world_size
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
self.shell.state.gizmo.drag = Some(GizmoDrag {
|
||||
handle,
|
||||
start_transform: transform,
|
||||
start_anchor,
|
||||
reference,
|
||||
});
|
||||
true
|
||||
}
|
||||
|
||||
/// Updates the in-progress drag against the current cursor position,
|
||||
/// applying the new transform directly to the selected entity. The
|
||||
/// command stack is only touched on release; intermediate frames just
|
||||
/// mutate the scene so the gizmo follows the pointer fluidly.
|
||||
fn advance_gizmo_drag(&mut self, size: (u32, u32), cursor: (f32, f32)) {
|
||||
let Some(drag) = self.shell.state.gizmo.drag else {
|
||||
return;
|
||||
};
|
||||
let Some(selected) = self.shell.state.selected else {
|
||||
return;
|
||||
};
|
||||
let Some(vp) = self.viewport.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let ray = vp.ray_from_cursor(cursor, size, self.shell.viewport_rect());
|
||||
// Ctrl-held → snap; the snap settings live on the editor state so
|
||||
// a future preferences page can tune the steps.
|
||||
let snap = self
|
||||
.modifiers
|
||||
.control_key()
|
||||
.then_some(&self.shell.state.gizmo.snap);
|
||||
let next = gizmo::apply_drag(&drag, &ray, snap);
|
||||
// Drag math operates in world space (start_transform was the
|
||||
// entity's *world* transform); for an entity with parents the
|
||||
// computed `next` lives in world space too, so writing it as the
|
||||
// local transform is only exact when the entity has no parent.
|
||||
// Hierarchy-aware gizmo math is a refinement for a later piece.
|
||||
self.shell.state.scene.set_local_transform(selected, next);
|
||||
}
|
||||
|
||||
/// Commits the in-progress drag (if any) by pushing a `SetTransformCmd`
|
||||
/// onto the command stack and clearing the drag — making the whole
|
||||
/// drag one undo entry.
|
||||
fn end_gizmo_drag(&mut self) {
|
||||
let Some(drag) = self.shell.state.gizmo.drag.take() else {
|
||||
return;
|
||||
};
|
||||
let Some(selected) = self.shell.state.selected else {
|
||||
return;
|
||||
};
|
||||
let Some(after) = self.shell.state.scene.local_transform(selected) else {
|
||||
return;
|
||||
};
|
||||
// Skip the command when nothing actually changed (the user clicked
|
||||
// a handle but didn't drag).
|
||||
let before = drag.start_transform;
|
||||
if before == after {
|
||||
return;
|
||||
}
|
||||
self.shell.push_command(SetTransformCmd {
|
||||
entity: selected,
|
||||
before,
|
||||
after,
|
||||
});
|
||||
}
|
||||
|
||||
/// Builds the per-frame [`ViewportOverlay`] the Shell's Viewport tab paints
|
||||
/// every world-space overlay with (transform gizmo, collider wireframes, the
|
||||
/// raycast probe). `None` only when the viewport isn't initialized yet — the
|
||||
/// `view_proj` is always available, so colliders/probe show without a
|
||||
/// selection; `gizmo_size` falls back to a unit when nothing is selected
|
||||
/// (the gizmo itself isn't painted then, so the value is unused there).
|
||||
fn build_gizmo_overlay(
|
||||
&self,
|
||||
size: (u32, u32),
|
||||
rect: Option<oxide_engine::math::Rect>,
|
||||
) -> Option<oxide_editor::shell::ViewportOverlay> {
|
||||
let vp = self.viewport.as_ref()?;
|
||||
let gizmo_size = self
|
||||
.shell
|
||||
.state
|
||||
.selected
|
||||
.and_then(|e| self.shell.state.scene.world_transform(e))
|
||||
.map(|t| self.gizmo_world_size(t.translation))
|
||||
.unwrap_or(1.0);
|
||||
Some(oxide_editor::shell::ViewportOverlay {
|
||||
view_proj: vp.view_projection_for(rect, size),
|
||||
gizmo_size,
|
||||
})
|
||||
}
|
||||
|
||||
/// **Freezes** a raycast probe: casts the editor camera→cursor ray against
|
||||
/// the *edited* scene's colliders right now and stores the result on the
|
||||
/// Shell so the Viewport tab keeps drawing it in world space (Stage 9 piece
|
||||
/// 8c). Because the ray is frozen into the world, orbiting the camera reveals
|
||||
/// it as a real 3D line — a ray cast from the live camera is otherwise just a
|
||||
/// point in that same camera's view. Builds a transient [`PhysicsWorld`] from
|
||||
/// the scene via [`sync_to_scene`](oxide_physics::PhysicsWorld::sync_to_scene)
|
||||
/// so the probe reflects unsaved edits without requiring Play. No-op if the
|
||||
/// cursor or viewport is unavailable or the ray is degenerate.
|
||||
fn cast_probe_ray(&mut self, size: (u32, u32)) {
|
||||
use oxide_editor::shell::{RaycastProbeHit, RaycastProbeViz};
|
||||
let rect = self.shell.viewport_rect();
|
||||
let Some(cursor) = self.last_cursor else {
|
||||
return;
|
||||
};
|
||||
let Some(vp) = self.viewport.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let ray = vp.ray_from_cursor(cursor, size, rect);
|
||||
if ray.direction == oxide_engine::math::Vec3::ZERO {
|
||||
return;
|
||||
}
|
||||
|
||||
const PROBE_DISTANCE: f32 = 1000.0;
|
||||
let mut world = oxide_physics::PhysicsWorld::new();
|
||||
world.sync_to_scene(&self.shell.state.scene);
|
||||
let hit = world.raycast(
|
||||
ray.origin,
|
||||
ray.direction,
|
||||
PROBE_DISTANCE,
|
||||
oxide_engine::layer::LayerMask::ALL,
|
||||
);
|
||||
// Surface a one-line result so the cast gives feedback even before the
|
||||
// user orbits to look at the frozen ray.
|
||||
match hit {
|
||||
Some(h) => {
|
||||
let name = self
|
||||
.shell
|
||||
.state
|
||||
.scene
|
||||
.name(h.entity)
|
||||
.unwrap_or_else(|| "<entity>".to_string());
|
||||
self.shell
|
||||
.set_status_hint(format!("Raycast probe: hit {name}"));
|
||||
}
|
||||
None => self.shell.set_status_hint("Raycast probe: miss"),
|
||||
}
|
||||
self.shell.set_raycast_probe_viz(Some(RaycastProbeViz {
|
||||
origin: ray.origin,
|
||||
end: hit
|
||||
.map(|h| h.point)
|
||||
.unwrap_or_else(|| ray.at(PROBE_DISTANCE)),
|
||||
hit: hit.map(|h| RaycastProbeHit {
|
||||
point: h.point,
|
||||
normal: h.normal,
|
||||
}),
|
||||
}));
|
||||
}
|
||||
|
||||
/// Writes the editor's preferences file to disk when the shell flagged
|
||||
/// a binding edit since the last call. Logs (but does not panic on) I/O
|
||||
/// errors — losing one save is recoverable; crashing the editor is not.
|
||||
fn save_preferences_if_dirty(&mut self) {
|
||||
if !self.shell.take_bindings_dirty() {
|
||||
return;
|
||||
}
|
||||
let snapshot = self.shell.state.settings.export();
|
||||
if let Err(err) = preferences::save(&snapshot) {
|
||||
log::warn!("failed to save editor preferences: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Drives the play-mode runtime (Stage 8.7). Reconciles the play `App`'s
|
||||
/// existence with the editor's [`PlayState`] (build it on Play, drop it on
|
||||
/// Stop), then advances the simulation as far as
|
||||
/// [`play::tick_for`](oxide_editor::play::tick_for) decides — swapping the
|
||||
/// editor scene into the `App` for the tick and back out so the rest of the
|
||||
/// editor keeps seeing `shell.state.scene`.
|
||||
///
|
||||
/// Called unconditionally each frame, before the cursor-gated editor input,
|
||||
/// so play continues regardless of where the pointer is.
|
||||
fn drive_play(&mut self, dt: f32) {
|
||||
let in_play = self.shell.state.is_in_play();
|
||||
// Build the runtime when play starts; tear it down when it stops. The
|
||||
// engine `App` carries the default modules plus physics (Stage 9) and
|
||||
// scripting (Stage 10); the project's own modules register here too in a
|
||||
// later stage.
|
||||
if in_play && self.play_app.is_none() {
|
||||
let mut app = App::new();
|
||||
// Share the editor's asset server so the play app resolves the same
|
||||
// assets *and* the file watcher's in-place reloads (which target the
|
||||
// editor server) reach a **playing** scene — live-reloading a script
|
||||
// while the scene runs.
|
||||
app.assets = self.shell.state.assets.clone();
|
||||
app.add_modules(DefaultModules);
|
||||
app.add_module(oxide_physics::PhysicsModule);
|
||||
app.add_module(oxide_script::ScriptModule);
|
||||
// Scripts resolve their `AssetRef<ScriptAsset>` through the project's
|
||||
// asset database; hand the play app a snapshot so uids map to files.
|
||||
if let Some(db) = &self.shell.state.asset_db {
|
||||
app.insert_resource(db.clone());
|
||||
}
|
||||
self.play_app = Some(app);
|
||||
} else if !in_play && self.play_app.is_some() {
|
||||
self.play_app = None;
|
||||
}
|
||||
|
||||
let tick = play::tick_for(self.shell.state.play, self.shell.take_step_request());
|
||||
if matches!(tick, Tick::Idle) {
|
||||
return;
|
||||
}
|
||||
let Some(app) = self.play_app.as_mut() else {
|
||||
return;
|
||||
};
|
||||
// Run the engine schedule against the editor's live scene, then hand it
|
||||
// back. `swap` is O(1) (two `Scene` moves), so the editor scene is only
|
||||
// "inside" the App for the duration of the tick.
|
||||
std::mem::swap(&mut self.shell.state.scene, &mut app.scene);
|
||||
match tick {
|
||||
Tick::Frame => app.update(dt),
|
||||
Tick::FixedStep => app.step(),
|
||||
Tick::Idle => {}
|
||||
}
|
||||
std::mem::swap(&mut self.shell.state.scene, &mut app.scene);
|
||||
}
|
||||
|
||||
/// Ray-picks the entity under the cursor and selects it (or clears the
|
||||
/// selection if the ray misses everything).
|
||||
fn pick_under_cursor(&mut self, size: (u32, u32)) {
|
||||
let Some(cursor) = self.last_cursor else {
|
||||
return;
|
||||
};
|
||||
let rect = self.shell.viewport_rect();
|
||||
let picked = self
|
||||
.viewport
|
||||
.as_ref()
|
||||
.and_then(|vp| vp.pick(&self.shell.state.scene, cursor, size, rect));
|
||||
self.shell.select(picked);
|
||||
}
|
||||
}
|
||||
|
||||
impl WindowApp for EditorApp {
|
||||
fn init(&mut self, ctx: &mut AppCtx<'_>) {
|
||||
let (w, h) = ctx.size();
|
||||
let device = ctx.render().gpu().device().clone();
|
||||
let format = ctx.render().surface_format();
|
||||
let layer = EguiLayer::new(ctx.window(), &device, format);
|
||||
self.egui_layer = Some(layer);
|
||||
self.viewport = Some(Viewport::new(&device, format));
|
||||
log::info!(
|
||||
"editor window open ({w}x{h}); docking shell active \
|
||||
(L-drag: orbit · R-drag: pan · scroll: zoom · F: toggle flythrough · \
|
||||
Ctrl+Z: undo · Ctrl+Q: quit)"
|
||||
);
|
||||
}
|
||||
|
||||
fn event(&mut self, ctx: &mut AppCtx<'_>, event: &WindowEvent) {
|
||||
// Let egui handle the event first (text fields, clicks, scrolling).
|
||||
// `consumed` is true when the pointer is over an egui widget, but
|
||||
// the Viewport tab is technically an egui widget too — so egui would
|
||||
// claim every click in the central area. Override that: if the cursor
|
||||
// is over the Viewport tab's rect we treat the event as ours, so
|
||||
// orbit/pan/zoom/pick work inside the dock.
|
||||
let egui_consumed = self
|
||||
.egui_layer
|
||||
.as_mut()
|
||||
.map(|layer| layer.on_window_event(ctx.window(), event))
|
||||
.unwrap_or(false);
|
||||
// A floating panel (egui Window) can overlap the viewport rect; when
|
||||
// the pointer is over one, the click belongs to egui, not the 3D view —
|
||||
// otherwise we'd drag the panel and orbit the camera at the same time.
|
||||
let over_floating = self
|
||||
.egui_layer
|
||||
.as_ref()
|
||||
.map(|layer| layer.pointer_over_floating())
|
||||
.unwrap_or(false);
|
||||
let over_viewport = !over_floating
|
||||
&& self
|
||||
.last_cursor
|
||||
.map(|c| self.shell.cursor_over_viewport(c))
|
||||
.unwrap_or(false);
|
||||
let consumed = egui_consumed && !over_viewport;
|
||||
|
||||
match event {
|
||||
WindowEvent::ModifiersChanged(modifiers) => {
|
||||
self.modifiers = modifiers.state();
|
||||
// If a gizmo drag is in flight, re-apply it with the new
|
||||
// modifier state so toggling Ctrl mid-drag snaps (or
|
||||
// unsnaps) the current position immediately — even when
|
||||
// the mouse hasn't moved since.
|
||||
if self.shell.state.gizmo.drag.is_some() {
|
||||
if let Some(cursor) = self.last_cursor {
|
||||
self.advance_gizmo_drag(ctx.size(), cursor);
|
||||
}
|
||||
}
|
||||
}
|
||||
WindowEvent::KeyboardInput { event, .. } => {
|
||||
if event.state != ElementState::Pressed {
|
||||
return;
|
||||
}
|
||||
let ctrl = self.modifiers.control_key();
|
||||
let shift = self.modifiers.shift_key();
|
||||
// Engine-global Ctrl+Q is handled here; everything else is
|
||||
// delegated to the shell so the same shortcut routing is
|
||||
// exercised by tests.
|
||||
if ctrl && event.physical_key == PhysicalKey::Code(KeyCode::KeyQ) {
|
||||
log::info!("Ctrl+Q — exiting editor");
|
||||
ctx.request_exit();
|
||||
return;
|
||||
}
|
||||
if ctrl {
|
||||
let ch = match event.physical_key {
|
||||
PhysicalKey::Code(KeyCode::KeyZ) => Some('z'),
|
||||
PhysicalKey::Code(KeyCode::KeyY) => Some('y'),
|
||||
PhysicalKey::Code(KeyCode::KeyS) => Some('s'),
|
||||
PhysicalKey::Code(KeyCode::Comma) => Some(','),
|
||||
PhysicalKey::Code(KeyCode::KeyP) => Some('p'),
|
||||
PhysicalKey::Code(KeyCode::Period) => Some('.'),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(ch) = ch {
|
||||
self.shell.try_consume_shortcut(true, shift, Some(ch));
|
||||
}
|
||||
}
|
||||
}
|
||||
WindowEvent::MouseInput { state, button, .. } => {
|
||||
let pressed = *state == ElementState::Pressed;
|
||||
match button {
|
||||
MouseButton::Left => {
|
||||
if pressed {
|
||||
// Try a gizmo handle first — if the click hit
|
||||
// one, we start a drag instead of orbiting.
|
||||
let on_gizmo = !consumed && self.try_begin_gizmo_drag(ctx.size());
|
||||
self.orbiting = !consumed && !on_gizmo;
|
||||
self.left_drag_dist = 0.0;
|
||||
} else {
|
||||
// Release: commit the gizmo drag if any (one
|
||||
// SetTransformCmd per drag = one undo entry).
|
||||
if self.shell.state.gizmo.drag.is_some() {
|
||||
self.end_gizmo_drag();
|
||||
} else if self.orbiting && self.left_drag_dist < 4.0 {
|
||||
// Click without drag → pick, and (if the raycast
|
||||
// probe is on) freeze a debug ray into the world
|
||||
// so it can be inspected by orbiting the camera.
|
||||
self.pick_under_cursor(ctx.size());
|
||||
if self.shell.raycast_probe_enabled() {
|
||||
self.cast_probe_ray(ctx.size());
|
||||
}
|
||||
}
|
||||
self.orbiting = false;
|
||||
}
|
||||
}
|
||||
MouseButton::Right | MouseButton::Middle => self.panning = pressed && !consumed,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
WindowEvent::CursorMoved { position, .. } => {
|
||||
let pos = (position.x as f32, position.y as f32);
|
||||
if let Some((lx, ly)) = self.last_cursor {
|
||||
let (dx, dy) = (pos.0 - lx, pos.1 - ly);
|
||||
if self.orbiting {
|
||||
self.left_drag_dist += dx.abs() + dy.abs();
|
||||
}
|
||||
|
||||
// If a gizmo drag is in flight, route the move into the
|
||||
// gizmo math and skip the camera controls entirely.
|
||||
if self.shell.state.gizmo.drag.is_some() {
|
||||
self.advance_gizmo_drag(ctx.size(), pos);
|
||||
} else if let Some(vp) = self.viewport.as_mut() {
|
||||
match vp.mode {
|
||||
CameraMode::Orbit => {
|
||||
if self.orbiting {
|
||||
vp.orbit.orbit(dx, dy);
|
||||
} else if self.panning {
|
||||
vp.orbit.pan(dx, dy);
|
||||
}
|
||||
}
|
||||
CameraMode::Flythrough => {
|
||||
// In flythrough the existing right-drag gesture
|
||||
// becomes mouse-look; left-drag is a no-op for
|
||||
// the camera (click-without-drag still picks).
|
||||
if self.panning {
|
||||
vp.flythrough.look(dx, dy);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.last_cursor = Some(pos);
|
||||
}
|
||||
WindowEvent::MouseWheel { delta, .. } if !consumed => {
|
||||
let amount = match delta {
|
||||
MouseScrollDelta::LineDelta(_, y) => *y,
|
||||
MouseScrollDelta::PixelDelta(p) => p.y as f32 / 40.0,
|
||||
};
|
||||
if let Some(vp) = self.viewport.as_mut() {
|
||||
// Scroll has different roles per mode: zoom-in/out for the
|
||||
// orbit subject, faster/slower travel for the flythrough.
|
||||
match vp.mode {
|
||||
CameraMode::Orbit => vp.orbit.zoom(amount),
|
||||
CameraMode::Flythrough => vp.flythrough.adjust_move_speed(amount),
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, ctx: &mut AppCtx<'_>) {
|
||||
// Drain the file watcher into AssetServer::reload_path and age out
|
||||
// the status bar's last hint.
|
||||
self.shell.frame_tick();
|
||||
// Propagate File → Quit (the shell can't reach the runner directly).
|
||||
if self.shell.take_quit_request() {
|
||||
ctx.request_exit();
|
||||
}
|
||||
|
||||
// Advance the play-mode simulation (if any) every frame, before the
|
||||
// cursor-gated editor input below — play must not depend on the pointer
|
||||
// being over the viewport.
|
||||
self.drive_play(ctx.dt);
|
||||
|
||||
// Bindings preferences page — when a capture is in progress, consume
|
||||
// the next pressed key/button into the targeted slot. Runs before
|
||||
// any other input poll so the captured press doesn't double-fire
|
||||
// a normal action.
|
||||
let input = ctx.input();
|
||||
if self.shell.capture_active() {
|
||||
self.shell.try_complete_capture(input);
|
||||
// Flush to disk if the capture committed a binding.
|
||||
self.save_preferences_if_dirty();
|
||||
return;
|
||||
}
|
||||
|
||||
// Editor input — polled per-frame from the Stage-7 InputState. Only
|
||||
// fires when the cursor is over the Viewport tab so the same keys do
|
||||
// not steal focus from a search box or text field elsewhere.
|
||||
let over_floating = self
|
||||
.egui_layer
|
||||
.as_ref()
|
||||
.map(|layer| layer.pointer_over_floating())
|
||||
.unwrap_or(false);
|
||||
let cursor_over_vp = !over_floating
|
||||
&& self
|
||||
.last_cursor
|
||||
.map(|c| self.shell.cursor_over_viewport(c))
|
||||
.unwrap_or(false);
|
||||
if !cursor_over_vp {
|
||||
// Even off the viewport, a "Restore defaults" click from the
|
||||
// preferences UI marks the bindings dirty — flush here.
|
||||
self.save_preferences_if_dirty();
|
||||
return;
|
||||
}
|
||||
|
||||
let actions = &self.shell.state.actions;
|
||||
if actions.action_pressed(action::TOGGLE_FLYTHROUGH, input) {
|
||||
if let Some(vp) = self.viewport.as_mut() {
|
||||
let new_mode = vp.toggle_camera_mode();
|
||||
let label = match new_mode {
|
||||
CameraMode::Orbit => "Camera: Orbit (L-drag orbit · R-drag pan · scroll zoom)",
|
||||
CameraMode::Flythrough => {
|
||||
"Camera: Flythrough (WASD/QE move · Shift sprint · R-drag look · scroll speed)"
|
||||
}
|
||||
};
|
||||
log::info!("{label}");
|
||||
self.shell.set_status_hint(label);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(vp) = self.viewport.as_mut() {
|
||||
if vp.mode == CameraMode::Flythrough {
|
||||
let actions = &self.shell.state.actions;
|
||||
let right = actions.axis(action::MOVE_RIGHT, input);
|
||||
let forward = actions.axis(action::MOVE_FORWARD, input);
|
||||
let up = actions.axis(action::MOVE_UP, input);
|
||||
// The camera's translate_local takes (right, up, -forward),
|
||||
// i.e. -Z is camera-forward, mirroring the OrbitCamera's
|
||||
// looking_at convention.
|
||||
let local = oxide_engine::math::Vec3::new(right, up, -forward);
|
||||
let sprint = actions.action_held(action::SPRINT, input);
|
||||
vp.flythrough.translate_local(local, ctx.dt, sprint);
|
||||
}
|
||||
}
|
||||
|
||||
// Gizmo tool hotkeys (W/E/R by default). Share keys with flythrough
|
||||
// movement, so they only fire in orbit mode — in flythrough WASD
|
||||
// moves the camera.
|
||||
let orbit_mode = matches!(
|
||||
self.viewport.as_ref().map(|v| v.mode),
|
||||
Some(CameraMode::Orbit)
|
||||
);
|
||||
if orbit_mode {
|
||||
let actions = &self.shell.state.actions;
|
||||
let new_mode = if actions.action_pressed(action::GIZMO_TRANSLATE, input) {
|
||||
Some(GizmoMode::Translate)
|
||||
} else if actions.action_pressed(action::GIZMO_ROTATE, input) {
|
||||
Some(GizmoMode::Rotate)
|
||||
} else if actions.action_pressed(action::GIZMO_SCALE, input) {
|
||||
Some(GizmoMode::Scale)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(mode) = new_mode {
|
||||
self.shell.state.gizmo.mode = mode;
|
||||
log::info!("Gizmo tool: {}", mode.label());
|
||||
self.shell
|
||||
.set_status_hint(format!("Gizmo: {}", mode.label()));
|
||||
}
|
||||
}
|
||||
|
||||
self.save_preferences_if_dirty();
|
||||
}
|
||||
|
||||
fn render(&mut self, ctx: &RenderCtx<'_>) {
|
||||
// Draw the 3D scene first; egui then composites its panels on top
|
||||
// (both record with `LoadOp::Load` over the engine's clear). Taken out
|
||||
// and back so the immutable scene borrow doesn't clash with `&mut self`.
|
||||
let rect = self.shell.viewport_rect();
|
||||
if let Some(mut vp) = self.viewport.take() {
|
||||
vp.render(&self.shell.state.scene, ctx, rect);
|
||||
self.viewport = Some(vp);
|
||||
}
|
||||
|
||||
// Hand the Shell the data its Viewport tab needs to paint the gizmo
|
||||
// overlay using the same projection the scene was drawn with.
|
||||
self.shell
|
||||
.set_viewport_overlay(self.build_gizmo_overlay(ctx.size, rect));
|
||||
|
||||
let Some(mut layer) = self.egui_layer.take() else {
|
||||
return;
|
||||
};
|
||||
let shell = &mut self.shell;
|
||||
layer.paint(
|
||||
ctx.window,
|
||||
ctx.gpu.device(),
|
||||
ctx.gpu.queue(),
|
||||
ctx.view,
|
||||
ctx.size,
|
||||
|ui| shell.build(ui),
|
||||
);
|
||||
self.egui_layer = Some(layer);
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
// Install the capturing logger so script output/errors also reach the
|
||||
// editor Console panel (still prints to the terminal, honours RUST_LOG).
|
||||
oxide_editor::console::init();
|
||||
log::info!("Oxide Editor starting…");
|
||||
|
||||
let config = WindowConfig {
|
||||
title: "Oxide Editor".to_string(),
|
||||
clear_color: VIEWPORT_CLEAR,
|
||||
..Default::default()
|
||||
};
|
||||
run(config, EditorApp::new())
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
//! The play-mode tick decision (Stage 8.7).
|
||||
//!
|
||||
//! The host runner ([`oxide_editor::main`](crate)) owns the play [`App`] and the
|
||||
//! window loop; this module isolates the one piece of that loop worth testing on
|
||||
//! its own: **how far to advance the simulation this frame** given the current
|
||||
//! [`PlayState`] and whether a single **Step** was requested.
|
||||
//!
|
||||
//! Keeping it a pure function pins the play-mode contract in a unit test instead
|
||||
//! of burying it in the (un-testable) GUI runner:
|
||||
//!
|
||||
//! - [`Playing`](PlayState::Playing) → advance one real frame ([`Tick::Frame`]).
|
||||
//! - [`Paused`](PlayState::Paused) + Step → advance exactly one fixed tick
|
||||
//! ([`Tick::FixedStep`]); a stray Step while *Playing* is ignored (the frame
|
||||
//! already advances).
|
||||
//! - [`Editing`](PlayState::Editing), or Paused with no Step → do nothing
|
||||
//! ([`Tick::Idle`]).
|
||||
//!
|
||||
//! [`App`]: oxide_engine::app::App
|
||||
|
||||
use crate::state::PlayState;
|
||||
|
||||
/// How the host runner should advance the play [`App`](oxide_engine::app::App)
|
||||
/// this frame. The runner maps each variant onto an engine call: `Frame` →
|
||||
/// [`App::update`](oxide_engine::app::App::update), `FixedStep` →
|
||||
/// [`App::step`](oxide_engine::app::App::step), `Idle` → no call.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Tick {
|
||||
/// Do not advance the simulation (editing, or paused with no step queued).
|
||||
Idle,
|
||||
/// Advance one normal frame by the real delta (playing).
|
||||
Frame,
|
||||
/// Advance exactly one fixed timestep (a single step while paused).
|
||||
FixedStep,
|
||||
}
|
||||
|
||||
/// Decides how to advance the simulation this frame. `step_requested` is whether
|
||||
/// the user asked for a single **Step** since the last frame; it is honoured
|
||||
/// only while [`Paused`](PlayState::Paused). See the [module docs](self).
|
||||
pub fn tick_for(play: PlayState, step_requested: bool) -> Tick {
|
||||
match play {
|
||||
PlayState::Playing => Tick::Frame,
|
||||
PlayState::Paused if step_requested => Tick::FixedStep,
|
||||
PlayState::Paused | PlayState::Editing => Tick::Idle,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn playing_advances_a_frame_regardless_of_step() {
|
||||
assert_eq!(tick_for(PlayState::Playing, false), Tick::Frame);
|
||||
// A stray step while playing is ignored — the frame already advances.
|
||||
assert_eq!(tick_for(PlayState::Playing, true), Tick::Frame);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paused_steps_only_when_requested() {
|
||||
assert_eq!(tick_for(PlayState::Paused, false), Tick::Idle);
|
||||
assert_eq!(tick_for(PlayState::Paused, true), Tick::FixedStep);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_never_advances() {
|
||||
assert_eq!(tick_for(PlayState::Editing, false), Tick::Idle);
|
||||
assert_eq!(tick_for(PlayState::Editing, true), Tick::Idle);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
//! Editor-wide preferences persistence on disk.
|
||||
//!
|
||||
//! The Stage-6 [`Settings`](oxide_engine::settings::Settings) framework
|
||||
//! defines *what* is persisted (named sections, each owning a typed value).
|
||||
//! This module defines *where* — the user-scoped file the editor reads on
|
||||
//! startup and writes on every change, so a binding remap or theme tweak
|
||||
//! survives a restart.
|
||||
//!
|
||||
//! # Location
|
||||
//!
|
||||
//! Linux: `$XDG_CONFIG_HOME/oxide/editor.ron`, falling back to
|
||||
//! `$HOME/.config/oxide/editor.ron`. The directory is created on demand;
|
||||
//! the path is the same one a Windows port would use once Stage-16 ships
|
||||
//! game export (Windows resolution lands then, not here).
|
||||
//!
|
||||
//! # Format
|
||||
//!
|
||||
//! The file is exactly the RON map [`Settings::export`] produces:
|
||||
//! `{ "section.name": "(field: value, …)", … }`. Each value is itself a
|
||||
//! RON-encoded string of that section's typed value. Loading does no
|
||||
//! schema validation — unknown sections are skipped by `Settings::import`,
|
||||
//! so removing a section in code never breaks an old file.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::ffi::OsString;
|
||||
use std::io;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Resolves the absolute path to the editor's preferences file, or `None`
|
||||
/// if the OS provides no usable home / config directory (a stripped-down
|
||||
/// container, an unusual launcher environment, …).
|
||||
pub fn config_path() -> Option<PathBuf> {
|
||||
resolve_config_path(|k| std::env::var_os(k))
|
||||
}
|
||||
|
||||
/// Resolution rules, factored so tests can inject env state without racing
|
||||
/// on the real process environment. Returns the first of:
|
||||
///
|
||||
/// 1. `$XDG_CONFIG_HOME/oxide/editor.ron`
|
||||
/// 2. `$HOME/.config/oxide/editor.ron`
|
||||
/// 3. `None` if neither is set.
|
||||
fn resolve_config_path(env: impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
|
||||
let base = env("XDG_CONFIG_HOME")
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| env("HOME").map(|h| PathBuf::from(h).join(".config")))?;
|
||||
Some(base.join("oxide").join("editor.ron"))
|
||||
}
|
||||
|
||||
/// Loads the preferences file, returning the same `BTreeMap` shape
|
||||
/// [`Settings::import`](oxide_engine::settings::Settings::import) consumes.
|
||||
///
|
||||
/// Returns `None` when no file exists yet (a fresh install) or it can't be
|
||||
/// parsed — both cases are silently treated as "no saved preferences" so
|
||||
/// the editor falls back to the code-defined defaults. A returned `Some`
|
||||
/// is the file's contents verbatim; the caller decides what to import.
|
||||
pub fn load() -> Option<BTreeMap<String, String>> {
|
||||
let path = config_path()?;
|
||||
let text = std::fs::read_to_string(&path).ok()?;
|
||||
ron::from_str(&text).ok()
|
||||
}
|
||||
|
||||
/// Writes `map` to the preferences file, creating the parent directory if
|
||||
/// necessary. The map is the output of
|
||||
/// [`Settings::export`](oxide_engine::settings::Settings::export); the
|
||||
/// editor calls this from the host runner whenever a binding edit or
|
||||
/// other settings change flips a dirty flag.
|
||||
pub fn save(map: &BTreeMap<String, String>) -> io::Result<()> {
|
||||
let path = config_path().ok_or_else(|| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::NotFound,
|
||||
"no $XDG_CONFIG_HOME or $HOME — cannot resolve editor preferences path",
|
||||
)
|
||||
})?;
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let text = ron::ser::to_string_pretty(map, ron::ser::PrettyConfig::default())
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
|
||||
std::fs::write(path, text)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A throw-away env stub built from a closure — keeps each test free of
|
||||
/// process-global env mutation, so the suite can run in parallel.
|
||||
fn env<'a>(map: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<OsString> + 'a {
|
||||
move |k| {
|
||||
map.iter()
|
||||
.find(|(kk, _)| *kk == k)
|
||||
.map(|(_, v)| OsString::from(*v))
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_path_uses_xdg_when_set() {
|
||||
let p = resolve_config_path(env(&[("XDG_CONFIG_HOME", "/tmp/x")])).unwrap();
|
||||
assert_eq!(p, PathBuf::from("/tmp/x/oxide/editor.ron"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_path_prefers_xdg_over_home_when_both_set() {
|
||||
let p =
|
||||
resolve_config_path(env(&[("XDG_CONFIG_HOME", "/tmp/x"), ("HOME", "/tmp/h")])).unwrap();
|
||||
assert_eq!(p, PathBuf::from("/tmp/x/oxide/editor.ron"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_path_falls_back_to_home_dot_config() {
|
||||
let p = resolve_config_path(env(&[("HOME", "/tmp/h")])).unwrap();
|
||||
assert_eq!(p, PathBuf::from("/tmp/h/.config/oxide/editor.ron"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_path_is_none_when_no_env_available() {
|
||||
let p = resolve_config_path(env(&[]));
|
||||
assert!(p.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_then_load_round_trips_the_exported_map() {
|
||||
// Direct file I/O test that doesn't go through config_path — write
|
||||
// to a temp file with a known shape and confirm the RON round-trip
|
||||
// matches what `Settings::export` produces.
|
||||
let scratch = std::env::temp_dir().join(format!(
|
||||
"oxide_editor_prefs_roundtrip_{}.ron",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_file(&scratch);
|
||||
|
||||
let mut map = BTreeMap::new();
|
||||
map.insert(
|
||||
"input.bindings".to_string(),
|
||||
"(bindings: {\"Jump\": [Key(KeyW)]})".to_string(),
|
||||
);
|
||||
let text = ron::ser::to_string_pretty(&map, ron::ser::PrettyConfig::default()).unwrap();
|
||||
std::fs::write(&scratch, &text).unwrap();
|
||||
|
||||
let read_back = std::fs::read_to_string(&scratch).unwrap();
|
||||
let parsed: BTreeMap<String, String> = ron::from_str(&read_back).unwrap();
|
||||
assert_eq!(parsed, map);
|
||||
|
||||
let _ = std::fs::remove_file(&scratch);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
//! A PTY-backed terminal: runs an interactive program (a shell, a REPL, an
|
||||
//! AI-agent CLI like `claude`) inside the editor.
|
||||
//!
|
||||
//! This is the interactive counterpart to the command [console](crate::console).
|
||||
//! The console pipes a one-shot command's output; a real terminal needs a
|
||||
//! **pseudo-terminal**: programs detect a tty and switch to full-screen/TUI mode,
|
||||
//! read raw keystrokes from stdin, and drive the screen with ANSI/VT escape
|
||||
//! sequences. So this module:
|
||||
//!
|
||||
//! - opens a PTY with [`portable-pty`] (cross-platform — Linux now, Windows
|
||||
//! later) and spawns the program attached to it;
|
||||
//! - feeds the program's byte stream into a [`vt100`] parser on a reader thread,
|
||||
//! which maintains the on-screen grid (cells, colours, cursor);
|
||||
//! - exposes the grid for the egui panel to render, and [`send_input`] to write
|
||||
//! keystrokes back to the program.
|
||||
//!
|
||||
//! [`send_input`]: PtyTerminal::send_input
|
||||
//!
|
||||
//! The two pure pieces — encoding an egui key press into the bytes a terminal
|
||||
//! expects ([`encode_key`]) and mapping a [`vt100`] colour to an egui colour
|
||||
//! ([`vt_color`]) — are unit-tested; the rendering/input loop itself is the
|
||||
//! eye-checked part.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use egui::{Key, Modifiers};
|
||||
use portable_pty::{Child, CommandBuilder, MasterPty, PtySize};
|
||||
|
||||
/// A live terminal session: the spawned child, its PTY, and the parsed screen.
|
||||
pub struct PtyTerminal {
|
||||
/// A short label for the session (e.g. the program name) shown on the tab.
|
||||
pub title: String,
|
||||
/// The parsed terminal screen, updated by the reader thread.
|
||||
parser: Arc<Mutex<vt100::Parser>>,
|
||||
/// The PTY master — kept for resizing.
|
||||
master: Box<dyn MasterPty + Send>,
|
||||
/// Writes keystrokes to the program (the PTY input side).
|
||||
writer: Box<dyn Write + Send>,
|
||||
/// The spawned child — killed on drop so closing the panel ends the program.
|
||||
child: Box<dyn Child + Send + Sync>,
|
||||
/// Current grid size, so we only resize on an actual change.
|
||||
rows: u16,
|
||||
cols: u16,
|
||||
}
|
||||
|
||||
impl PtyTerminal {
|
||||
/// Spawns `program` (with `args`) attached to a fresh PTY of `rows`×`cols`,
|
||||
/// running in `cwd`. `title` labels the session.
|
||||
pub fn spawn(
|
||||
title: impl Into<String>,
|
||||
program: &str,
|
||||
args: &[&str],
|
||||
cwd: &std::path::Path,
|
||||
rows: u16,
|
||||
cols: u16,
|
||||
) -> std::io::Result<Self> {
|
||||
let pty_system = portable_pty::native_pty_system();
|
||||
let pair = pty_system
|
||||
.openpty(PtySize {
|
||||
rows,
|
||||
cols,
|
||||
pixel_width: 0,
|
||||
pixel_height: 0,
|
||||
})
|
||||
.map_err(to_io)?;
|
||||
|
||||
let mut cmd = CommandBuilder::new(program);
|
||||
cmd.args(args);
|
||||
cmd.cwd(cwd);
|
||||
// Advertise a capable terminal so programs emit colour + use full-screen
|
||||
// mode; without this many tools fall back to dumb output.
|
||||
cmd.env("TERM", "xterm-256color");
|
||||
|
||||
let child = pair.slave.spawn_command(cmd).map_err(to_io)?;
|
||||
// Drop the slave handle so the master sees EOF when the child exits.
|
||||
drop(pair.slave);
|
||||
|
||||
let reader = pair.master.try_clone_reader().map_err(to_io)?;
|
||||
let writer = pair.master.take_writer().map_err(to_io)?;
|
||||
|
||||
let parser = Arc::new(Mutex::new(vt100::Parser::new(rows, cols, 0)));
|
||||
spawn_reader(reader, parser.clone());
|
||||
|
||||
Ok(Self {
|
||||
title: title.into(),
|
||||
parser,
|
||||
master: pair.master,
|
||||
writer,
|
||||
child,
|
||||
rows,
|
||||
cols,
|
||||
})
|
||||
}
|
||||
|
||||
/// Borrows the parsed screen state for rendering (locks the parser).
|
||||
pub fn with_screen<R>(&self, f: impl FnOnce(&vt100::Screen) -> R) -> R {
|
||||
let parser = self.parser.lock().unwrap();
|
||||
f(parser.screen())
|
||||
}
|
||||
|
||||
/// The current grid size in (rows, cols).
|
||||
pub fn size(&self) -> (u16, u16) {
|
||||
(self.rows, self.cols)
|
||||
}
|
||||
|
||||
/// Writes raw bytes (already terminal-encoded) to the program's input.
|
||||
pub fn send_input(&mut self, bytes: &[u8]) {
|
||||
let _ = self.writer.write_all(bytes);
|
||||
let _ = self.writer.flush();
|
||||
}
|
||||
|
||||
/// Resizes the PTY and parser to `rows`×`cols` (no-op if unchanged). Programs
|
||||
/// receive `SIGWINCH` and redraw to the new size.
|
||||
pub fn resize(&mut self, rows: u16, cols: u16) {
|
||||
if rows == 0 || cols == 0 || (rows == self.rows && cols == self.cols) {
|
||||
return;
|
||||
}
|
||||
self.rows = rows;
|
||||
self.cols = cols;
|
||||
let _ = self.master.resize(PtySize {
|
||||
rows,
|
||||
cols,
|
||||
pixel_width: 0,
|
||||
pixel_height: 0,
|
||||
});
|
||||
self.parser
|
||||
.lock()
|
||||
.unwrap()
|
||||
.screen_mut()
|
||||
.set_size(rows, cols);
|
||||
}
|
||||
|
||||
/// Whether the child program has exited.
|
||||
pub fn has_exited(&mut self) -> bool {
|
||||
matches!(self.child.try_wait(), Ok(Some(_)))
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PtyTerminal {
|
||||
fn drop(&mut self) {
|
||||
// End the program when the panel/session goes away.
|
||||
let _ = self.child.kill();
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawns the reader thread: pumps the PTY's output into the `vt100` parser until
|
||||
/// EOF (the child exited / the master closed).
|
||||
fn spawn_reader(mut reader: Box<dyn Read + Send>, parser: Arc<Mutex<vt100::Parser>>) {
|
||||
std::thread::spawn(move || {
|
||||
let mut buf = [0u8; 8192];
|
||||
loop {
|
||||
match reader.read(&mut buf) {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => parser.lock().unwrap().process(&buf[..n]),
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Adapts a `portable_pty` error into `std::io::Error`.
|
||||
fn to_io(err: impl std::fmt::Display) -> std::io::Error {
|
||||
std::io::Error::other(err.to_string())
|
||||
}
|
||||
|
||||
/// Encodes an egui [`Key`] press (with modifiers) into the byte sequence a
|
||||
/// terminal program expects on stdin, or `None` for keys we don't translate
|
||||
/// (printable characters arrive separately as text input events).
|
||||
///
|
||||
/// Covers the control keys a TUI needs: Enter, Backspace, Tab, Esc, the arrows
|
||||
/// and navigation keys (as ANSI CSI sequences), and `Ctrl`+letter (which maps to
|
||||
/// control codes 0x01–0x1A — e.g. `Ctrl+C` → `0x03`).
|
||||
pub fn encode_key(key: Key, mods: Modifiers) -> Option<Vec<u8>> {
|
||||
// Ctrl + A..Z -> 0x01..0x1A (Ctrl+C = ETX = 0x03, etc.).
|
||||
if mods.ctrl && !mods.alt {
|
||||
if let Some(letter) = letter_index(key) {
|
||||
return Some(vec![letter + 1]); // 'a' -> 1
|
||||
}
|
||||
}
|
||||
let bytes: &[u8] = match key {
|
||||
Key::Enter => b"\r",
|
||||
Key::Backspace => b"\x7f",
|
||||
Key::Tab => b"\t",
|
||||
Key::Escape => b"\x1b",
|
||||
Key::ArrowUp => b"\x1b[A",
|
||||
Key::ArrowDown => b"\x1b[B",
|
||||
Key::ArrowRight => b"\x1b[C",
|
||||
Key::ArrowLeft => b"\x1b[D",
|
||||
Key::Home => b"\x1b[H",
|
||||
Key::End => b"\x1b[F",
|
||||
Key::PageUp => b"\x1b[5~",
|
||||
Key::PageDown => b"\x1b[6~",
|
||||
Key::Delete => b"\x1b[3~",
|
||||
Key::Insert => b"\x1b[2~",
|
||||
_ => return None,
|
||||
};
|
||||
Some(bytes.to_vec())
|
||||
}
|
||||
|
||||
/// The 0-based index (`a`=0 … `z`=25) of an alphabetic [`Key`], else `None`.
|
||||
/// Used to map `Ctrl`+letter to its control code.
|
||||
fn letter_index(key: Key) -> Option<u8> {
|
||||
let name = key.name(); // "A".."Z" for letter keys
|
||||
let bytes = name.as_bytes();
|
||||
if bytes.len() == 1 && bytes[0].is_ascii_uppercase() {
|
||||
Some(bytes[0] - b'A')
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps a [`vt100`] colour to an egui colour, given the default foreground to use
|
||||
/// for [`vt100::Color::Default`].
|
||||
pub fn vt_color(color: vt100::Color, default: egui::Color32) -> egui::Color32 {
|
||||
match color {
|
||||
vt100::Color::Default => default,
|
||||
vt100::Color::Rgb(r, g, b) => egui::Color32::from_rgb(r, g, b),
|
||||
vt100::Color::Idx(i) => ansi_indexed(i),
|
||||
}
|
||||
}
|
||||
|
||||
/// The RGB for an ANSI 256-colour palette index: the 16 base colours, the
|
||||
/// 6×6×6 colour cube, and the 24-step grey ramp.
|
||||
fn ansi_indexed(i: u8) -> egui::Color32 {
|
||||
match i {
|
||||
// Standard + bright 16-colour palette.
|
||||
0 => egui::Color32::from_rgb(0x00, 0x00, 0x00),
|
||||
1 => egui::Color32::from_rgb(0xCD, 0x00, 0x00),
|
||||
2 => egui::Color32::from_rgb(0x00, 0xCD, 0x00),
|
||||
3 => egui::Color32::from_rgb(0xCD, 0xCD, 0x00),
|
||||
4 => egui::Color32::from_rgb(0x00, 0x00, 0xEE),
|
||||
5 => egui::Color32::from_rgb(0xCD, 0x00, 0xCD),
|
||||
6 => egui::Color32::from_rgb(0x00, 0xCD, 0xCD),
|
||||
7 => egui::Color32::from_rgb(0xE5, 0xE5, 0xE5),
|
||||
8 => egui::Color32::from_rgb(0x7F, 0x7F, 0x7F),
|
||||
9 => egui::Color32::from_rgb(0xFF, 0x00, 0x00),
|
||||
10 => egui::Color32::from_rgb(0x00, 0xFF, 0x00),
|
||||
11 => egui::Color32::from_rgb(0xFF, 0xFF, 0x00),
|
||||
12 => egui::Color32::from_rgb(0x5C, 0x5C, 0xFF),
|
||||
13 => egui::Color32::from_rgb(0xFF, 0x00, 0xFF),
|
||||
14 => egui::Color32::from_rgb(0x00, 0xFF, 0xFF),
|
||||
15 => egui::Color32::from_rgb(0xFF, 0xFF, 0xFF),
|
||||
// 6×6×6 colour cube (indices 16..=231).
|
||||
16..=231 => {
|
||||
let i = i - 16;
|
||||
let steps = [0u8, 95, 135, 175, 215, 255];
|
||||
let r = steps[(i / 36) as usize];
|
||||
let g = steps[((i / 6) % 6) as usize];
|
||||
let b = steps[(i % 6) as usize];
|
||||
egui::Color32::from_rgb(r, g, b)
|
||||
}
|
||||
// 24-step grey ramp (indices 232..=255).
|
||||
_ => {
|
||||
let level = 8 + (i - 232) * 10;
|
||||
egui::Color32::from_gray(level)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ctrl_c_encodes_to_etx() {
|
||||
assert_eq!(encode_key(Key::C, Modifiers::CTRL), Some(vec![0x03]));
|
||||
assert_eq!(encode_key(Key::A, Modifiers::CTRL), Some(vec![0x01]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_keys_encode_to_their_sequences() {
|
||||
assert_eq!(
|
||||
encode_key(Key::Enter, Modifiers::NONE),
|
||||
Some(b"\r".to_vec())
|
||||
);
|
||||
assert_eq!(
|
||||
encode_key(Key::Backspace, Modifiers::NONE),
|
||||
Some(b"\x7f".to_vec())
|
||||
);
|
||||
assert_eq!(
|
||||
encode_key(Key::ArrowUp, Modifiers::NONE),
|
||||
Some(b"\x1b[A".to_vec())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_letters_are_not_encoded_here() {
|
||||
// Printable text comes through egui text-input events, not key encoding.
|
||||
assert_eq!(encode_key(Key::A, Modifiers::NONE), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vt_default_color_uses_the_supplied_default() {
|
||||
let dflt = egui::Color32::from_rgb(1, 2, 3);
|
||||
assert_eq!(vt_color(vt100::Color::Default, dflt), dflt);
|
||||
assert_eq!(
|
||||
vt_color(vt100::Color::Rgb(10, 20, 30), dflt),
|
||||
egui::Color32::from_rgb(10, 20, 30)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ansi_cube_and_grey_indices_map_in_range() {
|
||||
// Index 16 is the bottom of the cube = black; 231 is white.
|
||||
assert_eq!(ansi_indexed(16), egui::Color32::from_rgb(0, 0, 0));
|
||||
assert_eq!(ansi_indexed(231), egui::Color32::from_rgb(255, 255, 255));
|
||||
// Greyscale ramp stays grey (r == g == b).
|
||||
let g = ansi_indexed(240);
|
||||
assert_eq!(g.r(), g.g());
|
||||
assert_eq!(g.g(), g.b());
|
||||
}
|
||||
}
|
||||
+6663
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,621 @@
|
||||
//! The editor's mutable runtime state.
|
||||
//!
|
||||
//! Split out from the shell so [commands](crate::commands) can mutate exactly
|
||||
//! the data that participates in undo/redo without taking a borrow of the
|
||||
//! whole shell (which also owns dock layout, dialog flags, and UI buffers).
|
||||
//!
|
||||
//! `EditorState` is the `C` parameter every editor `Command<C>` uses.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use oxide_engine::asset::{AssetDatabase, AssetServer};
|
||||
use oxide_engine::input::{ActionMap, ActionOverrides};
|
||||
use oxide_engine::layer::{GroupRegistry, LayerRegistry};
|
||||
use oxide_engine::prelude::*;
|
||||
use oxide_engine::project::{Project, RecentProjects};
|
||||
use oxide_engine::reflect::TypeRegistry;
|
||||
use oxide_engine::settings::Settings;
|
||||
|
||||
use crate::bindings;
|
||||
use crate::gizmo::{GizmoDrag, GizmoMode, SnapSettings};
|
||||
|
||||
/// The data the editor mutates over a session: the scene the user is editing,
|
||||
/// the current selection, the asset server, the open project (if any), the
|
||||
/// typed settings store, and the editor's input action bindings.
|
||||
///
|
||||
/// Held by the shell; commands operate on `&mut EditorState` so the change is
|
||||
/// guaranteed to flow through the same pipeline whether the user clicks a
|
||||
/// menu, drags a gizmo, or runs a script (Stage 10).
|
||||
pub struct EditorState {
|
||||
/// The scene currently open in the viewport / hierarchy.
|
||||
pub scene: Scene,
|
||||
/// The entity the inspector is bound to, if any.
|
||||
pub selected: Option<Entity>,
|
||||
/// The asset server shared by every loader (gltf, future texture/audio).
|
||||
/// Cloneable [`Arc`-backed handle](oxide_engine::asset::AssetServer) — cheap
|
||||
/// to hand to the file watcher.
|
||||
pub assets: AssetServer,
|
||||
/// The typed settings store. The shell registers core sections at startup
|
||||
/// (including the [`SETTINGS_SECTION`](crate::bindings::SETTINGS_SECTION)
|
||||
/// for [`actions`](Self::actions)) and modules add their own through the
|
||||
/// [extension API](crate::extension).
|
||||
pub settings: Settings,
|
||||
/// The editor's input action bindings (camera, future gizmo hotkeys, …).
|
||||
/// Default bindings are registered by
|
||||
/// [`bindings::register_defaults`](crate::bindings::register_defaults);
|
||||
/// the preferences UI reads / mutates this map directly, and the
|
||||
/// [`ActionOverrides`](oxide_engine::input::ActionOverrides) settings
|
||||
/// section stays in sync so a write-back through
|
||||
/// [`Settings::export`](oxide_engine::settings::Settings::export)
|
||||
/// captures the user's remap.
|
||||
pub actions: ActionMap,
|
||||
/// The open project, if any. `None` means the user is working in an
|
||||
/// unsaved scratch scene (handy for quick tinkering before saving).
|
||||
pub project: Option<Project>,
|
||||
/// The open project's asset database — the bridge between stable asset
|
||||
/// references (`AssetUid`/[`AssetRef<T>`](oxide_engine::asset::AssetRef)) and
|
||||
/// files under `assets/`. `Some` exactly when a [`project`](Self::project)
|
||||
/// is open; the shell scans it on open and rescans when the file watcher
|
||||
/// reports asset changes. The asset browser lists from it and the inspector
|
||||
/// asset-picker resolves through it.
|
||||
pub asset_db: Option<AssetDatabase>,
|
||||
/// The cross-session most-recently-used project list shown in the
|
||||
/// `File / Open Recent` submenu.
|
||||
pub recent: RecentProjects,
|
||||
/// Transform-gizmo UI state: active tool (translate / rotate / scale),
|
||||
/// snap settings, and the in-progress drag if any. The viewport reads
|
||||
/// this each frame to paint handles and dispatch drags; the inspector
|
||||
/// reads it to highlight the active axis. Default is
|
||||
/// [`GizmoMode::Translate`] with the default [`SnapSettings`].
|
||||
pub gizmo: GizmoState,
|
||||
/// The reflection registry that lets the inspector edit any registered
|
||||
/// component generically — list an entity's components, enumerate each
|
||||
/// one's fields, and get/set a single field by name. Seeded with the
|
||||
/// built-in reflected types (`Transform`, `Node`); modules add their own
|
||||
/// through the extension API. This is what makes the inspector
|
||||
/// reflection-driven instead of hand-coded per type.
|
||||
pub registry: TypeRegistry,
|
||||
/// Per-entity inspector order for **modular** components (the ones the
|
||||
/// user adds and reorders). Entries persist across re-selection. Anything
|
||||
/// currently on the entity that isn't in the map is appended in whatever
|
||||
/// order the registry reports it, so components inserted outside the
|
||||
/// inspector (e.g. by a script or `set_ron`) still show up.
|
||||
///
|
||||
/// *Node-baked* components — `Node`, `Transform`, `Layer` — render in a
|
||||
/// fixed canonical order above this list and are not tracked here.
|
||||
pub component_order: HashMap<Entity, Vec<&'static str>>,
|
||||
/// Project-wide layer names (which single layer each entity's [`Layer`]
|
||||
/// index means). Seeded with a small common set (`Default`, `UI`, `Player`,
|
||||
/// `World`); later work persists this to the open project's settings so a
|
||||
/// team can name layers like Unity's Layer Inspector. Layers are the
|
||||
/// *single-valued* membership concept — one per entity.
|
||||
pub layer_registry: LayerRegistry,
|
||||
/// Project-wide gameplay group names — the *multi-valued* counterpart to
|
||||
/// [`layer_registry`](Self::layer_registry). An entity is on one layer but
|
||||
/// in any number of groups (stored in its
|
||||
/// [`Tags`](oxide_engine::layer::Tags) component). The registry is the
|
||||
/// project's fixed vocabulary, so the inspector offers groups to pick from
|
||||
/// rather than free-typed strings. Empty until the user defines groups in
|
||||
/// the Groups editor.
|
||||
pub group_registry: GroupRegistry,
|
||||
/// The UI document currently open in the **UI Canvas** panel, if any. The
|
||||
/// canvas edits this `UiPanel`'s widget tree (via the
|
||||
/// [`WidgetPath`](oxide_engine::ui::WidgetPath) authoring primitives) and
|
||||
/// saves it as a `ui/` asset. `None` means the canvas shows its empty state.
|
||||
pub ui_doc: Option<UiDoc>,
|
||||
/// Named spawn templates backing the hierarchy's add-menu. Seeded with the
|
||||
/// built-in prefabs (`Empty`, `Cube`, `Sphere`, `Plane`, `Camera`,
|
||||
/// `Directional Light`); each spawns an entity already carrying the
|
||||
/// matching components via the reflection [`registry`](Self::registry).
|
||||
pub prefab_registry: PrefabRegistry,
|
||||
/// Whether the editor is editing, playing, or paused (Stage 8.7). Drives
|
||||
/// whether the host runner ticks the engine [`Schedule`] and gates the
|
||||
/// play toolbar. Always [`PlayState::Editing`] at startup.
|
||||
pub play: PlayState,
|
||||
/// The scene as it was the instant **Play** was pressed, used to restore it
|
||||
/// bit-for-bit on **Stop** so play-mode mutations never corrupt the authored
|
||||
/// scene. `Some` exactly while [`play`](Self::play) is not
|
||||
/// [`Editing`](PlayState::Editing). See [`enter_play`](Self::enter_play) /
|
||||
/// [`stop`](Self::stop).
|
||||
pub play_snapshot: Option<SceneSnapshot>,
|
||||
}
|
||||
|
||||
/// Whether the editor is authoring the scene or running it (Stage 8.7).
|
||||
///
|
||||
/// In [`Playing`](Self::Playing) the host runner ticks the engine
|
||||
/// [`Schedule`](oxide_engine::app::Schedule) each frame; [`Paused`](Self::Paused)
|
||||
/// freezes ticking but keeps the scene live so a single **Step** can advance one
|
||||
/// fixed tick and the inspector can still edit fields; [`Editing`](Self::Editing)
|
||||
/// is the normal authoring state where no systems run. Pressing **Play**
|
||||
/// snapshots the scene and pressing **Stop** restores it (see
|
||||
/// [`EditorState::enter_play`] / [`EditorState::stop`]).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum PlayState {
|
||||
/// Authoring; the engine schedule is not ticked.
|
||||
#[default]
|
||||
Editing,
|
||||
/// Running; the schedule is ticked every frame.
|
||||
Playing,
|
||||
/// Running but frozen; the schedule is ticked only one fixed step per Step.
|
||||
Paused,
|
||||
}
|
||||
|
||||
/// Editor-only transform-gizmo state held on [`EditorState`].
|
||||
///
|
||||
/// Decoupled from the gizmo *math* in [`crate::gizmo`] so the math stays
|
||||
/// pure-logic (rays in, transforms out) and this struct carries only the
|
||||
/// per-session UI choices.
|
||||
pub struct GizmoState {
|
||||
/// Which tool is active (toggle with W / E / R while the cursor is
|
||||
/// over the Viewport tab and the camera is in orbit mode).
|
||||
pub mode: GizmoMode,
|
||||
/// The snap step sizes applied during a drag while the snap modifier
|
||||
/// (Ctrl by default) is held.
|
||||
pub snap: SnapSettings,
|
||||
/// `Some` while the user is mid-drag on a handle; the runner
|
||||
/// recomputes the target's transform each frame via
|
||||
/// [`crate::gizmo::apply_drag`].
|
||||
pub drag: Option<GizmoDrag>,
|
||||
}
|
||||
|
||||
impl Default for GizmoState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
mode: GizmoMode::Translate,
|
||||
snap: SnapSettings::default(),
|
||||
drag: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An open UI document in the editor's **UI Canvas**.
|
||||
///
|
||||
/// Holds the [`UiPanel`] being authored, the asset it loads from / saves to (if
|
||||
/// it has been saved), the currently selected widget (by
|
||||
/// [`WidgetPath`](oxide_engine::ui::WidgetPath)), and whether there are unsaved
|
||||
/// edits. The same `UiPanel` RON the canvas writes is what the runtime loads.
|
||||
pub struct UiDoc {
|
||||
/// The panel (widget tree + pixel/world size) being edited.
|
||||
pub panel: UiPanel,
|
||||
/// The `ui/` asset this document is saved as, once saved.
|
||||
pub asset: Option<AssetUid>,
|
||||
/// The widget the property panel is bound to (root by default).
|
||||
pub selected: WidgetPath,
|
||||
/// Whether the document has edits not yet written to disk.
|
||||
pub dirty: bool,
|
||||
}
|
||||
|
||||
impl UiDoc {
|
||||
/// A new, empty document: a single full-bleed column root at a 1280×720
|
||||
/// authoring resolution. Not yet associated with an asset.
|
||||
pub fn new() -> Self {
|
||||
let root = Widget::column().with_id("root").with_style(UiLayoutStyle {
|
||||
width: UiSizing::Grow(1.0),
|
||||
height: UiSizing::Grow(1.0),
|
||||
..Default::default()
|
||||
});
|
||||
Self {
|
||||
panel: UiPanel::new(root, Vec2::new(1280.0, 720.0), Vec2::new(2.0, 1.125)),
|
||||
asset: None,
|
||||
selected: WidgetPath::root(),
|
||||
dirty: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for UiDoc {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// The settings section holding [`ExternalEditorPrefs`].
|
||||
pub const EXTERNAL_EDITOR_SECTION: &str = "editor.external_editor";
|
||||
|
||||
/// Preferences for opening a script (or other text asset) in an editor —
|
||||
/// registered as the [`EXTERNAL_EDITOR_SECTION`] settings section, editable in
|
||||
/// Preferences, persisted to `~/.config/oxide/editor.ron` like the bindings.
|
||||
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ExternalEditorPrefs {
|
||||
/// Command to launch, invoked as `<command> <file>` (whitespace-split; may
|
||||
/// carry its own flags, e.g. `"code -g"`). **Empty (the default) = auto**:
|
||||
/// run `$VISUAL`/`$EDITOR` in an editor Terminal tab, else `xdg-open`.
|
||||
pub command: String,
|
||||
}
|
||||
|
||||
impl EditorState {
|
||||
/// A blank state with an empty scene, no open project, and the editor's
|
||||
/// default action bindings registered (`F` toggle, WASD/QE move, Shift
|
||||
/// sprint — see [`bindings`](crate::bindings)).
|
||||
pub fn new() -> Self {
|
||||
Self::with_scene(Scene::new())
|
||||
}
|
||||
|
||||
/// Like [`new`](Self::new) but starting from a populated scene — used by
|
||||
/// the shell so the editor has something visible on launch.
|
||||
pub fn with_scene(scene: Scene) -> Self {
|
||||
let mut actions = ActionMap::new();
|
||||
bindings::register_defaults(&mut actions);
|
||||
let mut settings = Settings::new();
|
||||
settings.register::<ActionOverrides>(bindings::SETTINGS_SECTION);
|
||||
settings.register::<ExternalEditorPrefs>(EXTERNAL_EDITOR_SECTION);
|
||||
let mut registry = TypeRegistry::new();
|
||||
register_builtin_types(&mut registry);
|
||||
// Seed a small, generally-useful set of named layers (besides the
|
||||
// built-in "Default" at index 0). These are common filter slots, not
|
||||
// generic "Layer 1 / Layer 2" filler; the user renames or extends them
|
||||
// in the Layer Names editor.
|
||||
let mut layer_registry = LayerRegistry::new();
|
||||
layer_registry.set(1, "UI");
|
||||
layer_registry.set(2, "Player");
|
||||
layer_registry.set(3, "World");
|
||||
Self {
|
||||
scene,
|
||||
selected: None,
|
||||
assets: AssetServer::new(),
|
||||
settings,
|
||||
actions,
|
||||
project: None,
|
||||
asset_db: None,
|
||||
recent: RecentProjects::new(8),
|
||||
gizmo: GizmoState::default(),
|
||||
registry,
|
||||
component_order: HashMap::new(),
|
||||
layer_registry,
|
||||
group_registry: GroupRegistry::new(),
|
||||
ui_doc: None,
|
||||
prefab_registry: builtin_prefabs(),
|
||||
play: PlayState::Editing,
|
||||
play_snapshot: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the editor is currently running the scene
|
||||
/// ([`Playing`](PlayState::Playing) or [`Paused`](PlayState::Paused)) — the
|
||||
/// states in which the authored scene is "live" and will be restored on Stop.
|
||||
pub fn is_in_play(&self) -> bool {
|
||||
self.play != PlayState::Editing
|
||||
}
|
||||
|
||||
/// Enters **Play**: snapshots the current scene (so Stop can restore it) and
|
||||
/// transitions to [`Playing`](PlayState::Playing). No-op if already playing
|
||||
/// or paused — re-entering must not overwrite the original snapshot.
|
||||
pub fn enter_play(&mut self) {
|
||||
if self.is_in_play() {
|
||||
return;
|
||||
}
|
||||
self.play_snapshot = Some(self.scene.snapshot(&self.registry));
|
||||
self.play = PlayState::Playing;
|
||||
}
|
||||
|
||||
/// Toggles between [`Playing`](PlayState::Playing) and
|
||||
/// [`Paused`](PlayState::Paused). No-op while [`Editing`](PlayState::Editing)
|
||||
/// (there is nothing to pause).
|
||||
pub fn toggle_pause(&mut self) {
|
||||
self.play = match self.play {
|
||||
PlayState::Playing => PlayState::Paused,
|
||||
PlayState::Paused => PlayState::Playing,
|
||||
PlayState::Editing => return,
|
||||
};
|
||||
}
|
||||
|
||||
/// Stops play and restores the scene to its pre-play snapshot bit-for-bit,
|
||||
/// then returns to [`Editing`](PlayState::Editing). The restored scene has
|
||||
/// fresh entity handles, so the selection and any in-flight gizmo drag are
|
||||
/// cleared (the old [`Entity`] no longer exists). No-op while already
|
||||
/// editing.
|
||||
///
|
||||
/// A failed restore (corrupt component RON) leaves the live scene in place
|
||||
/// but still returns to editing; the caller may log the returned error.
|
||||
pub fn stop(&mut self) -> Result<(), SceneError> {
|
||||
if !self.is_in_play() {
|
||||
return Ok(());
|
||||
}
|
||||
let result = match self.play_snapshot.take() {
|
||||
Some(snapshot) => snapshot.restore(&self.registry).map(|scene| {
|
||||
self.scene = scene;
|
||||
}),
|
||||
None => Ok(()),
|
||||
};
|
||||
self.selected = None;
|
||||
self.gizmo.drag = None;
|
||||
self.play = PlayState::Editing;
|
||||
result
|
||||
}
|
||||
|
||||
/// Mirrors the current [`actions`](Self::actions) overrides into the
|
||||
/// `input.bindings` settings section so the next
|
||||
/// [`Settings::export`](oxide_engine::settings::Settings::export) round-
|
||||
/// trips them. Called by the shell after every binding edit.
|
||||
pub fn sync_action_overrides_to_settings(&mut self) {
|
||||
let overrides = self.actions.overrides();
|
||||
self.settings
|
||||
.set::<ActionOverrides>(bindings::SETTINGS_SECTION, overrides);
|
||||
}
|
||||
|
||||
/// Applies any [`ActionOverrides`] previously
|
||||
/// [`Settings::import`](oxide_engine::settings::Settings::import)'d into
|
||||
/// the `input.bindings` section on top of the registered defaults.
|
||||
/// Called by the host runner at startup, after loading the on-disk
|
||||
/// preferences file. No-op if the section is empty or unregistered.
|
||||
pub fn apply_action_overrides_from_settings(&mut self) {
|
||||
if let Some(o) = self
|
||||
.settings
|
||||
.get::<ActionOverrides>(bindings::SETTINGS_SECTION)
|
||||
{
|
||||
// Clone to release the immutable borrow before mutating actions.
|
||||
let o = o.clone();
|
||||
self.actions.apply_overrides(&o);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for EditorState {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Registers the engine's built-in reflected component types under stable
|
||||
/// names. Kept separate so the shell (and tests) seed a registry identically,
|
||||
/// and so modules layer their own `register_reflected` calls on top.
|
||||
///
|
||||
/// Transform and Node are reflected but **not** addable (every scene entity
|
||||
/// already carries them). `MeshRenderer` is addable, so it shows up in the
|
||||
/// inspector's "Add Component" menu and is copied by Duplicate. `PrimitiveShape`
|
||||
/// registers as an enum so its inspector widget is a dropdown.
|
||||
fn register_builtin_types(registry: &mut TypeRegistry) {
|
||||
// Node-baked components: reflected so the inspector can read/write them,
|
||||
// but **not** addable — every entity carries them inherently
|
||||
// (auto-attached on `Scene::spawn`), so the Add Component menu must not
|
||||
// offer to attach a duplicate.
|
||||
registry.register_reflected::<Transform>("Transform");
|
||||
registry.register_reflected::<Node>("Node");
|
||||
registry.register_reflected::<oxide_engine::layer::Layer>("Layer");
|
||||
// Modular components: addable from the inspector. Having several distinct
|
||||
// addable types is what lets the user attach more than one component to a
|
||||
// node and drag-reorder them (an archetypal ECS allows only one component
|
||||
// of a given type per entity, so a *second* mesh lives on a child — see the
|
||||
// Add Component menu's "as child" path).
|
||||
registry.register_addable::<oxide_engine::render::MeshRenderer>("MeshRenderer");
|
||||
registry.register_enum::<oxide_engine::render::PrimitiveShape>("PrimitiveShape");
|
||||
registry.register_addable::<oxide_engine::render::Camera>("Camera");
|
||||
registry.register_addable::<oxide_engine::render::DirectionalLight>("DirectionalLight");
|
||||
|
||||
// Stage-9 physics components: addable from the inspector and captured by the
|
||||
// play-mode snapshot (so Stop reverts a simulated body). No per-type editor
|
||||
// code — the reflection-driven inspector renders them from their fields, with
|
||||
// the two shape/kind enums shown as dropdowns.
|
||||
registry.register_addable::<oxide_physics::RigidBody>("RigidBody");
|
||||
registry.register_enum::<oxide_physics::RigidBodyKind>("RigidBodyKind");
|
||||
registry.register_addable::<oxide_physics::Collider>("Collider");
|
||||
registry.register_enum::<oxide_physics::ColliderShape>("ColliderShape");
|
||||
registry.register_addable::<oxide_physics::CharacterController>("CharacterController");
|
||||
|
||||
// Stage-10 scripting: the Script component is addable from the inspector and
|
||||
// captured by the play-mode snapshot (so Stop reverts a script attach/detach).
|
||||
// Its `source` field is an `AssetRef<ScriptAsset>`, which the inspector shows
|
||||
// as a picker filtered to the `scripts/` folder.
|
||||
registry.register_addable::<oxide_script::Script>("Script");
|
||||
}
|
||||
|
||||
/// The built-in prefabs the hierarchy add-menu offers. Data-driven via
|
||||
/// [`ComponentSpec`]: each prefab is a node name plus the components to attach,
|
||||
/// applied on spawn through the reflection registry. The type names here must
|
||||
/// match those registered in [`register_builtin_types`].
|
||||
fn builtin_prefabs() -> PrefabRegistry {
|
||||
use oxide_engine::render::{Camera, DirectionalLight, MeshRenderer, PrimitiveShape};
|
||||
|
||||
let mut reg = PrefabRegistry::new();
|
||||
// A bare node — just the node-baked Node/Transform/Layer.
|
||||
reg.register(Prefab::new("Empty"));
|
||||
// Primitive meshes (each a MeshRenderer with the matching shape).
|
||||
for (name, shape) in [
|
||||
("Cube", PrimitiveShape::Cube),
|
||||
("Sphere", PrimitiveShape::Sphere),
|
||||
("Plane", PrimitiveShape::Plane),
|
||||
] {
|
||||
let mesh = MeshRenderer {
|
||||
shape,
|
||||
..MeshRenderer::default()
|
||||
};
|
||||
if let Some(spec) = ComponentSpec::of("MeshRenderer", &mesh) {
|
||||
reg.register(Prefab::new(name).with(spec));
|
||||
}
|
||||
}
|
||||
// Viewpoint + light entities.
|
||||
if let Some(spec) = ComponentSpec::of("Camera", &Camera::default()) {
|
||||
reg.register(Prefab::new("Camera").with(spec));
|
||||
}
|
||||
if let Some(spec) = ComponentSpec::of("DirectionalLight", &DirectionalLight::default()) {
|
||||
reg.register(Prefab::new("Directional Light").with(spec));
|
||||
}
|
||||
reg
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use oxide_engine::math::{Transform, Vec3};
|
||||
|
||||
/// An editor state with one entity, ready to play.
|
||||
fn state_with_entity() -> (EditorState, Entity) {
|
||||
let mut state = EditorState::new();
|
||||
let e = state.scene.spawn("thing", Transform::IDENTITY);
|
||||
(state, e)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_play_snapshots_and_sets_playing() {
|
||||
let (mut state, _) = state_with_entity();
|
||||
assert_eq!(state.play, PlayState::Editing);
|
||||
assert!(state.play_snapshot.is_none());
|
||||
state.enter_play();
|
||||
assert_eq!(state.play, PlayState::Playing);
|
||||
assert!(state.play_snapshot.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn re_entering_play_does_not_overwrite_the_snapshot() {
|
||||
let (mut state, e) = state_with_entity();
|
||||
state.enter_play();
|
||||
let original = state.play_snapshot.clone();
|
||||
// Mutate, then (defensively) call enter_play again — the snapshot must
|
||||
// remain the *pre-play* one so Stop still reverts correctly.
|
||||
state
|
||||
.scene
|
||||
.set_local_transform(e, Transform::from_translation(Vec3::X));
|
||||
state.enter_play();
|
||||
assert_eq!(state.play_snapshot, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_pause_flips_only_while_in_play() {
|
||||
let (mut state, _) = state_with_entity();
|
||||
// No-op while editing.
|
||||
state.toggle_pause();
|
||||
assert_eq!(state.play, PlayState::Editing);
|
||||
state.enter_play();
|
||||
state.toggle_pause();
|
||||
assert_eq!(state.play, PlayState::Paused);
|
||||
state.toggle_pause();
|
||||
assert_eq!(state.play, PlayState::Playing);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stop_restores_the_scene_and_clears_play_state() {
|
||||
let (mut state, e) = state_with_entity();
|
||||
let before = state.scene.to_ron().unwrap();
|
||||
state.selected = Some(e);
|
||||
state.enter_play();
|
||||
// Simulate a play-mode mutation (as a tick would).
|
||||
state
|
||||
.scene
|
||||
.set_local_transform(e, Transform::from_translation(Vec3::new(5.0, 0.0, 0.0)));
|
||||
assert_ne!(state.scene.to_ron().unwrap(), before);
|
||||
|
||||
state.stop().unwrap();
|
||||
assert_eq!(state.play, PlayState::Editing);
|
||||
assert!(state.play_snapshot.is_none());
|
||||
// Scene reverted bit-for-bit; selection dropped (handles changed).
|
||||
assert_eq!(state.scene.to_ron().unwrap(), before);
|
||||
assert!(state.selected.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stop_while_editing_is_a_noop() {
|
||||
let (mut state, _) = state_with_entity();
|
||||
let before = state.scene.to_ron().unwrap();
|
||||
state.stop().unwrap();
|
||||
assert_eq!(state.play, PlayState::Editing);
|
||||
assert_eq!(state.scene.to_ron().unwrap(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn physics_components_are_addable_and_reflected() {
|
||||
let state = EditorState::new();
|
||||
// Editable via the reflection-driven inspector and offered in the Add
|
||||
// Component menu (addable), with no per-type editor code.
|
||||
for name in ["RigidBody", "Collider", "CharacterController"] {
|
||||
assert!(state.registry.is_registered(name), "{name} not registered");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_script_component_is_addable_and_reflected() {
|
||||
// Stage-10 dual-editability: Script is registered like any other
|
||||
// component, so the inspector offers it in Add Component and renders its
|
||||
// fields generically.
|
||||
let state = EditorState::new();
|
||||
assert!(state.registry.is_registered("Script"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stop_reverts_a_script_attach() {
|
||||
// Attaching a Script during play must be undone on Stop — the snapshot
|
||||
// captures the reflected Script component like any other.
|
||||
let mut state = EditorState::new();
|
||||
let e = state.scene.spawn("scripted", Transform::IDENTITY);
|
||||
state.enter_play();
|
||||
// The "running game" attaches a script at play time.
|
||||
state
|
||||
.scene
|
||||
.world_mut()
|
||||
.insert_one(e, oxide_script::Script::default())
|
||||
.unwrap();
|
||||
state.stop().unwrap();
|
||||
|
||||
let restored = state
|
||||
.scene
|
||||
.entities()
|
||||
.find(|&e| state.scene.name(e).as_deref() == Some("scripted"))
|
||||
.expect("the entity should be restored");
|
||||
assert!(
|
||||
state.scene.get::<oxide_script::Script>(restored).is_none(),
|
||||
"the play-time script attach should be reverted on Stop"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stop_reverts_a_simulated_physics_body() {
|
||||
// A body that "fell" during play must be restored on Stop — the snapshot
|
||||
// captures reflected physics components like any other.
|
||||
let mut state = EditorState::new();
|
||||
let e = state.scene.spawn(
|
||||
"ball",
|
||||
Transform::from_translation(Vec3::new(0.0, 5.0, 0.0)),
|
||||
);
|
||||
state
|
||||
.scene
|
||||
.world_mut()
|
||||
.insert_one(e, oxide_physics::RigidBody::default())
|
||||
.unwrap();
|
||||
state
|
||||
.scene
|
||||
.world_mut()
|
||||
.insert_one(e, oxide_physics::Collider::ball(0.5))
|
||||
.unwrap();
|
||||
|
||||
state.enter_play();
|
||||
// Simulate physics moving the body down (as the play tick would).
|
||||
state
|
||||
.scene
|
||||
.set_local_transform(e, Transform::from_translation(Vec3::new(0.0, 1.0, 0.0)));
|
||||
state.stop().unwrap();
|
||||
|
||||
// Snapshot restore respawns entities (handles change), so find by name
|
||||
// and confirm both the Transform and the physics components came back.
|
||||
let restored = state
|
||||
.scene
|
||||
.entities()
|
||||
.find(|&e| state.scene.name(e).as_deref() == Some("ball"))
|
||||
.expect("the ball entity should be restored");
|
||||
assert_eq!(
|
||||
state.scene.world_transform(restored).unwrap().translation,
|
||||
Vec3::new(0.0, 5.0, 0.0),
|
||||
"transform should revert to the pre-play pose"
|
||||
);
|
||||
let collider = state
|
||||
.scene
|
||||
.get::<oxide_physics::Collider>(restored)
|
||||
.expect("the Collider component should be restored");
|
||||
assert_eq!(collider.radius, 0.5);
|
||||
assert!(state
|
||||
.scene
|
||||
.get::<oxide_physics::RigidBody>(restored)
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_editor_section_is_registered_and_defaults_to_auto() {
|
||||
let state = EditorState::new();
|
||||
let prefs = state
|
||||
.settings
|
||||
.get::<ExternalEditorPrefs>(EXTERNAL_EDITOR_SECTION)
|
||||
.expect("external-editor settings section must be registered");
|
||||
assert!(
|
||||
prefs.command.is_empty(),
|
||||
"default is empty = auto ($VISUAL/$EDITOR terminal tab, else xdg-open)"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
//! The editor's command terminal: runs a shell command and streams its output
|
||||
//! into the [Console](crate::console) panel.
|
||||
//!
|
||||
//! This is the second half of the Stage-10 editor terminal — the log-capture
|
||||
//! Console shows engine/script output, and this adds **command execution**: type
|
||||
//! a command, it runs (via `sh -c`) with the working directory set to the open
|
||||
//! project, and its stdout/stderr stream back into the same panel as they
|
||||
//! arrive. Long-running commands (a build, a watcher, an AI-agent CLI) stream
|
||||
//! line by line rather than blocking the editor — each line is pushed to the
|
||||
//! shared console buffer from a reader thread, and the panel re-renders it next
|
||||
//! frame.
|
||||
//!
|
||||
//! Running arbitrary commands from the editor is intended: the terminal is the
|
||||
//! drop-in surface for dev tools and AI agents that edit the watched scripts
|
||||
//! (whose edits then flow back through live reload).
|
||||
|
||||
use std::io::{BufRead, BufReader, Read};
|
||||
use std::path::Path;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use log::Level;
|
||||
|
||||
use crate::console;
|
||||
|
||||
/// The console target terminal lines are tagged with (distinguishes shell output
|
||||
/// from engine `log` records in the panel).
|
||||
const TARGET: &str = "terminal";
|
||||
|
||||
/// Spawns `command` with `sh -c` in `cwd`, streaming its stdout/stderr into the
|
||||
/// console. Returns immediately; output arrives asynchronously. A blank command
|
||||
/// is ignored.
|
||||
///
|
||||
/// The command is echoed first (`$ <command>`); stdout lines log at info level,
|
||||
/// stderr at warn (so errors stand out), and the exit status is reported when
|
||||
/// the process finishes.
|
||||
pub fn run(command: &str, cwd: &Path) {
|
||||
let command = command.trim();
|
||||
if command.is_empty() {
|
||||
return;
|
||||
}
|
||||
console::append(Level::Info, TARGET, format!("$ {command}"));
|
||||
|
||||
let child = Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg(command)
|
||||
.current_dir(cwd)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn();
|
||||
|
||||
let mut child = match child {
|
||||
Ok(child) => child,
|
||||
Err(err) => {
|
||||
console::append(Level::Error, TARGET, format!("failed to start: {err}"));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let stdout = child.stdout.take();
|
||||
let stderr = child.stderr.take();
|
||||
|
||||
// One supervisor thread owns the child: it streams both pipes (stderr on its
|
||||
// own thread so the two don't deadlock on full buffers), waits, and reports
|
||||
// the exit status. Detached — the panel reads results from the shared buffer.
|
||||
std::thread::spawn(move || {
|
||||
let err_thread = stderr.map(|e| std::thread::spawn(move || stream(e, Level::Warn)));
|
||||
if let Some(out) = stdout {
|
||||
stream(out, Level::Info);
|
||||
}
|
||||
if let Some(handle) = err_thread {
|
||||
let _ = handle.join();
|
||||
}
|
||||
match child.wait() {
|
||||
Ok(status) if status.success() => {
|
||||
console::append(Level::Info, TARGET, "(exit 0)");
|
||||
}
|
||||
Ok(status) => {
|
||||
let code = status
|
||||
.code()
|
||||
.map(|c| c.to_string())
|
||||
.unwrap_or_else(|| "signal".to_string());
|
||||
console::append(Level::Warn, TARGET, format!("(exit {code})"));
|
||||
}
|
||||
Err(err) => console::append(Level::Error, TARGET, format!("wait failed: {err}")),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Reads `reader` line by line, pushing each line into the console at `level`.
|
||||
fn stream<R: Read>(reader: R, level: Level) {
|
||||
let mut buf = BufReader::new(reader);
|
||||
let mut line = String::new();
|
||||
loop {
|
||||
line.clear();
|
||||
match buf.read_line(&mut line) {
|
||||
Ok(0) => break, // EOF
|
||||
Ok(_) => console::append(
|
||||
level,
|
||||
TARGET,
|
||||
line.trim_end_matches(['\n', '\r']).to_string(),
|
||||
),
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
//! The editor's 3D viewport: an orbit / flythrough camera and a forward
|
||||
//! render of the scene.
|
||||
//!
|
||||
//! The engine stays UI-agnostic; this glue lives in the editor. [`Viewport`]
|
||||
//! owns a [`ForwardRenderer`], a small cache of primitive [`GpuMesh`]es, two
|
||||
//! camera modes ([`OrbitCamera`] for inspecting a target,
|
||||
//! [`FlythroughCamera`] for free-look navigation), and draws every scene
|
||||
//! entity that carries a [`MeshRenderer`](oxide_engine::render::MeshRenderer)
|
||||
//! component. The mode toggle preserves pose so the camera does not snap
|
||||
//! when switching.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use oxide_engine::hecs::Entity;
|
||||
use oxide_engine::math::{EulerRot, Quat, Transform, Vec3};
|
||||
use oxide_engine::prelude::*;
|
||||
use oxide_engine::wgpu;
|
||||
use oxide_engine::window::RenderCtx;
|
||||
|
||||
/// An orbit camera: looks at `target` from a yaw/pitch/distance offset.
|
||||
pub struct OrbitCamera {
|
||||
/// The point the camera orbits and looks at.
|
||||
pub target: Vec3,
|
||||
/// Horizontal angle (radians) around `+Y`.
|
||||
pub yaw: f32,
|
||||
/// Vertical angle (radians); clamped to avoid flipping over the poles.
|
||||
pub pitch: f32,
|
||||
/// Distance from `target` to the eye.
|
||||
pub distance: f32,
|
||||
}
|
||||
|
||||
impl Default for OrbitCamera {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
target: Vec3::new(0.0, 0.8, 0.0),
|
||||
yaw: 0.6,
|
||||
pitch: -0.45,
|
||||
distance: 12.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl OrbitCamera {
|
||||
/// The camera's orientation as a quaternion.
|
||||
fn rotation(&self) -> Quat {
|
||||
Quat::from_euler(EulerRot::YXZ, self.yaw, self.pitch, 0.0)
|
||||
}
|
||||
|
||||
/// The eye position in world space.
|
||||
fn eye(&self) -> Vec3 {
|
||||
self.target + self.rotation() * Vec3::new(0.0, 0.0, self.distance)
|
||||
}
|
||||
|
||||
/// The camera's world transform (what the renderer takes as the view).
|
||||
pub fn view_transform(&self) -> Transform {
|
||||
Transform::looking_at(self.eye(), self.target, Vec3::Y)
|
||||
}
|
||||
|
||||
/// Orbit by a pixel drag delta.
|
||||
pub fn orbit(&mut self, dx: f32, dy: f32) {
|
||||
const SENS: f32 = 0.005;
|
||||
self.yaw -= dx * SENS;
|
||||
self.pitch = (self.pitch - dy * SENS).clamp(-1.54, 1.54);
|
||||
}
|
||||
|
||||
/// Pan the target in the camera's screen plane by a pixel drag delta.
|
||||
pub fn pan(&mut self, dx: f32, dy: f32) {
|
||||
let rot = self.rotation();
|
||||
let right = rot * Vec3::X;
|
||||
let up = rot * Vec3::Y;
|
||||
// Scale panning with distance so it feels consistent at any zoom.
|
||||
let speed = self.distance * 0.0015;
|
||||
self.target += (-right * dx + up * dy) * speed;
|
||||
}
|
||||
|
||||
/// Zoom by a scroll delta (positive = closer).
|
||||
pub fn zoom(&mut self, amount: f32) {
|
||||
self.distance = (self.distance * (1.0 - amount * 0.1)).clamp(0.5, 500.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// A free-look "flythrough" camera: a position in world space plus a
|
||||
/// yaw/pitch orientation, driven by WASD/QE translation + mouse-look in the
|
||||
/// usual first-person convention.
|
||||
///
|
||||
/// Distinct from [`OrbitCamera`] because the two modes have fundamentally
|
||||
/// different controls; switching between them preserves the camera pose via
|
||||
/// [`FlythroughCamera::from_orbit`] / [`OrbitCamera::from_flythrough`] so the
|
||||
/// view doesn't snap on toggle.
|
||||
pub struct FlythroughCamera {
|
||||
/// Eye position in world space.
|
||||
pub position: Vec3,
|
||||
/// Horizontal angle (radians) around `+Y`, matching [`OrbitCamera::yaw`].
|
||||
pub yaw: f32,
|
||||
/// Vertical angle (radians); clamped to avoid flipping over the poles.
|
||||
pub pitch: f32,
|
||||
/// Translation speed in world units per second at the base (non-sprint)
|
||||
/// rate. Adjustable at runtime — the editor binds scroll-wheel to this.
|
||||
pub move_speed: f32,
|
||||
/// Multiplier applied while the "sprint" action is held.
|
||||
pub sprint_multiplier: f32,
|
||||
}
|
||||
|
||||
impl Default for FlythroughCamera {
|
||||
fn default() -> Self {
|
||||
// Place the eye where the default OrbitCamera would put it, so a
|
||||
// fresh project that starts in flythrough mode (a future preference)
|
||||
// sees the same opening view.
|
||||
let orbit = OrbitCamera::default();
|
||||
Self::from_orbit(&orbit)
|
||||
}
|
||||
}
|
||||
|
||||
impl FlythroughCamera {
|
||||
/// Position the flythrough camera to look at the same view the given
|
||||
/// orbit camera is showing. The eye lands at the orbit camera's eye
|
||||
/// position and the yaw/pitch are copied verbatim.
|
||||
pub fn from_orbit(orbit: &OrbitCamera) -> Self {
|
||||
Self {
|
||||
position: orbit.eye(),
|
||||
yaw: orbit.yaw,
|
||||
pitch: orbit.pitch,
|
||||
move_speed: 5.0,
|
||||
sprint_multiplier: 4.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The camera's orientation as a quaternion (same Y-yaw-then-X-pitch
|
||||
/// convention as [`OrbitCamera::rotation`]).
|
||||
fn rotation(&self) -> Quat {
|
||||
Quat::from_euler(EulerRot::YXZ, self.yaw, self.pitch, 0.0)
|
||||
}
|
||||
|
||||
/// Unit world-space forward direction (where the camera looks).
|
||||
pub fn forward(&self) -> Vec3 {
|
||||
self.rotation() * Vec3::new(0.0, 0.0, -1.0)
|
||||
}
|
||||
|
||||
/// Unit world-space right direction (camera's screen-right).
|
||||
pub fn right(&self) -> Vec3 {
|
||||
self.rotation() * Vec3::X
|
||||
}
|
||||
|
||||
/// Unit world-space up direction.
|
||||
pub fn up(&self) -> Vec3 {
|
||||
self.rotation() * Vec3::Y
|
||||
}
|
||||
|
||||
/// The camera's world transform (what the renderer takes as the view).
|
||||
pub fn view_transform(&self) -> Transform {
|
||||
Transform::looking_at(self.position, self.position + self.forward(), Vec3::Y)
|
||||
}
|
||||
|
||||
/// Mouse-look by a pixel drag delta. Same sensitivity as
|
||||
/// [`OrbitCamera::orbit`] so the gesture feels identical in both modes.
|
||||
pub fn look(&mut self, dx: f32, dy: f32) {
|
||||
const SENS: f32 = 0.005;
|
||||
self.yaw -= dx * SENS;
|
||||
self.pitch = (self.pitch - dy * SENS).clamp(-1.54, 1.54);
|
||||
}
|
||||
|
||||
/// Translate by a per-frame move vector in **camera-local** axes (`+X`
|
||||
/// right, `+Y` up, `-Z` forward — the same convention game code uses for
|
||||
/// a first-person move input). Each axis is expected to be in `[-1, 1]`,
|
||||
/// the natural range of an [`AxisBinding`](oxide_engine::input::AxisBinding).
|
||||
pub fn translate_local(&mut self, local: Vec3, dt: f32, sprint: bool) {
|
||||
if local.length_squared() == 0.0 {
|
||||
return;
|
||||
}
|
||||
let speed = if sprint {
|
||||
self.move_speed * self.sprint_multiplier
|
||||
} else {
|
||||
self.move_speed
|
||||
};
|
||||
// `local` is in camera-local axes (right / up / forward). Convert to
|
||||
// world by combining with the camera basis. `-Z` is forward, so a
|
||||
// local.z of `-1.0` (from a "forward" axis) moves along +forward.
|
||||
let world = self.right() * local.x + self.up() * local.y + self.forward() * (-local.z);
|
||||
self.position += world * (speed * dt);
|
||||
}
|
||||
|
||||
/// Adjust the base move speed by a scroll-wheel delta. Clamped so the
|
||||
/// camera never becomes immobile or too fast to control.
|
||||
pub fn adjust_move_speed(&mut self, scroll_lines: f32) {
|
||||
let factor = (1.0 + scroll_lines * 0.1).max(0.1);
|
||||
self.move_speed = (self.move_speed * factor).clamp(0.5, 200.0);
|
||||
}
|
||||
}
|
||||
|
||||
impl OrbitCamera {
|
||||
/// Position an orbit camera so it shows the same view as the given
|
||||
/// flythrough camera. The target is placed [`OrbitCamera::distance`]
|
||||
/// units in front of the flythrough's eye along its forward direction.
|
||||
pub fn from_flythrough(fly: &FlythroughCamera) -> Self {
|
||||
let distance = OrbitCamera::default().distance;
|
||||
Self {
|
||||
target: fly.position + fly.forward() * distance,
|
||||
yaw: fly.yaw,
|
||||
pitch: fly.pitch,
|
||||
distance,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Which input scheme drives the viewport camera.
|
||||
///
|
||||
/// [`Orbit`](Self::Orbit) is the default editor convention — useful for
|
||||
/// inspecting a single subject. [`Flythrough`](Self::Flythrough) is a
|
||||
/// first-person fly: WASD/QE translate, right-drag looks around, scroll
|
||||
/// adjusts move speed; better for navigating a level or open scene.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CameraMode {
|
||||
Orbit,
|
||||
Flythrough,
|
||||
}
|
||||
|
||||
/// Owns the renderer, primitive mesh cache, camera, and lighting for the editor
|
||||
/// viewport.
|
||||
pub struct Viewport {
|
||||
pipeline: RenderPipeline,
|
||||
meshes: HashMap<PrimitiveShape, GpuMesh>,
|
||||
pub camera: Camera,
|
||||
pub orbit: OrbitCamera,
|
||||
pub flythrough: FlythroughCamera,
|
||||
pub mode: CameraMode,
|
||||
pub lighting: Lighting,
|
||||
}
|
||||
|
||||
impl Viewport {
|
||||
/// Builds the viewport, uploading a GPU mesh for every primitive shape.
|
||||
pub fn new(device: &wgpu::Device, color_format: wgpu::TextureFormat) -> Self {
|
||||
let meshes = PrimitiveShape::ALL
|
||||
.iter()
|
||||
.map(|&shape| (shape, shape.mesh().upload(device, shape.label())))
|
||||
.collect();
|
||||
// The editor clears the frame before drawing the scene, so the viewport
|
||||
// pipeline is just the forward pass; post passes slot in here later.
|
||||
let mut pipeline = RenderPipeline::new();
|
||||
pipeline.add_pass("forward", ForwardPass::new(device, color_format));
|
||||
let orbit = OrbitCamera::default();
|
||||
let flythrough = FlythroughCamera::from_orbit(&orbit);
|
||||
Self {
|
||||
pipeline,
|
||||
meshes,
|
||||
camera: Camera::default(),
|
||||
orbit,
|
||||
flythrough,
|
||||
mode: CameraMode::Orbit,
|
||||
lighting: Lighting::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The view transform of the **active** camera (whichever mode is
|
||||
/// currently selected).
|
||||
pub fn view_transform(&self) -> Transform {
|
||||
match self.mode {
|
||||
CameraMode::Orbit => self.orbit.view_transform(),
|
||||
CameraMode::Flythrough => self.flythrough.view_transform(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Swaps between orbit and flythrough modes while preserving pose, so
|
||||
/// the visible scene does not jump when the user toggles. Returns the
|
||||
/// new mode for the caller to surface in the status bar.
|
||||
pub fn toggle_camera_mode(&mut self) -> CameraMode {
|
||||
match self.mode {
|
||||
CameraMode::Orbit => {
|
||||
self.flythrough = FlythroughCamera::from_orbit(&self.orbit);
|
||||
self.mode = CameraMode::Flythrough;
|
||||
}
|
||||
CameraMode::Flythrough => {
|
||||
self.orbit = OrbitCamera::from_flythrough(&self.flythrough);
|
||||
self.mode = CameraMode::Orbit;
|
||||
}
|
||||
}
|
||||
self.mode
|
||||
}
|
||||
|
||||
/// Renders the scene's renderable entities into the frame, before the editor
|
||||
/// UI is painted on top.
|
||||
///
|
||||
/// `viewport_rect` restricts drawing and projection to the Viewport
|
||||
/// tab's sub-rectangle of the surface (in physical pixels). `None`
|
||||
/// falls back to the full surface — handy for early frames before
|
||||
/// egui has reported a rect, and for any host that wants to render
|
||||
/// edge-to-edge.
|
||||
pub fn render(
|
||||
&mut self,
|
||||
scene: &Scene,
|
||||
ctx: &RenderCtx<'_>,
|
||||
viewport_rect: Option<oxide_engine::math::Rect>,
|
||||
) {
|
||||
// Snapshot renderables first so the query borrow is released before we
|
||||
// resolve world transforms.
|
||||
let renderables: Vec<(Entity, MeshRenderer)> = scene
|
||||
.world()
|
||||
.query::<&MeshRenderer>()
|
||||
.iter()
|
||||
.map(|(e, mr)| (e, *mr))
|
||||
.collect();
|
||||
|
||||
let view = self.view_transform();
|
||||
let mut objects = Vec::with_capacity(renderables.len());
|
||||
for (entity, mr) in &renderables {
|
||||
// Hierarchical: a disabled ancestor hides its whole subtree.
|
||||
if !scene.is_effectively_enabled(*entity).unwrap_or(true) {
|
||||
continue;
|
||||
}
|
||||
// Per-component: the MeshRenderer itself may be marked disabled
|
||||
// (e.g. by a script before a trigger fires).
|
||||
if scene.is_component_disabled(*entity, "MeshRenderer") {
|
||||
continue;
|
||||
}
|
||||
// Honor the camera's layer visibility: entities default to the
|
||||
// Default layer when they carry no explicit `Layer` component.
|
||||
let layers = scene.get::<Layer>(*entity).map(|l| *l).unwrap_or_default();
|
||||
if !self.camera.sees(layers) {
|
||||
continue;
|
||||
}
|
||||
let Some(world) = scene.world_transform(*entity) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(mesh) = self.meshes.get(&mr.shape) {
|
||||
objects.push(RenderObject {
|
||||
mesh,
|
||||
material: mr.material,
|
||||
transform: world,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
self.pipeline.render(&mut FrameContext {
|
||||
device: ctx.gpu.device(),
|
||||
queue: ctx.gpu.queue(),
|
||||
color: ctx.view,
|
||||
size: ctx.size,
|
||||
viewport_rect,
|
||||
clear_color: Color::BLACK, // editor clears separately; unused here
|
||||
camera: &self.camera,
|
||||
view_transform: &view,
|
||||
lighting: &self.lighting,
|
||||
objects: &objects,
|
||||
});
|
||||
}
|
||||
|
||||
/// Builds the world-space ray from `cursor` (window-physical pixels)
|
||||
/// through the viewport using the active camera's projection. The same
|
||||
/// helper feeds both entity picking and gizmo handle hit-testing — they
|
||||
/// must agree on the math or a click on a handle won't line up with
|
||||
/// what the user sees.
|
||||
pub fn ray_from_cursor(
|
||||
&self,
|
||||
cursor: (f32, f32),
|
||||
size: (u32, u32),
|
||||
viewport_rect: Option<oxide_engine::math::Rect>,
|
||||
) -> Ray {
|
||||
let rect = viewport_rect.unwrap_or_else(|| {
|
||||
oxide_engine::math::Rect::from_min_size(
|
||||
oxide_engine::math::Vec2::ZERO,
|
||||
oxide_engine::math::Vec2::new(size.0.max(1) as f32, size.1.max(1) as f32),
|
||||
)
|
||||
});
|
||||
let (w, h) = (rect.width().max(1.0), rect.height().max(1.0));
|
||||
// Cursor is in window coords; rebase to viewport-local before NDC.
|
||||
let local_x = cursor.0 - rect.min.x;
|
||||
let local_y = cursor.1 - rect.min.y;
|
||||
// Cursor → normalized device coordinates (flip Y: screen down, NDC up).
|
||||
let ndc_x = 2.0 * local_x / w - 1.0;
|
||||
let ndc_y = 1.0 - 2.0 * local_y / h;
|
||||
|
||||
let view = self.view_transform();
|
||||
let inv_vp = self.camera.view_projection(w / h, &view).inverse();
|
||||
// Unproject the near and far points of the pixel into world space.
|
||||
let near = inv_vp.project_point3(Vec3::new(ndc_x, ndc_y, 0.0));
|
||||
let far = inv_vp.project_point3(Vec3::new(ndc_x, ndc_y, 1.0));
|
||||
Ray::new(near, (far - near).normalize_or_zero())
|
||||
}
|
||||
|
||||
/// The combined view-projection matrix the viewport uses for `viewport_rect`'s
|
||||
/// aspect ratio. Exposed so the gizmo overlay can project world points
|
||||
/// to screen pixels with the same math the renderer drew with.
|
||||
pub fn view_projection_for(
|
||||
&self,
|
||||
viewport_rect: Option<oxide_engine::math::Rect>,
|
||||
size: (u32, u32),
|
||||
) -> oxide_engine::math::Mat4 {
|
||||
let rect = viewport_rect.unwrap_or_else(|| {
|
||||
oxide_engine::math::Rect::from_min_size(
|
||||
oxide_engine::math::Vec2::ZERO,
|
||||
oxide_engine::math::Vec2::new(size.0.max(1) as f32, size.1.max(1) as f32),
|
||||
)
|
||||
});
|
||||
let aspect = rect.width().max(1.0) / rect.height().max(1.0);
|
||||
let view = self.view_transform();
|
||||
self.camera.view_projection(aspect, &view)
|
||||
}
|
||||
|
||||
/// Picks the nearest renderable entity under the cursor (physical pixels),
|
||||
/// by casting a ray through the viewport and testing each entity's
|
||||
/// world-space bounds. Returns `None` if the ray hits nothing.
|
||||
///
|
||||
/// `viewport_rect` is the same sub-rectangle the render path used (the
|
||||
/// Viewport tab in the editor's case); the cursor is converted to NDC
|
||||
/// relative to it so a click at the tab's edge corresponds to the ray
|
||||
/// through that edge — not through the corresponding spot in a full-
|
||||
/// window projection. `None` falls back to the full window.
|
||||
pub fn pick(
|
||||
&self,
|
||||
scene: &Scene,
|
||||
cursor: (f32, f32),
|
||||
size: (u32, u32),
|
||||
viewport_rect: Option<oxide_engine::math::Rect>,
|
||||
) -> Option<Entity> {
|
||||
let ray = self.ray_from_cursor(cursor, size, viewport_rect);
|
||||
|
||||
let renderables: Vec<(Entity, MeshRenderer)> = scene
|
||||
.world()
|
||||
.query::<&MeshRenderer>()
|
||||
.iter()
|
||||
.map(|(e, mr)| (e, *mr))
|
||||
.collect();
|
||||
|
||||
let mut best: Option<(f32, Entity)> = None;
|
||||
for (entity, mr) in renderables {
|
||||
// Don't pick what isn't visible (effectively disabled subtree, or
|
||||
// a per-component disable on the MeshRenderer).
|
||||
if scene.is_component_disabled(entity, "MeshRenderer") {
|
||||
continue;
|
||||
}
|
||||
if !scene.is_effectively_enabled(entity).unwrap_or(true) {
|
||||
continue;
|
||||
}
|
||||
let Some(world) = scene.world_transform(entity) else {
|
||||
continue;
|
||||
};
|
||||
let aabb = transform_aabb(&world, &mr.shape.local_bounds());
|
||||
if let Some(t) = aabb.ray_intersection(&ray) {
|
||||
if best.map_or(true, |(bt, _)| t < bt) {
|
||||
best = Some((t, entity));
|
||||
}
|
||||
}
|
||||
}
|
||||
best.map(|(_, e)| e)
|
||||
}
|
||||
}
|
||||
|
||||
/// The world-space AABB of a local AABB transformed by `t` (transform its 8
|
||||
/// corners and re-fit).
|
||||
fn transform_aabb(t: &Transform, local: &oxide_engine::math::Aabb) -> oxide_engine::math::Aabb {
|
||||
oxide_engine::math::Aabb::from_points(local.corners().iter().map(|&c| t.transform_point(c)))
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "oxide-engine-derive"
|
||||
description = "Derive macros for Oxide's reflection system (#[derive(Reflect)])"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[lib]
|
||||
proc-macro = true
|
||||
|
||||
[dependencies]
|
||||
syn.workspace = true
|
||||
quote.workspace = true
|
||||
proc-macro2.workspace = true
|
||||
@@ -0,0 +1,257 @@
|
||||
//! Derive macros for Oxide's reflection system.
|
||||
//!
|
||||
//! This crate exists for exactly one job: `#[derive(Reflect)]`. It is the
|
||||
//! compile-time half of the engine's **dual-editable types** principle —
|
||||
//! every component's public fields should be editable from the editor
|
||||
//! inspector and from scripts through *one* representation, with no
|
||||
//! hand-written per-type code. The runtime half (the `Reflect` trait, the
|
||||
//! `FieldInfo` descriptor, and the `TypeRegistry`) lives in
|
||||
//! `oxide_engine::reflect`; this crate only generates the trait impl.
|
||||
//!
|
||||
//! ## What the derive generates
|
||||
//!
|
||||
//! For a struct with named fields, `#[derive(Reflect)]` emits an
|
||||
//! `oxide_engine::reflect::Reflect` impl that exposes each **public**,
|
||||
//! non-skipped field as:
|
||||
//!
|
||||
//! - a static [`FieldInfo`] entry (`name` + syntactic `type_name`), so a
|
||||
//! generic inspector can enumerate fields and pick a widget per type, and
|
||||
//! - per-field RON get/set, so a single field can be read or written without
|
||||
//! touching the rest of the component (the unit an inspector edits).
|
||||
//!
|
||||
//! Only `pub` fields are reflected — this matches the Unity/Godot convention
|
||||
//! that *public* fields are the editable surface. Use `#[reflect(skip)]` to
|
||||
//! exclude a public field.
|
||||
//!
|
||||
//! ```ignore
|
||||
//! use oxide_engine::reflect::Reflect;
|
||||
//!
|
||||
//! #[derive(Reflect, serde::Serialize, serde::Deserialize)]
|
||||
//! struct Timer {
|
||||
//! pub repeating: bool,
|
||||
//! pub duration: f32,
|
||||
//! #[reflect(skip)]
|
||||
//! pub elapsed: f32, // runtime state — not an authored field
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! Every reflected field must itself be `serde`-serializable, since get/set
|
||||
//! round-trip through RON.
|
||||
|
||||
use proc_macro::TokenStream;
|
||||
use quote::quote;
|
||||
use syn::{parse_macro_input, Data, DeriveInput, Fields, Visibility};
|
||||
|
||||
/// Derives `oxide_engine::reflect::Reflect` for a struct with named fields.
|
||||
///
|
||||
/// See the [crate-level docs](crate) for the field-selection rules
|
||||
/// (public-only, `#[reflect(skip)]`).
|
||||
#[proc_macro_derive(Reflect, attributes(reflect))]
|
||||
pub fn derive_reflect(input: TokenStream) -> TokenStream {
|
||||
let input = parse_macro_input!(input as DeriveInput);
|
||||
let name = &input.ident;
|
||||
let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
|
||||
|
||||
// Named-field structs and tuple structs are both supported. Tuple-struct
|
||||
// fields are addressed by their positional index ("0", "1", …), matching
|
||||
// Rust's own `self.0` / `self.1` syntax — this lets one-field newtype
|
||||
// components like `Layers(pub LayerMask)` reflect without a wrapper.
|
||||
let raw_fields = match &input.data {
|
||||
Data::Struct(data) => match &data.fields {
|
||||
Fields::Named(named) => named.named.iter().collect::<Vec<_>>(),
|
||||
Fields::Unnamed(unnamed) => unnamed.unnamed.iter().collect::<Vec<_>>(),
|
||||
Fields::Unit => {
|
||||
return compile_error(name, "Reflect cannot be derived for unit structs")
|
||||
}
|
||||
},
|
||||
_ => return compile_error(name, "Reflect can only be derived for structs"),
|
||||
};
|
||||
|
||||
let mut infos = Vec::new();
|
||||
let mut get_arms = Vec::new();
|
||||
let mut set_arms = Vec::new();
|
||||
|
||||
for (index, field) in raw_fields.iter().enumerate() {
|
||||
// Public-only: private fields are implementation detail, not the
|
||||
// authored/editable surface.
|
||||
if !matches!(field.vis, Visibility::Public(_)) {
|
||||
continue;
|
||||
}
|
||||
let attrs = parse_field_attrs(field);
|
||||
if attrs.skip {
|
||||
continue;
|
||||
}
|
||||
|
||||
// For named structs the field name + accessor is the ident; for tuple
|
||||
// structs the name is the index as a string and the accessor is the
|
||||
// syn::Index token (which renders as `0`, `1`, ...).
|
||||
let (field_name, accessor) = match &field.ident {
|
||||
Some(ident) => (ident.to_string(), quote!(#ident)),
|
||||
None => {
|
||||
let idx = syn::Index::from(index);
|
||||
(index.to_string(), quote!(#idx))
|
||||
}
|
||||
};
|
||||
let ty = &field.ty;
|
||||
// Syntactic type text, e.g. "f32", "bool", "Vec3", "Handle < Font >".
|
||||
// The inspector dispatches a widget on this; unknown types fall back to
|
||||
// a raw RON editor.
|
||||
let type_name = quote!(#ty).to_string();
|
||||
|
||||
let range_tokens = match attrs.range {
|
||||
Some((min, max)) => quote! {
|
||||
::core::option::Option::Some((#min, #max))
|
||||
},
|
||||
None => quote! { ::core::option::Option::None },
|
||||
};
|
||||
|
||||
infos.push(quote! {
|
||||
::oxide_engine::reflect::FieldInfo {
|
||||
name: #field_name,
|
||||
type_name: #type_name,
|
||||
range: #range_tokens,
|
||||
}
|
||||
});
|
||||
get_arms.push(quote! {
|
||||
#field_name => ::oxide_engine::reflect::__reflect_to_ron(&self.#accessor),
|
||||
});
|
||||
set_arms.push(quote! {
|
||||
#field_name => {
|
||||
self.#accessor = ::oxide_engine::reflect::__reflect_from_ron(#field_name, value)?;
|
||||
::core::result::Result::Ok(())
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let field_count = infos.len();
|
||||
|
||||
quote! {
|
||||
impl #impl_generics ::oxide_engine::reflect::Reflect for #name #ty_generics #where_clause {
|
||||
fn fields(&self) -> &'static [::oxide_engine::reflect::FieldInfo] {
|
||||
static FIELDS: [::oxide_engine::reflect::FieldInfo; #field_count] = [
|
||||
#(#infos),*
|
||||
];
|
||||
&FIELDS
|
||||
}
|
||||
|
||||
fn get_field(&self, name: &str) -> ::core::option::Option<::std::string::String> {
|
||||
match name {
|
||||
#(#get_arms)*
|
||||
_ => ::core::option::Option::None,
|
||||
}
|
||||
}
|
||||
|
||||
fn set_field(
|
||||
&mut self,
|
||||
name: &str,
|
||||
value: &str,
|
||||
) -> ::core::result::Result<(), ::oxide_engine::reflect::ReflectError> {
|
||||
match name {
|
||||
#(#set_arms)*
|
||||
_ => ::core::result::Result::Err(
|
||||
::oxide_engine::reflect::ReflectError::UnknownField(
|
||||
::std::string::ToString::to_string(name),
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Parsed `#[reflect(...)]` attributes on a single field.
|
||||
#[derive(Default)]
|
||||
struct FieldAttrs {
|
||||
/// `#[reflect(skip)]` — exclude this public field from reflection.
|
||||
skip: bool,
|
||||
/// `#[reflect(min = X, max = Y)]` — numeric bounds passed to inspector
|
||||
/// widgets so a normalized `f32` field becomes a slider instead of a drag.
|
||||
/// Both must be present for a range to be recorded.
|
||||
range: Option<(f32, f32)>,
|
||||
}
|
||||
|
||||
fn parse_field_attrs(field: &syn::Field) -> FieldAttrs {
|
||||
let mut out = FieldAttrs::default();
|
||||
let mut min: Option<f32> = None;
|
||||
let mut max: Option<f32> = None;
|
||||
for attr in &field.attrs {
|
||||
if !attr.path().is_ident("reflect") {
|
||||
continue;
|
||||
}
|
||||
let _ = attr.parse_nested_meta(|meta| {
|
||||
if meta.path.is_ident("skip") {
|
||||
out.skip = true;
|
||||
} else if meta.path.is_ident("min") {
|
||||
let lit: syn::LitFloat = meta.value()?.parse()?;
|
||||
min = Some(lit.base10_parse::<f32>()?);
|
||||
} else if meta.path.is_ident("max") {
|
||||
let lit: syn::LitFloat = meta.value()?.parse()?;
|
||||
max = Some(lit.base10_parse::<f32>()?);
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
if let (Some(mn), Some(mx)) = (min, max) {
|
||||
out.range = Some((mn, mx));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Derives `oxide_engine::reflect::ReflectEnum` for a fieldless (C-like) enum,
|
||||
/// exposing its variant names so a generic inspector can render a dropdown for
|
||||
/// fields of that enum type.
|
||||
///
|
||||
/// Only **unit** variants are supported — a variant carrying data has no single
|
||||
/// "pick from a list" representation. Variant names round-trip as RON (a unit
|
||||
/// variant `Foo::Bar` serializes as `Bar`), which is exactly what `set_field`
|
||||
/// consumes.
|
||||
///
|
||||
/// ```ignore
|
||||
/// use oxide_engine::reflect::ReflectEnum;
|
||||
///
|
||||
/// #[derive(ReflectEnum, serde::Serialize, serde::Deserialize)]
|
||||
/// enum Facing { North, East, South, West }
|
||||
/// assert_eq!(Facing::variants(), &["North", "East", "South", "West"]);
|
||||
/// ```
|
||||
#[proc_macro_derive(ReflectEnum)]
|
||||
pub fn derive_reflect_enum(input: TokenStream) -> TokenStream {
|
||||
let input = parse_macro_input!(input as DeriveInput);
|
||||
let name = &input.ident;
|
||||
let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
|
||||
|
||||
let data = match &input.data {
|
||||
Data::Enum(data) => data,
|
||||
_ => return compile_error(name, "ReflectEnum can only be derived for enums"),
|
||||
};
|
||||
|
||||
let mut variant_names = Vec::new();
|
||||
for variant in &data.variants {
|
||||
if !matches!(variant.fields, Fields::Unit) {
|
||||
return compile_error(
|
||||
&variant.ident,
|
||||
"ReflectEnum requires unit (fieldless) variants",
|
||||
);
|
||||
}
|
||||
variant_names.push(variant.ident.to_string());
|
||||
}
|
||||
let count = variant_names.len();
|
||||
|
||||
quote! {
|
||||
impl #impl_generics ::oxide_engine::reflect::ReflectEnum for #name #ty_generics #where_clause {
|
||||
fn variants() -> &'static [&'static str] {
|
||||
static VARIANTS: [&str; #count] = [ #(#variant_names),* ];
|
||||
&VARIANTS
|
||||
}
|
||||
}
|
||||
}
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Emit a `compile_error!` at the derived type so the message is attributed
|
||||
/// to the user's struct, not somewhere inside the generated impl.
|
||||
fn compile_error(name: &syn::Ident, message: &str) -> TokenStream {
|
||||
syn::Error::new(name.span(), message)
|
||||
.to_compile_error()
|
||||
.into()
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
[package]
|
||||
name = "oxide-engine"
|
||||
description = "Oxide 3D game engine — core library"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
glam.workspace = true
|
||||
hecs.workspace = true
|
||||
winit.workspace = true
|
||||
wgpu.workspace = true
|
||||
pollster.workspace = true
|
||||
bytemuck.workspace = true
|
||||
gltf.workspace = true
|
||||
ab_glyph.workspace = true
|
||||
log.workspace = true
|
||||
anyhow.workspace = true
|
||||
thiserror.workspace = true
|
||||
serde.workspace = true
|
||||
ron.workspace = true
|
||||
notify.workspace = true
|
||||
oxide-engine-derive = { path = "../engine-derive" }
|
||||
|
||||
[dev-dependencies]
|
||||
env_logger.workspace = true
|
||||
criterion.workspace = true
|
||||
|
||||
[[bench]]
|
||||
name = "transform"
|
||||
harness = false
|
||||
|
||||
[[bench]]
|
||||
name = "scene"
|
||||
harness = false
|
||||
|
||||
[[bench]]
|
||||
name = "app"
|
||||
harness = false
|
||||
@@ -0,0 +1,31 @@
|
||||
//! Benchmark for the Stage 5 schedule/module overhead.
|
||||
//!
|
||||
//! Stage 5 criterion: module/system scheduling overhead must be negligible
|
||||
//! compared to the Stage-4 hardcoded loop. There is no per-frame work here — the
|
||||
//! benchmark measures the *frame overhead itself*: advancing timing, walking the
|
||||
//! phase lists, and the fixed-timestep accumulator, with a realistic handful of
|
||||
//! empty systems registered. Check that `app_empty_update` is in the low
|
||||
//! nanoseconds (i.e. lost in the noise next to any real system's work).
|
||||
|
||||
use criterion::{black_box, criterion_group, criterion_main, Criterion};
|
||||
use oxide_engine::app::{App, Schedule};
|
||||
|
||||
fn empty_update(c: &mut Criterion) {
|
||||
let mut app = App::new();
|
||||
// A few no-op systems spread across phases, as a trivial game might have.
|
||||
for _ in 0..4 {
|
||||
app.add_system(Schedule::Update, |_| {});
|
||||
}
|
||||
app.add_system(Schedule::FixedUpdate, |_| {});
|
||||
app.add_system(Schedule::Render, |_| {});
|
||||
|
||||
c.bench_function("app_empty_update", |bencher| {
|
||||
bencher.iter(|| {
|
||||
app.update(black_box(1.0 / 60.0));
|
||||
black_box(app.time.frame)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
criterion_group!(benches, empty_update);
|
||||
criterion_main!(benches);
|
||||
@@ -0,0 +1,58 @@
|
||||
//! Benchmark for scene world-transform resolution.
|
||||
//!
|
||||
//! Stage 3 test criterion: a 10,000-entity scene with a 5-level-deep hierarchy
|
||||
//! must resolve all world transforms in under 1ms. The `world_transforms_10k`
|
||||
//! benchmark builds exactly that scene and measures a full bulk resolve; check
|
||||
//! its reported time against the 1ms budget.
|
||||
|
||||
use criterion::{black_box, criterion_group, criterion_main, Criterion};
|
||||
use oxide_engine::math::{Transform, Vec3};
|
||||
use oxide_engine::scene::{Entity, Scene};
|
||||
|
||||
/// Builds a scene of `total` entities arranged as a `depth`-level hierarchy.
|
||||
///
|
||||
/// Level 0 holds the roots; each subsequent level's entities are distributed as
|
||||
/// children of the previous level, so the tree is `depth` levels deep and the
|
||||
/// node count is exactly `total`.
|
||||
fn build_scene(total: usize, depth: usize) -> Scene {
|
||||
let mut scene = Scene::new();
|
||||
let per_level = total / depth;
|
||||
let mut previous: Vec<Entity> = Vec::new();
|
||||
|
||||
for level in 0..depth {
|
||||
// The last level absorbs any remainder so the count is exact.
|
||||
let count = if level == depth - 1 {
|
||||
total - per_level * (depth - 1)
|
||||
} else {
|
||||
per_level
|
||||
};
|
||||
let mut current = Vec::with_capacity(count);
|
||||
for i in 0..count {
|
||||
let t = Transform::from_translation(Vec3::new(0.01 * i as f32, 0.02, 0.03));
|
||||
let entity = if previous.is_empty() {
|
||||
scene.spawn("n", t)
|
||||
} else {
|
||||
// Spread children across the previous level round-robin.
|
||||
scene.spawn_child(previous[i % previous.len()], "n", t)
|
||||
};
|
||||
current.push(entity);
|
||||
}
|
||||
previous = current;
|
||||
}
|
||||
scene
|
||||
}
|
||||
|
||||
fn world_transforms_10k(c: &mut Criterion) {
|
||||
let scene = build_scene(10_000, 5);
|
||||
assert_eq!(scene.len(), 10_000);
|
||||
|
||||
c.bench_function("world_transforms_10k_depth5", |bencher| {
|
||||
bencher.iter(|| {
|
||||
let resolved = scene.world_transforms();
|
||||
black_box(resolved.len())
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
criterion_group!(benches, world_transforms_10k);
|
||||
criterion_main!(benches);
|
||||
@@ -0,0 +1,57 @@
|
||||
//! Benchmark for transform composition.
|
||||
//!
|
||||
//! Stage 1 test criterion: 1M transform multiplications must complete under
|
||||
//! 10ms. The `compose_1m` benchmark below measures exactly that workload; check
|
||||
//! its reported time against the 10ms budget.
|
||||
|
||||
use criterion::{black_box, criterion_group, criterion_main, Criterion};
|
||||
use oxide_engine::math::{Quat, Transform, Vec3};
|
||||
|
||||
fn compose_1m(c: &mut Criterion) {
|
||||
// A representative non-trivial transform (uniform scale → exact fast path).
|
||||
let a = Transform::from_trs(
|
||||
Vec3::new(1.0, 2.0, 3.0),
|
||||
Quat::from_euler(glam::EulerRot::XYZ, 0.3, 0.5, 0.7),
|
||||
Vec3::splat(1.5),
|
||||
);
|
||||
let b = Transform::from_trs(
|
||||
Vec3::new(-2.0, 0.5, 4.0),
|
||||
Quat::from_rotation_y(0.9),
|
||||
Vec3::splat(0.8),
|
||||
);
|
||||
|
||||
c.bench_function("compose_1m", |bencher| {
|
||||
bencher.iter(|| {
|
||||
// Compose 1M times. Inputs are re-fetched through `black_box` each
|
||||
// iteration so the optimizer can neither hoist the call nor let the
|
||||
// accumulated values blow up to infinity; the product is consumed.
|
||||
let mut acc = Vec3::ZERO;
|
||||
for _ in 0..1_000_000 {
|
||||
let product = black_box(a).mul_transform(&black_box(b));
|
||||
acc += product.translation;
|
||||
}
|
||||
black_box(acc)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn point_transform_1m(c: &mut Criterion) {
|
||||
let t = Transform::from_trs(
|
||||
Vec3::new(1.0, 2.0, 3.0),
|
||||
Quat::from_rotation_z(0.6),
|
||||
Vec3::splat(2.0),
|
||||
);
|
||||
c.bench_function("transform_point_1m", |bencher| {
|
||||
bencher.iter(|| {
|
||||
let mut acc = Vec3::ZERO;
|
||||
for i in 0..1_000_000u32 {
|
||||
let p = Vec3::splat(i as f32 * 1e-6);
|
||||
acc += t.transform_point(black_box(p));
|
||||
}
|
||||
black_box(acc)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
criterion_group!(benches, compose_1m, point_transform_1m);
|
||||
criterion_main!(benches);
|
||||
@@ -0,0 +1,503 @@
|
||||
//! The application core: an [`App`] assembled by registering [`Module`]s.
|
||||
//!
|
||||
//! Stage 5 ties the core framework together. An `App` owns the shared engine
|
||||
//! state — the [`Scene`], the [`AssetServer`], the [`TypeRegistry`], the
|
||||
//! [`LayerRegistry`], frame [`Time`], and arbitrary user resources — plus a
|
||||
//! [`Schedule`] of systems. Functionality is added by **modules**: each
|
||||
//! [`Module::build`] registers systems, component types, asset loaders, and
|
||||
//! resources, so the engine is composed rather than hard-wired and an exported
|
||||
//! game compiles in only the modules it uses.
|
||||
//!
|
||||
//! ```
|
||||
//! use oxide_engine::app::{App, DefaultModules};
|
||||
//!
|
||||
//! let mut app = App::new();
|
||||
//! app.add_modules(DefaultModules);
|
||||
//! app.update(1.0 / 60.0); // advance one frame
|
||||
//! ```
|
||||
|
||||
mod module;
|
||||
mod schedule;
|
||||
|
||||
pub use module::{CoreModule, DefaultModules, Module, RenderModule};
|
||||
pub use schedule::Schedule;
|
||||
|
||||
use std::any::{Any, TypeId};
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
use schedule::{run_phase, SystemEntry, Systems};
|
||||
|
||||
use crate::asset::{AssetLoader, AssetServer};
|
||||
use crate::layer::LayerRegistry;
|
||||
use crate::reflect::TypeRegistry;
|
||||
use crate::scene::Scene;
|
||||
|
||||
/// The default fixed-timestep duration (60 Hz) for [`Schedule::FixedUpdate`].
|
||||
pub const DEFAULT_FIXED_TIMESTEP: f32 = 1.0 / 60.0;
|
||||
|
||||
/// An upper bound on fixed steps per frame, so a long stall (e.g. a breakpoint)
|
||||
/// cannot trigger an unbounded catch-up "spiral of death".
|
||||
const MAX_FIXED_STEPS_PER_FRAME: u32 = 8;
|
||||
|
||||
/// Per-frame timing, refreshed by [`App::update`] and readable by systems.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Time {
|
||||
/// Seconds elapsed since the previous frame.
|
||||
pub delta: f32,
|
||||
/// Seconds elapsed since the app started.
|
||||
pub elapsed: f32,
|
||||
/// The fixed-timestep duration used by [`Schedule::FixedUpdate`].
|
||||
pub fixed_delta: f32,
|
||||
/// Frames advanced so far.
|
||||
pub frame: u64,
|
||||
}
|
||||
|
||||
impl Default for Time {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
delta: 0.0,
|
||||
elapsed: 0.0,
|
||||
fixed_delta: DEFAULT_FIXED_TIMESTEP,
|
||||
frame: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The application core. See the [module docs](self).
|
||||
pub struct App {
|
||||
/// The active scene graph.
|
||||
pub scene: Scene,
|
||||
/// The shared asset server (built-in loaders registered).
|
||||
pub assets: AssetServer,
|
||||
/// The reflection/type registry for dual-editable components.
|
||||
pub types: TypeRegistry,
|
||||
/// The project's named layers.
|
||||
pub layers: LayerRegistry,
|
||||
/// Per-frame timing.
|
||||
pub time: Time,
|
||||
|
||||
resources: HashMap<TypeId, Box<dyn Any>>,
|
||||
systems: Systems,
|
||||
|
||||
/// Registered modules → enabled flag.
|
||||
modules: BTreeMap<&'static str, bool>,
|
||||
/// The module currently being built, so registrations can be attributed.
|
||||
current_module: Option<&'static str>,
|
||||
/// Per-module bookkeeping for clean removal.
|
||||
module_types: HashMap<&'static str, Vec<&'static str>>,
|
||||
module_loaders: HashMap<&'static str, Vec<String>>,
|
||||
module_resources: HashMap<&'static str, Vec<TypeId>>,
|
||||
|
||||
fixed_accumulator: f32,
|
||||
}
|
||||
|
||||
impl App {
|
||||
/// A new app with empty core state and no modules. The [`AssetServer`] comes
|
||||
/// with the engine's built-in loaders already registered.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
scene: Scene::new(),
|
||||
assets: AssetServer::new(),
|
||||
types: TypeRegistry::new(),
|
||||
layers: LayerRegistry::new(),
|
||||
time: Time::default(),
|
||||
resources: HashMap::new(),
|
||||
systems: Systems::default(),
|
||||
modules: BTreeMap::new(),
|
||||
current_module: None,
|
||||
module_types: HashMap::new(),
|
||||
module_loaders: HashMap::new(),
|
||||
module_resources: HashMap::new(),
|
||||
fixed_accumulator: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
// --- Modules -----------------------------------------------------------
|
||||
|
||||
/// Adds a module, running its [`Module::build`] and attributing everything
|
||||
/// it registers to it.
|
||||
///
|
||||
/// # Panics
|
||||
/// Panics if a module with the same [`name`](Module::name) is already added.
|
||||
pub fn add_module<M: Module>(&mut self, module: M) -> &mut Self {
|
||||
let name = module.name();
|
||||
assert!(
|
||||
!self.modules.contains_key(name),
|
||||
"module '{name}' is already added"
|
||||
);
|
||||
self.modules.insert(name, true);
|
||||
let previous = self.current_module.replace(name);
|
||||
module.build(self);
|
||||
self.current_module = previous;
|
||||
self
|
||||
}
|
||||
|
||||
/// Adds a bundle of modules (e.g. [`DefaultModules`]).
|
||||
pub fn add_modules<B: ModuleBundle>(&mut self, bundle: B) -> &mut Self {
|
||||
bundle.add_to(self);
|
||||
self
|
||||
}
|
||||
|
||||
/// Whether a module is registered.
|
||||
pub fn has_module(&self, name: &str) -> bool {
|
||||
self.modules.contains_key(name)
|
||||
}
|
||||
|
||||
/// The registered module names, sorted.
|
||||
pub fn modules(&self) -> impl Iterator<Item = &'static str> + '_ {
|
||||
self.modules.keys().copied()
|
||||
}
|
||||
|
||||
/// Whether a registered module is enabled. Unknown modules report `false`.
|
||||
pub fn is_module_enabled(&self, name: &str) -> bool {
|
||||
self.modules.get(name).copied().unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Enables or disables a module's systems without removing them. Disabled
|
||||
/// modules' systems are skipped each frame. Returns whether the module exists.
|
||||
pub fn set_module_enabled(&mut self, name: &str, enabled: bool) -> bool {
|
||||
match self.modules.get_mut(name) {
|
||||
Some(flag) => {
|
||||
*flag = enabled;
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes a module and everything it contributed — systems, registered
|
||||
/// component types, asset loaders, and resources — leaving no dangling
|
||||
/// references. Returns whether the module existed.
|
||||
pub fn remove_module(&mut self, name: &str) -> bool {
|
||||
if self.modules.remove(name).is_none() {
|
||||
return false;
|
||||
}
|
||||
self.systems.remove_module(name);
|
||||
for type_name in self.module_types.remove(name).unwrap_or_default() {
|
||||
self.types.unregister(type_name);
|
||||
}
|
||||
for ext in self.module_loaders.remove(name).unwrap_or_default() {
|
||||
self.assets.unregister_loader(&ext);
|
||||
}
|
||||
for type_id in self.module_resources.remove(name).unwrap_or_default() {
|
||||
self.resources.remove(&type_id);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Whether a system contributed by `module` should run this frame: systems
|
||||
/// with no owning module always run; module-owned systems run only while
|
||||
/// their module is enabled.
|
||||
pub(crate) fn is_system_enabled(&self, module: Option<&'static str>) -> bool {
|
||||
match module {
|
||||
None => true,
|
||||
Some(name) => self.is_module_enabled(name),
|
||||
}
|
||||
}
|
||||
|
||||
// --- Registration (attributed to the current module) -------------------
|
||||
|
||||
/// Adds a system to a schedule phase. Systems run in phase order, then in
|
||||
/// registration order within a phase.
|
||||
pub fn add_system(
|
||||
&mut self,
|
||||
phase: Schedule,
|
||||
system: impl FnMut(&mut App) + 'static,
|
||||
) -> &mut Self {
|
||||
self.systems.push(
|
||||
phase,
|
||||
SystemEntry {
|
||||
module: self.current_module,
|
||||
run: Box::new(system),
|
||||
},
|
||||
);
|
||||
self
|
||||
}
|
||||
|
||||
/// Registers a reflected component type under `name` (see [`TypeRegistry`]).
|
||||
pub fn register_type<T>(&mut self, name: &'static str) -> &mut Self
|
||||
where
|
||||
T: hecs::Component + serde::Serialize + serde::de::DeserializeOwned,
|
||||
{
|
||||
self.types.register::<T>(name);
|
||||
if let Some(module) = self.current_module {
|
||||
self.module_types.entry(module).or_default().push(name);
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Registers an asset loader (see [`AssetServer::register_loader`]).
|
||||
pub fn add_loader<L: AssetLoader>(&mut self, loader: L) -> &mut Self {
|
||||
if let Some(module) = self.current_module {
|
||||
let exts = loader.extensions().iter().map(|e| e.to_lowercase());
|
||||
self.module_loaders.entry(module).or_default().extend(exts);
|
||||
}
|
||||
self.assets.register_loader(loader);
|
||||
self
|
||||
}
|
||||
|
||||
// --- Resources ---------------------------------------------------------
|
||||
|
||||
/// Inserts (or replaces) a shared resource of type `T`.
|
||||
pub fn insert_resource<T: 'static>(&mut self, value: T) -> &mut Self {
|
||||
let id = TypeId::of::<T>();
|
||||
if let Some(module) = self.current_module {
|
||||
self.module_resources.entry(module).or_default().push(id);
|
||||
}
|
||||
self.resources.insert(id, Box::new(value));
|
||||
self
|
||||
}
|
||||
|
||||
/// Borrows a resource of type `T`, or `None` if absent.
|
||||
pub fn get_resource<T: 'static>(&self) -> Option<&T> {
|
||||
self.resources
|
||||
.get(&TypeId::of::<T>())
|
||||
.and_then(|b| b.downcast_ref::<T>())
|
||||
}
|
||||
|
||||
/// Mutably borrows a resource of type `T`, or `None` if absent.
|
||||
pub fn get_resource_mut<T: 'static>(&mut self) -> Option<&mut T> {
|
||||
self.resources
|
||||
.get_mut(&TypeId::of::<T>())
|
||||
.and_then(|b| b.downcast_mut::<T>())
|
||||
}
|
||||
|
||||
/// Removes and returns the resource of type `T`, or `None` if absent.
|
||||
///
|
||||
/// Lets a system take exclusive ownership of a resource for the duration of
|
||||
/// a call — e.g. the physics step takes the `PhysicsWorld` out so it can
|
||||
/// borrow the [`Scene`] mutably at the same time — then re-inserts it.
|
||||
pub fn remove_resource<T: 'static>(&mut self) -> Option<T> {
|
||||
self.resources
|
||||
.remove(&TypeId::of::<T>())
|
||||
.and_then(|b| b.downcast::<T>().ok())
|
||||
.map(|b| *b)
|
||||
}
|
||||
|
||||
/// Whether a resource of type `T` is present.
|
||||
pub fn has_resource<T: 'static>(&self) -> bool {
|
||||
self.resources.contains_key(&TypeId::of::<T>())
|
||||
}
|
||||
|
||||
// --- Running -----------------------------------------------------------
|
||||
|
||||
/// Sets the fixed-timestep duration used by [`Schedule::FixedUpdate`].
|
||||
pub fn set_fixed_timestep(&mut self, seconds: f32) -> &mut Self {
|
||||
assert!(seconds > 0.0, "fixed timestep must be positive");
|
||||
self.time.fixed_delta = seconds;
|
||||
self
|
||||
}
|
||||
|
||||
/// The number of systems registered across all phases.
|
||||
pub fn system_count(&self) -> usize {
|
||||
self.systems.total()
|
||||
}
|
||||
|
||||
/// Advances one frame by `delta` seconds: runs the per-frame phases once and
|
||||
/// [`FixedUpdate`](Schedule::FixedUpdate) as many whole fixed steps as the
|
||||
/// accumulated time allows (capped to avoid a catch-up spiral).
|
||||
pub fn update(&mut self, delta: f32) {
|
||||
self.time.delta = delta;
|
||||
self.time.elapsed += delta;
|
||||
self.time.frame += 1;
|
||||
|
||||
// How many fixed steps to run this frame.
|
||||
self.fixed_accumulator += delta;
|
||||
let mut steps = (self.fixed_accumulator / self.time.fixed_delta) as u32;
|
||||
if steps > MAX_FIXED_STEPS_PER_FRAME {
|
||||
steps = MAX_FIXED_STEPS_PER_FRAME;
|
||||
self.fixed_accumulator = 0.0;
|
||||
} else {
|
||||
self.fixed_accumulator -= steps as f32 * self.time.fixed_delta;
|
||||
}
|
||||
|
||||
self.run_frame(steps);
|
||||
}
|
||||
|
||||
/// Advances **exactly one fixed timestep**: bumps frame time by
|
||||
/// [`fixed_delta`](Time::fixed_delta) and runs the per-frame phases once with
|
||||
/// a single [`FixedUpdate`](Schedule::FixedUpdate), bypassing the
|
||||
/// accumulator. This is the editor play-mode **Step** primitive — single-step
|
||||
/// the simulation while paused — and yields one deterministic tick.
|
||||
pub fn step(&mut self) {
|
||||
let dt = self.time.fixed_delta;
|
||||
self.time.delta = dt;
|
||||
self.time.elapsed += dt;
|
||||
self.time.frame += 1;
|
||||
self.run_frame(1);
|
||||
}
|
||||
|
||||
/// Runs the per-frame phases once with `fixed_steps` runs of
|
||||
/// [`FixedUpdate`](Schedule::FixedUpdate). The systems are moved out first so
|
||||
/// each gets exclusive `&mut App`, then anything registered mid-frame is
|
||||
/// folded back. Shared by [`update`](Self::update) and [`step`](Self::step).
|
||||
fn run_frame(&mut self, fixed_steps: u32) {
|
||||
let mut systems = std::mem::take(&mut self.systems);
|
||||
run_phase(&mut systems, self, Schedule::First);
|
||||
run_phase(&mut systems, self, Schedule::Input);
|
||||
run_phase(&mut systems, self, Schedule::PreUpdate);
|
||||
for _ in 0..fixed_steps {
|
||||
run_phase(&mut systems, self, Schedule::FixedUpdate);
|
||||
}
|
||||
run_phase(&mut systems, self, Schedule::Update);
|
||||
run_phase(&mut systems, self, Schedule::PostUpdate);
|
||||
run_phase(&mut systems, self, Schedule::Render);
|
||||
run_phase(&mut systems, self, Schedule::Last);
|
||||
|
||||
// Fold back anything registered during the frame, then restore.
|
||||
systems.merge(std::mem::take(&mut self.systems));
|
||||
self.systems = systems;
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for App {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// A group of modules added together. Implemented for [`DefaultModules`] and for
|
||||
/// tuples, so `app.add_modules((ModuleA, ModuleB))` works.
|
||||
pub trait ModuleBundle {
|
||||
/// Adds every module in the bundle to `app`.
|
||||
fn add_to(self, app: &mut App);
|
||||
}
|
||||
|
||||
impl<A: Module> ModuleBundle for (A,) {
|
||||
fn add_to(self, app: &mut App) {
|
||||
app.add_module(self.0);
|
||||
}
|
||||
}
|
||||
|
||||
impl<A: Module, B: Module> ModuleBundle for (A, B) {
|
||||
fn add_to(self, app: &mut App) {
|
||||
app.add_module(self.0);
|
||||
app.add_module(self.1);
|
||||
}
|
||||
}
|
||||
|
||||
impl<A: Module, B: Module, C: Module> ModuleBundle for (A, B, C) {
|
||||
fn add_to(self, app: &mut App) {
|
||||
app.add_module(self.0);
|
||||
app.add_module(self.1);
|
||||
app.add_module(self.2);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::math::{Transform, Vec3};
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
|
||||
#[test]
|
||||
fn empty_app_updates_and_advances_time() {
|
||||
let mut app = App::new();
|
||||
assert_eq!(app.time.frame, 0);
|
||||
app.update(0.5);
|
||||
assert_eq!(app.time.frame, 1);
|
||||
assert!((app.time.elapsed - 0.5).abs() < 1e-6);
|
||||
assert!((app.time.delta - 0.5).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn systems_run_in_phase_then_registration_order() {
|
||||
let log = Rc::new(std::cell::RefCell::new(Vec::new()));
|
||||
let mut app = App::new();
|
||||
let l = log.clone();
|
||||
app.add_system(Schedule::Update, move |_| l.borrow_mut().push("update-1"));
|
||||
let l = log.clone();
|
||||
app.add_system(Schedule::Update, move |_| l.borrow_mut().push("update-2"));
|
||||
let l = log.clone();
|
||||
app.add_system(Schedule::First, move |_| l.borrow_mut().push("first"));
|
||||
app.update(0.0);
|
||||
assert_eq!(*log.borrow(), vec!["first", "update-1", "update-2"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixed_update_runs_by_accumulated_time() {
|
||||
let count = Rc::new(Cell::new(0u32));
|
||||
let mut app = App::new();
|
||||
app.set_fixed_timestep(0.1);
|
||||
let c = count.clone();
|
||||
app.add_system(Schedule::FixedUpdate, move |_| c.set(c.get() + 1));
|
||||
|
||||
app.update(0.25); // 0.25 / 0.1 = 2 whole steps, ~0.05 left over
|
||||
assert_eq!(count.get(), 2);
|
||||
// 0.05 carried + 0.06 = 0.11 -> 1 more step (kept off the exact float
|
||||
// boundary so the result is robust to f32 rounding).
|
||||
app.update(0.06);
|
||||
assert_eq!(count.get(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixed_update_is_capped_against_spiral() {
|
||||
let count = Rc::new(Cell::new(0u32));
|
||||
let mut app = App::new();
|
||||
app.set_fixed_timestep(0.001);
|
||||
let c = count.clone();
|
||||
app.add_system(Schedule::FixedUpdate, move |_| c.set(c.get() + 1));
|
||||
app.update(10.0); // would be 10000 steps; capped
|
||||
assert_eq!(count.get(), MAX_FIXED_STEPS_PER_FRAME);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn step_runs_one_fixed_tick_and_the_per_frame_phases_once() {
|
||||
let fixed = Rc::new(Cell::new(0u32));
|
||||
let update = Rc::new(Cell::new(0u32));
|
||||
let mut app = App::new();
|
||||
app.set_fixed_timestep(0.1);
|
||||
let f = fixed.clone();
|
||||
app.add_system(Schedule::FixedUpdate, move |_| f.set(f.get() + 1));
|
||||
let u = update.clone();
|
||||
app.add_system(Schedule::Update, move |_| u.set(u.get() + 1));
|
||||
|
||||
app.step();
|
||||
// Exactly one fixed step and one Update, regardless of accumulator.
|
||||
assert_eq!(fixed.get(), 1);
|
||||
assert_eq!(update.get(), 1);
|
||||
assert_eq!(app.time.frame, 1);
|
||||
assert!((app.time.elapsed - 0.1).abs() < 1e-6);
|
||||
assert!((app.time.delta - 0.1).abs() < 1e-6);
|
||||
|
||||
// A second step advances exactly one more, deterministically.
|
||||
app.step();
|
||||
assert_eq!(fixed.get(), 2);
|
||||
assert_eq!(update.get(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resources_round_trip() {
|
||||
let mut app = App::new();
|
||||
app.insert_resource(42u32);
|
||||
assert_eq!(app.get_resource::<u32>(), Some(&42));
|
||||
*app.get_resource_mut::<u32>().unwrap() += 1;
|
||||
assert_eq!(app.get_resource::<u32>(), Some(&43));
|
||||
assert!(app.get_resource::<String>().is_none());
|
||||
|
||||
// remove_resource takes ownership and clears the slot.
|
||||
assert_eq!(app.remove_resource::<u32>(), Some(43));
|
||||
assert!(!app.has_resource::<u32>());
|
||||
assert_eq!(app.remove_resource::<u32>(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_system_can_mutate_the_scene_each_frame() {
|
||||
let mut app = App::new();
|
||||
app.scene.spawn("a", Transform::IDENTITY);
|
||||
// Each Update, nudge every entity's transform.
|
||||
app.add_system(Schedule::Update, |app| {
|
||||
let entities: Vec<_> = app.scene.entities().collect();
|
||||
for e in entities {
|
||||
if let Some(mut t) = app.scene.get_mut::<Transform>(e) {
|
||||
t.translation += Vec3::X;
|
||||
}
|
||||
}
|
||||
});
|
||||
app.update(0.0);
|
||||
app.update(0.0);
|
||||
let e = app.scene.entities().next().unwrap();
|
||||
assert!((app.scene.local_transform(e).unwrap().translation.x - 2.0).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
//! The [`Module`] trait and the engine's built-in modules.
|
||||
//!
|
||||
//! A module is the unit of engine extension: it bundles systems, component
|
||||
//! types, asset loaders, and resources behind one documented entry point, so
|
||||
//! anyone — including AI agents — can add a capability by writing a module, and
|
||||
//! an exported game compiles in only the modules it registers. The editor
|
||||
//! integration half of the trait arrives in Stage 6.
|
||||
|
||||
use super::{App, ModuleBundle, Schedule};
|
||||
use crate::layer::{Layer, Tags};
|
||||
use crate::math::Transform;
|
||||
use crate::render::MeshRenderer;
|
||||
use crate::scene::Node;
|
||||
|
||||
/// A self-contained unit of engine functionality.
|
||||
///
|
||||
/// Implement [`build`](Self::build) to register everything the module provides
|
||||
/// via the [`App`] facade ([`add_system`](App::add_system),
|
||||
/// [`register_type`](App::register_type), [`add_loader`](App::add_loader),
|
||||
/// [`insert_resource`](App::insert_resource)). Everything registered during
|
||||
/// `build` is attributed to the module, so it can be enabled, disabled, or
|
||||
/// removed as a unit.
|
||||
pub trait Module: 'static {
|
||||
/// A stable, unique name (used to enable/disable/remove the module and, in
|
||||
/// later stages, to express dependencies).
|
||||
fn name(&self) -> &'static str;
|
||||
|
||||
/// Registers the module's systems, types, loaders, and resources on `app`.
|
||||
fn build(&self, app: &mut App);
|
||||
}
|
||||
|
||||
/// The core module: registers the always-present scene component types for
|
||||
/// reflection (dual-editability), so the editor and scripts can address them.
|
||||
///
|
||||
/// This is the runtime "wrapper" for the math/scene/layer building blocks that
|
||||
/// already exist as plain library types — it does not add behavior, it exposes
|
||||
/// those types through the [`TypeRegistry`](crate::reflect::TypeRegistry).
|
||||
pub struct CoreModule;
|
||||
|
||||
impl Module for CoreModule {
|
||||
fn name(&self) -> &'static str {
|
||||
"core"
|
||||
}
|
||||
|
||||
fn build(&self, app: &mut App) {
|
||||
app.register_type::<Transform>("Transform");
|
||||
app.register_type::<Node>("Node");
|
||||
app.register_type::<Layer>("Layer");
|
||||
app.register_type::<Tags>("Tags");
|
||||
}
|
||||
}
|
||||
|
||||
/// The render module: registers the renderable scene components for reflection.
|
||||
///
|
||||
/// The forward renderer itself is driven by the editor/host today; this module
|
||||
/// is what makes [`MeshRenderer`] a first-class, dual-editable component. As the
|
||||
/// data-driven render pipeline grows it will register its render-phase systems
|
||||
/// here too.
|
||||
pub struct RenderModule;
|
||||
|
||||
impl Module for RenderModule {
|
||||
fn name(&self) -> &'static str {
|
||||
"render"
|
||||
}
|
||||
|
||||
fn build(&self, app: &mut App) {
|
||||
app.register_type::<MeshRenderer>("MeshRenderer");
|
||||
// Placeholder render-phase system so the phase is exercised; real passes
|
||||
// land with the Stage 5 render pipeline piece.
|
||||
app.add_system(Schedule::Render, |_app| {});
|
||||
}
|
||||
}
|
||||
|
||||
/// The engine's standard set of built-in modules, added with
|
||||
/// [`App::add_modules`](super::App::add_modules).
|
||||
///
|
||||
/// ```
|
||||
/// use oxide_engine::app::{App, DefaultModules};
|
||||
/// let mut app = App::new();
|
||||
/// app.add_modules(DefaultModules);
|
||||
/// assert!(app.has_module("core") && app.has_module("render"));
|
||||
/// ```
|
||||
pub struct DefaultModules;
|
||||
|
||||
impl ModuleBundle for DefaultModules {
|
||||
fn add_to(self, app: &mut App) {
|
||||
app.add_module(CoreModule);
|
||||
app.add_module(RenderModule);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::render::PrimitiveShape;
|
||||
|
||||
#[test]
|
||||
fn default_modules_register_core_types() {
|
||||
let mut app = App::new();
|
||||
app.add_modules(DefaultModules);
|
||||
assert!(app.has_module("core"));
|
||||
assert!(app.has_module("render"));
|
||||
assert!(app.types.is_registered("Transform"));
|
||||
assert!(app.types.is_registered("MeshRenderer"));
|
||||
assert_eq!(app.modules().collect::<Vec<_>>(), vec!["core", "render"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_a_module_removes_its_contributions() {
|
||||
let mut app = App::new();
|
||||
app.add_modules(DefaultModules);
|
||||
assert!(app.types.is_registered("MeshRenderer"));
|
||||
let systems_before = app.system_count();
|
||||
|
||||
assert!(app.remove_module("render"));
|
||||
// Its registered type is gone, its render system is gone, core remains.
|
||||
assert!(!app.has_module("render"));
|
||||
assert!(!app.types.is_registered("MeshRenderer"));
|
||||
assert!(app.types.is_registered("Transform"));
|
||||
assert!(app.system_count() < systems_before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabling_a_module_skips_its_systems_without_removing() {
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
|
||||
struct Ticker(Rc<Cell<u32>>);
|
||||
impl Module for Ticker {
|
||||
fn name(&self) -> &'static str {
|
||||
"ticker"
|
||||
}
|
||||
fn build(&self, app: &mut App) {
|
||||
let counter = self.0.clone();
|
||||
app.add_system(Schedule::Update, move |_| counter.set(counter.get() + 1));
|
||||
}
|
||||
}
|
||||
|
||||
let count = Rc::new(Cell::new(0u32));
|
||||
let mut app = App::new();
|
||||
app.add_module(Ticker(count.clone()));
|
||||
|
||||
app.update(0.0);
|
||||
assert_eq!(count.get(), 1);
|
||||
|
||||
app.set_module_enabled("ticker", false);
|
||||
app.update(0.0); // skipped
|
||||
assert_eq!(count.get(), 1);
|
||||
|
||||
app.set_module_enabled("ticker", true);
|
||||
app.update(0.0); // runs again
|
||||
assert_eq!(count.get(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_module_can_add_a_loader_removed_with_it() {
|
||||
// A module registers MeshRenderer + uses a primitive, then is removed.
|
||||
let mut app = App::new();
|
||||
app.add_module(RenderModule);
|
||||
// Sanity: the primitive enum the render component references is usable.
|
||||
assert_eq!(PrimitiveShape::ALL.len(), 3);
|
||||
assert!(app.remove_module("render"));
|
||||
assert!(!app.types.is_registered("MeshRenderer"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
//! The system schedule: the ordered phases an [`App`](super::App) runs each
|
||||
//! frame, and the per-phase lists of systems modules attach to.
|
||||
|
||||
use super::App;
|
||||
|
||||
/// The ordered phases of one frame.
|
||||
///
|
||||
/// Systems are attached to a phase and run in phase order; within a phase they
|
||||
/// run in registration order, so behavior is fully deterministic. The phases
|
||||
/// mirror a conventional game loop:
|
||||
///
|
||||
/// - [`First`](Self::First) — start-of-frame bookkeeping.
|
||||
/// - [`Input`](Self::Input) — gather input (Stage 7).
|
||||
/// - [`PreUpdate`](Self::PreUpdate) — engine work before game logic.
|
||||
/// - [`FixedUpdate`](Self::FixedUpdate) — fixed-timestep work; runs **zero or
|
||||
/// more** times per frame so simulation is frame-rate independent. Physics
|
||||
/// (Stage 9) lives here.
|
||||
/// - [`Update`](Self::Update) — per-frame game logic.
|
||||
/// - [`PostUpdate`](Self::PostUpdate) — engine work after game logic.
|
||||
/// - [`Render`](Self::Render) — drawing (Stage 5 pipeline onward).
|
||||
/// - [`Last`](Self::Last) — end-of-frame cleanup.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub enum Schedule {
|
||||
First,
|
||||
Input,
|
||||
PreUpdate,
|
||||
FixedUpdate,
|
||||
Update,
|
||||
PostUpdate,
|
||||
Render,
|
||||
Last,
|
||||
}
|
||||
|
||||
impl Schedule {
|
||||
/// The once-per-frame phases, in order (everything except `FixedUpdate`,
|
||||
/// which is driven separately by the fixed-timestep accumulator).
|
||||
pub(crate) const PER_FRAME: [Schedule; 7] = [
|
||||
Schedule::First,
|
||||
Schedule::Input,
|
||||
Schedule::PreUpdate,
|
||||
Schedule::Update,
|
||||
Schedule::PostUpdate,
|
||||
Schedule::Render,
|
||||
Schedule::Last,
|
||||
];
|
||||
}
|
||||
|
||||
/// One registered system: a closure plus the module that contributed it (so the
|
||||
/// module can be disabled or removed).
|
||||
pub(crate) struct SystemEntry {
|
||||
pub(crate) module: Option<&'static str>,
|
||||
pub(crate) run: Box<dyn FnMut(&mut App)>,
|
||||
}
|
||||
|
||||
/// The collection of systems, grouped by phase, in registration order.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Systems {
|
||||
first: Vec<SystemEntry>,
|
||||
input: Vec<SystemEntry>,
|
||||
pre_update: Vec<SystemEntry>,
|
||||
fixed_update: Vec<SystemEntry>,
|
||||
update: Vec<SystemEntry>,
|
||||
post_update: Vec<SystemEntry>,
|
||||
render: Vec<SystemEntry>,
|
||||
last: Vec<SystemEntry>,
|
||||
}
|
||||
|
||||
impl Systems {
|
||||
fn phase_mut(&mut self, phase: Schedule) -> &mut Vec<SystemEntry> {
|
||||
match phase {
|
||||
Schedule::First => &mut self.first,
|
||||
Schedule::Input => &mut self.input,
|
||||
Schedule::PreUpdate => &mut self.pre_update,
|
||||
Schedule::FixedUpdate => &mut self.fixed_update,
|
||||
Schedule::Update => &mut self.update,
|
||||
Schedule::PostUpdate => &mut self.post_update,
|
||||
Schedule::Render => &mut self.render,
|
||||
Schedule::Last => &mut self.last,
|
||||
}
|
||||
}
|
||||
|
||||
fn phase(&self, phase: Schedule) -> &[SystemEntry] {
|
||||
match phase {
|
||||
Schedule::First => &self.first,
|
||||
Schedule::Input => &self.input,
|
||||
Schedule::PreUpdate => &self.pre_update,
|
||||
Schedule::FixedUpdate => &self.fixed_update,
|
||||
Schedule::Update => &self.update,
|
||||
Schedule::PostUpdate => &self.post_update,
|
||||
Schedule::Render => &self.render,
|
||||
Schedule::Last => &self.last,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn push(&mut self, phase: Schedule, entry: SystemEntry) {
|
||||
self.phase_mut(phase).push(entry);
|
||||
}
|
||||
|
||||
pub(crate) fn total(&self) -> usize {
|
||||
Schedule::PER_FRAME
|
||||
.iter()
|
||||
.chain(std::iter::once(&Schedule::FixedUpdate))
|
||||
.map(|p| self.phase(*p).len())
|
||||
.sum()
|
||||
}
|
||||
|
||||
const ALL_PHASES: [Schedule; 8] = [
|
||||
Schedule::First,
|
||||
Schedule::Input,
|
||||
Schedule::PreUpdate,
|
||||
Schedule::FixedUpdate,
|
||||
Schedule::Update,
|
||||
Schedule::PostUpdate,
|
||||
Schedule::Render,
|
||||
Schedule::Last,
|
||||
];
|
||||
|
||||
/// Drops every system contributed by `module`.
|
||||
pub(crate) fn remove_module(&mut self, module: &str) {
|
||||
for phase in Self::ALL_PHASES {
|
||||
self.phase_mut(phase).retain(|e| e.module != Some(module));
|
||||
}
|
||||
}
|
||||
|
||||
/// Appends all of `other`'s systems (used to fold back systems registered
|
||||
/// while the frame was running).
|
||||
pub(crate) fn merge(&mut self, mut other: Systems) {
|
||||
for phase in Self::ALL_PHASES {
|
||||
let tail = std::mem::take(other.phase_mut(phase));
|
||||
self.phase_mut(phase).extend(tail);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs one phase: every enabled system in registration order.
|
||||
///
|
||||
/// The [`Systems`] are moved out of the [`App`] before phases run (so systems
|
||||
/// get exclusive `&mut App` access), so `systems` and `app` here are disjoint.
|
||||
/// Systems from a disabled module are skipped without being removed.
|
||||
pub(crate) fn run_phase(systems: &mut Systems, app: &mut App, phase: Schedule) {
|
||||
for entry in systems.phase_mut(phase) {
|
||||
if app.is_system_enabled(entry.module) {
|
||||
(entry.run)(app);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,215 @@
|
||||
//! glTF 2.0 static-mesh importer.
|
||||
//!
|
||||
//! Loads the mesh primitives of a glTF document into engine [`Mesh`]es, reading
|
||||
//! their PBR-lite [`Material`] factors and the world [`Transform`] of each
|
||||
//! placement (the node hierarchy is flattened into world space). Missing
|
||||
//! normals are generated; missing UVs default to zero. Animation, skinning, and
|
||||
//! textures are out of scope for Stage 4.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use crate::math::{Color, Transform, Vec2, Vec3};
|
||||
use crate::render::{Material, Mesh, Vertex};
|
||||
|
||||
/// Errors produced while importing a glTF document.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum GltfError {
|
||||
/// The file could not be read or parsed as glTF.
|
||||
#[error("failed to load glTF: {0}")]
|
||||
Load(#[from] gltf::Error),
|
||||
|
||||
/// A mesh primitive was missing the required `POSITION` attribute.
|
||||
#[error("glTF primitive has no POSITION attribute")]
|
||||
MissingPositions,
|
||||
}
|
||||
|
||||
/// One imported mesh placement: geometry, material, and world transform.
|
||||
pub struct GltfMesh {
|
||||
/// Optional node/mesh name from the document.
|
||||
pub name: Option<String>,
|
||||
/// The primitive's geometry.
|
||||
pub mesh: Mesh,
|
||||
/// The primitive's PBR-lite material.
|
||||
pub material: Material,
|
||||
/// World-space placement (node hierarchy flattened).
|
||||
pub transform: Transform,
|
||||
}
|
||||
|
||||
/// An imported glTF model: a flat list of mesh placements in world space.
|
||||
pub struct GltfModel {
|
||||
/// Every mesh primitive in the default scene, already placed in world space.
|
||||
pub meshes: Vec<GltfMesh>,
|
||||
}
|
||||
|
||||
impl GltfModel {
|
||||
/// Total triangle count across all imported primitives.
|
||||
pub fn triangle_count(&self) -> usize {
|
||||
self.meshes.iter().map(|m| m.mesh.triangle_count()).sum()
|
||||
}
|
||||
}
|
||||
|
||||
/// Imports a glTF/GLB file from `path` (external buffers are resolved relative
|
||||
/// to the file).
|
||||
pub fn load_gltf(path: impl AsRef<Path>) -> Result<GltfModel, GltfError> {
|
||||
let (document, buffers, _images) = gltf::import(path)?;
|
||||
build_model(&document, &buffers)
|
||||
}
|
||||
|
||||
/// The [`AssetServer`](super::AssetServer) loader for glTF/GLB files.
|
||||
///
|
||||
/// Registered by default (handles `.gltf` and `.glb`), so
|
||||
/// `assets.load::<GltfModel>("model.gltf")` works out of the box; it simply
|
||||
/// wraps [`load_gltf`] and adapts its error into [`AssetError`].
|
||||
pub struct GltfLoader;
|
||||
|
||||
impl super::AssetLoader for GltfLoader {
|
||||
type Asset = GltfModel;
|
||||
|
||||
fn extensions(&self) -> &'static [&'static str] {
|
||||
&["gltf", "glb"]
|
||||
}
|
||||
|
||||
fn load(&self, path: &Path) -> Result<GltfModel, super::AssetError> {
|
||||
load_gltf(path).map_err(|err| super::AssetError::Load {
|
||||
path: path.to_path_buf(),
|
||||
message: err.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Imports a glTF/GLB document from an in-memory byte slice (buffers must be
|
||||
/// embedded; used for tests and bundled assets).
|
||||
pub fn load_gltf_slice(bytes: &[u8]) -> Result<GltfModel, GltfError> {
|
||||
let (document, buffers, _images) = gltf::import_slice(bytes)?;
|
||||
build_model(&document, &buffers)
|
||||
}
|
||||
|
||||
/// Walks the default scene's node hierarchy, accumulating world transforms and
|
||||
/// emitting one [`GltfMesh`] per primitive.
|
||||
fn build_model(
|
||||
document: &gltf::Document,
|
||||
buffers: &[gltf::buffer::Data],
|
||||
) -> Result<GltfModel, GltfError> {
|
||||
let mut meshes = Vec::new();
|
||||
let scene = document
|
||||
.default_scene()
|
||||
.or_else(|| document.scenes().next());
|
||||
if let Some(scene) = scene {
|
||||
for node in scene.nodes() {
|
||||
visit_node(&node, Transform::IDENTITY, buffers, &mut meshes)?;
|
||||
}
|
||||
}
|
||||
Ok(GltfModel { meshes })
|
||||
}
|
||||
|
||||
fn visit_node(
|
||||
node: &gltf::Node,
|
||||
parent: Transform,
|
||||
buffers: &[gltf::buffer::Data],
|
||||
out: &mut Vec<GltfMesh>,
|
||||
) -> Result<(), GltfError> {
|
||||
let world = parent.mul_transform(&node_transform(node));
|
||||
|
||||
if let Some(mesh) = node.mesh() {
|
||||
for primitive in mesh.primitives() {
|
||||
let geometry = read_primitive(&primitive, buffers)?;
|
||||
out.push(GltfMesh {
|
||||
name: node.name().or_else(|| mesh.name()).map(str::to_owned),
|
||||
mesh: geometry,
|
||||
material: read_material(&primitive),
|
||||
transform: world,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for child in node.children() {
|
||||
visit_node(&child, world, buffers, out)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Converts a node's local TRS into an engine [`Transform`].
|
||||
fn node_transform(node: &gltf::Node) -> Transform {
|
||||
let (t, r, s) = node.transform().decomposed();
|
||||
Transform::from_trs(
|
||||
Vec3::from_array(t),
|
||||
glam::Quat::from_array(r),
|
||||
Vec3::from_array(s),
|
||||
)
|
||||
}
|
||||
|
||||
/// Reads one primitive's vertices and indices into a [`Mesh`].
|
||||
fn read_primitive(
|
||||
primitive: &gltf::Primitive,
|
||||
buffers: &[gltf::buffer::Data],
|
||||
) -> Result<Mesh, GltfError> {
|
||||
let reader = primitive.reader(|buffer| Some(&buffers[buffer.index()]));
|
||||
|
||||
let positions: Vec<[f32; 3]> = reader
|
||||
.read_positions()
|
||||
.ok_or(GltfError::MissingPositions)?
|
||||
.collect();
|
||||
|
||||
let normals: Option<Vec<[f32; 3]>> = reader.read_normals().map(|n| n.collect());
|
||||
let uvs: Option<Vec<[f32; 2]>> = reader.read_tex_coords(0).map(|tc| tc.into_f32().collect());
|
||||
|
||||
let indices: Vec<u32> = match reader.read_indices() {
|
||||
Some(idx) => idx.into_u32().collect(),
|
||||
// Non-indexed primitive: every three positions form a triangle.
|
||||
None => (0..positions.len() as u32).collect(),
|
||||
};
|
||||
|
||||
// Generate flat normals when the document omits them, so lighting still works.
|
||||
let normals = normals.unwrap_or_else(|| compute_normals(&positions, &indices));
|
||||
|
||||
let vertices = positions
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &p)| {
|
||||
let n = normals.get(i).copied().unwrap_or([0.0, 1.0, 0.0]);
|
||||
let uv = uvs
|
||||
.as_ref()
|
||||
.and_then(|u| u.get(i))
|
||||
.copied()
|
||||
.unwrap_or([0.0, 0.0]);
|
||||
Vertex::new(
|
||||
Vec3::from_array(p),
|
||||
Vec3::from_array(n),
|
||||
Vec2::from_array(uv),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Mesh::new(vertices, indices))
|
||||
}
|
||||
|
||||
/// Smooth per-vertex normals: accumulate each triangle's face normal at its
|
||||
/// vertices, then normalize.
|
||||
fn compute_normals(positions: &[[f32; 3]], indices: &[u32]) -> Vec<[f32; 3]> {
|
||||
let mut normals = vec![Vec3::ZERO; positions.len()];
|
||||
for tri in indices.chunks_exact(3) {
|
||||
let (a, b, c) = (tri[0] as usize, tri[1] as usize, tri[2] as usize);
|
||||
let pa = Vec3::from_array(positions[a]);
|
||||
let pb = Vec3::from_array(positions[b]);
|
||||
let pc = Vec3::from_array(positions[c]);
|
||||
let face = (pb - pa).cross(pc - pa);
|
||||
normals[a] += face;
|
||||
normals[b] += face;
|
||||
normals[c] += face;
|
||||
}
|
||||
normals
|
||||
.into_iter()
|
||||
.map(|n| n.normalize_or_zero().to_array())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Maps a primitive's PBR metallic-roughness factors onto a [`Material`].
|
||||
fn read_material(primitive: &gltf::Primitive) -> Material {
|
||||
let pbr = primitive.material().pbr_metallic_roughness();
|
||||
let [r, g, b, a] = pbr.base_color_factor();
|
||||
Material {
|
||||
albedo: Color::rgba(r, g, b, a),
|
||||
metallic: pbr.metallic_factor(),
|
||||
roughness: pbr.roughness_factor(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
//! [`Handle`]: a typed, ref-counted reference to a loaded asset.
|
||||
//!
|
||||
//! A handle is the unit of *ownership* in the asset system. It is cheap to clone
|
||||
//! (an `Arc` bump), and the asset behind it lives exactly as long as at least
|
||||
//! one handle does — drop the last handle and the asset is freed. The
|
||||
//! [`AssetServer`](super::AssetServer) keeps only a [`Weak`] reference in its
|
||||
//! dedup cache, so it never keeps an otherwise-unused asset alive.
|
||||
|
||||
use std::fmt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Condvar, Mutex};
|
||||
|
||||
/// A process-unique identifier assigned to every asset slot.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct AssetId(pub(crate) u64);
|
||||
|
||||
impl AssetId {
|
||||
/// The raw numeric id.
|
||||
pub fn value(self) -> u64 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// The lifecycle state of an asset behind a [`Handle`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LoadState {
|
||||
/// A background load is in progress; the value is not ready yet.
|
||||
Loading,
|
||||
/// The asset loaded successfully and can be read with [`Handle::get`].
|
||||
Loaded,
|
||||
/// Loading failed; see [`Handle::error`] for why.
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// The interior of an asset slot: its current state and (once ready) the value.
|
||||
///
|
||||
/// The value is stored as an `Arc<T>` so it can be cloned out cheaply and so a
|
||||
/// live reload can swap in fresh contents without disturbing readers that
|
||||
/// already hold the previous `Arc`.
|
||||
pub(crate) enum CellState<T> {
|
||||
Loading,
|
||||
Loaded(Arc<T>),
|
||||
Failed(Arc<str>),
|
||||
}
|
||||
|
||||
/// The shared, reference-counted storage for one asset.
|
||||
///
|
||||
/// Handles hold an `Arc<AssetCell<T>>`; the server's cache holds a
|
||||
/// `Weak<dyn Any>` to the same allocation for deduplication only.
|
||||
pub(crate) struct AssetCell<T> {
|
||||
id: AssetId,
|
||||
source: Option<PathBuf>,
|
||||
state: Mutex<CellState<T>>,
|
||||
ready: Condvar,
|
||||
}
|
||||
|
||||
impl<T> AssetCell<T> {
|
||||
pub(crate) fn new_loading(id: AssetId, source: Option<PathBuf>) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
id,
|
||||
source,
|
||||
state: Mutex::new(CellState::Loading),
|
||||
ready: Condvar::new(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn new_loaded(id: AssetId, source: Option<PathBuf>, value: T) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
id,
|
||||
source,
|
||||
state: Mutex::new(CellState::Loaded(Arc::new(value))),
|
||||
ready: Condvar::new(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn new_failed(id: AssetId, source: Option<PathBuf>, message: String) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
id,
|
||||
source,
|
||||
state: Mutex::new(CellState::Failed(Arc::from(message))),
|
||||
ready: Condvar::new(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn set_loaded(&self, value: T) {
|
||||
*self.state.lock().unwrap() = CellState::Loaded(Arc::new(value));
|
||||
self.ready.notify_all();
|
||||
}
|
||||
|
||||
pub(crate) fn set_failed(&self, message: String) {
|
||||
*self.state.lock().unwrap() = CellState::Failed(Arc::from(message));
|
||||
self.ready.notify_all();
|
||||
}
|
||||
}
|
||||
|
||||
/// A typed, reference-counted handle to an asset of type `T`.
|
||||
///
|
||||
/// Clone it freely to share ownership; the asset is freed when the last handle
|
||||
/// is dropped. Read the value with [`get`](Self::get) (returns `None` until the
|
||||
/// asset is loaded) or block for it with [`wait`](Self::wait).
|
||||
pub struct Handle<T> {
|
||||
cell: Arc<AssetCell<T>>,
|
||||
}
|
||||
|
||||
impl<T> Handle<T> {
|
||||
pub(crate) fn from_cell(cell: Arc<AssetCell<T>>) -> Self {
|
||||
Self { cell }
|
||||
}
|
||||
|
||||
/// This asset's process-unique id.
|
||||
pub fn id(&self) -> AssetId {
|
||||
self.cell.id
|
||||
}
|
||||
|
||||
/// The source path the asset was loaded from, if any (in-memory assets added
|
||||
/// with [`AssetServer::add`](super::AssetServer::add) have none).
|
||||
pub fn source(&self) -> Option<&Path> {
|
||||
self.cell.source.as_deref()
|
||||
}
|
||||
|
||||
/// The current lifecycle state.
|
||||
pub fn state(&self) -> LoadState {
|
||||
match &*self.cell.state.lock().unwrap() {
|
||||
CellState::Loading => LoadState::Loading,
|
||||
CellState::Loaded(_) => LoadState::Loaded,
|
||||
CellState::Failed(_) => LoadState::Failed,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the asset has finished loading successfully.
|
||||
pub fn is_loaded(&self) -> bool {
|
||||
matches!(&*self.cell.state.lock().unwrap(), CellState::Loaded(_))
|
||||
}
|
||||
|
||||
/// The loaded value as a cheap `Arc<T>` clone, or `None` if it is still
|
||||
/// loading or failed.
|
||||
pub fn get(&self) -> Option<Arc<T>> {
|
||||
match &*self.cell.state.lock().unwrap() {
|
||||
CellState::Loaded(value) => Some(value.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The error message if loading failed, else `None`.
|
||||
pub fn error(&self) -> Option<String> {
|
||||
match &*self.cell.state.lock().unwrap() {
|
||||
CellState::Failed(message) => Some(message.to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Blocks until the asset is no longer [`Loading`](LoadState::Loading),
|
||||
/// returning the value on success or `None` if it failed.
|
||||
pub fn wait(&self) -> Option<Arc<T>> {
|
||||
let mut guard = self.cell.state.lock().unwrap();
|
||||
loop {
|
||||
match &*guard {
|
||||
CellState::Loading => guard = self.cell.ready.wait(guard).unwrap(),
|
||||
CellState::Loaded(value) => return Some(value.clone()),
|
||||
CellState::Failed(_) => return None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The number of live handles to this asset (including this one). The
|
||||
/// server holds only a weak reference, so this counts handles alone.
|
||||
pub fn ref_count(&self) -> usize {
|
||||
Arc::strong_count(&self.cell)
|
||||
}
|
||||
|
||||
/// Replaces the asset's contents in place; every existing handle observes
|
||||
/// the new value on its next [`get`](Self::get). Used by live reload.
|
||||
pub(crate) fn set_loaded(&self, value: T) {
|
||||
self.cell.set_loaded(value);
|
||||
}
|
||||
|
||||
/// Marks the asset as failed in place.
|
||||
pub(crate) fn set_failed(&self, message: String) {
|
||||
self.cell.set_failed(message);
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Clone for Handle<T> {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
cell: self.cell.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> fmt::Debug for Handle<T> {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("Handle")
|
||||
.field("id", &self.cell.id.0)
|
||||
.field("state", &self.state())
|
||||
.field("source", &self.cell.source)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
//! Asset loading and management.
|
||||
//!
|
||||
//! Stage 4 introduced the first importer: a static-mesh [`glTF`](gltf) loader
|
||||
//! that turns a `.gltf`/`.glb` file into engine [`Mesh`](crate::render::Mesh)es,
|
||||
//! [`Material`](crate::render::Material)s, and placement [`Transform`](crate::math::Transform)s.
|
||||
//!
|
||||
//! Stage 5 adds the [`AssetServer`]: a central registry that loads assets through
|
||||
//! pluggable [`AssetLoader`]s, deduplicates by path+type, and hands out
|
||||
//! reference-counted [`Handle`]s (an asset lives as long as a handle to it does).
|
||||
//! It supports synchronous and background loading and in-place [reload](AssetServer::reload),
|
||||
//! the foundation later stages build live reload, streaming, and export packing
|
||||
//! on. The standalone [`load_gltf`] importer stays available; the server reaches
|
||||
//! it through the built-in [`GltfLoader`].
|
||||
|
||||
mod database;
|
||||
mod gltf;
|
||||
mod handle;
|
||||
mod server;
|
||||
|
||||
pub use database::{
|
||||
asset_ref_target, AssetDatabase, AssetDbError, AssetEntry, AssetKind, AssetRef, AssetUid,
|
||||
ASSET_MANIFEST_FILE,
|
||||
};
|
||||
pub use gltf::{load_gltf, load_gltf_slice, GltfError, GltfLoader, GltfMesh, GltfModel};
|
||||
pub use handle::{AssetId, Handle, LoadState};
|
||||
pub use server::{AssetError, AssetLoader, AssetServer};
|
||||
|
||||
/// Registers the engine's built-in asset loaders on `server`. Called by
|
||||
/// [`AssetServer::new`].
|
||||
pub(crate) fn register_default_loaders(server: &AssetServer) {
|
||||
server.register_loader(GltfLoader);
|
||||
server.register_loader(crate::ui::FontLoader);
|
||||
}
|
||||
@@ -0,0 +1,508 @@
|
||||
//! [`AssetServer`]: the central registry that loads, deduplicates, and hands out
|
||||
//! [`Handle`]s, plus the [`AssetLoader`] trait that makes it extensible.
|
||||
|
||||
use std::any::{Any, TypeId};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex, RwLock, Weak};
|
||||
|
||||
use super::handle::{AssetCell, AssetId, Handle};
|
||||
|
||||
/// Errors produced while loading assets.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AssetError {
|
||||
/// The path had no file extension to pick a loader by.
|
||||
#[error("path has no file extension: {0}")]
|
||||
NoExtension(PathBuf),
|
||||
|
||||
/// No loader was registered for the file's extension.
|
||||
#[error("no loader registered for extension '.{0}'")]
|
||||
NoLoader(String),
|
||||
|
||||
/// A loader exists for the extension, but it produces a different asset
|
||||
/// type than the one requested at the call site.
|
||||
#[error("loader for '.{ext}' produces a different asset type than requested")]
|
||||
TypeMismatch {
|
||||
/// The extension whose loader was selected.
|
||||
ext: String,
|
||||
},
|
||||
|
||||
/// The loader itself failed (I/O, parse, etc.).
|
||||
#[error("failed to load {path}: {message}")]
|
||||
Load {
|
||||
/// The asset path.
|
||||
path: PathBuf,
|
||||
/// The loader's error message.
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// A pluggable importer that turns a file into an asset of one concrete type.
|
||||
///
|
||||
/// Implement this for each asset format and register it with
|
||||
/// [`AssetServer::register_loader`]. The server dispatches by file extension and
|
||||
/// checks that the loader's [`Asset`](Self::Asset) type matches what the caller
|
||||
/// asked to load.
|
||||
pub trait AssetLoader: Send + Sync + 'static {
|
||||
/// The type this loader produces.
|
||||
type Asset: Send + Sync + 'static;
|
||||
|
||||
/// The lower-or-mixed-case extensions (without the dot) this loader handles,
|
||||
/// e.g. `&["gltf", "glb"]`.
|
||||
fn extensions(&self) -> &'static [&'static str];
|
||||
|
||||
/// Loads and parses the asset at `path`.
|
||||
fn load(&self, path: &Path) -> Result<Self::Asset, AssetError>;
|
||||
}
|
||||
|
||||
/// Type-erased view of an [`AssetLoader`] so loaders of different output types
|
||||
/// can share one registry.
|
||||
trait ErasedLoader: Send + Sync {
|
||||
fn output_type(&self) -> TypeId;
|
||||
fn load(&self, path: &Path) -> Result<Box<dyn Any + Send + Sync>, AssetError>;
|
||||
}
|
||||
|
||||
impl<L: AssetLoader> ErasedLoader for L {
|
||||
fn output_type(&self) -> TypeId {
|
||||
TypeId::of::<L::Asset>()
|
||||
}
|
||||
|
||||
fn load(&self, path: &Path) -> Result<Box<dyn Any + Send + Sync>, AssetError> {
|
||||
Ok(Box::new(<L as AssetLoader>::load(self, path)?))
|
||||
}
|
||||
}
|
||||
|
||||
type CacheKey = (TypeId, PathBuf);
|
||||
|
||||
/// One entry in the dedup cache. Carries a weak reference to the asset cell so
|
||||
/// dropped assets are pruned, plus a function pointer that knows how to rerun
|
||||
/// the loader for the cell's concrete type. Storing the reload-by-type as a
|
||||
/// per-entry `fn` is what lets [`AssetServer::reload_path`] reload an asset
|
||||
/// without knowing its `T` at the call site — the original `insert_cache::<T>`
|
||||
/// captures `T` into the function pointer.
|
||||
#[derive(Clone)]
|
||||
struct CacheEntry {
|
||||
weak: Weak<dyn Any + Send + Sync>,
|
||||
reload_in_place: fn(&AssetServer, &Path),
|
||||
}
|
||||
|
||||
struct Inner {
|
||||
loaders: RwLock<HashMap<String, Arc<dyn ErasedLoader>>>,
|
||||
/// Dedup cache: weak references, so a cached asset with no live handles is
|
||||
/// collected and reloaded fresh next time.
|
||||
cache: Mutex<HashMap<CacheKey, CacheEntry>>,
|
||||
next_id: AtomicU64,
|
||||
}
|
||||
|
||||
/// The central asset registry.
|
||||
///
|
||||
/// Cloning an `AssetServer` is cheap (it shares one inner state via `Arc`) so it
|
||||
/// can be handed to background load threads and stored across systems. Loading
|
||||
/// the same path+type twice returns handles to **one** shared asset; when the
|
||||
/// last handle is dropped the asset is freed.
|
||||
///
|
||||
/// ```no_run
|
||||
/// use oxide_engine::asset::AssetServer;
|
||||
/// use oxide_engine::asset::GltfModel;
|
||||
///
|
||||
/// let assets = AssetServer::new(); // glTF loader registered by default
|
||||
/// let model = assets.load::<GltfModel>("assets/models/cube.gltf");
|
||||
/// if let Some(model) = model.get() {
|
||||
/// println!("{} meshes", model.meshes.len());
|
||||
/// }
|
||||
/// ```
|
||||
#[derive(Clone)]
|
||||
pub struct AssetServer {
|
||||
inner: Arc<Inner>,
|
||||
}
|
||||
|
||||
impl AssetServer {
|
||||
/// A server with the engine's built-in loaders registered (currently glTF).
|
||||
pub fn new() -> Self {
|
||||
let server = Self::empty();
|
||||
super::register_default_loaders(&server);
|
||||
server
|
||||
}
|
||||
|
||||
/// A server with **no** loaders registered. Use [`register_loader`] to add
|
||||
/// them; handy for tests or fully custom asset pipelines.
|
||||
///
|
||||
/// [`register_loader`]: Self::register_loader
|
||||
pub fn empty() -> Self {
|
||||
Self {
|
||||
inner: Arc::new(Inner {
|
||||
loaders: RwLock::new(HashMap::new()),
|
||||
cache: Mutex::new(HashMap::new()),
|
||||
next_id: AtomicU64::new(1),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Registers `loader`, mapping each of its extensions to it.
|
||||
pub fn register_loader<L: AssetLoader>(&self, loader: L) {
|
||||
let exts: Vec<String> = loader
|
||||
.extensions()
|
||||
.iter()
|
||||
.map(|e| e.to_lowercase())
|
||||
.collect();
|
||||
let erased: Arc<dyn ErasedLoader> = Arc::new(loader);
|
||||
let mut loaders = self.inner.loaders.write().unwrap();
|
||||
for ext in exts {
|
||||
loaders.insert(ext, erased.clone());
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes the loader registered for `extension` (without the dot). Returns
|
||||
/// whether one was present. Used when a module that added a loader is removed.
|
||||
pub fn unregister_loader(&self, extension: &str) -> bool {
|
||||
self.inner
|
||||
.loaders
|
||||
.write()
|
||||
.unwrap()
|
||||
.remove(&extension.to_lowercase())
|
||||
.is_some()
|
||||
}
|
||||
|
||||
/// Loads the asset at `path` as type `T`, blocking until it is ready.
|
||||
///
|
||||
/// Returns a handle to a cached asset if one of the same path+type is
|
||||
/// already live. On failure the returned handle is in the
|
||||
/// [`Failed`](super::LoadState::Failed) state (inspect [`Handle::error`]).
|
||||
pub fn load<T: Send + Sync + 'static>(&self, path: impl AsRef<Path>) -> Handle<T> {
|
||||
let path = path.as_ref().to_path_buf();
|
||||
let key = (TypeId::of::<T>(), path.clone());
|
||||
if let Some(handle) = self.cached::<T>(&key) {
|
||||
return handle;
|
||||
}
|
||||
match self.run_loader::<T>(&path) {
|
||||
Ok(value) => {
|
||||
let cell = AssetCell::new_loaded(self.next_id(), Some(path), value);
|
||||
self.insert_cache(key, &cell);
|
||||
Handle::from_cell(cell)
|
||||
}
|
||||
// Failures are not cached, so a later load retries from scratch.
|
||||
Err(err) => Handle::from_cell(AssetCell::new_failed(
|
||||
self.next_id(),
|
||||
Some(path),
|
||||
err.to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads the asset at `path` as type `T` on a background thread, returning a
|
||||
/// handle immediately in the [`Loading`](super::LoadState::Loading) state.
|
||||
///
|
||||
/// Poll [`Handle::state`]/[`Handle::get`], or block with [`Handle::wait`].
|
||||
pub fn load_async<T: Send + Sync + 'static>(&self, path: impl AsRef<Path>) -> Handle<T> {
|
||||
let path = path.as_ref().to_path_buf();
|
||||
let key = (TypeId::of::<T>(), path.clone());
|
||||
if let Some(handle) = self.cached::<T>(&key) {
|
||||
return handle;
|
||||
}
|
||||
// Insert the loading cell up front so concurrent requests dedup onto it.
|
||||
let cell = AssetCell::<T>::new_loading(self.next_id(), Some(path.clone()));
|
||||
self.insert_cache(key.clone(), &cell);
|
||||
|
||||
let server = self.clone();
|
||||
let worker_cell = cell.clone();
|
||||
std::thread::spawn(move || match server.run_loader::<T>(&path) {
|
||||
Ok(value) => worker_cell.set_loaded(value),
|
||||
Err(err) => {
|
||||
worker_cell.set_failed(err.to_string());
|
||||
// Don't leave a failed slot cached.
|
||||
server.inner.cache.lock().unwrap().remove(&key);
|
||||
}
|
||||
});
|
||||
Handle::from_cell(cell)
|
||||
}
|
||||
|
||||
/// Adds an already-constructed, in-memory asset and returns a handle to it.
|
||||
/// In-memory assets have no source path and are not cached for dedup.
|
||||
pub fn add<T: Send + Sync + 'static>(&self, value: T) -> Handle<T> {
|
||||
Handle::from_cell(AssetCell::new_loaded(self.next_id(), None, value))
|
||||
}
|
||||
|
||||
/// Returns a handle to an already-loaded asset of this path+type, if one is
|
||||
/// still live, without triggering a load.
|
||||
pub fn get<T: Send + Sync + 'static>(&self, path: impl AsRef<Path>) -> Option<Handle<T>> {
|
||||
let key = (TypeId::of::<T>(), path.as_ref().to_path_buf());
|
||||
self.cached::<T>(&key)
|
||||
}
|
||||
|
||||
/// Re-runs the loader for `path` and updates the existing asset in place, so
|
||||
/// every live handle observes the new contents. If no handle is currently
|
||||
/// live, behaves like [`load`](Self::load). This is the foundation the
|
||||
/// live-reload stage builds on.
|
||||
pub fn reload<T: Send + Sync + 'static>(&self, path: impl AsRef<Path>) -> Handle<T> {
|
||||
let path = path.as_ref().to_path_buf();
|
||||
let key = (TypeId::of::<T>(), path.clone());
|
||||
let existing = self.cached::<T>(&key);
|
||||
match self.run_loader::<T>(&path) {
|
||||
Ok(value) => match existing {
|
||||
Some(handle) => {
|
||||
handle.set_loaded(value);
|
||||
handle
|
||||
}
|
||||
None => {
|
||||
let cell = AssetCell::new_loaded(self.next_id(), Some(path), value);
|
||||
self.insert_cache(key, &cell);
|
||||
Handle::from_cell(cell)
|
||||
}
|
||||
},
|
||||
Err(err) => match existing {
|
||||
Some(handle) => {
|
||||
handle.set_failed(err.to_string());
|
||||
handle
|
||||
}
|
||||
None => Handle::from_cell(AssetCell::new_failed(
|
||||
self.next_id(),
|
||||
Some(path),
|
||||
err.to_string(),
|
||||
)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The number of distinct assets still alive (have at least one live
|
||||
/// handle). Prunes collected entries as a side effect.
|
||||
pub fn live_asset_count(&self) -> usize {
|
||||
let mut cache = self.inner.cache.lock().unwrap();
|
||||
cache.retain(|_, entry| entry.weak.strong_count() > 0);
|
||||
cache.len()
|
||||
}
|
||||
|
||||
/// Reruns the loader for every cached asset whose source path is `path`,
|
||||
/// updating each existing handle in place. Returns the number of assets
|
||||
/// reloaded.
|
||||
///
|
||||
/// Unlike [`reload`](Self::reload) this does **not** need `T` at the call
|
||||
/// site — it dispatches on what types are actually cached for `path`. The
|
||||
/// file-watcher uses this to react to disk changes without knowing every
|
||||
/// asset type at compile time. Paths that are not currently cached return
|
||||
/// `0`; they will be loaded fresh by the next [`load`](Self::load) call.
|
||||
pub fn reload_path(&self, path: &Path) -> usize {
|
||||
// Snapshot the set of typed reload fns to call so we don't hold the
|
||||
// cache lock while re-running loaders (which would deadlock — `reload`
|
||||
// takes the lock too).
|
||||
let reloaders: Vec<fn(&AssetServer, &Path)> = {
|
||||
let cache = self.inner.cache.lock().unwrap();
|
||||
cache
|
||||
.iter()
|
||||
.filter_map(|(key, entry)| {
|
||||
if key.1 == path && entry.weak.strong_count() > 0 {
|
||||
Some(entry.reload_in_place)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let n = reloaders.len();
|
||||
for f in reloaders {
|
||||
f(self, path);
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
// --- internals ---------------------------------------------------------
|
||||
|
||||
fn next_id(&self) -> AssetId {
|
||||
AssetId(self.inner.next_id.fetch_add(1, Ordering::Relaxed))
|
||||
}
|
||||
|
||||
fn cached<T: Send + Sync + 'static>(&self, key: &CacheKey) -> Option<Handle<T>> {
|
||||
let cache = self.inner.cache.lock().unwrap();
|
||||
let arc = cache.get(key)?.weak.upgrade()?;
|
||||
let cell = arc.downcast::<AssetCell<T>>().ok()?;
|
||||
Some(Handle::from_cell(cell))
|
||||
}
|
||||
|
||||
fn insert_cache<T: Send + Sync + 'static>(&self, key: CacheKey, cell: &Arc<AssetCell<T>>) {
|
||||
let erased: Arc<dyn Any + Send + Sync> = cell.clone();
|
||||
// `reload_in_place` keeps the concrete `T` in its signature, so the
|
||||
// path-keyed `reload_path` can rebuild the typed handle without
|
||||
// knowing `T` at the call site.
|
||||
let entry = CacheEntry {
|
||||
weak: Arc::downgrade(&erased),
|
||||
reload_in_place: |server, path| {
|
||||
server.reload::<T>(path);
|
||||
},
|
||||
};
|
||||
self.inner.cache.lock().unwrap().insert(key, entry);
|
||||
}
|
||||
|
||||
fn run_loader<T: Send + Sync + 'static>(&self, path: &Path) -> Result<T, AssetError> {
|
||||
let ext = path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.ok_or_else(|| AssetError::NoExtension(path.to_path_buf()))?
|
||||
.to_lowercase();
|
||||
let loader = self
|
||||
.inner
|
||||
.loaders
|
||||
.read()
|
||||
.unwrap()
|
||||
.get(&ext)
|
||||
.cloned()
|
||||
.ok_or_else(|| AssetError::NoLoader(ext.clone()))?;
|
||||
if loader.output_type() != TypeId::of::<T>() {
|
||||
return Err(AssetError::TypeMismatch { ext });
|
||||
}
|
||||
let boxed = loader.load(path)?;
|
||||
Ok(*boxed
|
||||
.downcast::<T>()
|
||||
.expect("loader output_type matched the request but downcast failed"))
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AssetServer {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::asset::LoadState;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
// A trivial asset + loader: each "load" reads a file's text and counts how
|
||||
// many times the loader actually ran, so dedup can be observed.
|
||||
struct Counter(Arc<AtomicU32>);
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
struct TextAsset(String);
|
||||
|
||||
struct TextLoader(Arc<AtomicU32>);
|
||||
impl AssetLoader for TextLoader {
|
||||
type Asset = TextAsset;
|
||||
fn extensions(&self) -> &'static [&'static str] {
|
||||
&["txt"]
|
||||
}
|
||||
fn load(&self, path: &Path) -> Result<TextAsset, AssetError> {
|
||||
self.0.fetch_add(1, Ordering::SeqCst);
|
||||
let text = std::fs::read_to_string(path).map_err(|e| AssetError::Load {
|
||||
path: path.to_path_buf(),
|
||||
message: e.to_string(),
|
||||
})?;
|
||||
Ok(TextAsset(text.trim().to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
fn temp_file(name: &str, contents: &str) -> PathBuf {
|
||||
let mut path = std::env::temp_dir();
|
||||
path.push(format!(
|
||||
"oxide_asset_test_{}_{name}.txt",
|
||||
std::process::id()
|
||||
));
|
||||
std::fs::write(&path, contents).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn server() -> (AssetServer, Counter) {
|
||||
let counter = Arc::new(AtomicU32::new(0));
|
||||
let server = AssetServer::empty();
|
||||
server.register_loader(TextLoader(counter.clone()));
|
||||
(server, Counter(counter))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_and_reads_an_asset() {
|
||||
let (server, _c) = server();
|
||||
let path = temp_file("hello", " hello world ");
|
||||
let handle = server.load::<TextAsset>(&path);
|
||||
assert_eq!(handle.state(), LoadState::Loaded);
|
||||
assert_eq!(handle.get().unwrap().0, "hello world");
|
||||
assert_eq!(handle.source(), Some(path.as_path()));
|
||||
std::fs::remove_file(path).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loading_twice_yields_one_resource() {
|
||||
let (server, c) = server();
|
||||
let path = temp_file("dedup", "data");
|
||||
let a = server.load::<TextAsset>(&path);
|
||||
let b = server.load::<TextAsset>(&path);
|
||||
// Same allocation: loader ran once, ids match, two handles share it.
|
||||
assert_eq!(c.0.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(a.id(), b.id());
|
||||
assert_eq!(a.ref_count(), 2);
|
||||
assert_eq!(server.live_asset_count(), 1);
|
||||
std::fs::remove_file(path).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dropping_all_handles_frees_the_asset() {
|
||||
let (server, _c) = server();
|
||||
let path = temp_file("free", "data");
|
||||
let handle = server.load::<TextAsset>(&path);
|
||||
assert_eq!(server.live_asset_count(), 1);
|
||||
drop(handle);
|
||||
// With no live handles, the weak cache entry is dead and pruned.
|
||||
assert_eq!(server.live_asset_count(), 0);
|
||||
assert!(server.get::<TextAsset>(&path).is_none());
|
||||
std::fs::remove_file(path).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_loader_and_type_mismatch_are_distinct_errors() {
|
||||
let (server, _c) = server();
|
||||
let path = temp_file("x", "data");
|
||||
|
||||
// No loader for ".dat".
|
||||
let bad_ext = path.with_extension("dat");
|
||||
std::fs::write(&bad_ext, "data").unwrap();
|
||||
let h = server.load::<TextAsset>(&bad_ext);
|
||||
assert_eq!(h.state(), LoadState::Failed);
|
||||
assert!(h.error().unwrap().contains("no loader"));
|
||||
|
||||
// A ".txt" loader exists but produces TextAsset, not String.
|
||||
let renamed = path.with_extension("txt");
|
||||
std::fs::write(&renamed, "data").unwrap();
|
||||
let h2 = server.load::<String>(&renamed);
|
||||
assert!(h2.error().unwrap().contains("different asset type"));
|
||||
|
||||
std::fs::remove_file(path).ok();
|
||||
std::fs::remove_file(bad_ext).ok();
|
||||
std::fs::remove_file(renamed).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn async_load_completes_and_dedups() {
|
||||
let (server, c) = server();
|
||||
let path = temp_file("async", "background");
|
||||
let handle = server.load_async::<TextAsset>(&path);
|
||||
let value = handle.wait().expect("async load should succeed");
|
||||
assert_eq!(value.0, "background");
|
||||
// A second request dedups onto the same now-loaded asset.
|
||||
let again = server.load::<TextAsset>(&path);
|
||||
assert_eq!(again.id(), handle.id());
|
||||
assert_eq!(c.0.load(Ordering::SeqCst), 1);
|
||||
std::fs::remove_file(path).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reload_updates_in_place_for_existing_handles() {
|
||||
let (server, _c) = server();
|
||||
let path = temp_file("reload", "before");
|
||||
let handle = server.load::<TextAsset>(&path);
|
||||
assert_eq!(handle.get().unwrap().0, "before");
|
||||
|
||||
// Change the file on disk and reload: the SAME handle sees new contents.
|
||||
std::fs::write(&path, "after").unwrap();
|
||||
let reloaded = server.reload::<TextAsset>(&path);
|
||||
assert_eq!(reloaded.id(), handle.id());
|
||||
assert_eq!(handle.get().unwrap().0, "after");
|
||||
std::fs::remove_file(path).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_stores_in_memory_assets() {
|
||||
let (server, _c) = server();
|
||||
let handle = server.add(TextAsset("in-memory".to_string()));
|
||||
assert_eq!(handle.get().unwrap().0, "in-memory");
|
||||
assert!(handle.source().is_none());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,212 @@
|
||||
//! [`AxisBinding`] and [`Axis2DBinding`] — directional inputs composed from
|
||||
//! [`Binding`]s into floats and [`Vec2`]s.
|
||||
//!
|
||||
//! A 1D axis pairs a "positive" binding set with a "negative" binding set;
|
||||
//! each direction held contributes ±1. If both directions are held the
|
||||
//! contributions cancel and the axis reads 0 — a "soft brake" any third-
|
||||
//! person camera or twin-stick character controller needs out of the box.
|
||||
//! Each direction supports several bindings (a WASD axis can also accept
|
||||
//! arrow keys), and the same physical key can appear in many axes' direction
|
||||
//! sets.
|
||||
//!
|
||||
//! A 2D axis is just a pair of 1D axes (X then Y). Diagonals are
|
||||
//! intentionally **not** normalized at this layer — some games want
|
||||
//! Quake-style diagonal speedup, others want unit-length input. Whichever
|
||||
//! convention a game wants, applying it once at the call site is clearer
|
||||
//! than having to undo a default at every site that disagrees.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::math::Vec2;
|
||||
|
||||
use super::{Binding, InputState};
|
||||
|
||||
/// One direction of an axis — typically positive (right / forward / up) or
|
||||
/// negative (left / back / down) — bound to one or more physical inputs.
|
||||
/// Any binding held contributes a full unit; multiple held bindings on the
|
||||
/// same direction do not stack.
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AxisBinding {
|
||||
/// Bindings that pull the axis toward +1.
|
||||
pub positive: Vec<Binding>,
|
||||
/// Bindings that pull the axis toward -1.
|
||||
pub negative: Vec<Binding>,
|
||||
}
|
||||
|
||||
impl AxisBinding {
|
||||
/// A new axis with the given direction binding lists.
|
||||
pub fn new(
|
||||
positive: impl IntoIterator<Item = Binding>,
|
||||
negative: impl IntoIterator<Item = Binding>,
|
||||
) -> Self {
|
||||
Self {
|
||||
positive: positive.into_iter().collect(),
|
||||
negative: negative.into_iter().collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluates the axis against `input`. Returns -1, 0, or +1 (the
|
||||
/// directions OR'd together — multiple held bindings on the same side
|
||||
/// don't stack).
|
||||
pub fn value(&self, input: &InputState) -> f32 {
|
||||
let pos = self.positive.iter().any(|b| b.held(input));
|
||||
let neg = self.negative.iter().any(|b| b.held(input));
|
||||
match (pos, neg) {
|
||||
(true, false) => 1.0,
|
||||
(false, true) => -1.0,
|
||||
// Both held → mutual cancel; neither → idle. Same result.
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A 2D axis composed of two [`AxisBinding`]s (X and Y).
|
||||
///
|
||||
/// Output is the unmodified vector `(x.value, y.value)` — diagonals are
|
||||
/// `(±1, ±1)`, magnitude √2. Normalize at the call site if your game wants
|
||||
/// unit-length movement.
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Axis2DBinding {
|
||||
/// The X (right − left) axis.
|
||||
pub x: AxisBinding,
|
||||
/// The Y (up − down) axis.
|
||||
pub y: AxisBinding,
|
||||
}
|
||||
|
||||
impl Axis2DBinding {
|
||||
/// A 2D axis from four direction binding lists in the usual order
|
||||
/// (`right`, `left`, `up`, `down`).
|
||||
pub fn new(
|
||||
right: impl IntoIterator<Item = Binding>,
|
||||
left: impl IntoIterator<Item = Binding>,
|
||||
up: impl IntoIterator<Item = Binding>,
|
||||
down: impl IntoIterator<Item = Binding>,
|
||||
) -> Self {
|
||||
Self {
|
||||
x: AxisBinding::new(right, left),
|
||||
y: AxisBinding::new(up, down),
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluates the axis against `input`, returning the raw `(x, y)` value
|
||||
/// without normalization.
|
||||
pub fn value(&self, input: &InputState) -> Vec2 {
|
||||
Vec2::new(self.x.value(input), self.y.value(input))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use winit::keyboard::KeyCode;
|
||||
|
||||
fn ad_axis() -> AxisBinding {
|
||||
AxisBinding::new([Binding::Key(KeyCode::KeyD)], [Binding::Key(KeyCode::KeyA)])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_axis_is_zero() {
|
||||
let input = InputState::new();
|
||||
assert_eq!(ad_axis().value(&input), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn positive_direction_returns_plus_one() {
|
||||
let mut input = InputState::new();
|
||||
input.press_key(KeyCode::KeyD);
|
||||
assert_eq!(ad_axis().value(&input), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_direction_returns_minus_one() {
|
||||
let mut input = InputState::new();
|
||||
input.press_key(KeyCode::KeyA);
|
||||
assert_eq!(ad_axis().value(&input), -1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_directions_held_cancel_to_zero() {
|
||||
let mut input = InputState::new();
|
||||
input.press_key(KeyCode::KeyA);
|
||||
input.press_key(KeyCode::KeyD);
|
||||
assert_eq!(
|
||||
ad_axis().value(&input),
|
||||
0.0,
|
||||
"left+right held simultaneously must read as idle"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_bindings_on_same_direction_do_not_stack() {
|
||||
// WASD + arrow keys both contribute, but holding two positives is
|
||||
// still +1 (not +2). The axis is a directional indicator, not an
|
||||
// accumulator.
|
||||
let axis = AxisBinding::new(
|
||||
[
|
||||
Binding::Key(KeyCode::KeyD),
|
||||
Binding::Key(KeyCode::ArrowRight),
|
||||
],
|
||||
[
|
||||
Binding::Key(KeyCode::KeyA),
|
||||
Binding::Key(KeyCode::ArrowLeft),
|
||||
],
|
||||
);
|
||||
let mut input = InputState::new();
|
||||
input.press_key(KeyCode::KeyD);
|
||||
input.press_key(KeyCode::ArrowRight);
|
||||
assert_eq!(axis.value(&input), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn axis_2d_returns_vector_components_independently() {
|
||||
let axis = Axis2DBinding::new(
|
||||
[Binding::Key(KeyCode::KeyD)],
|
||||
[Binding::Key(KeyCode::KeyA)],
|
||||
[Binding::Key(KeyCode::KeyW)],
|
||||
[Binding::Key(KeyCode::KeyS)],
|
||||
);
|
||||
let mut input = InputState::new();
|
||||
|
||||
input.press_key(KeyCode::KeyD);
|
||||
input.press_key(KeyCode::KeyW);
|
||||
assert_eq!(axis.value(&input), Vec2::new(1.0, 1.0));
|
||||
|
||||
input.release_key(KeyCode::KeyD);
|
||||
input.press_key(KeyCode::KeyA);
|
||||
// Now A + W held.
|
||||
assert_eq!(axis.value(&input), Vec2::new(-1.0, 1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn axis_2d_diagonal_is_unnormalized() {
|
||||
// Diagonals are (±1, ±1) — caller normalizes if it cares.
|
||||
let axis = Axis2DBinding::new(
|
||||
[Binding::Key(KeyCode::KeyD)],
|
||||
[Binding::Key(KeyCode::KeyA)],
|
||||
[Binding::Key(KeyCode::KeyW)],
|
||||
[Binding::Key(KeyCode::KeyS)],
|
||||
);
|
||||
let mut input = InputState::new();
|
||||
input.press_key(KeyCode::KeyD);
|
||||
input.press_key(KeyCode::KeyW);
|
||||
let v = axis.value(&input);
|
||||
assert!(
|
||||
(v.length() - 2_f32.sqrt()).abs() < 1e-6,
|
||||
"diagonal must be sqrt(2), got {}",
|
||||
v.length()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn axis_ron_round_trip() {
|
||||
let axis = Axis2DBinding::new(
|
||||
[Binding::Key(KeyCode::KeyD)],
|
||||
[Binding::Key(KeyCode::KeyA)],
|
||||
[Binding::Key(KeyCode::KeyW)],
|
||||
[Binding::Key(KeyCode::KeyS)],
|
||||
);
|
||||
let s = ron::to_string(&axis).unwrap();
|
||||
let parsed: Axis2DBinding = ron::from_str(&s).unwrap();
|
||||
assert_eq!(parsed, axis);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
//! [`Binding`] — one physical input that can drive a named action.
|
||||
//!
|
||||
//! A binding is the smallest unit an [`ActionMap`](super::ActionMap) maps
|
||||
//! action names to. The enum is intentionally small (keys and mouse buttons
|
||||
//! today; gamepad / pointer-axis variants will be added without breaking
|
||||
//! existing serialized maps as long as new variants are appended).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use winit::event::MouseButton;
|
||||
use winit::keyboard::KeyCode;
|
||||
|
||||
use super::InputState;
|
||||
|
||||
/// One physical input that can be bound to a named action.
|
||||
///
|
||||
/// Two bindings compare equal only if they refer to the exact same physical
|
||||
/// input — the enum derives `Hash`/`Eq` so a `HashSet<Binding>` can be used
|
||||
/// to deduplicate a key's contribution to multiple actions without
|
||||
/// allocating per-action sets.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum Binding {
|
||||
/// A keyboard key, identified by layout-independent physical position
|
||||
/// (the same `KeyCode` an [`InputState`] query takes).
|
||||
Key(KeyCode),
|
||||
/// A mouse button.
|
||||
Mouse(MouseButton),
|
||||
}
|
||||
|
||||
impl Binding {
|
||||
/// `true` if this binding's `pressed` edge fired in `input` this frame.
|
||||
pub fn pressed(&self, input: &InputState) -> bool {
|
||||
match *self {
|
||||
Binding::Key(k) => input.pressed(k),
|
||||
Binding::Mouse(b) => input.mouse_pressed(b),
|
||||
}
|
||||
}
|
||||
|
||||
/// `true` if this binding's `released` edge fired in `input` this frame.
|
||||
pub fn released(&self, input: &InputState) -> bool {
|
||||
match *self {
|
||||
Binding::Key(k) => input.released(k),
|
||||
Binding::Mouse(b) => input.mouse_released(b),
|
||||
}
|
||||
}
|
||||
|
||||
/// `true` if this binding is currently held down in `input`.
|
||||
pub fn held(&self, input: &InputState) -> bool {
|
||||
match *self {
|
||||
Binding::Key(k) => input.held(k),
|
||||
Binding::Mouse(b) => input.mouse_held(b),
|
||||
}
|
||||
}
|
||||
|
||||
/// `true` if this binding was held *going into* this frame — i.e. it was
|
||||
/// held continuously from before the current frame's events arrived.
|
||||
/// Used by [`ActionMap`](super::ActionMap) to recover prior-frame state
|
||||
/// from the current frame's snapshot alone, without storing a previous
|
||||
/// `InputState`.
|
||||
///
|
||||
/// Derivation: a binding was held before the frame iff it is currently
|
||||
/// held or was released this frame (either way it was down going in),
|
||||
/// **except** when it was also pressed this frame — a same-frame tap
|
||||
/// goes idle → pressed → released, so it was not held going in.
|
||||
pub(crate) fn held_before_frame(&self, input: &InputState) -> bool {
|
||||
(self.held(input) || self.released(input)) && !self.pressed(input)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn key_binding_routes_to_keyboard_queries() {
|
||||
let mut input = InputState::new();
|
||||
let b = Binding::Key(KeyCode::Space);
|
||||
|
||||
input.press_key(KeyCode::Space);
|
||||
assert!(b.pressed(&input));
|
||||
assert!(b.held(&input));
|
||||
assert!(!b.released(&input));
|
||||
|
||||
input.end_frame();
|
||||
assert!(!b.pressed(&input));
|
||||
assert!(b.held(&input));
|
||||
|
||||
input.release_key(KeyCode::Space);
|
||||
assert!(b.released(&input));
|
||||
assert!(!b.held(&input));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mouse_binding_routes_to_mouse_queries() {
|
||||
let mut input = InputState::new();
|
||||
let b = Binding::Mouse(MouseButton::Right);
|
||||
|
||||
input.press_mouse(MouseButton::Right);
|
||||
assert!(b.pressed(&input));
|
||||
assert!(b.held(&input));
|
||||
|
||||
input.end_frame();
|
||||
input.release_mouse(MouseButton::Right);
|
||||
assert!(b.released(&input));
|
||||
assert!(!b.held(&input));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn held_before_frame_distinguishes_press_release_tap() {
|
||||
let b = Binding::Key(KeyCode::KeyJ);
|
||||
|
||||
// Idle → pressed this frame. Not held before.
|
||||
let mut input = InputState::new();
|
||||
input.press_key(KeyCode::KeyJ);
|
||||
assert!(!b.held_before_frame(&input));
|
||||
|
||||
// Held continuously. Held before.
|
||||
let mut input = InputState::new();
|
||||
input.press_key(KeyCode::KeyJ);
|
||||
input.end_frame();
|
||||
assert!(b.held_before_frame(&input));
|
||||
|
||||
// Held → released this frame. Held before.
|
||||
let mut input = InputState::new();
|
||||
input.press_key(KeyCode::KeyJ);
|
||||
input.end_frame();
|
||||
input.release_key(KeyCode::KeyJ);
|
||||
assert!(b.held_before_frame(&input));
|
||||
|
||||
// Same-frame tap (idle → pressed → released). Not held before.
|
||||
let mut input = InputState::new();
|
||||
input.press_key(KeyCode::KeyJ);
|
||||
input.release_key(KeyCode::KeyJ);
|
||||
assert!(!b.held_before_frame(&input));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ron_round_trip_preserves_key_and_mouse_variants() {
|
||||
let bindings = vec![
|
||||
Binding::Key(KeyCode::Space),
|
||||
Binding::Mouse(MouseButton::Left),
|
||||
Binding::Key(KeyCode::ShiftLeft),
|
||||
];
|
||||
let s = ron::to_string(&bindings).unwrap();
|
||||
let parsed: Vec<Binding> = ron::from_str(&s).unwrap();
|
||||
assert_eq!(parsed, bindings);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
//! Per-frame input — raw state, edges, and remappable named actions.
|
||||
//!
|
||||
//! Stage 7 builds the engine's input abstraction in three layers:
|
||||
//!
|
||||
//! 1. [`InputState`] (piece 1) — the per-frame snapshot of keyboard, mouse,
|
||||
//! cursor, and scroll, with `pressed` / `released` edge detection and a
|
||||
//! persistent `held` state. The windowing runner pumps raw `WindowEvent`s
|
||||
//! into it and clears edges between frames; game/editor code reads it via
|
||||
//! [`AppCtx::input`](crate::window::AppCtx::input).
|
||||
//! 2. [`Binding`] + [`ActionMap`] (piece 2) — named actions like `"Jump"`
|
||||
//! bound to one or more physical inputs, each carrying a **default**
|
||||
//! binding and a (possibly remapped) **current** binding. Game code
|
||||
//! queries actions by name, so a user-facing remap never touches game
|
||||
//! code. Current bindings round-trip through RON for persistence
|
||||
//! (typically via the [`Settings`](crate::settings::Settings) framework).
|
||||
//! 3. [`AxisBinding`] + [`Axis2DBinding`] (piece 3) — directional inputs
|
||||
//! composed from `Binding` direction sets (e.g. `WASD` → `Vec2 "Move"`),
|
||||
//! stored alongside button actions in the same [`ActionMap`] and
|
||||
//! persisted through the same [`ActionOverrides`] payload.
|
||||
//!
|
||||
//! # Why edges and state are tracked separately
|
||||
//!
|
||||
//! Game logic typically wants three distinct things from a physical input:
|
||||
//! the moment it became pressed (a jump fires once on key-down, never on
|
||||
//! subsequent frames while held), the moment it was released (a charged
|
||||
//! shot fires on key-up), and whether it is currently down (a sprint key
|
||||
//! accelerates while held). Tracking all three explicitly makes the
|
||||
//! semantics robust against OS key auto-repeat — a held key produces a
|
||||
//! single `pressed` edge no matter how many times the OS re-sends the
|
||||
//! event — and avoids the per-callsite bookkeeping every action would
|
||||
//! otherwise need.
|
||||
//!
|
||||
//! # Quick reference
|
||||
//!
|
||||
//! ```
|
||||
//! use oxide_engine::input::{ActionMap, Binding, InputState};
|
||||
//! use oxide_engine::winit::keyboard::KeyCode;
|
||||
//!
|
||||
//! let mut input = InputState::new();
|
||||
//! input.press_key(KeyCode::Space);
|
||||
//! assert!(input.pressed(KeyCode::Space)); // edge — true only this frame
|
||||
//! assert!(input.held(KeyCode::Space)); // state — true while held
|
||||
//!
|
||||
//! // Layer named actions on top — game code never names the physical key.
|
||||
//! let mut actions = ActionMap::new();
|
||||
//! actions.register("Jump", [Binding::Key(KeyCode::Space)]);
|
||||
//! assert!(actions.action_pressed("Jump", &input));
|
||||
//!
|
||||
//! input.end_frame();
|
||||
//! assert!(!input.pressed(KeyCode::Space)); // edge cleared
|
||||
//! assert!(input.held(KeyCode::Space)); // held persists
|
||||
//! ```
|
||||
//!
|
||||
//! # Synthesized-event API
|
||||
//!
|
||||
//! The mutators on [`InputState`] (`press_key`, `release_mouse`,
|
||||
//! `set_cursor`, `add_mouse_delta`, `add_scroll`, `forget_cursor`,
|
||||
//! `release_all_held`) are the same path `handle_event` uses, and are
|
||||
//! intentionally public so tests can drive input directly without
|
||||
//! constructing `winit` events (winit 0.30's `DeviceId` cannot be
|
||||
//! fabricated outside an event loop, so most `WindowEvent` variants are
|
||||
//! unreachable from synthesized events).
|
||||
|
||||
mod action;
|
||||
mod axis;
|
||||
mod binding;
|
||||
mod state;
|
||||
|
||||
pub use action::{ActionMap, ActionOverrides};
|
||||
pub use axis::{Axis2DBinding, AxisBinding};
|
||||
pub use binding::Binding;
|
||||
pub use state::InputState;
|
||||
@@ -0,0 +1,472 @@
|
||||
//! The per-frame [`InputState`] — keyboard, mouse, cursor, and scroll with
|
||||
//! edge detection. The module-level documentation lives in
|
||||
//! [`crate::input`](super); this file is the implementation.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use winit::event::{ElementState, MouseButton, MouseScrollDelta, WindowEvent};
|
||||
use winit::keyboard::{KeyCode, PhysicalKey};
|
||||
|
||||
use crate::math::Vec2;
|
||||
|
||||
/// Pixels-per-line factor used to normalize trackpad pixel scroll deltas into
|
||||
/// the same units as wheel-notch [`MouseScrollDelta::LineDelta`]. Matches the
|
||||
/// convention the editor's orbit-camera zoom already uses, so behavior is
|
||||
/// consistent whether the user has a mouse wheel or a touchpad.
|
||||
const SCROLL_PIXELS_PER_LINE: f32 = 40.0;
|
||||
|
||||
/// Per-frame snapshot of keyboard, mouse, and pointer state.
|
||||
///
|
||||
/// Built up across the frame from raw events and queried by game / editor
|
||||
/// code. All edge sets (pressed / released, mouse delta, scroll) are cleared
|
||||
/// by [`end_frame`](Self::end_frame); held state and cursor position persist
|
||||
/// across frames.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct InputState {
|
||||
keys_held: HashSet<KeyCode>,
|
||||
keys_pressed: HashSet<KeyCode>,
|
||||
keys_released: HashSet<KeyCode>,
|
||||
|
||||
mouse_held: HashSet<MouseButton>,
|
||||
mouse_pressed: HashSet<MouseButton>,
|
||||
mouse_released: HashSet<MouseButton>,
|
||||
|
||||
cursor: Option<Vec2>,
|
||||
mouse_delta: Vec2,
|
||||
scroll: Vec2,
|
||||
}
|
||||
|
||||
impl InputState {
|
||||
/// A new state with nothing pressed and no cursor known.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
// --- Queries: keyboard -------------------------------------------------
|
||||
|
||||
/// `true` if `key` became pressed this frame (edge — true for exactly the
|
||||
/// frame of the key-down, regardless of OS auto-repeat).
|
||||
pub fn pressed(&self, key: KeyCode) -> bool {
|
||||
self.keys_pressed.contains(&key)
|
||||
}
|
||||
|
||||
/// `true` if `key` was released this frame (edge — true for exactly the
|
||||
/// frame of the key-up).
|
||||
pub fn released(&self, key: KeyCode) -> bool {
|
||||
self.keys_released.contains(&key)
|
||||
}
|
||||
|
||||
/// `true` if `key` is currently held down (state — true every frame until
|
||||
/// the key-up arrives).
|
||||
pub fn held(&self, key: KeyCode) -> bool {
|
||||
self.keys_held.contains(&key)
|
||||
}
|
||||
|
||||
/// All currently-held keys. Useful for debug overlays.
|
||||
pub fn keys_held(&self) -> impl Iterator<Item = KeyCode> + '_ {
|
||||
self.keys_held.iter().copied()
|
||||
}
|
||||
|
||||
// --- Queries: mouse ----------------------------------------------------
|
||||
|
||||
/// `true` if `button` became pressed this frame (edge).
|
||||
pub fn mouse_pressed(&self, button: MouseButton) -> bool {
|
||||
self.mouse_pressed.contains(&button)
|
||||
}
|
||||
|
||||
/// `true` if `button` was released this frame (edge).
|
||||
pub fn mouse_released(&self, button: MouseButton) -> bool {
|
||||
self.mouse_released.contains(&button)
|
||||
}
|
||||
|
||||
/// `true` if `button` is currently held down (state).
|
||||
pub fn mouse_held(&self, button: MouseButton) -> bool {
|
||||
self.mouse_held.contains(&button)
|
||||
}
|
||||
|
||||
/// All currently-held mouse buttons.
|
||||
pub fn mouse_buttons_held(&self) -> impl Iterator<Item = MouseButton> + '_ {
|
||||
self.mouse_held.iter().copied()
|
||||
}
|
||||
|
||||
/// Current cursor position in physical pixels, or `None` if the cursor
|
||||
/// has not entered the window yet (or just left it).
|
||||
pub fn cursor(&self) -> Option<Vec2> {
|
||||
self.cursor
|
||||
}
|
||||
|
||||
/// Cursor movement since the last [`end_frame`](Self::end_frame), in
|
||||
/// physical pixels. The first cursor event of a session (or after a
|
||||
/// [`CursorLeft`](WindowEvent::CursorLeft)) seeds the position **without**
|
||||
/// producing a delta, so consumers never see a phantom jump on the first
|
||||
/// frame the cursor appears.
|
||||
pub fn mouse_delta(&self) -> Vec2 {
|
||||
self.mouse_delta
|
||||
}
|
||||
|
||||
/// Scroll accumulated since the last [`end_frame`](Self::end_frame), in
|
||||
/// line-equivalent units (pixel deltas are divided by a fixed pixels-per-
|
||||
/// line constant so wheels and touchpads report on the same scale).
|
||||
pub fn scroll(&self) -> Vec2 {
|
||||
self.scroll
|
||||
}
|
||||
|
||||
// --- Event pump --------------------------------------------------------
|
||||
|
||||
/// Folds one raw [`WindowEvent`] into the state.
|
||||
///
|
||||
/// Non-input events (resize, redraw, focus, …) are ignored, so the runner
|
||||
/// can pump every event without filtering. Auto-repeat key-down events
|
||||
/// from the OS do not re-fire the [`pressed`](Self::pressed) edge: a held
|
||||
/// key only produces an edge on the first down.
|
||||
pub fn handle_event(&mut self, event: &WindowEvent) {
|
||||
match event {
|
||||
WindowEvent::KeyboardInput { event, .. } => {
|
||||
if let PhysicalKey::Code(code) = event.physical_key {
|
||||
match event.state {
|
||||
ElementState::Pressed => self.press_key(code),
|
||||
ElementState::Released => self.release_key(code),
|
||||
}
|
||||
}
|
||||
}
|
||||
WindowEvent::MouseInput { state, button, .. } => match state {
|
||||
ElementState::Pressed => self.press_mouse(*button),
|
||||
ElementState::Released => self.release_mouse(*button),
|
||||
},
|
||||
WindowEvent::CursorMoved { position, .. } => {
|
||||
self.set_cursor(Vec2::new(position.x as f32, position.y as f32));
|
||||
}
|
||||
WindowEvent::CursorLeft { .. } => self.forget_cursor(),
|
||||
WindowEvent::MouseWheel { delta, .. } => match delta {
|
||||
MouseScrollDelta::LineDelta(x, y) => self.add_scroll(*x, *y),
|
||||
MouseScrollDelta::PixelDelta(p) => self.add_scroll(
|
||||
p.x as f32 / SCROLL_PIXELS_PER_LINE,
|
||||
p.y as f32 / SCROLL_PIXELS_PER_LINE,
|
||||
),
|
||||
},
|
||||
WindowEvent::Focused(false) => self.release_all_held(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Synthesized mutators (used by both handle_event and tests) --------
|
||||
|
||||
/// Records that `key` was pressed. The [`pressed`](Self::pressed) edge
|
||||
/// fires only when the key was not already held, so OS auto-repeat does
|
||||
/// not retrigger one-shot actions.
|
||||
pub fn press_key(&mut self, key: KeyCode) {
|
||||
if self.keys_held.insert(key) {
|
||||
self.keys_pressed.insert(key);
|
||||
}
|
||||
}
|
||||
|
||||
/// Records that `key` was released. The [`released`](Self::released)
|
||||
/// edge fires whether or not the key was previously tracked as held —
|
||||
/// the OS occasionally sends a release without a matching press (e.g.
|
||||
/// the window gained focus mid-press).
|
||||
pub fn release_key(&mut self, key: KeyCode) {
|
||||
self.keys_held.remove(&key);
|
||||
self.keys_released.insert(key);
|
||||
}
|
||||
|
||||
/// Records that `button` was pressed (with the same edge semantics as
|
||||
/// [`press_key`]).
|
||||
pub fn press_mouse(&mut self, button: MouseButton) {
|
||||
if self.mouse_held.insert(button) {
|
||||
self.mouse_pressed.insert(button);
|
||||
}
|
||||
}
|
||||
|
||||
/// Records that `button` was released.
|
||||
pub fn release_mouse(&mut self, button: MouseButton) {
|
||||
self.mouse_held.remove(&button);
|
||||
self.mouse_released.insert(button);
|
||||
}
|
||||
|
||||
/// Sets the cursor position. The delta is accumulated **only** relative
|
||||
/// to a previously-known cursor; the very first set (or the first set
|
||||
/// after a [`CursorLeft`](WindowEvent::CursorLeft) event) seeds the
|
||||
/// position without contributing to [`mouse_delta`](Self::mouse_delta).
|
||||
pub fn set_cursor(&mut self, position: Vec2) {
|
||||
if let Some(prev) = self.cursor {
|
||||
self.mouse_delta += position - prev;
|
||||
}
|
||||
self.cursor = Some(position);
|
||||
}
|
||||
|
||||
/// Adds a raw mouse delta in physical pixels. Useful for relative-motion
|
||||
/// sources (`DeviceEvent::MouseMotion`, future pointer-lock) and for tests.
|
||||
pub fn add_mouse_delta(&mut self, dx: f32, dy: f32) {
|
||||
self.mouse_delta += Vec2::new(dx, dy);
|
||||
}
|
||||
|
||||
/// Adds a scroll increment in line-equivalent units.
|
||||
pub fn add_scroll(&mut self, x: f32, y: f32) {
|
||||
self.scroll += Vec2::new(x, y);
|
||||
}
|
||||
|
||||
// --- Frame boundary ----------------------------------------------------
|
||||
|
||||
/// Clears per-frame edge state and accumulated deltas; held state and
|
||||
/// cursor position persist. The runner calls this after game logic has
|
||||
/// read the edges for the current frame.
|
||||
pub fn end_frame(&mut self) {
|
||||
self.keys_pressed.clear();
|
||||
self.keys_released.clear();
|
||||
self.mouse_pressed.clear();
|
||||
self.mouse_released.clear();
|
||||
self.mouse_delta = Vec2::ZERO;
|
||||
self.scroll = Vec2::ZERO;
|
||||
}
|
||||
|
||||
/// Forgets the cursor anchor so the next [`set_cursor`](Self::set_cursor)
|
||||
/// re-seeds without producing a phantom delta. The event pump calls this
|
||||
/// on [`CursorLeft`](WindowEvent::CursorLeft); the public exposure lets
|
||||
/// hosts that drive `InputState` directly (e.g. tests, or a future
|
||||
/// pointer-lock toggle) re-anchor without simulating a window event.
|
||||
pub fn forget_cursor(&mut self) {
|
||||
self.cursor = None;
|
||||
}
|
||||
|
||||
/// Releases every currently-held key and mouse button (firing each
|
||||
/// `released` edge once). The event pump calls this when the window
|
||||
/// loses focus, since the OS will never deliver the matching releases
|
||||
/// for keys held at that moment, and stuck-key bugs would otherwise
|
||||
/// follow the window across alt-tab cycles.
|
||||
pub fn release_all_held(&mut self) {
|
||||
for key in self.keys_held.drain() {
|
||||
self.keys_released.insert(key);
|
||||
}
|
||||
for button in self.mouse_held.drain() {
|
||||
self.mouse_released.insert(button);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn key_press_sets_edge_and_state() {
|
||||
let mut input = InputState::new();
|
||||
input.press_key(KeyCode::Space);
|
||||
|
||||
assert!(input.pressed(KeyCode::Space));
|
||||
assert!(input.held(KeyCode::Space));
|
||||
assert!(!input.released(KeyCode::Space));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_frame_clears_edges_but_not_held() {
|
||||
let mut input = InputState::new();
|
||||
input.press_key(KeyCode::Space);
|
||||
input.end_frame();
|
||||
|
||||
assert!(!input.pressed(KeyCode::Space), "edge must clear");
|
||||
assert!(input.held(KeyCode::Space), "state must persist");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_release_sets_edge_and_clears_held() {
|
||||
let mut input = InputState::new();
|
||||
input.press_key(KeyCode::KeyA);
|
||||
input.end_frame();
|
||||
|
||||
input.release_key(KeyCode::KeyA);
|
||||
assert!(input.released(KeyCode::KeyA));
|
||||
assert!(!input.held(KeyCode::KeyA));
|
||||
assert!(!input.pressed(KeyCode::KeyA));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn os_auto_repeat_does_not_refire_pressed_edge() {
|
||||
let mut input = InputState::new();
|
||||
input.press_key(KeyCode::KeyW);
|
||||
input.end_frame(); // pressed edge consumed
|
||||
|
||||
// The OS resends Pressed for the same key while it's held.
|
||||
input.press_key(KeyCode::KeyW);
|
||||
assert!(
|
||||
!input.pressed(KeyCode::KeyW),
|
||||
"auto-repeat must not retrigger pressed"
|
||||
);
|
||||
assert!(input.held(KeyCode::KeyW));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_without_prior_press_still_emits_edge() {
|
||||
// The OS occasionally delivers a release with no matching press (e.g.
|
||||
// window focused mid-press). The released edge still fires so consumers
|
||||
// can react.
|
||||
let mut input = InputState::new();
|
||||
input.release_key(KeyCode::Escape);
|
||||
assert!(input.released(KeyCode::Escape));
|
||||
assert!(!input.held(KeyCode::Escape));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressed_and_released_in_same_frame_both_fire() {
|
||||
// Within a single frame a quick tap should register both edges so
|
||||
// logic that wants a "click on release" pattern is reachable from
|
||||
// the synthesized input path.
|
||||
let mut input = InputState::new();
|
||||
input.press_key(KeyCode::Enter);
|
||||
input.release_key(KeyCode::Enter);
|
||||
|
||||
assert!(input.pressed(KeyCode::Enter));
|
||||
assert!(input.released(KeyCode::Enter));
|
||||
assert!(!input.held(KeyCode::Enter));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mouse_button_edges_parallel_keyboard() {
|
||||
let mut input = InputState::new();
|
||||
input.press_mouse(MouseButton::Left);
|
||||
assert!(input.mouse_pressed(MouseButton::Left));
|
||||
assert!(input.mouse_held(MouseButton::Left));
|
||||
|
||||
input.end_frame();
|
||||
assert!(!input.mouse_pressed(MouseButton::Left));
|
||||
assert!(input.mouse_held(MouseButton::Left));
|
||||
|
||||
input.release_mouse(MouseButton::Left);
|
||||
assert!(input.mouse_released(MouseButton::Left));
|
||||
assert!(!input.mouse_held(MouseButton::Left));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_cursor_move_produces_no_delta() {
|
||||
let mut input = InputState::new();
|
||||
input.set_cursor(Vec2::new(100.0, 200.0));
|
||||
assert_eq!(input.mouse_delta(), Vec2::ZERO);
|
||||
assert_eq!(input.cursor(), Some(Vec2::new(100.0, 200.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subsequent_cursor_moves_accumulate_delta() {
|
||||
let mut input = InputState::new();
|
||||
input.set_cursor(Vec2::new(100.0, 200.0));
|
||||
input.set_cursor(Vec2::new(110.0, 195.0));
|
||||
input.set_cursor(Vec2::new(115.0, 190.0));
|
||||
|
||||
// (110-100) + (115-110), (195-200) + (190-195) = (15, -10)
|
||||
assert_eq!(input.mouse_delta(), Vec2::new(15.0, -10.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_frame_resets_delta_but_preserves_cursor() {
|
||||
let mut input = InputState::new();
|
||||
input.set_cursor(Vec2::new(0.0, 0.0));
|
||||
input.set_cursor(Vec2::new(10.0, 10.0));
|
||||
input.end_frame();
|
||||
|
||||
assert_eq!(input.mouse_delta(), Vec2::ZERO);
|
||||
assert_eq!(input.cursor(), Some(Vec2::new(10.0, 10.0)));
|
||||
|
||||
// Next move accumulates from the persisted cursor, not from zero.
|
||||
input.set_cursor(Vec2::new(13.0, 11.0));
|
||||
assert_eq!(input.mouse_delta(), Vec2::new(3.0, 1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_mouse_delta_layers_on_top_of_cursor_motion() {
|
||||
let mut input = InputState::new();
|
||||
input.set_cursor(Vec2::new(0.0, 0.0));
|
||||
input.set_cursor(Vec2::new(5.0, 0.0));
|
||||
input.add_mouse_delta(2.0, 3.0); // e.g. raw DeviceEvent motion
|
||||
|
||||
assert_eq!(input.mouse_delta(), Vec2::new(7.0, 3.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scroll_accumulates_and_resets() {
|
||||
let mut input = InputState::new();
|
||||
input.add_scroll(0.0, 1.0);
|
||||
input.add_scroll(0.0, 2.5);
|
||||
assert_eq!(input.scroll(), Vec2::new(0.0, 3.5));
|
||||
|
||||
input.end_frame();
|
||||
assert_eq!(input.scroll(), Vec2::ZERO);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn focus_loss_via_handle_event_releases_held() {
|
||||
let mut input = InputState::new();
|
||||
input.press_key(KeyCode::KeyW);
|
||||
input.press_mouse(MouseButton::Left);
|
||||
input.end_frame();
|
||||
|
||||
// Focused(false) is one of the WindowEvent variants with no DeviceId,
|
||||
// so the routing through handle_event itself is exercised here.
|
||||
input.handle_event(&WindowEvent::Focused(false));
|
||||
|
||||
assert!(!input.held(KeyCode::KeyW), "key must not stay stuck");
|
||||
assert!(!input.mouse_held(MouseButton::Left));
|
||||
assert!(input.released(KeyCode::KeyW));
|
||||
assert!(input.mouse_released(MouseButton::Left));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_all_held_drops_state_and_fires_edges() {
|
||||
let mut input = InputState::new();
|
||||
input.press_key(KeyCode::KeyW);
|
||||
input.press_key(KeyCode::ShiftLeft);
|
||||
input.press_mouse(MouseButton::Right);
|
||||
input.end_frame();
|
||||
|
||||
input.release_all_held();
|
||||
|
||||
assert!(!input.held(KeyCode::KeyW));
|
||||
assert!(!input.held(KeyCode::ShiftLeft));
|
||||
assert!(!input.mouse_held(MouseButton::Right));
|
||||
assert!(input.released(KeyCode::KeyW));
|
||||
assert!(input.released(KeyCode::ShiftLeft));
|
||||
assert!(input.mouse_released(MouseButton::Right));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forget_cursor_resets_anchor_so_next_move_has_no_delta() {
|
||||
let mut input = InputState::new();
|
||||
input.set_cursor(Vec2::new(0.0, 0.0));
|
||||
input.set_cursor(Vec2::new(10.0, 10.0));
|
||||
input.end_frame();
|
||||
|
||||
input.forget_cursor();
|
||||
assert!(input.cursor().is_none());
|
||||
|
||||
// First move back in reseeds without contributing a delta.
|
||||
input.set_cursor(Vec2::new(200.0, 50.0));
|
||||
assert_eq!(input.mouse_delta(), Vec2::ZERO);
|
||||
assert_eq!(input.cursor(), Some(Vec2::new(200.0, 50.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_event_ignores_unrelated_window_events() {
|
||||
// These three WindowEvent variants don't carry a DeviceId, so they
|
||||
// can be constructed in tests — the routing through handle_event is
|
||||
// exercised end-to-end here.
|
||||
let mut input = InputState::new();
|
||||
input.press_key(KeyCode::Space);
|
||||
|
||||
input.handle_event(&WindowEvent::Resized(winit::dpi::PhysicalSize::new(
|
||||
800, 600,
|
||||
)));
|
||||
input.handle_event(&WindowEvent::CloseRequested);
|
||||
input.handle_event(&WindowEvent::RedrawRequested);
|
||||
|
||||
assert!(input.pressed(KeyCode::Space));
|
||||
assert!(input.held(KeyCode::Space));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_held_iterates_currently_held_keys() {
|
||||
let mut input = InputState::new();
|
||||
input.press_key(KeyCode::KeyW);
|
||||
input.press_key(KeyCode::KeyA);
|
||||
input.release_key(KeyCode::KeyA);
|
||||
|
||||
let held: HashSet<KeyCode> = input.keys_held().collect();
|
||||
assert_eq!(held, HashSet::from([KeyCode::KeyW]));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
//! Per-entity [`Layer`] membership and gameplay [`Tags`].
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::LayerMask;
|
||||
|
||||
/// Component: the **single** layer an entity is on.
|
||||
///
|
||||
/// Each entity belongs to exactly one of 32 logical layers (index `0..32`).
|
||||
/// Filters elsewhere — a camera's visibility mask, a physics collision filter,
|
||||
/// a raycast's layer filter — carry [`LayerMask`]s and select an entity by
|
||||
/// testing `mask.contains_layer(entity.layer.index)` (see [`Self::matches`]).
|
||||
///
|
||||
/// This matches the Unity model: **per-entity membership is single, filters
|
||||
/// are masks.** If you need an entity to be "in" multiple categories
|
||||
/// simultaneously, use [`Tags`] (gameplay tags) — tagging is the multi-valued
|
||||
/// concept; layers are the single-valued one.
|
||||
///
|
||||
/// Every freshly spawned entity is on [`DEFAULT`](Self::DEFAULT) (the layer
|
||||
/// named `"Default"` at index 0) unless changed, so it's visible to "see
|
||||
/// everything" filters out of the box.
|
||||
#[derive(
|
||||
Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, crate::reflect::Reflect,
|
||||
)]
|
||||
pub struct Layer {
|
||||
/// Layer index (`0..32`). Use the
|
||||
/// [`LayerRegistry`](super::LayerRegistry) to translate between this and a
|
||||
/// human-readable name.
|
||||
pub index: u32,
|
||||
}
|
||||
|
||||
impl Layer {
|
||||
/// The "Default" layer (index 0). Every freshly spawned entity starts here.
|
||||
pub const DEFAULT: Layer = Layer { index: 0 };
|
||||
|
||||
/// Builds a `Layer` on the given `index` (`0..32`).
|
||||
pub const fn on(index: u32) -> Self {
|
||||
Self { index }
|
||||
}
|
||||
|
||||
/// Whether this layer is selected by the given filter mask.
|
||||
pub const fn matches(self, filter: LayerMask) -> bool {
|
||||
filter.contains_layer(self.index)
|
||||
}
|
||||
|
||||
/// A [`LayerMask`] containing exactly this layer — useful when an API
|
||||
/// expects a mask (e.g. a one-layer camera visibility filter).
|
||||
pub const fn mask(self) -> LayerMask {
|
||||
LayerMask::layer(self.index)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Layer {
|
||||
fn default() -> Self {
|
||||
Layer::DEFAULT
|
||||
}
|
||||
}
|
||||
|
||||
/// Component: free-form gameplay tags on an entity.
|
||||
///
|
||||
/// Tags are the lightweight, string-keyed counterpart to [`Layer`]. Where a
|
||||
/// [`LayerMask`] is a fixed 32-slot bitset for hot-path *filtering*, tags are an
|
||||
/// open-ended set for *identification* — `"Enemy"`, `"Interactable"`,
|
||||
/// `"Checkpoint"` — that game code and scripts query by name. Stored sorted so
|
||||
/// serialization is deterministic.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Tags(BTreeSet<String>);
|
||||
|
||||
impl Tags {
|
||||
/// An empty tag set.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// A tag set containing the single tag `tag`.
|
||||
pub fn single(tag: impl Into<String>) -> Self {
|
||||
let mut set = BTreeSet::new();
|
||||
set.insert(tag.into());
|
||||
Tags(set)
|
||||
}
|
||||
|
||||
/// Adds `tag`. Returns `true` if it was not already present.
|
||||
pub fn insert(&mut self, tag: impl Into<String>) -> bool {
|
||||
self.0.insert(tag.into())
|
||||
}
|
||||
|
||||
/// Removes `tag`. Returns `true` if it was present.
|
||||
pub fn remove(&mut self, tag: &str) -> bool {
|
||||
self.0.remove(tag)
|
||||
}
|
||||
|
||||
/// Whether `tag` is present.
|
||||
pub fn contains(&self, tag: &str) -> bool {
|
||||
self.0.contains(tag)
|
||||
}
|
||||
|
||||
/// Iterates the tags in sorted order.
|
||||
pub fn iter(&self) -> impl Iterator<Item = &str> {
|
||||
self.0.iter().map(String::as_str)
|
||||
}
|
||||
|
||||
/// The number of tags.
|
||||
pub fn len(&self) -> usize {
|
||||
self.0.len()
|
||||
}
|
||||
|
||||
/// Whether there are no tags.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.0.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: Into<String>> FromIterator<S> for Tags {
|
||||
fn from_iter<I: IntoIterator<Item = S>>(iter: I) -> Self {
|
||||
Tags(iter.into_iter().map(Into::into).collect())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_layer_is_zero() {
|
||||
let l = Layer::default();
|
||||
assert_eq!(l.index, 0);
|
||||
// A "see everything" filter selects a default entity.
|
||||
assert!(l.matches(LayerMask::ALL));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_matches_filter_when_index_is_in_the_mask() {
|
||||
let on_npc = Layer::on(2);
|
||||
let npc_or_player = LayerMask::NONE.with(1).with(2);
|
||||
assert!(on_npc.matches(npc_or_player));
|
||||
assert!(!on_npc.matches(LayerMask::layer(5)));
|
||||
assert_eq!(on_npc.mask(), LayerMask::layer(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_round_trips_through_ron() {
|
||||
let l = Layer::on(7);
|
||||
let ron = ron::to_string(&l).unwrap();
|
||||
let back: Layer = ron::from_str(&ron).unwrap();
|
||||
assert_eq!(l, back);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tags_insert_remove_contains() {
|
||||
let mut tags = Tags::new();
|
||||
assert!(tags.insert("Enemy"));
|
||||
assert!(!tags.insert("Enemy")); // already present
|
||||
assert!(tags.insert("Flying"));
|
||||
assert!(tags.contains("Enemy"));
|
||||
assert_eq!(tags.len(), 2);
|
||||
assert!(tags.remove("Enemy"));
|
||||
assert!(!tags.contains("Enemy"));
|
||||
assert!(!tags.remove("Enemy"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tags_iterate_sorted_and_round_trip() {
|
||||
let tags: Tags = ["Zebra", "Apple", "Mango"].into_iter().collect();
|
||||
assert_eq!(
|
||||
tags.iter().collect::<Vec<_>>(),
|
||||
vec!["Apple", "Mango", "Zebra"]
|
||||
);
|
||||
let ron = ron::to_string(&tags).unwrap();
|
||||
let back: Tags = ron::from_str(&ron).unwrap();
|
||||
assert_eq!(tags, back);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
//! [`GroupRegistry`]: project-defined gameplay group names.
|
||||
//!
|
||||
//! Groups are the **multi-valued** counterpart to the single-valued
|
||||
//! [`Layer`](super::Layer). Where an entity is on exactly one layer (its
|
||||
//! render/physics filter slot), it can belong to *any number* of groups —
|
||||
//! `"Enemies"`, `"Interactables"`, `"SaveOnExit"` — which game code and scripts
|
||||
//! query by name. This mirrors the Unity model: one Layer + many tags/groups.
|
||||
//!
|
||||
//! Per-entity membership is stored in the [`Tags`](super::Tags) component. The
|
||||
//! registry is the project-level list of *which group names exist*, so the
|
||||
//! editor can offer a fixed set to pick from (predefined, not free-typed) and a
|
||||
//! team shares one vocabulary. Defining or deleting a group only changes that
|
||||
//! vocabulary; it never touches the tags already on entities (a deleted group
|
||||
//! simply becomes an "ungrouped" tag until removed).
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The set of project-defined group names.
|
||||
///
|
||||
/// Stored sorted (a [`BTreeSet`]) so the editor's dropdown order and serialized
|
||||
/// form are deterministic. Names are the identity used in data and UI, so they
|
||||
/// should be stable across a project's life.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub struct GroupRegistry {
|
||||
names: BTreeSet<String>,
|
||||
}
|
||||
|
||||
impl GroupRegistry {
|
||||
/// An empty registry — no groups defined yet.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Defines `name` as a group. Returns `true` if it was newly added.
|
||||
pub fn define(&mut self, name: impl Into<String>) -> bool {
|
||||
self.names.insert(name.into())
|
||||
}
|
||||
|
||||
/// Removes `name` from the defined groups. Returns `true` if it existed.
|
||||
///
|
||||
/// Entities already tagged with `name` keep the tag — only the project's
|
||||
/// list of valid groups shrinks.
|
||||
pub fn undefine(&mut self, name: &str) -> bool {
|
||||
self.names.remove(name)
|
||||
}
|
||||
|
||||
/// Whether `name` is a defined group.
|
||||
pub fn contains(&self, name: &str) -> bool {
|
||||
self.names.contains(name)
|
||||
}
|
||||
|
||||
/// Iterates the defined group names in sorted order.
|
||||
pub fn iter(&self) -> impl Iterator<Item = &str> {
|
||||
self.names.iter().map(String::as_str)
|
||||
}
|
||||
|
||||
/// The number of defined groups.
|
||||
pub fn len(&self) -> usize {
|
||||
self.names.len()
|
||||
}
|
||||
|
||||
/// Whether no groups are defined.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.names.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: Into<String>> FromIterator<S> for GroupRegistry {
|
||||
fn from_iter<I: IntoIterator<Item = S>>(iter: I) -> Self {
|
||||
GroupRegistry {
|
||||
names: iter.into_iter().map(Into::into).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn define_is_idempotent_and_reports_newness() {
|
||||
let mut reg = GroupRegistry::new();
|
||||
assert!(reg.is_empty());
|
||||
assert!(reg.define("Enemies"));
|
||||
assert!(!reg.define("Enemies")); // already defined
|
||||
assert!(reg.define("Pickups"));
|
||||
assert!(reg.contains("Enemies"));
|
||||
assert_eq!(reg.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undefine_removes_only_from_the_vocabulary() {
|
||||
let mut reg: GroupRegistry = ["Enemies", "Pickups"].into_iter().collect();
|
||||
assert!(reg.undefine("Enemies"));
|
||||
assert!(!reg.undefine("Enemies"));
|
||||
assert!(!reg.contains("Enemies"));
|
||||
assert!(reg.contains("Pickups"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iter_is_sorted() {
|
||||
let reg: GroupRegistry = ["Zed", "Alpha", "Mid"].into_iter().collect();
|
||||
assert_eq!(reg.iter().collect::<Vec<_>>(), vec!["Alpha", "Mid", "Zed"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_through_ron() {
|
||||
let reg: GroupRegistry = ["Enemies", "Interactables"].into_iter().collect();
|
||||
let ron = ron::to_string(®).unwrap();
|
||||
let back: GroupRegistry = ron::from_str(&ron).unwrap();
|
||||
assert_eq!(reg, back);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
//! [`LayerMask`]: a 32-slot bitset used to include/exclude entities.
|
||||
|
||||
use std::ops::{BitAnd, BitAndAssign, BitOr, BitOrAssign, BitXor, BitXorAssign, Not};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The number of distinct layers a [`LayerMask`] can represent.
|
||||
///
|
||||
/// Fixed at 32 so a mask is a single `u32` — cheap to copy, store on a
|
||||
/// component, and test in hot paths (physics filtering, render visibility,
|
||||
/// scene queries).
|
||||
pub const MAX_LAYERS: u32 = 32;
|
||||
|
||||
/// A set of layers, packed into the bits of a `u32`.
|
||||
///
|
||||
/// A `LayerMask` is the one shared primitive behind every "which layers does
|
||||
/// this interact with?" question in the engine. It plays two roles:
|
||||
///
|
||||
/// - **Membership** — the layers an entity *belongs to* (see
|
||||
/// [`Layer`](super::Layer)).
|
||||
/// - **Filter** — the layers a camera, query, or collision rule *cares about*.
|
||||
///
|
||||
/// Two masks interact when they share any layer: [`intersects`](Self::intersects)
|
||||
/// is the universal test (`(a & b) != 0`). Layer indices run `0..32`; passing an
|
||||
/// index `>= 32` panics (in every build), catching mistakes early rather than
|
||||
/// silently wrapping.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct LayerMask(u32);
|
||||
|
||||
impl LayerMask {
|
||||
/// The empty mask — interacts with nothing.
|
||||
pub const NONE: LayerMask = LayerMask(0);
|
||||
|
||||
/// Every layer set — interacts with everything.
|
||||
pub const ALL: LayerMask = LayerMask(u32::MAX);
|
||||
|
||||
/// A mask from a raw bit pattern.
|
||||
pub const fn from_bits(bits: u32) -> Self {
|
||||
LayerMask(bits)
|
||||
}
|
||||
|
||||
/// The raw bit pattern.
|
||||
pub const fn bits(self) -> u32 {
|
||||
self.0
|
||||
}
|
||||
|
||||
/// A mask containing only the single layer `index` (`0..32`).
|
||||
///
|
||||
/// # Panics
|
||||
/// Panics if `index >= 32`.
|
||||
pub const fn layer(index: u32) -> Self {
|
||||
assert!(
|
||||
index < MAX_LAYERS,
|
||||
"layer index out of range (must be 0..32)"
|
||||
);
|
||||
LayerMask(1u32 << index)
|
||||
}
|
||||
|
||||
/// This mask with layer `index` added.
|
||||
pub const fn with(self, index: u32) -> Self {
|
||||
LayerMask(self.0 | Self::layer(index).0)
|
||||
}
|
||||
|
||||
/// This mask with layer `index` removed.
|
||||
pub const fn without(self, index: u32) -> Self {
|
||||
LayerMask(self.0 & !Self::layer(index).0)
|
||||
}
|
||||
|
||||
/// This mask with layer `index` flipped.
|
||||
pub const fn toggled(self, index: u32) -> Self {
|
||||
LayerMask(self.0 ^ Self::layer(index).0)
|
||||
}
|
||||
|
||||
/// Whether layer `index` is present.
|
||||
///
|
||||
/// # Panics
|
||||
/// Panics if `index >= 32`.
|
||||
pub const fn contains_layer(self, index: u32) -> bool {
|
||||
self.0 & Self::layer(index).0 != 0
|
||||
}
|
||||
|
||||
/// Whether this mask and `other` share at least one layer.
|
||||
///
|
||||
/// This is the canonical interaction test — a body on the masks it belongs
|
||||
/// to "interacts with" a filter that selects any of those layers.
|
||||
pub const fn intersects(self, other: LayerMask) -> bool {
|
||||
self.0 & other.0 != 0
|
||||
}
|
||||
|
||||
/// Whether every layer in `other` is also in this mask.
|
||||
pub const fn contains(self, other: LayerMask) -> bool {
|
||||
self.0 & other.0 == other.0
|
||||
}
|
||||
|
||||
/// The union (bitwise OR) of two masks.
|
||||
pub const fn union(self, other: LayerMask) -> Self {
|
||||
LayerMask(self.0 | other.0)
|
||||
}
|
||||
|
||||
/// The intersection (bitwise AND) of two masks.
|
||||
pub const fn intersection(self, other: LayerMask) -> Self {
|
||||
LayerMask(self.0 & other.0)
|
||||
}
|
||||
|
||||
/// The layers in this mask that are not in `other`.
|
||||
pub const fn difference(self, other: LayerMask) -> Self {
|
||||
LayerMask(self.0 & !other.0)
|
||||
}
|
||||
|
||||
/// The complement — every layer not in this mask.
|
||||
pub const fn complement(self) -> Self {
|
||||
LayerMask(!self.0)
|
||||
}
|
||||
|
||||
/// Whether no layers are set.
|
||||
pub const fn is_empty(self) -> bool {
|
||||
self.0 == 0
|
||||
}
|
||||
|
||||
/// The number of layers set.
|
||||
pub const fn len(self) -> u32 {
|
||||
self.0.count_ones()
|
||||
}
|
||||
|
||||
/// Iterates the indices (`0..32`) of the set layers, ascending.
|
||||
pub fn iter(self) -> impl Iterator<Item = u32> {
|
||||
(0..MAX_LAYERS).filter(move |&i| self.0 & (1u32 << i) != 0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for LayerMask {
|
||||
/// The empty mask. Filters that should default to "see everything" must opt
|
||||
/// into [`LayerMask::ALL`] explicitly rather than rely on this.
|
||||
fn default() -> Self {
|
||||
LayerMask::NONE
|
||||
}
|
||||
}
|
||||
|
||||
impl FromIterator<u32> for LayerMask {
|
||||
/// Builds a mask from layer indices. Each index must be `0..32`.
|
||||
fn from_iter<I: IntoIterator<Item = u32>>(iter: I) -> Self {
|
||||
iter.into_iter().fold(LayerMask::NONE, LayerMask::with)
|
||||
}
|
||||
}
|
||||
|
||||
impl BitOr for LayerMask {
|
||||
type Output = LayerMask;
|
||||
fn bitor(self, rhs: LayerMask) -> LayerMask {
|
||||
self.union(rhs)
|
||||
}
|
||||
}
|
||||
|
||||
impl BitOrAssign for LayerMask {
|
||||
fn bitor_assign(&mut self, rhs: LayerMask) {
|
||||
self.0 |= rhs.0;
|
||||
}
|
||||
}
|
||||
|
||||
impl BitAnd for LayerMask {
|
||||
type Output = LayerMask;
|
||||
fn bitand(self, rhs: LayerMask) -> LayerMask {
|
||||
self.intersection(rhs)
|
||||
}
|
||||
}
|
||||
|
||||
impl BitAndAssign for LayerMask {
|
||||
fn bitand_assign(&mut self, rhs: LayerMask) {
|
||||
self.0 &= rhs.0;
|
||||
}
|
||||
}
|
||||
|
||||
impl BitXor for LayerMask {
|
||||
type Output = LayerMask;
|
||||
fn bitxor(self, rhs: LayerMask) -> LayerMask {
|
||||
LayerMask(self.0 ^ rhs.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl BitXorAssign for LayerMask {
|
||||
fn bitxor_assign(&mut self, rhs: LayerMask) {
|
||||
self.0 ^= rhs.0;
|
||||
}
|
||||
}
|
||||
|
||||
impl Not for LayerMask {
|
||||
type Output = LayerMask;
|
||||
fn not(self) -> LayerMask {
|
||||
self.complement()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn layer_sets_a_single_bit() {
|
||||
assert_eq!(LayerMask::layer(0).bits(), 0b1);
|
||||
assert_eq!(LayerMask::layer(3).bits(), 0b1000);
|
||||
assert_eq!(LayerMask::layer(31).bits(), 1 << 31);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic]
|
||||
fn layer_index_out_of_range_panics() {
|
||||
let _ = LayerMask::layer(32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builders_add_and_remove_layers() {
|
||||
let m = LayerMask::NONE.with(1).with(4);
|
||||
assert!(m.contains_layer(1));
|
||||
assert!(m.contains_layer(4));
|
||||
assert!(!m.contains_layer(0));
|
||||
assert_eq!(m.len(), 2);
|
||||
|
||||
let m = m.without(1);
|
||||
assert!(!m.contains_layer(1));
|
||||
assert!(m.contains_layer(4));
|
||||
|
||||
let m = m.toggled(4).toggled(7);
|
||||
assert!(!m.contains_layer(4));
|
||||
assert!(m.contains_layer(7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intersects_is_the_interaction_test() {
|
||||
let player = LayerMask::layer(1);
|
||||
let npc = LayerMask::layer(2);
|
||||
// A trigger that only fires for the player or npc layers.
|
||||
let trigger_filter = player.union(npc);
|
||||
assert!(player.intersects(trigger_filter));
|
||||
assert!(npc.intersects(trigger_filter));
|
||||
// A wall on layer 5 does not trip the trigger.
|
||||
assert!(!LayerMask::layer(5).intersects(trigger_filter));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_algebra() {
|
||||
let a = LayerMask::NONE.with(0).with(1).with(2);
|
||||
let b = LayerMask::NONE.with(1).with(2).with(3);
|
||||
assert_eq!(a.union(b), LayerMask::NONE.with(0).with(1).with(2).with(3));
|
||||
assert_eq!(a.intersection(b), LayerMask::NONE.with(1).with(2));
|
||||
assert_eq!(a.difference(b), LayerMask::layer(0));
|
||||
assert!(a.contains(LayerMask::NONE.with(0).with(1)));
|
||||
assert!(!a.contains(b));
|
||||
assert_eq!(LayerMask::ALL.complement(), LayerMask::NONE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bit_operators_match_named_methods() {
|
||||
let a = LayerMask::layer(1);
|
||||
let b = LayerMask::layer(2);
|
||||
assert_eq!(a | b, a.union(b));
|
||||
assert_eq!((a | b) & a, a);
|
||||
assert_eq!(a ^ a, LayerMask::NONE);
|
||||
assert_eq!(!LayerMask::NONE, LayerMask::ALL);
|
||||
|
||||
let mut m = LayerMask::NONE;
|
||||
m |= a;
|
||||
m |= b;
|
||||
assert!(m.intersects(a) && m.intersects(b));
|
||||
m &= a;
|
||||
assert_eq!(m, a);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iter_yields_ascending_indices() {
|
||||
let m = LayerMask::NONE.with(0).with(5).with(31);
|
||||
assert_eq!(m.iter().collect::<Vec<_>>(), vec![0, 5, 31]);
|
||||
assert!(LayerMask::NONE.iter().next().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_iter_collects_indices() {
|
||||
let m: LayerMask = [1u32, 3, 5].into_iter().collect();
|
||||
assert_eq!(m, LayerMask::NONE.with(1).with(3).with(5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_through_ron() {
|
||||
let m = LayerMask::NONE.with(2).with(9).with(30);
|
||||
let ron = ron::to_string(&m).unwrap();
|
||||
let back: LayerMask = ron::from_str(&ron).unwrap();
|
||||
assert_eq!(m, back);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
//! Layer & tags — the engine's filtering primitives.
|
||||
//!
|
||||
//! Stage 5 introduces one shared way to answer "which things interact with
|
||||
//! which?", so physics, rendering, and scene queries all speak the same
|
||||
//! language instead of each inventing its own:
|
||||
//!
|
||||
//! - [`LayerMask`] — a 32-slot bitset. The single primitive used both for an
|
||||
//! entity's **membership** and for the **filters** that select entities.
|
||||
//! Two masks interact when they share any layer ([`LayerMask::intersects`]).
|
||||
//! - [`LayerRegistry`] — project-level human-readable names for the 32 layers
|
||||
//! (e.g. layer 1 = `"Player"`), so masks can be authored and displayed by
|
||||
//! name. Layer 0 is `"Default"`.
|
||||
//! - [`Layer`] — the per-entity component holding its membership mask. Defaults
|
||||
//! to the `Default` layer so new entities are visible to broad filters.
|
||||
//! - [`Tags`] — a per-entity set of free-form string tags for gameplay
|
||||
//! *identification* (`"Enemy"`, `"Interactable"`), distinct from the
|
||||
//! hot-path [`LayerMask`]. This is the **multi-valued** membership concept
|
||||
//! (an entity is in many groups) paired with the single-valued [`Layer`].
|
||||
//! - [`GroupRegistry`] — project-level list of defined group names, so the
|
||||
//! editor offers a fixed vocabulary to tag entities with (predefined, like
|
||||
//! layers) rather than free-typed strings.
|
||||
//!
|
||||
//! How consumers use it (built out in later stages):
|
||||
//! - **Physics** (Stage 9): a collider's membership + filter masks drive
|
||||
//! collision groups and sensor/trigger filtering.
|
||||
//! - **Rendering** (Stage 5 pipeline): a camera holds a visibility filter; only
|
||||
//! entities whose [`Layer`] intersect it are drawn.
|
||||
//! - **Scene queries**: a raycast carries a filter mask tested against
|
||||
//! candidate entities' membership.
|
||||
//!
|
||||
//! All four types are serializable, so layer data is dual-editable (editor +
|
||||
//! scripts/AI) like every other engine component.
|
||||
|
||||
mod components;
|
||||
mod groups;
|
||||
mod mask;
|
||||
mod registry;
|
||||
|
||||
pub use components::{Layer, Tags};
|
||||
pub use groups::GroupRegistry;
|
||||
pub use mask::{LayerMask, MAX_LAYERS};
|
||||
pub use registry::LayerRegistry;
|
||||
@@ -0,0 +1,163 @@
|
||||
//! [`LayerRegistry`]: human-readable names for the 32 layers.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{LayerMask, MAX_LAYERS};
|
||||
|
||||
/// Maps layer indices (`0..32`) to project-defined names.
|
||||
///
|
||||
/// A [`LayerMask`] is just bits; the registry is what lets a project, the
|
||||
/// editor, and scripts talk about layer **3** as `"Enemy"` instead of a magic
|
||||
/// number. It is project-level data (serialized with the project, later stages)
|
||||
/// and changing a name never moves an entity between layers — only the label
|
||||
/// changes.
|
||||
///
|
||||
/// Index `0` is seeded with the name `"Default"`, the layer every entity starts
|
||||
/// on (see [`Layer`](super::Layer)). The remaining slots are unnamed until a
|
||||
/// project assigns them.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LayerRegistry {
|
||||
/// One slot per layer; `None` means the layer has no assigned name.
|
||||
names: Vec<Option<String>>,
|
||||
}
|
||||
|
||||
impl LayerRegistry {
|
||||
/// A registry with only layer 0 named (`"Default"`).
|
||||
pub fn new() -> Self {
|
||||
let mut names = vec![None; MAX_LAYERS as usize];
|
||||
names[0] = Some("Default".to_string());
|
||||
Self { names }
|
||||
}
|
||||
|
||||
/// Assigns `name` to layer `index`.
|
||||
///
|
||||
/// # Panics
|
||||
/// Panics if `index >= 32`.
|
||||
pub fn set(&mut self, index: u32, name: impl Into<String>) {
|
||||
assert!(
|
||||
index < MAX_LAYERS,
|
||||
"layer index out of range (must be 0..32)"
|
||||
);
|
||||
self.names[index as usize] = Some(name.into());
|
||||
}
|
||||
|
||||
/// Clears the name of layer `index`, leaving it unnamed.
|
||||
///
|
||||
/// # Panics
|
||||
/// Panics if `index >= 32`.
|
||||
pub fn clear(&mut self, index: u32) {
|
||||
assert!(
|
||||
index < MAX_LAYERS,
|
||||
"layer index out of range (must be 0..32)"
|
||||
);
|
||||
self.names[index as usize] = None;
|
||||
}
|
||||
|
||||
/// The name of layer `index`, or `None` if it is out of range or unnamed.
|
||||
pub fn name(&self, index: u32) -> Option<&str> {
|
||||
self.names.get(index as usize).and_then(|n| n.as_deref())
|
||||
}
|
||||
|
||||
/// The index of the layer named `name`, or `None` if no layer has it.
|
||||
///
|
||||
/// Names are not required to be unique; the lowest matching index wins.
|
||||
pub fn index_of(&self, name: &str) -> Option<u32> {
|
||||
self.names
|
||||
.iter()
|
||||
.position(|n| n.as_deref() == Some(name))
|
||||
.map(|i| i as u32)
|
||||
}
|
||||
|
||||
/// A [`LayerMask`] built from layer names, skipping any that are unknown.
|
||||
///
|
||||
/// Convenient for authoring filters by name, e.g.
|
||||
/// `registry.mask_of(["Player", "NPC"])`.
|
||||
pub fn mask_of<'a, I>(&self, names: I) -> LayerMask
|
||||
where
|
||||
I: IntoIterator<Item = &'a str>,
|
||||
{
|
||||
names.into_iter().filter_map(|n| self.index_of(n)).collect()
|
||||
}
|
||||
|
||||
/// Iterates `(index, name)` for every *named* layer, ascending by index.
|
||||
pub fn iter(&self) -> impl Iterator<Item = (u32, &str)> {
|
||||
self.names
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, n)| n.as_deref().map(|name| (i as u32, name)))
|
||||
}
|
||||
|
||||
/// The number of named layers.
|
||||
pub fn named_count(&self) -> usize {
|
||||
self.names.iter().filter(|n| n.is_some()).count()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for LayerRegistry {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_names_only_layer_zero() {
|
||||
let reg = LayerRegistry::new();
|
||||
assert_eq!(reg.name(0), Some("Default"));
|
||||
assert_eq!(reg.name(1), None);
|
||||
assert_eq!(reg.named_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_and_look_up_by_name() {
|
||||
let mut reg = LayerRegistry::new();
|
||||
reg.set(1, "Player");
|
||||
reg.set(2, "NPC");
|
||||
reg.set(5, "Water");
|
||||
assert_eq!(reg.name(2), Some("NPC"));
|
||||
assert_eq!(reg.index_of("Water"), Some(5));
|
||||
assert_eq!(reg.index_of("Missing"), None);
|
||||
assert_eq!(reg.named_count(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_of_names_builds_a_filter() {
|
||||
let mut reg = LayerRegistry::new();
|
||||
reg.set(1, "Player");
|
||||
reg.set(2, "NPC");
|
||||
let mask = reg.mask_of(["Player", "NPC", "Unknown"]);
|
||||
assert_eq!(mask, LayerMask::NONE.with(1).with(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_unsets_a_name() {
|
||||
let mut reg = LayerRegistry::new();
|
||||
reg.set(3, "Trigger");
|
||||
assert_eq!(reg.index_of("Trigger"), Some(3));
|
||||
reg.clear(3);
|
||||
assert_eq!(reg.name(3), None);
|
||||
assert_eq!(reg.index_of("Trigger"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iter_visits_named_layers_in_order() {
|
||||
let mut reg = LayerRegistry::new();
|
||||
reg.set(4, "B");
|
||||
reg.set(2, "A");
|
||||
let pairs: Vec<_> = reg.iter().collect();
|
||||
assert_eq!(pairs, vec![(0, "Default"), (2, "A"), (4, "B")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_through_ron() {
|
||||
let mut reg = LayerRegistry::new();
|
||||
reg.set(1, "Player");
|
||||
reg.set(7, "Foliage");
|
||||
let ron = ron::to_string(®).unwrap();
|
||||
let back: LayerRegistry = ron::from_str(&ron).unwrap();
|
||||
assert_eq!(reg, back);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
//! Oxide Engine — core library.
|
||||
//!
|
||||
//! Each system lives in its own module and is independently usable.
|
||||
//! Systems are enabled progressively as stages are completed.
|
||||
|
||||
#![deny(warnings)]
|
||||
|
||||
// Lets `#[derive(Reflect)]` emit `::oxide_engine::reflect::…` paths that
|
||||
// resolve even when the derive is used *inside* this crate (e.g. on the
|
||||
// engine's own component types). Standard proc-macro self-reference trick.
|
||||
extern crate self as oxide_engine;
|
||||
|
||||
pub mod app;
|
||||
pub mod asset;
|
||||
pub mod input;
|
||||
pub mod layer;
|
||||
pub mod math;
|
||||
pub mod prefab;
|
||||
pub mod project;
|
||||
pub mod reflect;
|
||||
pub mod render;
|
||||
pub mod scene;
|
||||
pub mod settings;
|
||||
pub mod ui;
|
||||
pub mod watch;
|
||||
pub mod window;
|
||||
|
||||
// Re-exported so engine consumers can use GPU/windowing/ECS types without
|
||||
// declaring (and version-matching) their own direct dependency.
|
||||
pub use hecs;
|
||||
pub use wgpu;
|
||||
pub use winit;
|
||||
|
||||
pub mod prelude {
|
||||
//! Common imports for engine consumers.
|
||||
//!
|
||||
//! The core engine container ([`App`](crate::app::App)) and the windowing
|
||||
//! event-handler trait ([`WindowApp`](crate::window::WindowApp)) both live
|
||||
//! here — they cover different roles and no longer share a name (Stage 6
|
||||
//! resolved the Stage-5 naming clash).
|
||||
pub use crate::app::{App, DefaultModules, Module, Schedule};
|
||||
pub use crate::asset::{
|
||||
load_gltf, AssetDatabase, AssetKind, AssetRef, AssetServer, AssetUid, GltfModel, Handle,
|
||||
};
|
||||
pub use crate::input::{
|
||||
ActionMap, ActionOverrides, Axis2DBinding, AxisBinding, Binding, InputState,
|
||||
};
|
||||
pub use crate::layer::{GroupRegistry, Layer, LayerMask, LayerRegistry, Tags};
|
||||
pub use crate::math::{
|
||||
Aabb, Color, EulerRot, Frustum, Mat3, Mat4, Plane, Quat, Range3, Ray, Rect, Transform,
|
||||
Vec2, Vec3, Vec4,
|
||||
};
|
||||
pub use crate::prefab::{ComponentSpec, Prefab, PrefabRegistry};
|
||||
pub use crate::project::{Project, RecentProjects};
|
||||
pub use crate::reflect::TypeRegistry;
|
||||
pub use crate::render::{
|
||||
Camera, ClearPass, DirectionalLight, ForwardPass, ForwardRenderer, FrameContext, Gpu,
|
||||
GpuMesh, Lighting, Material, Mesh, MeshRenderer, PrimitiveShape, RenderContext,
|
||||
RenderObject, RenderPass, RenderPipeline, UiBatch, UiOverlayPass, Vertex,
|
||||
};
|
||||
pub use crate::scene::{DespawnPolicy, Entity, Node, Scene, SceneError, SceneSnapshot};
|
||||
pub use crate::settings::Settings;
|
||||
pub use crate::ui::hit_test as ui_hit_test;
|
||||
pub use crate::ui::{
|
||||
layout as ui_layout, paint as ui_paint, shape as ui_shape, shape_runs as ui_shape_runs,
|
||||
Align as UiAlign, Anchor as UiAnchor, AnchorGroup as UiAnchorGroup,
|
||||
AtlasEntry as UiAtlasEntry, Border as UiBorder, DrawCommand as UiDrawCommand,
|
||||
Font as UiFont, FontId as UiFontId, FontRef as UiFontRef, FontStore as UiFontStore,
|
||||
FontWeight as UiFontWeight, GlyphAtlas as UiGlyphAtlas, GlyphId as UiGlyphId,
|
||||
GlyphKey as UiGlyphKey, Grid as UiGrid, Insets as UiInsets, LayoutNode as UiLayoutNode,
|
||||
LayoutStyle as UiLayoutStyle, LayoutTree as UiLayoutTree, PaintedFrame as UiPaintedFrame,
|
||||
Router as UiRouter, RouterEvent as UiRouterEvent, RouterFrame as UiRouterFrame,
|
||||
ShapeParams as UiShapeParams, ShapedGlyph as UiShapedGlyph, ShapedLine as UiShapedLine,
|
||||
ShapedText as UiShapedText, Sizing as UiSizing, Stack as UiStack,
|
||||
StackDirection as UiStackDirection, TextAlign as UiTextAlign, TextRun as UiTextRun,
|
||||
TextStyle as UiTextStyle, Theme as UiTheme, UiPanel, VisualStyle as UiVisualStyle, Widget,
|
||||
WidgetId, WidgetKind, WidgetPath, WidgetValue,
|
||||
};
|
||||
pub use crate::watch::{reload_changed_assets, ChangeEvent, ChangeKind, FileWatcher};
|
||||
pub use crate::window::{run, AppCtx, WindowApp, WindowConfig};
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
//! Axis-aligned bounding box ([`Aabb`]).
|
||||
//!
|
||||
//! Stored as `min`/`max` corners. An AABB with any `min` component greater than
|
||||
//! the corresponding `max` is considered *empty* (contains no points), which is
|
||||
//! the natural identity for union operations.
|
||||
|
||||
use crate::math::Ray;
|
||||
use glam::Vec3;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// An axis-aligned bounding box defined by its minimum and maximum corners.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Aabb {
|
||||
/// Minimum corner (smallest x, y, z).
|
||||
pub min: Vec3,
|
||||
/// Maximum corner (largest x, y, z).
|
||||
pub max: Vec3,
|
||||
}
|
||||
|
||||
impl Aabb {
|
||||
/// An empty box: `min` is `+inf`, `max` is `-inf`. Unioning any point with
|
||||
/// this yields a box tightly bounding that point.
|
||||
pub const EMPTY: Self = Self {
|
||||
min: Vec3::splat(f32::INFINITY),
|
||||
max: Vec3::splat(f32::NEG_INFINITY),
|
||||
};
|
||||
|
||||
/// Creates an AABB from two corners, sorting components so `min <= max`.
|
||||
#[inline]
|
||||
pub fn new(a: Vec3, b: Vec3) -> Self {
|
||||
Self {
|
||||
min: a.min(b),
|
||||
max: a.max(b),
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates an AABB from a center point and half-extents.
|
||||
#[inline]
|
||||
pub fn from_center_half_extents(center: Vec3, half_extents: Vec3) -> Self {
|
||||
Self {
|
||||
min: center - half_extents,
|
||||
max: center + half_extents,
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the tightest AABB containing all `points`. Returns [`Aabb::EMPTY`]
|
||||
/// if the iterator is empty.
|
||||
pub fn from_points(points: impl IntoIterator<Item = Vec3>) -> Self {
|
||||
let mut bb = Self::EMPTY;
|
||||
for p in points {
|
||||
bb.expand_to_include(p);
|
||||
}
|
||||
bb
|
||||
}
|
||||
|
||||
/// Returns `true` if this box contains no points (any axis inverted).
|
||||
#[inline]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.min.x > self.max.x || self.min.y > self.max.y || self.min.z > self.max.z
|
||||
}
|
||||
|
||||
/// The center point of the box. Meaningless for an empty box.
|
||||
#[inline]
|
||||
pub fn center(&self) -> Vec3 {
|
||||
(self.min + self.max) * 0.5
|
||||
}
|
||||
|
||||
/// The full size (max - min) along each axis.
|
||||
#[inline]
|
||||
pub fn size(&self) -> Vec3 {
|
||||
(self.max - self.min).max(Vec3::ZERO)
|
||||
}
|
||||
|
||||
/// Half of [`Aabb::size`].
|
||||
#[inline]
|
||||
pub fn half_extents(&self) -> Vec3 {
|
||||
self.size() * 0.5
|
||||
}
|
||||
|
||||
/// The surface area of the box (used by spatial acceleration heuristics).
|
||||
#[inline]
|
||||
pub fn surface_area(&self) -> f32 {
|
||||
let s = self.size();
|
||||
2.0 * (s.x * s.y + s.y * s.z + s.z * s.x)
|
||||
}
|
||||
|
||||
/// The volume of the box.
|
||||
#[inline]
|
||||
pub fn volume(&self) -> f32 {
|
||||
let s = self.size();
|
||||
s.x * s.y * s.z
|
||||
}
|
||||
|
||||
/// Grows the box (in place) to include `point`.
|
||||
#[inline]
|
||||
pub fn expand_to_include(&mut self, point: Vec3) {
|
||||
self.min = self.min.min(point);
|
||||
self.max = self.max.max(point);
|
||||
}
|
||||
|
||||
/// Returns the union of this box and `other` (smallest box containing both).
|
||||
#[inline]
|
||||
pub fn union(&self, other: &Aabb) -> Aabb {
|
||||
Aabb {
|
||||
min: self.min.min(other.min),
|
||||
max: self.max.max(other.max),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the intersection of two boxes, or [`Aabb::EMPTY`] if disjoint.
|
||||
#[inline]
|
||||
pub fn intersection(&self, other: &Aabb) -> Aabb {
|
||||
let min = self.min.max(other.min);
|
||||
let max = self.max.min(other.max);
|
||||
if min.x > max.x || min.y > max.y || min.z > max.z {
|
||||
Aabb::EMPTY
|
||||
} else {
|
||||
Aabb { min, max }
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if `point` is inside or on the boundary of the box.
|
||||
#[inline]
|
||||
pub fn contains_point(&self, point: Vec3) -> bool {
|
||||
point.cmpge(self.min).all() && point.cmple(self.max).all()
|
||||
}
|
||||
|
||||
/// Returns `true` if the two boxes overlap (touching counts as overlap).
|
||||
#[inline]
|
||||
pub fn intersects(&self, other: &Aabb) -> bool {
|
||||
self.min.cmple(other.max).all() && self.max.cmpge(other.min).all()
|
||||
}
|
||||
|
||||
/// Returns the point on or inside the box closest to `point`.
|
||||
#[inline]
|
||||
pub fn closest_point(&self, point: Vec3) -> Vec3 {
|
||||
point.clamp(self.min, self.max)
|
||||
}
|
||||
|
||||
/// The eight corner vertices of the box.
|
||||
pub fn corners(&self) -> [Vec3; 8] {
|
||||
let (lo, hi) = (self.min, self.max);
|
||||
[
|
||||
Vec3::new(lo.x, lo.y, lo.z),
|
||||
Vec3::new(hi.x, lo.y, lo.z),
|
||||
Vec3::new(lo.x, hi.y, lo.z),
|
||||
Vec3::new(hi.x, hi.y, lo.z),
|
||||
Vec3::new(lo.x, lo.y, hi.z),
|
||||
Vec3::new(hi.x, lo.y, hi.z),
|
||||
Vec3::new(lo.x, hi.y, hi.z),
|
||||
Vec3::new(hi.x, hi.y, hi.z),
|
||||
]
|
||||
}
|
||||
|
||||
/// Slab-method ray/box intersection. Returns the entry distance `t` along
|
||||
/// the ray if it hits (including when the origin is inside, where `t` is the
|
||||
/// clamped near distance), otherwise `None`.
|
||||
pub fn ray_intersection(&self, ray: &Ray) -> Option<f32> {
|
||||
let inv_dir = Vec3::ONE / ray.direction;
|
||||
let t0 = (self.min - ray.origin) * inv_dir;
|
||||
let t1 = (self.max - ray.origin) * inv_dir;
|
||||
let t_near = t0.min(t1);
|
||||
let t_far = t0.max(t1);
|
||||
let t_enter = t_near.max_element();
|
||||
let t_exit = t_far.min_element();
|
||||
if t_enter <= t_exit && t_exit >= 0.0 {
|
||||
Some(t_enter.max(0.0))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn empty_box_contains_nothing() {
|
||||
assert!(Aabb::EMPTY.is_empty());
|
||||
assert!(!Aabb::EMPTY.contains_point(Vec3::ZERO));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_sorts_corners() {
|
||||
let bb = Aabb::new(Vec3::new(1.0, 5.0, -2.0), Vec3::new(-1.0, 0.0, 3.0));
|
||||
assert_eq!(bb.min, Vec3::new(-1.0, 0.0, -2.0));
|
||||
assert_eq!(bb.max, Vec3::new(1.0, 5.0, 3.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn center_size_extents() {
|
||||
let bb = Aabb::from_center_half_extents(Vec3::new(1.0, 2.0, 3.0), Vec3::splat(2.0));
|
||||
assert_eq!(bb.center(), Vec3::new(1.0, 2.0, 3.0));
|
||||
assert_eq!(bb.size(), Vec3::splat(4.0));
|
||||
assert_eq!(bb.half_extents(), Vec3::splat(2.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_points_bounds_all() {
|
||||
let bb = Aabb::from_points([
|
||||
Vec3::new(0.0, 0.0, 0.0),
|
||||
Vec3::new(2.0, -1.0, 4.0),
|
||||
Vec3::new(-3.0, 5.0, 1.0),
|
||||
]);
|
||||
assert_eq!(bb.min, Vec3::new(-3.0, -1.0, 0.0));
|
||||
assert_eq!(bb.max, Vec3::new(2.0, 5.0, 4.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_points_empty_is_empty() {
|
||||
assert!(Aabb::from_points([]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contains_and_closest() {
|
||||
let bb = Aabb::new(Vec3::ZERO, Vec3::splat(2.0));
|
||||
assert!(bb.contains_point(Vec3::ONE));
|
||||
assert!(bb.contains_point(Vec3::ZERO)); // boundary
|
||||
assert!(!bb.contains_point(Vec3::new(3.0, 1.0, 1.0)));
|
||||
assert_eq!(
|
||||
bb.closest_point(Vec3::new(5.0, -1.0, 1.0)),
|
||||
Vec3::new(2.0, 0.0, 1.0)
|
||||
);
|
||||
assert_eq!(bb.closest_point(Vec3::ONE), Vec3::ONE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn union_and_intersection() {
|
||||
let a = Aabb::new(Vec3::ZERO, Vec3::splat(2.0));
|
||||
let b = Aabb::new(Vec3::ONE, Vec3::splat(3.0));
|
||||
assert_eq!(a.union(&b), Aabb::new(Vec3::ZERO, Vec3::splat(3.0)));
|
||||
assert_eq!(a.intersection(&b), Aabb::new(Vec3::ONE, Vec3::splat(2.0)));
|
||||
|
||||
let c = Aabb::new(Vec3::splat(5.0), Vec3::splat(6.0));
|
||||
assert!(a.intersection(&c).is_empty());
|
||||
assert!(!a.intersects(&c));
|
||||
assert!(a.intersects(&b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn surface_area_and_volume() {
|
||||
let bb = Aabb::new(Vec3::ZERO, Vec3::new(1.0, 2.0, 3.0));
|
||||
assert_eq!(bb.volume(), 6.0);
|
||||
assert_eq!(bb.surface_area(), 2.0 * (2.0 + 6.0 + 3.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corners_count_and_span() {
|
||||
let bb = Aabb::new(Vec3::ZERO, Vec3::ONE);
|
||||
let corners = bb.corners();
|
||||
assert_eq!(corners.len(), 8);
|
||||
assert!(corners.contains(&Vec3::ZERO));
|
||||
assert!(corners.contains(&Vec3::ONE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ray_hits_from_outside() {
|
||||
let bb = Aabb::new(Vec3::splat(-1.0), Vec3::splat(1.0));
|
||||
let ray = Ray::new(Vec3::new(-5.0, 0.0, 0.0), Vec3::X);
|
||||
let t = bb.ray_intersection(&ray).expect("should hit");
|
||||
assert!((t - 4.0).abs() <= 1e-4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ray_from_inside_returns_zero() {
|
||||
let bb = Aabb::new(Vec3::splat(-1.0), Vec3::splat(1.0));
|
||||
let ray = Ray::new(Vec3::ZERO, Vec3::X);
|
||||
assert_eq!(bb.ray_intersection(&ray), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ray_misses() {
|
||||
let bb = Aabb::new(Vec3::splat(-1.0), Vec3::splat(1.0));
|
||||
let ray = Ray::new(Vec3::new(-5.0, 5.0, 0.0), Vec3::X);
|
||||
assert_eq!(bb.ray_intersection(&ray), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ray_pointing_away_misses() {
|
||||
let bb = Aabb::new(Vec3::splat(-1.0), Vec3::splat(1.0));
|
||||
let ray = Ray::new(Vec3::new(-5.0, 0.0, 0.0), Vec3::NEG_X);
|
||||
assert_eq!(bb.ray_intersection(&ray), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
//! Linear RGBA [`Color`].
|
||||
//!
|
||||
//! Colors are stored as `f32` components in **linear** space (the space shaders
|
||||
//! and lighting math expect). Helpers convert to/from 8-bit sRGB for I/O.
|
||||
|
||||
use glam::{Vec3, Vec4};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// An RGBA color with linear `f32` components, nominally in `[0, 1]` but not
|
||||
/// clamped (values above 1.0 represent HDR / emissive intensity).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Color {
|
||||
/// Red channel (linear).
|
||||
pub r: f32,
|
||||
/// Green channel (linear).
|
||||
pub g: f32,
|
||||
/// Blue channel (linear).
|
||||
pub b: f32,
|
||||
/// Alpha (opacity); `1.0` is fully opaque.
|
||||
pub a: f32,
|
||||
}
|
||||
|
||||
impl Color {
|
||||
/// Opaque black.
|
||||
pub const BLACK: Self = Self::rgb(0.0, 0.0, 0.0);
|
||||
/// Opaque white.
|
||||
pub const WHITE: Self = Self::rgb(1.0, 1.0, 1.0);
|
||||
/// Opaque red.
|
||||
pub const RED: Self = Self::rgb(1.0, 0.0, 0.0);
|
||||
/// Opaque green.
|
||||
pub const GREEN: Self = Self::rgb(0.0, 1.0, 0.0);
|
||||
/// Opaque blue.
|
||||
pub const BLUE: Self = Self::rgb(0.0, 0.0, 1.0);
|
||||
/// Fully transparent (all channels zero).
|
||||
pub const TRANSPARENT: Self = Self::rgba(0.0, 0.0, 0.0, 0.0);
|
||||
|
||||
/// Creates a color from linear RGBA components.
|
||||
#[inline]
|
||||
pub const fn rgba(r: f32, g: f32, b: f32, a: f32) -> Self {
|
||||
Self { r, g, b, a }
|
||||
}
|
||||
|
||||
/// Creates an opaque color from linear RGB components.
|
||||
#[inline]
|
||||
pub const fn rgb(r: f32, g: f32, b: f32) -> Self {
|
||||
Self { r, g, b, a: 1.0 }
|
||||
}
|
||||
|
||||
/// Creates a linear color from 8-bit **sRGB** components (the usual format
|
||||
/// of color pickers and image files), with full opacity.
|
||||
#[inline]
|
||||
pub fn from_srgb_u8(r: u8, g: u8, b: u8) -> Self {
|
||||
Self::rgb(
|
||||
srgb_to_linear(r as f32 / 255.0),
|
||||
srgb_to_linear(g as f32 / 255.0),
|
||||
srgb_to_linear(b as f32 / 255.0),
|
||||
)
|
||||
}
|
||||
|
||||
/// Creates a linear color from a packed `0xRRGGBB` hex value.
|
||||
#[inline]
|
||||
pub fn from_hex(hex: u32) -> Self {
|
||||
Self::from_srgb_u8(
|
||||
((hex >> 16) & 0xFF) as u8,
|
||||
((hex >> 8) & 0xFF) as u8,
|
||||
(hex & 0xFF) as u8,
|
||||
)
|
||||
}
|
||||
|
||||
/// Converts to 8-bit sRGB `(r, g, b, a)`, clamping to `[0, 1]` first.
|
||||
#[inline]
|
||||
pub fn to_srgb_u8(&self) -> [u8; 4] {
|
||||
[
|
||||
(linear_to_srgb(self.r.clamp(0.0, 1.0)) * 255.0).round() as u8,
|
||||
(linear_to_srgb(self.g.clamp(0.0, 1.0)) * 255.0).round() as u8,
|
||||
(linear_to_srgb(self.b.clamp(0.0, 1.0)) * 255.0).round() as u8,
|
||||
(self.a.clamp(0.0, 1.0) * 255.0).round() as u8,
|
||||
]
|
||||
}
|
||||
|
||||
/// Returns the color as a `Vec4` (`[r, g, b, a]`).
|
||||
#[inline]
|
||||
pub fn to_vec4(&self) -> Vec4 {
|
||||
Vec4::new(self.r, self.g, self.b, self.a)
|
||||
}
|
||||
|
||||
/// Returns the RGB channels as a `Vec3`.
|
||||
#[inline]
|
||||
pub fn to_vec3(&self) -> Vec3 {
|
||||
Vec3::new(self.r, self.g, self.b)
|
||||
}
|
||||
|
||||
/// Returns a copy with the alpha replaced.
|
||||
#[inline]
|
||||
pub fn with_alpha(&self, a: f32) -> Self {
|
||||
Self { a, ..*self }
|
||||
}
|
||||
|
||||
/// Linearly interpolates between two colors. `t` is clamped to `[0, 1]`.
|
||||
#[inline]
|
||||
pub fn lerp(&self, other: Color, t: f32) -> Color {
|
||||
let t = t.clamp(0.0, 1.0);
|
||||
Color {
|
||||
r: self.r + (other.r - self.r) * t,
|
||||
g: self.g + (other.g - self.g) * t,
|
||||
b: self.b + (other.b - self.b) * t,
|
||||
a: self.a + (other.a - self.a) * t,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts a single sRGB channel value in `[0, 1]` to linear space.
|
||||
#[inline]
|
||||
fn srgb_to_linear(c: f32) -> f32 {
|
||||
if c <= 0.04045 {
|
||||
c / 12.92
|
||||
} else {
|
||||
((c + 0.055) / 1.055).powf(2.4)
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts a single linear channel value in `[0, 1]` to sRGB space.
|
||||
#[inline]
|
||||
fn linear_to_srgb(c: f32) -> f32 {
|
||||
if c <= 0.003_130_8 {
|
||||
c * 12.92
|
||||
} else {
|
||||
1.055 * c.powf(1.0 / 2.4) - 0.055
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const EPS: f32 = 1e-4;
|
||||
|
||||
#[test]
|
||||
fn constants() {
|
||||
assert_eq!(Color::WHITE, Color::rgb(1.0, 1.0, 1.0));
|
||||
assert_eq!(Color::TRANSPARENT.a, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn srgb_round_trip() {
|
||||
let original = [10u8, 128, 240];
|
||||
let c = Color::from_srgb_u8(original[0], original[1], original[2]);
|
||||
let back = c.to_srgb_u8();
|
||||
assert_eq!([back[0], back[1], back[2]], original);
|
||||
assert_eq!(back[3], 255);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn srgb_endpoints_are_exact() {
|
||||
assert!(srgb_to_linear(0.0).abs() <= EPS);
|
||||
assert!((srgb_to_linear(1.0) - 1.0).abs() <= EPS);
|
||||
assert!((linear_to_srgb(1.0) - 1.0).abs() <= EPS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hex_parsing() {
|
||||
let c = Color::from_hex(0xFF0000);
|
||||
assert_eq!(c.to_srgb_u8()[0], 255);
|
||||
assert_eq!(c.to_srgb_u8()[1], 0);
|
||||
assert_eq!(c.to_srgb_u8()[2], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lerp_endpoints_and_midpoint() {
|
||||
let a = Color::rgba(0.0, 0.0, 0.0, 0.0);
|
||||
let b = Color::rgba(1.0, 1.0, 1.0, 1.0);
|
||||
assert_eq!(a.lerp(b, 0.0), a);
|
||||
assert_eq!(a.lerp(b, 1.0), b);
|
||||
assert_eq!(a.lerp(b, 0.5), Color::rgba(0.5, 0.5, 0.5, 0.5));
|
||||
// Clamps out-of-range t.
|
||||
assert_eq!(a.lerp(b, 2.0), b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vec_conversions_and_alpha() {
|
||||
let c = Color::rgba(0.1, 0.2, 0.3, 0.4);
|
||||
assert_eq!(c.to_vec4(), Vec4::new(0.1, 0.2, 0.3, 0.4));
|
||||
assert_eq!(c.to_vec3(), Vec3::new(0.1, 0.2, 0.3));
|
||||
assert_eq!(c.with_alpha(1.0).a, 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdr_values_not_clamped_in_storage() {
|
||||
let c = Color::rgb(4.0, 0.0, 0.0);
|
||||
assert_eq!(c.r, 4.0);
|
||||
// But output is clamped.
|
||||
assert_eq!(c.to_srgb_u8()[0], 255);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
//! A view [`Frustum`]: six planes used for visibility culling.
|
||||
|
||||
use crate::math::{Aabb, Plane};
|
||||
use glam::{Mat4, Vec3, Vec4, Vec4Swizzles};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A frustum represented by six bounding planes, each with its normal pointing
|
||||
/// *inward*. A point is inside the frustum when it lies in the positive
|
||||
/// half-space of every plane.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Frustum {
|
||||
/// Planes ordered: left, right, bottom, top, near, far.
|
||||
pub planes: [Plane; 6],
|
||||
}
|
||||
|
||||
impl Frustum {
|
||||
/// Extracts the six frustum planes from a combined view-projection matrix
|
||||
/// using the Gribb–Hartmann method. Works for both perspective and
|
||||
/// orthographic projections.
|
||||
pub fn from_view_projection(view_projection: Mat4) -> Self {
|
||||
// Rows of the matrix (glam is column-major, so build rows explicitly).
|
||||
let m = view_projection;
|
||||
let row0 = Vec4::new(m.x_axis.x, m.y_axis.x, m.z_axis.x, m.w_axis.x);
|
||||
let row1 = Vec4::new(m.x_axis.y, m.y_axis.y, m.z_axis.y, m.w_axis.y);
|
||||
let row2 = Vec4::new(m.x_axis.z, m.y_axis.z, m.z_axis.z, m.w_axis.z);
|
||||
let row3 = Vec4::new(m.x_axis.w, m.y_axis.w, m.z_axis.w, m.w_axis.w);
|
||||
|
||||
let plane_from = |v: Vec4| Plane::new(v.xyz(), v.w);
|
||||
|
||||
let planes = [
|
||||
plane_from(row3 + row0), // left
|
||||
plane_from(row3 - row0), // right
|
||||
plane_from(row3 + row1), // bottom
|
||||
plane_from(row3 - row1), // top
|
||||
plane_from(row3 + row2), // near
|
||||
plane_from(row3 - row2), // far
|
||||
];
|
||||
Self { planes }
|
||||
}
|
||||
|
||||
/// Returns `true` if `point` is inside (or on the boundary of) the frustum.
|
||||
pub fn contains_point(&self, point: Vec3) -> bool {
|
||||
self.planes
|
||||
.iter()
|
||||
.all(|plane| plane.signed_distance(point) >= 0.0)
|
||||
}
|
||||
|
||||
/// Returns `true` if any part of `aabb` is inside the frustum.
|
||||
///
|
||||
/// This is a conservative test: it may very rarely report a box as visible
|
||||
/// when it is just outside a corner, but never culls a visible box. That is
|
||||
/// the correct trade-off for rendering.
|
||||
pub fn intersects_aabb(&self, aabb: &Aabb) -> bool {
|
||||
for plane in &self.planes {
|
||||
// The "positive vertex": the AABB corner farthest along the normal.
|
||||
let p = Vec3::new(
|
||||
if plane.normal.x >= 0.0 {
|
||||
aabb.max.x
|
||||
} else {
|
||||
aabb.min.x
|
||||
},
|
||||
if plane.normal.y >= 0.0 {
|
||||
aabb.max.y
|
||||
} else {
|
||||
aabb.min.y
|
||||
},
|
||||
if plane.normal.z >= 0.0 {
|
||||
aabb.max.z
|
||||
} else {
|
||||
aabb.min.z
|
||||
},
|
||||
);
|
||||
// If the farthest corner is behind a plane, the box is fully outside.
|
||||
if plane.signed_distance(p) < 0.0 {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Returns `true` if the sphere at `center` with `radius` is at least
|
||||
/// partially inside the frustum.
|
||||
pub fn intersects_sphere(&self, center: Vec3, radius: f32) -> bool {
|
||||
self.planes
|
||||
.iter()
|
||||
.all(|plane| plane.signed_distance(center) >= -radius)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn perspective_vp() -> Mat4 {
|
||||
let proj = Mat4::perspective_rh(60_f32.to_radians(), 1.0, 1.0, 100.0);
|
||||
let view = Mat4::look_at_rh(Vec3::new(0.0, 0.0, 0.0), Vec3::NEG_Z, Vec3::Y);
|
||||
proj * view
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_in_front_is_inside() {
|
||||
let f = Frustum::from_view_projection(perspective_vp());
|
||||
assert!(f.contains_point(Vec3::new(0.0, 0.0, -10.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_behind_camera_is_outside() {
|
||||
let f = Frustum::from_view_projection(perspective_vp());
|
||||
assert!(!f.contains_point(Vec3::new(0.0, 0.0, 10.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_beyond_far_is_outside() {
|
||||
let f = Frustum::from_view_projection(perspective_vp());
|
||||
assert!(!f.contains_point(Vec3::new(0.0, 0.0, -500.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_way_off_to_side_is_outside() {
|
||||
let f = Frustum::from_view_projection(perspective_vp());
|
||||
assert!(!f.contains_point(Vec3::new(500.0, 0.0, -10.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aabb_in_view_intersects() {
|
||||
let f = Frustum::from_view_projection(perspective_vp());
|
||||
let bb = Aabb::from_center_half_extents(Vec3::new(0.0, 0.0, -10.0), Vec3::splat(1.0));
|
||||
assert!(f.intersects_aabb(&bb));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aabb_behind_camera_is_culled() {
|
||||
let f = Frustum::from_view_projection(perspective_vp());
|
||||
let bb = Aabb::from_center_half_extents(Vec3::new(0.0, 0.0, 50.0), Vec3::splat(1.0));
|
||||
assert!(!f.intersects_aabb(&bb));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sphere_culling() {
|
||||
let f = Frustum::from_view_projection(perspective_vp());
|
||||
assert!(f.intersects_sphere(Vec3::new(0.0, 0.0, -10.0), 1.0));
|
||||
// Just behind the camera but large enough to poke into the near plane.
|
||||
assert!(!f.intersects_sphere(Vec3::new(0.0, 0.0, 50.0), 1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orthographic_frustum_works() {
|
||||
let proj = Mat4::orthographic_rh(-10.0, 10.0, -10.0, 10.0, 1.0, 100.0);
|
||||
let view = Mat4::look_at_rh(Vec3::ZERO, Vec3::NEG_Z, Vec3::Y);
|
||||
let f = Frustum::from_view_projection(proj * view);
|
||||
assert!(f.contains_point(Vec3::new(5.0, 5.0, -10.0)));
|
||||
assert!(!f.contains_point(Vec3::new(50.0, 0.0, -10.0)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
//! Math and core geometric primitives.
|
||||
//!
|
||||
//! This module is the foundation every other Oxide system depends on. It builds
|
||||
//! on [`glam`] for vectors, quaternions, and matrices, and adds the engine's own
|
||||
//! higher-level types:
|
||||
//!
|
||||
//! - [`Transform`] — decomposed translation/rotation/scale, the unit of placement
|
||||
//! - [`Aabb`] — axis-aligned bounding box for bounds and culling
|
||||
//! - [`Ray`] — origin + direction, for picking and queries
|
||||
//! - [`Plane`] — infinite plane in Hessian normal form
|
||||
//! - [`Frustum`] — six-plane view volume for visibility culling
|
||||
//! - [`Color`] — linear RGBA color with sRGB conversion
|
||||
//! - [`Rect`] — 2D rectangle for UI and viewports
|
||||
//! - [`Range3`] — 3D value range for clamping and remapping
|
||||
//!
|
||||
//! `glam`'s own types are re-exported so downstream crates have a single import
|
||||
//! site for all math.
|
||||
|
||||
mod aabb;
|
||||
mod color;
|
||||
mod frustum;
|
||||
mod plane;
|
||||
mod range3;
|
||||
mod ray;
|
||||
mod rect;
|
||||
mod transform;
|
||||
|
||||
pub use aabb::Aabb;
|
||||
pub use color::Color;
|
||||
pub use frustum::Frustum;
|
||||
pub use plane::Plane;
|
||||
pub use range3::Range3;
|
||||
pub use ray::Ray;
|
||||
pub use rect::Rect;
|
||||
pub use transform::Transform;
|
||||
|
||||
// Re-export the most commonly used `glam` types so consumers don't need a
|
||||
// separate dependency on `glam` for everyday math.
|
||||
pub use glam::{EulerRot, Mat3, Mat4, Quat, Vec2, Vec3, Vec4};
|
||||
@@ -0,0 +1,154 @@
|
||||
//! An infinite [`Plane`] in Hessian normal form.
|
||||
|
||||
use crate::math::Ray;
|
||||
use glam::Vec3;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// An infinite plane defined by a unit `normal` and a signed distance `d` from
|
||||
/// the origin, such that every point `p` on the plane satisfies
|
||||
/// `normal · p + d = 0`.
|
||||
///
|
||||
/// The positive half-space is the side the normal points toward.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Plane {
|
||||
/// Unit-length plane normal.
|
||||
pub normal: Vec3,
|
||||
/// Signed distance from the origin along `-normal`.
|
||||
pub d: f32,
|
||||
}
|
||||
|
||||
impl Plane {
|
||||
/// Creates a plane from a normal and signed distance, normalizing the input
|
||||
/// (and scaling `d` to match) so the result is in Hessian normal form.
|
||||
#[inline]
|
||||
pub fn new(normal: Vec3, d: f32) -> Self {
|
||||
let len = normal.length();
|
||||
if len > 0.0 {
|
||||
Self {
|
||||
normal: normal / len,
|
||||
d: d / len,
|
||||
}
|
||||
} else {
|
||||
Self { normal, d }
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a plane from a point on it and a normal direction.
|
||||
#[inline]
|
||||
pub fn from_point_normal(point: Vec3, normal: Vec3) -> Self {
|
||||
let n = normal.normalize_or_zero();
|
||||
Self {
|
||||
normal: n,
|
||||
d: -n.dot(point),
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a plane through three points. Winding `a → b → c` determines the
|
||||
/// normal direction (right-hand rule).
|
||||
#[inline]
|
||||
pub fn from_points(a: Vec3, b: Vec3, c: Vec3) -> Self {
|
||||
let normal = (b - a).cross(c - a);
|
||||
Self::from_point_normal(a, normal)
|
||||
}
|
||||
|
||||
/// The signed distance from `point` to the plane. Positive on the side the
|
||||
/// normal points toward, negative behind it, zero on the plane.
|
||||
#[inline]
|
||||
pub fn signed_distance(&self, point: Vec3) -> f32 {
|
||||
self.normal.dot(point) + self.d
|
||||
}
|
||||
|
||||
/// Projects `point` orthogonally onto the plane.
|
||||
#[inline]
|
||||
pub fn project_point(&self, point: Vec3) -> Vec3 {
|
||||
point - self.normal * self.signed_distance(point)
|
||||
}
|
||||
|
||||
/// Returns the intersection distance `t` along `ray`, or `None` if the ray
|
||||
/// is parallel to the plane (or points away from it).
|
||||
pub fn ray_intersection(&self, ray: &Ray) -> Option<f32> {
|
||||
let denom = self.normal.dot(ray.direction);
|
||||
if denom.abs() <= f32::EPSILON {
|
||||
return None; // Parallel.
|
||||
}
|
||||
let t = -(self.normal.dot(ray.origin) + self.d) / denom;
|
||||
(t >= 0.0).then_some(t)
|
||||
}
|
||||
|
||||
/// Returns a plane facing the opposite direction (same geometric plane).
|
||||
#[inline]
|
||||
pub fn flipped(&self) -> Plane {
|
||||
Plane {
|
||||
normal: -self.normal,
|
||||
d: -self.d,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const EPS: f32 = 1e-4;
|
||||
|
||||
#[test]
|
||||
fn from_point_normal_passes_through_point() {
|
||||
let p = Plane::from_point_normal(Vec3::new(0.0, 2.0, 0.0), Vec3::Y);
|
||||
assert!(p.signed_distance(Vec3::new(5.0, 2.0, -3.0)).abs() <= EPS);
|
||||
assert!((p.signed_distance(Vec3::new(0.0, 5.0, 0.0)) - 3.0).abs() <= EPS);
|
||||
assert!((p.signed_distance(Vec3::ZERO) + 2.0).abs() <= EPS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_normalizes() {
|
||||
let p = Plane::new(Vec3::new(0.0, 0.0, 4.0), 8.0);
|
||||
assert!((p.normal - Vec3::Z).length() <= EPS);
|
||||
assert!((p.d - 2.0).abs() <= EPS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_points_winding() {
|
||||
let p = Plane::from_points(Vec3::ZERO, Vec3::X, Vec3::Y);
|
||||
// X cross Y = Z.
|
||||
assert!((p.normal - Vec3::Z).length() <= EPS);
|
||||
assert!(p.d.abs() <= EPS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_lands_on_plane() {
|
||||
let p = Plane::from_point_normal(Vec3::ZERO, Vec3::Y);
|
||||
let proj = p.project_point(Vec3::new(3.0, 7.0, -2.0));
|
||||
assert!((proj - Vec3::new(3.0, 0.0, -2.0)).length() <= EPS);
|
||||
assert!(p.signed_distance(proj).abs() <= EPS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ray_intersects_plane() {
|
||||
let p = Plane::from_point_normal(Vec3::ZERO, Vec3::Y);
|
||||
let ray = Ray::new(Vec3::new(0.0, 5.0, 0.0), Vec3::NEG_Y);
|
||||
let t = p.ray_intersection(&ray).expect("should hit");
|
||||
assert!((t - 5.0).abs() <= EPS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ray_parallel_misses() {
|
||||
let p = Plane::from_point_normal(Vec3::ZERO, Vec3::Y);
|
||||
let ray = Ray::new(Vec3::new(0.0, 5.0, 0.0), Vec3::X);
|
||||
assert_eq!(p.ray_intersection(&ray), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ray_pointing_away_misses() {
|
||||
let p = Plane::from_point_normal(Vec3::ZERO, Vec3::Y);
|
||||
let ray = Ray::new(Vec3::new(0.0, 5.0, 0.0), Vec3::Y);
|
||||
assert_eq!(p.ray_intersection(&ray), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flipped_reverses_sign() {
|
||||
let p = Plane::from_point_normal(Vec3::new(0.0, 2.0, 0.0), Vec3::Y);
|
||||
let f = p.flipped();
|
||||
let pt = Vec3::new(0.0, 5.0, 0.0);
|
||||
assert!((p.signed_distance(pt) + f.signed_distance(pt)).abs() <= EPS);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
//! A 3D value [`Range3`]: an inclusive `[min, max]` interval per axis.
|
||||
//!
|
||||
//! Unlike [`Aabb`](crate::math::Aabb), which models geometry, `Range3` models a
|
||||
//! *value range* — clamping configuration values, remapping parameters, and
|
||||
//! describing generation bounds. It provides interpolation and remapping that
|
||||
//! an AABB intentionally does not.
|
||||
|
||||
use glam::Vec3;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// An inclusive per-axis range `[min, max]`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Range3 {
|
||||
/// Lower bound on each axis.
|
||||
pub min: Vec3,
|
||||
/// Upper bound on each axis.
|
||||
pub max: Vec3,
|
||||
}
|
||||
|
||||
impl Range3 {
|
||||
/// The unit range `[0, 1]` on every axis.
|
||||
pub const UNIT: Self = Self {
|
||||
min: Vec3::ZERO,
|
||||
max: Vec3::ONE,
|
||||
};
|
||||
|
||||
/// Creates a range from two bounds, sorting so `min <= max` per axis.
|
||||
#[inline]
|
||||
pub fn new(a: Vec3, b: Vec3) -> Self {
|
||||
Self {
|
||||
min: a.min(b),
|
||||
max: a.max(b),
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a range spanning `[-extent, +extent]` on each axis.
|
||||
#[inline]
|
||||
pub fn symmetric(extent: Vec3) -> Self {
|
||||
Self {
|
||||
min: -extent,
|
||||
max: extent,
|
||||
}
|
||||
}
|
||||
|
||||
/// The width of the range on each axis (`max - min`).
|
||||
#[inline]
|
||||
pub fn span(&self) -> Vec3 {
|
||||
self.max - self.min
|
||||
}
|
||||
|
||||
/// The midpoint of the range.
|
||||
#[inline]
|
||||
pub fn center(&self) -> Vec3 {
|
||||
(self.min + self.max) * 0.5
|
||||
}
|
||||
|
||||
/// Clamps `value` into the range per axis.
|
||||
#[inline]
|
||||
pub fn clamp(&self, value: Vec3) -> Vec3 {
|
||||
value.clamp(self.min, self.max)
|
||||
}
|
||||
|
||||
/// Returns `true` if `value` lies within the range (inclusive).
|
||||
#[inline]
|
||||
pub fn contains(&self, value: Vec3) -> bool {
|
||||
value.cmpge(self.min).all() && value.cmple(self.max).all()
|
||||
}
|
||||
|
||||
/// Linearly interpolates from `min` to `max` by `t` per axis. `t` is **not**
|
||||
/// clamped, so values outside `[0, 1]` extrapolate.
|
||||
#[inline]
|
||||
pub fn lerp(&self, t: Vec3) -> Vec3 {
|
||||
self.min + self.span() * t
|
||||
}
|
||||
|
||||
/// The inverse of [`Range3::lerp`]: returns where `value` sits in `[0, 1]`
|
||||
/// within the range, per axis. Axes with zero span yield `0.0`.
|
||||
#[inline]
|
||||
pub fn inverse_lerp(&self, value: Vec3) -> Vec3 {
|
||||
let span = self.span();
|
||||
let raw = (value - self.min) / span;
|
||||
// Guard against division by zero on degenerate axes.
|
||||
Vec3::select(span.cmpeq(Vec3::ZERO), Vec3::ZERO, raw)
|
||||
}
|
||||
|
||||
/// Remaps `value` from this range into `target`, preserving its relative
|
||||
/// position per axis.
|
||||
#[inline]
|
||||
pub fn remap(&self, value: Vec3, target: &Range3) -> Vec3 {
|
||||
target.lerp(self.inverse_lerp(value))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const EPS: f32 = 1e-4;
|
||||
|
||||
fn approx(a: Vec3, b: Vec3) -> bool {
|
||||
(a - b).length() <= EPS
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_sorts_bounds() {
|
||||
let r = Range3::new(Vec3::new(5.0, 0.0, -2.0), Vec3::new(1.0, 3.0, 4.0));
|
||||
assert_eq!(r.min, Vec3::new(1.0, 0.0, -2.0));
|
||||
assert_eq!(r.max, Vec3::new(5.0, 3.0, 4.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symmetric_and_span_center() {
|
||||
let r = Range3::symmetric(Vec3::splat(2.0));
|
||||
assert_eq!(r.min, Vec3::splat(-2.0));
|
||||
assert_eq!(r.span(), Vec3::splat(4.0));
|
||||
assert_eq!(r.center(), Vec3::ZERO);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clamp_and_contains() {
|
||||
let r = Range3::new(Vec3::ZERO, Vec3::splat(10.0));
|
||||
assert_eq!(
|
||||
r.clamp(Vec3::new(-5.0, 5.0, 20.0)),
|
||||
Vec3::new(0.0, 5.0, 10.0)
|
||||
);
|
||||
assert!(r.contains(Vec3::splat(5.0)));
|
||||
assert!(!r.contains(Vec3::new(11.0, 5.0, 5.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lerp_and_inverse_round_trip() {
|
||||
let r = Range3::new(Vec3::new(2.0, 4.0, 6.0), Vec3::new(4.0, 8.0, 12.0));
|
||||
let t = Vec3::new(0.5, 0.25, 0.75);
|
||||
let v = r.lerp(t);
|
||||
assert!(approx(v, Vec3::new(3.0, 5.0, 10.5)));
|
||||
assert!(approx(r.inverse_lerp(v), t));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lerp_extrapolates() {
|
||||
let r = Range3::UNIT;
|
||||
assert!(approx(r.lerp(Vec3::splat(2.0)), Vec3::splat(2.0)));
|
||||
assert!(approx(r.lerp(Vec3::splat(-1.0)), Vec3::splat(-1.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inverse_lerp_degenerate_axis_is_zero() {
|
||||
let r = Range3::new(Vec3::new(5.0, 0.0, 0.0), Vec3::new(5.0, 10.0, 10.0));
|
||||
// x axis has zero span → 0.0 rather than NaN/inf.
|
||||
let result = r.inverse_lerp(Vec3::new(5.0, 5.0, 5.0));
|
||||
assert!(result.x.is_finite());
|
||||
assert_eq!(result.x, 0.0);
|
||||
assert!((result.y - 0.5).abs() <= EPS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remap_between_ranges() {
|
||||
let from = Range3::new(Vec3::ZERO, Vec3::splat(100.0));
|
||||
let to = Range3::new(Vec3::ZERO, Vec3::ONE);
|
||||
assert!(approx(from.remap(Vec3::splat(50.0), &to), Vec3::splat(0.5)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
//! A half-line [`Ray`] with an origin and a normalized direction.
|
||||
|
||||
use glam::Vec3;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A ray: a point plus a direction, extending to infinity in one direction.
|
||||
///
|
||||
/// The direction is normalized on construction so that the parameter `t` in
|
||||
/// [`Ray::at`] is a true distance.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Ray {
|
||||
/// The starting point of the ray.
|
||||
pub origin: Vec3,
|
||||
/// The (normalized) direction of travel.
|
||||
pub direction: Vec3,
|
||||
}
|
||||
|
||||
impl Ray {
|
||||
/// Creates a ray, normalizing `direction`.
|
||||
///
|
||||
/// If `direction` is zero-length it is left as-is (degenerate ray); callers
|
||||
/// that care should validate with [`Ray::is_valid`].
|
||||
#[inline]
|
||||
pub fn new(origin: Vec3, direction: Vec3) -> Self {
|
||||
Self {
|
||||
origin,
|
||||
direction: direction.normalize_or_zero(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a ray from an origin toward a target point.
|
||||
#[inline]
|
||||
pub fn from_to(origin: Vec3, target: Vec3) -> Self {
|
||||
Self::new(origin, target - origin)
|
||||
}
|
||||
|
||||
/// Returns the point at distance `t` along the ray.
|
||||
#[inline]
|
||||
pub fn at(&self, t: f32) -> Vec3 {
|
||||
self.origin + self.direction * t
|
||||
}
|
||||
|
||||
/// Returns `true` if the direction is a valid (non-zero, finite) unit vector.
|
||||
#[inline]
|
||||
pub fn is_valid(&self) -> bool {
|
||||
self.direction.is_finite() && (self.direction.length_squared() - 1.0).abs() <= 1e-4
|
||||
}
|
||||
|
||||
/// Returns the point on the ray closest to `point`, clamped to `t >= 0`.
|
||||
#[inline]
|
||||
pub fn closest_point(&self, point: Vec3) -> Vec3 {
|
||||
let t = (point - self.origin).dot(self.direction).max(0.0);
|
||||
self.at(t)
|
||||
}
|
||||
|
||||
/// Returns the shortest distance from `point` to the ray.
|
||||
#[inline]
|
||||
pub fn distance_to_point(&self, point: Vec3) -> f32 {
|
||||
self.closest_point(point).distance(point)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const EPS: f32 = 1e-4;
|
||||
|
||||
#[test]
|
||||
fn new_normalizes_direction() {
|
||||
let ray = Ray::new(Vec3::ZERO, Vec3::new(0.0, 5.0, 0.0));
|
||||
assert!((ray.direction.length() - 1.0).abs() <= EPS);
|
||||
assert!(ray.is_valid());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_direction_is_invalid() {
|
||||
let ray = Ray::new(Vec3::ZERO, Vec3::ZERO);
|
||||
assert!(!ray.is_valid());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn at_returns_distance_point() {
|
||||
let ray = Ray::new(Vec3::new(1.0, 0.0, 0.0), Vec3::X);
|
||||
assert!((ray.at(4.0) - Vec3::new(5.0, 0.0, 0.0)).length() <= EPS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_to_points_at_target() {
|
||||
let ray = Ray::from_to(Vec3::ZERO, Vec3::new(0.0, 0.0, 10.0));
|
||||
assert!((ray.direction - Vec3::Z).length() <= EPS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closest_point_and_distance() {
|
||||
let ray = Ray::new(Vec3::ZERO, Vec3::X);
|
||||
// Point off to the side.
|
||||
let p = Vec3::new(3.0, 4.0, 0.0);
|
||||
assert!((ray.closest_point(p) - Vec3::new(3.0, 0.0, 0.0)).length() <= EPS);
|
||||
assert!((ray.distance_to_point(p) - 4.0).abs() <= EPS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closest_point_clamps_behind_origin() {
|
||||
let ray = Ray::new(Vec3::ZERO, Vec3::X);
|
||||
let p = Vec3::new(-5.0, 2.0, 0.0);
|
||||
// Behind the origin → clamps to the origin.
|
||||
assert!((ray.closest_point(p) - Vec3::ZERO).length() <= EPS);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
//! A 2D axis-aligned [`Rect`]angle, used for UI, viewports, and texture regions.
|
||||
|
||||
use glam::Vec2;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// An axis-aligned rectangle defined by its `min` (top-left in a y-down UI
|
||||
/// space, or bottom-left in y-up) and `max` corners.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Rect {
|
||||
/// Minimum corner (smallest x and y).
|
||||
pub min: Vec2,
|
||||
/// Maximum corner (largest x and y).
|
||||
pub max: Vec2,
|
||||
}
|
||||
|
||||
impl Rect {
|
||||
/// A zero-area rectangle at the origin.
|
||||
pub const ZERO: Self = Self {
|
||||
min: Vec2::ZERO,
|
||||
max: Vec2::ZERO,
|
||||
};
|
||||
|
||||
/// Creates a rectangle from two corners, sorting so `min <= max`.
|
||||
#[inline]
|
||||
pub fn new(a: Vec2, b: Vec2) -> Self {
|
||||
Self {
|
||||
min: a.min(b),
|
||||
max: a.max(b),
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a rectangle from a `min` corner and a size.
|
||||
#[inline]
|
||||
pub fn from_min_size(min: Vec2, size: Vec2) -> Self {
|
||||
Self {
|
||||
min,
|
||||
max: min + size,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a rectangle from a center point and full size.
|
||||
#[inline]
|
||||
pub fn from_center_size(center: Vec2, size: Vec2) -> Self {
|
||||
let half = size * 0.5;
|
||||
Self {
|
||||
min: center - half,
|
||||
max: center + half,
|
||||
}
|
||||
}
|
||||
|
||||
/// The width and height as a vector.
|
||||
#[inline]
|
||||
pub fn size(&self) -> Vec2 {
|
||||
(self.max - self.min).max(Vec2::ZERO)
|
||||
}
|
||||
|
||||
/// The width (x extent).
|
||||
#[inline]
|
||||
pub fn width(&self) -> f32 {
|
||||
self.size().x
|
||||
}
|
||||
|
||||
/// The height (y extent).
|
||||
#[inline]
|
||||
pub fn height(&self) -> f32 {
|
||||
self.size().y
|
||||
}
|
||||
|
||||
/// The center point.
|
||||
#[inline]
|
||||
pub fn center(&self) -> Vec2 {
|
||||
(self.min + self.max) * 0.5
|
||||
}
|
||||
|
||||
/// The area (`width * height`).
|
||||
#[inline]
|
||||
pub fn area(&self) -> f32 {
|
||||
let s = self.size();
|
||||
s.x * s.y
|
||||
}
|
||||
|
||||
/// Returns `true` if the rectangle has zero (or inverted) area.
|
||||
#[inline]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.min.x >= self.max.x || self.min.y >= self.max.y
|
||||
}
|
||||
|
||||
/// Returns `true` if `point` is inside or on the boundary.
|
||||
#[inline]
|
||||
pub fn contains_point(&self, point: Vec2) -> bool {
|
||||
point.cmpge(self.min).all() && point.cmple(self.max).all()
|
||||
}
|
||||
|
||||
/// Returns `true` if the two rectangles overlap (touching counts).
|
||||
#[inline]
|
||||
pub fn intersects(&self, other: &Rect) -> bool {
|
||||
self.min.cmple(other.max).all() && self.max.cmpge(other.min).all()
|
||||
}
|
||||
|
||||
/// Returns the overlapping region, or [`Rect::ZERO`] if disjoint.
|
||||
#[inline]
|
||||
pub fn intersection(&self, other: &Rect) -> Rect {
|
||||
let min = self.min.max(other.min);
|
||||
let max = self.max.min(other.max);
|
||||
if min.x > max.x || min.y > max.y {
|
||||
Rect::ZERO
|
||||
} else {
|
||||
Rect { min, max }
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the smallest rectangle containing both.
|
||||
#[inline]
|
||||
pub fn union(&self, other: &Rect) -> Rect {
|
||||
Rect {
|
||||
min: self.min.min(other.min),
|
||||
max: self.max.max(other.max),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the point inside the rectangle closest to `point`.
|
||||
#[inline]
|
||||
pub fn closest_point(&self, point: Vec2) -> Vec2 {
|
||||
point.clamp(self.min, self.max)
|
||||
}
|
||||
|
||||
/// Returns a copy expanded outward by `amount` on every side (negative
|
||||
/// shrinks).
|
||||
#[inline]
|
||||
pub fn expanded(&self, amount: f32) -> Rect {
|
||||
Rect {
|
||||
min: self.min - Vec2::splat(amount),
|
||||
max: self.max + Vec2::splat(amount),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn new_sorts_corners() {
|
||||
let r = Rect::new(Vec2::new(4.0, 1.0), Vec2::new(0.0, 5.0));
|
||||
assert_eq!(r.min, Vec2::new(0.0, 1.0));
|
||||
assert_eq!(r.max, Vec2::new(4.0, 5.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn min_size_and_center_size() {
|
||||
let r = Rect::from_min_size(Vec2::new(1.0, 2.0), Vec2::new(4.0, 6.0));
|
||||
assert_eq!(r.size(), Vec2::new(4.0, 6.0));
|
||||
assert_eq!(r.center(), Vec2::new(3.0, 5.0));
|
||||
|
||||
let c = Rect::from_center_size(Vec2::ZERO, Vec2::new(2.0, 2.0));
|
||||
assert_eq!(c.min, Vec2::new(-1.0, -1.0));
|
||||
assert_eq!(c.max, Vec2::new(1.0, 1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dimensions_and_area() {
|
||||
let r = Rect::from_min_size(Vec2::ZERO, Vec2::new(3.0, 4.0));
|
||||
assert_eq!(r.width(), 3.0);
|
||||
assert_eq!(r.height(), 4.0);
|
||||
assert_eq!(r.area(), 12.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_detection() {
|
||||
assert!(Rect::ZERO.is_empty());
|
||||
assert!(Rect::new(Vec2::ZERO, Vec2::new(0.0, 5.0)).is_empty());
|
||||
assert!(!Rect::from_min_size(Vec2::ZERO, Vec2::ONE).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contains_and_closest() {
|
||||
let r = Rect::from_min_size(Vec2::ZERO, Vec2::splat(2.0));
|
||||
assert!(r.contains_point(Vec2::ONE));
|
||||
assert!(!r.contains_point(Vec2::new(3.0, 1.0)));
|
||||
assert_eq!(r.closest_point(Vec2::new(5.0, -1.0)), Vec2::new(2.0, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intersection_and_union() {
|
||||
let a = Rect::from_min_size(Vec2::ZERO, Vec2::splat(2.0));
|
||||
let b = Rect::from_min_size(Vec2::ONE, Vec2::splat(2.0));
|
||||
assert!(a.intersects(&b));
|
||||
assert_eq!(a.intersection(&b), Rect::new(Vec2::ONE, Vec2::splat(2.0)));
|
||||
assert_eq!(a.union(&b), Rect::new(Vec2::ZERO, Vec2::splat(3.0)));
|
||||
|
||||
let c = Rect::from_min_size(Vec2::splat(10.0), Vec2::ONE);
|
||||
assert!(!a.intersects(&c));
|
||||
assert_eq!(a.intersection(&c), Rect::ZERO);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expanded_grows_and_shrinks() {
|
||||
let r = Rect::from_min_size(Vec2::ZERO, Vec2::splat(4.0));
|
||||
assert_eq!(
|
||||
r.expanded(1.0),
|
||||
Rect::new(Vec2::splat(-1.0), Vec2::splat(5.0))
|
||||
);
|
||||
assert_eq!(r.expanded(-1.0), Rect::new(Vec2::ONE, Vec2::splat(3.0)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
//! Affine [`Transform`]: translation, rotation, and (non-uniform) scale.
|
||||
//!
|
||||
//! A `Transform` is the canonical way to place an object in space. It composes
|
||||
//! as `parent * child`, matching the convention used by the scene graph in
|
||||
//! later stages. Internally it is stored in decomposed (TRS) form so that
|
||||
//! individual components stay editable without matrix round-trips.
|
||||
|
||||
use glam::{Affine3A, Mat4, Quat, Vec3};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A 3D affine transform stored as translation, rotation, and scale.
|
||||
///
|
||||
/// The effective matrix is `T * R * S` (scale applied first, then rotation,
|
||||
/// then translation), which is the standard convention for scene hierarchies.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, crate::reflect::Reflect)]
|
||||
pub struct Transform {
|
||||
/// World/local-space position.
|
||||
pub translation: Vec3,
|
||||
/// Orientation as a unit quaternion.
|
||||
pub rotation: Quat,
|
||||
/// Per-axis scale. May be non-uniform; zero or negative components are
|
||||
/// permitted but make the transform non-invertible / mirror-inducing.
|
||||
pub scale: Vec3,
|
||||
}
|
||||
|
||||
impl Default for Transform {
|
||||
/// The identity transform: no translation, no rotation, unit scale.
|
||||
fn default() -> Self {
|
||||
Self::IDENTITY
|
||||
}
|
||||
}
|
||||
|
||||
impl Transform {
|
||||
/// The identity transform.
|
||||
pub const IDENTITY: Self = Self {
|
||||
translation: Vec3::ZERO,
|
||||
rotation: Quat::IDENTITY,
|
||||
scale: Vec3::ONE,
|
||||
};
|
||||
|
||||
/// Creates a transform from a translation only (identity rotation, unit scale).
|
||||
#[inline]
|
||||
pub const fn from_translation(translation: Vec3) -> Self {
|
||||
Self {
|
||||
translation,
|
||||
rotation: Quat::IDENTITY,
|
||||
scale: Vec3::ONE,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a transform from a rotation only.
|
||||
#[inline]
|
||||
pub const fn from_rotation(rotation: Quat) -> Self {
|
||||
Self {
|
||||
translation: Vec3::ZERO,
|
||||
rotation,
|
||||
scale: Vec3::ONE,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a transform from a uniform scale.
|
||||
#[inline]
|
||||
pub const fn from_scale(scale: Vec3) -> Self {
|
||||
Self {
|
||||
translation: Vec3::ZERO,
|
||||
rotation: Quat::IDENTITY,
|
||||
scale,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a transform from all three components.
|
||||
#[inline]
|
||||
pub const fn from_trs(translation: Vec3, rotation: Quat, scale: Vec3) -> Self {
|
||||
Self {
|
||||
translation,
|
||||
rotation,
|
||||
scale,
|
||||
}
|
||||
}
|
||||
|
||||
/// Decomposes a 4x4 matrix back into a TRS transform.
|
||||
///
|
||||
/// Negative determinants (mirrored matrices) are handled by `glam`'s
|
||||
/// decomposition, which folds the sign into the scale.
|
||||
#[inline]
|
||||
pub fn from_matrix(matrix: Mat4) -> Self {
|
||||
let (scale, rotation, translation) = matrix.to_scale_rotation_translation();
|
||||
Self {
|
||||
translation,
|
||||
rotation,
|
||||
scale,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the equivalent 4x4 homogeneous matrix.
|
||||
#[inline]
|
||||
pub fn to_matrix(&self) -> Mat4 {
|
||||
Mat4::from_scale_rotation_translation(self.scale, self.rotation, self.translation)
|
||||
}
|
||||
|
||||
/// Returns the equivalent [`Affine3A`], which is cheaper to compose than a
|
||||
/// full `Mat4` and is what the renderer/scene graph use internally.
|
||||
#[inline]
|
||||
pub fn to_affine(&self) -> Affine3A {
|
||||
Affine3A::from_scale_rotation_translation(self.scale, self.rotation, self.translation)
|
||||
}
|
||||
|
||||
/// Composes two transforms: `self * rhs` applies `rhs` first, then `self`.
|
||||
///
|
||||
/// This is exact for the translation and rotation channels. When either
|
||||
/// operand carries non-uniform scale combined with rotation, the true
|
||||
/// product is no longer a pure TRS transform; in that case the result is
|
||||
/// re-decomposed from the composed matrix so the returned `Transform`
|
||||
/// remains the closest TRS approximation. For uniform scale (the common
|
||||
/// scene-graph case) the composition is exact.
|
||||
#[inline]
|
||||
pub fn mul_transform(&self, rhs: &Transform) -> Transform {
|
||||
// Fast path: uniform scale composes exactly in TRS form.
|
||||
if is_uniform(self.scale) {
|
||||
let scale = self.scale * rhs.scale;
|
||||
let rotation = self.rotation * rhs.rotation;
|
||||
let translation = self.translation + self.rotation * (self.scale * rhs.translation);
|
||||
Transform {
|
||||
translation,
|
||||
rotation,
|
||||
scale,
|
||||
}
|
||||
} else {
|
||||
Transform::from_matrix(self.to_matrix() * rhs.to_matrix())
|
||||
}
|
||||
}
|
||||
|
||||
/// Transforms a point (affected by translation, rotation, and scale).
|
||||
#[inline]
|
||||
pub fn transform_point(&self, point: Vec3) -> Vec3 {
|
||||
self.translation + self.rotation * (self.scale * point)
|
||||
}
|
||||
|
||||
/// Transforms a direction vector (rotation and scale only, no translation).
|
||||
#[inline]
|
||||
pub fn transform_vector(&self, vector: Vec3) -> Vec3 {
|
||||
self.rotation * (self.scale * vector)
|
||||
}
|
||||
|
||||
/// Returns the inverse transform, such that
|
||||
/// `t.mul_transform(&t.inverse())` is approximately the identity.
|
||||
///
|
||||
/// # Panics
|
||||
/// Does not panic, but if any scale component is zero the inverse scale
|
||||
/// will contain infinities — the transform is not invertible in that case.
|
||||
#[inline]
|
||||
pub fn inverse(&self) -> Transform {
|
||||
let inv_scale = Vec3::ONE / self.scale;
|
||||
let inv_rotation = self.rotation.inverse();
|
||||
let inv_translation = inv_rotation * (inv_scale * -self.translation);
|
||||
Transform {
|
||||
translation: inv_translation,
|
||||
rotation: inv_rotation,
|
||||
scale: inv_scale,
|
||||
}
|
||||
}
|
||||
|
||||
/// The local forward direction (`-Z`) rotated into this transform's space.
|
||||
#[inline]
|
||||
pub fn forward(&self) -> Vec3 {
|
||||
self.rotation * Vec3::NEG_Z
|
||||
}
|
||||
|
||||
/// The local up direction (`+Y`) rotated into this transform's space.
|
||||
#[inline]
|
||||
pub fn up(&self) -> Vec3 {
|
||||
self.rotation * Vec3::Y
|
||||
}
|
||||
|
||||
/// The local right direction (`+X`) rotated into this transform's space.
|
||||
#[inline]
|
||||
pub fn right(&self) -> Vec3 {
|
||||
self.rotation * Vec3::X
|
||||
}
|
||||
|
||||
/// Builds a transform positioned at `eye` looking toward `target`.
|
||||
///
|
||||
/// `up` is the reference up vector. Returns the identity rotation if `eye`
|
||||
/// and `target` coincide.
|
||||
pub fn looking_at(eye: Vec3, target: Vec3, up: Vec3) -> Transform {
|
||||
let forward = target - eye;
|
||||
let rotation = if forward.length_squared() <= f32::EPSILON {
|
||||
Quat::IDENTITY
|
||||
} else {
|
||||
// glam's look_to is right-handed with -Z forward; invert the view
|
||||
// rotation to get an object-space orientation.
|
||||
Quat::from_mat4(&Mat4::look_to_rh(eye, forward.normalize(), up)).inverse()
|
||||
};
|
||||
Transform {
|
||||
translation: eye,
|
||||
rotation,
|
||||
scale: Vec3::ONE,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if every component is finite (no NaN/inf).
|
||||
#[inline]
|
||||
pub fn is_finite(&self) -> bool {
|
||||
self.translation.is_finite() && self.rotation.is_finite() && self.scale.is_finite()
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if all three components of `scale` are equal.
|
||||
#[inline]
|
||||
fn is_uniform(scale: Vec3) -> bool {
|
||||
(scale.x - scale.y).abs() <= f32::EPSILON && (scale.y - scale.z).abs() <= f32::EPSILON
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::f32::consts::{FRAC_PI_2, PI};
|
||||
|
||||
const EPS: f32 = 1e-4;
|
||||
|
||||
fn approx_vec(a: Vec3, b: Vec3) -> bool {
|
||||
(a - b).length() <= EPS
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_is_default() {
|
||||
assert_eq!(Transform::default(), Transform::IDENTITY);
|
||||
let p = Vec3::new(1.0, 2.0, 3.0);
|
||||
assert_eq!(Transform::IDENTITY.transform_point(p), p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn translation_moves_points() {
|
||||
let t = Transform::from_translation(Vec3::new(1.0, 2.0, 3.0));
|
||||
assert!(approx_vec(
|
||||
t.transform_point(Vec3::ZERO),
|
||||
Vec3::new(1.0, 2.0, 3.0)
|
||||
));
|
||||
// Vectors ignore translation.
|
||||
assert!(approx_vec(t.transform_vector(Vec3::X), Vec3::X));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotation_rotates_points() {
|
||||
let t = Transform::from_rotation(Quat::from_rotation_z(FRAC_PI_2));
|
||||
assert!(approx_vec(t.transform_point(Vec3::X), Vec3::Y));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_scales_points() {
|
||||
let t = Transform::from_scale(Vec3::new(2.0, 3.0, 4.0));
|
||||
assert!(approx_vec(
|
||||
t.transform_point(Vec3::ONE),
|
||||
Vec3::new(2.0, 3.0, 4.0)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matrix_round_trip() {
|
||||
let t = Transform::from_trs(
|
||||
Vec3::new(5.0, -2.0, 1.0),
|
||||
Quat::from_euler(glam::EulerRot::XYZ, 0.3, -0.7, 1.1),
|
||||
Vec3::new(2.0, 2.0, 2.0),
|
||||
);
|
||||
let back = Transform::from_matrix(t.to_matrix());
|
||||
assert!(approx_vec(t.translation, back.translation));
|
||||
assert!(approx_vec(t.scale, back.scale));
|
||||
// Quaternions q and -q represent the same rotation.
|
||||
let dot = t.rotation.dot(back.rotation).abs();
|
||||
assert!((dot - 1.0).abs() <= EPS, "rotation mismatch: dot={dot}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inverse_cancels() {
|
||||
let t = Transform::from_trs(
|
||||
Vec3::new(3.0, 4.0, 5.0),
|
||||
Quat::from_rotation_y(0.9),
|
||||
Vec3::splat(2.0),
|
||||
);
|
||||
let id = t.mul_transform(&t.inverse());
|
||||
assert!(approx_vec(id.translation, Vec3::ZERO));
|
||||
assert!(approx_vec(id.scale, Vec3::ONE));
|
||||
assert!(approx_vec(
|
||||
id.transform_point(Vec3::new(7.0, 8.0, 9.0)),
|
||||
Vec3::new(7.0, 8.0, 9.0)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composition_matches_matrix() {
|
||||
let a = Transform::from_trs(
|
||||
Vec3::new(1.0, 0.0, -2.0),
|
||||
Quat::from_rotation_x(0.4),
|
||||
Vec3::splat(1.5),
|
||||
);
|
||||
let b = Transform::from_trs(
|
||||
Vec3::new(-3.0, 2.0, 1.0),
|
||||
Quat::from_rotation_z(-0.8),
|
||||
Vec3::splat(0.5),
|
||||
);
|
||||
let composed = a.mul_transform(&b);
|
||||
let p = Vec3::new(2.0, -1.0, 3.0);
|
||||
let via_transform = composed.transform_point(p);
|
||||
let via_matrix = (a.to_matrix() * b.to_matrix()).transform_point3(p);
|
||||
assert!(approx_vec(via_transform, via_matrix));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonuniform_composition_falls_back_to_matrix() {
|
||||
let a = Transform::from_trs(
|
||||
Vec3::new(0.0, 1.0, 0.0),
|
||||
Quat::from_rotation_z(FRAC_PI_2),
|
||||
Vec3::new(2.0, 1.0, 1.0),
|
||||
);
|
||||
let b = Transform::from_trs(
|
||||
Vec3::new(1.0, 0.0, 0.0),
|
||||
Quat::IDENTITY,
|
||||
Vec3::new(1.0, 3.0, 1.0),
|
||||
);
|
||||
let composed = a.mul_transform(&b);
|
||||
let p = Vec3::new(1.0, 2.0, -1.0);
|
||||
let via_transform = composed.transform_point(p);
|
||||
let via_matrix = (a.to_matrix() * b.to_matrix()).transform_point3(p);
|
||||
// Re-decomposition keeps this close even with non-uniform scale.
|
||||
assert!(
|
||||
approx_vec(via_transform, via_matrix),
|
||||
"{via_transform} vs {via_matrix}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_scale_is_non_invertible() {
|
||||
let t = Transform::from_scale(Vec3::new(0.0, 1.0, 1.0));
|
||||
let inv = t.inverse();
|
||||
assert!(!inv.scale.x.is_finite());
|
||||
assert!(t.is_finite()); // the forward transform itself is still finite
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gimbal_lock_path_stays_stable() {
|
||||
// Pitch to +90° (a classic gimbal-lock orientation) and confirm the
|
||||
// basis vectors remain orthonormal after round-tripping through a matrix.
|
||||
let t =
|
||||
Transform::from_rotation(Quat::from_euler(glam::EulerRot::YXZ, 0.0, FRAC_PI_2, 0.0));
|
||||
let back = Transform::from_matrix(t.to_matrix());
|
||||
assert!(approx_vec(back.forward(), t.forward()));
|
||||
assert!(approx_vec(back.up(), t.up()));
|
||||
// Orthonormality.
|
||||
assert!(t.forward().dot(t.up()).abs() <= EPS);
|
||||
assert!(t.right().dot(t.up()).abs() <= EPS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn looking_at_faces_target() {
|
||||
let t = Transform::looking_at(Vec3::new(0.0, 0.0, 5.0), Vec3::ZERO, Vec3::Y);
|
||||
// Forward should point toward the target (-Z world direction).
|
||||
assert!(approx_vec(t.forward(), Vec3::NEG_Z));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn looking_at_degenerate_is_identity_rotation() {
|
||||
let t = Transform::looking_at(Vec3::ONE, Vec3::ONE, Vec3::Y);
|
||||
assert_eq!(t.rotation, Quat::IDENTITY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn basis_vectors_for_half_turn() {
|
||||
let t = Transform::from_rotation(Quat::from_rotation_y(PI));
|
||||
assert!(approx_vec(t.forward(), Vec3::Z));
|
||||
assert!(approx_vec(t.right(), Vec3::NEG_X));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
//! Prefabs — named templates that spawn an entity already carrying a set of
|
||||
//! components.
|
||||
//!
|
||||
//! The engine deliberately has **no parallel "object type" system**: an entity
|
||||
//! *is* its set of components. A [`Prefab`] is therefore nothing more than a
|
||||
//! named bundle of **(component name, value)** specs, applied on spawn through
|
||||
//! the [`TypeRegistry`]. "Spawn a Cube" means "spawn an entity, then set its
|
||||
//! `MeshRenderer` to a cube" — the same name-keyed path the editor and scripts
|
||||
//! already use, so prefabs are pure data (serializable, dual-editable) rather
|
||||
//! than code.
|
||||
//!
|
||||
//! This is what makes the editor's add-menu **data-driven**: the menu lists the
|
||||
//! prefabs in a [`PrefabRegistry`] instead of hard-coding one button per type.
|
||||
//!
|
||||
//! ```
|
||||
//! use oxide_engine::prelude::*;
|
||||
//! use oxide_engine::prefab::{ComponentSpec, Prefab, PrefabRegistry};
|
||||
//! use oxide_engine::reflect::TypeRegistry;
|
||||
//!
|
||||
//! // A registry that knows how to round-trip MeshRenderer by name.
|
||||
//! let mut types = TypeRegistry::new();
|
||||
//! types.register_reflected::<MeshRenderer>("MeshRenderer");
|
||||
//!
|
||||
//! // A "Cube" prefab: an entity carrying a default MeshRenderer (shape = Cube).
|
||||
//! let mut prefabs = PrefabRegistry::new();
|
||||
//! prefabs.register(
|
||||
//! Prefab::new("Cube")
|
||||
//! .with(ComponentSpec::of("MeshRenderer", &MeshRenderer::default()).unwrap()),
|
||||
//! );
|
||||
//!
|
||||
//! let mut scene = Scene::new();
|
||||
//! let cube = prefabs.spawn("Cube", &mut scene, &types).unwrap();
|
||||
//! assert!(types.has(scene.world(), cube, "MeshRenderer").unwrap());
|
||||
//! ```
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use hecs::Entity;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::math::Transform;
|
||||
use crate::reflect::TypeRegistry;
|
||||
use crate::scene::Scene;
|
||||
|
||||
/// One component a prefab attaches: a registered type **name** plus its value
|
||||
/// serialized as **RON** — the same representation [`TypeRegistry::set_ron`]
|
||||
/// consumes.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ComponentSpec {
|
||||
/// The component's registered name in the [`TypeRegistry`].
|
||||
pub type_name: String,
|
||||
/// The component value as RON.
|
||||
pub ron: String,
|
||||
}
|
||||
|
||||
impl ComponentSpec {
|
||||
/// A spec from a name and an already-serialized RON string.
|
||||
pub fn new(type_name: impl Into<String>, ron: impl Into<String>) -> Self {
|
||||
Self {
|
||||
type_name: type_name.into(),
|
||||
ron: ron.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A spec built by serializing a concrete component `value`. Returns `None`
|
||||
/// if it cannot be serialized to RON.
|
||||
pub fn of<T: Serialize>(type_name: impl Into<String>, value: &T) -> Option<Self> {
|
||||
ron::to_string(value)
|
||||
.ok()
|
||||
.map(|ron| Self::new(type_name, ron))
|
||||
}
|
||||
}
|
||||
|
||||
/// A named spawn template: a node name plus the components to attach beyond the
|
||||
/// node-baked ones.
|
||||
///
|
||||
/// Every spawned entity already carries `Node`, `Transform`, and `Layer`
|
||||
/// (auto-attached by [`Scene::spawn`]); a prefab's [`components`](Self::components)
|
||||
/// are layered on top. A spec named `"Transform"` overrides the identity
|
||||
/// transform `spawn` starts with, so a prefab can place itself.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Prefab {
|
||||
/// The name given to the spawned node (also the registry key).
|
||||
pub name: String,
|
||||
/// Components attached on spawn, applied in order.
|
||||
pub components: Vec<ComponentSpec>,
|
||||
}
|
||||
|
||||
impl Prefab {
|
||||
/// An empty prefab (spawns a bare node with just the node-baked components).
|
||||
pub fn new(name: impl Into<String>) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
components: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds a component spec (builder style).
|
||||
pub fn with(mut self, spec: ComponentSpec) -> Self {
|
||||
self.components.push(spec);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// A registry of prefabs keyed by name — the data-driven source for the
|
||||
/// editor's "add an entity that already carries these components" menu.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PrefabRegistry {
|
||||
prefabs: BTreeMap<String, Prefab>,
|
||||
}
|
||||
|
||||
impl PrefabRegistry {
|
||||
/// An empty registry.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Registers `prefab` under its [`name`](Prefab::name). Re-registering the
|
||||
/// same name replaces the entry.
|
||||
pub fn register(&mut self, prefab: Prefab) {
|
||||
self.prefabs.insert(prefab.name.clone(), prefab);
|
||||
}
|
||||
|
||||
/// The prefab registered under `name`, if any.
|
||||
pub fn get(&self, name: &str) -> Option<&Prefab> {
|
||||
self.prefabs.get(name)
|
||||
}
|
||||
|
||||
/// Whether a prefab is registered under `name`.
|
||||
pub fn contains(&self, name: &str) -> bool {
|
||||
self.prefabs.contains_key(name)
|
||||
}
|
||||
|
||||
/// The registered prefab names, sorted — what an add-menu lists.
|
||||
pub fn names(&self) -> impl Iterator<Item = &str> + '_ {
|
||||
self.prefabs.keys().map(String::as_str)
|
||||
}
|
||||
|
||||
/// The number of registered prefabs.
|
||||
pub fn len(&self) -> usize {
|
||||
self.prefabs.len()
|
||||
}
|
||||
|
||||
/// Whether no prefabs are registered.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.prefabs.is_empty()
|
||||
}
|
||||
|
||||
/// Spawns the named prefab as a **root** entity, applying its component
|
||||
/// specs through `registry`. Returns the new entity, or `None` if `name`
|
||||
/// isn't registered.
|
||||
///
|
||||
/// Application is best-effort: a spec whose type isn't registered or whose
|
||||
/// RON doesn't parse is skipped (the entity is still created with whatever
|
||||
/// applied). Use [`unknown_specs`](Self::unknown_specs) to validate a prefab
|
||||
/// against a registry up front.
|
||||
pub fn spawn(&self, name: &str, scene: &mut Scene, registry: &TypeRegistry) -> Option<Entity> {
|
||||
let prefab = self.prefabs.get(name)?;
|
||||
let entity = scene.spawn(prefab.name.clone(), Transform::IDENTITY);
|
||||
apply(prefab, entity, scene, registry);
|
||||
Some(entity)
|
||||
}
|
||||
|
||||
/// Like [`spawn`](Self::spawn) but parents the new entity under `parent`.
|
||||
pub fn spawn_child(
|
||||
&self,
|
||||
name: &str,
|
||||
parent: Entity,
|
||||
scene: &mut Scene,
|
||||
registry: &TypeRegistry,
|
||||
) -> Option<Entity> {
|
||||
let prefab = self.prefabs.get(name)?;
|
||||
let entity = scene.spawn_child(parent, prefab.name.clone(), Transform::IDENTITY);
|
||||
apply(prefab, entity, scene, registry);
|
||||
Some(entity)
|
||||
}
|
||||
|
||||
/// The type names a prefab references that `registry` doesn't know — empty
|
||||
/// when the prefab will spawn fully. Handy for surfacing authoring typos.
|
||||
pub fn unknown_specs(&self, name: &str, registry: &TypeRegistry) -> Vec<String> {
|
||||
self.prefabs
|
||||
.get(name)
|
||||
.map(|p| {
|
||||
p.components
|
||||
.iter()
|
||||
.filter(|s| !registry.is_registered(&s.type_name))
|
||||
.map(|s| s.type_name.clone())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies a prefab's component specs onto an already-spawned `entity`.
|
||||
fn apply(prefab: &Prefab, entity: Entity, scene: &mut Scene, registry: &TypeRegistry) {
|
||||
for spec in &prefab.components {
|
||||
// Best-effort: an unknown type or malformed RON simply doesn't apply,
|
||||
// leaving the rest of the prefab intact.
|
||||
let _ = registry.set_ron(scene.world_mut(), entity, &spec.type_name, &spec.ron);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::render::{MeshRenderer, PrimitiveShape};
|
||||
use crate::scene::Node;
|
||||
|
||||
fn types() -> TypeRegistry {
|
||||
let mut r = TypeRegistry::new();
|
||||
r.register_reflected::<Transform>("Transform");
|
||||
r.register_reflected::<MeshRenderer>("MeshRenderer");
|
||||
r
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_lists_names_sorted_and_looks_up() {
|
||||
let mut prefabs = PrefabRegistry::new();
|
||||
prefabs.register(Prefab::new("Sphere"));
|
||||
prefabs.register(Prefab::new("Cube"));
|
||||
assert_eq!(prefabs.names().collect::<Vec<_>>(), vec!["Cube", "Sphere"]);
|
||||
assert!(prefabs.contains("Cube"));
|
||||
assert!(prefabs.get("Cube").is_some());
|
||||
assert_eq!(prefabs.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_attaches_specced_components() {
|
||||
let types = types();
|
||||
let mut prefabs = PrefabRegistry::new();
|
||||
let mesh = MeshRenderer {
|
||||
shape: PrimitiveShape::Sphere,
|
||||
..MeshRenderer::default()
|
||||
};
|
||||
prefabs
|
||||
.register(Prefab::new("Ball").with(ComponentSpec::of("MeshRenderer", &mesh).unwrap()));
|
||||
|
||||
let mut scene = Scene::new();
|
||||
let e = prefabs.spawn("Ball", &mut scene, &types).unwrap();
|
||||
|
||||
// Node name comes from the prefab; the spec'd component is attached.
|
||||
assert_eq!(scene.world().get::<&Node>(e).unwrap().name, "Ball");
|
||||
let got = scene.world().get::<&MeshRenderer>(e).unwrap();
|
||||
assert_eq!(got.shape, PrimitiveShape::Sphere);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_child_parents_under_the_target() {
|
||||
let types = types();
|
||||
let mut prefabs = PrefabRegistry::new();
|
||||
prefabs.register(Prefab::new("Child"));
|
||||
|
||||
let mut scene = Scene::new();
|
||||
let parent = scene.spawn("parent", Transform::IDENTITY);
|
||||
let child = prefabs
|
||||
.spawn_child("Child", parent, &mut scene, &types)
|
||||
.unwrap();
|
||||
assert_eq!(scene.parent(child), Some(parent));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transform_spec_overrides_the_identity_spawn() {
|
||||
let types = types();
|
||||
let mut prefabs = PrefabRegistry::new();
|
||||
let placed = Transform::from_translation(crate::math::Vec3::new(1.0, 2.0, 3.0));
|
||||
prefabs
|
||||
.register(Prefab::new("Placed").with(ComponentSpec::of("Transform", &placed).unwrap()));
|
||||
|
||||
let mut scene = Scene::new();
|
||||
let e = prefabs.spawn("Placed", &mut scene, &types).unwrap();
|
||||
let t = scene.local_transform(e).unwrap();
|
||||
assert!((t.translation - crate::math::Vec3::new(1.0, 2.0, 3.0)).length() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_prefab_name_spawns_nothing() {
|
||||
let types = types();
|
||||
let prefabs = PrefabRegistry::new();
|
||||
let mut scene = Scene::new();
|
||||
assert!(prefabs.spawn("Nope", &mut scene, &types).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_specs_are_reported_and_skipped() {
|
||||
let types = types(); // knows Transform + MeshRenderer
|
||||
let mut prefabs = PrefabRegistry::new();
|
||||
prefabs.register(
|
||||
Prefab::new("Mixed")
|
||||
.with(ComponentSpec::of("MeshRenderer", &MeshRenderer::default()).unwrap())
|
||||
.with(ComponentSpec::new("Ghost", "()")),
|
||||
);
|
||||
assert_eq!(prefabs.unknown_specs("Mixed", &types), vec!["Ghost"]);
|
||||
|
||||
// Spawn still succeeds; the known component applies, the ghost is skipped.
|
||||
let mut scene = Scene::new();
|
||||
let e = prefabs.spawn("Mixed", &mut scene, &types).unwrap();
|
||||
assert!(types.has(scene.world(), e, "MeshRenderer").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefab_round_trips_through_ron() {
|
||||
let mut prefabs = PrefabRegistry::new();
|
||||
prefabs.register(
|
||||
Prefab::new("Cube")
|
||||
.with(ComponentSpec::of("MeshRenderer", &MeshRenderer::default()).unwrap()),
|
||||
);
|
||||
let ron = ron::to_string(&prefabs).unwrap();
|
||||
let back: PrefabRegistry = ron::from_str(&ron).unwrap();
|
||||
assert_eq!(prefabs, back);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
//! Projects: the on-disk unit a game is authored as.
|
||||
//!
|
||||
//! A **project** is a root directory containing a project file plus a defined
|
||||
//! folder layout (scenes, assets, scripts). The project file (RON) records the
|
||||
//! project name, the engine version it was made with, the set of enabled
|
||||
//! [modules](crate::app::Module), and per-project settings. The format lives in
|
||||
//! the engine — not the editor — because the exported runtime and the Stage-16
|
||||
//! packer read it too; the editor adds the create/open/save UI on top.
|
||||
//!
|
||||
//! Per-project settings are stored as **opaque per-section RON blobs**
|
||||
//! (`section name → RON`), so this module stays independent of the typed
|
||||
//! settings framework: that framework serializes its typed sections to these
|
||||
//! strings and back.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The project file's name within the project root.
|
||||
pub const PROJECT_FILE_NAME: &str = "project.oxide";
|
||||
|
||||
/// The subdirectory holding scene files.
|
||||
pub const SCENES_DIR: &str = "scenes";
|
||||
/// The subdirectory holding asset files (meshes, textures, audio, …).
|
||||
pub const ASSETS_DIR: &str = "assets";
|
||||
/// The subdirectory holding game scripts.
|
||||
pub const SCRIPTS_DIR: &str = "scripts";
|
||||
|
||||
/// Errors from project operations.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ProjectError {
|
||||
/// A project file already exists where a new project was to be created.
|
||||
#[error("a project already exists at {0}")]
|
||||
AlreadyExists(PathBuf),
|
||||
|
||||
/// No project file was found at the given location.
|
||||
#[error("no project file found at {0}")]
|
||||
NotFound(PathBuf),
|
||||
|
||||
/// Filesystem I/O failed.
|
||||
#[error("project i/o error at {path}: {source}")]
|
||||
Io {
|
||||
/// The path involved.
|
||||
path: PathBuf,
|
||||
/// The underlying error.
|
||||
source: std::io::Error,
|
||||
},
|
||||
|
||||
/// The project file could not be parsed.
|
||||
#[error("malformed project file at {path}: {message}")]
|
||||
Parse {
|
||||
/// The project file path.
|
||||
path: PathBuf,
|
||||
/// The parser message.
|
||||
message: String,
|
||||
},
|
||||
|
||||
/// The project file could not be serialized.
|
||||
#[error("failed to serialize project: {0}")]
|
||||
Serialize(String),
|
||||
}
|
||||
|
||||
/// The serialized contents of a project file.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ProjectMeta {
|
||||
/// Human-readable project name.
|
||||
pub name: String,
|
||||
/// The engine version this project was last saved with.
|
||||
pub engine_version: String,
|
||||
/// Names of the modules enabled for this project.
|
||||
pub enabled_modules: Vec<String>,
|
||||
/// Per-project settings as opaque RON blobs, keyed by section name. The
|
||||
/// typed settings framework round-trips its sections through here.
|
||||
pub settings: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl ProjectMeta {
|
||||
fn new(name: impl Into<String>) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
engine_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
enabled_modules: Vec::new(),
|
||||
settings: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An open project: its root directory plus the loaded [`ProjectMeta`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Project {
|
||||
root: PathBuf,
|
||||
meta: ProjectMeta,
|
||||
}
|
||||
|
||||
impl Project {
|
||||
/// Creates a new project rooted at `root` (created if missing), scaffolding
|
||||
/// the `scenes`/`assets`/`scripts` folders and writing the project file.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AlreadyExists`](ProjectError::AlreadyExists) if a project file is
|
||||
/// already present, or [`Io`](ProjectError::Io) on filesystem failure.
|
||||
pub fn create(root: impl AsRef<Path>, name: impl Into<String>) -> Result<Self, ProjectError> {
|
||||
let root = root.as_ref().to_path_buf();
|
||||
let file = root.join(PROJECT_FILE_NAME);
|
||||
if file.exists() {
|
||||
return Err(ProjectError::AlreadyExists(file));
|
||||
}
|
||||
for dir in [
|
||||
&root,
|
||||
&root.join(SCENES_DIR),
|
||||
&root.join(ASSETS_DIR),
|
||||
&root.join(SCRIPTS_DIR),
|
||||
] {
|
||||
std::fs::create_dir_all(dir).map_err(|source| ProjectError::Io {
|
||||
path: dir.clone(),
|
||||
source,
|
||||
})?;
|
||||
}
|
||||
let project = Self {
|
||||
root,
|
||||
meta: ProjectMeta::new(name),
|
||||
};
|
||||
project.save()?;
|
||||
Ok(project)
|
||||
}
|
||||
|
||||
/// Opens an existing project. `path` may be the project root directory or
|
||||
/// the project file itself.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`NotFound`](ProjectError::NotFound) if no project file is present, or
|
||||
/// [`Parse`](ProjectError::Parse)/[`Io`](ProjectError::Io) on failure.
|
||||
pub fn open(path: impl AsRef<Path>) -> Result<Self, ProjectError> {
|
||||
let path = path.as_ref();
|
||||
let (root, file) = if path.is_dir() {
|
||||
(path.to_path_buf(), path.join(PROJECT_FILE_NAME))
|
||||
} else {
|
||||
let root = path.parent().unwrap_or(Path::new(".")).to_path_buf();
|
||||
(root, path.to_path_buf())
|
||||
};
|
||||
if !file.exists() {
|
||||
return Err(ProjectError::NotFound(file));
|
||||
}
|
||||
let text = std::fs::read_to_string(&file).map_err(|source| ProjectError::Io {
|
||||
path: file.clone(),
|
||||
source,
|
||||
})?;
|
||||
let meta: ProjectMeta = ron::from_str(&text).map_err(|err| ProjectError::Parse {
|
||||
path: file.clone(),
|
||||
message: err.to_string(),
|
||||
})?;
|
||||
Ok(Self { root, meta })
|
||||
}
|
||||
|
||||
/// Writes the project file, stamping it with the current engine version.
|
||||
pub fn save(&self) -> Result<(), ProjectError> {
|
||||
let file = self.project_file_path();
|
||||
let pretty = ron::ser::PrettyConfig::default();
|
||||
let text = ron::ser::to_string_pretty(&self.meta, pretty)
|
||||
.map_err(|err| ProjectError::Serialize(err.to_string()))?;
|
||||
std::fs::write(&file, text).map_err(|source| ProjectError::Io { path: file, source })
|
||||
}
|
||||
|
||||
// --- Layout ------------------------------------------------------------
|
||||
|
||||
/// The project root directory.
|
||||
pub fn root(&self) -> &Path {
|
||||
&self.root
|
||||
}
|
||||
|
||||
/// The path of the project file.
|
||||
pub fn project_file_path(&self) -> PathBuf {
|
||||
self.root.join(PROJECT_FILE_NAME)
|
||||
}
|
||||
|
||||
/// The scenes directory.
|
||||
pub fn scenes_dir(&self) -> PathBuf {
|
||||
self.root.join(SCENES_DIR)
|
||||
}
|
||||
|
||||
/// The assets directory.
|
||||
pub fn assets_dir(&self) -> PathBuf {
|
||||
self.root.join(ASSETS_DIR)
|
||||
}
|
||||
|
||||
/// The scripts directory.
|
||||
pub fn scripts_dir(&self) -> PathBuf {
|
||||
self.root.join(SCRIPTS_DIR)
|
||||
}
|
||||
|
||||
// --- Metadata ----------------------------------------------------------
|
||||
|
||||
/// The project's metadata (name, modules, settings).
|
||||
pub fn meta(&self) -> &ProjectMeta {
|
||||
&self.meta
|
||||
}
|
||||
|
||||
/// The project name.
|
||||
pub fn name(&self) -> &str {
|
||||
&self.meta.name
|
||||
}
|
||||
|
||||
/// Renames the project (call [`save`](Self::save) to persist).
|
||||
pub fn set_name(&mut self, name: impl Into<String>) {
|
||||
self.meta.name = name.into();
|
||||
}
|
||||
|
||||
/// Whether `module` is enabled for this project.
|
||||
pub fn is_module_enabled(&self, module: &str) -> bool {
|
||||
self.meta.enabled_modules.iter().any(|m| m == module)
|
||||
}
|
||||
|
||||
/// Enables `module` (no-op if already enabled).
|
||||
pub fn enable_module(&mut self, module: impl Into<String>) {
|
||||
let module = module.into();
|
||||
if !self.is_module_enabled(&module) {
|
||||
self.meta.enabled_modules.push(module);
|
||||
}
|
||||
}
|
||||
|
||||
/// Disables `module`. Returns whether it was enabled.
|
||||
pub fn disable_module(&mut self, module: &str) -> bool {
|
||||
let before = self.meta.enabled_modules.len();
|
||||
self.meta.enabled_modules.retain(|m| m != module);
|
||||
self.meta.enabled_modules.len() != before
|
||||
}
|
||||
|
||||
/// The raw RON blob stored for settings `section`, if any.
|
||||
pub fn settings_section(&self, section: &str) -> Option<&str> {
|
||||
self.meta.settings.get(section).map(String::as_str)
|
||||
}
|
||||
|
||||
/// Stores a raw RON blob for settings `section`.
|
||||
pub fn set_settings_section(&mut self, section: impl Into<String>, ron: impl Into<String>) {
|
||||
self.meta.settings.insert(section.into(), ron.into());
|
||||
}
|
||||
}
|
||||
|
||||
/// A most-recently-used list of project roots, persisted globally (an editor
|
||||
/// preference, not part of any single project).
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct RecentProjects {
|
||||
entries: Vec<PathBuf>,
|
||||
#[serde(default = "default_limit")]
|
||||
limit: usize,
|
||||
}
|
||||
|
||||
fn default_limit() -> usize {
|
||||
10
|
||||
}
|
||||
|
||||
impl RecentProjects {
|
||||
/// A list retaining at most `limit` entries.
|
||||
pub fn new(limit: usize) -> Self {
|
||||
Self {
|
||||
entries: Vec::new(),
|
||||
limit: limit.max(1),
|
||||
}
|
||||
}
|
||||
|
||||
/// Records `root` as the most recent project, de-duplicating and capping.
|
||||
pub fn record(&mut self, root: impl AsRef<Path>) {
|
||||
let root = root.as_ref().to_path_buf();
|
||||
self.entries.retain(|p| p != &root);
|
||||
self.entries.insert(0, root);
|
||||
self.entries.truncate(self.limit.max(1));
|
||||
}
|
||||
|
||||
/// The recorded roots, most-recent first.
|
||||
pub fn entries(&self) -> &[PathBuf] {
|
||||
&self.entries
|
||||
}
|
||||
|
||||
/// Loads the list from a RON file, or returns an empty list if absent.
|
||||
pub fn load(path: impl AsRef<Path>) -> Self {
|
||||
std::fs::read_to_string(path)
|
||||
.ok()
|
||||
.and_then(|text| ron::from_str(&text).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Saves the list to a RON file.
|
||||
pub fn save(&self, path: impl AsRef<Path>) -> std::io::Result<()> {
|
||||
let text = ron::ser::to_string_pretty(self, ron::ser::PrettyConfig::default())
|
||||
.map_err(|e| std::io::Error::other(e.to_string()))?;
|
||||
std::fs::write(path, text)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn temp_root(tag: &str) -> PathBuf {
|
||||
let mut path = std::env::temp_dir();
|
||||
path.push(format!(
|
||||
"oxide_project_test_{}_{}_{tag}",
|
||||
std::process::id(),
|
||||
// A counter to keep tests isolated within the process.
|
||||
COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst),
|
||||
));
|
||||
path
|
||||
}
|
||||
|
||||
static COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
|
||||
|
||||
#[test]
|
||||
fn create_scaffolds_layout_and_file() {
|
||||
let root = temp_root("create");
|
||||
let project = Project::create(&root, "My Game").unwrap();
|
||||
assert!(project.project_file_path().exists());
|
||||
assert!(project.scenes_dir().is_dir());
|
||||
assert!(project.assets_dir().is_dir());
|
||||
assert!(project.scripts_dir().is_dir());
|
||||
assert_eq!(project.name(), "My Game");
|
||||
std::fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_then_open_round_trips() {
|
||||
let root = temp_root("roundtrip");
|
||||
let mut project = Project::create(&root, "Game").unwrap();
|
||||
project.enable_module("physics");
|
||||
project.enable_module("audio");
|
||||
project.set_settings_section("editor", "(theme:\"dark\")");
|
||||
project.save().unwrap();
|
||||
|
||||
// Open by directory.
|
||||
let opened = Project::open(&root).unwrap();
|
||||
assert_eq!(opened.name(), "Game");
|
||||
assert!(opened.is_module_enabled("physics") && opened.is_module_enabled("audio"));
|
||||
assert_eq!(opened.settings_section("editor"), Some("(theme:\"dark\")"));
|
||||
|
||||
// Open by file path.
|
||||
let by_file = Project::open(opened.project_file_path()).unwrap();
|
||||
assert_eq!(by_file.meta(), opened.meta());
|
||||
std::fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_refuses_to_overwrite() {
|
||||
let root = temp_root("nooverwrite");
|
||||
Project::create(&root, "A").unwrap();
|
||||
let err = Project::create(&root, "B").unwrap_err();
|
||||
assert!(matches!(err, ProjectError::AlreadyExists(_)));
|
||||
std::fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_missing_is_not_found() {
|
||||
let root = temp_root("missing");
|
||||
let err = Project::open(&root).unwrap_err();
|
||||
assert!(matches!(err, ProjectError::NotFound(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn module_enable_disable() {
|
||||
let root = temp_root("modules");
|
||||
let mut project = Project::create(&root, "M").unwrap();
|
||||
project.enable_module("terrain");
|
||||
project.enable_module("terrain"); // idempotent
|
||||
assert_eq!(project.meta().enabled_modules, vec!["terrain"]);
|
||||
assert!(project.disable_module("terrain"));
|
||||
assert!(!project.disable_module("terrain"));
|
||||
assert!(!project.is_module_enabled("terrain"));
|
||||
std::fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_projects_dedup_and_cap() {
|
||||
let mut recent = RecentProjects::new(3);
|
||||
recent.record("/a");
|
||||
recent.record("/b");
|
||||
recent.record("/a"); // moves /a to front, no dup
|
||||
recent.record("/c");
|
||||
recent.record("/d"); // evicts the oldest (/b)
|
||||
let entries: Vec<_> = recent
|
||||
.entries()
|
||||
.iter()
|
||||
.map(|p| p.to_str().unwrap())
|
||||
.collect();
|
||||
assert_eq!(entries, vec!["/d", "/c", "/a"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_projects_persist() {
|
||||
let root = temp_root("recent");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let file = root.join("recent.ron");
|
||||
let mut recent = RecentProjects::new(5);
|
||||
recent.record("/x");
|
||||
recent.record("/y");
|
||||
recent.save(&file).unwrap();
|
||||
let loaded = RecentProjects::load(&file);
|
||||
assert_eq!(loaded.entries(), recent.entries());
|
||||
std::fs::remove_dir_all(root).ok();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,142 @@
|
||||
//! [`Camera`]: perspective projection plus view/projection matrix helpers.
|
||||
//!
|
||||
//! A camera holds only projection parameters; its *position* is a
|
||||
//! [`Transform`] supplied at render time (so a camera can be an entity in the
|
||||
//! scene). The view matrix is the inverse of that world transform.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::layer::{Layer, LayerMask};
|
||||
use crate::math::{Mat4, Transform};
|
||||
|
||||
/// A perspective camera.
|
||||
///
|
||||
/// Stage 4 ships perspective projection only; orthographic and other
|
||||
/// projections can be added later without changing the renderer interface.
|
||||
///
|
||||
/// A `Camera` is also a **reflected, addable component**: place one on an
|
||||
/// entity and it becomes the scene's viewpoint, dual-editable from the editor
|
||||
/// and scripts like any other component. (The runtime gathering of camera
|
||||
/// entities into the render path is wired in a later stage; today the editor
|
||||
/// drives its own viewport camera.)
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, crate::reflect::Reflect)]
|
||||
pub struct Camera {
|
||||
/// Vertical field of view, in radians.
|
||||
pub fov_y: f32,
|
||||
/// Near clip plane distance (> 0).
|
||||
pub z_near: f32,
|
||||
/// Far clip plane distance (> `z_near`).
|
||||
pub z_far: f32,
|
||||
/// The layers this camera renders. An entity is drawn only if its
|
||||
/// [`Layer`](crate::layer::Layer) membership intersects this mask. Defaults
|
||||
/// to [`LayerMask::ALL`] (sees everything) — e.g. a minimap or first-person
|
||||
/// view-model camera narrows it. The host applies it when gathering objects.
|
||||
pub visibility: LayerMask,
|
||||
}
|
||||
|
||||
impl Default for Camera {
|
||||
/// A 60° vertical FOV camera with a 0.1–1000 unit depth range that sees all
|
||||
/// layers.
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
fov_y: 60_f32.to_radians(),
|
||||
z_near: 0.1,
|
||||
z_far: 1000.0,
|
||||
visibility: LayerMask::ALL,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Camera {
|
||||
/// Creates a perspective camera from a vertical FOV (radians) and clip range,
|
||||
/// seeing all layers.
|
||||
pub fn perspective(fov_y: f32, z_near: f32, z_far: f32) -> Self {
|
||||
Self {
|
||||
fov_y,
|
||||
z_near,
|
||||
z_far,
|
||||
visibility: LayerMask::ALL,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the layer-visibility mask (builder style).
|
||||
pub fn with_visibility(mut self, visibility: LayerMask) -> Self {
|
||||
self.visibility = visibility;
|
||||
self
|
||||
}
|
||||
|
||||
/// Whether this camera renders an entity with the given layer membership.
|
||||
pub fn sees(&self, layer: Layer) -> bool {
|
||||
layer.matches(self.visibility)
|
||||
}
|
||||
|
||||
/// The projection matrix for a viewport of the given `aspect` (width /
|
||||
/// height). Uses a reversed-Z-free, `0..1` NDC depth range (wgpu/Vulkan/
|
||||
/// DX/Metal convention).
|
||||
pub fn projection_matrix(&self, aspect: f32) -> Mat4 {
|
||||
Mat4::perspective_rh(
|
||||
self.fov_y,
|
||||
aspect.max(f32::EPSILON),
|
||||
self.z_near,
|
||||
self.z_far,
|
||||
)
|
||||
}
|
||||
|
||||
/// The view matrix for a camera placed at `view_transform` — i.e. the
|
||||
/// inverse of the camera's world transform.
|
||||
pub fn view_matrix(view_transform: &Transform) -> Mat4 {
|
||||
view_transform.to_matrix().inverse()
|
||||
}
|
||||
|
||||
/// The combined view-projection matrix: `projection * view`.
|
||||
pub fn view_projection(&self, aspect: f32, view_transform: &Transform) -> Mat4 {
|
||||
self.projection_matrix(aspect) * Self::view_matrix(view_transform)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::math::Vec3;
|
||||
|
||||
#[test]
|
||||
fn visibility_filters_by_layer() {
|
||||
// Default camera sees every layer.
|
||||
let cam = Camera::default();
|
||||
assert!(cam.sees(Layer::on(7)));
|
||||
|
||||
// A camera restricted to the "UI" layer (3) only sees layer-3 entities.
|
||||
let ui_cam = Camera::default().with_visibility(LayerMask::layer(3));
|
||||
assert!(ui_cam.sees(Layer::on(3)));
|
||||
assert!(!ui_cam.sees(Layer::on(0)));
|
||||
assert!(!ui_cam.sees(Layer::default())); // default layer 0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projection_is_finite_and_depth_mapped() {
|
||||
let cam = Camera::default();
|
||||
let proj = cam.projection_matrix(16.0 / 9.0);
|
||||
assert!(proj.is_finite());
|
||||
// A point on the near plane maps to NDC z ~ 0, the far plane to ~ 1.
|
||||
let near = proj.project_point3(Vec3::new(0.0, 0.0, -cam.z_near));
|
||||
let far = proj.project_point3(Vec3::new(0.0, 0.0, -cam.z_far));
|
||||
assert!(near.z.abs() < 1e-3, "near z = {}", near.z);
|
||||
assert!((far.z - 1.0).abs() < 1e-3, "far z = {}", far.z);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_matrix_moves_world_into_camera_space() {
|
||||
// Camera at +Z looking at the origin: the origin should sit straight
|
||||
// ahead, down the camera's -Z axis.
|
||||
let cam_tf = Transform::looking_at(Vec3::new(0.0, 0.0, 5.0), Vec3::ZERO, Vec3::Y);
|
||||
let view = Camera::view_matrix(&cam_tf);
|
||||
let origin_in_view = view.project_point3(Vec3::ZERO);
|
||||
assert!((origin_in_view.x).abs() < 1e-5);
|
||||
assert!((origin_in_view.y).abs() < 1e-5);
|
||||
assert!(
|
||||
(origin_in_view.z + 5.0).abs() < 1e-4,
|
||||
"z = {}",
|
||||
origin_in_view.z
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
//! Window surface rendering: swapchain configuration, resize, clear loop.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use winit::window::Window;
|
||||
|
||||
use super::{clear_view, Gpu, RenderError};
|
||||
use crate::math::Color;
|
||||
use crate::window::RenderCtx;
|
||||
|
||||
/// Renders to a window surface.
|
||||
///
|
||||
/// Owns the [`Gpu`] plus the window's [`wgpu::Surface`] and its
|
||||
/// configuration. Stage 2 scope: every frame is cleared to
|
||||
/// [`clear_color`](Self::clear_color); draw passes come in later stages.
|
||||
pub struct RenderContext {
|
||||
gpu: Gpu,
|
||||
surface: wgpu::Surface<'static>,
|
||||
config: wgpu::SurfaceConfiguration,
|
||||
clear_color: Color,
|
||||
}
|
||||
|
||||
impl RenderContext {
|
||||
/// Acquires the GPU and configures a surface for `window`.
|
||||
///
|
||||
/// The window is held by `Arc` so the surface (which borrows it) can be
|
||||
/// `'static`, as winit hands out windows from its event loop.
|
||||
///
|
||||
/// To run on any device, several render backends are tried in turn — the
|
||||
/// default (env-selected Vulkan/Metal/DX12), then GL, then a software
|
||||
/// adapter — and the first that produces a *configurable* surface wins.
|
||||
/// This is what lets the engine survive drivers that report a GPU but
|
||||
/// cannot present to the window's surface (e.g. old NVIDIA on Wayland under
|
||||
/// Vulkan, where `surface.configure` would otherwise fail).
|
||||
pub fn new(window: Arc<Window>) -> Result<Self, RenderError> {
|
||||
// (label, backend override, force a software adapter)
|
||||
let attempts: [(&str, Option<wgpu::Backends>, bool); 3] = [
|
||||
("default", None, false),
|
||||
("GL", Some(wgpu::Backends::GL), false),
|
||||
("software", None, true),
|
||||
];
|
||||
|
||||
let mut last_err: Option<RenderError> = None;
|
||||
for (i, &(label, backends, force_fallback)) in attempts.iter().enumerate() {
|
||||
match Self::try_backend(&window, backends, force_fallback) {
|
||||
Ok(ctx) => {
|
||||
if i > 0 {
|
||||
log::warn!("render backend fell back to '{label}'");
|
||||
}
|
||||
return Ok(ctx);
|
||||
}
|
||||
Err(err) => {
|
||||
log::warn!("render backend '{label}' unavailable: {err}");
|
||||
last_err = Some(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(last_err.unwrap_or(RenderError::NoWorkingBackend))
|
||||
}
|
||||
|
||||
/// Attempts one backend: build an instance (optionally forcing `backends`),
|
||||
/// create the surface, acquire an adapter/device (optionally a software
|
||||
/// one), and configure the surface. Any failure returns `Err` so the caller
|
||||
/// can try the next backend rather than aborting the process.
|
||||
fn try_backend(
|
||||
window: &Arc<Window>,
|
||||
backends: Option<wgpu::Backends>,
|
||||
force_fallback_adapter: bool,
|
||||
) -> Result<Self, RenderError> {
|
||||
let size = window.inner_size();
|
||||
// The window doubles as the display handle (needed by GL/X11-style
|
||||
// backends); `from_env` keeps backend/flags overridable via WGPU_*.
|
||||
let mut desc =
|
||||
wgpu::InstanceDescriptor::new_with_display_handle_from_env(Box::new(window.clone()));
|
||||
if let Some(backends) = backends {
|
||||
desc.backends = backends;
|
||||
}
|
||||
let instance = wgpu::Instance::new(desc);
|
||||
let surface = instance.create_surface(window.clone())?;
|
||||
let gpu = Gpu::with_instance(instance, Some(&surface), force_fallback_adapter)?;
|
||||
|
||||
let config = surface
|
||||
.get_default_config(gpu.adapter(), size.width.max(1), size.height.max(1))
|
||||
.ok_or(RenderError::UnsupportedSurface)?;
|
||||
configure_surface(gpu.device(), &surface, &config)?;
|
||||
log::info!(
|
||||
"surface configured: {}x{} {:?} ({:?}) on {:?}",
|
||||
config.width,
|
||||
config.height,
|
||||
config.format,
|
||||
config.present_mode,
|
||||
gpu.adapter().get_info().backend,
|
||||
);
|
||||
|
||||
Ok(Self {
|
||||
gpu,
|
||||
surface,
|
||||
config,
|
||||
clear_color: Color::BLACK,
|
||||
})
|
||||
}
|
||||
|
||||
/// Reconfigures the surface for a new window size. Zero dimensions
|
||||
/// (minimized window) are clamped to 1 so the surface stays valid.
|
||||
pub fn resize(&mut self, width: u32, height: u32) {
|
||||
self.config.width = width.max(1);
|
||||
self.config.height = height.max(1);
|
||||
self.surface.configure(self.gpu.device(), &self.config);
|
||||
}
|
||||
|
||||
/// Current surface size in physical pixels.
|
||||
pub fn size(&self) -> (u32, u32) {
|
||||
(self.config.width, self.config.height)
|
||||
}
|
||||
|
||||
/// The surface's texture format. Apps need this to build render pipelines
|
||||
/// (or UI integrations) whose output matches the surface.
|
||||
pub fn surface_format(&self) -> wgpu::TextureFormat {
|
||||
self.config.format
|
||||
}
|
||||
|
||||
/// The color the surface is cleared to each frame.
|
||||
pub fn clear_color(&self) -> Color {
|
||||
self.clear_color
|
||||
}
|
||||
|
||||
/// Sets the clear color; takes effect on the next rendered frame.
|
||||
pub fn set_clear_color(&mut self, color: Color) {
|
||||
self.clear_color = color;
|
||||
}
|
||||
|
||||
/// Renders one frame: acquires the next surface texture, clears it, and
|
||||
/// presents. Equivalent to [`render_frame_with`](Self::render_frame_with)
|
||||
/// with an empty draw hook.
|
||||
pub fn render_frame(&mut self, window: &Window) -> Result<(), RenderError> {
|
||||
self.render_frame_with(window, |_| {})
|
||||
}
|
||||
|
||||
/// Renders one frame, invoking `draw` after the clear and before present.
|
||||
///
|
||||
/// The surface texture is acquired and cleared to
|
||||
/// [`clear_color`](Self::clear_color), then `draw` is handed a
|
||||
/// [`RenderCtx`] so it can record additional passes into the same view
|
||||
/// (use `LoadOp::Load` to preserve the clear), and finally the frame is
|
||||
/// presented.
|
||||
///
|
||||
/// Lost or outdated surfaces (e.g. mid-resize) are reconfigured and the
|
||||
/// frame skipped; timed-out or occluded acquires skip the frame. All are
|
||||
/// normal transient conditions and not reported as errors.
|
||||
pub fn render_frame_with(
|
||||
&mut self,
|
||||
window: &Window,
|
||||
draw: impl FnOnce(&RenderCtx<'_>),
|
||||
) -> Result<(), RenderError> {
|
||||
use wgpu::CurrentSurfaceTexture;
|
||||
let frame = match self.surface.get_current_texture() {
|
||||
// A suboptimal frame is still presentable; the next resize event
|
||||
// reconfigures the surface anyway.
|
||||
CurrentSurfaceTexture::Success(frame) | CurrentSurfaceTexture::Suboptimal(frame) => {
|
||||
frame
|
||||
}
|
||||
CurrentSurfaceTexture::Lost | CurrentSurfaceTexture::Outdated => {
|
||||
self.surface.configure(self.gpu.device(), &self.config);
|
||||
return Ok(());
|
||||
}
|
||||
CurrentSurfaceTexture::Timeout | CurrentSurfaceTexture::Occluded => return Ok(()),
|
||||
CurrentSurfaceTexture::Validation => return Err(RenderError::SurfaceValidation),
|
||||
};
|
||||
let view = frame
|
||||
.texture
|
||||
.create_view(&wgpu::TextureViewDescriptor::default());
|
||||
clear_view(self.gpu.device(), self.gpu.queue(), &view, self.clear_color);
|
||||
|
||||
let ctx = RenderCtx {
|
||||
gpu: &self.gpu,
|
||||
view: &view,
|
||||
window,
|
||||
surface_format: self.config.format,
|
||||
size: (self.config.width, self.config.height),
|
||||
};
|
||||
draw(&ctx);
|
||||
|
||||
frame.present();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The underlying GPU handle.
|
||||
pub fn gpu(&self) -> &Gpu {
|
||||
&self.gpu
|
||||
}
|
||||
}
|
||||
|
||||
/// Configures `surface`, capturing any validation error instead of letting it
|
||||
/// reach wgpu's default (fatal, process-aborting) error handler.
|
||||
///
|
||||
/// `surface.configure` returns `()` and reports failures through the device's
|
||||
/// error sink, which by default panics. Wrapping it in a validation error scope
|
||||
/// turns "Invalid surface" (and similar) into a recoverable [`Result`] so the
|
||||
/// caller can fall back to another backend.
|
||||
fn configure_surface(
|
||||
device: &wgpu::Device,
|
||||
surface: &wgpu::Surface<'static>,
|
||||
config: &wgpu::SurfaceConfiguration,
|
||||
) -> Result<(), RenderError> {
|
||||
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
surface.configure(device, config);
|
||||
// `pop()` consumes the guard and yields any captured error. On native
|
||||
// backends the future is already resolved; `block_on` just unwraps it.
|
||||
if let Some(err) = pollster::block_on(scope.pop()) {
|
||||
return Err(RenderError::SurfaceConfigure(err.to_string()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
//! [`ForwardRenderer`]: a single-pass forward renderer with a depth buffer and
|
||||
//! one directional light.
|
||||
//!
|
||||
//! Stage 4 scope: draw a list of [`RenderObject`]s (each a [`GpuMesh`] +
|
||||
//! [`Material`] + [`Transform`]) through the lit shader, into a caller-provided
|
||||
//! color target, using an owned depth texture. Shadows, multiple lights, and
|
||||
//! post-processing arrive in later stages.
|
||||
|
||||
use std::num::NonZeroU64;
|
||||
|
||||
use bytemuck::{Pod, Zeroable};
|
||||
use glam::Mat3;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::mesh::{GpuMesh, Vertex};
|
||||
use super::{Camera, Material};
|
||||
use crate::math::{Color, Transform, Vec3, Vec4};
|
||||
|
||||
/// Depth buffer format used by the forward pass.
|
||||
pub const DEPTH_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth32Float;
|
||||
|
||||
/// A directional light: parallel rays with a travel `direction`.
|
||||
///
|
||||
/// Also a **reflected, addable component**: drop one on an entity to author a
|
||||
/// sun/key light in the scene, dual-editable from the editor and scripts.
|
||||
/// (Gathering light entities into the forward pass is a later-stage wiring; the
|
||||
/// renderer currently takes its [`Lighting`] directly.)
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, crate::reflect::Reflect)]
|
||||
pub struct DirectionalLight {
|
||||
/// The direction the light travels (does not need to be normalized).
|
||||
pub direction: Vec3,
|
||||
/// Light color.
|
||||
pub color: Color,
|
||||
/// Scalar intensity multiplier.
|
||||
pub intensity: f32,
|
||||
}
|
||||
|
||||
impl Default for DirectionalLight {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
direction: Vec3::new(-0.5, -1.0, -0.35),
|
||||
color: Color::WHITE,
|
||||
intensity: 1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Scene lighting for a forward pass: one directional light plus an ambient term.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Lighting {
|
||||
/// The single directional (sun) light.
|
||||
pub light: DirectionalLight,
|
||||
/// Flat ambient color added everywhere (cheap fill light).
|
||||
pub ambient: Color,
|
||||
}
|
||||
|
||||
impl Default for Lighting {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
light: DirectionalLight::default(),
|
||||
ambient: Color::rgb(0.08, 0.08, 0.10),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One drawable: a GPU mesh placed by `transform` and shaded with `material`.
|
||||
pub struct RenderObject<'a> {
|
||||
/// The mesh to draw.
|
||||
pub mesh: &'a GpuMesh,
|
||||
/// Its surface material.
|
||||
pub material: Material,
|
||||
/// World placement.
|
||||
pub transform: Transform,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Pod, Zeroable)]
|
||||
struct GlobalsUniform {
|
||||
view_proj: [[f32; 4]; 4],
|
||||
camera_pos: [f32; 4],
|
||||
light_dir: [f32; 4],
|
||||
light_color: [f32; 4],
|
||||
ambient: [f32; 4],
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Pod, Zeroable)]
|
||||
struct ObjectUniform {
|
||||
model: [[f32; 4]; 4],
|
||||
normal_mtx: [[f32; 4]; 4],
|
||||
albedo: [f32; 4],
|
||||
mr: [f32; 4],
|
||||
}
|
||||
|
||||
/// A forward renderer owning its pipeline, depth buffer, and uniform storage.
|
||||
pub struct ForwardRenderer {
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
globals_buffer: wgpu::Buffer,
|
||||
globals_bind_group: wgpu::BindGroup,
|
||||
object_layout: wgpu::BindGroupLayout,
|
||||
object_buffer: wgpu::Buffer,
|
||||
object_bind_group: wgpu::BindGroup,
|
||||
/// Per-object stride: `size_of::<ObjectUniform>` rounded up to the device's
|
||||
/// minimum dynamic-uniform-buffer offset alignment.
|
||||
object_stride: u64,
|
||||
object_capacity: u32,
|
||||
depth: Option<DepthTarget>,
|
||||
color_format: wgpu::TextureFormat,
|
||||
}
|
||||
|
||||
struct DepthTarget {
|
||||
view: wgpu::TextureView,
|
||||
width: u32,
|
||||
height: u32,
|
||||
}
|
||||
|
||||
impl ForwardRenderer {
|
||||
/// Builds the renderer for a given color target format (e.g. the surface
|
||||
/// format for a window, or `Rgba8Unorm` for offscreen rendering).
|
||||
pub fn new(device: &wgpu::Device, color_format: wgpu::TextureFormat) -> Self {
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("oxide.forward.lit"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("shaders/lit.wgsl").into()),
|
||||
});
|
||||
|
||||
let globals_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("oxide.forward.globals_layout"),
|
||||
entries: &[wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: NonZeroU64::new(std::mem::size_of::<GlobalsUniform>() as u64),
|
||||
},
|
||||
count: None,
|
||||
}],
|
||||
});
|
||||
|
||||
let object_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("oxide.forward.object_layout"),
|
||||
entries: &[wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: true,
|
||||
min_binding_size: NonZeroU64::new(std::mem::size_of::<ObjectUniform>() as u64),
|
||||
},
|
||||
count: None,
|
||||
}],
|
||||
});
|
||||
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("oxide.forward.pipeline_layout"),
|
||||
bind_group_layouts: &[Some(&globals_layout), Some(&object_layout)],
|
||||
immediate_size: 0,
|
||||
});
|
||||
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("oxide.forward.pipeline"),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: Some("vs_main"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[Vertex::LAYOUT],
|
||||
},
|
||||
primitive: wgpu::PrimitiveState {
|
||||
topology: wgpu::PrimitiveTopology::TriangleList,
|
||||
strip_index_format: None,
|
||||
front_face: wgpu::FrontFace::Ccw,
|
||||
cull_mode: Some(wgpu::Face::Back),
|
||||
unclipped_depth: false,
|
||||
polygon_mode: wgpu::PolygonMode::Fill,
|
||||
conservative: false,
|
||||
},
|
||||
depth_stencil: Some(wgpu::DepthStencilState {
|
||||
format: DEPTH_FORMAT,
|
||||
depth_write_enabled: Some(true),
|
||||
depth_compare: Some(wgpu::CompareFunction::Less),
|
||||
stencil: wgpu::StencilState::default(),
|
||||
bias: wgpu::DepthBiasState::default(),
|
||||
}),
|
||||
multisample: wgpu::MultisampleState::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some("fs_main"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: color_format,
|
||||
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
|
||||
let globals_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("oxide.forward.globals"),
|
||||
size: std::mem::size_of::<GlobalsUniform>() as u64,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let globals_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("oxide.forward.globals_bg"),
|
||||
layout: &globals_layout,
|
||||
entries: &[wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: globals_buffer.as_entire_binding(),
|
||||
}],
|
||||
});
|
||||
|
||||
let object_stride = align_up(
|
||||
std::mem::size_of::<ObjectUniform>() as u64,
|
||||
device.limits().min_uniform_buffer_offset_alignment as u64,
|
||||
);
|
||||
let object_capacity = 16;
|
||||
let (object_buffer, object_bind_group) =
|
||||
create_object_storage(device, &object_layout, object_stride, object_capacity);
|
||||
|
||||
Self {
|
||||
pipeline,
|
||||
globals_buffer,
|
||||
globals_bind_group,
|
||||
object_layout,
|
||||
object_buffer,
|
||||
object_bind_group,
|
||||
object_stride,
|
||||
object_capacity,
|
||||
depth: None,
|
||||
color_format,
|
||||
}
|
||||
}
|
||||
|
||||
/// The color target format this renderer was built for.
|
||||
pub fn color_format(&self) -> wgpu::TextureFormat {
|
||||
self.color_format
|
||||
}
|
||||
|
||||
/// Renders `objects` into `target` (whose full physical size is
|
||||
/// `width`×`height`) as seen by `camera` placed at `view_transform`, lit
|
||||
/// by `lighting`. Drawing is restricted to `viewport_rect` (a sub-
|
||||
/// rectangle of the target), and the projection uses that rect's aspect
|
||||
/// ratio.
|
||||
///
|
||||
/// The color target is *loaded* (not cleared) so a clear pass run before
|
||||
/// this — e.g. the window's clear color — shows through as the background;
|
||||
/// the depth buffer is cleared to 1.0 each call.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn render(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
target: &wgpu::TextureView,
|
||||
(width, height): (u32, u32),
|
||||
viewport_rect: crate::math::Rect,
|
||||
camera: &Camera,
|
||||
view_transform: &Transform,
|
||||
lighting: &Lighting,
|
||||
objects: &[RenderObject<'_>],
|
||||
) {
|
||||
let (width, height) = (width.max(1), height.max(1));
|
||||
// Clamp the viewport rect to the target so wgpu doesn't complain.
|
||||
let vp_w = viewport_rect.width().max(1.0).min(width as f32);
|
||||
let vp_h = viewport_rect.height().max(1.0).min(height as f32);
|
||||
let vp_x = viewport_rect.min.x.max(0.0).min(width as f32 - vp_w);
|
||||
let vp_y = viewport_rect.min.y.max(0.0).min(height as f32 - vp_h);
|
||||
|
||||
// Depth must match the full color target's dimensions (the
|
||||
// attachment binding requires that). Pixels outside `set_viewport`
|
||||
// are never written, so the extra depth is wasted memory but never
|
||||
// incorrect.
|
||||
self.ensure_depth(device, width, height);
|
||||
self.ensure_object_capacity(device, objects.len() as u32);
|
||||
|
||||
// Globals — aspect comes from the viewport rect, not the target.
|
||||
let aspect = vp_w / vp_h;
|
||||
let view_proj = camera.view_projection(aspect, view_transform);
|
||||
let to_light = (-lighting.light.direction).normalize_or_zero();
|
||||
let lc = lighting.light.color;
|
||||
let amb = lighting.ambient;
|
||||
let globals = GlobalsUniform {
|
||||
view_proj: view_proj.to_cols_array_2d(),
|
||||
camera_pos: view_transform.translation.extend(1.0).to_array(),
|
||||
light_dir: to_light.extend(0.0).to_array(),
|
||||
light_color: (Vec4::new(lc.r, lc.g, lc.b, 1.0) * lighting.light.intensity).to_array(),
|
||||
ambient: Vec4::new(amb.r, amb.g, amb.b, 1.0).to_array(),
|
||||
};
|
||||
queue.write_buffer(&self.globals_buffer, 0, bytemuck::bytes_of(&globals));
|
||||
|
||||
// Per-object uniforms.
|
||||
for (i, obj) in objects.iter().enumerate() {
|
||||
let model = obj.transform.to_matrix();
|
||||
let normal_mtx = Mat3::from_mat4(model).inverse().transpose();
|
||||
let normal_mtx4 = [
|
||||
normal_mtx.x_axis.extend(0.0).to_array(),
|
||||
normal_mtx.y_axis.extend(0.0).to_array(),
|
||||
normal_mtx.z_axis.extend(0.0).to_array(),
|
||||
[0.0, 0.0, 0.0, 1.0],
|
||||
];
|
||||
let a = obj.material.albedo;
|
||||
let uniform = ObjectUniform {
|
||||
model: model.to_cols_array_2d(),
|
||||
normal_mtx: normal_mtx4,
|
||||
albedo: [a.r, a.g, a.b, a.a],
|
||||
mr: [obj.material.metallic, obj.material.roughness, 0.0, 0.0],
|
||||
};
|
||||
queue.write_buffer(
|
||||
&self.object_buffer,
|
||||
i as u64 * self.object_stride,
|
||||
bytemuck::bytes_of(&uniform),
|
||||
);
|
||||
}
|
||||
|
||||
let depth_view = &self.depth.as_ref().expect("depth ensured above").view;
|
||||
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
||||
label: Some("oxide.forward.encoder"),
|
||||
});
|
||||
{
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("oxide.forward.pass"),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: target,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Load,
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: depth_view,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(1.0),
|
||||
store: wgpu::StoreOp::Store,
|
||||
}),
|
||||
stencil_ops: None,
|
||||
}),
|
||||
timestamp_writes: None,
|
||||
occlusion_query_set: None,
|
||||
multiview_mask: None,
|
||||
});
|
||||
// Restrict drawing to the host's viewport sub-rect. Pixels
|
||||
// outside this rectangle keep whatever the prior pass (e.g.
|
||||
// ClearPass or the window clear) wrote there.
|
||||
pass.set_viewport(vp_x, vp_y, vp_w, vp_h, 0.0, 1.0);
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
pass.set_bind_group(0, &self.globals_bind_group, &[]);
|
||||
for (i, obj) in objects.iter().enumerate() {
|
||||
let offset = (i as u64 * self.object_stride) as u32;
|
||||
pass.set_bind_group(1, &self.object_bind_group, &[offset]);
|
||||
pass.set_vertex_buffer(0, obj.mesh.vertex_buffer.slice(..));
|
||||
pass.set_index_buffer(obj.mesh.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
|
||||
pass.draw_indexed(0..obj.mesh.index_count, 0, 0..1);
|
||||
}
|
||||
}
|
||||
queue.submit([encoder.finish()]);
|
||||
}
|
||||
|
||||
fn ensure_depth(&mut self, device: &wgpu::Device, width: u32, height: u32) {
|
||||
let stale = match &self.depth {
|
||||
Some(d) => d.width != width || d.height != height,
|
||||
None => true,
|
||||
};
|
||||
if stale {
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("oxide.forward.depth"),
|
||||
size: wgpu::Extent3d {
|
||||
width,
|
||||
height,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: DEPTH_FORMAT,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||
view_formats: &[],
|
||||
});
|
||||
self.depth = Some(DepthTarget {
|
||||
view: texture.create_view(&wgpu::TextureViewDescriptor::default()),
|
||||
width,
|
||||
height,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_object_capacity(&mut self, device: &wgpu::Device, needed: u32) {
|
||||
if needed > self.object_capacity {
|
||||
let capacity = needed.next_power_of_two();
|
||||
let (buffer, bind_group) =
|
||||
create_object_storage(device, &self.object_layout, self.object_stride, capacity);
|
||||
self.object_buffer = buffer;
|
||||
self.object_bind_group = bind_group;
|
||||
self.object_capacity = capacity;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Allocates the per-object uniform buffer (`capacity` slots of `stride` bytes)
|
||||
/// and a dynamic-offset bind group over it.
|
||||
fn create_object_storage(
|
||||
device: &wgpu::Device,
|
||||
layout: &wgpu::BindGroupLayout,
|
||||
stride: u64,
|
||||
capacity: u32,
|
||||
) -> (wgpu::Buffer, wgpu::BindGroup) {
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("oxide.forward.objects"),
|
||||
size: stride * capacity as u64,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("oxide.forward.object_bg"),
|
||||
layout,
|
||||
entries: &[wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
|
||||
buffer: &buffer,
|
||||
offset: 0,
|
||||
size: NonZeroU64::new(std::mem::size_of::<ObjectUniform>() as u64),
|
||||
}),
|
||||
}],
|
||||
});
|
||||
(buffer, bind_group)
|
||||
}
|
||||
|
||||
/// Rounds `value` up to the next multiple of `align` (a power of two).
|
||||
fn align_up(value: u64, align: u64) -> u64 {
|
||||
let align = align.max(1);
|
||||
value.div_ceil(align) * align
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
//! GPU acquisition: instance, adapter, device, queue.
|
||||
|
||||
use super::RenderError;
|
||||
|
||||
/// A handle to the GPU: instance, adapter, and the device/queue pair every
|
||||
/// rendering operation goes through.
|
||||
///
|
||||
/// Created either for a window surface (via [`RenderContext`]) or headless
|
||||
/// with [`Gpu::headless`] for offscreen rendering and tests.
|
||||
///
|
||||
/// [`RenderContext`]: super::RenderContext
|
||||
pub struct Gpu {
|
||||
instance: wgpu::Instance,
|
||||
adapter: wgpu::Adapter,
|
||||
device: wgpu::Device,
|
||||
queue: wgpu::Queue,
|
||||
}
|
||||
|
||||
impl Gpu {
|
||||
/// Acquires an adapter and device from an existing `instance`, preferring
|
||||
/// an adapter that can present to `compatible_surface` when one is given.
|
||||
///
|
||||
/// `force_fallback_adapter` requests a software adapter (e.g. llvmpipe),
|
||||
/// used as a last resort when no hardware adapter works.
|
||||
pub(crate) fn with_instance(
|
||||
instance: wgpu::Instance,
|
||||
compatible_surface: Option<&wgpu::Surface<'_>>,
|
||||
force_fallback_adapter: bool,
|
||||
) -> Result<Self, RenderError> {
|
||||
let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
|
||||
power_preference: wgpu::PowerPreference::HighPerformance,
|
||||
force_fallback_adapter,
|
||||
compatible_surface,
|
||||
}))?;
|
||||
log::info!(
|
||||
"GPU adapter: {} ({:?})",
|
||||
adapter.get_info().name,
|
||||
adapter.get_info().backend
|
||||
);
|
||||
let (device, queue) =
|
||||
pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
|
||||
label: Some("oxide.device"),
|
||||
..Default::default()
|
||||
}))?;
|
||||
Ok(Self {
|
||||
instance,
|
||||
adapter,
|
||||
device,
|
||||
queue,
|
||||
})
|
||||
}
|
||||
|
||||
/// Acquires the GPU without any surface, for offscreen rendering and
|
||||
/// automated tests.
|
||||
///
|
||||
/// Tries a hardware adapter first, then falls back to a software adapter
|
||||
/// (e.g. llvmpipe) so headless rendering also works on machines without a
|
||||
/// usable GPU.
|
||||
pub fn headless() -> Result<Self, RenderError> {
|
||||
// `from_env` keeps backend/flags overridable via WGPU_* env vars.
|
||||
let instance =
|
||||
wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle_from_env());
|
||||
match Self::with_instance(instance, None, false) {
|
||||
Ok(gpu) => Ok(gpu),
|
||||
Err(hardware_err) => {
|
||||
log::warn!("no hardware GPU adapter ({hardware_err}); trying software fallback");
|
||||
let instance = wgpu::Instance::new(
|
||||
wgpu::InstanceDescriptor::new_without_display_handle_from_env(),
|
||||
);
|
||||
Self::with_instance(instance, None, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The wgpu instance the adapter was created from.
|
||||
pub fn instance(&self) -> &wgpu::Instance {
|
||||
&self.instance
|
||||
}
|
||||
|
||||
/// The physical adapter in use.
|
||||
pub fn adapter(&self) -> &wgpu::Adapter {
|
||||
&self.adapter
|
||||
}
|
||||
|
||||
/// The logical device used to create GPU resources.
|
||||
pub fn device(&self) -> &wgpu::Device {
|
||||
&self.device
|
||||
}
|
||||
|
||||
/// The queue used to submit command buffers.
|
||||
pub fn queue(&self) -> &wgpu::Queue {
|
||||
&self.queue
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//! [`Material`]: a PBR-lite surface description.
|
||||
//!
|
||||
//! Stage 4 keeps materials to the parameters the basic lit pass consumes:
|
||||
//! an albedo (base) color plus metallic/roughness factors. Textures, emissive,
|
||||
//! and the full PBR set arrive with the shader system in a later stage.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::math::Color;
|
||||
|
||||
/// A PBR-lite material: base color and metallic/roughness factors.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Material {
|
||||
/// Base (albedo) color, linear RGBA.
|
||||
pub albedo: Color,
|
||||
/// Metalness in `[0, 1]` (0 = dielectric, 1 = metal).
|
||||
pub metallic: f32,
|
||||
/// Perceptual roughness in `[0, 1]` (0 = mirror, 1 = fully rough).
|
||||
pub roughness: f32,
|
||||
}
|
||||
|
||||
impl Default for Material {
|
||||
/// A neutral mid-gray dielectric.
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
albedo: Color::rgb(0.8, 0.8, 0.8),
|
||||
metallic: 0.0,
|
||||
roughness: 0.6,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Material {
|
||||
/// A matte, non-metallic material of the given color.
|
||||
pub fn diffuse(albedo: Color) -> Self {
|
||||
Self {
|
||||
albedo,
|
||||
metallic: 0.0,
|
||||
roughness: 0.9,
|
||||
}
|
||||
}
|
||||
|
||||
/// A metallic material of the given color and roughness.
|
||||
pub fn metal(albedo: Color, roughness: f32) -> Self {
|
||||
Self {
|
||||
albedo,
|
||||
metallic: 1.0,
|
||||
roughness: roughness.clamp(0.0, 1.0),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
//! Mesh data: CPU-side [`Mesh`] geometry, its GPU upload ([`GpuMesh`]), and
|
||||
//! built-in primitive builders.
|
||||
//!
|
||||
//! A [`Vertex`] carries position, normal, and UV — the minimal set the Stage 4
|
||||
//! forward renderer needs for lit, textured-ready geometry. Meshes are built on
|
||||
//! the CPU (procedurally or, later, from a GLTF import) and uploaded once into a
|
||||
//! [`GpuMesh`] for drawing.
|
||||
|
||||
use bytemuck::{Pod, Zeroable};
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
use crate::math::{Aabb, Vec2, Vec3};
|
||||
|
||||
/// A single mesh vertex: position, normal, and texture coordinate.
|
||||
///
|
||||
/// `repr(C)` + [`Pod`] so a `&[Vertex]` can be uploaded straight into a GPU
|
||||
/// vertex buffer with no per-field marshalling.
|
||||
#[repr(C)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Pod, Zeroable)]
|
||||
pub struct Vertex {
|
||||
/// Object-space position.
|
||||
pub position: [f32; 3],
|
||||
/// Object-space normal (expected unit length for correct lighting).
|
||||
pub normal: [f32; 3],
|
||||
/// Texture coordinate.
|
||||
pub uv: [f32; 2],
|
||||
}
|
||||
|
||||
impl Vertex {
|
||||
/// Builds a vertex from math types.
|
||||
pub fn new(position: Vec3, normal: Vec3, uv: Vec2) -> Self {
|
||||
Self {
|
||||
position: position.to_array(),
|
||||
normal: normal.to_array(),
|
||||
uv: uv.to_array(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The `wgpu` vertex buffer layout matching this struct's fields
|
||||
/// (`@location(0)` position, `@location(1)` normal, `@location(2)` uv).
|
||||
pub const LAYOUT: wgpu::VertexBufferLayout<'static> = wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<Vertex>() as wgpu::BufferAddress,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &wgpu::vertex_attr_array![
|
||||
0 => Float32x3, // position
|
||||
1 => Float32x3, // normal
|
||||
2 => Float32x2, // uv
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/// CPU-side mesh geometry: an indexed triangle list.
|
||||
///
|
||||
/// Indices are `u32` (32-bit), so meshes are not limited to 65k vertices.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Mesh {
|
||||
/// Vertex data.
|
||||
pub vertices: Vec<Vertex>,
|
||||
/// Triangle indices into [`vertices`](Self::vertices), three per triangle.
|
||||
pub indices: Vec<u32>,
|
||||
}
|
||||
|
||||
impl Mesh {
|
||||
/// Creates a mesh from raw vertex and index data.
|
||||
pub fn new(vertices: Vec<Vertex>, indices: Vec<u32>) -> Self {
|
||||
Self { vertices, indices }
|
||||
}
|
||||
|
||||
/// Number of triangles (index count / 3).
|
||||
pub fn triangle_count(&self) -> usize {
|
||||
self.indices.len() / 3
|
||||
}
|
||||
|
||||
/// The axis-aligned bounds of the mesh in object space
|
||||
/// ([`Aabb::EMPTY`](crate::math::Aabb) for an empty mesh).
|
||||
pub fn bounds(&self) -> Aabb {
|
||||
Aabb::from_points(self.vertices.iter().map(|v| Vec3::from_array(v.position)))
|
||||
}
|
||||
|
||||
/// Uploads the mesh into GPU vertex/index buffers for drawing.
|
||||
pub fn upload(&self, device: &wgpu::Device, label: &str) -> GpuMesh {
|
||||
let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some(&format!("{label}.vertices")),
|
||||
contents: bytemuck::cast_slice(&self.vertices),
|
||||
usage: wgpu::BufferUsages::VERTEX,
|
||||
});
|
||||
let index_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some(&format!("{label}.indices")),
|
||||
contents: bytemuck::cast_slice(&self.indices),
|
||||
usage: wgpu::BufferUsages::INDEX,
|
||||
});
|
||||
GpuMesh {
|
||||
vertex_buffer,
|
||||
index_buffer,
|
||||
index_count: self.indices.len() as u32,
|
||||
}
|
||||
}
|
||||
|
||||
/// A unit cube centered at the origin (side length 1), with per-face normals
|
||||
/// and UVs (so each face is flat-shaded correctly).
|
||||
pub fn cube() -> Self {
|
||||
Self::box_mesh(Vec3::splat(1.0))
|
||||
}
|
||||
|
||||
/// An axis-aligned box of the given `size` (full extents), centered at the
|
||||
/// origin, with per-face normals and UVs.
|
||||
pub fn box_mesh(size: Vec3) -> Self {
|
||||
let h = size * 0.5;
|
||||
// (normal, then the four corners CCW seen from outside)
|
||||
let faces: [(Vec3, [Vec3; 4]); 6] = [
|
||||
// +X
|
||||
(
|
||||
Vec3::X,
|
||||
[
|
||||
Vec3::new(h.x, -h.y, h.z),
|
||||
Vec3::new(h.x, -h.y, -h.z),
|
||||
Vec3::new(h.x, h.y, -h.z),
|
||||
Vec3::new(h.x, h.y, h.z),
|
||||
],
|
||||
),
|
||||
// -X
|
||||
(
|
||||
Vec3::NEG_X,
|
||||
[
|
||||
Vec3::new(-h.x, -h.y, -h.z),
|
||||
Vec3::new(-h.x, -h.y, h.z),
|
||||
Vec3::new(-h.x, h.y, h.z),
|
||||
Vec3::new(-h.x, h.y, -h.z),
|
||||
],
|
||||
),
|
||||
// +Y
|
||||
(
|
||||
Vec3::Y,
|
||||
[
|
||||
Vec3::new(-h.x, h.y, h.z),
|
||||
Vec3::new(h.x, h.y, h.z),
|
||||
Vec3::new(h.x, h.y, -h.z),
|
||||
Vec3::new(-h.x, h.y, -h.z),
|
||||
],
|
||||
),
|
||||
// -Y
|
||||
(
|
||||
Vec3::NEG_Y,
|
||||
[
|
||||
Vec3::new(-h.x, -h.y, -h.z),
|
||||
Vec3::new(h.x, -h.y, -h.z),
|
||||
Vec3::new(h.x, -h.y, h.z),
|
||||
Vec3::new(-h.x, -h.y, h.z),
|
||||
],
|
||||
),
|
||||
// +Z
|
||||
(
|
||||
Vec3::Z,
|
||||
[
|
||||
Vec3::new(-h.x, -h.y, h.z),
|
||||
Vec3::new(h.x, -h.y, h.z),
|
||||
Vec3::new(h.x, h.y, h.z),
|
||||
Vec3::new(-h.x, h.y, h.z),
|
||||
],
|
||||
),
|
||||
// -Z
|
||||
(
|
||||
Vec3::NEG_Z,
|
||||
[
|
||||
Vec3::new(h.x, -h.y, -h.z),
|
||||
Vec3::new(-h.x, -h.y, -h.z),
|
||||
Vec3::new(-h.x, h.y, -h.z),
|
||||
Vec3::new(h.x, h.y, -h.z),
|
||||
],
|
||||
),
|
||||
];
|
||||
let uvs = [
|
||||
Vec2::new(0.0, 1.0),
|
||||
Vec2::new(1.0, 1.0),
|
||||
Vec2::new(1.0, 0.0),
|
||||
Vec2::new(0.0, 0.0),
|
||||
];
|
||||
let mut vertices = Vec::with_capacity(24);
|
||||
let mut indices = Vec::with_capacity(36);
|
||||
for (normal, corners) in faces {
|
||||
let base = vertices.len() as u32;
|
||||
for (corner, uv) in corners.iter().zip(uvs.iter()) {
|
||||
vertices.push(Vertex::new(*corner, normal, *uv));
|
||||
}
|
||||
indices.extend_from_slice(&[base, base + 1, base + 2, base, base + 2, base + 3]);
|
||||
}
|
||||
Self::new(vertices, indices)
|
||||
}
|
||||
|
||||
/// A flat plane of `size` units on the XZ axes, centered at the origin,
|
||||
/// facing `+Y`. Useful as a ground reference.
|
||||
pub fn plane(size: f32) -> Self {
|
||||
let h = size * 0.5;
|
||||
let n = Vec3::Y;
|
||||
let vertices = vec![
|
||||
Vertex::new(Vec3::new(-h, 0.0, h), n, Vec2::new(0.0, 1.0)),
|
||||
Vertex::new(Vec3::new(h, 0.0, h), n, Vec2::new(1.0, 1.0)),
|
||||
Vertex::new(Vec3::new(h, 0.0, -h), n, Vec2::new(1.0, 0.0)),
|
||||
Vertex::new(Vec3::new(-h, 0.0, -h), n, Vec2::new(0.0, 0.0)),
|
||||
];
|
||||
Self::new(vertices, vec![0, 1, 2, 0, 2, 3])
|
||||
}
|
||||
|
||||
/// A UV sphere of `radius` with `sectors` longitudinal and `stacks`
|
||||
/// latitudinal divisions. Normals are the (normalized) positions.
|
||||
pub fn uv_sphere(radius: f32, sectors: u32, stacks: u32) -> Self {
|
||||
use std::f32::consts::PI;
|
||||
let sectors = sectors.max(3);
|
||||
let stacks = stacks.max(2);
|
||||
let mut vertices = Vec::new();
|
||||
for i in 0..=stacks {
|
||||
// From +Y pole (phi=0) to -Y pole (phi=PI).
|
||||
let phi = PI * i as f32 / stacks as f32;
|
||||
let (sin_phi, cos_phi) = phi.sin_cos();
|
||||
for j in 0..=sectors {
|
||||
let theta = 2.0 * PI * j as f32 / sectors as f32;
|
||||
let (sin_theta, cos_theta) = theta.sin_cos();
|
||||
let dir = Vec3::new(sin_phi * cos_theta, cos_phi, sin_phi * sin_theta);
|
||||
let uv = Vec2::new(j as f32 / sectors as f32, i as f32 / stacks as f32);
|
||||
vertices.push(Vertex::new(dir * radius, dir, uv));
|
||||
}
|
||||
}
|
||||
let mut indices = Vec::new();
|
||||
let row = sectors + 1;
|
||||
for i in 0..stacks {
|
||||
for j in 0..sectors {
|
||||
let a = i * row + j;
|
||||
let b = a + row;
|
||||
// Two triangles per quad; skip degenerate ones at the poles.
|
||||
// Vertex order is `a → a+1 → b` and `a+1 → b+1 → b`, which
|
||||
// winds the quad CCW when seen from *outside* the sphere —
|
||||
// the wgpu front-face convention. The previous ordering
|
||||
// (`a, b, a+1` / `a+1, b, b+1`) wound them CW from outside,
|
||||
// which made back-face culling eat the sphere's surface and
|
||||
// showed intersecting opaque meshes through it.
|
||||
if i != 0 {
|
||||
indices.extend_from_slice(&[a, a + 1, b]);
|
||||
}
|
||||
if i != stacks - 1 {
|
||||
indices.extend_from_slice(&[a + 1, b + 1, b]);
|
||||
}
|
||||
}
|
||||
}
|
||||
Self::new(vertices, indices)
|
||||
}
|
||||
}
|
||||
|
||||
/// A mesh uploaded to the GPU: vertex and index buffers ready to draw.
|
||||
pub struct GpuMesh {
|
||||
/// Vertex buffer, laid out per [`Vertex::LAYOUT`].
|
||||
pub vertex_buffer: wgpu::Buffer,
|
||||
/// `u32` index buffer.
|
||||
pub index_buffer: wgpu::Buffer,
|
||||
/// Number of indices to draw.
|
||||
pub index_count: u32,
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
//! GPU rendering infrastructure.
|
||||
//!
|
||||
//! Stage 2 acquired a GPU ([`Gpu`]), drove a window surface ([`RenderContext`]),
|
||||
//! and cleared it each frame. Stage 4 adds mesh rendering: build geometry
|
||||
//! ([`Mesh`]/[`Vertex`]), upload it ([`GpuMesh`]), describe surfaces with a
|
||||
//! [`Material`], place a [`Camera`], and draw through the [`ForwardRenderer`].
|
||||
|
||||
mod camera;
|
||||
mod context;
|
||||
mod forward;
|
||||
mod gpu;
|
||||
mod material;
|
||||
mod mesh;
|
||||
mod pipeline;
|
||||
mod renderable;
|
||||
mod ui_pass;
|
||||
|
||||
pub use camera::Camera;
|
||||
pub use context::RenderContext;
|
||||
pub use forward::{DirectionalLight, ForwardRenderer, Lighting, RenderObject, DEPTH_FORMAT};
|
||||
pub use gpu::Gpu;
|
||||
pub use material::Material;
|
||||
pub use mesh::{GpuMesh, Mesh, Vertex};
|
||||
pub use pipeline::{ClearPass, ForwardPass, FrameContext, RenderPass, RenderPipeline};
|
||||
pub use renderable::{MeshRenderer, PrimitiveShape};
|
||||
pub use ui_pass::{UiBatch, UiOverlayPass};
|
||||
|
||||
use crate::math::Color;
|
||||
|
||||
/// Errors produced by the rendering layer.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum RenderError {
|
||||
/// No GPU adapter compatible with the requested surface (or headless use)
|
||||
/// was found on this system.
|
||||
#[error("no compatible GPU adapter found: {0}")]
|
||||
NoAdapter(#[from] wgpu::RequestAdapterError),
|
||||
|
||||
/// The adapter was found but refused to provide a device.
|
||||
#[error("failed to request GPU device: {0}")]
|
||||
Device(#[from] wgpu::RequestDeviceError),
|
||||
|
||||
/// The window surface could not be created.
|
||||
#[error("failed to create surface: {0}")]
|
||||
CreateSurface(#[from] wgpu::CreateSurfaceError),
|
||||
|
||||
/// The adapter cannot present to the created surface.
|
||||
#[error("the GPU adapter does not support presenting to this surface")]
|
||||
UnsupportedSurface,
|
||||
|
||||
/// Configuring the surface raised a validation error. On some drivers a
|
||||
/// backend reports a GPU but cannot actually present to the window surface
|
||||
/// (e.g. old NVIDIA on Wayland under Vulkan); this is caught so the engine
|
||||
/// can fall back to another backend instead of aborting.
|
||||
#[error("surface configuration failed: {0}")]
|
||||
SurfaceConfigure(String),
|
||||
|
||||
/// Every render backend/adapter the engine tried failed to produce a
|
||||
/// working surface — no usable GPU path on this system.
|
||||
#[error("no working render backend found (tried Vulkan/Metal/DX12, GL, and software)")]
|
||||
NoWorkingBackend,
|
||||
|
||||
/// Acquiring the next frame raised a validation error — a bug in surface
|
||||
/// configuration, not a transient condition.
|
||||
#[error("surface frame acquisition failed validation")]
|
||||
SurfaceValidation,
|
||||
}
|
||||
|
||||
/// Records and submits a render pass that clears `view` to `color`.
|
||||
///
|
||||
/// This is the whole of Stage 2's rendering: both the windowed
|
||||
/// [`RenderContext`] and offscreen targets (e.g. tests) clear through here.
|
||||
pub fn clear_view(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
view: &wgpu::TextureView,
|
||||
color: Color,
|
||||
) {
|
||||
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
||||
label: Some("oxide.clear"),
|
||||
});
|
||||
// The pass is dropped immediately: a load-op clear with no draws is all
|
||||
// that is needed to fill the target.
|
||||
let _ = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("oxide.clear.pass"),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(to_wgpu_color(color)),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
depth_stencil_attachment: None,
|
||||
timestamp_writes: None,
|
||||
occlusion_query_set: None,
|
||||
multiview_mask: None,
|
||||
});
|
||||
queue.submit([encoder.finish()]);
|
||||
}
|
||||
|
||||
/// Converts the engine's [`Color`] (linear `f32`) to a [`wgpu::Color`]
|
||||
/// (linear `f64`), as used by clear operations.
|
||||
pub fn to_wgpu_color(color: Color) -> wgpu::Color {
|
||||
wgpu::Color {
|
||||
r: color.r as f64,
|
||||
g: color.g as f64,
|
||||
b: color.b as f64,
|
||||
a: color.a as f64,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
//! [`RenderPipeline`]: a data-driven, ordered list of composable render passes.
|
||||
//!
|
||||
//! Stage 4's renderer drew everything in one hardcoded pass. Stage 5 generalizes
|
||||
//! that into a list of named [`RenderPass`]es that share one frame's targets and
|
||||
//! run in order. A project enables only the passes it needs — this is the
|
||||
//! mechanism behind *scalable fidelity*: a flat unlit/low-poly look (or a
|
||||
//! stylized post effect) versus a full realistic stack, paying only for the
|
||||
//! passes turned on.
|
||||
//!
|
||||
//! The Stage-4 forward pass is retrofitted onto this as [`ForwardPass`], so the
|
||||
//! default pipeline ([`RenderPipeline::forward`]) is just `[Clear, Forward]` and
|
||||
//! produces pixel-identical output. Later stages add passes (shadows,
|
||||
//! post-process, overlay UI) **without touching the renderer core** — they
|
||||
//! register a pass.
|
||||
|
||||
use crate::math::{Color, Rect, Transform, Vec2};
|
||||
|
||||
use super::{clear_view, Camera, ForwardRenderer, Lighting, RenderObject};
|
||||
|
||||
/// Everything one frame's passes operate on: the shared color target and the
|
||||
/// scene view to draw.
|
||||
///
|
||||
/// Passes share the same `color` target (and, as the pipeline grows, depth and
|
||||
/// intermediate textures), which is what makes them *composable*: a clear pass
|
||||
/// fills the target, the forward pass draws into it, a future post pass reads and
|
||||
/// rewrites it.
|
||||
pub struct FrameContext<'a> {
|
||||
/// The GPU device.
|
||||
pub device: &'a wgpu::Device,
|
||||
/// The GPU queue.
|
||||
pub queue: &'a wgpu::Queue,
|
||||
/// The color target every pass renders into.
|
||||
pub color: &'a wgpu::TextureView,
|
||||
/// Target size in physical pixels (the whole color target the pipeline
|
||||
/// is writing into).
|
||||
pub size: (u32, u32),
|
||||
/// The sub-rectangle of the target that drawing is restricted to, in
|
||||
/// physical pixels (`min` = upper-left, `max` = lower-right). Passes
|
||||
/// configure the wgpu viewport from this and the camera uses its
|
||||
/// aspect ratio for the projection.
|
||||
///
|
||||
/// `None` means "use the full target" — the default for headless tests
|
||||
/// and for hosts that render to a whole window. The editor sets this to
|
||||
/// the Viewport tab's rect from the docking shell so picking and
|
||||
/// projection align with what the user sees inside the tab rather than
|
||||
/// stretching across the whole window.
|
||||
pub viewport_rect: Option<Rect>,
|
||||
/// The background clear color (used by [`ClearPass`]).
|
||||
pub clear_color: Color,
|
||||
/// The camera to render from.
|
||||
pub camera: &'a Camera,
|
||||
/// The camera's world placement.
|
||||
pub view_transform: &'a Transform,
|
||||
/// Scene lighting.
|
||||
pub lighting: &'a Lighting,
|
||||
/// The drawables, already culled by the host (e.g. by camera
|
||||
/// [`visibility`](Camera::visibility)).
|
||||
pub objects: &'a [RenderObject<'a>],
|
||||
}
|
||||
|
||||
impl FrameContext<'_> {
|
||||
/// The viewport rect [`viewport_rect`](Self::viewport_rect) resolves to —
|
||||
/// the explicit sub-rect when set, otherwise the full target.
|
||||
pub fn resolved_viewport(&self) -> Rect {
|
||||
self.viewport_rect.unwrap_or_else(|| {
|
||||
Rect::from_min_size(
|
||||
Vec2::ZERO,
|
||||
Vec2::new(self.size.0.max(1) as f32, self.size.1.max(1) as f32),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// One stage of the frame. Implement this to add a custom pass; register it on a
|
||||
/// [`RenderPipeline`]. Passes are owned by the pipeline and run in order.
|
||||
pub trait RenderPass {
|
||||
/// Records this pass's GPU work for the frame.
|
||||
fn run(&mut self, frame: &mut FrameContext<'_>);
|
||||
}
|
||||
|
||||
struct PassEntry {
|
||||
name: String,
|
||||
enabled: bool,
|
||||
pass: Box<dyn RenderPass>,
|
||||
}
|
||||
|
||||
/// An ordered, named list of render passes.
|
||||
///
|
||||
/// Add passes with [`add_pass`](Self::add_pass), toggle them with
|
||||
/// [`set_enabled`](Self::set_enabled), or drop them with [`remove`](Self::remove)
|
||||
/// — all without touching any pass's implementation. [`render`](Self::render)
|
||||
/// runs every enabled pass in order against one [`FrameContext`].
|
||||
#[derive(Default)]
|
||||
pub struct RenderPipeline {
|
||||
passes: Vec<PassEntry>,
|
||||
}
|
||||
|
||||
impl RenderPipeline {
|
||||
/// An empty pipeline (no passes).
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// The default forward pipeline: a [`ClearPass`] followed by a
|
||||
/// [`ForwardPass`]. Pixel-identical to the Stage-4 renderer's output.
|
||||
pub fn forward(device: &wgpu::Device, color_format: wgpu::TextureFormat) -> Self {
|
||||
let mut pipeline = Self::new();
|
||||
pipeline.add_pass("clear", ClearPass);
|
||||
pipeline.add_pass("forward", ForwardPass::new(device, color_format));
|
||||
pipeline
|
||||
}
|
||||
|
||||
/// Appends a named pass (enabled). Replaces any existing pass with the same
|
||||
/// name, keeping its position.
|
||||
pub fn add_pass(&mut self, name: impl Into<String>, pass: impl RenderPass + 'static) {
|
||||
let name = name.into();
|
||||
let entry = PassEntry {
|
||||
name: name.clone(),
|
||||
enabled: true,
|
||||
pass: Box::new(pass),
|
||||
};
|
||||
match self.passes.iter_mut().find(|e| e.name == name) {
|
||||
Some(existing) => *existing = entry,
|
||||
None => self.passes.push(entry),
|
||||
}
|
||||
}
|
||||
|
||||
/// Inserts a pass before the pass named `before` (or at the end if not
|
||||
/// found). Useful for slotting a post effect into a fixed position.
|
||||
pub fn insert_before(
|
||||
&mut self,
|
||||
before: &str,
|
||||
name: impl Into<String>,
|
||||
pass: impl RenderPass + 'static,
|
||||
) {
|
||||
let entry = PassEntry {
|
||||
name: name.into(),
|
||||
enabled: true,
|
||||
pass: Box::new(pass),
|
||||
};
|
||||
match self.passes.iter().position(|e| e.name == before) {
|
||||
Some(index) => self.passes.insert(index, entry),
|
||||
None => self.passes.push(entry),
|
||||
}
|
||||
}
|
||||
|
||||
/// Enables or disables the named pass. Returns whether it exists.
|
||||
pub fn set_enabled(&mut self, name: &str, enabled: bool) -> bool {
|
||||
match self.passes.iter_mut().find(|e| e.name == name) {
|
||||
Some(entry) => {
|
||||
entry.enabled = enabled;
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes the named pass. Returns whether it existed.
|
||||
pub fn remove(&mut self, name: &str) -> bool {
|
||||
let before = self.passes.len();
|
||||
self.passes.retain(|e| e.name != name);
|
||||
self.passes.len() != before
|
||||
}
|
||||
|
||||
/// Whether a pass with this name is registered.
|
||||
pub fn has_pass(&self, name: &str) -> bool {
|
||||
self.passes.iter().any(|e| e.name == name)
|
||||
}
|
||||
|
||||
/// The pass names in execution order.
|
||||
pub fn pass_names(&self) -> impl Iterator<Item = &str> {
|
||||
self.passes.iter().map(|e| e.name.as_str())
|
||||
}
|
||||
|
||||
/// Runs every enabled pass in order against `frame`.
|
||||
pub fn render(&mut self, frame: &mut FrameContext<'_>) {
|
||||
for entry in &mut self.passes {
|
||||
if entry.enabled {
|
||||
entry.pass.run(frame);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A pass that clears the color target to [`FrameContext::clear_color`].
|
||||
///
|
||||
/// Conventionally the first pass, so later passes load over the cleared
|
||||
/// background (matching the Stage-4 clear-then-draw flow).
|
||||
pub struct ClearPass;
|
||||
|
||||
impl RenderPass for ClearPass {
|
||||
fn run(&mut self, frame: &mut FrameContext<'_>) {
|
||||
clear_view(frame.device, frame.queue, frame.color, frame.clear_color);
|
||||
}
|
||||
}
|
||||
|
||||
/// A pass that draws the frame's objects with the lit forward renderer.
|
||||
///
|
||||
/// Wraps the Stage-4 [`ForwardRenderer`]; the color target is *loaded* (so a
|
||||
/// preceding [`ClearPass`] shows through), depth is managed internally.
|
||||
pub struct ForwardPass {
|
||||
renderer: ForwardRenderer,
|
||||
}
|
||||
|
||||
impl ForwardPass {
|
||||
/// Builds a forward pass for the given color target format.
|
||||
pub fn new(device: &wgpu::Device, color_format: wgpu::TextureFormat) -> Self {
|
||||
Self {
|
||||
renderer: ForwardRenderer::new(device, color_format),
|
||||
}
|
||||
}
|
||||
|
||||
/// The wrapped renderer's color format.
|
||||
pub fn color_format(&self) -> wgpu::TextureFormat {
|
||||
self.renderer.color_format()
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderPass for ForwardPass {
|
||||
fn run(&mut self, frame: &mut FrameContext<'_>) {
|
||||
self.renderer.render(
|
||||
frame.device,
|
||||
frame.queue,
|
||||
frame.color,
|
||||
frame.size,
|
||||
frame.resolved_viewport(),
|
||||
frame.camera,
|
||||
frame.view_transform,
|
||||
frame.lighting,
|
||||
frame.objects,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
//! Renderable scene components: [`MeshRenderer`] and [`PrimitiveShape`].
|
||||
//!
|
||||
//! A [`MeshRenderer`] is the component that makes a scene entity show up in the
|
||||
//! 3D viewport: it pairs a mesh source with a [`Material`]. Stage 4 ships the
|
||||
//! built-in [`PrimitiveShape`] source (cube/sphere/plane) — lightweight and
|
||||
//! serializable, so the editor (and later scripts/AI agents) can author what an
|
||||
//! entity renders. Imported meshes attach later via a mesh-asset handle.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{Material, Mesh};
|
||||
use crate::math::{Aabb, Vec3};
|
||||
|
||||
/// A built-in primitive mesh an entity can render.
|
||||
///
|
||||
/// This names a shape rather than embedding vertex data, so it stays tiny,
|
||||
/// serializable, and cheap to edit; the renderer resolves it to a (cached)
|
||||
/// [`Mesh`]/GPU buffer.
|
||||
#[derive(
|
||||
Debug,
|
||||
Clone,
|
||||
Copy,
|
||||
PartialEq,
|
||||
Eq,
|
||||
Hash,
|
||||
Default,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
crate::reflect::ReflectEnum,
|
||||
)]
|
||||
pub enum PrimitiveShape {
|
||||
/// Unit cube centered at the origin.
|
||||
#[default]
|
||||
Cube,
|
||||
/// Unit-radius UV sphere.
|
||||
Sphere,
|
||||
/// A 1×1 ground plane on the XZ axes, facing `+Y`.
|
||||
Plane,
|
||||
}
|
||||
|
||||
impl PrimitiveShape {
|
||||
/// All shapes, for building caches / editor menus.
|
||||
pub const ALL: [PrimitiveShape; 3] = [
|
||||
PrimitiveShape::Cube,
|
||||
PrimitiveShape::Sphere,
|
||||
PrimitiveShape::Plane,
|
||||
];
|
||||
|
||||
/// A human-readable label.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
PrimitiveShape::Cube => "Cube",
|
||||
PrimitiveShape::Sphere => "Sphere",
|
||||
PrimitiveShape::Plane => "Plane",
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the CPU [`Mesh`] for this shape.
|
||||
pub fn mesh(self) -> Mesh {
|
||||
match self {
|
||||
PrimitiveShape::Cube => Mesh::cube(),
|
||||
PrimitiveShape::Sphere => Mesh::uv_sphere(1.0, 32, 16),
|
||||
PrimitiveShape::Plane => Mesh::plane(1.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// The object-space bounds of this shape, without building a mesh — used for
|
||||
/// ray-picking and culling.
|
||||
pub fn local_bounds(self) -> Aabb {
|
||||
let half = match self {
|
||||
PrimitiveShape::Cube => Vec3::splat(0.5),
|
||||
PrimitiveShape::Sphere => Vec3::ONE,
|
||||
PrimitiveShape::Plane => Vec3::new(0.5, 0.0, 0.5),
|
||||
};
|
||||
Aabb::from_center_half_extents(Vec3::ZERO, half)
|
||||
}
|
||||
}
|
||||
|
||||
/// Component: what an entity renders.
|
||||
///
|
||||
/// Attach to a scene entity (via the ECS) to make it appear in a forward pass.
|
||||
/// Stage 4 sources the mesh from a [`PrimitiveShape`]; the [`Material`] is
|
||||
/// edited in the inspector. Both are serializable, supporting the engine's
|
||||
/// dual-editable (editor + script/AI) component goal.
|
||||
#[derive(
|
||||
Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize, crate::reflect::Reflect,
|
||||
)]
|
||||
pub struct MeshRenderer {
|
||||
/// The mesh to draw.
|
||||
pub shape: PrimitiveShape,
|
||||
/// The surface material.
|
||||
pub material: Material,
|
||||
}
|
||||
|
||||
impl MeshRenderer {
|
||||
/// A renderer for `shape` with the default material.
|
||||
pub fn new(shape: PrimitiveShape) -> Self {
|
||||
Self {
|
||||
shape,
|
||||
material: Material::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A renderer for `shape` with an explicit `material`.
|
||||
pub fn with_material(shape: PrimitiveShape, material: Material) -> Self {
|
||||
Self { shape, material }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::math::Color;
|
||||
|
||||
#[test]
|
||||
fn every_shape_builds_a_nonempty_mesh() {
|
||||
for shape in PrimitiveShape::ALL {
|
||||
let mesh = shape.mesh();
|
||||
assert!(!mesh.vertices.is_empty(), "{shape:?} has no vertices");
|
||||
assert!(mesh.triangle_count() > 0, "{shape:?} has no triangles");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mesh_renderer_round_trips_through_ron() {
|
||||
let mr = MeshRenderer::with_material(
|
||||
PrimitiveShape::Sphere,
|
||||
Material::metal(Color::rgb(0.2, 0.4, 0.8), 0.25),
|
||||
);
|
||||
let ron = ron::to_string(&mr).unwrap();
|
||||
let back: MeshRenderer = ron::from_str(&ron).unwrap();
|
||||
assert_eq!(mr, back);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// Stage 4 forward lit shader: a single directional light with Lambert diffuse,
|
||||
// ambient, and a Blinn-Phong specular term scaled by material roughness/metallic
|
||||
// (PBR-lite). Output is linear color; an sRGB surface format converts on write.
|
||||
|
||||
struct Globals {
|
||||
view_proj: mat4x4<f32>,
|
||||
camera_pos: vec4<f32>, // xyz world-space camera position
|
||||
light_dir: vec4<f32>, // xyz unit vector pointing TOWARD the light
|
||||
light_color: vec4<f32>, // rgb light color * intensity
|
||||
ambient: vec4<f32>, // rgb ambient term
|
||||
};
|
||||
|
||||
struct ObjectData {
|
||||
model: mat4x4<f32>,
|
||||
normal_mtx: mat4x4<f32>, // inverse-transpose of model (3x3 in a 4x4)
|
||||
albedo: vec4<f32>,
|
||||
mr: vec4<f32>, // x = metallic, y = roughness
|
||||
};
|
||||
|
||||
@group(0) @binding(0) var<uniform> globals: Globals;
|
||||
@group(1) @binding(0) var<uniform> obj: ObjectData;
|
||||
|
||||
struct VsOut {
|
||||
@builtin(position) clip_pos: vec4<f32>,
|
||||
@location(0) world_pos: vec3<f32>,
|
||||
@location(1) world_normal: vec3<f32>,
|
||||
@location(2) uv: vec2<f32>,
|
||||
};
|
||||
|
||||
@vertex
|
||||
fn vs_main(
|
||||
@location(0) position: vec3<f32>,
|
||||
@location(1) normal: vec3<f32>,
|
||||
@location(2) uv: vec2<f32>,
|
||||
) -> VsOut {
|
||||
let world = obj.model * vec4<f32>(position, 1.0);
|
||||
var out: VsOut;
|
||||
out.world_pos = world.xyz;
|
||||
out.world_normal = (obj.normal_mtx * vec4<f32>(normal, 0.0)).xyz;
|
||||
out.uv = uv;
|
||||
out.clip_pos = globals.view_proj * world;
|
||||
return out;
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
|
||||
let n = normalize(in.world_normal);
|
||||
let l = normalize(globals.light_dir.xyz);
|
||||
let v = normalize(globals.camera_pos.xyz - in.world_pos);
|
||||
let h = normalize(l + v);
|
||||
|
||||
let albedo = obj.albedo.rgb;
|
||||
let metallic = obj.mr.x;
|
||||
let roughness = clamp(obj.mr.y, 0.04, 1.0);
|
||||
|
||||
let ndl = max(dot(n, l), 0.0);
|
||||
let ndh = max(dot(n, h), 0.0);
|
||||
|
||||
// Metals have no diffuse; dielectrics get a fixed 0.04 specular, metals
|
||||
// tint their specular by the albedo.
|
||||
let diffuse = albedo * (1.0 - metallic);
|
||||
let spec_color = mix(vec3<f32>(0.04), albedo, metallic);
|
||||
let spec_power = mix(8.0, 256.0, 1.0 - roughness);
|
||||
let spec = spec_color * pow(ndh, spec_power) * select(0.0, 1.0, ndl > 0.0);
|
||||
|
||||
let direct = (diffuse * ndl + spec) * globals.light_color.rgb;
|
||||
let ambient = albedo * globals.ambient.rgb;
|
||||
return vec4<f32>(ambient + direct, obj.albedo.a);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// Oxide Stage-8 UI overlay shader.
|
||||
//
|
||||
// One vertex format covers both solid quads and glyph quads: the sentinel UV
|
||||
// `(-1, -1)` marks "solid color, do not sample the atlas". This avoids
|
||||
// branching on a separate flag attribute and keeps the vertex stride tight
|
||||
// (32 bytes — pos2 + uv2 + color4).
|
||||
|
||||
struct Uniforms {
|
||||
mvp: mat4x4<f32>,
|
||||
};
|
||||
|
||||
@group(0) @binding(0) var<uniform> u: Uniforms;
|
||||
@group(0) @binding(1) var atlas: texture_2d<f32>;
|
||||
@group(0) @binding(2) var atlas_sampler: sampler;
|
||||
|
||||
struct VsIn {
|
||||
@location(0) position: vec2<f32>,
|
||||
@location(1) uv: vec2<f32>,
|
||||
@location(2) color: vec4<f32>,
|
||||
};
|
||||
|
||||
struct VsOut {
|
||||
@builtin(position) clip_pos: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
@location(1) color: vec4<f32>,
|
||||
};
|
||||
|
||||
@vertex
|
||||
fn vs_main(in: VsIn) -> VsOut {
|
||||
var out: VsOut;
|
||||
out.clip_pos = u.mvp * vec4<f32>(in.position, 0.0, 1.0);
|
||||
out.uv = in.uv;
|
||||
out.color = in.color;
|
||||
return out;
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
|
||||
// Solid quads use the sentinel UV (-1, -1). Sampling out-of-range would
|
||||
// be clamped or wrapped depending on the sampler, but we cheaply detect
|
||||
// it instead so a single texture binding serves every primitive.
|
||||
if (in.uv.x < 0.0) {
|
||||
return in.color;
|
||||
}
|
||||
let alpha = textureSample(atlas, atlas_sampler, in.uv).r;
|
||||
return vec4<f32>(in.color.rgb, in.color.a * alpha);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,95 @@
|
||||
//! [`DisabledComponents`]: a hidden per-entity set of disabled component names.
|
||||
//!
|
||||
//! Some game-objects need a component *attached but not active* — e.g. a
|
||||
//! camera that defaults disabled and a script turns it on at a trigger. ECS
|
||||
//! component-sets don't carry an "active" bit per component on their own, so
|
||||
//! this component stores the set of *type names* (matching the reflection
|
||||
//! registry) that should be skipped by systems on this entity.
|
||||
//!
|
||||
//! - Each engine system that runs on a per-entity component query consults
|
||||
//! [`Scene::is_component_disabled`](crate::scene::Scene::is_component_disabled)
|
||||
//! (or this component directly) before acting; it's the Unity
|
||||
//! "Component.enabled" equivalent in an archetypal ECS.
|
||||
//! - The editor inspector reads + writes it through a per-component
|
||||
//! checkbox, hides the component itself from view (it's metadata, not
|
||||
//! authored data), and copies the set on Duplicate.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Per-entity set of *disabled* component type names.
|
||||
///
|
||||
/// Names match the reflection registry (e.g. `"MeshRenderer"`). An absent
|
||||
/// component (or an empty set) means every component on the entity is active.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct DisabledComponents {
|
||||
/// Disabled component type names. Stored as `String` so the data
|
||||
/// round-trips through RON without the `&'static str` reference issue.
|
||||
pub disabled: HashSet<String>,
|
||||
}
|
||||
|
||||
impl DisabledComponents {
|
||||
/// An empty set — every component is active.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Whether the component with this type name is disabled.
|
||||
pub fn is_disabled(&self, type_name: &str) -> bool {
|
||||
self.disabled.contains(type_name)
|
||||
}
|
||||
|
||||
/// Marks the component disabled (`true`) or active (`false`). Adds or
|
||||
/// removes the entry as needed.
|
||||
pub fn set_disabled(&mut self, type_name: &str, disabled: bool) {
|
||||
if disabled {
|
||||
self.disabled.insert(type_name.to_string());
|
||||
} else {
|
||||
self.disabled.remove(type_name);
|
||||
}
|
||||
}
|
||||
|
||||
/// True if no components are currently disabled — a hint to systems that
|
||||
/// the entire `DisabledComponents` component can be removed.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.disabled.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_is_empty_and_disables_nothing() {
|
||||
let d = DisabledComponents::new();
|
||||
assert!(d.is_empty());
|
||||
assert!(!d.is_disabled("MeshRenderer"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_disabled_toggles_membership() {
|
||||
let mut d = DisabledComponents::new();
|
||||
d.set_disabled("MeshRenderer", true);
|
||||
assert!(d.is_disabled("MeshRenderer"));
|
||||
assert!(!d.is_empty());
|
||||
// Idempotent.
|
||||
d.set_disabled("MeshRenderer", true);
|
||||
assert_eq!(d.disabled.len(), 1);
|
||||
// Re-enable removes the entry.
|
||||
d.set_disabled("MeshRenderer", false);
|
||||
assert!(!d.is_disabled("MeshRenderer"));
|
||||
assert!(d.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_through_ron() {
|
||||
let mut d = DisabledComponents::new();
|
||||
d.set_disabled("MeshRenderer", true);
|
||||
d.set_disabled("RigidBody", true);
|
||||
let text = ron::to_string(&d).unwrap();
|
||||
let back: DisabledComponents = ron::from_str(&text).unwrap();
|
||||
assert_eq!(back, d);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,722 @@
|
||||
//! The [`Scene`]: entities, their components, and a transform hierarchy.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use hecs::{Component, Entity, World};
|
||||
|
||||
use super::SceneError;
|
||||
use crate::math::Transform;
|
||||
use crate::scene::node::Node;
|
||||
|
||||
/// What happens to an entity's children when it is despawned.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DespawnPolicy {
|
||||
/// Despawn the entity together with its entire subtree.
|
||||
Recursive,
|
||||
/// Despawn only the entity; reparent each child to the entity's parent,
|
||||
/// promoting them to roots if the entity was itself a root.
|
||||
DetachChildren,
|
||||
}
|
||||
|
||||
/// A scene graph: a [`hecs`] world plus a parent/child transform hierarchy.
|
||||
///
|
||||
/// Entities are [`hecs::Entity`] handles. Every entity created through the
|
||||
/// scene carries a [`Node`] and a (local) [`Transform`]; arbitrary additional
|
||||
/// components can be attached via [`world_mut`](Self::world_mut) for the
|
||||
/// systems added in later stages (meshes, rigid bodies, …).
|
||||
///
|
||||
/// The hierarchy is owned by the scene rather than stored as components, which
|
||||
/// keeps child ordering deterministic (important for serialization and the
|
||||
/// editor) and lets reparenting avoid archetype churn. Local transforms are the
|
||||
/// authored values; [`world_transform`](Self::world_transform) and
|
||||
/// [`world_transforms`](Self::world_transforms) resolve them against the
|
||||
/// hierarchy as `parent_world * local`.
|
||||
#[derive(Default)]
|
||||
pub struct Scene {
|
||||
world: World,
|
||||
/// Top-level entities, in insertion order.
|
||||
roots: Vec<Entity>,
|
||||
/// Child lists keyed by parent, each in insertion order. Entities with no
|
||||
/// children may be absent.
|
||||
children: HashMap<Entity, Vec<Entity>>,
|
||||
/// Upward links. Roots are absent from this map.
|
||||
parents: HashMap<Entity, Entity>,
|
||||
}
|
||||
|
||||
impl Scene {
|
||||
/// Creates an empty scene.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
// --- Lifecycle ---------------------------------------------------------
|
||||
|
||||
/// Spawns a new root entity carrying `node` and `transform`.
|
||||
///
|
||||
/// Every spawned entity automatically carries the three *node-baked*
|
||||
/// components: [`Node`], [`Transform`], and
|
||||
/// [`Layer`](crate::layer::Layer) (membership in the default layer).
|
||||
/// They are inherent to being an entity in this scene — single-instance,
|
||||
/// not added through the editor's "Add Component" menu, not removable.
|
||||
/// Modular components (`MeshRenderer`, future colliders, scripts, …) are
|
||||
/// attached on top.
|
||||
pub fn spawn(&mut self, node: impl Into<Node>, transform: Transform) -> Entity {
|
||||
let entity = self
|
||||
.world
|
||||
.spawn((node.into(), transform, crate::layer::Layer::default()));
|
||||
self.roots.push(entity);
|
||||
entity
|
||||
}
|
||||
|
||||
/// Spawns a new entity as a child of `parent`. Auto-attaches the same
|
||||
/// node-baked components as [`spawn`](Self::spawn).
|
||||
///
|
||||
/// # Panics
|
||||
/// Panics if `parent` is not a live entity in this scene.
|
||||
pub fn spawn_child(
|
||||
&mut self,
|
||||
parent: Entity,
|
||||
node: impl Into<Node>,
|
||||
transform: Transform,
|
||||
) -> Entity {
|
||||
assert!(
|
||||
self.world.contains(parent),
|
||||
"spawn_child: parent {parent:?} is not a live entity in this scene"
|
||||
);
|
||||
let entity = self
|
||||
.world
|
||||
.spawn((node.into(), transform, crate::layer::Layer::default()));
|
||||
self.parents.insert(entity, parent);
|
||||
self.children.entry(parent).or_default().push(entity);
|
||||
entity
|
||||
}
|
||||
|
||||
/// Despawns `entity`, handling its children according to `policy`.
|
||||
///
|
||||
/// Returns `true` if the entity existed and was removed.
|
||||
pub fn despawn(&mut self, entity: Entity, policy: DespawnPolicy) -> bool {
|
||||
if !self.world.contains(entity) {
|
||||
return false;
|
||||
}
|
||||
// Remember the parent before unlinking, so DetachChildren can promote
|
||||
// the orphans to the right place.
|
||||
let grandparent = self.parents.get(&entity).copied();
|
||||
self.unlink(entity);
|
||||
|
||||
match policy {
|
||||
DespawnPolicy::Recursive => self.despawn_recursive(entity),
|
||||
DespawnPolicy::DetachChildren => {
|
||||
let kids = self.children.remove(&entity).unwrap_or_default();
|
||||
let _ = self.world.despawn(entity);
|
||||
for kid in kids {
|
||||
match grandparent {
|
||||
Some(gp) => {
|
||||
self.parents.insert(kid, gp);
|
||||
self.children.entry(gp).or_default().push(kid);
|
||||
}
|
||||
None => {
|
||||
self.parents.remove(&kid);
|
||||
self.roots.push(kid);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Recursively despawns `entity` and everything beneath it. Assumes
|
||||
/// `entity` has already been unlinked from its parent / the root list.
|
||||
fn despawn_recursive(&mut self, entity: Entity) {
|
||||
let kids = self.children.remove(&entity).unwrap_or_default();
|
||||
self.parents.remove(&entity);
|
||||
let _ = self.world.despawn(entity);
|
||||
for kid in kids {
|
||||
self.despawn_recursive(kid);
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes `entity` from its parent's child list (or the root list) and
|
||||
/// from the parent map, without touching the entity itself.
|
||||
fn unlink(&mut self, entity: Entity) {
|
||||
match self.parents.remove(&entity) {
|
||||
Some(parent) => {
|
||||
if let Some(siblings) = self.children.get_mut(&parent) {
|
||||
siblings.retain(|&e| e != entity);
|
||||
}
|
||||
}
|
||||
None => self.roots.retain(|&e| e != entity),
|
||||
}
|
||||
}
|
||||
|
||||
// --- Hierarchy ---------------------------------------------------------
|
||||
|
||||
/// Reparents `entity` under `new_parent`, or makes it a root when
|
||||
/// `new_parent` is `None`. Child ordering places `entity` last among its
|
||||
/// new siblings.
|
||||
///
|
||||
/// Local transforms are preserved as-is (this does not compensate to keep
|
||||
/// the world transform fixed).
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`SceneError::NoSuchEntity`] if `entity` or `new_parent` is not live.
|
||||
/// - [`SceneError::WouldCycle`] if `new_parent` is `entity` itself or one
|
||||
/// of its descendants.
|
||||
pub fn set_parent(
|
||||
&mut self,
|
||||
entity: Entity,
|
||||
new_parent: Option<Entity>,
|
||||
) -> Result<(), SceneError> {
|
||||
if !self.world.contains(entity) {
|
||||
return Err(SceneError::NoSuchEntity);
|
||||
}
|
||||
if let Some(parent) = new_parent {
|
||||
if !self.world.contains(parent) {
|
||||
return Err(SceneError::NoSuchEntity);
|
||||
}
|
||||
// Walking up from the prospective parent must not reach `entity`,
|
||||
// otherwise the link would form a cycle.
|
||||
if parent == entity || self.is_ancestor(entity, parent) {
|
||||
return Err(SceneError::WouldCycle);
|
||||
}
|
||||
}
|
||||
|
||||
self.unlink(entity);
|
||||
match new_parent {
|
||||
Some(parent) => {
|
||||
self.parents.insert(entity, parent);
|
||||
self.children.entry(parent).or_default().push(entity);
|
||||
}
|
||||
None => self.roots.push(entity),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Moves `entity` under `new_parent` (or to the root level when `None`),
|
||||
/// positioned **immediately before** sibling `before`. If `before` is
|
||||
/// `None` or isn't a child of the target, `entity` is appended.
|
||||
///
|
||||
/// Unlike [`set_parent`](Self::set_parent) (which always appends), this
|
||||
/// controls the sibling order, so it covers both reparenting *and*
|
||||
/// reordering within the same parent — the operation a hierarchy
|
||||
/// drag-and-drop with an insertion indicator needs. The position is
|
||||
/// resolved *after* unlinking `entity`, so reordering within one parent
|
||||
/// doesn't suffer an off-by-one. Rejects cycles like `set_parent`.
|
||||
pub fn reorder(
|
||||
&mut self,
|
||||
entity: Entity,
|
||||
new_parent: Option<Entity>,
|
||||
before: Option<Entity>,
|
||||
) -> Result<(), SceneError> {
|
||||
if !self.world.contains(entity) {
|
||||
return Err(SceneError::NoSuchEntity);
|
||||
}
|
||||
if let Some(parent) = new_parent {
|
||||
if !self.world.contains(parent) {
|
||||
return Err(SceneError::NoSuchEntity);
|
||||
}
|
||||
if parent == entity || self.is_ancestor(entity, parent) {
|
||||
return Err(SceneError::WouldCycle);
|
||||
}
|
||||
}
|
||||
|
||||
self.unlink(entity);
|
||||
let siblings = match new_parent {
|
||||
Some(parent) => {
|
||||
self.parents.insert(entity, parent);
|
||||
self.children.entry(parent).or_default()
|
||||
}
|
||||
None => &mut self.roots,
|
||||
};
|
||||
let index = before
|
||||
.and_then(|b| siblings.iter().position(|&e| e == b))
|
||||
.unwrap_or(siblings.len());
|
||||
siblings.insert(index, entity);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns `true` if `ancestor` lies on the parent chain above `entity`.
|
||||
fn is_ancestor(&self, ancestor: Entity, entity: Entity) -> bool {
|
||||
let mut cursor = self.parents.get(&entity).copied();
|
||||
while let Some(p) = cursor {
|
||||
if p == ancestor {
|
||||
return true;
|
||||
}
|
||||
cursor = self.parents.get(&p).copied();
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// The parent of `entity`, or `None` if it is a root or absent.
|
||||
pub fn parent(&self, entity: Entity) -> Option<Entity> {
|
||||
self.parents.get(&entity).copied()
|
||||
}
|
||||
|
||||
/// The direct children of `entity`, in order. Empty for leaves.
|
||||
pub fn children(&self, entity: Entity) -> &[Entity] {
|
||||
self.children.get(&entity).map_or(&[], Vec::as_slice)
|
||||
}
|
||||
|
||||
/// The top-level entities, in insertion order.
|
||||
pub fn roots(&self) -> &[Entity] {
|
||||
&self.roots
|
||||
}
|
||||
|
||||
// --- Component access --------------------------------------------------
|
||||
|
||||
/// The node name, or `None` if `entity` is not live.
|
||||
pub fn name(&self, entity: Entity) -> Option<String> {
|
||||
self.world.get::<&Node>(entity).ok().map(|n| n.name.clone())
|
||||
}
|
||||
|
||||
/// Renames `entity`. Returns `false` if it is not live.
|
||||
pub fn set_name(&mut self, entity: Entity, name: impl Into<String>) -> bool {
|
||||
match self.world.get::<&mut Node>(entity) {
|
||||
Ok(mut node) => {
|
||||
node.name = name.into();
|
||||
true
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `entity` is enabled, or `None` if it is not live.
|
||||
pub fn is_enabled(&self, entity: Entity) -> Option<bool> {
|
||||
self.world.get::<&Node>(entity).ok().map(|n| n.enabled)
|
||||
}
|
||||
|
||||
/// Sets the enabled flag on `entity`. Returns `false` if it is not live.
|
||||
pub fn set_enabled(&mut self, entity: Entity, enabled: bool) -> bool {
|
||||
match self.world.get::<&mut Node>(entity) {
|
||||
Ok(mut node) => {
|
||||
node.enabled = enabled;
|
||||
true
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the named component on `entity` is marked disabled by a
|
||||
/// [`DisabledComponents`](crate::scene::DisabledComponents) component.
|
||||
/// Defaults to `false` when no `DisabledComponents` is attached.
|
||||
///
|
||||
/// Systems that act on a per-entity component query check this to honor
|
||||
/// "attached but inactive" — the ECS equivalent of Unity's
|
||||
/// `Component.enabled = false`.
|
||||
pub fn is_component_disabled(&self, entity: hecs::Entity, type_name: &str) -> bool {
|
||||
self.world
|
||||
.get::<&super::DisabledComponents>(entity)
|
||||
.ok()
|
||||
.map(|d| d.is_disabled(type_name))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Whether `entity` is enabled **and every ancestor is enabled** — its
|
||||
/// effective state in the hierarchy. `None` if it is not live.
|
||||
///
|
||||
/// [`is_enabled`](Self::is_enabled) reports an entity's own authored flag;
|
||||
/// this reports whether it is actually active, since disabling a node
|
||||
/// disables its whole subtree (rendering, physics, audio, and queries skip
|
||||
/// effectively-disabled entities). This is the Unity/Godot
|
||||
/// `activeInHierarchy` distinction: the per-node flag is what you author,
|
||||
/// the effective value is what systems honor.
|
||||
pub fn is_effectively_enabled(&self, entity: Entity) -> Option<bool> {
|
||||
if !self.contains(entity) {
|
||||
return None;
|
||||
}
|
||||
let mut current = Some(entity);
|
||||
while let Some(e) = current {
|
||||
if !self.is_enabled(e).unwrap_or(true) {
|
||||
return Some(false);
|
||||
}
|
||||
current = self.parent(e);
|
||||
}
|
||||
Some(true)
|
||||
}
|
||||
|
||||
/// The authored (local) transform of `entity`, or `None` if not live.
|
||||
pub fn local_transform(&self, entity: Entity) -> Option<Transform> {
|
||||
self.world.get::<&Transform>(entity).ok().map(|t| *t)
|
||||
}
|
||||
|
||||
/// Sets the local transform of `entity`. Returns `false` if not live.
|
||||
pub fn set_local_transform(&mut self, entity: Entity, transform: Transform) -> bool {
|
||||
match self.world.get::<&mut Transform>(entity) {
|
||||
Ok(mut t) => {
|
||||
*t = transform;
|
||||
true
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
// --- World transforms --------------------------------------------------
|
||||
|
||||
/// Resolves the world-space transform of a single `entity` by composing
|
||||
/// local transforms up the parent chain. `None` if `entity` is not live.
|
||||
///
|
||||
/// For resolving many entities at once, prefer
|
||||
/// [`world_transforms`](Self::world_transforms), which is a single pass.
|
||||
pub fn world_transform(&self, entity: Entity) -> Option<Transform> {
|
||||
let local = self.local_transform(entity)?;
|
||||
match self.parents.get(&entity) {
|
||||
Some(&parent) => Some(self.world_transform(parent)?.mul_transform(&local)),
|
||||
None => Some(local),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves world-space transforms for every entity in the scene in a
|
||||
/// single top-down pass (`parent_world * local`).
|
||||
pub fn world_transforms(&self) -> HashMap<Entity, Transform> {
|
||||
let mut out = HashMap::with_capacity(self.len());
|
||||
// Depth-first from each root, carrying the accumulated parent world
|
||||
// transform down the stack.
|
||||
let mut stack: Vec<(Entity, Transform)> = Vec::new();
|
||||
for &root in &self.roots {
|
||||
if let Some(local) = self.local_transform(root) {
|
||||
stack.push((root, local));
|
||||
}
|
||||
}
|
||||
while let Some((entity, world)) = stack.pop() {
|
||||
out.insert(entity, world);
|
||||
if let Some(children) = self.children.get(&entity) {
|
||||
for &child in children {
|
||||
if let Some(local) = self.local_transform(child) {
|
||||
stack.push((child, world.mul_transform(&local)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// --- ECS access --------------------------------------------------------
|
||||
|
||||
/// Whether `entity` is live in this scene.
|
||||
pub fn contains(&self, entity: Entity) -> bool {
|
||||
self.world.contains(entity)
|
||||
}
|
||||
|
||||
/// Number of live entities.
|
||||
pub fn len(&self) -> usize {
|
||||
self.world.len() as usize
|
||||
}
|
||||
|
||||
/// Whether the scene has no entities.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.world.len() == 0
|
||||
}
|
||||
|
||||
/// An iterator over every live entity, in unspecified order.
|
||||
pub fn entities(&self) -> impl Iterator<Item = Entity> + '_ {
|
||||
self.world.iter().map(|e| e.entity())
|
||||
}
|
||||
|
||||
/// Borrows a component of `entity`, e.g. `scene.get::<Transform>(e)`.
|
||||
pub fn get<T: Component>(&self, entity: Entity) -> Option<hecs::Ref<'_, T>> {
|
||||
self.world.get::<&T>(entity).ok()
|
||||
}
|
||||
|
||||
/// Mutably borrows a component of `entity`.
|
||||
///
|
||||
/// Do not mutate hierarchy state through here — use the scene's own
|
||||
/// methods so the parent/child bookkeeping stays consistent.
|
||||
pub fn get_mut<T: Component>(&mut self, entity: Entity) -> Option<hecs::RefMut<'_, T>> {
|
||||
self.world.get::<&mut T>(entity).ok()
|
||||
}
|
||||
|
||||
/// The underlying [`hecs::World`], for read-only ECS queries.
|
||||
pub fn world(&self) -> &World {
|
||||
&self.world
|
||||
}
|
||||
|
||||
/// The underlying [`hecs::World`], for attaching extra components.
|
||||
///
|
||||
/// Spawning or despawning directly through the world bypasses the scene's
|
||||
/// hierarchy bookkeeping; use [`spawn`](Self::spawn) /
|
||||
/// [`despawn`](Self::despawn) for lifecycle and reserve this for adding or
|
||||
/// querying non-hierarchy components.
|
||||
pub fn world_mut(&mut self) -> &mut World {
|
||||
&mut self.world
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::math::Vec3;
|
||||
|
||||
fn t(x: f32, y: f32, z: f32) -> Transform {
|
||||
Transform::from_translation(Vec3::new(x, y, z))
|
||||
}
|
||||
|
||||
fn approx(a: Vec3, b: Vec3) -> bool {
|
||||
(a - b).length() <= 1e-5
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_makes_roots() {
|
||||
let mut scene = Scene::new();
|
||||
let a = scene.spawn("a", Transform::IDENTITY);
|
||||
let b = scene.spawn("b", Transform::IDENTITY);
|
||||
assert_eq!(scene.roots(), &[a, b]);
|
||||
assert_eq!(scene.len(), 2);
|
||||
assert_eq!(scene.parent(a), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_child_links_both_ways() {
|
||||
let mut scene = Scene::new();
|
||||
let parent = scene.spawn("parent", Transform::IDENTITY);
|
||||
let child = scene.spawn_child(parent, "child", Transform::IDENTITY);
|
||||
assert_eq!(scene.parent(child), Some(parent));
|
||||
assert_eq!(scene.children(parent), &[child]);
|
||||
assert_eq!(scene.roots(), &[parent]); // child is not a root
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn world_transform_composes_down_the_chain() {
|
||||
let mut scene = Scene::new();
|
||||
let a = scene.spawn("a", t(1.0, 0.0, 0.0));
|
||||
let b = scene.spawn_child(a, "b", t(0.0, 2.0, 0.0));
|
||||
let c = scene.spawn_child(b, "c", t(0.0, 0.0, 3.0));
|
||||
let w = scene.world_transform(c).unwrap();
|
||||
assert!(approx(w.translation, Vec3::new(1.0, 2.0, 3.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bulk_world_transforms_match_single() {
|
||||
let mut scene = Scene::new();
|
||||
let a = scene.spawn("a", t(5.0, 0.0, 0.0));
|
||||
let b = scene.spawn_child(a, "b", t(0.0, 1.0, 0.0));
|
||||
let c = scene.spawn_child(a, "c", t(0.0, 0.0, 1.0));
|
||||
let all = scene.world_transforms();
|
||||
for e in [a, b, c] {
|
||||
assert!(approx(
|
||||
all[&e].translation,
|
||||
scene.world_transform(e).unwrap().translation
|
||||
));
|
||||
}
|
||||
assert!(approx(all[&b].translation, Vec3::new(5.0, 1.0, 0.0)));
|
||||
assert!(approx(all[&c].translation, Vec3::new(5.0, 0.0, 1.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotation_propagates_to_children() {
|
||||
use crate::math::Quat;
|
||||
use std::f32::consts::FRAC_PI_2;
|
||||
let mut scene = Scene::new();
|
||||
// Parent rotated 90° about Z, child offset +X by 1.
|
||||
let parent = scene.spawn(
|
||||
"p",
|
||||
Transform::from_rotation(Quat::from_rotation_z(FRAC_PI_2)),
|
||||
);
|
||||
let child = scene.spawn_child(parent, "c", t(1.0, 0.0, 0.0));
|
||||
let w = scene.world_transform(child).unwrap();
|
||||
// The +X offset is rotated into +Y by the parent.
|
||||
assert!(approx(w.translation, Vec3::new(0.0, 1.0, 0.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn despawn_recursive_removes_subtree() {
|
||||
let mut scene = Scene::new();
|
||||
let a = scene.spawn("a", Transform::IDENTITY);
|
||||
let b = scene.spawn_child(a, "b", Transform::IDENTITY);
|
||||
let c = scene.spawn_child(b, "c", Transform::IDENTITY);
|
||||
assert!(scene.despawn(a, DespawnPolicy::Recursive));
|
||||
assert!(!scene.contains(a));
|
||||
assert!(!scene.contains(b));
|
||||
assert!(!scene.contains(c));
|
||||
assert!(scene.roots().is_empty());
|
||||
assert_eq!(scene.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn despawn_detach_promotes_children_to_grandparent() {
|
||||
let mut scene = Scene::new();
|
||||
let root = scene.spawn("root", Transform::IDENTITY);
|
||||
let mid = scene.spawn_child(root, "mid", Transform::IDENTITY);
|
||||
let leaf = scene.spawn_child(mid, "leaf", Transform::IDENTITY);
|
||||
assert!(scene.despawn(mid, DespawnPolicy::DetachChildren));
|
||||
assert!(!scene.contains(mid));
|
||||
assert!(scene.contains(leaf));
|
||||
// leaf is now a child of root directly.
|
||||
assert_eq!(scene.parent(leaf), Some(root));
|
||||
assert_eq!(scene.children(root), &[leaf]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn despawn_detach_root_promotes_children_to_roots() {
|
||||
let mut scene = Scene::new();
|
||||
let root = scene.spawn("root", Transform::IDENTITY);
|
||||
let child = scene.spawn_child(root, "child", Transform::IDENTITY);
|
||||
assert!(scene.despawn(root, DespawnPolicy::DetachChildren));
|
||||
assert_eq!(scene.parent(child), None);
|
||||
assert_eq!(scene.roots(), &[child]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reparent_updates_links() {
|
||||
let mut scene = Scene::new();
|
||||
let a = scene.spawn("a", Transform::IDENTITY);
|
||||
let b = scene.spawn("b", Transform::IDENTITY);
|
||||
let c = scene.spawn_child(a, "c", Transform::IDENTITY);
|
||||
scene.set_parent(c, Some(b)).unwrap();
|
||||
assert_eq!(scene.parent(c), Some(b));
|
||||
assert_eq!(scene.children(a), &[] as &[Entity]);
|
||||
assert_eq!(scene.children(b), &[c]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reparent_to_root() {
|
||||
let mut scene = Scene::new();
|
||||
let a = scene.spawn("a", Transform::IDENTITY);
|
||||
let c = scene.spawn_child(a, "c", Transform::IDENTITY);
|
||||
scene.set_parent(c, None).unwrap();
|
||||
assert_eq!(scene.parent(c), None);
|
||||
assert!(scene.roots().contains(&c));
|
||||
assert_eq!(scene.children(a), &[] as &[Entity]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reparent_cycle_is_rejected() {
|
||||
let mut scene = Scene::new();
|
||||
let a = scene.spawn("a", Transform::IDENTITY);
|
||||
let b = scene.spawn_child(a, "b", Transform::IDENTITY);
|
||||
// Making `a` a child of its own descendant `b` would form a cycle.
|
||||
assert_eq!(scene.set_parent(a, Some(b)), Err(SceneError::WouldCycle));
|
||||
// Self-parenting is also a cycle.
|
||||
assert_eq!(scene.set_parent(a, Some(a)), Err(SceneError::WouldCycle));
|
||||
// The hierarchy is unchanged.
|
||||
assert_eq!(scene.parent(b), Some(a));
|
||||
assert_eq!(scene.parent(a), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reparent_missing_entity_errors() {
|
||||
let mut scene = Scene::new();
|
||||
let a = scene.spawn("a", Transform::IDENTITY);
|
||||
scene.despawn(a, DespawnPolicy::Recursive);
|
||||
assert_eq!(scene.set_parent(a, None), Err(SceneError::NoSuchEntity));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enable_and_rename() {
|
||||
let mut scene = Scene::new();
|
||||
let a = scene.spawn("a", Transform::IDENTITY);
|
||||
assert_eq!(scene.is_enabled(a), Some(true));
|
||||
assert!(scene.set_enabled(a, false));
|
||||
assert_eq!(scene.is_enabled(a), Some(false));
|
||||
assert!(scene.set_name(a, "renamed"));
|
||||
assert_eq!(scene.name(a).as_deref(), Some("renamed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reorder_moves_within_and_between_parents() {
|
||||
let mut scene = Scene::new();
|
||||
let a = scene.spawn("a", Transform::IDENTITY);
|
||||
let b = scene.spawn("b", Transform::IDENTITY);
|
||||
let c = scene.spawn("c", Transform::IDENTITY);
|
||||
assert_eq!(scene.roots(), &[a, b, c]);
|
||||
|
||||
// Reorder within the root list: move c before a → [c, a, b].
|
||||
scene.reorder(c, None, Some(a)).unwrap();
|
||||
assert_eq!(scene.roots(), &[c, a, b]);
|
||||
|
||||
// Same-parent move that crosses its old slot (no off-by-one): move c to
|
||||
// just before b → [a, c, b].
|
||||
scene.reorder(c, None, Some(b)).unwrap();
|
||||
assert_eq!(scene.roots(), &[a, c, b]);
|
||||
|
||||
// Reparent + position: put b under a, before none → appended child.
|
||||
scene.reorder(b, Some(a), None).unwrap();
|
||||
assert_eq!(scene.roots(), &[a, c]);
|
||||
assert_eq!(scene.children(a), &[b]);
|
||||
assert_eq!(scene.parent(b), Some(a));
|
||||
|
||||
// Insert before an existing child: c under a, before b → [c, b].
|
||||
scene.reorder(c, Some(a), Some(b)).unwrap();
|
||||
assert_eq!(scene.children(a), &[c, b]);
|
||||
|
||||
// Cycle rejected: a cannot become a child of its descendant b.
|
||||
assert!(matches!(
|
||||
scene.reorder(a, Some(b), None),
|
||||
Err(SceneError::WouldCycle)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_auto_attaches_layers_default() {
|
||||
// Every entity is inherently *on* some layer (the default if not
|
||||
// overridden) — so Layer is a node-baked component the scene always
|
||||
// provides, not something the user has to add. Pinned here.
|
||||
use super::super::super::layer::Layer;
|
||||
let mut scene = Scene::new();
|
||||
let e = scene.spawn("e", Transform::IDENTITY);
|
||||
{
|
||||
let layers = scene.world().get::<&Layer>(e).expect("Layer attached");
|
||||
assert_eq!(*layers, Layer::DEFAULT);
|
||||
}
|
||||
|
||||
let child = scene.spawn_child(e, "child", Transform::IDENTITY);
|
||||
let layers = scene
|
||||
.world()
|
||||
.get::<&Layer>(child)
|
||||
.expect("child gets Layer too");
|
||||
assert_eq!(*layers, Layer::DEFAULT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_component_disabled_reads_the_disabled_set() {
|
||||
use super::super::DisabledComponents;
|
||||
let mut scene = Scene::new();
|
||||
let e = scene.spawn("e", Transform::IDENTITY);
|
||||
// No DisabledComponents attached → nothing is disabled.
|
||||
assert!(!scene.is_component_disabled(e, "MeshRenderer"));
|
||||
|
||||
let mut d = DisabledComponents::new();
|
||||
d.set_disabled("MeshRenderer", true);
|
||||
scene.world_mut().insert_one(e, d).unwrap();
|
||||
assert!(scene.is_component_disabled(e, "MeshRenderer"));
|
||||
assert!(!scene.is_component_disabled(e, "RigidBody"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_enabled_cascades_from_ancestors() {
|
||||
let mut scene = Scene::new();
|
||||
let player = scene.spawn("player", Transform::IDENTITY);
|
||||
let camera = scene.spawn_child(player, "camera", Transform::IDENTITY);
|
||||
let mesh = scene.spawn_child(camera, "mesh", Transform::IDENTITY);
|
||||
|
||||
// All enabled by default → effectively enabled.
|
||||
assert_eq!(scene.is_effectively_enabled(mesh), Some(true));
|
||||
|
||||
// Disabling the root disables the whole subtree's effective state,
|
||||
// even though each descendant's own flag is still true.
|
||||
scene.set_enabled(player, false);
|
||||
assert_eq!(scene.is_enabled(camera), Some(true)); // own flag unchanged
|
||||
assert_eq!(scene.is_effectively_enabled(camera), Some(false));
|
||||
assert_eq!(scene.is_effectively_enabled(mesh), Some(false));
|
||||
|
||||
// Re-enable the root; disable a middle node → only it + below are off.
|
||||
scene.set_enabled(player, true);
|
||||
scene.set_enabled(camera, false);
|
||||
assert_eq!(scene.is_effectively_enabled(player), Some(true));
|
||||
assert_eq!(scene.is_effectively_enabled(camera), Some(false));
|
||||
assert_eq!(scene.is_effectively_enabled(mesh), Some(false));
|
||||
|
||||
// A dead entity has no effective state.
|
||||
let ghost = scene.spawn("ghost", Transform::IDENTITY);
|
||||
scene.despawn(ghost, DespawnPolicy::Recursive);
|
||||
assert_eq!(scene.is_effectively_enabled(ghost), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extra_components_via_world() {
|
||||
// The scene is a real ECS: extra components can ride along on entities.
|
||||
let mut scene = Scene::new();
|
||||
let a = scene.spawn("a", Transform::IDENTITY);
|
||||
scene.world_mut().insert_one(a, 42u32).unwrap();
|
||||
assert_eq!(*scene.get::<u32>(a).unwrap(), 42);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//! Scene graph and entity management.
|
||||
//!
|
||||
//! Stage 3 builds the world model every later system plugs into. Entities are
|
||||
//! [`hecs`] handles living in a [`Scene`], which adds a parent/child
|
||||
//! [`Transform`](crate::math::Transform) hierarchy on top of the bare ECS:
|
||||
//!
|
||||
//! - [`Scene`] — owns the entities and the hierarchy; spawn, despawn,
|
||||
//! reparent, query, and resolve world-space transforms
|
||||
//! - [`Node`] — per-entity metadata (`name`, `enabled`)
|
||||
//! - [`DespawnPolicy`] — whether despawning takes the subtree with it or
|
||||
//! detaches the children
|
||||
//!
|
||||
//! Local transforms are authored per entity; the scene resolves them against
|
||||
//! the hierarchy on demand ([`Scene::world_transform`],
|
||||
//! [`Scene::world_transforms`]). The node-baked hierarchy serializes to RON via
|
||||
//! [`Scene::to_ron`] / [`Scene::from_ron`]; a registry-aware
|
||||
//! [`SceneSnapshot`] (via [`Scene::snapshot`]) additionally captures every
|
||||
//! reflected component, for play-mode restore and full scene files.
|
||||
//!
|
||||
//! `hecs` is re-exported as [`oxide_engine::hecs`](crate::hecs) so consumers
|
||||
//! share one copy of [`Entity`](hecs::Entity) and the query API.
|
||||
|
||||
mod disabled;
|
||||
mod graph;
|
||||
mod node;
|
||||
mod serialize;
|
||||
mod snapshot;
|
||||
|
||||
pub use disabled::DisabledComponents;
|
||||
pub use graph::{DespawnPolicy, Scene};
|
||||
pub use node::Node;
|
||||
pub use snapshot::SceneSnapshot;
|
||||
|
||||
// The handle type is part of the public scene API; re-export it here so callers
|
||||
// can name it without reaching into the `hecs` re-export.
|
||||
pub use hecs::Entity;
|
||||
|
||||
/// Errors produced by scene operations.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum SceneError {
|
||||
/// An operation referenced an entity that is not live in this scene.
|
||||
#[error("entity does not exist in this scene")]
|
||||
NoSuchEntity,
|
||||
|
||||
/// A reparent would have made an entity its own ancestor.
|
||||
#[error("cannot parent an entity to itself or one of its descendants")]
|
||||
WouldCycle,
|
||||
|
||||
/// Encoding the scene to RON failed.
|
||||
#[error("scene serialization failed: {0}")]
|
||||
Serialize(String),
|
||||
|
||||
/// Decoding the scene from RON failed, or the data was inconsistent.
|
||||
#[error("scene deserialization failed: {0}")]
|
||||
Deserialize(String),
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
//! The [`Node`] component: per-entity scene metadata.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Metadata attached to every entity that participates in the scene graph.
|
||||
///
|
||||
/// A `Node` carries the human-facing identity of an entity (its `name`, shown
|
||||
/// in the editor hierarchy) and an `enabled` flag. Disabling a node is a
|
||||
/// declaration of intent that later systems honor — rendering, physics, and
|
||||
/// audio skip disabled subtrees — but it does **not** affect transform
|
||||
/// resolution, which is purely geometric. Stage 3 only stores and edits the
|
||||
/// flag; the systems that act on it arrive in later stages.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, crate::reflect::Reflect)]
|
||||
pub struct Node {
|
||||
/// Display name. Need not be unique; entities are identified by their
|
||||
/// [`Entity`](hecs::Entity) handle, not by name.
|
||||
pub name: String,
|
||||
/// Whether this node (and, by convention, its subtree) is active.
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
impl Node {
|
||||
/// Creates an enabled node with the given name.
|
||||
pub fn new(name: impl Into<String>) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Node {
|
||||
/// An enabled, unnamed node.
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
name: String::new(),
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Into<String>> From<T> for Node {
|
||||
fn from(name: T) -> Self {
|
||||
Self::new(name)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
//! RON serialization for [`Scene`].
|
||||
//!
|
||||
//! `hecs::Entity` handles are runtime values that are not stable across a
|
||||
//! save/load, so the scene is flattened to a list of records with array
|
||||
//! indices standing in for entity references. The list is built in a
|
||||
//! deterministic pre-order walk of the hierarchy, so a serialize → deserialize
|
||||
//! → serialize cycle is byte-for-byte stable.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use hecs::Entity;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{Node, Scene, SceneError};
|
||||
use crate::math::Transform;
|
||||
|
||||
/// One entity in the flattened scene. `children` holds indices into the
|
||||
/// surrounding [`SceneData::nodes`] list.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
struct NodeRecord {
|
||||
name: String,
|
||||
enabled: bool,
|
||||
transform: Transform,
|
||||
children: Vec<usize>,
|
||||
}
|
||||
|
||||
/// The serializable form of a [`Scene`]: a flat node list plus the indices of
|
||||
/// the root nodes. Parent links are implied by the `children` arrays.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
struct SceneData {
|
||||
nodes: Vec<NodeRecord>,
|
||||
roots: Vec<usize>,
|
||||
}
|
||||
|
||||
impl Scene {
|
||||
/// Serializes the scene to a pretty-printed RON string.
|
||||
pub fn to_ron(&self) -> Result<String, SceneError> {
|
||||
let data = self.to_data();
|
||||
ron::ser::to_string_pretty(&data, ron::ser::PrettyConfig::default())
|
||||
.map_err(|e| SceneError::Serialize(e.to_string()))
|
||||
}
|
||||
|
||||
/// Reconstructs a scene from a RON string produced by [`to_ron`](Self::to_ron).
|
||||
pub fn from_ron(ron: &str) -> Result<Scene, SceneError> {
|
||||
let data: SceneData =
|
||||
ron::from_str(ron).map_err(|e| SceneError::Deserialize(e.to_string()))?;
|
||||
Scene::from_data(&data)
|
||||
}
|
||||
|
||||
/// Flattens the hierarchy into index-based records via a deterministic
|
||||
/// pre-order walk (roots in order, then each subtree depth-first).
|
||||
fn to_data(&self) -> SceneData {
|
||||
let mut index: HashMap<Entity, usize> = HashMap::with_capacity(self.len());
|
||||
let mut order: Vec<Entity> = Vec::with_capacity(self.len());
|
||||
for &root in self.roots() {
|
||||
self.assign_indices(root, &mut index, &mut order);
|
||||
}
|
||||
|
||||
let nodes = order
|
||||
.iter()
|
||||
.map(|&entity| {
|
||||
let node = self
|
||||
.get::<Node>(entity)
|
||||
.expect("entity in hierarchy must have a Node");
|
||||
let transform = self
|
||||
.local_transform(entity)
|
||||
.expect("entity in hierarchy must have a Transform");
|
||||
NodeRecord {
|
||||
name: node.name.clone(),
|
||||
enabled: node.enabled,
|
||||
transform,
|
||||
children: self.children(entity).iter().map(|c| index[c]).collect(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let roots = self.roots().iter().map(|r| index[r]).collect();
|
||||
SceneData { nodes, roots }
|
||||
}
|
||||
|
||||
/// Pre-order index assignment helper for [`to_data`](Self::to_data).
|
||||
fn assign_indices(
|
||||
&self,
|
||||
entity: Entity,
|
||||
index: &mut HashMap<Entity, usize>,
|
||||
order: &mut Vec<Entity>,
|
||||
) {
|
||||
index.insert(entity, order.len());
|
||||
order.push(entity);
|
||||
for &child in self.children(entity) {
|
||||
self.assign_indices(child, index, order);
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuilds a scene from flattened records, validating index references.
|
||||
fn from_data(data: &SceneData) -> Result<Scene, SceneError> {
|
||||
let mut scene = Scene::new();
|
||||
let n = data.nodes.len();
|
||||
|
||||
// Spawn every entity first (as a root), so all indices resolve before
|
||||
// wiring up parent/child links.
|
||||
let entities: Vec<Entity> = data
|
||||
.nodes
|
||||
.iter()
|
||||
.map(|rec| {
|
||||
scene.spawn(
|
||||
Node {
|
||||
name: rec.name.clone(),
|
||||
enabled: rec.enabled,
|
||||
},
|
||||
rec.transform,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Re-link: each record's children become children of that record's
|
||||
// entity (and are removed from the root list).
|
||||
for (i, rec) in data.nodes.iter().enumerate() {
|
||||
for &child_idx in &rec.children {
|
||||
let child = *entities
|
||||
.get(child_idx)
|
||||
.ok_or(SceneError::Deserialize(format!(
|
||||
"child index {child_idx} out of range (have {n} nodes)"
|
||||
)))?;
|
||||
scene
|
||||
.set_parent(child, Some(entities[i]))
|
||||
.map_err(|e| SceneError::Deserialize(e.to_string()))?;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate the declared roots match the entities left parentless. The
|
||||
// re-link above already produced the correct root set; we just confirm
|
||||
// the file's `roots` list is consistent so corrupt input is rejected.
|
||||
for &root_idx in &data.roots {
|
||||
let entity = *entities
|
||||
.get(root_idx)
|
||||
.ok_or(SceneError::Deserialize(format!(
|
||||
"root index {root_idx} out of range (have {n} nodes)"
|
||||
)))?;
|
||||
if scene.parent(entity).is_some() {
|
||||
return Err(SceneError::Deserialize(format!(
|
||||
"node {root_idx} is listed as a root but is also a child"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(scene)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::math::{Quat, Vec3};
|
||||
use crate::scene::DespawnPolicy;
|
||||
|
||||
/// Builds a small, varied scene used by the round-trip tests.
|
||||
fn sample() -> Scene {
|
||||
let mut scene = Scene::new();
|
||||
let root = scene.spawn(
|
||||
Node {
|
||||
name: "root".into(),
|
||||
enabled: true,
|
||||
},
|
||||
Transform::from_translation(Vec3::new(1.0, 2.0, 3.0)),
|
||||
);
|
||||
let arm = scene.spawn_child(
|
||||
root,
|
||||
"arm",
|
||||
Transform::from_rotation(Quat::from_rotation_y(0.5)),
|
||||
);
|
||||
scene.spawn_child(arm, "hand", Transform::from_scale(Vec3::splat(2.0)));
|
||||
let mut disabled = Node::new("disabled");
|
||||
disabled.enabled = false;
|
||||
scene.spawn_child(root, disabled, Transform::IDENTITY);
|
||||
// A second independent root, to exercise multi-root serialization.
|
||||
scene.spawn("other-root", Transform::from_translation(Vec3::NEG_X));
|
||||
scene
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_preserves_structure() {
|
||||
let scene = sample();
|
||||
let ron = scene.to_ron().unwrap();
|
||||
let restored = Scene::from_ron(&ron).unwrap();
|
||||
|
||||
// Re-serializing the restored scene yields identical text: structure,
|
||||
// names, flags, transforms, and ordering all survived.
|
||||
assert_eq!(ron, restored.to_ron().unwrap());
|
||||
assert_eq!(scene.len(), restored.len());
|
||||
assert_eq!(scene.roots().len(), restored.roots().len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_preserves_world_transforms() {
|
||||
let scene = sample();
|
||||
let restored = Scene::from_ron(&scene.to_ron().unwrap()).unwrap();
|
||||
|
||||
// Compare resolved world transforms by name, since entity ids differ
|
||||
// across the rebuild.
|
||||
let by_name = |s: &Scene| -> Vec<(String, Vec3)> {
|
||||
let worlds = s.world_transforms();
|
||||
let mut v: Vec<_> = worlds
|
||||
.iter()
|
||||
.map(|(&e, t)| (s.name(e).unwrap(), t.translation))
|
||||
.collect();
|
||||
v.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
v
|
||||
};
|
||||
let a = by_name(&scene);
|
||||
let b = by_name(&restored);
|
||||
assert_eq!(a.len(), b.len());
|
||||
for ((na, ta), (nb, tb)) in a.iter().zip(b.iter()) {
|
||||
assert_eq!(na, nb);
|
||||
assert!((*ta - *tb).length() <= 1e-5, "{na}: {ta} vs {tb}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_scene_round_trips() {
|
||||
let scene = Scene::new();
|
||||
let restored = Scene::from_ron(&scene.to_ron().unwrap()).unwrap();
|
||||
assert!(restored.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reordering_after_edits_still_round_trips() {
|
||||
// Mutating the scene (despawn) must not break index bookkeeping.
|
||||
let mut scene = sample();
|
||||
let root = scene.roots()[0];
|
||||
let kid = scene.children(root)[0];
|
||||
scene.despawn(kid, DespawnPolicy::DetachChildren);
|
||||
let ron = scene.to_ron().unwrap();
|
||||
assert_eq!(ron, Scene::from_ron(&ron).unwrap().to_ron().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_child_index_is_rejected() {
|
||||
let bad = r#"(nodes: [(name: "a", enabled: true, transform: (translation: (0,0,0), rotation: (0,0,0,1), scale: (1,1,1)), children: [5])], roots: [0])"#;
|
||||
assert!(Scene::from_ron(bad).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
//! Registry-aware full-scene capture, for play mode (and future scene files).
|
||||
//!
|
||||
//! [`Scene::to_ron`](super::Scene::to_ron) records only the node-baked
|
||||
//! `Node`/`Transform`/hierarchy. A [`SceneSnapshot`] additionally captures
|
||||
//! **every reflected component** on each entity through the
|
||||
//! [`TypeRegistry`], so a scene mutated while *playing* (physics moving bodies,
|
||||
//! scripts spawning or editing entities) can be restored **bit-for-bit** when
|
||||
//! play stops — avoiding Unity's classic "edited in play mode, lost it" footgun.
|
||||
//!
|
||||
//! Why a separate type from [`SceneData`](super::serialize): the plain RON form
|
||||
//! is registry-free (it can round-trip without knowing any component types),
|
||||
//! whereas a snapshot needs the registry to enumerate and serialize arbitrary
|
||||
//! components. Keeping the two apart means the cheap path stays cheap.
|
||||
//!
|
||||
//! Fidelity is bounded by what is *registered*, plus the engine's intrinsic
|
||||
//! components: every reflected type in the [`TypeRegistry`] is captured, as are
|
||||
//! the built-in non-reflected components (`Node`/`Transform`, plus the inspector-
|
||||
//! hidden [`Tags`] and [`DisabledComponents`]). A *module's* component that is
|
||||
//! neither registered nor one of those is invisible to capture — modules that
|
||||
//! want play-mode survival register their components, which they do anyway to be
|
||||
//! editable. Within that set, capture → restore → capture is stable.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
use hecs::Entity;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{DisabledComponents, Node, Scene, SceneError};
|
||||
use crate::layer::Tags;
|
||||
use crate::math::Transform;
|
||||
use crate::reflect::TypeRegistry;
|
||||
|
||||
/// Components captured explicitly via [`Scene::spawn`] on restore, so they are
|
||||
/// excluded from the per-node component map to avoid storing them twice.
|
||||
/// (`Layer` is *not* here: it is auto-attached on spawn but carries authored
|
||||
/// data, so it round-trips through the component map like any other component.)
|
||||
const SPAWN_BAKED: [&str; 2] = ["Node", "Transform"];
|
||||
|
||||
/// One entity in a flattened [`SceneSnapshot`]. `children` holds indices into
|
||||
/// the surrounding [`SceneSnapshot::nodes`] list (entity handles are not stable
|
||||
/// across a capture/restore, so positions stand in for references).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
struct SnapshotNode {
|
||||
name: String,
|
||||
enabled: bool,
|
||||
transform: Transform,
|
||||
children: Vec<usize>,
|
||||
/// Every *other* registered component on the node, `type_name` → RON.
|
||||
/// A `BTreeMap` so the serialized form is order-stable.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
components: BTreeMap<String, String>,
|
||||
/// The entity's gameplay [`Tags`], if any. Captured directly (not via the
|
||||
/// registry) because `Tags` is an engine-intrinsic component edited through
|
||||
/// the inspector's dedicated Groups UI, not registered as a generic
|
||||
/// reflected type — without this, Play→Stop would wipe group membership.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
tags: Option<Tags>,
|
||||
/// The entity's [`DisabledComponents`] set, if any. Captured directly for
|
||||
/// the same reason as [`tags`](Self::tags): it's hidden engine metadata, not
|
||||
/// a reflected authored component.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
disabled: Option<DisabledComponents>,
|
||||
}
|
||||
|
||||
/// A complete, restorable capture of a [`Scene`]: hierarchy plus every reflected
|
||||
/// component on each node. See the [module docs](self).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SceneSnapshot {
|
||||
nodes: Vec<SnapshotNode>,
|
||||
roots: Vec<usize>,
|
||||
}
|
||||
|
||||
impl SceneSnapshot {
|
||||
/// Captures `scene` in full, serializing every component the `registry`
|
||||
/// knows about on each entity. Built from a deterministic pre-order walk of
|
||||
/// the hierarchy, so two captures of equal scenes compare equal.
|
||||
pub fn capture(scene: &Scene, registry: &TypeRegistry) -> Self {
|
||||
let mut index: HashMap<Entity, usize> = HashMap::with_capacity(scene.len());
|
||||
let mut order: Vec<Entity> = Vec::with_capacity(scene.len());
|
||||
for &root in scene.roots() {
|
||||
assign_indices(scene, root, &mut index, &mut order);
|
||||
}
|
||||
|
||||
let nodes = order
|
||||
.iter()
|
||||
.map(|&entity| {
|
||||
// Pull the node-baked fields first, then drop the borrow before
|
||||
// the reflection walk reads the same world.
|
||||
let (name, enabled) = {
|
||||
let node = scene
|
||||
.get::<Node>(entity)
|
||||
.expect("entity in hierarchy must have a Node");
|
||||
(node.name.clone(), node.enabled)
|
||||
};
|
||||
let transform = scene
|
||||
.local_transform(entity)
|
||||
.expect("entity in hierarchy must have a Transform");
|
||||
|
||||
let mut components = BTreeMap::new();
|
||||
for type_name in registry.components_on(scene.world(), entity) {
|
||||
if SPAWN_BAKED.contains(&type_name) {
|
||||
continue;
|
||||
}
|
||||
if let Ok(ron) = registry.get_ron(scene.world(), entity, type_name) {
|
||||
components.insert(type_name.to_string(), ron);
|
||||
}
|
||||
}
|
||||
|
||||
SnapshotNode {
|
||||
name,
|
||||
enabled,
|
||||
transform,
|
||||
children: scene.children(entity).iter().map(|c| index[c]).collect(),
|
||||
components,
|
||||
tags: scene.get::<Tags>(entity).map(|t| (*t).clone()),
|
||||
disabled: scene
|
||||
.get::<DisabledComponents>(entity)
|
||||
.map(|d| (*d).clone()),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let roots = scene.roots().iter().map(|r| index[r]).collect();
|
||||
SceneSnapshot { nodes, roots }
|
||||
}
|
||||
|
||||
/// Rebuilds a fresh [`Scene`] from this snapshot. Spawns every node (which
|
||||
/// auto-attaches the node-baked `Node`/`Transform`/`Layer`), re-links the
|
||||
/// hierarchy, then applies each captured component over the defaults.
|
||||
///
|
||||
/// Entity handles in the new scene differ from the captured ones — callers
|
||||
/// holding an [`Entity`] (e.g. an editor selection) must drop or re-resolve
|
||||
/// it after a restore.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Deserialize`](SceneError::Deserialize) if an index reference is out of
|
||||
/// range or a component's stored RON no longer parses for its type.
|
||||
pub fn restore(&self, registry: &TypeRegistry) -> Result<Scene, SceneError> {
|
||||
let mut scene = Scene::new();
|
||||
let n = self.nodes.len();
|
||||
|
||||
// Spawn every entity first (as a root) so all indices resolve before
|
||||
// wiring parent/child links.
|
||||
let entities: Vec<Entity> = self
|
||||
.nodes
|
||||
.iter()
|
||||
.map(|rec| {
|
||||
scene.spawn(
|
||||
Node {
|
||||
name: rec.name.clone(),
|
||||
enabled: rec.enabled,
|
||||
},
|
||||
rec.transform,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Re-link the hierarchy.
|
||||
for (i, rec) in self.nodes.iter().enumerate() {
|
||||
for &child_idx in &rec.children {
|
||||
let child = *entities.get(child_idx).ok_or_else(|| {
|
||||
SceneError::Deserialize(format!(
|
||||
"child index {child_idx} out of range (have {n} nodes)"
|
||||
))
|
||||
})?;
|
||||
scene
|
||||
.set_parent(child, Some(entities[i]))
|
||||
.map_err(|e| SceneError::Deserialize(e.to_string()))?;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply captured components over the spawn defaults.
|
||||
for (i, rec) in self.nodes.iter().enumerate() {
|
||||
for (type_name, ron) in &rec.components {
|
||||
registry
|
||||
.set_ron(scene.world_mut(), entities[i], type_name, ron)
|
||||
.map_err(|e| SceneError::Deserialize(e.to_string()))?;
|
||||
}
|
||||
// Reinstate the engine-intrinsic, non-reflected components.
|
||||
if let Some(tags) = &rec.tags {
|
||||
let _ = scene.world_mut().insert_one(entities[i], tags.clone());
|
||||
}
|
||||
if let Some(disabled) = &rec.disabled {
|
||||
let _ = scene.world_mut().insert_one(entities[i], disabled.clone());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(scene)
|
||||
}
|
||||
|
||||
/// Serializes the snapshot to a pretty-printed RON string.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Serialize`](SceneError::Serialize) if encoding fails.
|
||||
pub fn to_ron(&self) -> Result<String, SceneError> {
|
||||
ron::ser::to_string_pretty(self, ron::ser::PrettyConfig::default())
|
||||
.map_err(|e| SceneError::Serialize(e.to_string()))
|
||||
}
|
||||
|
||||
/// Reconstructs a snapshot from a string produced by [`to_ron`](Self::to_ron).
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Deserialize`](SceneError::Deserialize) if the text is not valid.
|
||||
pub fn from_ron(ron: &str) -> Result<Self, SceneError> {
|
||||
ron::from_str(ron).map_err(|e| SceneError::Deserialize(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Pre-order index assignment: mirrors the plain-RON walk so snapshot and
|
||||
/// `to_ron` order entities identically.
|
||||
fn assign_indices(
|
||||
scene: &Scene,
|
||||
entity: Entity,
|
||||
index: &mut HashMap<Entity, usize>,
|
||||
order: &mut Vec<Entity>,
|
||||
) {
|
||||
index.insert(entity, order.len());
|
||||
order.push(entity);
|
||||
for &child in scene.children(entity) {
|
||||
assign_indices(scene, child, index, order);
|
||||
}
|
||||
}
|
||||
|
||||
impl Scene {
|
||||
/// Captures this scene in full (hierarchy + every reflected component) into
|
||||
/// a restorable [`SceneSnapshot`]. See that type for why it differs from
|
||||
/// [`to_ron`](Self::to_ron).
|
||||
pub fn snapshot(&self, registry: &TypeRegistry) -> SceneSnapshot {
|
||||
SceneSnapshot::capture(self, registry)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::math::Vec3;
|
||||
use crate::render::{MeshRenderer, PrimitiveShape};
|
||||
|
||||
/// A registry seeded like the editor's: node-baked types plus a couple of
|
||||
/// modular components, so snapshots exercise the component map.
|
||||
fn registry() -> TypeRegistry {
|
||||
let mut r = TypeRegistry::new();
|
||||
r.register_reflected::<Transform>("Transform");
|
||||
r.register_reflected::<Node>("Node");
|
||||
r.register_reflected::<crate::layer::Layer>("Layer");
|
||||
r.register_reflected::<MeshRenderer>("MeshRenderer");
|
||||
r
|
||||
}
|
||||
|
||||
fn sample_scene() -> Scene {
|
||||
let mut scene = Scene::new();
|
||||
let parent = scene.spawn("parent", Transform::IDENTITY);
|
||||
let child = scene.spawn(
|
||||
"child",
|
||||
Transform::from_translation(Vec3::new(1.0, 2.0, 3.0)),
|
||||
);
|
||||
scene.set_parent(child, Some(parent)).unwrap();
|
||||
scene
|
||||
.world_mut()
|
||||
.insert_one(
|
||||
child,
|
||||
MeshRenderer {
|
||||
shape: PrimitiveShape::Sphere,
|
||||
..MeshRenderer::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
scene
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capture_restore_round_trips_components_and_hierarchy() {
|
||||
let reg = registry();
|
||||
let scene = sample_scene();
|
||||
let snap = scene.snapshot(®);
|
||||
|
||||
let restored = snap.restore(®).unwrap();
|
||||
// Hierarchy (plain RON) matches.
|
||||
assert_eq!(scene.to_ron().unwrap(), restored.to_ron().unwrap());
|
||||
// And the full snapshot (incl. components) matches.
|
||||
assert_eq!(snap, restored.snapshot(®));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_reinstates_a_modular_component() {
|
||||
let reg = registry();
|
||||
let scene = sample_scene();
|
||||
let snap = scene.snapshot(®);
|
||||
let restored = snap.restore(®).unwrap();
|
||||
|
||||
// The child's MeshRenderer (Sphere) survived the round-trip.
|
||||
let child = restored
|
||||
.entities()
|
||||
.find(|&e| {
|
||||
restored
|
||||
.get::<Node>(e)
|
||||
.map(|n| n.name == "child")
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.unwrap();
|
||||
let mesh = restored.get::<MeshRenderer>(child).unwrap();
|
||||
assert_eq!(mesh.shape, PrimitiveShape::Sphere);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stop_reverts_a_play_mode_mutation_bit_for_bit() {
|
||||
// The play-mode contract: snapshot, mutate (as a tick would), restore,
|
||||
// and the scene returns to its captured form.
|
||||
let reg = registry();
|
||||
let mut scene = sample_scene();
|
||||
let snap = scene.snapshot(®);
|
||||
|
||||
// Mutate every transform, as a physics/script tick might.
|
||||
let entities: Vec<_> = scene.entities().collect();
|
||||
for e in entities {
|
||||
let t = scene.local_transform(e).unwrap();
|
||||
scene.set_local_transform(e, Transform::from_translation(t.translation + Vec3::X));
|
||||
}
|
||||
assert_ne!(snap, scene.snapshot(®));
|
||||
|
||||
let reverted = snap.restore(®).unwrap();
|
||||
assert_eq!(snap, reverted.snapshot(®));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn captures_intrinsic_tags_and_disabled() {
|
||||
use crate::layer::Tags;
|
||||
use crate::scene::DisabledComponents;
|
||||
|
||||
let reg = registry();
|
||||
let mut scene = sample_scene();
|
||||
let parent = scene
|
||||
.entities()
|
||||
.find(|&e| {
|
||||
scene
|
||||
.get::<Node>(e)
|
||||
.map(|n| n.name == "parent")
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.unwrap();
|
||||
scene
|
||||
.world_mut()
|
||||
.insert_one(parent, Tags::single("Enemy"))
|
||||
.unwrap();
|
||||
let mut dc = DisabledComponents::new();
|
||||
dc.set_disabled("MeshRenderer", true);
|
||||
scene.world_mut().insert_one(parent, dc).unwrap();
|
||||
|
||||
let snap = scene.snapshot(®);
|
||||
let restored = snap.restore(®).unwrap();
|
||||
// Both engine-intrinsic, non-reflected components survive the round-trip
|
||||
// even though neither is in the registry.
|
||||
assert_eq!(snap, restored.snapshot(®));
|
||||
let rparent = restored
|
||||
.entities()
|
||||
.find(|&e| {
|
||||
restored
|
||||
.get::<Node>(e)
|
||||
.map(|n| n.name == "parent")
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.unwrap();
|
||||
assert!(restored.get::<Tags>(rparent).unwrap().contains("Enemy"));
|
||||
assert!(restored
|
||||
.get::<DisabledComponents>(rparent)
|
||||
.unwrap()
|
||||
.is_disabled("MeshRenderer"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ron_round_trips() {
|
||||
let reg = registry();
|
||||
let snap = sample_scene().snapshot(®);
|
||||
let ron = snap.to_ron().unwrap();
|
||||
assert_eq!(snap, SceneSnapshot::from_ron(&ron).unwrap());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
//! The settings / preferences framework.
|
||||
//!
|
||||
//! A unified, serialized configuration store shared by the engine, the editor,
|
||||
//! and modules. Each contributor registers a typed **section** (a plain
|
||||
//! `serde`-serializable struct) under a name; the framework persists every
|
||||
//! section to RON and restores it, without any central code knowing the
|
||||
//! sections' shapes. This is what lets:
|
||||
//!
|
||||
//! - **engine** preferences (render/quality defaults),
|
||||
//! - **editor** preferences (theme, layout, shortcuts), and
|
||||
//! - **per-module** settings (each module's own options)
|
||||
//!
|
||||
//! all live in one place, while a [`Project`](crate::project::Project) persists
|
||||
//! the per-project subset (it stores section → RON blobs that line up exactly
|
||||
//! with [`Settings::export`]/[`Settings::import`]).
|
||||
//!
|
||||
//! ```
|
||||
//! use oxide_engine::settings::Settings;
|
||||
//! use serde::{Serialize, Deserialize};
|
||||
//!
|
||||
//! #[derive(Serialize, Deserialize, Default, PartialEq, Debug)]
|
||||
//! struct EditorPrefs { theme: String, grid: bool }
|
||||
//!
|
||||
//! let mut settings = Settings::new();
|
||||
//! settings.register::<EditorPrefs>("editor");
|
||||
//! settings.get_mut::<EditorPrefs>("editor").unwrap().theme = "dark".into();
|
||||
//!
|
||||
//! // Persist every section to RON, and restore it later.
|
||||
//! let saved = settings.export();
|
||||
//! let mut restored = Settings::new();
|
||||
//! restored.register::<EditorPrefs>("editor");
|
||||
//! restored.import(&saved);
|
||||
//! assert_eq!(restored.get::<EditorPrefs>("editor").unwrap().theme, "dark");
|
||||
//! ```
|
||||
|
||||
use std::any::Any;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::Serialize;
|
||||
|
||||
/// The monomorphized operations for one registered section, as plain function
|
||||
/// pointers (the closures capture nothing).
|
||||
struct SectionOps {
|
||||
value: Box<dyn Any>,
|
||||
to_ron: fn(&dyn Any) -> Option<String>,
|
||||
from_ron: fn(&str) -> Option<Box<dyn Any>>,
|
||||
default: fn() -> Box<dyn Any>,
|
||||
}
|
||||
|
||||
/// A registry of typed, serializable settings sections keyed by name.
|
||||
#[derive(Default)]
|
||||
pub struct Settings {
|
||||
sections: BTreeMap<&'static str, SectionOps>,
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
/// An empty settings store.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Registers section type `T` under `name`, initialized to `T::default()`.
|
||||
/// Re-registering the same name resets it to default.
|
||||
pub fn register<T>(&mut self, name: &'static str)
|
||||
where
|
||||
T: Serialize + DeserializeOwned + Default + 'static,
|
||||
{
|
||||
self.sections.insert(
|
||||
name,
|
||||
SectionOps {
|
||||
value: Box::new(T::default()),
|
||||
to_ron: |any| any.downcast_ref::<T>().and_then(|v| ron::to_string(v).ok()),
|
||||
from_ron: |text| {
|
||||
ron::from_str::<T>(text)
|
||||
.ok()
|
||||
.map(|v| Box::new(v) as Box<dyn Any>)
|
||||
},
|
||||
default: || Box::new(T::default()) as Box<dyn Any>,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Whether a section is registered under `name`.
|
||||
pub fn is_registered(&self, name: &str) -> bool {
|
||||
self.sections.contains_key(name)
|
||||
}
|
||||
|
||||
/// The registered section names, sorted.
|
||||
pub fn names(&self) -> impl Iterator<Item = &'static str> + '_ {
|
||||
self.sections.keys().copied()
|
||||
}
|
||||
|
||||
/// Borrows section `name` as `T`, or `None` if absent or the type mismatches.
|
||||
pub fn get<T: 'static>(&self, name: &str) -> Option<&T> {
|
||||
self.sections.get(name)?.value.downcast_ref::<T>()
|
||||
}
|
||||
|
||||
/// Mutably borrows section `name` as `T`.
|
||||
pub fn get_mut<T: 'static>(&mut self, name: &str) -> Option<&mut T> {
|
||||
self.sections.get_mut(name)?.value.downcast_mut::<T>()
|
||||
}
|
||||
|
||||
/// Replaces the value of section `name`. Returns whether it was registered
|
||||
/// (with a matching type).
|
||||
pub fn set<T: 'static>(&mut self, name: &str, value: T) -> bool {
|
||||
match self.sections.get_mut(name) {
|
||||
// Only overwrite if the registered type matches.
|
||||
Some(section) if section.value.is::<T>() => {
|
||||
section.value = Box::new(value);
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resets section `name` to its default. Returns whether it was registered.
|
||||
pub fn reset(&mut self, name: &str) -> bool {
|
||||
match self.sections.get_mut(name) {
|
||||
Some(section) => {
|
||||
section.value = (section.default)();
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Serializes section `name` to RON, or `None` if it is not registered.
|
||||
pub fn section_ron(&self, name: &str) -> Option<String> {
|
||||
let section = self.sections.get(name)?;
|
||||
(section.to_ron)(section.value.as_ref())
|
||||
}
|
||||
|
||||
/// Loads section `name` from a RON blob, replacing its value. Returns `false`
|
||||
/// if the section is not registered or the text fails to parse.
|
||||
pub fn load_section(&mut self, name: &str, ron: &str) -> bool {
|
||||
match self.sections.get_mut(name) {
|
||||
Some(section) => match (section.from_ron)(ron) {
|
||||
Some(value) => {
|
||||
section.value = value;
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
},
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Serializes every section to a `name → RON` map (the format a
|
||||
/// [`Project`](crate::project::Project) stores).
|
||||
pub fn export(&self) -> BTreeMap<String, String> {
|
||||
self.sections
|
||||
.iter()
|
||||
.filter_map(|(name, section)| {
|
||||
(section.to_ron)(section.value.as_ref()).map(|ron| (name.to_string(), ron))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Loads every matching, registered section from a `name → RON` map.
|
||||
/// Unknown sections are ignored (a module may be disabled); malformed
|
||||
/// sections are skipped, leaving their current value.
|
||||
pub fn import(&mut self, map: &BTreeMap<String, String>) {
|
||||
for (name, ron) in map {
|
||||
self.load_section(name, ron);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize, Default, PartialEq, Debug)]
|
||||
struct EditorPrefs {
|
||||
theme: String,
|
||||
grid: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, PartialEq, Debug)]
|
||||
struct Render {
|
||||
shadows: bool,
|
||||
msaa: u32,
|
||||
}
|
||||
impl Default for Render {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
shadows: true,
|
||||
msaa: 4,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn settings() -> Settings {
|
||||
let mut s = Settings::new();
|
||||
s.register::<EditorPrefs>("editor");
|
||||
s.register::<Render>("render");
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_and_typed_access() {
|
||||
let mut s = settings();
|
||||
assert_eq!(s.names().collect::<Vec<_>>(), vec!["editor", "render"]);
|
||||
assert!(s.get::<Render>("render").unwrap().shadows);
|
||||
s.get_mut::<EditorPrefs>("editor").unwrap().theme = "dark".into();
|
||||
assert_eq!(s.get::<EditorPrefs>("editor").unwrap().theme, "dark");
|
||||
// Wrong type → None.
|
||||
assert!(s.get::<Render>("editor").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_and_reset() {
|
||||
let mut s = settings();
|
||||
assert!(s.set(
|
||||
"render",
|
||||
Render {
|
||||
shadows: false,
|
||||
msaa: 8
|
||||
}
|
||||
));
|
||||
assert_eq!(s.get::<Render>("render").unwrap().msaa, 8);
|
||||
// Setting an unregistered section fails.
|
||||
assert!(!s.set("missing", 5u32));
|
||||
// Reset returns to default.
|
||||
assert!(s.reset("render"));
|
||||
assert_eq!(s.get::<Render>("render").unwrap(), &Render::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_import_round_trips() {
|
||||
let mut s = settings();
|
||||
s.get_mut::<EditorPrefs>("editor").unwrap().theme = "light".into();
|
||||
s.get_mut::<EditorPrefs>("editor").unwrap().grid = true;
|
||||
s.set(
|
||||
"render",
|
||||
Render {
|
||||
shadows: false,
|
||||
msaa: 2,
|
||||
},
|
||||
);
|
||||
let saved = s.export();
|
||||
|
||||
// A fresh store with the same sections restores the saved values.
|
||||
let mut restored = settings();
|
||||
restored.import(&saved);
|
||||
assert_eq!(
|
||||
restored.get::<EditorPrefs>("editor").unwrap(),
|
||||
&EditorPrefs {
|
||||
theme: "light".into(),
|
||||
grid: true
|
||||
}
|
||||
);
|
||||
assert_eq!(restored.get::<Render>("render").unwrap().msaa, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_ignores_unknown_and_malformed() {
|
||||
let mut s = settings();
|
||||
let mut map = BTreeMap::new();
|
||||
map.insert("editor".to_string(), "(theme:\"x\",grid:true)".to_string());
|
||||
map.insert("disabled_module".to_string(), "(whatever:1)".to_string());
|
||||
map.insert("render".to_string(), "not valid ron".to_string());
|
||||
s.import(&map);
|
||||
// Known + valid applied.
|
||||
assert_eq!(s.get::<EditorPrefs>("editor").unwrap().theme, "x");
|
||||
// Malformed left the section at its default (unchanged).
|
||||
assert_eq!(s.get::<Render>("render").unwrap(), &Render::default());
|
||||
// Unknown silently ignored.
|
||||
assert!(!s.is_registered("disabled_module"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,763 @@
|
||||
//! Layout algorithm — turns a [`Widget`] tree into resolved screen rects.
|
||||
//!
|
||||
//! [`layout`] is a single recursive top-down pass that mixes a one-shot
|
||||
//! intrinsic-size measurement (for `FitContent` and `Grow` accounting) with
|
||||
//! the actual placement. The resulting [`LayoutTree`] is a flat `Vec` of
|
||||
//! [`LayoutNode`]s; each node records its own `rect`, `content_rect`
|
||||
//! (padding-inset), and the indices of its direct children. The layout
|
||||
//! function itself has no GPU, no input, no allocation outside the result —
|
||||
//! every test in this stage runs headlessly.
|
||||
//!
|
||||
//! # Slot vs rect, and why anchor children skip resizing
|
||||
//!
|
||||
//! The recursion uses two entry points:
|
||||
//!
|
||||
//! - [`arrange_in_slot`] is for stack / grid children and the root: the slot
|
||||
//! is the **outer space** the widget can occupy; the algorithm applies the
|
||||
//! widget's margin, sizing, and alignment to derive its rect.
|
||||
//! - [`arrange_in_rect`] is for anchor children: the rect is *already* what
|
||||
//! the anchor decided; the widget's margin / sizing / alignment are skipped
|
||||
//! so the anchor is authoritative. Padding still applies (it's an inside-
|
||||
//! the-rect concern). This matches the Unity/Godot convention that "anchor
|
||||
//! determines rect" — sizing knobs would let the child silently disagree
|
||||
//! with the anchor it was placed by.
|
||||
//!
|
||||
//! # DPI scale factor
|
||||
//!
|
||||
//! Every linear input (sizing, padding, margin, gaps, anchor offsets) is in
|
||||
//! logical pixels and multiplied by [`layout`]'s `scale` argument at resolve
|
||||
//! time. The widget tree is DPI-independent; the layout call is where the
|
||||
//! display's scale factor enters.
|
||||
|
||||
use glam::Vec2;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::math::Rect;
|
||||
|
||||
use super::style::{Align, Insets, LayoutStyle, Sizing};
|
||||
use super::widget::{AnchorGroup, Grid, Stack, StackDirection, Widget, WidgetId, WidgetKind};
|
||||
|
||||
/// One node in a resolved [`LayoutTree`] — the widget's id and its on-screen
|
||||
/// rectangles.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct LayoutNode {
|
||||
/// Mirror of [`Widget::id`].
|
||||
pub id: WidgetId,
|
||||
/// The outer rectangle the widget occupies, after margin / sizing /
|
||||
/// alignment.
|
||||
pub rect: Rect,
|
||||
/// `rect` minus the widget's padding — the area children are arranged
|
||||
/// inside.
|
||||
pub content_rect: Rect,
|
||||
/// Indices into [`LayoutTree::nodes`] of the direct children, in the same
|
||||
/// order as on the input widget.
|
||||
pub children: Vec<u32>,
|
||||
}
|
||||
|
||||
/// Result of laying out a widget tree — a flat array of [`LayoutNode`]s with
|
||||
/// the root at index 0.
|
||||
#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct LayoutTree {
|
||||
nodes: Vec<LayoutNode>,
|
||||
}
|
||||
|
||||
impl LayoutTree {
|
||||
/// All nodes, root first, in the pre-order produced by [`layout`].
|
||||
pub fn nodes(&self) -> &[LayoutNode] {
|
||||
&self.nodes
|
||||
}
|
||||
|
||||
/// The root node (always present after a successful layout).
|
||||
pub fn root(&self) -> Option<&LayoutNode> {
|
||||
self.nodes.first()
|
||||
}
|
||||
|
||||
/// Look up the first node with the given non-empty id.
|
||||
///
|
||||
/// Returns `None` if `id` is empty or no node matches. Linear scan — fine
|
||||
/// for the dozens-of-widgets trees Stage 8 currently targets; a hash map
|
||||
/// can be added if a profile says it's hot.
|
||||
pub fn find(&self, id: &WidgetId) -> Option<&LayoutNode> {
|
||||
if id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
self.nodes.iter().find(|n| n.id == *id)
|
||||
}
|
||||
|
||||
/// The direct children of the node at `index`.
|
||||
pub fn children_of(&self, index: usize) -> impl Iterator<Item = &LayoutNode> + '_ {
|
||||
self.nodes[index]
|
||||
.children
|
||||
.iter()
|
||||
.map(move |i| &self.nodes[*i as usize])
|
||||
}
|
||||
}
|
||||
|
||||
/// Lay out `root` inside `viewport` at the given DPI `scale`, producing a
|
||||
/// [`LayoutTree`] with one entry per widget in pre-order.
|
||||
pub fn layout(root: &Widget, viewport: Rect, scale: f32) -> LayoutTree {
|
||||
let mut nodes = Vec::with_capacity(root.node_count());
|
||||
arrange_in_slot(root, viewport, scale, &mut nodes);
|
||||
LayoutTree { nodes }
|
||||
}
|
||||
|
||||
// ---------- internal: recursive arrangement ----------
|
||||
|
||||
fn arrange_in_slot(widget: &Widget, slot: Rect, scale: f32, out: &mut Vec<LayoutNode>) -> u32 {
|
||||
let margin = widget.style.margin.scaled(scale);
|
||||
let outer = shrink(slot, margin);
|
||||
let outer_size = outer.size();
|
||||
|
||||
let intrinsic = measure(widget, outer_size, scale);
|
||||
let resolved_w = resolve_axis(widget.style.width, outer_size.x, intrinsic.x, scale);
|
||||
let resolved_h = resolve_axis(widget.style.height, outer_size.y, intrinsic.y, scale);
|
||||
let resolved = Vec2::new(resolved_w, resolved_h);
|
||||
|
||||
let extra = (outer_size - resolved).max(Vec2::ZERO);
|
||||
let offset = Vec2::new(
|
||||
align_offset(widget.style.align_horizontal, extra.x),
|
||||
align_offset(widget.style.align_vertical, extra.y),
|
||||
);
|
||||
let rect = Rect::from_min_size(outer.min + offset, resolved);
|
||||
|
||||
arrange_in_rect(widget, rect, scale, out)
|
||||
}
|
||||
|
||||
fn arrange_in_rect(widget: &Widget, rect: Rect, scale: f32, out: &mut Vec<LayoutNode>) -> u32 {
|
||||
let padding = widget.style.padding.scaled(scale);
|
||||
let content_rect = shrink(rect, padding);
|
||||
|
||||
let my_idx = out.len() as u32;
|
||||
out.push(LayoutNode {
|
||||
id: widget.id.clone(),
|
||||
rect,
|
||||
content_rect,
|
||||
children: Vec::new(),
|
||||
});
|
||||
|
||||
match &widget.kind {
|
||||
WidgetKind::Leaf { .. } => {}
|
||||
WidgetKind::Stack(stack) => arrange_stack(my_idx, stack, content_rect, scale, out),
|
||||
WidgetKind::Grid(grid) => arrange_grid(my_idx, grid, content_rect, scale, out),
|
||||
WidgetKind::Anchor(group) => arrange_anchor(my_idx, group, content_rect, scale, out),
|
||||
}
|
||||
|
||||
my_idx
|
||||
}
|
||||
|
||||
fn arrange_stack(
|
||||
parent_idx: u32,
|
||||
stack: &Stack,
|
||||
content: Rect,
|
||||
scale: f32,
|
||||
out: &mut Vec<LayoutNode>,
|
||||
) {
|
||||
let n = stack.children.len();
|
||||
if n == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let gap = stack.gap * scale;
|
||||
let total_gap = gap * n.saturating_sub(1) as f32;
|
||||
let content_main = main_extent(stack.direction, content.size());
|
||||
let content_cross = cross_extent(stack.direction, content.size());
|
||||
|
||||
// Pass 1: compute each child's main-axis size (fixed/fit) and tally
|
||||
// grow weights.
|
||||
let mut main_sizes: Vec<f32> = Vec::with_capacity(n);
|
||||
let mut grow_weights: Vec<Option<f32>> = Vec::with_capacity(n);
|
||||
let mut fixed_main_total = 0.0_f32;
|
||||
let mut total_grow = 0.0_f32;
|
||||
|
||||
for child in &stack.children {
|
||||
let margin = child.style.margin.scaled(scale);
|
||||
let margin_main = main_extent(
|
||||
stack.direction,
|
||||
Vec2::new(margin.horizontal(), margin.vertical()),
|
||||
);
|
||||
let main_sizing = match stack.direction {
|
||||
StackDirection::Row => child.style.width,
|
||||
StackDirection::Column => child.style.height,
|
||||
};
|
||||
|
||||
let (inner_main, weight) = match main_sizing {
|
||||
Sizing::Fixed(v) => (v * scale, None),
|
||||
Sizing::FitContent => {
|
||||
let m = measure(child, content.size(), scale);
|
||||
(main_extent(stack.direction, m), None)
|
||||
}
|
||||
Sizing::Grow(w) => (0.0, Some(w.max(0.0))),
|
||||
};
|
||||
|
||||
if let Some(w) = weight {
|
||||
total_grow += w;
|
||||
}
|
||||
grow_weights.push(weight);
|
||||
main_sizes.push(inner_main + margin_main);
|
||||
fixed_main_total += inner_main + margin_main;
|
||||
}
|
||||
|
||||
let leftover = (content_main - fixed_main_total - total_gap).max(0.0);
|
||||
if total_grow > 0.0 {
|
||||
for (i, w) in grow_weights.iter().enumerate() {
|
||||
if let Some(w) = w {
|
||||
main_sizes[i] += leftover * (*w / total_grow);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// After distributing Grow, any remaining slack is positioned via the
|
||||
// stack's `main_align`. (If any child grew, slack is zero.)
|
||||
let used_main: f32 = main_sizes.iter().sum::<f32>() + total_gap;
|
||||
let extra = (content_main - used_main).max(0.0);
|
||||
let start_offset = align_offset(stack.main_align, extra);
|
||||
|
||||
// Pass 2: place each child in its slot.
|
||||
let mut cursor = start_offset;
|
||||
let mut child_indices = Vec::with_capacity(n);
|
||||
for (i, child) in stack.children.iter().enumerate() {
|
||||
let slot_main = main_sizes[i];
|
||||
let slot = make_slot(stack.direction, content, cursor, slot_main, content_cross);
|
||||
cursor += slot_main + gap;
|
||||
child_indices.push(arrange_in_slot(child, slot, scale, out));
|
||||
}
|
||||
|
||||
out[parent_idx as usize].children = child_indices;
|
||||
}
|
||||
|
||||
fn arrange_grid(
|
||||
parent_idx: u32,
|
||||
grid: &Grid,
|
||||
content: Rect,
|
||||
scale: f32,
|
||||
out: &mut Vec<LayoutNode>,
|
||||
) {
|
||||
if grid.cols == 0 || grid.rows == 0 || grid.children.is_empty() {
|
||||
return;
|
||||
}
|
||||
let gap = grid.gap * scale;
|
||||
let total_gap_x = gap.x * grid.cols.saturating_sub(1) as f32;
|
||||
let total_gap_y = gap.y * grid.rows.saturating_sub(1) as f32;
|
||||
let cell_w = ((content.width() - total_gap_x) / grid.cols as f32).max(0.0);
|
||||
let cell_h = ((content.height() - total_gap_y) / grid.rows as f32).max(0.0);
|
||||
let cells = grid.cols * grid.rows;
|
||||
|
||||
let mut child_indices = Vec::with_capacity(grid.children.len().min(cells as usize));
|
||||
for (i, child) in grid.children.iter().enumerate() {
|
||||
if i as u32 >= cells {
|
||||
break;
|
||||
}
|
||||
let row = i as u32 / grid.cols;
|
||||
let col = i as u32 % grid.cols;
|
||||
let cell_origin =
|
||||
content.min + Vec2::new(col as f32 * (cell_w + gap.x), row as f32 * (cell_h + gap.y));
|
||||
let slot = Rect::from_min_size(cell_origin, Vec2::new(cell_w, cell_h));
|
||||
child_indices.push(arrange_in_slot(child, slot, scale, out));
|
||||
}
|
||||
out[parent_idx as usize].children = child_indices;
|
||||
}
|
||||
|
||||
fn arrange_anchor(
|
||||
parent_idx: u32,
|
||||
group: &AnchorGroup,
|
||||
content: Rect,
|
||||
scale: f32,
|
||||
out: &mut Vec<LayoutNode>,
|
||||
) {
|
||||
let size = content.size();
|
||||
let mut child_indices = Vec::with_capacity(group.children.len());
|
||||
for child in &group.children {
|
||||
let a = child.style.anchor;
|
||||
let min = content.min + size * a.min + a.offset_min * scale;
|
||||
let max = content.min + size * a.max + a.offset_max * scale;
|
||||
let target = Rect::new(min, max);
|
||||
child_indices.push(arrange_in_rect(child, target, scale, out));
|
||||
}
|
||||
out[parent_idx as usize].children = child_indices;
|
||||
}
|
||||
|
||||
// ---------- internal: measurement ----------
|
||||
|
||||
fn measure(widget: &Widget, available: Vec2, scale: f32) -> Vec2 {
|
||||
match &widget.kind {
|
||||
WidgetKind::Leaf { intrinsic } => *intrinsic * scale,
|
||||
WidgetKind::Stack(stack) => measure_stack(&widget.style, stack, available, scale),
|
||||
WidgetKind::Grid(grid) => measure_grid(&widget.style, grid, available, scale),
|
||||
// Anchor parents derive their children's rects from the parent's size,
|
||||
// so they can't propose an intrinsic "fit" size; FitContent on an
|
||||
// anchor parent collapses to zero.
|
||||
WidgetKind::Anchor(_) => Vec2::ZERO,
|
||||
}
|
||||
}
|
||||
|
||||
/// Outer footprint of a child (the slot it would consume in its parent),
|
||||
/// including its own margin.
|
||||
fn measure_outer(widget: &Widget, available: Vec2, scale: f32) -> Vec2 {
|
||||
let intrinsic = measure(widget, available, scale);
|
||||
let w = match widget.style.width {
|
||||
Sizing::Fixed(v) => v * scale,
|
||||
Sizing::FitContent => intrinsic.x,
|
||||
Sizing::Grow(_) => 0.0,
|
||||
};
|
||||
let h = match widget.style.height {
|
||||
Sizing::Fixed(v) => v * scale,
|
||||
Sizing::FitContent => intrinsic.y,
|
||||
Sizing::Grow(_) => 0.0,
|
||||
};
|
||||
let m = widget.style.margin.scaled(scale);
|
||||
Vec2::new(w + m.horizontal(), h + m.vertical())
|
||||
}
|
||||
|
||||
fn measure_stack(parent_style: &LayoutStyle, stack: &Stack, available: Vec2, scale: f32) -> Vec2 {
|
||||
let mut main = 0.0_f32;
|
||||
let mut cross = 0.0_f32;
|
||||
let n = stack.children.len();
|
||||
for child in &stack.children {
|
||||
let s = measure_outer(child, available, scale);
|
||||
main += main_extent(stack.direction, s);
|
||||
cross = cross.max(cross_extent(stack.direction, s));
|
||||
}
|
||||
if n > 1 {
|
||||
main += stack.gap * scale * (n - 1) as f32;
|
||||
}
|
||||
let p = parent_style.padding.scaled(scale);
|
||||
match stack.direction {
|
||||
StackDirection::Row => Vec2::new(main + p.horizontal(), cross + p.vertical()),
|
||||
StackDirection::Column => Vec2::new(cross + p.horizontal(), main + p.vertical()),
|
||||
}
|
||||
}
|
||||
|
||||
fn measure_grid(parent_style: &LayoutStyle, grid: &Grid, available: Vec2, scale: f32) -> Vec2 {
|
||||
if grid.cols == 0 || grid.rows == 0 {
|
||||
return Vec2::ZERO;
|
||||
}
|
||||
let mut cell_w = 0.0_f32;
|
||||
let mut cell_h = 0.0_f32;
|
||||
for child in &grid.children {
|
||||
let s = measure_outer(child, available, scale);
|
||||
cell_w = cell_w.max(s.x);
|
||||
cell_h = cell_h.max(s.y);
|
||||
}
|
||||
let gap = grid.gap * scale;
|
||||
let total = Vec2::new(
|
||||
cell_w * grid.cols as f32 + gap.x * grid.cols.saturating_sub(1) as f32,
|
||||
cell_h * grid.rows as f32 + gap.y * grid.rows.saturating_sub(1) as f32,
|
||||
);
|
||||
let p = parent_style.padding.scaled(scale);
|
||||
Vec2::new(total.x + p.horizontal(), total.y + p.vertical())
|
||||
}
|
||||
|
||||
// ---------- internal: small helpers ----------
|
||||
|
||||
fn shrink(r: Rect, i: Insets) -> Rect {
|
||||
let min = r.min + Vec2::new(i.left, i.top);
|
||||
let max = r.max - Vec2::new(i.right, i.bottom);
|
||||
Rect::new(min, max)
|
||||
}
|
||||
|
||||
fn align_offset(align: Align, extra: f32) -> f32 {
|
||||
match align {
|
||||
Align::Start => 0.0,
|
||||
Align::Center => extra * 0.5,
|
||||
Align::End => extra,
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_axis(sizing: Sizing, available: f32, intrinsic: f32, scale: f32) -> f32 {
|
||||
match sizing {
|
||||
Sizing::Fixed(v) => (v * scale).min(available),
|
||||
Sizing::Grow(_) => available,
|
||||
Sizing::FitContent => intrinsic.min(available),
|
||||
}
|
||||
}
|
||||
|
||||
fn main_extent(dir: StackDirection, v: Vec2) -> f32 {
|
||||
match dir {
|
||||
StackDirection::Row => v.x,
|
||||
StackDirection::Column => v.y,
|
||||
}
|
||||
}
|
||||
|
||||
fn cross_extent(dir: StackDirection, v: Vec2) -> f32 {
|
||||
match dir {
|
||||
StackDirection::Row => v.y,
|
||||
StackDirection::Column => v.x,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_slot(dir: StackDirection, content: Rect, cursor: f32, main: f32, cross: f32) -> Rect {
|
||||
match dir {
|
||||
StackDirection::Row => {
|
||||
Rect::from_min_size(content.min + Vec2::new(cursor, 0.0), Vec2::new(main, cross))
|
||||
}
|
||||
StackDirection::Column => {
|
||||
Rect::from_min_size(content.min + Vec2::new(0.0, cursor), Vec2::new(cross, main))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::ui::style::Anchor;
|
||||
|
||||
fn vp(w: f32, h: f32) -> Rect {
|
||||
Rect::from_min_size(Vec2::ZERO, Vec2::new(w, h))
|
||||
}
|
||||
|
||||
/// `Grow(1.0)` on both axes — the common "fill the parent" style for
|
||||
/// container tests where intrinsic sizing would collapse the root.
|
||||
fn grow_both() -> LayoutStyle {
|
||||
LayoutStyle {
|
||||
width: Sizing::Grow(1.0),
|
||||
height: Sizing::Grow(1.0),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_leaf_takes_intrinsic_size_at_origin() {
|
||||
let w = Widget::leaf(Vec2::new(40.0, 20.0)).with_id("a");
|
||||
let tree = layout(&w, vp(800.0, 600.0), 1.0);
|
||||
let n = tree.find(&"a".into()).unwrap();
|
||||
assert_eq!(
|
||||
n.rect,
|
||||
Rect::from_min_size(Vec2::ZERO, Vec2::new(40.0, 20.0))
|
||||
);
|
||||
assert_eq!(n.content_rect, n.rect);
|
||||
assert_eq!(tree.nodes().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dpi_scale_doubles_sizes() {
|
||||
let w = Widget::leaf(Vec2::new(40.0, 20.0));
|
||||
let tree = layout(&w, vp(800.0, 600.0), 2.0);
|
||||
let n = tree.root().unwrap();
|
||||
assert_eq!(n.rect.size(), Vec2::new(80.0, 40.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_stack_places_fixed_children_with_gap() {
|
||||
let row = Widget::row()
|
||||
.with_id("row")
|
||||
.with_gap(4.0)
|
||||
.with_child(Widget::leaf(Vec2::new(30.0, 20.0)).with_id("a"))
|
||||
.with_child(Widget::leaf(Vec2::new(50.0, 20.0)).with_id("b"))
|
||||
.with_child(Widget::leaf(Vec2::new(10.0, 20.0)).with_id("c"));
|
||||
let tree = layout(&row, vp(200.0, 100.0), 1.0);
|
||||
let a = tree.find(&"a".into()).unwrap().rect;
|
||||
let b = tree.find(&"b".into()).unwrap().rect;
|
||||
let c = tree.find(&"c".into()).unwrap().rect;
|
||||
assert_eq!(a, Rect::from_min_size(Vec2::ZERO, Vec2::new(30.0, 20.0)));
|
||||
assert_eq!(
|
||||
b,
|
||||
Rect::from_min_size(Vec2::new(34.0, 0.0), Vec2::new(50.0, 20.0))
|
||||
);
|
||||
assert_eq!(
|
||||
c,
|
||||
Rect::from_min_size(Vec2::new(88.0, 0.0), Vec2::new(10.0, 20.0))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_stack_grow_fills_leftover_space() {
|
||||
// 200 wide; A=30 fixed, B=Grow, C=10 fixed → B gets 160 wide.
|
||||
let row =
|
||||
Widget::row()
|
||||
.with_id("row")
|
||||
.with_style(grow_both())
|
||||
.with_child(Widget::leaf(Vec2::new(30.0, 20.0)).with_id("a"))
|
||||
.with_child(Widget::leaf(Vec2::new(0.0, 20.0)).with_id("b").with_style(
|
||||
LayoutStyle {
|
||||
width: Sizing::Grow(1.0),
|
||||
height: Sizing::Fixed(20.0),
|
||||
..Default::default()
|
||||
},
|
||||
))
|
||||
.with_child(Widget::leaf(Vec2::new(10.0, 20.0)).with_id("c"));
|
||||
let tree = layout(&row, vp(200.0, 100.0), 1.0);
|
||||
let b = tree.find(&"b".into()).unwrap().rect;
|
||||
assert_eq!(b.min.x, 30.0);
|
||||
assert_eq!(b.width(), 160.0);
|
||||
assert_eq!(tree.find(&"c".into()).unwrap().rect.min.x, 190.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_stack_grow_weights_split_proportionally() {
|
||||
// 300 wide root; A=Grow(1), B=Grow(2) → A gets 100, B gets 200.
|
||||
let row = Widget::row()
|
||||
.with_style(grow_both())
|
||||
.with_child(Widget::default().with_id("a").with_style(LayoutStyle {
|
||||
width: Sizing::Grow(1.0),
|
||||
height: Sizing::Grow(1.0),
|
||||
..Default::default()
|
||||
}))
|
||||
.with_child(Widget::default().with_id("b").with_style(LayoutStyle {
|
||||
width: Sizing::Grow(2.0),
|
||||
height: Sizing::Grow(1.0),
|
||||
..Default::default()
|
||||
}));
|
||||
let tree = layout(&row, vp(300.0, 30.0), 1.0);
|
||||
let a = tree.find(&"a".into()).unwrap().rect;
|
||||
let b = tree.find(&"b".into()).unwrap().rect;
|
||||
assert_eq!(a.width(), 100.0);
|
||||
assert_eq!(b.width(), 200.0);
|
||||
assert_eq!(b.min.x, 100.0);
|
||||
// Cross axis Grow fills full height.
|
||||
assert_eq!(a.height(), 30.0);
|
||||
assert_eq!(b.height(), 30.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_stack_main_align_center_splits_extra() {
|
||||
// Two 30-wide children with gap 0 → main extent 60; viewport 200 →
|
||||
// 140 extra, centered → 70 each side.
|
||||
let row = Widget::row()
|
||||
.with_main_align(Align::Center)
|
||||
.with_style(grow_both())
|
||||
.with_child(Widget::leaf(Vec2::new(30.0, 10.0)).with_id("a"))
|
||||
.with_child(Widget::leaf(Vec2::new(30.0, 10.0)).with_id("b"));
|
||||
let tree = layout(&row, vp(200.0, 20.0), 1.0);
|
||||
assert_eq!(tree.find(&"a".into()).unwrap().rect.min.x, 70.0);
|
||||
assert_eq!(tree.find(&"b".into()).unwrap().rect.min.x, 100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_stack_cross_align_end_docks_to_bottom() {
|
||||
// Child is 30x10 in a 100-wide row with 40 tall → align End → top=30.
|
||||
let row = Widget::row().with_style(grow_both()).with_child(
|
||||
Widget::leaf(Vec2::new(30.0, 10.0))
|
||||
.with_id("a")
|
||||
.with_style(LayoutStyle {
|
||||
align_vertical: Align::End,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
let tree = layout(&row, vp(100.0, 40.0), 1.0);
|
||||
let a = tree.find(&"a".into()).unwrap().rect;
|
||||
assert_eq!(a.min.y, 30.0);
|
||||
assert_eq!(a.max.y, 40.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn column_stack_flows_top_to_bottom() {
|
||||
let col = Widget::column()
|
||||
.with_gap(2.0)
|
||||
.with_child(Widget::leaf(Vec2::new(20.0, 10.0)).with_id("a"))
|
||||
.with_child(Widget::leaf(Vec2::new(20.0, 30.0)).with_id("b"));
|
||||
let tree = layout(&col, vp(100.0, 100.0), 1.0);
|
||||
let a = tree.find(&"a".into()).unwrap().rect;
|
||||
let b = tree.find(&"b".into()).unwrap().rect;
|
||||
assert_eq!(a.min, Vec2::ZERO);
|
||||
assert_eq!(a.max.y, 10.0);
|
||||
assert_eq!(b.min.y, 12.0);
|
||||
assert_eq!(b.max.y, 42.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_two_by_three_makes_six_equal_cells() {
|
||||
// 100x60 content, 2 cols × 3 rows, no gap → cells 50x20.
|
||||
let grid = Widget::grid(2, 3)
|
||||
.with_style(grow_both())
|
||||
.with_children((0..6).map(|i| Widget::leaf(Vec2::ZERO).with_id(format!("c{i}"))));
|
||||
let tree = layout(&grid, vp(100.0, 60.0), 1.0);
|
||||
for i in 0..6 {
|
||||
let row = i / 2;
|
||||
let col = i % 2;
|
||||
let n = tree.find(&format!("c{i}").into()).unwrap();
|
||||
// Default FitContent of zero intrinsic ⇒ children collapse to
|
||||
// (col*50, row*20)–(col*50, row*20) at Start align inside the
|
||||
// cell. Verify the *cell origin* via the node's `rect.min`.
|
||||
assert_eq!(n.rect.min, Vec2::new(col as f32 * 50.0, row as f32 * 20.0));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_gap_subtracts_from_cell_size() {
|
||||
let grid = Widget::grid(2, 2)
|
||||
.with_style(grow_both())
|
||||
.with_grid_gap(Vec2::new(10.0, 10.0))
|
||||
.with_children((0..4).map(|i| {
|
||||
Widget::default()
|
||||
.with_id(format!("c{i}"))
|
||||
.with_style(LayoutStyle {
|
||||
width: Sizing::Grow(1.0),
|
||||
height: Sizing::Grow(1.0),
|
||||
..Default::default()
|
||||
})
|
||||
}));
|
||||
let tree = layout(&grid, vp(110.0, 110.0), 1.0);
|
||||
// (110 - 10 gap) / 2 = 50 per cell.
|
||||
for i in 0..4 {
|
||||
let n = tree.find(&format!("c{i}").into()).unwrap();
|
||||
assert_eq!(n.rect.size(), Vec2::new(50.0, 50.0));
|
||||
}
|
||||
// Second column starts at 60 (50 + 10 gap).
|
||||
assert_eq!(tree.find(&"c1".into()).unwrap().rect.min.x, 60.0);
|
||||
// Second row starts at 60.
|
||||
assert_eq!(tree.find(&"c2".into()).unwrap().rect.min.y, 60.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anchor_fill_makes_child_match_parent_content() {
|
||||
let parent = Widget::anchor()
|
||||
.with_id("p")
|
||||
.with_style(LayoutStyle {
|
||||
width: Sizing::Grow(1.0),
|
||||
height: Sizing::Grow(1.0),
|
||||
..Default::default()
|
||||
})
|
||||
.with_child(Widget::leaf(Vec2::ZERO).with_id("child"));
|
||||
let tree = layout(&parent, vp(200.0, 100.0), 1.0);
|
||||
let child = tree.find(&"child".into()).unwrap();
|
||||
assert_eq!(
|
||||
child.rect,
|
||||
Rect::from_min_size(Vec2::ZERO, Vec2::new(200.0, 100.0))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anchor_top_right_with_offsets_places_child_relative_to_corner() {
|
||||
// Pin the child's top-right at the parent's top-right, then push the
|
||||
// top-left corner 80 pixels left and 24 pixels down → 80×24 child in
|
||||
// the top-right corner.
|
||||
let parent = Widget::anchor()
|
||||
.with_style(LayoutStyle {
|
||||
width: Sizing::Grow(1.0),
|
||||
height: Sizing::Grow(1.0),
|
||||
..Default::default()
|
||||
})
|
||||
.with_child(
|
||||
Widget::leaf(Vec2::ZERO)
|
||||
.with_id("c")
|
||||
.with_style(LayoutStyle {
|
||||
anchor: Anchor::TOP_RIGHT
|
||||
.with_offsets(Vec2::new(-80.0, 0.0), Vec2::new(0.0, 24.0)),
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
let tree = layout(&parent, vp(300.0, 200.0), 1.0);
|
||||
let c = tree.find(&"c".into()).unwrap().rect;
|
||||
assert_eq!(c.min, Vec2::new(220.0, 0.0));
|
||||
assert_eq!(c.max, Vec2::new(300.0, 24.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anchor_dpi_scales_offsets() {
|
||||
let parent = Widget::anchor()
|
||||
.with_style(LayoutStyle {
|
||||
width: Sizing::Grow(1.0),
|
||||
height: Sizing::Grow(1.0),
|
||||
..Default::default()
|
||||
})
|
||||
.with_child(
|
||||
Widget::leaf(Vec2::ZERO)
|
||||
.with_id("c")
|
||||
.with_style(LayoutStyle {
|
||||
anchor: Anchor::TOP_LEFT.with_offsets(Vec2::ZERO, Vec2::new(40.0, 20.0)),
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
let tree = layout(&parent, vp(400.0, 400.0), 2.0);
|
||||
let c = tree.find(&"c".into()).unwrap().rect;
|
||||
assert_eq!(c.min, Vec2::ZERO);
|
||||
assert_eq!(c.max, Vec2::new(80.0, 40.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn padding_shrinks_content_rect_and_offsets_children() {
|
||||
let row = Widget::row()
|
||||
.with_id("row")
|
||||
.with_style(LayoutStyle {
|
||||
width: Sizing::Grow(1.0),
|
||||
height: Sizing::Grow(1.0),
|
||||
padding: Insets::all(10.0),
|
||||
..Default::default()
|
||||
})
|
||||
.with_child(Widget::leaf(Vec2::new(50.0, 20.0)).with_id("a"));
|
||||
let tree = layout(&row, vp(200.0, 100.0), 1.0);
|
||||
let row_node = tree.find(&"row".into()).unwrap();
|
||||
assert_eq!(
|
||||
row_node.rect,
|
||||
Rect::from_min_size(Vec2::ZERO, Vec2::new(200.0, 100.0))
|
||||
);
|
||||
assert_eq!(
|
||||
row_node.content_rect,
|
||||
Rect::from_min_size(Vec2::splat(10.0), Vec2::new(180.0, 80.0))
|
||||
);
|
||||
let a = tree.find(&"a".into()).unwrap().rect;
|
||||
assert_eq!(a.min, Vec2::splat(10.0));
|
||||
assert_eq!(a.size(), Vec2::new(50.0, 20.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn margin_reserves_space_outside_widget() {
|
||||
let row =
|
||||
Widget::row().with_child(Widget::leaf(Vec2::new(40.0, 20.0)).with_id("a").with_style(
|
||||
LayoutStyle {
|
||||
margin: Insets::symmetric(5.0, 0.0),
|
||||
..Default::default()
|
||||
},
|
||||
));
|
||||
let tree = layout(&row, vp(200.0, 100.0), 1.0);
|
||||
let a = tree.find(&"a".into()).unwrap().rect;
|
||||
// 5px left margin → child starts at 5, width 40.
|
||||
assert_eq!(a.min.x, 5.0);
|
||||
assert_eq!(a.max.x, 45.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fit_content_stack_sums_children_plus_padding() {
|
||||
// Two 30x10 fixed children, no gap, padding=8 → root 76 x 26.
|
||||
let row = Widget::row()
|
||||
.with_id("root")
|
||||
.with_style(LayoutStyle {
|
||||
padding: Insets::all(8.0),
|
||||
..Default::default()
|
||||
})
|
||||
.with_child(Widget::leaf(Vec2::new(30.0, 10.0)))
|
||||
.with_child(Widget::leaf(Vec2::new(30.0, 10.0)));
|
||||
let tree = layout(&row, vp(1000.0, 1000.0), 1.0);
|
||||
let r = tree.find(&"root".into()).unwrap().rect;
|
||||
assert_eq!(r.size(), Vec2::new(76.0, 26.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_tree_round_trips_through_ron() {
|
||||
let w = Widget::row()
|
||||
.with_id("root")
|
||||
.with_gap(4.0)
|
||||
.with_child(Widget::leaf(Vec2::new(20.0, 10.0)).with_id("a"));
|
||||
let tree = layout(&w, vp(100.0, 50.0), 1.0);
|
||||
let text = ron::ser::to_string(&tree).unwrap();
|
||||
let decoded: LayoutTree = ron::de::from_str(&text).unwrap();
|
||||
assert_eq!(tree, decoded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_rejects_empty_id() {
|
||||
let w = Widget::leaf(Vec2::ONE);
|
||||
let tree = layout(&w, vp(10.0, 10.0), 1.0);
|
||||
assert!(tree.find(&WidgetId::default()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn children_of_iterates_direct_children_only() {
|
||||
let tree = layout(
|
||||
&Widget::row()
|
||||
.with_child(Widget::leaf(Vec2::new(10.0, 10.0)).with_id("a"))
|
||||
.with_child(
|
||||
Widget::column()
|
||||
.with_id("col")
|
||||
.with_child(Widget::leaf(Vec2::new(10.0, 10.0)).with_id("inner")),
|
||||
),
|
||||
vp(100.0, 100.0),
|
||||
1.0,
|
||||
);
|
||||
let ids: Vec<_> = tree
|
||||
.children_of(0)
|
||||
.map(|n| n.id.as_str().to_owned())
|
||||
.collect();
|
||||
assert_eq!(ids, vec!["a".to_string(), "col".to_string()]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
//! In-game UI system — widget tree, layout, styling, text, input routing.
|
||||
//!
|
||||
//! Stage 8 builds the engine's **in-game** UI — what an exported game uses
|
||||
//! to draw its menus, HUDs, and tools. This is intentionally distinct from
|
||||
//! the editor's `egui` (which stays editor-only): a shipped game cannot pull
|
||||
//! in `egui`, so the runtime owns its own widget tree, lays it out, batches
|
||||
//! it through the Stage-5 render pipeline, and routes input through the
|
||||
//! Stage-7 model.
|
||||
//!
|
||||
//! Stage 8 is shipped in pieces:
|
||||
//!
|
||||
//! 1. **Piece 1 — widget tree + layout (this module, right now).** A flat
|
||||
//! [`Widget`] data structure, three layout modes ([`Stack`], [`Grid`],
|
||||
//! [`AnchorGroup`]), and a pure-logic [`layout`] function that turns a
|
||||
//! tree into a [`LayoutTree`] of resolved screen rects. No rendering,
|
||||
//! no input, fully testable headlessly.
|
||||
//! 2. Piece 2 — styling & theming (`Style` / `Theme` + RON dual-edit).
|
||||
//! 3. Piece 3 — text shaping & glyph atlas.
|
||||
//! 4. Piece 4 — 2D overlay render pass.
|
||||
//! 5. Piece 5 — input routing (hit-test, hover/focus/press).
|
||||
//! 6. Piece 6 — events + data binding.
|
||||
//! 7. Pieces 7–9 — GUI tail (`examples/ui_menu`, `examples/ui_hud`, editor
|
||||
//! UI canvas).
|
||||
//!
|
||||
//! # Worked example
|
||||
//!
|
||||
//! ```
|
||||
//! use glam::Vec2;
|
||||
//! use oxide_engine::math::Rect;
|
||||
//! use oxide_engine::ui::{layout, Insets, LayoutStyle, Sizing, Widget};
|
||||
//!
|
||||
//! // A toolbar with two buttons and a stretching spacer between them.
|
||||
//! let toolbar = Widget::row()
|
||||
//! .with_id("toolbar")
|
||||
//! .with_gap(8.0)
|
||||
//! .with_style(LayoutStyle {
|
||||
//! width: Sizing::Grow(1.0),
|
||||
//! height: Sizing::Fixed(32.0),
|
||||
//! padding: Insets::all(4.0),
|
||||
//! ..Default::default()
|
||||
//! })
|
||||
//! .with_child(Widget::leaf(Vec2::new(64.0, 24.0)).with_id("file"))
|
||||
//! .with_child(
|
||||
//! Widget::default()
|
||||
//! .with_id("spacer")
|
||||
//! .with_style(LayoutStyle {
|
||||
//! width: Sizing::Grow(1.0),
|
||||
//! height: Sizing::Grow(1.0),
|
||||
//! ..Default::default()
|
||||
//! }),
|
||||
//! )
|
||||
//! .with_child(Widget::leaf(Vec2::new(64.0, 24.0)).with_id("help"));
|
||||
//!
|
||||
//! let viewport = Rect::from_min_size(Vec2::ZERO, Vec2::new(800.0, 600.0));
|
||||
//! let tree = layout(&toolbar, viewport, 1.0);
|
||||
//! let toolbar_rect = tree.root().unwrap().rect;
|
||||
//! assert_eq!(toolbar_rect.height(), 32.0);
|
||||
//! let help_rect = tree.find(&"help".into()).unwrap().rect;
|
||||
//! assert_eq!(help_rect.max.x, 800.0 - 4.0); // padding on the right
|
||||
//! ```
|
||||
|
||||
mod layout;
|
||||
pub mod paint;
|
||||
mod panel;
|
||||
pub mod routing;
|
||||
mod style;
|
||||
pub mod text;
|
||||
mod theme;
|
||||
mod value;
|
||||
mod visual;
|
||||
mod widget;
|
||||
|
||||
pub use layout::{layout, LayoutNode, LayoutTree};
|
||||
pub use paint::{paint, DrawCommand, PaintedFrame};
|
||||
pub use panel::UiPanel;
|
||||
pub use routing::{hit_test, Router, RouterEvent, RouterFrame};
|
||||
pub use style::{Align, Anchor, Insets, LayoutStyle, Sizing};
|
||||
pub use text::{
|
||||
shape, shape_runs, AtlasEntry, Font, FontError, FontId, FontLoader, FontStore, GlyphAtlas,
|
||||
GlyphId, GlyphKey, RasterizedGlyph, ShapeParams, ShapedGlyph, ShapedLine, ShapedText,
|
||||
TextAlign, TextRun, TextStyle,
|
||||
};
|
||||
pub use theme::Theme;
|
||||
pub use value::WidgetValue;
|
||||
pub use visual::{Border, FontRef, FontWeight, VisualStyle};
|
||||
pub use widget::{
|
||||
AnchorGroup, Grid, Stack, StackDirection, Widget, WidgetId, WidgetKind, WidgetPath,
|
||||
};
|
||||
@@ -0,0 +1,319 @@
|
||||
//! Paint — turn a laid-out widget tree into a flat list of draw commands.
|
||||
//!
|
||||
//! Layout (piece 1) is purely geometric: rects in, rects out. Paint (piece 4)
|
||||
//! adds the *visual* dimension: solid fills for backgrounds, textured quads
|
||||
//! for text. The output is a [`PaintedFrame`] — a flat list of
|
||||
//! [`DrawCommand`]s the [`UiOverlayPass`](super::super::render::UiOverlayPass)
|
||||
//! consumes directly. Keeping paint pure-CPU and the GPU pass downstream
|
||||
//! lets every paint test run headlessly; the GPU pass only has to know how
|
||||
//! to *consume* commands, not how to derive them.
|
||||
//!
|
||||
//! # Algorithm
|
||||
//!
|
||||
//! 1. Walk the [`LayoutTree`] in node order (root first, children after).
|
||||
//! 2. For each laid-out node:
|
||||
//! - Resolve its [`VisualStyle`] under the active [`Theme`].
|
||||
//! - If the resolved style has a background, emit one [`DrawCommand::Quad`]
|
||||
//! filling `node.rect`.
|
||||
//! - If the source widget has `text`, shape it inside `node.content_rect`
|
||||
//! with the resolved font / size / color, then emit one
|
||||
//! [`DrawCommand::Glyph`] per non-space glyph.
|
||||
//! 3. The frame's overall `size` mirrors the layout root's rect so the GPU
|
||||
//! pass knows how big the viewport for this batch is.
|
||||
//!
|
||||
//! Render order is the layout order: parents before children, so the
|
||||
//! children draw *on top of* their parents (matching standard UI layering).
|
||||
|
||||
use glam::Vec2;
|
||||
|
||||
use super::layout::LayoutTree;
|
||||
use super::text::{shape, FontStore, GlyphKey, ShapeParams, TextStyle};
|
||||
use super::theme::Theme;
|
||||
use super::visual::VisualStyle;
|
||||
use super::widget::Widget;
|
||||
use crate::math::{Color, Rect};
|
||||
|
||||
/// One draw call in a painted UI frame.
|
||||
///
|
||||
/// All commands share a single GPU pipeline and one texture (the glyph
|
||||
/// atlas). Solid quads emit a sentinel UV the shader recognises as
|
||||
/// "untextured" so a single fragment path handles both cases.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum DrawCommand {
|
||||
/// A solid-colored axis-aligned rectangle.
|
||||
Quad { rect: Rect, color: Color },
|
||||
/// One glyph quad — the renderer turns the [`GlyphKey`] into an atlas
|
||||
/// region at draw time. `pen_position` is the **baseline** point; the
|
||||
/// atlas's per-glyph bearing positions the quad relative to it.
|
||||
Glyph {
|
||||
key: GlyphKey,
|
||||
pen_position: Vec2,
|
||||
color: Color,
|
||||
},
|
||||
}
|
||||
|
||||
/// Output of [`paint`] — the size of the painted area and the ordered list
|
||||
/// of draw commands.
|
||||
#[derive(Debug, Default, Clone, PartialEq)]
|
||||
pub struct PaintedFrame {
|
||||
/// Size of the painted area in (post-scale) pixels — usually the
|
||||
/// layout root's rect size.
|
||||
pub size: Vec2,
|
||||
/// Draw commands in the order they should be submitted (back-to-front).
|
||||
pub commands: Vec<DrawCommand>,
|
||||
}
|
||||
|
||||
/// Walk a laid-out widget tree under a theme and produce the draw commands
|
||||
/// for one frame.
|
||||
///
|
||||
/// `scale` matches the value passed to
|
||||
/// [`layout`](super::layout::layout) — paint uses it to pass the same DPI
|
||||
/// factor to [`shape`] for text.
|
||||
pub fn paint(
|
||||
root: &Widget,
|
||||
tree: &LayoutTree,
|
||||
theme: &Theme,
|
||||
fonts: &FontStore,
|
||||
scale: f32,
|
||||
) -> PaintedFrame {
|
||||
let mut commands = Vec::new();
|
||||
paint_widget(root, tree, 0, theme, fonts, scale, &mut commands);
|
||||
let size = tree
|
||||
.root()
|
||||
.map(|node| node.rect.size())
|
||||
.unwrap_or(Vec2::ZERO);
|
||||
PaintedFrame { size, commands }
|
||||
}
|
||||
|
||||
fn paint_widget(
|
||||
widget: &Widget,
|
||||
tree: &LayoutTree,
|
||||
node_index: usize,
|
||||
theme: &Theme,
|
||||
fonts: &FontStore,
|
||||
scale: f32,
|
||||
out: &mut Vec<DrawCommand>,
|
||||
) {
|
||||
let node = &tree.nodes()[node_index];
|
||||
let resolved = widget.resolve_visual(theme);
|
||||
|
||||
// Background fill — only emit if the rect has area and a background was
|
||||
// resolved. A `corner_radius` is captured in the resolved style for
|
||||
// future use but ignored by piece-4's rectangular renderer.
|
||||
if let Some(bg) = resolved.background {
|
||||
if !node.rect.is_empty() {
|
||||
out.push(DrawCommand::Quad {
|
||||
rect: node.rect,
|
||||
color: bg,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Text — shape inside `content_rect` (so padding is respected) and emit
|
||||
// one glyph per non-empty position.
|
||||
if let Some(text) = widget.text.as_ref() {
|
||||
paint_text(text, node.content_rect, &resolved, fonts, scale, out);
|
||||
}
|
||||
|
||||
// Children draw on top of self.
|
||||
for (child_widget, child_index) in widget.children().iter().zip(node.children.iter()) {
|
||||
paint_widget(
|
||||
child_widget,
|
||||
tree,
|
||||
*child_index as usize,
|
||||
theme,
|
||||
fonts,
|
||||
scale,
|
||||
out,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn paint_text(
|
||||
text: &str,
|
||||
content_rect: Rect,
|
||||
resolved: &VisualStyle,
|
||||
fonts: &FontStore,
|
||||
scale: f32,
|
||||
out: &mut Vec<DrawCommand>,
|
||||
) {
|
||||
let Some(font_ref) = resolved.font.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let Some(font_id) = fonts.resolve(font_ref) else {
|
||||
return;
|
||||
};
|
||||
let size_px = resolved.font_size.unwrap_or(14.0);
|
||||
let color = resolved.foreground.unwrap_or(Color::BLACK);
|
||||
let style = TextStyle {
|
||||
font: font_id,
|
||||
size_px,
|
||||
};
|
||||
let params = ShapeParams {
|
||||
max_width: Some(content_rect.width()),
|
||||
scale,
|
||||
..ShapeParams::default()
|
||||
};
|
||||
let shaped = shape(text, style, ¶ms, fonts);
|
||||
for line in &shaped.lines {
|
||||
for g in &line.glyphs {
|
||||
out.push(DrawCommand::Glyph {
|
||||
key: g.key,
|
||||
pen_position: content_rect.min + g.position,
|
||||
color,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::layout::layout;
|
||||
use super::super::text::{common_system_font_paths, Font};
|
||||
use super::super::visual::FontRef;
|
||||
use super::super::widget::Widget;
|
||||
use super::*;
|
||||
use glam::Vec2;
|
||||
|
||||
fn viewport(w: f32, h: f32) -> Rect {
|
||||
Rect::from_min_size(Vec2::ZERO, Vec2::new(w, h))
|
||||
}
|
||||
|
||||
fn solid_panel(color: Color, w: f32, h: f32) -> Widget {
|
||||
Widget::leaf(Vec2::new(w, h)).with_visual(VisualStyle {
|
||||
background: Some(color),
|
||||
..VisualStyle::EMPTY
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solid_widget_emits_one_quad_at_its_rect() {
|
||||
let root = solid_panel(Color::RED, 40.0, 20.0);
|
||||
let tree = layout(&root, viewport(100.0, 50.0), 1.0);
|
||||
let theme = Theme::new();
|
||||
let fonts = FontStore::new();
|
||||
let painted = paint(&root, &tree, &theme, &fonts, 1.0);
|
||||
assert_eq!(painted.size, Vec2::new(40.0, 20.0));
|
||||
assert_eq!(painted.commands.len(), 1);
|
||||
match &painted.commands[0] {
|
||||
DrawCommand::Quad { rect, color } => {
|
||||
assert_eq!(
|
||||
*rect,
|
||||
Rect::from_min_size(Vec2::ZERO, Vec2::new(40.0, 20.0))
|
||||
);
|
||||
assert_eq!(*color, Color::RED);
|
||||
}
|
||||
_ => panic!("expected a Quad"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn widget_without_visual_emits_no_quads() {
|
||||
// Default widget has empty visual — nothing to paint.
|
||||
let root = Widget::leaf(Vec2::new(40.0, 20.0));
|
||||
let tree = layout(&root, viewport(100.0, 50.0), 1.0);
|
||||
let painted = paint(&root, &tree, &Theme::new(), &FontStore::new(), 1.0);
|
||||
assert!(painted.commands.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn child_quad_is_emitted_after_parent_quad() {
|
||||
let root = Widget::row()
|
||||
.with_visual(VisualStyle {
|
||||
background: Some(Color::WHITE),
|
||||
..VisualStyle::EMPTY
|
||||
})
|
||||
.with_style(super::super::style::LayoutStyle {
|
||||
width: super::super::style::Sizing::Fixed(100.0),
|
||||
height: super::super::style::Sizing::Fixed(50.0),
|
||||
..Default::default()
|
||||
})
|
||||
.with_child(solid_panel(Color::RED, 40.0, 20.0));
|
||||
let tree = layout(&root, viewport(200.0, 100.0), 1.0);
|
||||
let painted = paint(&root, &tree, &Theme::new(), &FontStore::new(), 1.0);
|
||||
assert_eq!(painted.commands.len(), 2);
|
||||
// Parent (white) painted before child (red), so child draws on top.
|
||||
match &painted.commands[0] {
|
||||
DrawCommand::Quad { color, .. } => assert_eq!(*color, Color::WHITE),
|
||||
_ => panic!(),
|
||||
}
|
||||
match &painted.commands[1] {
|
||||
DrawCommand::Quad { color, .. } => assert_eq!(*color, Color::RED),
|
||||
_ => panic!(),
|
||||
}
|
||||
}
|
||||
|
||||
fn try_load_font() -> Option<Font> {
|
||||
for path in common_system_font_paths() {
|
||||
if std::path::Path::new(path).exists() {
|
||||
if let Ok(font) = Font::from_path(path) {
|
||||
return Some(font);
|
||||
}
|
||||
}
|
||||
}
|
||||
eprintln!("SKIP: no system font available for paint tests");
|
||||
None
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_emits_one_glyph_per_visible_char() {
|
||||
let Some(font) = try_load_font() else {
|
||||
return;
|
||||
};
|
||||
let descriptor = FontRef::regular("Sys");
|
||||
let mut fonts = FontStore::new();
|
||||
fonts.insert_with_descriptor(descriptor.clone(), font);
|
||||
let theme = Theme::new().with_default(VisualStyle {
|
||||
font: Some(descriptor),
|
||||
font_size: Some(14.0),
|
||||
foreground: Some(Color::BLACK),
|
||||
..VisualStyle::EMPTY
|
||||
});
|
||||
|
||||
let root = Widget::leaf(Vec2::new(80.0, 20.0))
|
||||
.with_id("label")
|
||||
.with_text("Hi")
|
||||
.with_style(super::super::style::LayoutStyle {
|
||||
width: super::super::style::Sizing::Fixed(80.0),
|
||||
height: super::super::style::Sizing::Fixed(20.0),
|
||||
..Default::default()
|
||||
});
|
||||
let tree = layout(&root, viewport(200.0, 100.0), 1.0);
|
||||
let painted = paint(&root, &tree, &theme, &fonts, 1.0);
|
||||
|
||||
// "Hi" → 2 glyphs (H, i). No background → no Quad commands.
|
||||
let glyph_count = painted
|
||||
.commands
|
||||
.iter()
|
||||
.filter(|c| matches!(c, DrawCommand::Glyph { .. }))
|
||||
.count();
|
||||
let quad_count = painted
|
||||
.commands
|
||||
.iter()
|
||||
.filter(|c| matches!(c, DrawCommand::Quad { .. }))
|
||||
.count();
|
||||
assert_eq!(glyph_count, 2);
|
||||
assert_eq!(quad_count, 0);
|
||||
|
||||
// Both glyphs sit at the same baseline.
|
||||
let baselines: Vec<f32> = painted
|
||||
.commands
|
||||
.iter()
|
||||
.filter_map(|c| match c {
|
||||
DrawCommand::Glyph { pen_position, .. } => Some(pen_position.y),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(baselines[0], baselines[1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_without_font_in_theme_silently_emits_nothing() {
|
||||
// No font registered → text resolves but shape returns no lines.
|
||||
// Paint must not panic.
|
||||
let root = Widget::leaf(Vec2::new(40.0, 20.0)).with_text("Hi");
|
||||
let tree = layout(&root, viewport(100.0, 50.0), 1.0);
|
||||
let painted = paint(&root, &tree, &Theme::new(), &FontStore::new(), 1.0);
|
||||
assert!(painted.commands.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
//! World-space UI panels — a [`Widget`] tree rendered onto a quad in 3D.
|
||||
//!
|
||||
//! Stage 8's UI is "in-game UI" — what the exported game uses to draw
|
||||
//! menus and HUDs. Most of the time those are **screen-space**: pixel-
|
||||
//! anchored, drawn over the 3D scene by piece 4a's
|
||||
//! [`UiOverlayPass`](super::super::render::UiOverlayPass) using an
|
||||
//! orthographic projection. A [`UiPanel`] is the world-space alternative —
|
||||
//! the same `Widget` tree, but laid out on a flat panel that sits in the
|
||||
//! 3D world at some [`Transform`].
|
||||
//!
|
||||
//! This is what gives game projects:
|
||||
//!
|
||||
//! - **Diegetic UI** — terminal screens, signs, dashboards, control
|
||||
//! panels — the player sees them rendered inside the world rather than
|
||||
//! pasted over it.
|
||||
//! - **Editor previews** — the UI canvas (piece 9) can drop a panel into
|
||||
//! the scene to preview a document at scale, on the same hardware path
|
||||
//! the shipped game uses.
|
||||
//! - **VR / room-scale UI** later — once Stage-13 head-mounted display
|
||||
//! support lands, world-space panels are the only sensible way to
|
||||
//! present interactive UI.
|
||||
//!
|
||||
//! # How the math works
|
||||
//!
|
||||
//! A panel describes itself in two coordinate spaces:
|
||||
//!
|
||||
//! - **Pixel space** — where the layout algorithm operates. `pixel_size`
|
||||
//! is the resolution the `Widget` tree is laid out at (e.g.,
|
||||
//! `Vec2::new(1024.0, 768.0)`). Glyphs are rasterized at this scale.
|
||||
//! - **World space** — where the panel sits in 3D. `world_size` is its
|
||||
//! physical size in world units (e.g., `Vec2::new(2.0, 1.5)` for a
|
||||
//! 2 m × 1.5 m monitor).
|
||||
//!
|
||||
//! The piece-4 vertex format carries 2D pixel-space positions. To draw
|
||||
//! that on a 3D quad, [`UiBatch::world_space`](super::super::render::UiBatch::world_space)
|
||||
//! builds a single MVP that composes:
|
||||
//!
|
||||
//! ```text
|
||||
//! mvp = camera_view_projection
|
||||
//! * panel_transform // world placement
|
||||
//! * scale(world.x / pixel.x, -world.y / pixel.y, 1) // pixels → world (and flip y, since UI is y-down)
|
||||
//! * translate(-pixel.x / 2, -pixel.y / 2, 0) // recentre pixel origin
|
||||
//! ```
|
||||
//!
|
||||
//! The same `UiOverlayPass` then draws the panel using the same shader
|
||||
//! and the same R8 atlas — the only thing that distinguishes a screen-
|
||||
//! space batch from a world-space one is which constructor built it.
|
||||
//!
|
||||
//! # Overlay semantics for piece 4b
|
||||
//!
|
||||
//! World-space panels in piece 4b render as **overlays**: no depth test,
|
||||
//! no depth write — they draw on top of whatever's already in the color
|
||||
//! target. That keeps the implementation simple and matches the common
|
||||
//! "always-visible" use case (player nameplates, mission markers,
|
||||
//! editor canvas previews).
|
||||
//!
|
||||
//! A future depth-aware mode (where a panel behind a wall is properly
|
||||
//! hidden) is in [`PLAN.md`](../../../../PLAN.md)'s Stage-8 backlog and
|
||||
//! slots in by attaching a depth attachment to a second pass of the
|
||||
//! same pipeline.
|
||||
|
||||
use glam::Mat4;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::layout::layout;
|
||||
use super::paint::paint;
|
||||
use super::text::FontStore;
|
||||
use super::theme::Theme;
|
||||
use super::widget::Widget;
|
||||
use crate::math::{Rect, Transform, Vec2};
|
||||
|
||||
/// A widget tree placed on a 3D quad.
|
||||
///
|
||||
/// `UiPanel` carries pure data: the document, its pixel resolution, and
|
||||
/// its world size. The host owns the panel's [`Transform`] separately
|
||||
/// (typically as an ECS component on the same entity), the active
|
||||
/// [`Theme`], and the [`FontStore`] — all three are needed at render
|
||||
/// time to build the panel's [`UiBatch`](super::super::render::UiBatch).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct UiPanel {
|
||||
/// The UI document on this panel.
|
||||
pub root: Widget,
|
||||
/// Resolution to lay out the UI at, in logical pixels. Drives the
|
||||
/// pixel size of every glyph rasterization (so a higher
|
||||
/// `pixel_size.x` on the same `world_size.x` produces a crisper
|
||||
/// panel at a cost of more atlas memory).
|
||||
pub pixel_size: Vec2,
|
||||
/// Panel dimensions in world units. Together with `pixel_size` this
|
||||
/// gives the pixels-per-world-unit ratio the MVP uses.
|
||||
pub world_size: Vec2,
|
||||
}
|
||||
|
||||
impl UiPanel {
|
||||
/// Build a panel with the given UI document and dimensions. Equivalent
|
||||
/// to the struct literal; kept as a function so the API can grow
|
||||
/// validation later without breaking callers.
|
||||
pub fn new(root: Widget, pixel_size: Vec2, world_size: Vec2) -> Self {
|
||||
Self {
|
||||
root,
|
||||
pixel_size,
|
||||
world_size,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience: lay out + paint this panel and build the
|
||||
/// [`UiBatch`](super::super::render::UiBatch) the
|
||||
/// [`UiOverlayPass`](super::super::render::UiOverlayPass) consumes.
|
||||
///
|
||||
/// Returns `None` if the panel's `pixel_size` is non-positive — the
|
||||
/// caller didn't configure the panel and there's no meaningful
|
||||
/// rendering to do.
|
||||
pub fn build_batch(
|
||||
&self,
|
||||
theme: &Theme,
|
||||
fonts: &FontStore,
|
||||
panel_transform: &Transform,
|
||||
view_projection: Mat4,
|
||||
) -> Option<super::super::render::UiBatch> {
|
||||
if self.pixel_size.x <= 0.0 || self.pixel_size.y <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let viewport = Rect::from_min_size(Vec2::ZERO, self.pixel_size);
|
||||
let tree = layout(&self.root, viewport, 1.0);
|
||||
let painted = paint(&self.root, &tree, theme, fonts, 1.0);
|
||||
Some(super::super::render::UiBatch::world_space(
|
||||
painted,
|
||||
self.pixel_size,
|
||||
self.world_size,
|
||||
panel_transform,
|
||||
view_projection,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::style::{LayoutStyle, Sizing};
|
||||
use super::super::visual::VisualStyle;
|
||||
use super::*;
|
||||
use crate::math::{Color, Vec3};
|
||||
|
||||
#[test]
|
||||
fn build_batch_returns_none_on_zero_pixel_size() {
|
||||
let panel = UiPanel::new(Widget::default(), Vec2::ZERO, Vec2::new(2.0, 2.0));
|
||||
let theme = Theme::new();
|
||||
let fonts = FontStore::new();
|
||||
let result = panel.build_batch(&theme, &fonts, &Transform::default(), Mat4::IDENTITY);
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_batch_succeeds_with_valid_panel() {
|
||||
let root = Widget::default()
|
||||
.with_style(LayoutStyle {
|
||||
width: Sizing::Grow(1.0),
|
||||
height: Sizing::Grow(1.0),
|
||||
..Default::default()
|
||||
})
|
||||
.with_visual(VisualStyle {
|
||||
background: Some(Color::RED),
|
||||
..VisualStyle::EMPTY
|
||||
});
|
||||
let panel = UiPanel::new(root, Vec2::new(256.0, 128.0), Vec2::new(2.0, 1.0));
|
||||
let theme = Theme::new();
|
||||
let fonts = FontStore::new();
|
||||
let batch = panel
|
||||
.build_batch(&theme, &fonts, &Transform::default(), Mat4::IDENTITY)
|
||||
.expect("valid panel should build a batch");
|
||||
// The batch's painted frame matches the panel's pixel size and has
|
||||
// one Quad command (the red background).
|
||||
assert_eq!(batch.frame.size, Vec2::new(256.0, 128.0));
|
||||
assert_eq!(batch.frame.commands.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_round_trips_through_ron() {
|
||||
let panel = UiPanel::new(
|
||||
Widget::row().with_id("hud").with_visual(VisualStyle {
|
||||
background: Some(Color::WHITE),
|
||||
..VisualStyle::EMPTY
|
||||
}),
|
||||
Vec2::new(1024.0, 768.0),
|
||||
Vec2::new(4.0, 3.0),
|
||||
);
|
||||
let text = ron::ser::to_string_pretty(&panel, ron::ser::PrettyConfig::default()).unwrap();
|
||||
let decoded: UiPanel = ron::de::from_str(&text).unwrap();
|
||||
assert_eq!(panel, decoded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_mvp_keeps_pixel_origin_at_panel_centre() {
|
||||
// Sanity: with an identity view-projection and default panel
|
||||
// transform, a vertex at (0, 0) in pixel space lands at the top-
|
||||
// left of the panel in world space, which under our MVP becomes
|
||||
// (-world.x/2, +world.y/2, 0) (y-down → y-up).
|
||||
let panel = UiPanel::new(
|
||||
Widget::default()
|
||||
.with_style(LayoutStyle {
|
||||
width: Sizing::Grow(1.0),
|
||||
height: Sizing::Grow(1.0),
|
||||
..Default::default()
|
||||
})
|
||||
.with_visual(VisualStyle {
|
||||
background: Some(Color::RED),
|
||||
..VisualStyle::EMPTY
|
||||
}),
|
||||
Vec2::new(2.0, 2.0),
|
||||
Vec2::new(2.0, 2.0),
|
||||
);
|
||||
let batch = panel
|
||||
.build_batch(
|
||||
&Theme::new(),
|
||||
&FontStore::new(),
|
||||
&Transform::default(),
|
||||
Mat4::IDENTITY,
|
||||
)
|
||||
.unwrap();
|
||||
// Apply the MVP to the pixel-space top-left (0, 0, 0, 1).
|
||||
let top_left = batch.mvp * Vec3::new(0.0, 0.0, 0.0).extend(1.0);
|
||||
assert!(
|
||||
(top_left.x - -1.0).abs() < 1e-5 && (top_left.y - 1.0).abs() < 1e-5,
|
||||
"top-left should map to (-1, 1) under identity MVP, got {top_left:?}"
|
||||
);
|
||||
// Bottom-right pixel maps to (+world.x/2, -world.y/2).
|
||||
let bottom_right = batch.mvp * Vec3::new(2.0, 2.0, 0.0).extend(1.0);
|
||||
assert!(
|
||||
(bottom_right.x - 1.0).abs() < 1e-5 && (bottom_right.y - -1.0).abs() < 1e-5,
|
||||
"bottom-right should map to (1, -1), got {bottom_right:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,642 @@
|
||||
//! Input routing — hit-test the UI against the cursor, track hover / press /
|
||||
//! focus per widget, and tell the host whether the UI captured the frame's
|
||||
//! input so the game can decide whether to also handle it.
|
||||
//!
|
||||
//! Stage 8's UI must *consume input before the game* (PLAN.md): if the
|
||||
//! cursor is over a button, clicking shouldn't also fire the game-world
|
||||
//! action bound to that mouse button. The [`Router`] gives the host one
|
||||
//! object to drive each frame:
|
||||
//!
|
||||
//! ```text
|
||||
//! game loop:
|
||||
//! input.handle_event(e); ...
|
||||
//! let frame = router.process(&layout_tree, &input);
|
||||
//! if !frame.captured_mouse { /* game receives mouse input */ }
|
||||
//! if !frame.captured_keyboard { /* game receives keys */ }
|
||||
//! for event in &frame.events { /* run widget callbacks (piece 6) */ }
|
||||
//! ```
|
||||
//!
|
||||
//! The router is purely a state machine over the Stage-7 [`InputState`] and
|
||||
//! the Stage-8 [`LayoutTree`] — no GPU, no widget callbacks (those land in
|
||||
//! piece 6). Tests run headlessly.
|
||||
//!
|
||||
//! # Hit-test order
|
||||
//!
|
||||
//! Hit testing walks [`LayoutTree::nodes`] in **reverse order**. That order
|
||||
//! matches the paint order (parents-before-children, earlier siblings
|
||||
//! before later ones — see [`super::paint`]) — so the *last* node drawn
|
||||
//! is the *first* one tested, which is exactly the topmost interactive
|
||||
//! widget under the cursor.
|
||||
//!
|
||||
//! Anonymous widgets (`WidgetId::default()`) are treated as transparent
|
||||
//! for hit-test purposes: the router skips them and looks deeper, so a
|
||||
//! decorative container without an id doesn't block clicks reaching the
|
||||
//! button inside it.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use winit::event::MouseButton;
|
||||
|
||||
use super::layout::{LayoutNode, LayoutTree};
|
||||
use super::widget::WidgetId;
|
||||
use crate::input::InputState;
|
||||
use crate::math::Vec2;
|
||||
|
||||
/// One event emitted by [`Router::process`] for the current frame.
|
||||
///
|
||||
/// Events are ordered: hover changes come first, then per-button press /
|
||||
/// release / click, then focus changes. Callers in piece 6 will dispatch
|
||||
/// each event to the matching widget's registered callback.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum RouterEvent {
|
||||
/// The cursor moved onto this widget this frame.
|
||||
Hovered(WidgetId),
|
||||
/// The cursor moved off this widget this frame.
|
||||
Unhovered(WidgetId),
|
||||
/// A mouse button was pressed while the cursor was over this widget.
|
||||
Pressed(WidgetId, MouseButton),
|
||||
/// A mouse button was released while the cursor was over this widget.
|
||||
/// May or may not be accompanied by a [`Clicked`](Self::Clicked); see
|
||||
/// the comment on that variant.
|
||||
Released(WidgetId, MouseButton),
|
||||
/// A click completed on this widget: the press *and* release happened
|
||||
/// over the same widget without the cursor leaving in between.
|
||||
/// Dragging off cancels the click.
|
||||
Clicked(WidgetId, MouseButton),
|
||||
/// This widget became the focused widget.
|
||||
FocusGained(WidgetId),
|
||||
/// This widget lost focus.
|
||||
FocusLost(WidgetId),
|
||||
}
|
||||
|
||||
/// What [`Router::process`] produces for one frame.
|
||||
#[derive(Debug, Default, Clone, PartialEq)]
|
||||
pub struct RouterFrame {
|
||||
/// Events emitted this frame, in the order they were observed.
|
||||
pub events: Vec<RouterEvent>,
|
||||
/// `true` if the cursor is over any (non-anonymous) widget — the game
|
||||
/// should not also process this frame's mouse input.
|
||||
pub captured_mouse: bool,
|
||||
/// `true` if a widget currently has keyboard focus — the game should
|
||||
/// not also process this frame's key events.
|
||||
pub captured_keyboard: bool,
|
||||
}
|
||||
|
||||
impl RouterFrame {
|
||||
/// `true` if this frame contains a `Clicked` event on `id` for the
|
||||
/// given mouse button. The immediate-mode pattern: game code calls
|
||||
/// `if frame.clicked("play", MouseButton::Left) { start_game() }`
|
||||
/// instead of registering a callback.
|
||||
pub fn clicked(&self, id: impl AsRef<str>, button: MouseButton) -> bool {
|
||||
let id = id.as_ref();
|
||||
self.events.iter().any(|e| match e {
|
||||
RouterEvent::Clicked(w, b) => w.as_str() == id && *b == button,
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Shorthand for [`clicked`](Self::clicked) with the left button.
|
||||
pub fn clicked_left(&self, id: impl AsRef<str>) -> bool {
|
||||
self.clicked(id, MouseButton::Left)
|
||||
}
|
||||
|
||||
/// `true` if this frame contains a `Pressed` event on `id` with the
|
||||
/// given mouse button.
|
||||
pub fn pressed(&self, id: impl AsRef<str>, button: MouseButton) -> bool {
|
||||
let id = id.as_ref();
|
||||
self.events.iter().any(|e| match e {
|
||||
RouterEvent::Pressed(w, b) => w.as_str() == id && *b == button,
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
/// `true` if this frame contains a `Released` event on `id` with the
|
||||
/// given mouse button.
|
||||
pub fn released(&self, id: impl AsRef<str>, button: MouseButton) -> bool {
|
||||
let id = id.as_ref();
|
||||
self.events.iter().any(|e| match e {
|
||||
RouterEvent::Released(w, b) => w.as_str() == id && *b == button,
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
/// `true` if the cursor entered `id` this frame.
|
||||
pub fn hovered_in(&self, id: impl AsRef<str>) -> bool {
|
||||
let id = id.as_ref();
|
||||
self.events.iter().any(|e| match e {
|
||||
RouterEvent::Hovered(w) => w.as_str() == id,
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
/// `true` if the cursor left `id` this frame.
|
||||
pub fn hovered_out(&self, id: impl AsRef<str>) -> bool {
|
||||
let id = id.as_ref();
|
||||
self.events.iter().any(|e| match e {
|
||||
RouterEvent::Unhovered(w) => w.as_str() == id,
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
/// `true` if `id` gained focus this frame.
|
||||
pub fn focus_gained(&self, id: impl AsRef<str>) -> bool {
|
||||
let id = id.as_ref();
|
||||
self.events.iter().any(|e| match e {
|
||||
RouterEvent::FocusGained(w) => w.as_str() == id,
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
/// `true` if `id` lost focus this frame.
|
||||
pub fn focus_lost(&self, id: impl AsRef<str>) -> bool {
|
||||
let id = id.as_ref();
|
||||
self.events.iter().any(|e| match e {
|
||||
RouterEvent::FocusLost(w) => w.as_str() == id,
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-widget input state machine. Persists hover / focus / pending-press
|
||||
/// across frames so click-detection (press *and* release on the same
|
||||
/// widget) works correctly across the multiple frames a click typically
|
||||
/// spans.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Router {
|
||||
hovered: Option<WidgetId>,
|
||||
focused: Option<WidgetId>,
|
||||
/// Per-button: the widget that received the most recent un-released
|
||||
/// press. A click completes if the release happens over the same
|
||||
/// widget; otherwise the press is cancelled (drag-off semantics).
|
||||
pending: HashMap<MouseButton, WidgetId>,
|
||||
}
|
||||
|
||||
impl Router {
|
||||
/// Build an empty router with no hover, no focus, and no pending presses.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// The currently hovered widget, or `None` when the cursor is not over
|
||||
/// any addressable widget.
|
||||
pub fn hovered(&self) -> Option<&WidgetId> {
|
||||
self.hovered.as_ref()
|
||||
}
|
||||
|
||||
/// The currently focused widget, or `None` if none.
|
||||
pub fn focused(&self) -> Option<&WidgetId> {
|
||||
self.focused.as_ref()
|
||||
}
|
||||
|
||||
/// Explicitly focus a widget (e.g., from game code after opening a
|
||||
/// menu). Emits no event — the caller decided to do this.
|
||||
pub fn set_focused(&mut self, id: Option<WidgetId>) {
|
||||
self.focused = id;
|
||||
}
|
||||
|
||||
/// Run the input pipeline against one frame's [`InputState`] and the
|
||||
/// current [`LayoutTree`]. Updates internal state, returns events plus
|
||||
/// the capture flags.
|
||||
pub fn process(&mut self, tree: &LayoutTree, input: &InputState) -> RouterFrame {
|
||||
let mut frame = RouterFrame::default();
|
||||
let new_hover = input
|
||||
.cursor()
|
||||
.and_then(|c| hit_test(tree, c))
|
||||
.map(|node| node.id.clone());
|
||||
|
||||
// Hover transitions.
|
||||
if new_hover != self.hovered {
|
||||
if let Some(old) = self.hovered.take() {
|
||||
frame.events.push(RouterEvent::Unhovered(old));
|
||||
}
|
||||
if let Some(new) = new_hover.clone() {
|
||||
frame.events.push(RouterEvent::Hovered(new));
|
||||
}
|
||||
}
|
||||
self.hovered = new_hover;
|
||||
frame.captured_mouse = self.hovered.is_some();
|
||||
|
||||
// Mouse press / release per button. The Stage-7 InputState
|
||||
// exposes "buttons held" + per-button edge flags; we walk the
|
||||
// currently-relevant buttons (those held this frame *or* present
|
||||
// as pending from previous frames).
|
||||
let mut buttons = std::collections::HashSet::new();
|
||||
buttons.extend(input.mouse_buttons_held());
|
||||
buttons.extend(self.pending.keys().copied());
|
||||
// Common buttons that may have just pressed/released without being
|
||||
// held now (release edge happens after the held set has cleared
|
||||
// the button).
|
||||
for b in [MouseButton::Left, MouseButton::Right, MouseButton::Middle] {
|
||||
if input.mouse_pressed(b) || input.mouse_released(b) {
|
||||
buttons.insert(b);
|
||||
}
|
||||
}
|
||||
|
||||
for button in buttons {
|
||||
if input.mouse_pressed(button) {
|
||||
if let Some(target) = self.hovered.clone() {
|
||||
frame
|
||||
.events
|
||||
.push(RouterEvent::Pressed(target.clone(), button));
|
||||
self.pending.insert(button, target.clone());
|
||||
self.update_focus(Some(target), &mut frame);
|
||||
} else {
|
||||
// Click outside any widget clears focus.
|
||||
self.update_focus(None, &mut frame);
|
||||
}
|
||||
}
|
||||
if input.mouse_released(button) {
|
||||
if let Some(pending_id) = self.pending.remove(&button) {
|
||||
if let Some(current) = self.hovered.clone() {
|
||||
frame
|
||||
.events
|
||||
.push(RouterEvent::Released(current.clone(), button));
|
||||
if current == pending_id {
|
||||
frame.events.push(RouterEvent::Clicked(current, button));
|
||||
}
|
||||
} else {
|
||||
// Drag-off then release: cancel the click. No
|
||||
// Released event has a target either, since we
|
||||
// require a hovered widget for that.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
frame.captured_keyboard = self.focused.is_some();
|
||||
frame
|
||||
}
|
||||
|
||||
/// Move focus to `next` (or clear it when `None`), emitting `FocusLost`
|
||||
/// / `FocusGained` events. Idempotent when `next` matches the current
|
||||
/// focus.
|
||||
fn update_focus(&mut self, next: Option<WidgetId>, frame: &mut RouterFrame) {
|
||||
if next == self.focused {
|
||||
return;
|
||||
}
|
||||
if let Some(old) = self.focused.take() {
|
||||
frame.events.push(RouterEvent::FocusLost(old));
|
||||
}
|
||||
if let Some(new) = next.clone() {
|
||||
frame.events.push(RouterEvent::FocusGained(new));
|
||||
}
|
||||
self.focused = next;
|
||||
}
|
||||
}
|
||||
|
||||
/// Hit-test `point` against the laid-out widgets. Returns the topmost
|
||||
/// (most-recently-painted) [`LayoutNode`] with a non-empty id whose `rect`
|
||||
/// contains the point, or `None` if no addressable widget is under the
|
||||
/// point.
|
||||
///
|
||||
/// Anonymous widgets (empty `id`) are skipped so a decorative container
|
||||
/// doesn't block hits on the button it contains. Iteration is in reverse
|
||||
/// node order — children and later siblings (drawn on top) are tested
|
||||
/// before their parents.
|
||||
pub fn hit_test(tree: &LayoutTree, point: Vec2) -> Option<&LayoutNode> {
|
||||
for node in tree.nodes().iter().rev() {
|
||||
if node.id.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if node.rect.contains_point(point) {
|
||||
return Some(node);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::layout::layout;
|
||||
use super::super::style::{LayoutStyle, Sizing};
|
||||
use super::super::widget::Widget;
|
||||
use super::*;
|
||||
use crate::math::Rect;
|
||||
|
||||
fn viewport(w: f32, h: f32) -> Rect {
|
||||
Rect::from_min_size(Vec2::ZERO, Vec2::new(w, h))
|
||||
}
|
||||
|
||||
fn make_tree() -> (Widget, LayoutTree) {
|
||||
// Root container with two side-by-side leaves: "left" and "right".
|
||||
let root = Widget::row()
|
||||
.with_id("root")
|
||||
.with_style(LayoutStyle {
|
||||
width: Sizing::Fixed(200.0),
|
||||
height: Sizing::Fixed(100.0),
|
||||
..Default::default()
|
||||
})
|
||||
.with_child(
|
||||
Widget::leaf(Vec2::new(100.0, 100.0))
|
||||
.with_id("left")
|
||||
.with_style(LayoutStyle {
|
||||
width: Sizing::Fixed(100.0),
|
||||
height: Sizing::Fixed(100.0),
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.with_child(
|
||||
Widget::leaf(Vec2::new(100.0, 100.0))
|
||||
.with_id("right")
|
||||
.with_style(LayoutStyle {
|
||||
width: Sizing::Fixed(100.0),
|
||||
height: Sizing::Fixed(100.0),
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
let tree = layout(&root, viewport(400.0, 200.0), 1.0);
|
||||
(root, tree)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hit_test_returns_topmost_widget_with_id() {
|
||||
let (_root, tree) = make_tree();
|
||||
// Cursor over the left child → returns "left", not "root".
|
||||
let hit = hit_test(&tree, Vec2::new(50.0, 50.0)).unwrap();
|
||||
assert_eq!(hit.id.as_str(), "left");
|
||||
// Cursor over the right child → "right".
|
||||
let hit = hit_test(&tree, Vec2::new(150.0, 50.0)).unwrap();
|
||||
assert_eq!(hit.id.as_str(), "right");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hit_test_falls_back_to_parent_when_children_dont_cover() {
|
||||
// Root 200×100 with 20-pixel padding, containing one 80×60 button.
|
||||
// The padding gutter is "root-only" space — clicks there should
|
||||
// resolve to "root", not the button.
|
||||
let root = Widget::row()
|
||||
.with_id("root")
|
||||
.with_style(LayoutStyle {
|
||||
width: Sizing::Fixed(200.0),
|
||||
height: Sizing::Fixed(100.0),
|
||||
padding: super::super::style::Insets::all(20.0),
|
||||
..Default::default()
|
||||
})
|
||||
.with_child(
|
||||
Widget::leaf(Vec2::new(80.0, 60.0))
|
||||
.with_id("button")
|
||||
.with_style(LayoutStyle {
|
||||
width: Sizing::Fixed(80.0),
|
||||
height: Sizing::Fixed(60.0),
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
let tree = layout(&root, viewport(400.0, 200.0), 1.0);
|
||||
// Inside the button.
|
||||
let hit = hit_test(&tree, Vec2::new(60.0, 50.0)).unwrap();
|
||||
assert_eq!(hit.id.as_str(), "button");
|
||||
// Inside root's padding gutter (10, 50) → root, not button.
|
||||
let hit = hit_test(&tree, Vec2::new(10.0, 50.0)).unwrap();
|
||||
assert_eq!(hit.id.as_str(), "root");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hit_test_skips_anonymous_widgets() {
|
||||
// A button buried inside two anonymous containers should still hit.
|
||||
let root = Widget::row()
|
||||
.with_style(LayoutStyle {
|
||||
width: Sizing::Fixed(200.0),
|
||||
height: Sizing::Fixed(100.0),
|
||||
..Default::default()
|
||||
})
|
||||
.with_child(
|
||||
Widget::row()
|
||||
.with_style(LayoutStyle {
|
||||
width: Sizing::Fixed(100.0),
|
||||
height: Sizing::Fixed(100.0),
|
||||
..Default::default()
|
||||
})
|
||||
.with_child(
|
||||
Widget::leaf(Vec2::new(80.0, 80.0))
|
||||
.with_id("button")
|
||||
.with_style(LayoutStyle {
|
||||
width: Sizing::Fixed(80.0),
|
||||
height: Sizing::Fixed(80.0),
|
||||
..Default::default()
|
||||
}),
|
||||
),
|
||||
);
|
||||
let tree = layout(&root, viewport(200.0, 100.0), 1.0);
|
||||
let hit = hit_test(&tree, Vec2::new(20.0, 20.0)).unwrap();
|
||||
assert_eq!(hit.id.as_str(), "button");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hit_test_returns_none_outside_root() {
|
||||
let (_root, tree) = make_tree();
|
||||
let hit = hit_test(&tree, Vec2::new(500.0, 500.0));
|
||||
assert!(hit.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_moving_onto_widget_emits_hovered_event() {
|
||||
let (_root, tree) = make_tree();
|
||||
let mut router = Router::new();
|
||||
let mut input = InputState::new();
|
||||
|
||||
// First frame: cursor outside, no hover.
|
||||
input.set_cursor(Vec2::new(500.0, 500.0));
|
||||
let f = router.process(&tree, &input);
|
||||
assert!(f.events.is_empty());
|
||||
assert!(!f.captured_mouse);
|
||||
|
||||
// Move into the left widget.
|
||||
input.set_cursor(Vec2::new(50.0, 50.0));
|
||||
let f = router.process(&tree, &input);
|
||||
assert_eq!(f.events, vec![RouterEvent::Hovered("left".into())]);
|
||||
assert!(f.captured_mouse);
|
||||
assert_eq!(router.hovered(), Some(&"left".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_moving_off_emits_unhovered() {
|
||||
let (_root, tree) = make_tree();
|
||||
let mut router = Router::new();
|
||||
let mut input = InputState::new();
|
||||
input.set_cursor(Vec2::new(50.0, 50.0));
|
||||
router.process(&tree, &input);
|
||||
|
||||
// Move off the widget.
|
||||
input.set_cursor(Vec2::new(500.0, 500.0));
|
||||
let f = router.process(&tree, &input);
|
||||
assert_eq!(f.events, vec![RouterEvent::Unhovered("left".into())]);
|
||||
assert!(!f.captured_mouse);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_moving_between_widgets_swaps_hover() {
|
||||
let (_root, tree) = make_tree();
|
||||
let mut router = Router::new();
|
||||
let mut input = InputState::new();
|
||||
input.set_cursor(Vec2::new(50.0, 50.0));
|
||||
router.process(&tree, &input);
|
||||
|
||||
input.set_cursor(Vec2::new(150.0, 50.0));
|
||||
let f = router.process(&tree, &input);
|
||||
// Unhover left, then hover right (both this frame).
|
||||
assert_eq!(
|
||||
f.events,
|
||||
vec![
|
||||
RouterEvent::Unhovered("left".into()),
|
||||
RouterEvent::Hovered("right".into()),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressing_over_widget_emits_pressed_and_focuses() {
|
||||
let (_root, tree) = make_tree();
|
||||
let mut router = Router::new();
|
||||
let mut input = InputState::new();
|
||||
input.set_cursor(Vec2::new(50.0, 50.0));
|
||||
// Frame 1: hover only.
|
||||
router.process(&tree, &input);
|
||||
// Frame 2: press the left button while hovering.
|
||||
input.press_mouse(MouseButton::Left);
|
||||
let f = router.process(&tree, &input);
|
||||
assert!(f
|
||||
.events
|
||||
.contains(&RouterEvent::Pressed("left".into(), MouseButton::Left)));
|
||||
assert!(f.events.contains(&RouterEvent::FocusGained("left".into())));
|
||||
assert_eq!(router.focused(), Some(&"left".into()));
|
||||
assert!(f.captured_keyboard);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn press_then_release_on_same_widget_emits_click() {
|
||||
let (_root, tree) = make_tree();
|
||||
let mut router = Router::new();
|
||||
let mut input = InputState::new();
|
||||
input.set_cursor(Vec2::new(50.0, 50.0));
|
||||
router.process(&tree, &input);
|
||||
input.press_mouse(MouseButton::Left);
|
||||
router.process(&tree, &input);
|
||||
input.end_frame(); // clear the press edge
|
||||
input.release_mouse(MouseButton::Left);
|
||||
let f = router.process(&tree, &input);
|
||||
// Released and Clicked, both on "left".
|
||||
assert!(f
|
||||
.events
|
||||
.contains(&RouterEvent::Released("left".into(), MouseButton::Left)));
|
||||
assert!(f
|
||||
.events
|
||||
.contains(&RouterEvent::Clicked("left".into(), MouseButton::Left)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn press_then_drag_off_then_release_does_not_emit_click() {
|
||||
let (_root, tree) = make_tree();
|
||||
let mut router = Router::new();
|
||||
let mut input = InputState::new();
|
||||
input.set_cursor(Vec2::new(50.0, 50.0));
|
||||
router.process(&tree, &input);
|
||||
input.press_mouse(MouseButton::Left);
|
||||
router.process(&tree, &input);
|
||||
input.end_frame();
|
||||
|
||||
// Drag onto the right widget, then release.
|
||||
input.set_cursor(Vec2::new(150.0, 50.0));
|
||||
input.release_mouse(MouseButton::Left);
|
||||
let f = router.process(&tree, &input);
|
||||
let clicked = f
|
||||
.events
|
||||
.iter()
|
||||
.any(|e| matches!(e, RouterEvent::Clicked(_, _)));
|
||||
assert!(!clicked, "drag-off should cancel the click");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressing_outside_any_widget_clears_focus() {
|
||||
let (_root, tree) = make_tree();
|
||||
let mut router = Router::new();
|
||||
router.set_focused(Some("left".into()));
|
||||
let mut input = InputState::new();
|
||||
input.set_cursor(Vec2::new(500.0, 500.0));
|
||||
input.press_mouse(MouseButton::Left);
|
||||
let f = router.process(&tree, &input);
|
||||
assert!(f.events.contains(&RouterEvent::FocusLost("left".into())));
|
||||
assert_eq!(router.focused(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn captured_flags_match_state() {
|
||||
let (_root, tree) = make_tree();
|
||||
let mut router = Router::new();
|
||||
let mut input = InputState::new();
|
||||
// No cursor, no focus → nothing captured.
|
||||
let f = router.process(&tree, &input);
|
||||
assert!(!f.captured_mouse);
|
||||
assert!(!f.captured_keyboard);
|
||||
|
||||
// Cursor over a widget → captures mouse.
|
||||
input.set_cursor(Vec2::new(50.0, 50.0));
|
||||
let f = router.process(&tree, &input);
|
||||
assert!(f.captured_mouse);
|
||||
assert!(!f.captured_keyboard);
|
||||
|
||||
// Press → focuses, captures keyboard too.
|
||||
input.press_mouse(MouseButton::Left);
|
||||
let f = router.process(&tree, &input);
|
||||
assert!(f.captured_keyboard);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_off_screen_does_not_hover() {
|
||||
let (_root, tree) = make_tree();
|
||||
let mut router = Router::new();
|
||||
let input = InputState::new(); // cursor unset
|
||||
let f = router.process(&tree, &input);
|
||||
assert!(f.events.is_empty());
|
||||
assert!(!f.captured_mouse);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn router_frame_clicked_query_matches_button_and_id() {
|
||||
let (_root, tree) = make_tree();
|
||||
let mut router = Router::new();
|
||||
let mut input = InputState::new();
|
||||
input.set_cursor(Vec2::new(50.0, 50.0));
|
||||
router.process(&tree, &input);
|
||||
input.press_mouse(MouseButton::Left);
|
||||
router.process(&tree, &input);
|
||||
input.end_frame();
|
||||
input.release_mouse(MouseButton::Left);
|
||||
let f = router.process(&tree, &input);
|
||||
// Immediate-mode query: was "left" clicked with Left?
|
||||
assert!(f.clicked_left("left"));
|
||||
assert!(f.clicked("left", MouseButton::Left));
|
||||
// Different id or different button → false.
|
||||
assert!(!f.clicked_left("right"));
|
||||
assert!(!f.clicked("left", MouseButton::Right));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn router_frame_query_methods_cover_each_event_kind() {
|
||||
// Build a frame manually with one of each event variant and
|
||||
// verify each query method matches exactly one.
|
||||
let f = RouterFrame {
|
||||
events: vec![
|
||||
RouterEvent::Hovered("a".into()),
|
||||
RouterEvent::Unhovered("b".into()),
|
||||
RouterEvent::Pressed("c".into(), MouseButton::Right),
|
||||
RouterEvent::Released("d".into(), MouseButton::Middle),
|
||||
RouterEvent::Clicked("e".into(), MouseButton::Left),
|
||||
RouterEvent::FocusGained("f".into()),
|
||||
RouterEvent::FocusLost("g".into()),
|
||||
],
|
||||
captured_mouse: true,
|
||||
captured_keyboard: true,
|
||||
};
|
||||
assert!(f.hovered_in("a"));
|
||||
assert!(f.hovered_out("b"));
|
||||
assert!(f.pressed("c", MouseButton::Right));
|
||||
assert!(f.released("d", MouseButton::Middle));
|
||||
assert!(f.clicked("e", MouseButton::Left));
|
||||
assert!(f.focus_gained("f"));
|
||||
assert!(f.focus_lost("g"));
|
||||
// Negative checks.
|
||||
assert!(!f.hovered_in("b"));
|
||||
assert!(!f.clicked_left("c")); // Pressed, not Clicked
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
//! Layout style primitives — sizing, padding, margin, alignment, and anchors.
|
||||
//!
|
||||
//! Every Stage-8 widget carries a [`LayoutStyle`] that tells the layout
|
||||
//! algorithm how to size and position it inside its parent's content rect.
|
||||
//! The primitives here are deliberately small and orthogonal so they compose
|
||||
//! into the three layout modes (stack, grid, anchor) without each mode
|
||||
//! introducing its own bespoke parameters.
|
||||
//!
|
||||
//! All linear measurements (`Sizing::Fixed`, [`Insets`] fields, anchor
|
||||
//! offsets, stack/grid gaps) are in **logical pixels**. The layout function
|
||||
//! takes a separate `scale` factor (typically the window's DPI scale) and
|
||||
//! multiplies these values at resolve time, so one widget tree lays out
|
||||
//! sensibly on a 1× laptop and a 2× HiDPI monitor without per-widget rewrites.
|
||||
|
||||
use glam::Vec2;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// How a widget asks to be sized along one axis.
|
||||
///
|
||||
/// Sizing interacts with the parent's layout mode:
|
||||
///
|
||||
/// - In a stack, the **main axis** sums all `Fixed` and `FitContent` sizes,
|
||||
/// then divides leftover space among `Grow` siblings by weight. The
|
||||
/// **cross axis** sizes each child independently (`Grow` fills the parent's
|
||||
/// cross extent; the other variants behave like the main axis).
|
||||
/// - In a grid, every child fills its cell, but `Fixed`/`FitContent` cap the
|
||||
/// child's drawn size and let [`LayoutStyle::align_horizontal`] /
|
||||
/// [`LayoutStyle::align_vertical`] position the smaller rect inside the
|
||||
/// cell.
|
||||
/// - In an anchor parent, child sizing is **ignored** along axes the anchor
|
||||
/// actually constrains; the anchor + offsets fully determine the child's
|
||||
/// rect.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub enum Sizing {
|
||||
/// A fixed size in logical pixels. Multiplied by the layout scale factor.
|
||||
Fixed(f32),
|
||||
/// Take a share of the parent's leftover space, weighted by `f32`.
|
||||
///
|
||||
/// Two siblings with `Grow(1.0)` split leftover space evenly; `Grow(2.0)`
|
||||
/// next to `Grow(1.0)` takes 2/3 of it. A non-positive weight contributes
|
||||
/// nothing and the child collapses to zero on that axis.
|
||||
Grow(f32),
|
||||
/// Size to fit the widget's own content — the intrinsic size for leaves,
|
||||
/// the recursive content extent for containers.
|
||||
#[default]
|
||||
FitContent,
|
||||
}
|
||||
|
||||
/// Per-side spacing in logical pixels — used for both padding (inside) and
|
||||
/// margin (outside).
|
||||
///
|
||||
/// Padding shrinks a widget's `content_rect` (children draw inside it); margin
|
||||
/// reserves space *around* the widget so siblings don't touch it. Both are
|
||||
/// scaled by the layout scale factor at resolve time.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)]
|
||||
pub struct Insets {
|
||||
pub left: f32,
|
||||
pub right: f32,
|
||||
pub top: f32,
|
||||
pub bottom: f32,
|
||||
}
|
||||
|
||||
impl Insets {
|
||||
pub const ZERO: Self = Self {
|
||||
left: 0.0,
|
||||
right: 0.0,
|
||||
top: 0.0,
|
||||
bottom: 0.0,
|
||||
};
|
||||
|
||||
/// Same value on every side.
|
||||
pub const fn all(v: f32) -> Self {
|
||||
Self {
|
||||
left: v,
|
||||
right: v,
|
||||
top: v,
|
||||
bottom: v,
|
||||
}
|
||||
}
|
||||
|
||||
/// Symmetric: one value for left+right, another for top+bottom.
|
||||
pub const fn symmetric(horizontal: f32, vertical: f32) -> Self {
|
||||
Self {
|
||||
left: horizontal,
|
||||
right: horizontal,
|
||||
top: vertical,
|
||||
bottom: vertical,
|
||||
}
|
||||
}
|
||||
|
||||
/// Combined horizontal extent (`left + right`).
|
||||
#[inline]
|
||||
pub fn horizontal(&self) -> f32 {
|
||||
self.left + self.right
|
||||
}
|
||||
|
||||
/// Combined vertical extent (`top + bottom`).
|
||||
#[inline]
|
||||
pub fn vertical(&self) -> f32 {
|
||||
self.top + self.bottom
|
||||
}
|
||||
|
||||
/// Component-wise scale (used internally by the layout algorithm to apply
|
||||
/// the DPI factor; exposed for tests that want to verify the scaling).
|
||||
#[inline]
|
||||
pub fn scaled(&self, scale: f32) -> Self {
|
||||
Self {
|
||||
left: self.left * scale,
|
||||
right: self.right * scale,
|
||||
top: self.top * scale,
|
||||
bottom: self.bottom * scale,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Alignment along one axis when a widget is smaller than its slot.
|
||||
///
|
||||
/// In a row stack, `align_vertical` decides whether a short child docks to the
|
||||
/// top, middle, or bottom of the row's content rect. The stack's own
|
||||
/// [`Stack::main_align`](super::widget::Stack::main_align) does the analogous
|
||||
/// thing along the **main** axis when all children are sized but don't sum to
|
||||
/// the full main extent.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub enum Align {
|
||||
/// Top / left edge.
|
||||
#[default]
|
||||
Start,
|
||||
/// Centered in the available space.
|
||||
Center,
|
||||
/// Bottom / right edge.
|
||||
End,
|
||||
}
|
||||
|
||||
/// How a child positions itself inside an [`AnchorGroup`](super::widget::AnchorGroup)
|
||||
/// parent.
|
||||
///
|
||||
/// Anchors are two normalized points in `[0, 1]²` (the **anchor rectangle**)
|
||||
/// plus per-corner offsets in logical pixels. The child's resulting rect is:
|
||||
///
|
||||
/// ```text
|
||||
/// rect.min = parent.content.min + parent.content.size * anchor.min + offset_min * scale
|
||||
/// rect.max = parent.content.min + parent.content.size * anchor.max + offset_max * scale
|
||||
/// ```
|
||||
///
|
||||
/// This is the standard Unity / Godot anchor formulation: pick two anchor
|
||||
/// corners (a single point for "follow that corner", a full rectangle for
|
||||
/// "dock to this edge / fill"), then nudge with offsets. The default is
|
||||
/// [`Anchor::FILL`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Anchor {
|
||||
pub min: Vec2,
|
||||
pub max: Vec2,
|
||||
pub offset_min: Vec2,
|
||||
pub offset_max: Vec2,
|
||||
}
|
||||
|
||||
impl Anchor {
|
||||
/// Fill the parent's content rect exactly. The default for new widgets.
|
||||
pub const FILL: Self = Self {
|
||||
min: Vec2::ZERO,
|
||||
max: Vec2::ONE,
|
||||
offset_min: Vec2::ZERO,
|
||||
offset_max: Vec2::ZERO,
|
||||
};
|
||||
|
||||
/// Pin to the top-left corner with `offset_max` controlling the child's
|
||||
/// size (which is otherwise zero because `min == max`).
|
||||
pub const TOP_LEFT: Self = Self {
|
||||
min: Vec2::ZERO,
|
||||
max: Vec2::ZERO,
|
||||
offset_min: Vec2::ZERO,
|
||||
offset_max: Vec2::ZERO,
|
||||
};
|
||||
|
||||
/// Pin to the top-right corner.
|
||||
pub const TOP_RIGHT: Self = Self {
|
||||
min: Vec2::new(1.0, 0.0),
|
||||
max: Vec2::new(1.0, 0.0),
|
||||
offset_min: Vec2::ZERO,
|
||||
offset_max: Vec2::ZERO,
|
||||
};
|
||||
|
||||
/// Pin to the bottom-left corner.
|
||||
pub const BOTTOM_LEFT: Self = Self {
|
||||
min: Vec2::new(0.0, 1.0),
|
||||
max: Vec2::new(0.0, 1.0),
|
||||
offset_min: Vec2::ZERO,
|
||||
offset_max: Vec2::ZERO,
|
||||
};
|
||||
|
||||
/// Pin to the bottom-right corner.
|
||||
pub const BOTTOM_RIGHT: Self = Self {
|
||||
min: Vec2::ONE,
|
||||
max: Vec2::ONE,
|
||||
offset_min: Vec2::ZERO,
|
||||
offset_max: Vec2::ZERO,
|
||||
};
|
||||
|
||||
/// Dock to the top edge — full width, child height controlled by
|
||||
/// `offset_max.y`.
|
||||
pub const TOP: Self = Self {
|
||||
min: Vec2::ZERO,
|
||||
max: Vec2::new(1.0, 0.0),
|
||||
offset_min: Vec2::ZERO,
|
||||
offset_max: Vec2::ZERO,
|
||||
};
|
||||
|
||||
/// Dock to the bottom edge — full width, child height controlled by
|
||||
/// `offset_min.y` (negative pushes the top edge upward).
|
||||
pub const BOTTOM: Self = Self {
|
||||
min: Vec2::new(0.0, 1.0),
|
||||
max: Vec2::ONE,
|
||||
offset_min: Vec2::ZERO,
|
||||
offset_max: Vec2::ZERO,
|
||||
};
|
||||
|
||||
/// Dock to the left edge — full height, child width via `offset_max.x`.
|
||||
pub const LEFT: Self = Self {
|
||||
min: Vec2::ZERO,
|
||||
max: Vec2::new(0.0, 1.0),
|
||||
offset_min: Vec2::ZERO,
|
||||
offset_max: Vec2::ZERO,
|
||||
};
|
||||
|
||||
/// Dock to the right edge — full height, child width via `offset_min.x`
|
||||
/// (negative widens the child leftward).
|
||||
pub const RIGHT: Self = Self {
|
||||
min: Vec2::new(1.0, 0.0),
|
||||
max: Vec2::ONE,
|
||||
offset_min: Vec2::ZERO,
|
||||
offset_max: Vec2::ZERO,
|
||||
};
|
||||
|
||||
/// Construct an anchor with explicit corner pair (offsets zero).
|
||||
pub const fn between(min: Vec2, max: Vec2) -> Self {
|
||||
Self {
|
||||
min,
|
||||
max,
|
||||
offset_min: Vec2::ZERO,
|
||||
offset_max: Vec2::ZERO,
|
||||
}
|
||||
}
|
||||
|
||||
/// Add fixed offsets in logical pixels to the resolved corners.
|
||||
pub const fn with_offsets(mut self, offset_min: Vec2, offset_max: Vec2) -> Self {
|
||||
self.offset_min = offset_min;
|
||||
self.offset_max = offset_max;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Anchor {
|
||||
fn default() -> Self {
|
||||
Self::FILL
|
||||
}
|
||||
}
|
||||
|
||||
/// Combined style controlling how a widget sizes, spaces, and aligns itself
|
||||
/// inside its parent's slot.
|
||||
///
|
||||
/// `LayoutStyle` is deliberately one flat struct (rather than per-axis or
|
||||
/// per-mode sub-structs) because every widget needs the same fields and most
|
||||
/// of them are zero by default. Tests and authors can write
|
||||
/// `LayoutStyle::default()` and only set the fields they care about.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)]
|
||||
pub struct LayoutStyle {
|
||||
/// Horizontal sizing rule.
|
||||
pub width: Sizing,
|
||||
/// Vertical sizing rule.
|
||||
pub height: Sizing,
|
||||
/// Space *inside* this widget's rect, before children are arranged.
|
||||
pub padding: Insets,
|
||||
/// Space *outside* this widget's rect, reserved in the parent's layout
|
||||
/// before computing leftover space.
|
||||
pub margin: Insets,
|
||||
/// Horizontal alignment when this widget's resolved width is smaller than
|
||||
/// the slot the parent gave it.
|
||||
pub align_horizontal: Align,
|
||||
/// Vertical alignment when this widget's resolved height is smaller than
|
||||
/// the slot the parent gave it.
|
||||
pub align_vertical: Align,
|
||||
/// Anchor — only consulted when this widget's parent is an
|
||||
/// [`AnchorGroup`](super::widget::AnchorGroup); ignored otherwise.
|
||||
pub anchor: Anchor,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_layout_style_is_fit_content_fill_anchor() {
|
||||
let s = LayoutStyle::default();
|
||||
assert_eq!(s.width, Sizing::FitContent);
|
||||
assert_eq!(s.height, Sizing::FitContent);
|
||||
assert_eq!(s.padding, Insets::ZERO);
|
||||
assert_eq!(s.margin, Insets::ZERO);
|
||||
assert_eq!(s.align_horizontal, Align::Start);
|
||||
assert_eq!(s.align_vertical, Align::Start);
|
||||
assert_eq!(s.anchor, Anchor::FILL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insets_helpers_are_correct() {
|
||||
let i = Insets::all(4.0);
|
||||
assert_eq!(i.left, 4.0);
|
||||
assert_eq!(i.right, 4.0);
|
||||
assert_eq!(i.top, 4.0);
|
||||
assert_eq!(i.bottom, 4.0);
|
||||
assert_eq!(i.horizontal(), 8.0);
|
||||
assert_eq!(i.vertical(), 8.0);
|
||||
|
||||
let s = Insets::symmetric(2.0, 6.0);
|
||||
assert_eq!(s.horizontal(), 4.0);
|
||||
assert_eq!(s.vertical(), 12.0);
|
||||
|
||||
let scaled = i.scaled(2.0);
|
||||
assert_eq!(scaled, Insets::all(8.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anchor_constants_match_doc_corners() {
|
||||
// FILL spans the whole parent.
|
||||
assert_eq!(Anchor::FILL.min, Vec2::ZERO);
|
||||
assert_eq!(Anchor::FILL.max, Vec2::ONE);
|
||||
// Each corner pin collapses to a point.
|
||||
assert_eq!(Anchor::TOP_LEFT.min, Anchor::TOP_LEFT.max);
|
||||
assert_eq!(Anchor::TOP_RIGHT.min, Vec2::new(1.0, 0.0));
|
||||
assert_eq!(Anchor::BOTTOM_LEFT.max, Vec2::new(0.0, 1.0));
|
||||
assert_eq!(Anchor::BOTTOM_RIGHT.min, Vec2::ONE);
|
||||
// Edge docks span one full axis.
|
||||
assert_eq!(Anchor::TOP.min, Vec2::ZERO);
|
||||
assert_eq!(Anchor::TOP.max, Vec2::new(1.0, 0.0));
|
||||
assert_eq!(Anchor::BOTTOM.min, Vec2::new(0.0, 1.0));
|
||||
assert_eq!(Anchor::LEFT.max, Vec2::new(0.0, 1.0));
|
||||
assert_eq!(Anchor::RIGHT.min, Vec2::new(1.0, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_style_round_trips_through_ron() {
|
||||
let s = LayoutStyle {
|
||||
width: Sizing::Grow(2.0),
|
||||
height: Sizing::Fixed(48.0),
|
||||
padding: Insets::all(8.0),
|
||||
margin: Insets::symmetric(4.0, 2.0),
|
||||
align_horizontal: Align::Center,
|
||||
align_vertical: Align::End,
|
||||
anchor: Anchor::TOP_RIGHT.with_offsets(Vec2::new(-100.0, 0.0), Vec2::ZERO),
|
||||
};
|
||||
let text = ron::ser::to_string_pretty(&s, ron::ser::PrettyConfig::default()).unwrap();
|
||||
let decoded: LayoutStyle = ron::de::from_str(&text).unwrap();
|
||||
assert_eq!(s, decoded);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
//! Glyph atlas — packs rasterized glyphs into one R8 alpha texture, caches
|
||||
//! them by (font, glyph, size), and exposes UV regions the renderer draws as
|
||||
//! textured quads.
|
||||
//!
|
||||
//! The atlas **is** the cache: every glyph is rasterized exactly once per
|
||||
//! `(FontId, GlyphId, size_px)` triple and reused for the rest of the
|
||||
//! process's lifetime. The performance discussion in the Stage-8 design
|
||||
//! notes assumes this — a HUD that repaints the same characters every frame
|
||||
//! never re-rasterizes after warm-up.
|
||||
//!
|
||||
//! # Packer choice
|
||||
//!
|
||||
//! Piece 3 uses a **shelf packer**: glyphs are arranged in horizontal rows
|
||||
//! ("shelves") whose height is the height of the first glyph that opened the
|
||||
//! shelf. Subsequent glyphs either fit horizontally on an existing shelf
|
||||
//! (height ≤ shelf height) or start a new shelf below. This is the standard
|
||||
//! choice for monotonically-growing glyph atlases — simple, deterministic,
|
||||
//! near-optimal density for typically-uniform glyph heights, and easy to
|
||||
//! grow (later: multi-page atlases) when full.
|
||||
//!
|
||||
//! Piece 3 does **not** evict. With a 1024×1024 R8 atlas the typical Western
|
||||
//! UI uses a single-digit-percent fraction; CJK or many-size scenarios that
|
||||
//! actually run out are handled by piece-4 follow-ups (multi-page atlases
|
||||
//! or LRU per page).
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use glam::Vec2;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::font::{FontId, FontStore, GlyphId};
|
||||
|
||||
/// Cache key for one rasterized glyph.
|
||||
///
|
||||
/// `size_px` is rounded to the nearest pixel before being used as the key —
|
||||
/// distinct 23.4-pixel and 23.6-pixel renderings would otherwise produce
|
||||
/// different atlas entries despite being visually indistinguishable.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct GlyphKey {
|
||||
pub font: FontId,
|
||||
pub glyph: GlyphId,
|
||||
pub size_px: u16,
|
||||
}
|
||||
|
||||
impl GlyphKey {
|
||||
/// Build a key, rounding `size_px` to the nearest pixel.
|
||||
pub fn new(font: FontId, glyph: GlyphId, size_px: f32) -> Self {
|
||||
Self {
|
||||
font,
|
||||
glyph,
|
||||
size_px: size_px.round().max(1.0) as u16,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One glyph's packed location inside the atlas plus the metrics the
|
||||
/// renderer needs to position its quad on a baseline.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct AtlasEntry {
|
||||
/// Top-left UV (normalized to `[0, 1]`).
|
||||
pub uv_min: Vec2,
|
||||
/// Bottom-right UV.
|
||||
pub uv_max: Vec2,
|
||||
/// Width / height of the packed region in **pixels**, so the renderer
|
||||
/// can size the quad without re-querying the atlas dimensions.
|
||||
pub size_px: Vec2,
|
||||
/// Offset from the glyph's pen position to the top-left of the quad,
|
||||
/// in pixels (`bearing.x` left/right, `bearing.y` from the **baseline**;
|
||||
/// negative `y` means the glyph extends above the baseline).
|
||||
pub bearing: Vec2,
|
||||
/// Horizontal advance for the next glyph at this size.
|
||||
pub advance_px: f32,
|
||||
}
|
||||
|
||||
/// CPU-side glyph atlas — owns the alpha buffer, the packer state, and the
|
||||
/// `(GlyphKey -> AtlasEntry)` cache.
|
||||
///
|
||||
/// A piece-4 GPU follow-up will upload [`pixels`](Self::pixels) into a
|
||||
/// single R8 texture and re-upload only the dirty region when new glyphs are
|
||||
/// packed. Piece 3 stays pixel-buffer-only so every test runs headlessly.
|
||||
#[derive(Debug)]
|
||||
pub struct GlyphAtlas {
|
||||
width: u32,
|
||||
height: u32,
|
||||
pixels: Vec<u8>,
|
||||
cache: HashMap<GlyphKey, AtlasEntry>,
|
||||
packer: ShelfPacker,
|
||||
dirty: bool,
|
||||
}
|
||||
|
||||
impl GlyphAtlas {
|
||||
/// Allocate a fresh `width × height` R8 atlas (one byte per pixel,
|
||||
/// initially zero).
|
||||
pub fn new(width: u32, height: u32) -> Self {
|
||||
Self {
|
||||
width,
|
||||
height,
|
||||
pixels: vec![0u8; (width as usize) * (height as usize)],
|
||||
cache: HashMap::new(),
|
||||
packer: ShelfPacker::new(width, height),
|
||||
dirty: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// `(width, height)` in pixels.
|
||||
pub fn size(&self) -> (u32, u32) {
|
||||
(self.width, self.height)
|
||||
}
|
||||
|
||||
/// Raw alpha buffer (`width * height` bytes, row-major). The piece-4
|
||||
/// render pass will upload this into an R8 texture; tests assert on it
|
||||
/// directly.
|
||||
pub fn pixels(&self) -> &[u8] {
|
||||
&self.pixels
|
||||
}
|
||||
|
||||
/// Look up an entry, rasterizing and packing if not yet present.
|
||||
///
|
||||
/// Returns `None` if the glyph has no outline (e.g., a space — the
|
||||
/// shaper still positions it via the font's advance) **or** the atlas
|
||||
/// has no room for the rasterized bitmap. A space-glyph miss is
|
||||
/// indistinguishable from a packing failure by signature; in practice
|
||||
/// the shaper handles both the same way (skip the quad, keep the
|
||||
/// advance).
|
||||
pub fn get_or_rasterize(&mut self, key: GlyphKey, fonts: &FontStore) -> Option<AtlasEntry> {
|
||||
if let Some(entry) = self.cache.get(&key) {
|
||||
return Some(*entry);
|
||||
}
|
||||
let font = fonts.get(key.font)?;
|
||||
let raster = font.rasterize(key.glyph, key.size_px as f32)?;
|
||||
let (x, y) = self.packer.pack(raster.width, raster.height)?;
|
||||
|
||||
// Blit the alpha mask into the atlas at (x, y).
|
||||
let aw = self.width as usize;
|
||||
for row in 0..raster.height as usize {
|
||||
let src_start = row * raster.width as usize;
|
||||
let dst_start = (y as usize + row) * aw + x as usize;
|
||||
self.pixels[dst_start..dst_start + raster.width as usize]
|
||||
.copy_from_slice(&raster.bitmap[src_start..src_start + raster.width as usize]);
|
||||
}
|
||||
self.dirty = true;
|
||||
|
||||
let w = self.width as f32;
|
||||
let h = self.height as f32;
|
||||
let entry = AtlasEntry {
|
||||
uv_min: Vec2::new(x as f32 / w, y as f32 / h),
|
||||
uv_max: Vec2::new(
|
||||
(x + raster.width) as f32 / w,
|
||||
(y + raster.height) as f32 / h,
|
||||
),
|
||||
size_px: Vec2::new(raster.width as f32, raster.height as f32),
|
||||
bearing: Vec2::new(raster.bearing_x, raster.bearing_y),
|
||||
advance_px: raster.advance_x,
|
||||
};
|
||||
self.cache.insert(key, entry);
|
||||
Some(entry)
|
||||
}
|
||||
|
||||
/// Borrow an entry that's already cached, without triggering
|
||||
/// rasterization. Useful when the renderer wants to draw only glyphs the
|
||||
/// atlas already knows.
|
||||
pub fn get(&self, key: &GlyphKey) -> Option<&AtlasEntry> {
|
||||
self.cache.get(key)
|
||||
}
|
||||
|
||||
/// Number of cached glyphs.
|
||||
pub fn len(&self) -> usize {
|
||||
self.cache.len()
|
||||
}
|
||||
|
||||
/// `true` if no glyphs are cached.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.cache.is_empty()
|
||||
}
|
||||
|
||||
/// `true` if [`get_or_rasterize`](Self::get_or_rasterize) added at least
|
||||
/// one glyph since the last [`clear_dirty`](Self::clear_dirty). The
|
||||
/// piece-4 render pass checks this before re-uploading the texture.
|
||||
pub fn dirty(&self) -> bool {
|
||||
self.dirty
|
||||
}
|
||||
|
||||
/// Clear the dirty flag. Call after uploading the texture.
|
||||
pub fn clear_dirty(&mut self) {
|
||||
self.dirty = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- shelf packer ----------
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ShelfPacker {
|
||||
width: u32,
|
||||
height: u32,
|
||||
shelves: Vec<Shelf>,
|
||||
next_y: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Shelf {
|
||||
y: u32,
|
||||
height: u32,
|
||||
cursor_x: u32,
|
||||
}
|
||||
|
||||
impl ShelfPacker {
|
||||
fn new(width: u32, height: u32) -> Self {
|
||||
Self {
|
||||
width,
|
||||
height,
|
||||
shelves: Vec::new(),
|
||||
next_y: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn pack(&mut self, w: u32, h: u32) -> Option<(u32, u32)> {
|
||||
if w > self.width || h > self.height {
|
||||
return None;
|
||||
}
|
||||
// Prefer the tightest-fitting existing shelf that still has
|
||||
// horizontal room — keeps shelf heights stable and packs short
|
||||
// glyphs against short glyphs.
|
||||
let mut best: Option<usize> = None;
|
||||
let mut best_waste = u32::MAX;
|
||||
for (i, shelf) in self.shelves.iter().enumerate() {
|
||||
if shelf.cursor_x + w <= self.width && h <= shelf.height {
|
||||
let waste = shelf.height - h;
|
||||
if waste < best_waste {
|
||||
best = Some(i);
|
||||
best_waste = waste;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(i) = best {
|
||||
let shelf = &mut self.shelves[i];
|
||||
let x = shelf.cursor_x;
|
||||
let y = shelf.y;
|
||||
shelf.cursor_x += w;
|
||||
return Some((x, y));
|
||||
}
|
||||
// No existing shelf fits — open a new one at `next_y` if there's
|
||||
// vertical room.
|
||||
if self.next_y + h > self.height {
|
||||
return None;
|
||||
}
|
||||
let y = self.next_y;
|
||||
self.next_y += h;
|
||||
self.shelves.push(Shelf {
|
||||
y,
|
||||
height: h,
|
||||
cursor_x: w,
|
||||
});
|
||||
Some((0, y))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::font::try_load_system_font;
|
||||
use super::*;
|
||||
use crate::ui::visual::FontRef;
|
||||
|
||||
#[test]
|
||||
fn key_rounds_size_to_nearest_pixel() {
|
||||
let k1 = GlyphKey::new(FontId(0), GlyphId(1), 23.4);
|
||||
let k2 = GlyphKey::new(FontId(0), GlyphId(1), 23.6);
|
||||
assert_eq!(k1.size_px, 23);
|
||||
assert_eq!(k2.size_px, 24);
|
||||
assert_ne!(k1, k2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_clamps_sub_pixel_size_to_one() {
|
||||
// A 0.4-pixel font would otherwise round to zero, producing a useless
|
||||
// key. The packer requires width ≥ 1.
|
||||
let k = GlyphKey::new(FontId(0), GlyphId(1), 0.4);
|
||||
assert_eq!(k.size_px, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shelf_packer_fits_glyphs_in_order() {
|
||||
let mut p = ShelfPacker::new(64, 64);
|
||||
// First glyph opens a shelf at y=0 with height 10.
|
||||
assert_eq!(p.pack(20, 10), Some((0, 0)));
|
||||
// Second glyph fits on the same shelf — same y, advanced cursor.
|
||||
assert_eq!(p.pack(20, 10), Some((20, 0)));
|
||||
// Third glyph: doesn't fit horizontally on shelf 0; opens shelf 1
|
||||
// at y=10.
|
||||
assert_eq!(p.pack(40, 8), Some((0, 10)));
|
||||
// Tall glyph that fits horizontally on neither existing shelf opens
|
||||
// shelf 2 at y=18.
|
||||
assert_eq!(p.pack(64, 20), Some((0, 18)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shelf_packer_prefers_tight_fit_among_existing_shelves() {
|
||||
let mut p = ShelfPacker::new(64, 64);
|
||||
// Open shelf 0 at y=0 with height 20, occupying width 50.
|
||||
assert_eq!(p.pack(50, 20), Some((0, 0)));
|
||||
// A 50-wide 8-tall glyph won't fit horizontally on shelf 0
|
||||
// (50 + 50 = 100 > 64) — that forces shelf 1 open at y=20 with
|
||||
// height 8.
|
||||
assert_eq!(p.pack(50, 8), Some((0, 20)));
|
||||
// Now pack a 10×8 glyph: shelf 0 (waste 12) and shelf 1 (waste 0)
|
||||
// both fit horizontally, so the tight-fit shelf 1 wins.
|
||||
assert_eq!(p.pack(10, 8), Some((50, 20)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shelf_packer_rejects_overflow() {
|
||||
let mut p = ShelfPacker::new(32, 32);
|
||||
// First fills almost all the vertical room.
|
||||
assert_eq!(p.pack(32, 30), Some((0, 0)));
|
||||
// 4-tall glyph won't fit vertically.
|
||||
assert_eq!(p.pack(8, 4), None);
|
||||
// Anything wider than the atlas is also rejected.
|
||||
let mut p2 = ShelfPacker::new(32, 32);
|
||||
assert_eq!(p2.pack(40, 4), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_atlas_has_no_dirty_no_entries() {
|
||||
let atlas = GlyphAtlas::new(64, 64);
|
||||
assert_eq!(atlas.size(), (64, 64));
|
||||
assert!(!atlas.dirty());
|
||||
assert!(atlas.is_empty());
|
||||
assert!(atlas.pixels().iter().all(|&p| p == 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dirty_flag_lifecycle() {
|
||||
let Some(font) = try_load_system_font() else {
|
||||
return;
|
||||
};
|
||||
let mut store = FontStore::new();
|
||||
let id = store.insert(font);
|
||||
let mut atlas = GlyphAtlas::new(256, 256);
|
||||
assert!(!atlas.dirty());
|
||||
|
||||
let glyph = store.get(id).unwrap().glyph_id('A');
|
||||
atlas
|
||||
.get_or_rasterize(GlyphKey::new(id, glyph, 24.0), &store)
|
||||
.unwrap();
|
||||
assert!(atlas.dirty());
|
||||
atlas.clear_dirty();
|
||||
assert!(!atlas.dirty());
|
||||
|
||||
// Second lookup of the same key is a cache hit — no rasterization,
|
||||
// no new dirty.
|
||||
atlas
|
||||
.get_or_rasterize(GlyphKey::new(id, glyph, 24.0), &store)
|
||||
.unwrap();
|
||||
assert!(!atlas.dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distinct_glyphs_get_distinct_regions() {
|
||||
let Some(font) = try_load_system_font() else {
|
||||
return;
|
||||
};
|
||||
let mut store = FontStore::new();
|
||||
let id = store.insert_with_descriptor(FontRef::regular("System"), font);
|
||||
let mut atlas = GlyphAtlas::new(512, 512);
|
||||
|
||||
let a = store.get(id).unwrap().glyph_id('A');
|
||||
let b = store.get(id).unwrap().glyph_id('B');
|
||||
let e_a = atlas
|
||||
.get_or_rasterize(GlyphKey::new(id, a, 24.0), &store)
|
||||
.unwrap();
|
||||
let e_b = atlas
|
||||
.get_or_rasterize(GlyphKey::new(id, b, 24.0), &store)
|
||||
.unwrap();
|
||||
// Different glyphs → different UV rects.
|
||||
assert_ne!(e_a.uv_min, e_b.uv_min);
|
||||
// UV rects stay inside `[0, 1]`.
|
||||
assert!(e_a.uv_min.x >= 0.0 && e_a.uv_max.x <= 1.0);
|
||||
assert!(e_a.uv_min.y >= 0.0 && e_a.uv_max.y <= 1.0);
|
||||
|
||||
assert_eq!(atlas.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn space_glyph_returns_none_but_does_not_corrupt_atlas() {
|
||||
let Some(font) = try_load_system_font() else {
|
||||
return;
|
||||
};
|
||||
let mut store = FontStore::new();
|
||||
let id = store.insert(font);
|
||||
let space = store.get(id).unwrap().glyph_id(' ');
|
||||
let mut atlas = GlyphAtlas::new(128, 128);
|
||||
assert!(atlas
|
||||
.get_or_rasterize(GlyphKey::new(id, space, 24.0), &store)
|
||||
.is_none());
|
||||
assert!(atlas.is_empty());
|
||||
assert!(!atlas.dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn atlas_pixels_match_rasterized_bitmap_at_packed_region() {
|
||||
let Some(font) = try_load_system_font() else {
|
||||
return;
|
||||
};
|
||||
let mut store = FontStore::new();
|
||||
let id = store.insert(font);
|
||||
let mut atlas = GlyphAtlas::new(128, 128);
|
||||
let glyph = store.get(id).unwrap().glyph_id('A');
|
||||
let entry = atlas
|
||||
.get_or_rasterize(GlyphKey::new(id, glyph, 24.0), &store)
|
||||
.unwrap();
|
||||
// Convert the entry's UV back to a pixel rect and verify *some*
|
||||
// pixel inside it is opaque (i.e., the blit actually happened).
|
||||
let x = (entry.uv_min.x * 128.0).round() as usize;
|
||||
let y = (entry.uv_min.y * 128.0).round() as usize;
|
||||
let w = entry.size_px.x as usize;
|
||||
let h = entry.size_px.y as usize;
|
||||
let mut had_opaque = false;
|
||||
for row in 0..h {
|
||||
for col in 0..w {
|
||||
if atlas.pixels()[(y + row) * 128 + (x + col)] > 200 {
|
||||
had_opaque = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(had_opaque, "blitted region should contain opaque pixels");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
//! Font loading and per-glyph metrics — thin wrapper over [`ab_glyph::FontVec`].
|
||||
//!
|
||||
//! The text system stays a layer above the font crate so it can swap
|
||||
//! rasterizers later (an SDF generator, a different parser) without churning
|
||||
//! the public Stage-8 API. Every text query a [`super::shape::shape`] or
|
||||
//! [`super::atlas::GlyphAtlas`] call needs goes through [`Font`]'s methods —
|
||||
//! `ab_glyph` is never visible to consumers of the engine.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
use ab_glyph::{Font as AbFont, FontVec, PxScale, ScaleFont};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
use super::super::visual::FontRef;
|
||||
|
||||
/// Errors returned from font loading.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum FontError {
|
||||
/// Reading the font file from disk failed.
|
||||
#[error("font file read failed: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
/// The bytes were not a valid TTF / OTF font.
|
||||
#[error("not a valid TTF/OTF font")]
|
||||
InvalidFont,
|
||||
}
|
||||
|
||||
/// Stable, opaque identifier for a font registered in a [`FontStore`].
|
||||
///
|
||||
/// Held in [`GlyphKey`](super::atlas::GlyphKey)s in the atlas and in
|
||||
/// [`TextStyle`](super::shape::TextStyle)s passed to the shaper, so a font's
|
||||
/// id never changes once registered. `Copy` + `Hash` so it indexes hash maps
|
||||
/// cheaply.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct FontId(pub u32);
|
||||
|
||||
/// One loaded font — a parsed TTF/OTF that can report metrics and rasterize
|
||||
/// individual glyphs.
|
||||
pub struct Font {
|
||||
inner: FontVec,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Font {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Font").finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of rasterizing one glyph at a specific pixel size — the alpha mask
|
||||
/// plus enough metrics to position it on a baseline.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct RasterizedGlyph {
|
||||
/// Width of the alpha mask in pixels.
|
||||
pub width: u32,
|
||||
/// Height of the alpha mask in pixels.
|
||||
pub height: u32,
|
||||
/// X offset from the glyph's pen position to the mask's left edge.
|
||||
pub bearing_x: f32,
|
||||
/// Y offset from the glyph's baseline to the mask's top edge (negative
|
||||
/// for glyphs that extend above the baseline, which is most of them).
|
||||
pub bearing_y: f32,
|
||||
/// How far to advance the pen along the baseline before the next glyph.
|
||||
pub advance_x: f32,
|
||||
/// Row-major alpha bytes (`width * height` bytes, `0 = transparent`,
|
||||
/// `255 = opaque`).
|
||||
pub bitmap: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Font {
|
||||
/// Parse a TTF/OTF font from raw bytes. Bytes are owned by the [`Font`].
|
||||
pub fn from_bytes(bytes: Vec<u8>) -> Result<Self, FontError> {
|
||||
FontVec::try_from_vec(bytes)
|
||||
.map(|inner| Self { inner })
|
||||
.map_err(|_| FontError::InvalidFont)
|
||||
}
|
||||
|
||||
/// Load and parse a TTF/OTF file from disk.
|
||||
pub fn from_path(path: impl AsRef<Path>) -> Result<Self, FontError> {
|
||||
let bytes = std::fs::read(path.as_ref())?;
|
||||
Self::from_bytes(bytes)
|
||||
}
|
||||
|
||||
/// The glyph id for a `char`. Returns the font's `notdef` glyph (id `0`)
|
||||
/// for characters the font does not contain — same behavior as
|
||||
/// `ab_glyph`.
|
||||
pub fn glyph_id(&self, ch: char) -> GlyphId {
|
||||
GlyphId(self.inner.glyph_id(ch).0)
|
||||
}
|
||||
|
||||
/// Horizontal advance for the next glyph at `size_px` logical pixels.
|
||||
pub fn h_advance_px(&self, glyph: GlyphId, size_px: f32) -> f32 {
|
||||
self.inner
|
||||
.as_scaled(PxScale::from(size_px))
|
||||
.h_advance(ab_glyph::GlyphId(glyph.0))
|
||||
}
|
||||
|
||||
/// Ascender height in pixels at the given size.
|
||||
pub fn ascent_px(&self, size_px: f32) -> f32 {
|
||||
self.inner.as_scaled(PxScale::from(size_px)).ascent()
|
||||
}
|
||||
|
||||
/// Descender depth in pixels at the given size. Negative for fonts where
|
||||
/// the descender sits below the baseline (the common case).
|
||||
pub fn descent_px(&self, size_px: f32) -> f32 {
|
||||
self.inner.as_scaled(PxScale::from(size_px)).descent()
|
||||
}
|
||||
|
||||
/// Line gap in pixels — extra leading the font recommends between lines.
|
||||
pub fn line_gap_px(&self, size_px: f32) -> f32 {
|
||||
self.inner.as_scaled(PxScale::from(size_px)).line_gap()
|
||||
}
|
||||
|
||||
/// Total recommended line height at `size_px` (ascent − descent +
|
||||
/// line_gap). Multiplied by `TextStyle`'s line-height factor by the
|
||||
/// shaper.
|
||||
pub fn line_height_px(&self, size_px: f32) -> f32 {
|
||||
let scaled = self.inner.as_scaled(PxScale::from(size_px));
|
||||
scaled.ascent() - scaled.descent() + scaled.line_gap()
|
||||
}
|
||||
|
||||
/// Rasterize a single glyph to an alpha bitmap. Returns `None` for
|
||||
/// glyphs with no outline (e.g., the space character) — the caller still
|
||||
/// gets the advance via [`Font::h_advance_px`] and should treat the
|
||||
/// glyph as zero-area.
|
||||
pub fn rasterize(&self, glyph: GlyphId, size_px: f32) -> Option<RasterizedGlyph> {
|
||||
let scale = PxScale::from(size_px);
|
||||
let scaled = self.inner.as_scaled(scale);
|
||||
let advance_x = scaled.h_advance(ab_glyph::GlyphId(glyph.0));
|
||||
let mut positioned = ab_glyph::GlyphId(glyph.0).with_scale(scale);
|
||||
positioned.position = ab_glyph::point(0.0, 0.0);
|
||||
let outlined = self.inner.outline_glyph(positioned)?;
|
||||
let bounds = outlined.px_bounds();
|
||||
let width = bounds.width().ceil().max(1.0) as u32;
|
||||
let height = bounds.height().ceil().max(1.0) as u32;
|
||||
let mut bitmap = vec![0u8; (width as usize) * (height as usize)];
|
||||
outlined.draw(|x, y, coverage| {
|
||||
if x < width && y < height {
|
||||
let idx = (y as usize) * (width as usize) + (x as usize);
|
||||
bitmap[idx] = (coverage * 255.0).round().clamp(0.0, 255.0) as u8;
|
||||
}
|
||||
});
|
||||
Some(RasterizedGlyph {
|
||||
width,
|
||||
height,
|
||||
bearing_x: bounds.min.x,
|
||||
bearing_y: bounds.min.y,
|
||||
advance_x,
|
||||
bitmap,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// [`AssetLoader`](crate::asset::AssetLoader) for TTF/OTF fonts.
|
||||
///
|
||||
/// Registered by default on every [`AssetServer`](crate::asset::AssetServer), so
|
||||
/// a font file under a project's `assets/fonts/` can be loaded by path and an
|
||||
/// [`AssetRef<Font>`](crate::asset::AssetRef) resolved to a [`Handle<Font>`](crate::asset::Handle)
|
||||
/// — the link that lets the UI canvas pick a font asset and the runtime draw with it.
|
||||
pub struct FontLoader;
|
||||
|
||||
impl crate::asset::AssetLoader for FontLoader {
|
||||
type Asset = Font;
|
||||
|
||||
fn extensions(&self) -> &'static [&'static str] {
|
||||
&["ttf", "otf"]
|
||||
}
|
||||
|
||||
fn load(&self, path: &Path) -> Result<Font, crate::asset::AssetError> {
|
||||
Font::from_path(path).map_err(|err| crate::asset::AssetError::Load {
|
||||
path: path.to_path_buf(),
|
||||
message: err.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Opaque per-font glyph index. Mirrors `ab_glyph::GlyphId` but is the only
|
||||
/// glyph type exposed by the engine, so consumers do not need an `ab_glyph`
|
||||
/// dependency.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct GlyphId(pub u16);
|
||||
|
||||
/// Registry of loaded fonts, indexed by [`FontId`] and (optionally) by
|
||||
/// [`FontRef`] descriptor.
|
||||
///
|
||||
/// Why a descriptor index: piece-2 [`Theme`](super::super::theme::Theme)s
|
||||
/// store fonts by family + weight + italic (`FontRef`), not by raw bytes.
|
||||
/// `FontStore::resolve(&font_ref)` turns the descriptor into a [`FontId`] the
|
||||
/// shaper can use, so a theme like `{ font: Some(FontRef::bold("Inter")) }`
|
||||
/// works end-to-end as soon as the matching face has been registered.
|
||||
#[derive(Default)]
|
||||
pub struct FontStore {
|
||||
fonts: Vec<Font>,
|
||||
by_descriptor: HashMap<FontRef, FontId>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for FontStore {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("FontStore")
|
||||
.field("len", &self.fonts.len())
|
||||
.field("descriptors", &self.by_descriptor.len())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl FontStore {
|
||||
/// Create an empty store.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Register a font with no descriptor — accessible only by its returned
|
||||
/// [`FontId`]. Useful for one-off uses where the font isn't part of a
|
||||
/// theme cascade.
|
||||
pub fn insert(&mut self, font: Font) -> FontId {
|
||||
let id = FontId(self.fonts.len() as u32);
|
||||
self.fonts.push(font);
|
||||
id
|
||||
}
|
||||
|
||||
/// Register a font and associate it with a descriptor.
|
||||
///
|
||||
/// Re-registering the same descriptor replaces the previous association
|
||||
/// but does not free the previous [`FontId`] — both ids continue to
|
||||
/// reference the now-distinct font. This matches Stage-7 `ActionMap`
|
||||
/// re-registration semantics: ids are stable, names can be remapped.
|
||||
pub fn insert_with_descriptor(&mut self, descriptor: FontRef, font: Font) -> FontId {
|
||||
let id = self.insert(font);
|
||||
self.by_descriptor.insert(descriptor, id);
|
||||
id
|
||||
}
|
||||
|
||||
/// Look up a font by `FontId`.
|
||||
pub fn get(&self, id: FontId) -> Option<&Font> {
|
||||
self.fonts.get(id.0 as usize)
|
||||
}
|
||||
|
||||
/// Resolve a [`FontRef`] descriptor (piece-2 theme value) to a
|
||||
/// [`FontId`], if the matching face has been registered.
|
||||
pub fn resolve(&self, descriptor: &FontRef) -> Option<FontId> {
|
||||
self.by_descriptor.get(descriptor).copied()
|
||||
}
|
||||
|
||||
/// Number of registered fonts.
|
||||
pub fn len(&self) -> usize {
|
||||
self.fonts.len()
|
||||
}
|
||||
|
||||
/// `true` if no fonts are registered.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.fonts.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// Common system paths a Linux-style host is likely to have a sans-serif
|
||||
/// TTF at. Used by tests (and the eventual editor "no theme font set" path)
|
||||
/// to find *some* font without bundling one.
|
||||
///
|
||||
/// Returned in priority order; the first existing path is the one to try.
|
||||
/// Empty on hosts the search doesn't know about — the caller must handle
|
||||
/// "no candidate found" gracefully.
|
||||
pub fn common_system_font_paths() -> &'static [&'static str] {
|
||||
&[
|
||||
// Linux distributions:
|
||||
"/usr/share/fonts/liberation/LiberationSans-Regular.ttf",
|
||||
"/usr/share/fonts/TTF/DejaVuSans.ttf",
|
||||
"/usr/share/fonts/dejavu/DejaVuSans.ttf",
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||
"/usr/share/fonts/Adwaita/AdwaitaSans-Regular.ttf",
|
||||
// macOS:
|
||||
"/Library/Fonts/Arial.ttf",
|
||||
"/System/Library/Fonts/Helvetica.ttc",
|
||||
]
|
||||
}
|
||||
|
||||
/// Try to load a sans-serif font from a well-known system path. Returns
|
||||
/// `None` (and prints `SKIP:`) if no candidate exists — the same pattern
|
||||
/// the Stage-4 GPU tests use for "no adapter".
|
||||
///
|
||||
/// Test-only helper shared between the `font`, `atlas`, and `shape` modules
|
||||
/// so the same "skip when no system font" branch isn't duplicated.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn try_load_system_font() -> Option<Font> {
|
||||
for path in common_system_font_paths() {
|
||||
if Path::new(path).exists() {
|
||||
match Font::from_path(path) {
|
||||
Ok(font) => return Some(font),
|
||||
Err(err) => {
|
||||
eprintln!("SKIP-candidate: {path} present but failed to load: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
eprintln!("SKIP: no system font available at any common Linux/macOS path");
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rejects_garbage_bytes() {
|
||||
let err = Font::from_bytes(vec![0u8; 32]).unwrap_err();
|
||||
assert!(matches!(err, FontError::InvalidFont));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_file_returns_io_error() {
|
||||
let err = Font::from_path("/nonexistent/font.ttf").unwrap_err();
|
||||
assert!(matches!(err, FontError::Io(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_loader_loads_through_the_asset_server() {
|
||||
use crate::asset::{AssetRef, AssetServer, AssetUid};
|
||||
|
||||
// Find a real font file on disk; skip cleanly if the host has none.
|
||||
let Some(path) = common_system_font_paths()
|
||||
.iter()
|
||||
.map(std::path::Path::new)
|
||||
.find(|p| p.exists())
|
||||
else {
|
||||
eprintln!("SKIP: no system font path available");
|
||||
return;
|
||||
};
|
||||
|
||||
// The default-registered FontLoader makes `.ttf`/`.otf` loadable.
|
||||
let server = AssetServer::new();
|
||||
let handle = server.load::<Font>(path);
|
||||
assert!(handle.is_loaded(), "font should load: {:?}", handle.error());
|
||||
// An asset reference to a hypothetical uid resolves to a handle when the
|
||||
// database hands back this path (proven in asset::database tests); here
|
||||
// we just confirm the loaded Font is usable.
|
||||
assert!(handle.get().unwrap().h_advance_px(GlyphId(0), 16.0) >= 0.0);
|
||||
// AssetRef<Font> is constructible (the field type the UI canvas uses).
|
||||
let _ = AssetRef::<Font>::new(AssetUid(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_assigns_distinct_ids() {
|
||||
let Some(a) = try_load_system_font() else {
|
||||
return;
|
||||
};
|
||||
let Some(b) = try_load_system_font() else {
|
||||
return;
|
||||
};
|
||||
let mut store = FontStore::new();
|
||||
let id_a = store.insert(a);
|
||||
let id_b = store.insert(b);
|
||||
assert_ne!(id_a, id_b);
|
||||
assert_eq!(store.len(), 2);
|
||||
assert!(store.get(id_a).is_some());
|
||||
assert!(store.get(id_b).is_some());
|
||||
assert!(store.get(FontId(99)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn descriptor_resolves_to_registered_font() {
|
||||
let Some(font) = try_load_system_font() else {
|
||||
return;
|
||||
};
|
||||
let descriptor = FontRef::regular("System");
|
||||
let mut store = FontStore::new();
|
||||
let id = store.insert_with_descriptor(descriptor.clone(), font);
|
||||
assert_eq!(store.resolve(&descriptor), Some(id));
|
||||
// A different descriptor with no associated font is None.
|
||||
assert_eq!(store.resolve(&FontRef::bold("System")), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metrics_are_finite_and_non_zero() {
|
||||
let Some(font) = try_load_system_font() else {
|
||||
return;
|
||||
};
|
||||
let advance = font.h_advance_px(font.glyph_id('A'), 24.0);
|
||||
assert!(advance.is_finite());
|
||||
assert!(advance > 0.0);
|
||||
let ascent = font.ascent_px(24.0);
|
||||
let descent = font.descent_px(24.0);
|
||||
assert!(ascent > 0.0);
|
||||
// ab_glyph's `descent` is negative for descenders below the baseline.
|
||||
assert!(descent <= 0.0);
|
||||
assert!(font.line_height_px(24.0) > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rasterize_produces_bitmap_for_solid_glyph() {
|
||||
let Some(font) = try_load_system_font() else {
|
||||
return;
|
||||
};
|
||||
let raster = font
|
||||
.rasterize(font.glyph_id('A'), 24.0)
|
||||
.expect("'A' outlines");
|
||||
assert!(raster.width > 0 && raster.height > 0);
|
||||
assert_eq!(
|
||||
raster.bitmap.len(),
|
||||
(raster.width as usize) * (raster.height as usize)
|
||||
);
|
||||
// A capital A at 24px should have at least one fully-opaque pixel
|
||||
// near its central stroke.
|
||||
assert!(raster.bitmap.iter().any(|&p| p > 200));
|
||||
// And some transparent pixels (it's not a solid square).
|
||||
assert!(raster.bitmap.iter().any(|&p| p < 10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rasterize_space_returns_none_but_advance_works() {
|
||||
let Some(font) = try_load_system_font() else {
|
||||
return;
|
||||
};
|
||||
let space = font.glyph_id(' ');
|
||||
// Space has no outline — rasterize returns None.
|
||||
assert!(font.rasterize(space, 24.0).is_none());
|
||||
// But the advance is still positive so the shaper can lay it out.
|
||||
assert!(font.h_advance_px(space, 24.0) > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn common_system_font_paths_returns_some_candidates() {
|
||||
let paths = common_system_font_paths();
|
||||
assert!(!paths.is_empty());
|
||||
// Every entry should be an absolute path so the existence check is
|
||||
// unambiguous on the host.
|
||||
for p in paths {
|
||||
assert!(p.starts_with('/'), "{p:?} should be an absolute path");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
//! Text shaping + glyph atlas — piece 3 of the Stage-8 in-game UI system.
|
||||
//!
|
||||
//! Three sub-modules cooperate:
|
||||
//!
|
||||
//! - [`font`] wraps `ab_glyph::FontVec` behind an engine-owned [`Font`] /
|
||||
//! [`FontStore`] surface so consumers never see the font crate directly.
|
||||
//! Adds descriptor-based lookup keyed by the piece-2
|
||||
//! [`FontRef`](super::visual::FontRef), so a theme's `font: Some(...)`
|
||||
//! resolves to a [`FontId`] the shaper can use.
|
||||
//! - [`atlas`] packs rasterized glyphs into one R8 alpha texture via a
|
||||
//! shelf packer and caches them by [`GlyphKey`]. The atlas **is** the
|
||||
//! cache — the chosen library never re-rasterizes a glyph that's already
|
||||
//! been packed, which is why this stage's choice between ab_glyph and
|
||||
//! fontdue is a one-time-startup decision, not a per-frame one.
|
||||
//! - [`shape`] turns a sequence of [`TextRun`]s into positioned
|
||||
//! [`ShapedGlyph`]s with line wrapping, alignment, multi-font runs, and
|
||||
//! DPI scaling. Pure CPU; never touches the atlas. The renderer
|
||||
//! (piece 4) walks the [`ShapedText`] output and queries the atlas per
|
||||
//! glyph to emit textured quads.
|
||||
//!
|
||||
//! # End-to-end shape → atlas
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use oxide_engine::ui::text::{
|
||||
//! shape, FontStore, GlyphAtlas, ShapeParams, ShapedText, TextStyle,
|
||||
//! };
|
||||
//! # use oxide_engine::ui::text::Font;
|
||||
//! # fn load_font() -> Font { todo!() }
|
||||
//!
|
||||
//! let mut fonts = FontStore::new();
|
||||
//! let id = fonts.insert(load_font());
|
||||
//! let style = TextStyle { font: id, size_px: 16.0 };
|
||||
//! let shaped: ShapedText = shape("Hello world", style, &ShapeParams::default(), &fonts);
|
||||
//!
|
||||
//! let mut atlas = GlyphAtlas::new(1024, 1024);
|
||||
//! for line in &shaped.lines {
|
||||
//! for glyph in &line.glyphs {
|
||||
//! // get_or_rasterize returns None for glyphs with no outline (e.g.
|
||||
//! // the space character). Real renderers skip emitting a quad.
|
||||
//! if let Some(entry) = atlas.get_or_rasterize(glyph.key, &fonts) {
|
||||
//! let _quad_top_left = glyph.position + entry.bearing;
|
||||
//! let _quad_size = entry.size_px;
|
||||
//! }
|
||||
//! }
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
pub mod atlas;
|
||||
pub mod font;
|
||||
pub mod shape;
|
||||
|
||||
pub use atlas::{AtlasEntry, GlyphAtlas, GlyphKey};
|
||||
pub use font::{
|
||||
common_system_font_paths, Font, FontError, FontId, FontLoader, FontStore, GlyphId,
|
||||
RasterizedGlyph,
|
||||
};
|
||||
pub use shape::{
|
||||
shape, shape_runs, ShapeParams, ShapedGlyph, ShapedLine, ShapedText, TextAlign, TextRun,
|
||||
TextStyle,
|
||||
};
|
||||
@@ -0,0 +1,726 @@
|
||||
//! Text shaping — turns a sequence of [`TextRun`]s into positioned glyphs,
|
||||
//! laid out on baselines, wrapped to a width, and aligned.
|
||||
//!
|
||||
//! The shaper does **not** rasterize: it only consults [`Font`](super::font::Font)
|
||||
//! metrics (ascender, descender, advance width). Each output [`ShapedGlyph`]
|
||||
//! carries a [`GlyphKey`] the renderer (piece 4) feeds into the atlas to
|
||||
//! resolve to a textured quad. This split keeps the shaper purely
|
||||
//! deterministic and CPU-cheap — every test in this module runs without a
|
||||
//! GPU and most without a font.
|
||||
//!
|
||||
//! # Algorithm
|
||||
//!
|
||||
//! 1. **Tokenize** each run into items: a `Word` (maximal run of non-
|
||||
//! whitespace), a `Whitespace` stretch, or a `Break` (`\n`). Each
|
||||
//! word/whitespace item caches its own width, computed once from the
|
||||
//! font's per-glyph advance.
|
||||
//! 2. **Greedy line break**: keep adding items to the current line; on a
|
||||
//! word that would overflow `max_width`, flush the line and start a new
|
||||
//! one. Pending inter-word whitespace at the wrap point is **discarded**
|
||||
//! (it was the gap between lines, not part of either line); leading
|
||||
//! whitespace on a wrapped line is dropped for the same reason. `\n`
|
||||
//! forces a flush regardless of width.
|
||||
//! 3. **Position**: for each line, find the line's `max_ascent` (across the
|
||||
//! fonts used on it) — that's the baseline offset from the line's top
|
||||
//! edge — then walk items left-to-right, emitting `ShapedGlyph`s at
|
||||
//! `(pen_x, baseline_y)` and advancing `pen_x` by each glyph's advance.
|
||||
//! 4. **Align**: per line, shift glyphs by `align_offset(max_width −
|
||||
//! line_width)` — Left/Center/Right. Without a `max_width`, alignment
|
||||
//! is degenerate (everything is left-aligned).
|
||||
//!
|
||||
//! # Multi-font runs
|
||||
//!
|
||||
//! Lines may mix items from different runs (and therefore different fonts).
|
||||
//! Line metrics (ascent, descent, line height) are taken from the *largest*
|
||||
//! contribution among the line's items. This is the CSS behavior: a small
|
||||
//! superscript run on the same line as body text doesn't collapse the
|
||||
//! baseline.
|
||||
//!
|
||||
//! # Limitations (deliberate, scoped to piece 3)
|
||||
//!
|
||||
//! - One glyph per `char` (no ligatures, no combining marks, no shaping).
|
||||
//! ab_glyph does not shape; full Unicode shaping is a `rustybuzz` /
|
||||
//! `harfbuzz` follow-up.
|
||||
//! - No BiDi or RTL — text flows left-to-right.
|
||||
//! - No hyphenation or character-level fallback inside an overflowing word.
|
||||
//! - Whitespace is ASCII (` `, `\t`, `\r`). `\t` and `\r` are treated as
|
||||
//! regular spaces.
|
||||
|
||||
use glam::Vec2;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::atlas::GlyphKey;
|
||||
use super::font::{FontId, FontStore};
|
||||
|
||||
/// Per-run style — which font and what point size.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct TextStyle {
|
||||
pub font: FontId,
|
||||
/// Logical font size in pixels. Multiplied by [`ShapeParams::scale`] at
|
||||
/// shape time, so the same `TextStyle` produces correctly-sized output
|
||||
/// at 1×, 2×, or any other DPI factor.
|
||||
pub size_px: f32,
|
||||
}
|
||||
|
||||
/// One run of text with a single [`TextStyle`].
|
||||
///
|
||||
/// `shape` takes a single run; `shape_runs` takes many for mixed styles
|
||||
/// (different fonts/sizes/etc. on the same line).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct TextRun<'a> {
|
||||
pub text: &'a str,
|
||||
pub style: TextStyle,
|
||||
}
|
||||
|
||||
/// Horizontal alignment of each line within `max_width`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub enum TextAlign {
|
||||
#[default]
|
||||
Left,
|
||||
Center,
|
||||
Right,
|
||||
}
|
||||
|
||||
/// Parameters that apply to the whole shape call: wrapping width, alignment,
|
||||
/// line-height factor, and the DPI scale factor.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct ShapeParams {
|
||||
/// Maximum line width in **post-scale** pixels. `None` disables
|
||||
/// wrapping (and makes alignment a no-op).
|
||||
pub max_width: Option<f32>,
|
||||
/// Horizontal alignment within `max_width`.
|
||||
pub align: TextAlign,
|
||||
/// Multiplier applied to each line's natural line height. `1.0` is the
|
||||
/// font's own recommendation; `1.4` is a comfortable reading default.
|
||||
pub line_height: f32,
|
||||
/// DPI scale factor — multiplies every logical `size_px` from the
|
||||
/// runs. Same role as [`super::super::layout::layout`]'s `scale`.
|
||||
pub scale: f32,
|
||||
}
|
||||
|
||||
impl Default for ShapeParams {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_width: None,
|
||||
align: TextAlign::Left,
|
||||
line_height: 1.0,
|
||||
scale: 1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One positioned glyph in the shaped output.
|
||||
///
|
||||
/// `position` is the **pen position at the baseline** — the renderer adds
|
||||
/// the atlas's per-glyph bearing to convert it into the top-left of the
|
||||
/// glyph quad. Keeping it at the baseline (rather than at the top-left) is
|
||||
/// what makes hit testing and caret positioning straightforward in pieces
|
||||
/// 5–6.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct ShapedGlyph {
|
||||
pub key: GlyphKey,
|
||||
pub position: Vec2,
|
||||
}
|
||||
|
||||
/// One shaped line — the glyphs, the line's content width (trailing
|
||||
/// whitespace excluded), and the line's baseline / total height.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ShapedLine {
|
||||
pub glyphs: Vec<ShapedGlyph>,
|
||||
pub width: f32,
|
||||
pub baseline_y: f32,
|
||||
pub line_height: f32,
|
||||
}
|
||||
|
||||
/// Full shaped output — `lines` in vertical order and the overall bounding
|
||||
/// box `size`. `size.x` is the widest line's width (not `max_width`);
|
||||
/// `size.y` is the sum of line heights, which equals the height of the
|
||||
/// rectangle the text fits in.
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct ShapedText {
|
||||
pub lines: Vec<ShapedLine>,
|
||||
pub size: Vec2,
|
||||
}
|
||||
|
||||
/// Shape a single run of text. Convenience wrapper around [`shape_runs`].
|
||||
pub fn shape(text: &str, style: TextStyle, params: &ShapeParams, fonts: &FontStore) -> ShapedText {
|
||||
shape_runs(&[TextRun { text, style }], params, fonts)
|
||||
}
|
||||
|
||||
/// Shape one or more runs into a single output. Items from different runs
|
||||
/// share lines and share alignment, just as if they were one continuous
|
||||
/// string with mixed styles.
|
||||
pub fn shape_runs(runs: &[TextRun], params: &ShapeParams, fonts: &FontStore) -> ShapedText {
|
||||
let mut items: Vec<Item> = Vec::new();
|
||||
for run in runs {
|
||||
tokenize_run(run, params.scale, fonts, &mut items);
|
||||
}
|
||||
|
||||
let raw_lines = break_lines(items, params.max_width);
|
||||
position_lines(raw_lines, params, fonts)
|
||||
}
|
||||
|
||||
// ---------- internals ----------
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum Item {
|
||||
Word {
|
||||
font: FontId,
|
||||
size_px: f32,
|
||||
width: f32,
|
||||
// (char, glyph id, advance) — kept so the positioner doesn't have to
|
||||
// re-walk the source string.
|
||||
glyphs: Vec<GlyphAdvance>,
|
||||
},
|
||||
Whitespace {
|
||||
font: FontId,
|
||||
size_px: f32,
|
||||
width: f32,
|
||||
},
|
||||
Break,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct GlyphAdvance {
|
||||
glyph: super::font::GlyphId,
|
||||
advance: f32,
|
||||
}
|
||||
|
||||
impl Item {
|
||||
fn width(&self) -> f32 {
|
||||
match self {
|
||||
Item::Word { width, .. } | Item::Whitespace { width, .. } => *width,
|
||||
Item::Break => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn font_size(&self) -> Option<(FontId, f32)> {
|
||||
match self {
|
||||
Item::Word { font, size_px, .. } | Item::Whitespace { font, size_px, .. } => {
|
||||
Some((*font, *size_px))
|
||||
}
|
||||
Item::Break => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_whitespace(&self) -> bool {
|
||||
matches!(self, Item::Whitespace { .. })
|
||||
}
|
||||
}
|
||||
|
||||
fn is_break(c: char) -> bool {
|
||||
c == '\n'
|
||||
}
|
||||
|
||||
fn is_space_like(c: char) -> bool {
|
||||
matches!(c, ' ' | '\t' | '\r')
|
||||
}
|
||||
|
||||
fn tokenize_run(run: &TextRun, scale: f32, fonts: &FontStore, out: &mut Vec<Item>) {
|
||||
let style = run.style;
|
||||
let size_px = style.size_px * scale;
|
||||
let Some(font) = fonts.get(style.font) else {
|
||||
// Unknown font id — skip the run rather than panicking. Tests in
|
||||
// piece 4 will catch missing fonts before rendering; for piece 3
|
||||
// we want shape to remain a total function.
|
||||
return;
|
||||
};
|
||||
|
||||
let mut buf_word: Vec<GlyphAdvance> = Vec::new();
|
||||
let mut buf_word_width: f32 = 0.0;
|
||||
let mut buf_ws_width: f32 = 0.0;
|
||||
let mut state = TokState::Empty;
|
||||
|
||||
for c in run.text.chars() {
|
||||
if is_break(c) {
|
||||
flush_buffers(
|
||||
&mut state,
|
||||
&mut buf_word,
|
||||
&mut buf_word_width,
|
||||
&mut buf_ws_width,
|
||||
style.font,
|
||||
size_px,
|
||||
out,
|
||||
);
|
||||
out.push(Item::Break);
|
||||
continue;
|
||||
}
|
||||
if is_space_like(c) {
|
||||
if matches!(state, TokState::Word) {
|
||||
out.push(Item::Word {
|
||||
font: style.font,
|
||||
size_px,
|
||||
width: buf_word_width,
|
||||
glyphs: std::mem::take(&mut buf_word),
|
||||
});
|
||||
buf_word_width = 0.0;
|
||||
}
|
||||
state = TokState::Whitespace;
|
||||
let glyph = font.glyph_id(' ');
|
||||
buf_ws_width += font.h_advance_px(glyph, size_px);
|
||||
continue;
|
||||
}
|
||||
// Non-whitespace.
|
||||
if matches!(state, TokState::Whitespace) {
|
||||
out.push(Item::Whitespace {
|
||||
font: style.font,
|
||||
size_px,
|
||||
width: buf_ws_width,
|
||||
});
|
||||
buf_ws_width = 0.0;
|
||||
}
|
||||
state = TokState::Word;
|
||||
let glyph = font.glyph_id(c);
|
||||
let advance = font.h_advance_px(glyph, size_px);
|
||||
buf_word.push(GlyphAdvance { glyph, advance });
|
||||
buf_word_width += advance;
|
||||
}
|
||||
|
||||
flush_buffers(
|
||||
&mut state,
|
||||
&mut buf_word,
|
||||
&mut buf_word_width,
|
||||
&mut buf_ws_width,
|
||||
style.font,
|
||||
size_px,
|
||||
out,
|
||||
);
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
enum TokState {
|
||||
Empty,
|
||||
Word,
|
||||
Whitespace,
|
||||
}
|
||||
|
||||
fn flush_buffers(
|
||||
state: &mut TokState,
|
||||
word: &mut Vec<GlyphAdvance>,
|
||||
word_width: &mut f32,
|
||||
ws_width: &mut f32,
|
||||
font: FontId,
|
||||
size_px: f32,
|
||||
out: &mut Vec<Item>,
|
||||
) {
|
||||
match state {
|
||||
TokState::Word => {
|
||||
out.push(Item::Word {
|
||||
font,
|
||||
size_px,
|
||||
width: *word_width,
|
||||
glyphs: std::mem::take(word),
|
||||
});
|
||||
*word_width = 0.0;
|
||||
}
|
||||
TokState::Whitespace => {
|
||||
out.push(Item::Whitespace {
|
||||
font,
|
||||
size_px,
|
||||
width: *ws_width,
|
||||
});
|
||||
*ws_width = 0.0;
|
||||
}
|
||||
TokState::Empty => {}
|
||||
}
|
||||
*state = TokState::Empty;
|
||||
}
|
||||
|
||||
fn break_lines(items: Vec<Item>, max_width: Option<f32>) -> Vec<Vec<Item>> {
|
||||
let mut raw_lines: Vec<Vec<Item>> = Vec::new();
|
||||
let mut current: Vec<Item> = Vec::new();
|
||||
let mut current_width: f32 = 0.0;
|
||||
let mut pending_ws: Vec<Item> = Vec::new();
|
||||
let mut pending_ws_width: f32 = 0.0;
|
||||
|
||||
for item in items {
|
||||
match item {
|
||||
Item::Break => {
|
||||
raw_lines.push(std::mem::take(&mut current));
|
||||
current_width = 0.0;
|
||||
pending_ws.clear();
|
||||
pending_ws_width = 0.0;
|
||||
}
|
||||
Item::Whitespace { width, .. } => {
|
||||
pending_ws_width += width;
|
||||
pending_ws.push(item);
|
||||
}
|
||||
Item::Word { width, .. } => {
|
||||
let fits = match max_width {
|
||||
Some(max) => {
|
||||
current.is_empty() || current_width + pending_ws_width + width <= max
|
||||
}
|
||||
None => true,
|
||||
};
|
||||
if fits {
|
||||
current.append(&mut pending_ws);
|
||||
current_width += pending_ws_width;
|
||||
current_width += width;
|
||||
current.push(item);
|
||||
} else {
|
||||
raw_lines.push(std::mem::take(&mut current));
|
||||
// Leading whitespace on a wrapped line is dropped.
|
||||
pending_ws.clear();
|
||||
current_width = width;
|
||||
current.push(item);
|
||||
}
|
||||
pending_ws_width = 0.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !current.is_empty() {
|
||||
raw_lines.push(current);
|
||||
}
|
||||
raw_lines
|
||||
}
|
||||
|
||||
fn position_lines(
|
||||
raw_lines: Vec<Vec<Item>>,
|
||||
params: &ShapeParams,
|
||||
fonts: &FontStore,
|
||||
) -> ShapedText {
|
||||
let mut lines: Vec<ShapedLine> = Vec::new();
|
||||
let mut cursor_y: f32 = 0.0;
|
||||
let mut widest: f32 = 0.0;
|
||||
|
||||
for line_items in raw_lines {
|
||||
// Line metrics from the largest contributing item.
|
||||
let mut max_ascent: f32 = 0.0;
|
||||
let mut min_descent: f32 = 0.0;
|
||||
let mut max_line_height: f32 = 0.0;
|
||||
for item in &line_items {
|
||||
if let Some((font_id, size_px)) = item.font_size() {
|
||||
if let Some(font) = fonts.get(font_id) {
|
||||
max_ascent = max_ascent.max(font.ascent_px(size_px));
|
||||
min_descent = min_descent.min(font.descent_px(size_px));
|
||||
max_line_height = max_line_height.max(font.line_height_px(size_px));
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = min_descent; // descent reserved for vertical-extent queries later
|
||||
let line_height = max_line_height * params.line_height;
|
||||
|
||||
// Trailing whitespace is excluded from line width.
|
||||
let mut content_width: f32 = 0.0;
|
||||
let last_non_ws = line_items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.rev()
|
||||
.find(|(_, it)| !it.is_whitespace())
|
||||
.map(|(i, _)| i);
|
||||
if let Some(end) = last_non_ws {
|
||||
for it in &line_items[..=end] {
|
||||
content_width += it.width();
|
||||
}
|
||||
}
|
||||
|
||||
// Horizontal alignment offset.
|
||||
let align_pad = match params.max_width {
|
||||
Some(max) => {
|
||||
let extra = (max - content_width).max(0.0);
|
||||
match params.align {
|
||||
TextAlign::Left => 0.0,
|
||||
TextAlign::Center => extra * 0.5,
|
||||
TextAlign::Right => extra,
|
||||
}
|
||||
}
|
||||
None => 0.0,
|
||||
};
|
||||
|
||||
let baseline_y = cursor_y + max_ascent;
|
||||
let mut pen_x = align_pad;
|
||||
let mut glyphs: Vec<ShapedGlyph> = Vec::new();
|
||||
for item in &line_items {
|
||||
match item {
|
||||
Item::Word {
|
||||
font,
|
||||
size_px,
|
||||
glyphs: g,
|
||||
..
|
||||
} => {
|
||||
for ga in g {
|
||||
glyphs.push(ShapedGlyph {
|
||||
key: GlyphKey::new(*font, ga.glyph, *size_px),
|
||||
position: Vec2::new(pen_x, baseline_y),
|
||||
});
|
||||
pen_x += ga.advance;
|
||||
}
|
||||
}
|
||||
Item::Whitespace { width, .. } => {
|
||||
pen_x += *width;
|
||||
}
|
||||
Item::Break => {}
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(ShapedLine {
|
||||
glyphs,
|
||||
width: content_width,
|
||||
baseline_y,
|
||||
line_height,
|
||||
});
|
||||
cursor_y += line_height;
|
||||
widest = widest.max(content_width);
|
||||
}
|
||||
|
||||
ShapedText {
|
||||
lines,
|
||||
size: Vec2::new(widest, cursor_y),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::font::try_load_system_font;
|
||||
use super::*;
|
||||
|
||||
fn make_store_and_style(size_px: f32) -> Option<(FontStore, TextStyle)> {
|
||||
let font = try_load_system_font()?;
|
||||
let mut store = FontStore::new();
|
||||
let id = store.insert(font);
|
||||
Some((store, TextStyle { font: id, size_px }))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_text_produces_no_lines() {
|
||||
let Some((store, style)) = make_store_and_style(16.0) else {
|
||||
return;
|
||||
};
|
||||
let out = shape("", style, &ShapeParams::default(), &store);
|
||||
assert!(out.lines.is_empty());
|
||||
assert_eq!(out.size, Vec2::ZERO);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_word_emits_one_line_with_correct_glyph_count() {
|
||||
let Some((store, style)) = make_store_and_style(16.0) else {
|
||||
return;
|
||||
};
|
||||
let out = shape("Hello", style, &ShapeParams::default(), &store);
|
||||
assert_eq!(out.lines.len(), 1);
|
||||
assert_eq!(out.lines[0].glyphs.len(), 5);
|
||||
// Glyphs are at the same baseline.
|
||||
let baseline = out.lines[0].baseline_y;
|
||||
for g in &out.lines[0].glyphs {
|
||||
assert_eq!(g.position.y, baseline);
|
||||
}
|
||||
// x positions are monotonically increasing.
|
||||
for w in out.lines[0].glyphs.windows(2) {
|
||||
assert!(w[1].position.x > w[0].position.x);
|
||||
}
|
||||
// Line width matches the last glyph's pen-end (advance sum).
|
||||
assert!(out.lines[0].width > 0.0);
|
||||
assert!(out.size.x >= out.lines[0].width);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_newline_starts_new_line() {
|
||||
let Some((store, style)) = make_store_and_style(16.0) else {
|
||||
return;
|
||||
};
|
||||
let out = shape("a\nb", style, &ShapeParams::default(), &store);
|
||||
assert_eq!(out.lines.len(), 2);
|
||||
assert_eq!(out.lines[0].glyphs.len(), 1);
|
||||
assert_eq!(out.lines[1].glyphs.len(), 1);
|
||||
// Second baseline is below the first by one line height.
|
||||
assert!(out.lines[1].baseline_y > out.lines[0].baseline_y);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_wrap_splits_into_multiple_lines() {
|
||||
let Some((store, style)) = make_store_and_style(16.0) else {
|
||||
return;
|
||||
};
|
||||
// A line wide enough for "Hello" but not "Hello world".
|
||||
let one_word_width = shape("Hello", style, &ShapeParams::default(), &store).lines[0].width;
|
||||
let params = ShapeParams {
|
||||
max_width: Some(one_word_width + 2.0),
|
||||
..ShapeParams::default()
|
||||
};
|
||||
let out = shape("Hello world", style, ¶ms, &store);
|
||||
assert_eq!(out.lines.len(), 2);
|
||||
// First line is just "Hello" (5 glyphs).
|
||||
assert_eq!(out.lines[0].glyphs.len(), 5);
|
||||
// Second line is "world" (5 glyphs); leading whitespace dropped.
|
||||
assert_eq!(out.lines[1].glyphs.len(), 5);
|
||||
// Second line starts at x = 0 (Left align by default; no leading
|
||||
// whitespace consumed pen space).
|
||||
assert_eq!(out.lines[1].glyphs[0].position.x, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trailing_whitespace_excluded_from_line_width() {
|
||||
let Some((store, style)) = make_store_and_style(16.0) else {
|
||||
return;
|
||||
};
|
||||
let bare = shape("Hi", style, &ShapeParams::default(), &store);
|
||||
let trailing = shape("Hi ", style, &ShapeParams::default(), &store);
|
||||
assert_eq!(bare.lines[0].width, trailing.lines[0].width);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alignment_shifts_glyph_positions_within_max_width() {
|
||||
let Some((store, style)) = make_store_and_style(16.0) else {
|
||||
return;
|
||||
};
|
||||
let left = shape(
|
||||
"Hi",
|
||||
style,
|
||||
&ShapeParams {
|
||||
max_width: Some(200.0),
|
||||
align: TextAlign::Left,
|
||||
..ShapeParams::default()
|
||||
},
|
||||
&store,
|
||||
);
|
||||
let center = shape(
|
||||
"Hi",
|
||||
style,
|
||||
&ShapeParams {
|
||||
max_width: Some(200.0),
|
||||
align: TextAlign::Center,
|
||||
..ShapeParams::default()
|
||||
},
|
||||
&store,
|
||||
);
|
||||
let right = shape(
|
||||
"Hi",
|
||||
style,
|
||||
&ShapeParams {
|
||||
max_width: Some(200.0),
|
||||
align: TextAlign::Right,
|
||||
..ShapeParams::default()
|
||||
},
|
||||
&store,
|
||||
);
|
||||
let l = left.lines[0].glyphs[0].position.x;
|
||||
let c = center.lines[0].glyphs[0].position.x;
|
||||
let r = right.lines[0].glyphs[0].position.x;
|
||||
assert_eq!(l, 0.0);
|
||||
assert!(c > l && c < r);
|
||||
// Centered + right cases place the line within `max_width = 200`.
|
||||
let width = left.lines[0].width;
|
||||
assert!((c - (200.0 - width) * 0.5).abs() < 0.001);
|
||||
assert!((r - (200.0 - width)).abs() < 0.001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dpi_scale_doubles_advance_widths_and_baseline_drop() {
|
||||
let Some((store, style)) = make_store_and_style(16.0) else {
|
||||
return;
|
||||
};
|
||||
let at_1x = shape("Hello", style, &ShapeParams::default(), &store);
|
||||
let at_2x = shape(
|
||||
"Hello",
|
||||
style,
|
||||
&ShapeParams {
|
||||
scale: 2.0,
|
||||
..ShapeParams::default()
|
||||
},
|
||||
&store,
|
||||
);
|
||||
// Line width at 2× is ~2× at 1×.
|
||||
let ratio = at_2x.lines[0].width / at_1x.lines[0].width;
|
||||
assert!((ratio - 2.0).abs() < 0.05, "ratio = {ratio}");
|
||||
// First glyph's baseline drops at 2× by ~2× the 1× drop.
|
||||
let baseline_ratio = at_2x.lines[0].baseline_y / at_1x.lines[0].baseline_y;
|
||||
assert!((baseline_ratio - 2.0).abs() < 0.1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_height_multiplier_increases_vertical_spacing() {
|
||||
let Some((store, style)) = make_store_and_style(16.0) else {
|
||||
return;
|
||||
};
|
||||
let single = shape("a\nb", style, &ShapeParams::default(), &store);
|
||||
let spaced = shape(
|
||||
"a\nb",
|
||||
style,
|
||||
&ShapeParams {
|
||||
line_height: 2.0,
|
||||
..ShapeParams::default()
|
||||
},
|
||||
&store,
|
||||
);
|
||||
let gap_1 = single.lines[1].baseline_y - single.lines[0].baseline_y;
|
||||
let gap_2 = spaced.lines[1].baseline_y - spaced.lines[0].baseline_y;
|
||||
// Doubling the line-height factor roughly doubles inter-baseline
|
||||
// distance — exact ratio depends on the font's gap fraction.
|
||||
assert!(
|
||||
(gap_2 / gap_1 - 2.0).abs() < 0.05,
|
||||
"gap_2/gap_1 = {}",
|
||||
gap_2 / gap_1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glyph_keys_are_stable_across_calls() {
|
||||
let Some((store, style)) = make_store_and_style(16.0) else {
|
||||
return;
|
||||
};
|
||||
let a = shape("X", style, &ShapeParams::default(), &store);
|
||||
let b = shape("X", style, &ShapeParams::default(), &store);
|
||||
assert_eq!(a.lines[0].glyphs[0].key, b.lines[0].glyphs[0].key);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_font_run_takes_max_ascent_from_largest_size() {
|
||||
let Some(font) = try_load_system_font() else {
|
||||
return;
|
||||
};
|
||||
let mut store = FontStore::new();
|
||||
let id = store.insert(font);
|
||||
let small = TextStyle {
|
||||
font: id,
|
||||
size_px: 12.0,
|
||||
};
|
||||
let big = TextStyle {
|
||||
font: id,
|
||||
size_px: 32.0,
|
||||
};
|
||||
let mixed = shape_runs(
|
||||
&[
|
||||
TextRun {
|
||||
text: "Hi ",
|
||||
style: small,
|
||||
},
|
||||
TextRun {
|
||||
text: "X",
|
||||
style: big,
|
||||
},
|
||||
],
|
||||
&ShapeParams::default(),
|
||||
&store,
|
||||
);
|
||||
let small_only = shape("Hi", small, &ShapeParams::default(), &store);
|
||||
// The big-size baseline must be at least as deep as the small-size
|
||||
// baseline because the line's ascent is the max of contributions.
|
||||
assert!(mixed.lines[0].baseline_y >= small_only.lines[0].baseline_y);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_font_id_does_not_panic() {
|
||||
// No font registered → shape returns no lines instead of panicking.
|
||||
let store = FontStore::new();
|
||||
let style = TextStyle {
|
||||
font: FontId(99),
|
||||
size_px: 16.0,
|
||||
};
|
||||
let out = shape("Hello", style, &ShapeParams::default(), &store);
|
||||
assert!(out.lines.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_wrap_when_max_width_is_none() {
|
||||
let Some((store, style)) = make_store_and_style(16.0) else {
|
||||
return;
|
||||
};
|
||||
let out = shape(
|
||||
"one two three four five",
|
||||
style,
|
||||
&ShapeParams::default(),
|
||||
&store,
|
||||
);
|
||||
assert_eq!(out.lines.len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
//! Theme — reusable named [`VisualStyle`]s plus a default fallback.
|
||||
//!
|
||||
//! A [`Theme`] is what a project ships to give every UI document a consistent
|
||||
//! look without hand-styling every widget. The resolution rule is a strict
|
||||
//! left-to-right cascade:
|
||||
//!
|
||||
//! 1. Start with `theme.default` (a `VisualStyle` whose `Some` fields are the
|
||||
//! project-wide defaults — body text color, border weight, …).
|
||||
//! 2. If the widget specifies `theme_style: Some("button")` and the theme
|
||||
//! contains a `"button"` entry, merge that on top.
|
||||
//! 3. Merge the widget's per-instance `visual` on top.
|
||||
//!
|
||||
//! Each merge is field-by-field: a `Some` on the right replaces the field;
|
||||
//! a `None` keeps what was there. The result is a single [`VisualStyle`]
|
||||
//! where any field that's still `None` means "the renderer's own hard-coded
|
||||
//! fallback applies" — that fallback lives in piece 4 (the 2D overlay pass).
|
||||
//!
|
||||
//! Why named styles instead of CSS-like selectors: it makes per-widget
|
||||
//! attribution explicit in the UI document (`theme_style: "button-primary"`)
|
||||
//! and keeps theme resolution constant-time per widget. CSS selectors and
|
||||
//! cascading rules are a richer model but their authoring cost dwarfs what
|
||||
//! Stage-8 game UIs actually need.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::visual::VisualStyle;
|
||||
|
||||
/// A named-style theme. Holds a `default` style applied to every widget plus
|
||||
/// a map of named styles widgets can opt into by their `theme_style` field.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Theme {
|
||||
/// Project-wide defaults — applied first to every widget before its
|
||||
/// `theme_style` and per-instance overrides.
|
||||
#[serde(default, skip_serializing_if = "VisualStyle::is_empty")]
|
||||
pub default: VisualStyle,
|
||||
/// Named style buckets — `theme_style: "button"` on a widget pulls the
|
||||
/// `"button"` entry here on top of `default`.
|
||||
///
|
||||
/// Stored as a `BTreeMap` (not `HashMap`) so RON output is in a
|
||||
/// deterministic order — important for diff-friendly UI documents and
|
||||
/// reproducible RON snapshots in tests.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub styles: BTreeMap<String, VisualStyle>,
|
||||
}
|
||||
|
||||
impl Theme {
|
||||
/// Empty theme — no default fields, no named styles. Every widget under
|
||||
/// this theme inherits only the renderer's hard-coded fallback.
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
default: VisualStyle::EMPTY,
|
||||
styles: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert (or replace) a named style. Chainable for builder-style theme
|
||||
/// construction in tests and examples.
|
||||
pub fn with_style(mut self, name: impl Into<String>, style: VisualStyle) -> Self {
|
||||
self.styles.insert(name.into(), style);
|
||||
self
|
||||
}
|
||||
|
||||
/// Replace the project-wide default style.
|
||||
pub fn with_default(mut self, default: VisualStyle) -> Self {
|
||||
self.default = default;
|
||||
self
|
||||
}
|
||||
|
||||
/// Resolve the effective visual style for a widget that opts into
|
||||
/// `style_ref` (if any) and provides its own `override_with` per-instance
|
||||
/// fields.
|
||||
///
|
||||
/// Cascade: `self.default` → (`self.styles[style_ref]` if present) →
|
||||
/// `override_with`. A missing named style is treated as empty (no
|
||||
/// contribution) rather than an error — UI documents stay valid when a
|
||||
/// theme is swapped for a smaller one mid-development.
|
||||
pub fn resolve(&self, style_ref: Option<&str>, override_with: &VisualStyle) -> VisualStyle {
|
||||
let mut resolved = self.default.clone();
|
||||
if let Some(name) = style_ref {
|
||||
if let Some(named) = self.styles.get(name) {
|
||||
resolved = resolved.merged(named);
|
||||
}
|
||||
}
|
||||
resolved.merged(override_with)
|
||||
}
|
||||
|
||||
/// Serialize this theme to a pretty-printed RON string.
|
||||
pub fn to_ron(&self) -> Result<String, ron::Error> {
|
||||
ron::ser::to_string_pretty(self, ron::ser::PrettyConfig::default())
|
||||
}
|
||||
|
||||
/// Parse a theme from a RON string.
|
||||
pub fn from_ron(text: &str) -> Result<Self, ron::de::SpannedError> {
|
||||
ron::de::from_str(text)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::math::Color;
|
||||
use crate::ui::visual::{Border, FontRef};
|
||||
|
||||
fn theme_with_three_styles() -> Theme {
|
||||
Theme::new()
|
||||
.with_default(VisualStyle {
|
||||
foreground: Some(Color::BLACK),
|
||||
background: Some(Color::WHITE),
|
||||
font: Some(FontRef::regular("Inter")),
|
||||
font_size: Some(14.0),
|
||||
..VisualStyle::EMPTY
|
||||
})
|
||||
.with_style(
|
||||
"button",
|
||||
VisualStyle {
|
||||
background: Some(Color::rgb(0.85, 0.85, 0.9)),
|
||||
border: Some(Border::new(Color::rgb(0.6, 0.6, 0.7), 1.0)),
|
||||
corner_radius: Some(4.0),
|
||||
..VisualStyle::EMPTY
|
||||
},
|
||||
)
|
||||
.with_style(
|
||||
"button-primary",
|
||||
VisualStyle {
|
||||
background: Some(Color::rgb(0.2, 0.4, 0.8)),
|
||||
foreground: Some(Color::WHITE),
|
||||
..VisualStyle::EMPTY
|
||||
},
|
||||
)
|
||||
.with_style(
|
||||
"label",
|
||||
VisualStyle {
|
||||
foreground: Some(Color::rgb(0.2, 0.2, 0.2)),
|
||||
..VisualStyle::EMPTY
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_returns_default_for_no_style_or_overrides() {
|
||||
let theme = theme_with_three_styles();
|
||||
let resolved = theme.resolve(None, &VisualStyle::EMPTY);
|
||||
assert_eq!(resolved.foreground, Some(Color::BLACK));
|
||||
assert_eq!(resolved.background, Some(Color::WHITE));
|
||||
assert_eq!(resolved.font_size, Some(14.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn named_style_overrides_default() {
|
||||
let theme = theme_with_three_styles();
|
||||
let resolved = theme.resolve(Some("button"), &VisualStyle::EMPTY);
|
||||
assert_eq!(resolved.background, Some(Color::rgb(0.85, 0.85, 0.9)));
|
||||
// Foreground not set on "button" → kept from default.
|
||||
assert_eq!(resolved.foreground, Some(Color::BLACK));
|
||||
assert_eq!(resolved.corner_radius, Some(4.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn per_instance_override_takes_final_precedence() {
|
||||
let theme = theme_with_three_styles();
|
||||
let overlay = VisualStyle {
|
||||
background: Some(Color::RED),
|
||||
..VisualStyle::EMPTY
|
||||
};
|
||||
let resolved = theme.resolve(Some("button-primary"), &overlay);
|
||||
// Per-instance background wins over the named style.
|
||||
assert_eq!(resolved.background, Some(Color::RED));
|
||||
// The named style's foreground (WHITE) still beats the default (BLACK).
|
||||
assert_eq!(resolved.foreground, Some(Color::WHITE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_named_style_falls_back_to_default() {
|
||||
let theme = theme_with_three_styles();
|
||||
let resolved = theme.resolve(Some("does-not-exist"), &VisualStyle::EMPTY);
|
||||
// Same as resolve(None, &EMPTY).
|
||||
assert_eq!(resolved, theme.resolve(None, &VisualStyle::EMPTY));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_round_trips_through_ron() {
|
||||
let theme = theme_with_three_styles();
|
||||
let text = theme.to_ron().unwrap();
|
||||
let decoded = Theme::from_ron(&text).unwrap();
|
||||
assert_eq!(theme, decoded);
|
||||
// Named styles are alphabetised by BTreeMap, so "button" precedes
|
||||
// "button-primary" precedes "label" in the serialized form.
|
||||
let button_pos = text.find("\"button\"").unwrap();
|
||||
let primary_pos = text.find("\"button-primary\"").unwrap();
|
||||
let label_pos = text.find("\"label\"").unwrap();
|
||||
assert!(button_pos < primary_pos);
|
||||
assert!(primary_pos < label_pos);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_theme_round_trips_to_empty_ron() {
|
||||
let empty = Theme::new();
|
||||
let text = empty.to_ron().unwrap();
|
||||
let decoded = Theme::from_ron(&text).unwrap();
|
||||
assert_eq!(empty, decoded);
|
||||
// The empty theme should not mention either field.
|
||||
assert!(!text.contains("default:"));
|
||||
assert!(!text.contains("styles:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builder_chaining_inserts_styles_in_order() {
|
||||
let t = Theme::new()
|
||||
.with_style("a", VisualStyle::EMPTY)
|
||||
.with_style("b", VisualStyle::EMPTY);
|
||||
assert_eq!(t.styles.len(), 2);
|
||||
assert!(t.styles.contains_key("a"));
|
||||
assert!(t.styles.contains_key("b"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
//! Per-widget typed value — the state interactive widgets carry.
|
||||
//!
|
||||
//! Stage 8's UI is data-driven: a slider knows its current position, a
|
||||
//! text input knows the string the user has typed, a checkbox knows
|
||||
//! whether it's checked. Rather than encoding "which kind of state does
|
||||
//! this widget have" inside the layout enum, every [`Widget`](super::widget::Widget)
|
||||
//! has an optional `value: Option<WidgetValue>` orthogonal to its `kind`.
|
||||
//! That keeps the layout algorithm simple (it doesn't care about state)
|
||||
//! and lets the same `Leaf` form a button (no value) or a checkbox
|
||||
//! (`Bool` value).
|
||||
//!
|
||||
//! # Data binding model
|
||||
//!
|
||||
//! Stage-8 piece-6 uses the **immediate-mode** pattern (the same as
|
||||
//! `egui` and Bevy UI): the widget tree is the source of truth for the
|
||||
//! frame. Each frame the host:
|
||||
//!
|
||||
//! 1. Pulls latest game data into the matching widget values (e.g.,
|
||||
//! `root.set_value("volume", WidgetValue::Float(audio.master_volume as f64))`).
|
||||
//! 2. Runs the [`Router`](super::routing::Router).
|
||||
//! 3. Reads back any widget values that interactive widgets may have
|
||||
//! changed, and pushes them into game data
|
||||
//! (`audio.master_volume = root.value("volume")?.as_float()? as f32`).
|
||||
//!
|
||||
//! No callback storage, no `Rc<RefCell<...>>` for state, no lifetime
|
||||
//! gymnastics — exactly what a game's main loop wants.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A typed value carried on an interactive widget — the slider's
|
||||
/// position, a checkbox's check, a text-input's string.
|
||||
///
|
||||
/// Variants are intentionally minimal; richer types (Color, Vec2, etc.)
|
||||
/// can be added as widget needs grow. RON round-trips so a UI document
|
||||
/// can ship default values inline.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum WidgetValue {
|
||||
Bool(bool),
|
||||
Int(i64),
|
||||
Float(f64),
|
||||
Text(String),
|
||||
}
|
||||
|
||||
impl WidgetValue {
|
||||
/// Borrow as a bool if this is a [`Bool`](Self::Bool).
|
||||
pub fn as_bool(&self) -> Option<bool> {
|
||||
match self {
|
||||
Self::Bool(v) => Some(*v),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Borrow as an i64 if this is an [`Int`](Self::Int).
|
||||
pub fn as_int(&self) -> Option<i64> {
|
||||
match self {
|
||||
Self::Int(v) => Some(*v),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Borrow as an f64 if this is a [`Float`](Self::Float).
|
||||
pub fn as_float(&self) -> Option<f64> {
|
||||
match self {
|
||||
Self::Float(v) => Some(*v),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Borrow as a string slice if this is a [`Text`](Self::Text).
|
||||
pub fn as_text(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::Text(s) => Some(s.as_str()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<bool> for WidgetValue {
|
||||
fn from(v: bool) -> Self {
|
||||
Self::Bool(v)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<i64> for WidgetValue {
|
||||
fn from(v: i64) -> Self {
|
||||
Self::Int(v)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<i32> for WidgetValue {
|
||||
fn from(v: i32) -> Self {
|
||||
Self::Int(v as i64)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<f64> for WidgetValue {
|
||||
fn from(v: f64) -> Self {
|
||||
Self::Float(v)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<f32> for WidgetValue {
|
||||
fn from(v: f32) -> Self {
|
||||
Self::Float(v as f64)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for WidgetValue {
|
||||
fn from(v: String) -> Self {
|
||||
Self::Text(v)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for WidgetValue {
|
||||
fn from(v: &str) -> Self {
|
||||
Self::Text(v.to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn as_accessors_match_variants() {
|
||||
assert_eq!(WidgetValue::Bool(true).as_bool(), Some(true));
|
||||
assert_eq!(WidgetValue::Bool(true).as_int(), None);
|
||||
assert_eq!(WidgetValue::Int(42).as_int(), Some(42));
|
||||
assert_eq!(WidgetValue::Float(1.5).as_float(), Some(1.5));
|
||||
assert_eq!(WidgetValue::Text("hi".into()).as_text(), Some("hi"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn primitive_conversions() {
|
||||
let v: WidgetValue = true.into();
|
||||
assert_eq!(v, WidgetValue::Bool(true));
|
||||
let v: WidgetValue = 7_i32.into();
|
||||
assert_eq!(v, WidgetValue::Int(7));
|
||||
let v: WidgetValue = 1.5_f32.into();
|
||||
assert!((v.as_float().unwrap() - 1.5_f64).abs() < 1e-5);
|
||||
let v: WidgetValue = "label".into();
|
||||
assert_eq!(v.as_text(), Some("label"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ron_round_trips_each_variant() {
|
||||
for v in [
|
||||
WidgetValue::Bool(true),
|
||||
WidgetValue::Int(-99),
|
||||
WidgetValue::Float(0.42),
|
||||
WidgetValue::Text("hello".into()),
|
||||
] {
|
||||
let text = ron::ser::to_string(&v).unwrap();
|
||||
let decoded: WidgetValue = ron::de::from_str(&text).unwrap();
|
||||
assert_eq!(v, decoded);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
//! Visual style — colors, borders, fonts. The *what does it look like* layer.
|
||||
//!
|
||||
//! [`VisualStyle`] is orthogonal to the Stage-8 [`LayoutStyle`](super::style::LayoutStyle):
|
||||
//! layout decides where a widget *is*; visual decides what it *looks like*.
|
||||
//! Every field is `Option<T>`. `None` means **inherit** — from a [`Theme`](super::theme::Theme)
|
||||
//! when present, otherwise from the renderer's hard-coded fallback in piece 4.
|
||||
//! `Some` means **override**: this widget (or this named theme style) wants
|
||||
//! exactly this value, regardless of what the theme provides.
|
||||
//!
|
||||
//! Why optional fields instead of full values: it lets a tiny per-widget
|
||||
//! override stay tiny in RON (one line for "button-pressed has a brighter
|
||||
//! background") without re-stating every color/border/font the theme already
|
||||
//! provides. The same merging rule works equally well for theme cascades
|
||||
//! (default → named style → per-instance) and for runtime state changes
|
||||
//! (hover/focus/press overlays in piece 5).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::asset::AssetRef;
|
||||
use crate::math::Color;
|
||||
|
||||
use super::text::Font;
|
||||
|
||||
/// Optional per-widget visual properties. `None` on a field means "inherit";
|
||||
/// `Some` means "override".
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct VisualStyle {
|
||||
/// Filled background color drawn behind the widget's `content_rect`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub background: Option<Color>,
|
||||
/// Foreground color — text, icons, anything drawn *on top of* the
|
||||
/// background.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub foreground: Option<Color>,
|
||||
/// Border drawn around the widget's `rect`. `Some(border)` with a
|
||||
/// `width <= 0.0` is treated as "no border" by the renderer, the same as
|
||||
/// `None`, but the value still serializes — useful for theme overrides
|
||||
/// that explicitly *suppress* a border.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub border: Option<Border>,
|
||||
/// Corner radius in logical pixels (zero means square). Applies to both
|
||||
/// background fill and border.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub corner_radius: Option<f32>,
|
||||
/// Font family + weight + italic flag. Piece 3 turns this into a
|
||||
/// shaped glyph stream.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub font: Option<FontRef>,
|
||||
/// A specific font **asset** to draw with, chosen in the editor's UI canvas
|
||||
/// from the project's `fonts/`. When set it takes precedence over the
|
||||
/// portable [`font`](Self::font) descriptor (the renderer resolves the
|
||||
/// [`AssetRef`] to a loaded face via the asset database); when `None` the
|
||||
/// descriptor / theme path applies as before. This is the engine's first
|
||||
/// `AssetRef<T>` field — the asset-picker's end-to-end target.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub font_asset: Option<AssetRef<Font>>,
|
||||
/// Font size in logical pixels.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub font_size: Option<f32>,
|
||||
}
|
||||
|
||||
impl VisualStyle {
|
||||
/// Empty style — every field `None`. Equivalent to [`Default::default`];
|
||||
/// `EMPTY` exists as a `const` for places that want it as an associated
|
||||
/// constant.
|
||||
pub const EMPTY: Self = Self {
|
||||
background: None,
|
||||
foreground: None,
|
||||
border: None,
|
||||
corner_radius: None,
|
||||
font: None,
|
||||
font_asset: None,
|
||||
font_size: None,
|
||||
};
|
||||
|
||||
/// Returns a style where every `Some` field in `override_with` replaces
|
||||
/// the corresponding field in `self`.
|
||||
///
|
||||
/// This is the merge primitive themes and runtime state use: build a
|
||||
/// resolved style by cascading default → named-style → per-instance →
|
||||
/// state-overlay, each call replacing only the fields the caller cared
|
||||
/// about.
|
||||
pub fn merged(&self, override_with: &VisualStyle) -> VisualStyle {
|
||||
VisualStyle {
|
||||
background: override_with.background.or(self.background),
|
||||
foreground: override_with.foreground.or(self.foreground),
|
||||
border: override_with.border.or(self.border),
|
||||
corner_radius: override_with.corner_radius.or(self.corner_radius),
|
||||
font: override_with.font.clone().or_else(|| self.font.clone()),
|
||||
font_asset: override_with.font_asset.or(self.font_asset),
|
||||
font_size: override_with.font_size.or(self.font_size),
|
||||
}
|
||||
}
|
||||
|
||||
/// True if every field is `None`. Handy as a `skip_serializing_if` test
|
||||
/// when embedding a `VisualStyle` in a host struct that wants the empty
|
||||
/// case to vanish from RON entirely.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
*self == Self::EMPTY
|
||||
}
|
||||
}
|
||||
|
||||
/// Border drawn around a widget's `rect`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Border {
|
||||
pub color: Color,
|
||||
pub width: f32,
|
||||
}
|
||||
|
||||
impl Border {
|
||||
pub const fn new(color: Color, width: f32) -> Self {
|
||||
Self { color, width }
|
||||
}
|
||||
}
|
||||
|
||||
/// Reference to a font face the renderer will load and shape with.
|
||||
///
|
||||
/// Piece 2 stores the descriptor only; piece 3 (text shaping & glyph atlas)
|
||||
/// resolves it to an actual loaded face. Keeping the descriptor as plain
|
||||
/// `family` + `weight` + `italic` (rather than a path or a handle) means UI
|
||||
/// documents are portable: a theme can ask for `"Inter"` and the runtime can
|
||||
/// pick the platform's best match for that name without rewriting the
|
||||
/// document.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct FontRef {
|
||||
pub family: String,
|
||||
#[serde(default, skip_serializing_if = "FontWeight::is_default")]
|
||||
pub weight: FontWeight,
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub italic: bool,
|
||||
}
|
||||
|
||||
impl FontRef {
|
||||
/// Regular-weight, upright font of the given family.
|
||||
pub fn regular(family: impl Into<String>) -> Self {
|
||||
Self {
|
||||
family: family.into(),
|
||||
weight: FontWeight::Regular,
|
||||
italic: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Bold-weight, upright font of the given family.
|
||||
pub fn bold(family: impl Into<String>) -> Self {
|
||||
Self {
|
||||
family: family.into(),
|
||||
weight: FontWeight::Bold,
|
||||
italic: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Font weight — the named buckets the OpenType weight axis snaps to.
|
||||
///
|
||||
/// Stored as a discrete enum (rather than a `u16` 100–900) because the
|
||||
/// editor's style inspector and a hand-edited RON file both want
|
||||
/// `weight: Bold` to round-trip exactly. Renderers can map each variant to
|
||||
/// its OpenType weight value in piece 3.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
|
||||
pub enum FontWeight {
|
||||
Thin,
|
||||
Light,
|
||||
#[default]
|
||||
Regular,
|
||||
Medium,
|
||||
Bold,
|
||||
Black,
|
||||
}
|
||||
|
||||
impl FontWeight {
|
||||
/// OpenType weight value (100..=900) for this bucket.
|
||||
pub fn opentype_value(self) -> u16 {
|
||||
match self {
|
||||
Self::Thin => 100,
|
||||
Self::Light => 300,
|
||||
Self::Regular => 400,
|
||||
Self::Medium => 500,
|
||||
Self::Bold => 700,
|
||||
Self::Black => 900,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_default(&self) -> bool {
|
||||
*self == Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn empty_style_has_no_set_fields() {
|
||||
let s = VisualStyle::default();
|
||||
assert!(s.is_empty());
|
||||
assert_eq!(s, VisualStyle::EMPTY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_overrides_only_set_fields() {
|
||||
let base = VisualStyle {
|
||||
background: Some(Color::WHITE),
|
||||
foreground: Some(Color::BLACK),
|
||||
border: Some(Border::new(Color::BLACK, 1.0)),
|
||||
corner_radius: Some(4.0),
|
||||
font: Some(FontRef::regular("Inter")),
|
||||
font_asset: None,
|
||||
font_size: Some(14.0),
|
||||
};
|
||||
let overlay = VisualStyle {
|
||||
background: Some(Color::rgb(0.9, 0.9, 0.9)),
|
||||
font_size: Some(16.0),
|
||||
..VisualStyle::EMPTY
|
||||
};
|
||||
let merged = base.merged(&overlay);
|
||||
assert_eq!(merged.background, Some(Color::rgb(0.9, 0.9, 0.9))); // overlaid
|
||||
assert_eq!(merged.foreground, Some(Color::BLACK)); // kept from base
|
||||
assert_eq!(merged.font_size, Some(16.0)); // overlaid
|
||||
assert_eq!(merged.corner_radius, Some(4.0)); // kept from base
|
||||
assert_eq!(merged.font, Some(FontRef::regular("Inter")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_with_empty_overlay_is_identity() {
|
||||
let base = VisualStyle {
|
||||
background: Some(Color::WHITE),
|
||||
foreground: Some(Color::BLACK),
|
||||
..VisualStyle::EMPTY
|
||||
};
|
||||
assert_eq!(base.merged(&VisualStyle::EMPTY), base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_into_empty_base_takes_overlay() {
|
||||
let overlay = VisualStyle {
|
||||
background: Some(Color::RED),
|
||||
..VisualStyle::EMPTY
|
||||
};
|
||||
assert_eq!(VisualStyle::EMPTY.merged(&overlay), overlay);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_ref_helpers_match_fields() {
|
||||
let r = FontRef::regular("Inter");
|
||||
assert_eq!(r.family, "Inter");
|
||||
assert_eq!(r.weight, FontWeight::Regular);
|
||||
assert!(!r.italic);
|
||||
|
||||
let b = FontRef::bold("Inter");
|
||||
assert_eq!(b.weight, FontWeight::Bold);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_weight_opentype_value() {
|
||||
assert_eq!(FontWeight::Thin.opentype_value(), 100);
|
||||
assert_eq!(FontWeight::Regular.opentype_value(), 400);
|
||||
assert_eq!(FontWeight::Bold.opentype_value(), 700);
|
||||
assert_eq!(FontWeight::Black.opentype_value(), 900);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visual_style_round_trips_through_ron_compactly() {
|
||||
let s = VisualStyle {
|
||||
background: Some(Color::WHITE),
|
||||
corner_radius: Some(8.0),
|
||||
font: Some(FontRef::bold("Inter")),
|
||||
..VisualStyle::EMPTY
|
||||
};
|
||||
let text = ron::ser::to_string(&s).unwrap();
|
||||
// Fields that are `None` must not appear in the serialized form.
|
||||
assert!(!text.contains("foreground"));
|
||||
assert!(!text.contains("border"));
|
||||
assert!(!text.contains("font_size"));
|
||||
let decoded: VisualStyle = ron::de::from_str(&text).unwrap();
|
||||
assert_eq!(s, decoded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_asset_overrides_and_round_trips() {
|
||||
use crate::asset::{AssetRef, AssetUid};
|
||||
|
||||
// An overlay's font_asset replaces the base's, like the other fields.
|
||||
let base = VisualStyle {
|
||||
font_asset: Some(AssetRef::new(AssetUid(1))),
|
||||
..VisualStyle::EMPTY
|
||||
};
|
||||
let overlay = VisualStyle {
|
||||
font_asset: Some(AssetRef::new(AssetUid(2))),
|
||||
..VisualStyle::EMPTY
|
||||
};
|
||||
assert_eq!(
|
||||
base.merged(&overlay).font_asset,
|
||||
Some(AssetRef::new(AssetUid(2)))
|
||||
);
|
||||
// An empty overlay keeps the base reference (inherit semantics).
|
||||
assert_eq!(base.merged(&VisualStyle::EMPTY).font_asset, base.font_asset);
|
||||
|
||||
// Round-trips compactly and is skipped when unset.
|
||||
let text = ron::ser::to_string(&base).unwrap();
|
||||
assert!(text.contains("font_asset"));
|
||||
assert_eq!(ron::de::from_str::<VisualStyle>(&text).unwrap(), base);
|
||||
let empty_text = ron::ser::to_string(&VisualStyle::EMPTY).unwrap();
|
||||
assert!(!empty_text.contains("font_asset"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_style_round_trips_to_empty_ron() {
|
||||
let text = ron::ser::to_string(&VisualStyle::EMPTY).unwrap();
|
||||
// No fields set → the struct should serialize to its empty form.
|
||||
let decoded: VisualStyle = ron::de::from_str(&text).unwrap();
|
||||
assert_eq!(decoded, VisualStyle::EMPTY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_ref_defaults_skip_in_ron() {
|
||||
let f = FontRef::regular("Inter");
|
||||
let text = ron::ser::to_string(&f).unwrap();
|
||||
// Regular weight and non-italic should be skipped.
|
||||
assert!(!text.contains("Regular"));
|
||||
assert!(!text.contains("italic"));
|
||||
let decoded: FontRef = ron::de::from_str(&text).unwrap();
|
||||
assert_eq!(f, decoded);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,933 @@
|
||||
//! Widget tree — the data structure laid out by [`super::layout`].
|
||||
//!
|
||||
//! Stage 8 splits widgets cleanly into **what** (the [`WidgetKind`]) and
|
||||
//! **how** (the [`LayoutStyle`] held on every node). The kind decides whether
|
||||
//! a node has children and how they're arranged; the style is the same fields
|
||||
//! on every widget so the layout algorithm has one place to look.
|
||||
//!
|
||||
//! Piece 1 ships only what the layout algorithm needs: a [`Leaf`](WidgetKind::Leaf)
|
||||
//! placeholder with an intrinsic size, and three container kinds — [`Stack`]
|
||||
//! (row/column), [`Grid`], and [`AnchorGroup`]. Interactive widgets (button,
|
||||
//! checkbox, slider, text input, …) are layered on top in later pieces by
|
||||
//! decorating leaves with kind-specific style/state; they all participate in
|
||||
//! the same layout pass without the algorithm having to know about them.
|
||||
//!
|
||||
//! # Building a tree
|
||||
//!
|
||||
//! ```
|
||||
//! use glam::Vec2;
|
||||
//! use oxide_engine::ui::{Insets, LayoutStyle, Sizing, Widget};
|
||||
//!
|
||||
//! let panel = Widget::row()
|
||||
//! .with_id("toolbar")
|
||||
//! .with_style(LayoutStyle {
|
||||
//! width: Sizing::Grow(1.0),
|
||||
//! height: Sizing::Fixed(32.0),
|
||||
//! padding: Insets::all(4.0),
|
||||
//! ..Default::default()
|
||||
//! })
|
||||
//! .with_child(Widget::leaf(Vec2::new(80.0, 24.0)).with_id("file"))
|
||||
//! .with_child(Widget::leaf(Vec2::new(80.0, 24.0)).with_id("edit"));
|
||||
//! assert_eq!(panel.children().len(), 2);
|
||||
//! ```
|
||||
|
||||
use glam::Vec2;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::style::LayoutStyle;
|
||||
use super::value::WidgetValue;
|
||||
use super::visual::VisualStyle;
|
||||
|
||||
/// Stable identifier for a widget — used to look up its laid-out rect in a
|
||||
/// [`LayoutTree`](super::layout::LayoutTree) and (in later pieces) to wire up
|
||||
/// input routing and data binding.
|
||||
///
|
||||
/// Stored as `String` so UI documents can ship author-facing names (`"play"`,
|
||||
/// `"volume-slider"`) straight through RON. The empty id (`""`) is the default
|
||||
/// and means "anonymous"; multiple anonymous widgets are allowed and lookups
|
||||
/// by empty id are rejected by [`LayoutTree::find`](super::layout::LayoutTree::find).
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct WidgetId(pub String);
|
||||
|
||||
impl WidgetId {
|
||||
/// `true` if the id string is empty.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.0.is_empty()
|
||||
}
|
||||
|
||||
/// Borrow the underlying string.
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for WidgetId {
|
||||
fn from(s: &str) -> Self {
|
||||
Self(s.to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
/// A path from a root [`Widget`] to one of its descendants: the sequence of
|
||||
/// child indices to follow from the root. The **empty** path denotes the root
|
||||
/// itself.
|
||||
///
|
||||
/// Unlike [`WidgetId`] (optional, author-facing, possibly absent or duplicated)
|
||||
/// a path addresses *exactly one* node positionally, so it is what the editor's
|
||||
/// UI canvas uses to target structural edits — insert, remove, move — and to
|
||||
/// record them on the undo stack. Paths are only valid against the tree they
|
||||
/// were derived from; an edit that changes sibling order invalidates the paths
|
||||
/// after it (the move helper accounts for this itself).
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct WidgetPath(pub Vec<usize>);
|
||||
|
||||
impl WidgetPath {
|
||||
/// The root path (addresses the tree's root widget).
|
||||
pub fn root() -> Self {
|
||||
Self(Vec::new())
|
||||
}
|
||||
|
||||
/// Whether this path addresses the root (is empty).
|
||||
pub fn is_root(&self) -> bool {
|
||||
self.0.is_empty()
|
||||
}
|
||||
|
||||
/// Depth from the root (number of indices).
|
||||
pub fn len(&self) -> usize {
|
||||
self.0.len()
|
||||
}
|
||||
|
||||
/// Whether the path is empty — alias of [`is_root`](Self::is_root), provided
|
||||
/// for the clippy `len`/`is_empty` pairing.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.0.is_empty()
|
||||
}
|
||||
|
||||
/// A child path one level deeper, selecting child `index`.
|
||||
pub fn child(&self, index: usize) -> Self {
|
||||
let mut v = self.0.clone();
|
||||
v.push(index);
|
||||
Self(v)
|
||||
}
|
||||
|
||||
/// Splits into `(parent_path, last_index)`, or `None` for the root.
|
||||
pub fn split_last(&self) -> Option<(WidgetPath, usize)> {
|
||||
let (last, rest) = self.0.split_last()?;
|
||||
Some((WidgetPath(rest.to_vec()), *last))
|
||||
}
|
||||
|
||||
/// Whether `self` is `other` or lies underneath it (prefix test). Used to
|
||||
/// reject moving a subtree into its own descendant.
|
||||
pub fn starts_with(&self, other: &WidgetPath) -> bool {
|
||||
self.0.starts_with(&other.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for WidgetId {
|
||||
fn from(s: String) -> Self {
|
||||
Self(s)
|
||||
}
|
||||
}
|
||||
|
||||
/// A widget tree node — id, layout style, optional visual style + theme
|
||||
/// reference, and a kind that decides what children it holds.
|
||||
///
|
||||
/// `style` (Stage-8 piece 1) controls layout — where the widget is.
|
||||
/// `visual` (piece 2) carries per-instance visual overrides — what the
|
||||
/// widget looks like — and `theme_style` opts into a named entry in the
|
||||
/// project's [`Theme`](super::theme::Theme). Both default to empty so a
|
||||
/// piece-1 UI document still parses unchanged.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Widget {
|
||||
#[serde(default, skip_serializing_if = "WidgetId::is_empty")]
|
||||
pub id: WidgetId,
|
||||
#[serde(default)]
|
||||
pub style: LayoutStyle,
|
||||
#[serde(default, skip_serializing_if = "VisualStyle::is_empty")]
|
||||
pub visual: VisualStyle,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub theme_style: Option<String>,
|
||||
/// Text content shaped inside this widget's `content_rect`. Orthogonal
|
||||
/// to `kind`: a button is a `Leaf` with `text` + `visual.background`; a
|
||||
/// label is a `Leaf` with `text` only. Renderers shape this string
|
||||
/// against the resolved [`VisualStyle::font`] and [`VisualStyle::font_size`].
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub text: Option<String>,
|
||||
/// Per-widget typed state — `Bool` for a checkbox, `Float` for a
|
||||
/// slider, `Text` for a text input. Orthogonal to `kind`; absent
|
||||
/// means "no state". See [`super::value::WidgetValue`] and the
|
||||
/// piece-6 [`Widget::value`](Self::value) / [`set_value`](Self::set_value)
|
||||
/// helpers.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub value: Option<WidgetValue>,
|
||||
pub kind: WidgetKind,
|
||||
}
|
||||
|
||||
/// What a widget *is* — leaf or one of three container layout modes.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum WidgetKind {
|
||||
/// A childless node with an intrinsic logical size. Real interactive
|
||||
/// widgets (label, button, image) layer on top of this in later pieces.
|
||||
Leaf { intrinsic: Vec2 },
|
||||
/// Row or column container.
|
||||
Stack(Stack),
|
||||
/// Equal-cell grid container.
|
||||
Grid(Grid),
|
||||
/// Container that positions each child via the child's own
|
||||
/// [`Anchor`](super::style::Anchor).
|
||||
Anchor(AnchorGroup),
|
||||
}
|
||||
|
||||
impl Default for WidgetKind {
|
||||
fn default() -> Self {
|
||||
Self::Leaf {
|
||||
intrinsic: Vec2::ZERO,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stack container — arranges children along a main axis.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Stack {
|
||||
pub direction: StackDirection,
|
||||
/// Logical-pixel gap between adjacent children.
|
||||
#[serde(default)]
|
||||
pub gap: f32,
|
||||
/// How leftover space on the main axis is distributed *after* children
|
||||
/// have been sized. Ignored when any child uses [`Sizing::Grow`](super::style::Sizing::Grow),
|
||||
/// since `Grow` consumes the leftover space directly.
|
||||
#[serde(default)]
|
||||
pub main_align: super::style::Align,
|
||||
#[serde(default)]
|
||||
pub children: Vec<Widget>,
|
||||
}
|
||||
|
||||
/// Direction of a [`Stack`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub enum StackDirection {
|
||||
/// Children flow left-to-right.
|
||||
#[default]
|
||||
Row,
|
||||
/// Children flow top-to-bottom.
|
||||
Column,
|
||||
}
|
||||
|
||||
/// Equal-cell grid container — `cols × rows` cells filled in row-major order.
|
||||
///
|
||||
/// Piece-1 grids are intentionally simple: every cell is the same size,
|
||||
/// computed from the parent's content rect. More flexible grids (auto-sized
|
||||
/// rows/columns, spans) are a follow-up; the use cases the editor's Stage-7
|
||||
/// preferences page and the Stage-8 settings examples actually need are all
|
||||
/// served by the equal-cell case.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Grid {
|
||||
pub cols: u32,
|
||||
pub rows: u32,
|
||||
/// `gap.x` between columns, `gap.y` between rows (logical pixels).
|
||||
#[serde(default)]
|
||||
pub gap: Vec2,
|
||||
#[serde(default)]
|
||||
pub children: Vec<Widget>,
|
||||
}
|
||||
|
||||
/// Anchor container — each child is placed according to its own
|
||||
/// [`LayoutStyle::anchor`](super::style::LayoutStyle::anchor).
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AnchorGroup {
|
||||
#[serde(default)]
|
||||
pub children: Vec<Widget>,
|
||||
}
|
||||
|
||||
impl Widget {
|
||||
/// Build a leaf widget with the given intrinsic logical size.
|
||||
pub fn leaf(intrinsic: Vec2) -> Self {
|
||||
Self {
|
||||
kind: WidgetKind::Leaf { intrinsic },
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Build an empty stack with the given direction (gap 0, default align).
|
||||
pub fn stack(direction: StackDirection) -> Self {
|
||||
Self {
|
||||
kind: WidgetKind::Stack(Stack {
|
||||
direction,
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Shortcut for `Widget::stack(StackDirection::Row)`.
|
||||
pub fn row() -> Self {
|
||||
Self::stack(StackDirection::Row)
|
||||
}
|
||||
|
||||
/// Shortcut for `Widget::stack(StackDirection::Column)`.
|
||||
pub fn column() -> Self {
|
||||
Self::stack(StackDirection::Column)
|
||||
}
|
||||
|
||||
/// Build an empty grid container.
|
||||
pub fn grid(cols: u32, rows: u32) -> Self {
|
||||
Self {
|
||||
kind: WidgetKind::Grid(Grid {
|
||||
cols,
|
||||
rows,
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Build an empty anchor container.
|
||||
pub fn anchor() -> Self {
|
||||
Self {
|
||||
kind: WidgetKind::Anchor(AnchorGroup::default()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the widget id (builder).
|
||||
pub fn with_id(mut self, id: impl Into<WidgetId>) -> Self {
|
||||
self.id = id.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Replace the whole [`LayoutStyle`] (builder).
|
||||
pub fn with_style(mut self, style: LayoutStyle) -> Self {
|
||||
self.style = style;
|
||||
self
|
||||
}
|
||||
|
||||
/// Replace the per-instance [`VisualStyle`] (builder).
|
||||
pub fn with_visual(mut self, visual: VisualStyle) -> Self {
|
||||
self.visual = visual;
|
||||
self
|
||||
}
|
||||
|
||||
/// Opt this widget into a named entry of the active
|
||||
/// [`Theme`](super::theme::Theme) (builder). Pass `""` or call
|
||||
/// [`Widget::clear_theme_style`] to remove the reference.
|
||||
pub fn with_theme_style(mut self, name: impl Into<String>) -> Self {
|
||||
let name = name.into();
|
||||
self.theme_style = if name.is_empty() { None } else { Some(name) };
|
||||
self
|
||||
}
|
||||
|
||||
/// Drop any `theme_style` reference (builder).
|
||||
pub fn clear_theme_style(mut self) -> Self {
|
||||
self.theme_style = None;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set this widget's text content (builder). Pass `""` to clear it. The
|
||||
/// text is shaped at paint time against the widget's resolved font and
|
||||
/// font size from the active theme.
|
||||
pub fn with_text(mut self, text: impl Into<String>) -> Self {
|
||||
let s = text.into();
|
||||
self.text = if s.is_empty() { None } else { Some(s) };
|
||||
self
|
||||
}
|
||||
|
||||
/// Set this widget's typed value (builder).
|
||||
pub fn with_value(mut self, value: impl Into<WidgetValue>) -> Self {
|
||||
self.value = Some(value.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the stack gap (builder). Panics if not a stack — surfaces author
|
||||
/// mistakes during construction rather than producing a silently
|
||||
/// misshapen UI at layout time.
|
||||
pub fn with_gap(mut self, gap: f32) -> Self {
|
||||
match &mut self.kind {
|
||||
WidgetKind::Stack(s) => s.gap = gap,
|
||||
_ => panic!("with_gap is only valid on Stack widgets"),
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the stack main-axis alignment (builder). Panics if not a stack.
|
||||
pub fn with_main_align(mut self, align: super::style::Align) -> Self {
|
||||
match &mut self.kind {
|
||||
WidgetKind::Stack(s) => s.main_align = align,
|
||||
_ => panic!("with_main_align is only valid on Stack widgets"),
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the grid gap vector (builder). Panics if not a grid.
|
||||
pub fn with_grid_gap(mut self, gap: Vec2) -> Self {
|
||||
match &mut self.kind {
|
||||
WidgetKind::Grid(g) => g.gap = gap,
|
||||
_ => panic!("with_grid_gap is only valid on Grid widgets"),
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Append a single child to a container widget (builder). Panics on a
|
||||
/// leaf so the misuse is caught at construction.
|
||||
pub fn with_child(mut self, child: Widget) -> Self {
|
||||
children_mut(&mut self.kind, |c| c.push(child));
|
||||
self
|
||||
}
|
||||
|
||||
/// Append many children (builder).
|
||||
pub fn with_children(mut self, children: impl IntoIterator<Item = Widget>) -> Self {
|
||||
children_mut(&mut self.kind, |c| c.extend(children));
|
||||
self
|
||||
}
|
||||
|
||||
/// Borrow the direct children of this widget. Empty for leaves.
|
||||
pub fn children(&self) -> &[Widget] {
|
||||
match &self.kind {
|
||||
WidgetKind::Leaf { .. } => &[],
|
||||
WidgetKind::Stack(s) => &s.children,
|
||||
WidgetKind::Grid(g) => &g.children,
|
||||
WidgetKind::Anchor(a) => &a.children,
|
||||
}
|
||||
}
|
||||
|
||||
/// Borrow the direct children mutably. Empty slice for leaves.
|
||||
///
|
||||
/// Underpins [`find_by_id_mut`](Self::find_by_id_mut) and the piece-6
|
||||
/// data-binding helpers; safer than reaching into `kind` because all
|
||||
/// container kinds funnel through one accessor.
|
||||
pub fn children_mut(&mut self) -> &mut [Widget] {
|
||||
match &mut self.kind {
|
||||
WidgetKind::Leaf { .. } => &mut [],
|
||||
WidgetKind::Stack(s) => &mut s.children,
|
||||
WidgetKind::Grid(g) => &mut g.children,
|
||||
WidgetKind::Anchor(a) => &mut a.children,
|
||||
}
|
||||
}
|
||||
|
||||
/// Borrow this widget's children as the owning `Vec`, or `None` for a
|
||||
/// [`Leaf`](WidgetKind::Leaf) (which cannot hold children). Unlike
|
||||
/// [`children_mut`](Self::children_mut) this exposes the `Vec` itself, so
|
||||
/// callers can insert/remove — the basis of the structural edits below.
|
||||
pub fn children_vec_mut(&mut self) -> Option<&mut Vec<Widget>> {
|
||||
match &mut self.kind {
|
||||
WidgetKind::Leaf { .. } => None,
|
||||
WidgetKind::Stack(s) => Some(&mut s.children),
|
||||
WidgetKind::Grid(g) => Some(&mut g.children),
|
||||
WidgetKind::Anchor(a) => Some(&mut a.children),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this widget is a container (can hold children) rather than a leaf.
|
||||
pub fn is_container(&self) -> bool {
|
||||
!matches!(self.kind, WidgetKind::Leaf { .. })
|
||||
}
|
||||
|
||||
/// Borrow the widget addressed by `path` (the root for the empty path), or
|
||||
/// `None` if any index along the way is out of range.
|
||||
pub fn get_path(&self, path: &WidgetPath) -> Option<&Widget> {
|
||||
let mut node = self;
|
||||
for &i in &path.0 {
|
||||
node = node.children().get(i)?;
|
||||
}
|
||||
Some(node)
|
||||
}
|
||||
|
||||
/// Mutable counterpart of [`get_path`](Self::get_path).
|
||||
pub fn get_path_mut(&mut self, path: &WidgetPath) -> Option<&mut Widget> {
|
||||
let mut node = self;
|
||||
for &i in &path.0 {
|
||||
node = node.children_mut().get_mut(i)?;
|
||||
}
|
||||
Some(node)
|
||||
}
|
||||
|
||||
/// Inserts `child` at `index` among the children of the widget addressed by
|
||||
/// `parent`, returning whether it succeeded. `index` is clamped to the
|
||||
/// child count (so it can append). Fails if `parent` does not resolve or is
|
||||
/// a leaf.
|
||||
pub fn insert_child(&mut self, parent: &WidgetPath, index: usize, child: Widget) -> bool {
|
||||
let Some(parent) = self.get_path_mut(parent) else {
|
||||
return false;
|
||||
};
|
||||
let Some(children) = parent.children_vec_mut() else {
|
||||
return false;
|
||||
};
|
||||
children.insert(index.min(children.len()), child);
|
||||
true
|
||||
}
|
||||
|
||||
/// Appends `child` to the children of the widget addressed by `parent`.
|
||||
/// Convenience over [`insert_child`](Self::insert_child) with a trailing
|
||||
/// index.
|
||||
pub fn push_child_at(&mut self, parent: &WidgetPath, child: Widget) -> bool {
|
||||
self.insert_child(parent, usize::MAX, child)
|
||||
}
|
||||
|
||||
/// Removes and returns the widget addressed by `path`. The root cannot be
|
||||
/// removed (returns `None` for the empty path), nor can an out-of-range or
|
||||
/// unreachable path.
|
||||
pub fn remove_path(&mut self, path: &WidgetPath) -> Option<Widget> {
|
||||
let (parent, index) = path.split_last()?;
|
||||
let children = self.get_path_mut(&parent)?.children_vec_mut()?;
|
||||
(index < children.len()).then(|| children.remove(index))
|
||||
}
|
||||
|
||||
/// Moves the subtree at `from` to be child `index` of `to_parent`,
|
||||
/// returning whether it succeeded. Rejects moving the root, or moving a node
|
||||
/// into itself or one of its own descendants. Sibling indices shift when the
|
||||
/// node is detached, so both `to_parent` and `index` are adjusted internally
|
||||
/// to mean what the caller intended *before* the move.
|
||||
pub fn move_subtree(
|
||||
&mut self,
|
||||
from: &WidgetPath,
|
||||
to_parent: &WidgetPath,
|
||||
index: usize,
|
||||
) -> bool {
|
||||
if from.is_root() || to_parent.starts_with(from) {
|
||||
return false;
|
||||
}
|
||||
// The destination must exist and be a container; check before detaching
|
||||
// (removing `from`, which is not an ancestor of `to_parent`, leaves the
|
||||
// destination node itself unchanged — only its path may shift).
|
||||
if !self.get_path(to_parent).is_some_and(Widget::is_container) {
|
||||
return false;
|
||||
}
|
||||
let Some(node) = self.remove_path(from) else {
|
||||
return false;
|
||||
};
|
||||
let to_parent = adjust_path_for_removal(to_parent, from);
|
||||
let (from_parent, from_index) = from.split_last().expect("non-root checked above");
|
||||
// Inserting back into the same parent after the detach point shifts the
|
||||
// target slot down by one.
|
||||
let index = if from_parent.0 == to_parent.0 && from_index < index {
|
||||
index - 1
|
||||
} else {
|
||||
index
|
||||
};
|
||||
self.insert_child(&to_parent, index, node)
|
||||
}
|
||||
|
||||
/// Find a descendant (or self) with this id. Returns the first match
|
||||
/// in pre-order. `None` if no widget matches (or `id` is empty).
|
||||
pub fn find_by_id(&self, id: &WidgetId) -> Option<&Widget> {
|
||||
if id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if self.id == *id {
|
||||
return Some(self);
|
||||
}
|
||||
for child in self.children() {
|
||||
if let Some(found) = child.find_by_id(id) {
|
||||
return Some(found);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Mutable counterpart of [`find_by_id`](Self::find_by_id).
|
||||
pub fn find_by_id_mut(&mut self, id: &WidgetId) -> Option<&mut Widget> {
|
||||
if id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if self.id == *id {
|
||||
return Some(self);
|
||||
}
|
||||
for child in self.children_mut() {
|
||||
if let Some(found) = child.find_by_id_mut(id) {
|
||||
return Some(found);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Borrow the [`WidgetValue`] of the descendant with this id, if any.
|
||||
/// One half of the piece-6 data-binding loop: read what the UI says.
|
||||
pub fn value(&self, id: &WidgetId) -> Option<&WidgetValue> {
|
||||
self.find_by_id(id).and_then(|w| w.value.as_ref())
|
||||
}
|
||||
|
||||
/// Set the [`WidgetValue`] of the descendant with this id, returning
|
||||
/// `true` if such a widget exists. The other half of the piece-6
|
||||
/// data-binding loop: write game state into the UI.
|
||||
pub fn set_value(&mut self, id: &WidgetId, value: impl Into<WidgetValue>) -> bool {
|
||||
match self.find_by_id_mut(id) {
|
||||
Some(w) => {
|
||||
w.value = Some(value.into());
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Recursive count of nodes including `self`. Handy for sanity checks
|
||||
/// in tests when comparing against a [`LayoutTree::nodes`](super::layout::LayoutTree::nodes)
|
||||
/// length.
|
||||
pub fn node_count(&self) -> usize {
|
||||
1 + self
|
||||
.children()
|
||||
.iter()
|
||||
.map(Widget::node_count)
|
||||
.sum::<usize>()
|
||||
}
|
||||
|
||||
/// Resolve this widget's effective [`VisualStyle`] under a given theme,
|
||||
/// cascading `theme.default` → `theme.styles[self.theme_style]` →
|
||||
/// `self.visual`. See [`Theme::resolve`](super::theme::Theme::resolve)
|
||||
/// for the merge rules. Children are *not* recursively resolved here —
|
||||
/// piece 4 walks the tree pairing each [`super::layout::LayoutNode`] with
|
||||
/// its resolved style.
|
||||
pub fn resolve_visual(&self, theme: &super::theme::Theme) -> VisualStyle {
|
||||
theme.resolve(self.theme_style.as_deref(), &self.visual)
|
||||
}
|
||||
|
||||
/// Serialize this widget tree to a pretty-printed RON string — the
|
||||
/// canonical UI-document format an editor saves and the runtime loads.
|
||||
pub fn to_ron(&self) -> Result<String, ron::Error> {
|
||||
ron::ser::to_string_pretty(self, ron::ser::PrettyConfig::default())
|
||||
}
|
||||
|
||||
/// Parse a widget tree from a RON string produced by [`to_ron`](Self::to_ron).
|
||||
pub fn from_ron(text: &str) -> Result<Self, ron::de::SpannedError> {
|
||||
ron::de::from_str(text)
|
||||
}
|
||||
}
|
||||
|
||||
/// Rewrites `path` to stay valid after the widget at `removed` is detached.
|
||||
///
|
||||
/// Detaching shifts the later siblings of `removed` down by one. A path is
|
||||
/// affected only if it descends through `removed`'s parent and its index at
|
||||
/// that depth is *after* the removed index; then that one index decrements.
|
||||
/// `path` must not be `removed` or beneath it (the caller guarantees this).
|
||||
fn adjust_path_for_removal(path: &WidgetPath, removed: &WidgetPath) -> WidgetPath {
|
||||
let Some((removed_parent, removed_index)) = removed.split_last() else {
|
||||
return path.clone();
|
||||
};
|
||||
let depth = removed_parent.0.len();
|
||||
let mut out = path.0.clone();
|
||||
if out.len() > depth && out[..depth] == removed_parent.0[..] && out[depth] > removed_index {
|
||||
out[depth] -= 1;
|
||||
}
|
||||
WidgetPath(out)
|
||||
}
|
||||
|
||||
fn children_mut(kind: &mut WidgetKind, f: impl FnOnce(&mut Vec<Widget>)) {
|
||||
match kind {
|
||||
WidgetKind::Stack(s) => f(&mut s.children),
|
||||
WidgetKind::Grid(g) => f(&mut g.children),
|
||||
WidgetKind::Anchor(a) => f(&mut a.children),
|
||||
WidgetKind::Leaf { .. } => panic!("cannot add children to a Leaf widget"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A row root with three leaf children id'd "a","b","c".
|
||||
fn abc_tree() -> Widget {
|
||||
Widget::row()
|
||||
.with_id("root")
|
||||
.with_child(Widget::leaf(Vec2::ZERO).with_id("a"))
|
||||
.with_child(Widget::leaf(Vec2::ZERO).with_id("b"))
|
||||
.with_child(Widget::leaf(Vec2::ZERO).with_id("c"))
|
||||
}
|
||||
|
||||
fn ids_of(children: &[Widget]) -> Vec<&str> {
|
||||
children.iter().map(|w| w.id.as_str()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_path_addresses_nodes() {
|
||||
let root = abc_tree();
|
||||
assert_eq!(
|
||||
root.get_path(&WidgetPath::root()).unwrap().id.as_str(),
|
||||
"root"
|
||||
);
|
||||
assert_eq!(
|
||||
root.get_path(&WidgetPath(vec![1])).unwrap().id.as_str(),
|
||||
"b"
|
||||
);
|
||||
assert!(root.get_path(&WidgetPath(vec![9])).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_and_remove_children_by_path() {
|
||||
let mut root = abc_tree();
|
||||
// Insert "x" between a and b.
|
||||
assert!(root.insert_child(
|
||||
&WidgetPath::root(),
|
||||
1,
|
||||
Widget::leaf(Vec2::ZERO).with_id("x")
|
||||
));
|
||||
assert_eq!(ids_of(root.children()), ["a", "x", "b", "c"]);
|
||||
// Append "z" via the clamping path.
|
||||
assert!(root.push_child_at(&WidgetPath::root(), Widget::leaf(Vec2::ZERO).with_id("z")));
|
||||
assert_eq!(ids_of(root.children()), ["a", "x", "b", "c", "z"]);
|
||||
// A leaf rejects children; the root cannot be removed.
|
||||
assert!(!root.insert_child(&WidgetPath(vec![0]), 0, Widget::default()));
|
||||
assert!(root.remove_path(&WidgetPath::root()).is_none());
|
||||
// Remove "x".
|
||||
let removed = root.remove_path(&WidgetPath(vec![1])).unwrap();
|
||||
assert_eq!(removed.id.as_str(), "x");
|
||||
assert_eq!(ids_of(root.children()), ["a", "b", "c", "z"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_subtree_reorders_within_parent() {
|
||||
let mut root = abc_tree();
|
||||
// Move "a" (index 0) to the end (index 3 in pre-removal terms).
|
||||
assert!(root.move_subtree(&WidgetPath(vec![0]), &WidgetPath::root(), 3));
|
||||
assert_eq!(ids_of(root.children()), ["b", "c", "a"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_subtree_across_branches_adjusts_paths() {
|
||||
// root[ col(0) [a], b(1), c(2) ]: move c into the column before a.
|
||||
let mut root = Widget::row()
|
||||
.with_id("root")
|
||||
.with_child(
|
||||
Widget::column()
|
||||
.with_id("col")
|
||||
.with_child(Widget::leaf(Vec2::ZERO).with_id("a")),
|
||||
)
|
||||
.with_child(Widget::leaf(Vec2::ZERO).with_id("b"))
|
||||
.with_child(Widget::leaf(Vec2::ZERO).with_id("c"));
|
||||
assert!(root.move_subtree(&WidgetPath(vec![2]), &WidgetPath(vec![0]), 0));
|
||||
// c now leads the column; root has col + b left.
|
||||
assert_eq!(
|
||||
ids_of(root.get_path(&WidgetPath(vec![0])).unwrap().children()),
|
||||
["c", "a"]
|
||||
);
|
||||
assert_eq!(ids_of(root.children()), ["col", "b"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_subtree_rejects_into_own_descendant_and_root() {
|
||||
let mut root = Widget::row().with_id("root").with_child(
|
||||
Widget::column()
|
||||
.with_id("col")
|
||||
.with_child(Widget::leaf(Vec2::ZERO).with_id("a")),
|
||||
);
|
||||
// Can't move "col" (path [0]) under its own child "a" (path [0,0]).
|
||||
assert!(!root.move_subtree(&WidgetPath(vec![0]), &WidgetPath(vec![0, 0]), 0));
|
||||
// Can't move the root.
|
||||
assert!(!root.move_subtree(&WidgetPath::root(), &WidgetPath(vec![0]), 0));
|
||||
// Tree is unchanged.
|
||||
assert_eq!(ids_of(root.children()), ["col"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn widget_id_from_str_and_string() {
|
||||
let a: WidgetId = "abc".into();
|
||||
let b: WidgetId = String::from("abc").into();
|
||||
assert_eq!(a, b);
|
||||
assert_eq!(a.as_str(), "abc");
|
||||
assert!(!a.is_empty());
|
||||
assert!(WidgetId::default().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_widget_is_zero_leaf() {
|
||||
let w = Widget::default();
|
||||
assert_eq!(w.id, WidgetId::default());
|
||||
assert_eq!(w.style, LayoutStyle::default());
|
||||
assert!(matches!(w.kind, WidgetKind::Leaf { intrinsic } if intrinsic == Vec2::ZERO));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builder_methods_compose() {
|
||||
let w = Widget::row()
|
||||
.with_id("toolbar")
|
||||
.with_gap(4.0)
|
||||
.with_main_align(super::super::style::Align::Center)
|
||||
.with_child(Widget::leaf(Vec2::new(10.0, 10.0)).with_id("a"))
|
||||
.with_children([Widget::leaf(Vec2::new(20.0, 10.0)).with_id("b")]);
|
||||
assert_eq!(w.id.as_str(), "toolbar");
|
||||
let WidgetKind::Stack(s) = &w.kind else {
|
||||
panic!("expected stack");
|
||||
};
|
||||
assert_eq!(s.direction, StackDirection::Row);
|
||||
assert_eq!(s.gap, 4.0);
|
||||
assert_eq!(s.main_align, super::super::style::Align::Center);
|
||||
assert_eq!(s.children.len(), 2);
|
||||
assert_eq!(s.children[0].id.as_str(), "a");
|
||||
assert_eq!(s.children[1].id.as_str(), "b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "cannot add children to a Leaf widget")]
|
||||
fn adding_child_to_leaf_panics() {
|
||||
let _ = Widget::leaf(Vec2::new(1.0, 1.0)).with_child(Widget::leaf(Vec2::ONE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "with_gap is only valid on Stack widgets")]
|
||||
fn gap_on_non_stack_panics() {
|
||||
let _ = Widget::grid(2, 2).with_gap(4.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_count_recurses() {
|
||||
let tree = Widget::row()
|
||||
.with_child(Widget::leaf(Vec2::ONE))
|
||||
.with_child(
|
||||
Widget::column()
|
||||
.with_child(Widget::leaf(Vec2::ONE))
|
||||
.with_child(Widget::leaf(Vec2::ONE)),
|
||||
);
|
||||
// root + leaf + (column + 2 leaves) = 5
|
||||
assert_eq!(tree.node_count(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn widget_round_trips_through_ron() {
|
||||
let w = Widget::row()
|
||||
.with_id("root")
|
||||
.with_gap(8.0)
|
||||
.with_child(Widget::leaf(Vec2::new(40.0, 20.0)).with_id("a"))
|
||||
.with_child(Widget::anchor().with_child(Widget::leaf(Vec2::new(10.0, 10.0))));
|
||||
let text = ron::ser::to_string_pretty(&w, ron::ser::PrettyConfig::default()).unwrap();
|
||||
let decoded: Widget = ron::de::from_str(&text).unwrap();
|
||||
assert_eq!(w, decoded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visual_and_theme_style_builders_set_fields() {
|
||||
use super::super::visual::VisualStyle;
|
||||
use crate::math::Color;
|
||||
|
||||
let w = Widget::leaf(Vec2::ONE)
|
||||
.with_id("a")
|
||||
.with_visual(VisualStyle {
|
||||
background: Some(Color::RED),
|
||||
..VisualStyle::EMPTY
|
||||
})
|
||||
.with_theme_style("button");
|
||||
assert_eq!(w.visual.background, Some(Color::RED));
|
||||
assert_eq!(w.theme_style.as_deref(), Some("button"));
|
||||
|
||||
// Passing an empty string drops the reference.
|
||||
let cleared = w.clone().with_theme_style("");
|
||||
assert_eq!(cleared.theme_style, None);
|
||||
|
||||
let explicitly_cleared = w.clear_theme_style();
|
||||
assert_eq!(explicitly_cleared.theme_style, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_visual_cascades_theme_named_overrides() {
|
||||
use super::super::theme::Theme;
|
||||
use super::super::visual::VisualStyle;
|
||||
use crate::math::Color;
|
||||
|
||||
let theme = Theme::new()
|
||||
.with_default(VisualStyle {
|
||||
foreground: Some(Color::BLACK),
|
||||
background: Some(Color::WHITE),
|
||||
..VisualStyle::EMPTY
|
||||
})
|
||||
.with_style(
|
||||
"button",
|
||||
VisualStyle {
|
||||
background: Some(Color::rgb(0.85, 0.85, 0.9)),
|
||||
..VisualStyle::EMPTY
|
||||
},
|
||||
);
|
||||
let w = Widget::leaf(Vec2::ONE)
|
||||
.with_theme_style("button")
|
||||
.with_visual(VisualStyle {
|
||||
foreground: Some(Color::RED),
|
||||
..VisualStyle::EMPTY
|
||||
});
|
||||
let resolved = w.resolve_visual(&theme);
|
||||
assert_eq!(resolved.foreground, Some(Color::RED)); // per-instance
|
||||
assert_eq!(resolved.background, Some(Color::rgb(0.85, 0.85, 0.9))); // named
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn widget_with_visual_and_theme_style_round_trips_through_ron() {
|
||||
use super::super::visual::{FontRef, VisualStyle};
|
||||
use crate::math::Color;
|
||||
|
||||
let w = Widget::row()
|
||||
.with_id("toolbar")
|
||||
.with_theme_style("toolbar")
|
||||
.with_visual(VisualStyle {
|
||||
background: Some(Color::rgb(0.1, 0.1, 0.1)),
|
||||
font: Some(FontRef::bold("Inter")),
|
||||
..VisualStyle::EMPTY
|
||||
})
|
||||
.with_child(Widget::leaf(Vec2::new(40.0, 20.0)).with_theme_style("button"));
|
||||
let text = w.to_ron().unwrap();
|
||||
let decoded = Widget::from_ron(&text).unwrap();
|
||||
assert_eq!(w, decoded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_widget_serializes_without_new_fields() {
|
||||
// The new `visual` and `theme_style` fields skip when empty/None, so
|
||||
// a piece-1 default widget should still serialize to the piece-1
|
||||
// form (no `visual:` or `theme_style:` keys in the output).
|
||||
let w = Widget::default();
|
||||
let text = w.to_ron().unwrap();
|
||||
assert!(!text.contains("visual:"));
|
||||
assert!(!text.contains("theme_style:"));
|
||||
// And re-parsing yields the same value.
|
||||
assert_eq!(Widget::from_ron(&text).unwrap(), w);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_by_id_walks_the_subtree() {
|
||||
let tree = Widget::row()
|
||||
.with_id("root")
|
||||
.with_child(Widget::leaf(Vec2::ONE).with_id("a"))
|
||||
.with_child(
|
||||
Widget::column()
|
||||
.with_id("group")
|
||||
.with_child(Widget::leaf(Vec2::ONE).with_id("buried")),
|
||||
);
|
||||
assert_eq!(tree.find_by_id(&"root".into()).unwrap().id.as_str(), "root");
|
||||
assert_eq!(tree.find_by_id(&"a".into()).unwrap().id.as_str(), "a");
|
||||
assert_eq!(
|
||||
tree.find_by_id(&"buried".into()).unwrap().id.as_str(),
|
||||
"buried"
|
||||
);
|
||||
assert!(tree.find_by_id(&"missing".into()).is_none());
|
||||
// Empty id is never a match.
|
||||
assert!(tree.find_by_id(&WidgetId::default()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_value_updates_a_descendant() {
|
||||
let mut tree = Widget::row()
|
||||
.with_id("root")
|
||||
.with_child(Widget::leaf(Vec2::ONE).with_id("volume"))
|
||||
.with_child(Widget::leaf(Vec2::ONE).with_id("invert_y"));
|
||||
assert!(tree.set_value(&"volume".into(), 0.75_f32));
|
||||
assert!(tree.set_value(&"invert_y".into(), true));
|
||||
assert_eq!(
|
||||
tree.value(&"volume".into()).and_then(|v| v.as_float()),
|
||||
Some(0.75_f32 as f64)
|
||||
);
|
||||
assert_eq!(
|
||||
tree.value(&"invert_y".into()).and_then(|v| v.as_bool()),
|
||||
Some(true)
|
||||
);
|
||||
// Unknown id: returns false, tree unchanged.
|
||||
assert!(!tree.set_value(&"missing".into(), 0.0_f32));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_value_builder_sets_value() {
|
||||
let w = Widget::leaf(Vec2::ONE).with_id("checkbox").with_value(true);
|
||||
assert_eq!(w.value.as_ref().unwrap().as_bool(), Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_round_trips_through_widget_ron() {
|
||||
use super::super::value::WidgetValue;
|
||||
let w = Widget::leaf(Vec2::ONE)
|
||||
.with_id("slider")
|
||||
.with_value(WidgetValue::Float(0.42));
|
||||
let text = w.to_ron().unwrap();
|
||||
let decoded = Widget::from_ron(&text).unwrap();
|
||||
assert_eq!(w, decoded);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
//! File-watcher foundation.
|
||||
//!
|
||||
//! Watches directories — typically a [`Project`](crate::project::Project)'s
|
||||
//! `assets/`, `scenes/`, and `scripts/` folders — and emits **debounced**,
|
||||
//! **deduplicated** change events. Built on the `notify` crate.
|
||||
//!
|
||||
//! ## Why debounce
|
||||
//!
|
||||
//! Filesystem events are noisy: editors write files in several syscalls (write,
|
||||
//! rename, chmod), platforms report different fine-grained events for the same
|
||||
//! logical change, and recursive watches can re-emit while a directory is being
|
||||
//! populated. Forwarding every raw event to a reloader would re-parse assets
|
||||
//! many times for one user save. The watcher collapses bursts on each path into
|
||||
//! one event emitted after the path has been **quiet** for a configurable
|
||||
//! window.
|
||||
//!
|
||||
//! ## Layered design (testability)
|
||||
//!
|
||||
//! The debounce/coalesce logic lives in a **pure** [`Debouncer`] that takes
|
||||
//! `Instant`s from the caller, so unit tests verify it without touching real
|
||||
//! files or sleeping. [`FileWatcher`] wraps `notify` plus a worker thread that
|
||||
//! drives the debouncer with real time and forwards settled events through an
|
||||
//! mpsc channel. A tolerant integration smoke test covers the wiring.
|
||||
//!
|
||||
//! ## Asset reload wiring
|
||||
//!
|
||||
//! [`reload_changed_assets`] rereads any cached assets whose source path
|
||||
//! changed via [`AssetServer::reload_path`]. This is the groundwork for
|
||||
//! Stage-10 script hot-reload — same pattern, different reloader.
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use std::time::Duration;
|
||||
//! use oxide_engine::watch::{FileWatcher, reload_changed_assets};
|
||||
//! use oxide_engine::asset::AssetServer;
|
||||
//!
|
||||
//! let assets = AssetServer::new();
|
||||
//! let (mut watcher, events) = FileWatcher::new(Duration::from_millis(150))?;
|
||||
//! watcher.watch("path/to/project/assets")?;
|
||||
//!
|
||||
//! // Pump in the editor's per-frame tick:
|
||||
//! while let Ok(event) = events.try_recv() {
|
||||
//! reload_changed_assets(&assets, std::iter::once(event));
|
||||
//! }
|
||||
//! # Ok::<(), oxide_engine::watch::WatchError>(())
|
||||
//! ```
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::mpsc::{channel, Receiver};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread::JoinHandle;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use notify::{EventKind, RecommendedWatcher, RecursiveMode, Watcher};
|
||||
|
||||
use crate::asset::AssetServer;
|
||||
|
||||
/// Coarse classification of a filesystem change.
|
||||
///
|
||||
/// The fine-grained `notify::EventKind` variants are collapsed into three
|
||||
/// outcomes because every consumer downstream — asset reload, script reload,
|
||||
/// project-panel refresh — only needs to know "rerun the loader", "drop the
|
||||
/// entry", or "treat as new". Distinguishing a rename's two legs or an attr
|
||||
/// change from a content write does not change what to do.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum ChangeKind {
|
||||
/// A file or directory appeared at this path.
|
||||
Created,
|
||||
/// An existing file's contents (or a directory's set of children) changed.
|
||||
Modified,
|
||||
/// A file or directory was removed at this path.
|
||||
Removed,
|
||||
}
|
||||
|
||||
/// One settled change event for a single path.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct ChangeEvent {
|
||||
/// The path that changed (absolute when the underlying backend reports it
|
||||
/// as such — `notify` typically does on the platforms Oxide targets).
|
||||
pub path: PathBuf,
|
||||
/// What kind of change it was, after coalescing.
|
||||
pub kind: ChangeKind,
|
||||
}
|
||||
|
||||
/// Errors from the file-watcher subsystem.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum WatchError {
|
||||
/// The underlying `notify` backend failed (no inotify slots, path missing,
|
||||
/// permission denied, …).
|
||||
#[error("file-watcher backend error: {0}")]
|
||||
Backend(#[from] notify::Error),
|
||||
}
|
||||
|
||||
/// Pure debounce-and-coalesce core.
|
||||
///
|
||||
/// Holds the most recent change kind seen for each path plus the time it was
|
||||
/// last touched. [`drain_ready`](Self::drain_ready) emits an event for every
|
||||
/// path that has been quiet for at least `quiet_window` relative to a
|
||||
/// caller-supplied `now`. Because the caller controls `now`, tests can drive
|
||||
/// the debouncer through a deterministic timeline.
|
||||
pub struct Debouncer {
|
||||
quiet_window: Duration,
|
||||
pending: HashMap<PathBuf, (ChangeKind, Instant)>,
|
||||
}
|
||||
|
||||
impl Debouncer {
|
||||
/// A debouncer that emits a path's event once it has been quiet for at
|
||||
/// least `quiet_window`.
|
||||
pub fn new(quiet_window: Duration) -> Self {
|
||||
Self {
|
||||
quiet_window,
|
||||
pending: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The configured quiet window.
|
||||
pub fn quiet_window(&self) -> Duration {
|
||||
self.quiet_window
|
||||
}
|
||||
|
||||
/// Number of paths currently in the pending set.
|
||||
pub fn pending_len(&self) -> usize {
|
||||
self.pending.len()
|
||||
}
|
||||
|
||||
/// Records a raw change for `path` observed at `now`.
|
||||
///
|
||||
/// Coalescing rules (chosen to match what a downstream reloader cares
|
||||
/// about):
|
||||
/// - `Created` then `Modified` → `Created` (still a fresh file overall).
|
||||
/// - `Removed` then `Modified` → `Created` (a file came back at this path).
|
||||
/// - Otherwise the newer kind wins, including `Removed` superseding any
|
||||
/// prior `Created`/`Modified`.
|
||||
pub fn record(&mut self, path: PathBuf, kind: ChangeKind, now: Instant) {
|
||||
let promoted = match self.pending.get(&path).map(|(k, _)| *k) {
|
||||
Some(ChangeKind::Created) if kind == ChangeKind::Modified => ChangeKind::Created,
|
||||
Some(ChangeKind::Removed) if kind == ChangeKind::Modified => ChangeKind::Created,
|
||||
_ => kind,
|
||||
};
|
||||
self.pending.insert(path, (promoted, now));
|
||||
}
|
||||
|
||||
/// Removes and returns every event whose last update is at least
|
||||
/// `quiet_window` old relative to `now`. The returned vector is sorted by
|
||||
/// path so output is deterministic for testing and snapshotting.
|
||||
pub fn drain_ready(&mut self, now: Instant) -> Vec<ChangeEvent> {
|
||||
let mut ready: Vec<ChangeEvent> = Vec::new();
|
||||
self.pending.retain(|path, (kind, t)| {
|
||||
if now.saturating_duration_since(*t) >= self.quiet_window {
|
||||
ready.push(ChangeEvent {
|
||||
path: path.clone(),
|
||||
kind: *kind,
|
||||
});
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
ready.sort_by(|a, b| a.path.cmp(&b.path));
|
||||
ready
|
||||
}
|
||||
}
|
||||
|
||||
/// A directory watcher that emits debounced [`ChangeEvent`]s.
|
||||
///
|
||||
/// Construction returns the watcher plus the [`Receiver`] events arrive on.
|
||||
/// Add directories with [`watch`](Self::watch); remove them with
|
||||
/// [`unwatch`](Self::unwatch). Dropping the watcher stops the worker thread
|
||||
/// and disconnects the receiver.
|
||||
pub struct FileWatcher {
|
||||
/// Kept alive so its `Drop` releases the backend's OS watches.
|
||||
_backend: RecommendedWatcher,
|
||||
debouncer: Arc<Mutex<Debouncer>>,
|
||||
stop: Arc<AtomicBool>,
|
||||
worker: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl FileWatcher {
|
||||
/// Creates a watcher whose worker forwards settled events through the
|
||||
/// returned receiver. Paths are not watched until you call
|
||||
/// [`watch`](Self::watch).
|
||||
pub fn new(quiet_window: Duration) -> Result<(Self, Receiver<ChangeEvent>), WatchError> {
|
||||
let (raw_tx, raw_rx) = channel::<notify::Result<notify::Event>>();
|
||||
let backend = RecommendedWatcher::new(
|
||||
move |res| {
|
||||
// If the receiving end is gone we are tearing down; nothing to
|
||||
// do but drop the event.
|
||||
let _ = raw_tx.send(res);
|
||||
},
|
||||
notify::Config::default(),
|
||||
)?;
|
||||
|
||||
let debouncer = Arc::new(Mutex::new(Debouncer::new(quiet_window)));
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let (out_tx, out_rx) = channel::<ChangeEvent>();
|
||||
|
||||
// Drain the backend frequently enough that bursts settle within a few
|
||||
// ticks; quarter of the quiet window is short enough to be responsive
|
||||
// without busy-waiting.
|
||||
let tick = (quiet_window / 4).max(Duration::from_millis(10));
|
||||
let worker_debouncer = debouncer.clone();
|
||||
let worker_stop = stop.clone();
|
||||
let worker = std::thread::spawn(move || {
|
||||
while !worker_stop.load(Ordering::Relaxed) {
|
||||
match raw_rx.recv_timeout(tick) {
|
||||
Ok(Ok(event)) => {
|
||||
if let Some(kind) = classify(&event.kind) {
|
||||
let now = Instant::now();
|
||||
let mut d = worker_debouncer.lock().unwrap();
|
||||
for path in event.paths {
|
||||
d.record(path, kind, now);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Backend reported an error event; ignore but keep running.
|
||||
Ok(Err(_)) => {}
|
||||
// Tick elapsed with no new events. Fall through to drain.
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
|
||||
// Raw channel disconnected → backend dropped → we're done.
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
|
||||
}
|
||||
|
||||
let ready = worker_debouncer.lock().unwrap().drain_ready(Instant::now());
|
||||
for ev in ready {
|
||||
if out_tx.send(ev).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok((
|
||||
Self {
|
||||
_backend: backend,
|
||||
debouncer,
|
||||
stop,
|
||||
worker: Some(worker),
|
||||
},
|
||||
out_rx,
|
||||
))
|
||||
}
|
||||
|
||||
/// Recursively watches `path`. Repeated calls with the same path are
|
||||
/// equivalent to one call.
|
||||
pub fn watch(&mut self, path: impl AsRef<Path>) -> Result<(), WatchError> {
|
||||
self._backend
|
||||
.watch(path.as_ref(), RecursiveMode::Recursive)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stops watching `path`. Errors if the backend was not watching it.
|
||||
pub fn unwatch(&mut self, path: impl AsRef<Path>) -> Result<(), WatchError> {
|
||||
self._backend.unwatch(path.as_ref())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read-only snapshot of how many paths are currently buffered by the
|
||||
/// debouncer (haven't yet been quiet long enough to fire). Mostly for
|
||||
/// tests and diagnostics.
|
||||
pub fn pending_count(&self) -> usize {
|
||||
self.debouncer.lock().unwrap().pending_len()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for FileWatcher {
|
||||
fn drop(&mut self) {
|
||||
// Signal first so the worker exits its next loop iteration; dropping
|
||||
// the backend closes the raw channel as a secondary safety net.
|
||||
self.stop.store(true, Ordering::Relaxed);
|
||||
if let Some(h) = self.worker.take() {
|
||||
let _ = h.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Translates a `notify` event kind into our coarse [`ChangeKind`]. Returns
|
||||
/// `None` for events we deliberately ignore (e.g. access timestamps).
|
||||
fn classify(kind: &EventKind) -> Option<ChangeKind> {
|
||||
match kind {
|
||||
EventKind::Create(_) => Some(ChangeKind::Created),
|
||||
EventKind::Modify(_) => Some(ChangeKind::Modified),
|
||||
EventKind::Remove(_) => Some(ChangeKind::Removed),
|
||||
// Reads/opens don't change the file; skipping keeps the event stream
|
||||
// focused on "something to reload".
|
||||
EventKind::Access(_) => None,
|
||||
// `Any` is the fallback some backends emit for "something happened";
|
||||
// treat as Modified so a reloader still gets a chance.
|
||||
EventKind::Any => Some(ChangeKind::Modified),
|
||||
EventKind::Other => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Reruns the loader for every cached asset whose source path appears in
|
||||
/// `events` with a `Created` or `Modified` kind.
|
||||
///
|
||||
/// Returns the total number of asset entries reloaded. Paths that are not
|
||||
/// currently cached (no live handle) are silently ignored — there is nothing
|
||||
/// to reload, and the next [`AssetServer::load`] will pick up the new contents
|
||||
/// anyway. `Removed` events are ignored here too: the engine does not
|
||||
/// preemptively invalidate handles when the underlying file disappears,
|
||||
/// because gameplay code may want the last-loaded copy to keep working.
|
||||
pub fn reload_changed_assets<I>(server: &AssetServer, events: I) -> usize
|
||||
where
|
||||
I: IntoIterator<Item = ChangeEvent>,
|
||||
{
|
||||
let mut n = 0;
|
||||
for ev in events {
|
||||
if matches!(ev.kind, ChangeKind::Created | ChangeKind::Modified) {
|
||||
n += server.reload_path(&ev.path);
|
||||
}
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn p(name: &str) -> PathBuf {
|
||||
PathBuf::from(name)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dedupes_a_burst_for_one_path() {
|
||||
let mut d = Debouncer::new(Duration::from_millis(100));
|
||||
let t0 = Instant::now();
|
||||
d.record(p("a"), ChangeKind::Modified, t0);
|
||||
d.record(p("a"), ChangeKind::Modified, t0 + Duration::from_millis(10));
|
||||
d.record(p("a"), ChangeKind::Modified, t0 + Duration::from_millis(30));
|
||||
// Path is still "hot" — nothing should fire yet.
|
||||
assert!(d.drain_ready(t0 + Duration::from_millis(80)).is_empty());
|
||||
// After the quiet window elapses since the last touch, one event fires.
|
||||
let ready = d.drain_ready(t0 + Duration::from_millis(130) + Duration::from_millis(10));
|
||||
assert_eq!(ready.len(), 1);
|
||||
assert_eq!(ready[0].path, p("a"));
|
||||
assert_eq!(ready[0].kind, ChangeKind::Modified);
|
||||
// And the pending set is empty afterwards.
|
||||
assert_eq!(d.pending_len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_path_settles_independently() {
|
||||
let mut d = Debouncer::new(Duration::from_millis(50));
|
||||
let t0 = Instant::now();
|
||||
d.record(p("a"), ChangeKind::Modified, t0);
|
||||
d.record(p("b"), ChangeKind::Created, t0 + Duration::from_millis(30));
|
||||
// At t0+60: "a" is quiet for 60ms (≥ 50ms) but "b" is only 30ms quiet.
|
||||
let ready = d.drain_ready(t0 + Duration::from_millis(60));
|
||||
assert_eq!(ready.len(), 1);
|
||||
assert_eq!(ready[0].path, p("a"));
|
||||
assert_eq!(d.pending_len(), 1);
|
||||
// At t0+90: "b" has been quiet for 60ms and now fires.
|
||||
let ready = d.drain_ready(t0 + Duration::from_millis(90));
|
||||
assert_eq!(ready.len(), 1);
|
||||
assert_eq!(ready[0].path, p("b"));
|
||||
assert_eq!(ready[0].kind, ChangeKind::Created);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drain_output_is_sorted_by_path() {
|
||||
let mut d = Debouncer::new(Duration::from_millis(10));
|
||||
let t0 = Instant::now();
|
||||
d.record(p("zeta"), ChangeKind::Modified, t0);
|
||||
d.record(p("alpha"), ChangeKind::Modified, t0);
|
||||
d.record(p("mid"), ChangeKind::Modified, t0);
|
||||
let ready = d.drain_ready(t0 + Duration::from_millis(20));
|
||||
assert_eq!(
|
||||
ready.iter().map(|e| e.path.clone()).collect::<Vec<_>>(),
|
||||
vec![p("alpha"), p("mid"), p("zeta")]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn created_then_modified_stays_created() {
|
||||
let mut d = Debouncer::new(Duration::from_millis(10));
|
||||
let t0 = Instant::now();
|
||||
d.record(p("a"), ChangeKind::Created, t0);
|
||||
d.record(p("a"), ChangeKind::Modified, t0 + Duration::from_millis(2));
|
||||
let ready = d.drain_ready(t0 + Duration::from_millis(20));
|
||||
assert_eq!(ready.len(), 1);
|
||||
assert_eq!(ready[0].kind, ChangeKind::Created);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removed_then_modified_becomes_created() {
|
||||
// A file is deleted, then a new file appears at the same path (e.g.
|
||||
// editors that save by atomic-replace). Downstream wants to treat this
|
||||
// as a fresh asset, not a missing one.
|
||||
let mut d = Debouncer::new(Duration::from_millis(10));
|
||||
let t0 = Instant::now();
|
||||
d.record(p("a"), ChangeKind::Removed, t0);
|
||||
d.record(p("a"), ChangeKind::Modified, t0 + Duration::from_millis(2));
|
||||
let ready = d.drain_ready(t0 + Duration::from_millis(20));
|
||||
assert_eq!(ready.len(), 1);
|
||||
assert_eq!(ready[0].kind, ChangeKind::Created);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removed_supersedes_prior_kinds() {
|
||||
let mut d = Debouncer::new(Duration::from_millis(10));
|
||||
let t0 = Instant::now();
|
||||
d.record(p("a"), ChangeKind::Created, t0);
|
||||
d.record(p("a"), ChangeKind::Modified, t0 + Duration::from_millis(1));
|
||||
d.record(p("a"), ChangeKind::Removed, t0 + Duration::from_millis(2));
|
||||
let ready = d.drain_ready(t0 + Duration::from_millis(20));
|
||||
assert_eq!(ready.len(), 1);
|
||||
assert_eq!(ready[0].kind, ChangeKind::Removed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_covers_the_three_main_kinds() {
|
||||
use notify::event::{CreateKind, ModifyKind, RemoveKind};
|
||||
assert_eq!(
|
||||
classify(&EventKind::Create(CreateKind::File)),
|
||||
Some(ChangeKind::Created)
|
||||
);
|
||||
assert_eq!(
|
||||
classify(&EventKind::Modify(ModifyKind::Any)),
|
||||
Some(ChangeKind::Modified)
|
||||
);
|
||||
assert_eq!(
|
||||
classify(&EventKind::Remove(RemoveKind::File)),
|
||||
Some(ChangeKind::Removed)
|
||||
);
|
||||
assert_eq!(classify(&EventKind::Any), Some(ChangeKind::Modified));
|
||||
}
|
||||
|
||||
/// Tolerant smoke test: write a file under a temp dir, then poll for an
|
||||
/// event with a generous timeout. The unit tests above already cover the
|
||||
/// debounce logic deterministically, so this only needs to prove the
|
||||
/// notify→debouncer→channel wiring is connected.
|
||||
#[test]
|
||||
fn end_to_end_emits_on_real_filesystem_change() {
|
||||
let mut dir = std::env::temp_dir();
|
||||
dir.push(format!("oxide_watch_smoke_{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
|
||||
let (mut watcher, events) =
|
||||
FileWatcher::new(Duration::from_millis(80)).expect("create watcher");
|
||||
watcher.watch(&dir).expect("watch tempdir");
|
||||
|
||||
// Some platforms need a brief moment between watch() and producing
|
||||
// events for fresh writes; the deadline below absorbs that.
|
||||
let file = dir.join("hello.txt");
|
||||
std::fs::write(&file, "first").unwrap();
|
||||
// Touch a couple more times to exercise dedup under real timing.
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
std::fs::write(&file, "second").unwrap();
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
std::fs::write(&file, "third").unwrap();
|
||||
|
||||
// Wait up to 3 seconds for at least one event for our file. This is
|
||||
// intentionally generous: CI machines under load and macOS FSEvents
|
||||
// can take a second or more to deliver the first event.
|
||||
let deadline = Instant::now() + Duration::from_secs(3);
|
||||
let mut saw = None;
|
||||
while Instant::now() < deadline {
|
||||
if let Ok(ev) = events.recv_timeout(Duration::from_millis(100)) {
|
||||
// Some backends report a canonicalized path; compare by file
|
||||
// name to stay robust to that.
|
||||
if ev.path.file_name() == Some(std::ffi::OsStr::new("hello.txt")) {
|
||||
saw = Some(ev);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
if saw.is_none() {
|
||||
// Some sandboxes (containerized CI) disable filesystem-event
|
||||
// backends entirely; skip rather than fail flakily there.
|
||||
eprintln!("SKIP: no inotify/FSEvent backend appears to deliver events here");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
//! The [`WindowApp`] trait and per-callback context.
|
||||
|
||||
use winit::event::WindowEvent;
|
||||
use winit::window::Window;
|
||||
|
||||
use crate::input::InputState;
|
||||
use crate::math::Color;
|
||||
use crate::render::{Gpu, RenderContext};
|
||||
|
||||
/// An application driven by the engine's event loop.
|
||||
///
|
||||
/// Implement this and pass the value to [`run`](super::run). All methods have
|
||||
/// empty defaults so minimal apps only override what they need. Per frame the
|
||||
/// engine calls [`event`](Self::event) for each pending window event, then
|
||||
/// [`update`](Self::update), then clears and presents the surface.
|
||||
///
|
||||
/// This trait is the **window-event handler** — the per-frame plumbing between
|
||||
/// `winit` and a renderer. It is distinct from the engine's
|
||||
/// [`App`](crate::app::App) **container**, which owns the scene, assets, and
|
||||
/// scheduled systems. The editor's main loop typically implements this trait
|
||||
/// on a struct that *also* owns an `oxide_engine::app::App`.
|
||||
pub trait WindowApp {
|
||||
/// Called once, after the window and GPU context exist but before the
|
||||
/// first frame.
|
||||
fn init(&mut self, ctx: &mut AppCtx<'_>) {
|
||||
let _ = ctx;
|
||||
}
|
||||
|
||||
/// Called for every raw window event (keyboard, mouse, resize, focus, …).
|
||||
///
|
||||
/// Events the engine itself reacts to (close request, resize) are still
|
||||
/// forwarded here afterwards, so apps observe everything.
|
||||
fn event(&mut self, ctx: &mut AppCtx<'_>, event: &WindowEvent) {
|
||||
let _ = (ctx, event);
|
||||
}
|
||||
|
||||
/// Called once per frame, before the frame is rendered.
|
||||
fn update(&mut self, ctx: &mut AppCtx<'_>) {
|
||||
let _ = ctx;
|
||||
}
|
||||
|
||||
/// Called each frame after the surface has been cleared and before it is
|
||||
/// presented, so the app can record its own draw commands into the frame.
|
||||
///
|
||||
/// This is the hook editor/overlay UI (egui) and, in later stages, the
|
||||
/// scene renderer draw through. The surface is cleared with
|
||||
/// [`LoadOp::Clear`](wgpu::LoadOp::Clear) *before* this runs; record passes
|
||||
/// here with [`LoadOp::Load`](wgpu::LoadOp::Load) to draw on top of the
|
||||
/// clear color rather than wiping it.
|
||||
fn render(&mut self, ctx: &RenderCtx<'_>) {
|
||||
let _ = ctx;
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-frame rendering context passed to [`WindowApp::render`].
|
||||
///
|
||||
/// Unlike [`AppCtx`], this borrows the GPU and surface immutably: by the time
|
||||
/// the draw hook runs the frame's surface texture is already acquired, so the
|
||||
/// app receives the handles it needs to record additional passes into
|
||||
/// [`view`](Self::view) without re-entering the render context.
|
||||
pub struct RenderCtx<'a> {
|
||||
/// The GPU device/queue to record and submit commands with.
|
||||
pub gpu: &'a Gpu,
|
||||
/// The current frame's surface texture view (the render target).
|
||||
pub view: &'a wgpu::TextureView,
|
||||
/// The window being rendered, e.g. for input/UI integration that needs it.
|
||||
pub window: &'a Window,
|
||||
/// The surface's texture format, needed to build matching pipelines.
|
||||
pub surface_format: wgpu::TextureFormat,
|
||||
/// Surface size in physical pixels (`width`, `height`).
|
||||
pub size: (u32, u32),
|
||||
}
|
||||
|
||||
/// Engine state handed to every [`WindowApp`] callback.
|
||||
pub struct AppCtx<'a> {
|
||||
pub(crate) render: &'a mut RenderContext,
|
||||
pub(crate) window: &'a Window,
|
||||
pub(crate) exit: &'a mut bool,
|
||||
pub(crate) input: &'a InputState,
|
||||
/// Seconds elapsed since the previous frame (`0.0` during
|
||||
/// [`App::init`] and the first frame).
|
||||
pub dt: f32,
|
||||
}
|
||||
|
||||
impl AppCtx<'_> {
|
||||
/// The render context driving the window surface.
|
||||
pub fn render(&mut self) -> &mut RenderContext {
|
||||
self.render
|
||||
}
|
||||
|
||||
/// Sets the color the surface is cleared to, effective next frame.
|
||||
pub fn set_clear_color(&mut self, color: Color) {
|
||||
self.render.set_clear_color(color);
|
||||
}
|
||||
|
||||
/// The current clear color.
|
||||
pub fn clear_color(&self) -> Color {
|
||||
self.render.clear_color()
|
||||
}
|
||||
|
||||
/// Current surface size in physical pixels.
|
||||
pub fn size(&self) -> (u32, u32) {
|
||||
self.render.size()
|
||||
}
|
||||
|
||||
/// Sets the window title.
|
||||
pub fn set_title(&self, title: &str) {
|
||||
self.window.set_title(title);
|
||||
}
|
||||
|
||||
/// The window being driven, e.g. to construct UI/input integration that
|
||||
/// needs a window handle.
|
||||
pub fn window(&self) -> &Window {
|
||||
self.window
|
||||
}
|
||||
|
||||
/// Asks the event loop to exit after the current callback returns.
|
||||
pub fn request_exit(&mut self) {
|
||||
*self.exit = true;
|
||||
}
|
||||
|
||||
/// The per-frame input snapshot.
|
||||
///
|
||||
/// Reflects every keyboard / mouse / scroll event delivered since the
|
||||
/// previous frame's `update` returned. In [`WindowApp::event`] callbacks
|
||||
/// it includes the event currently being delivered (the runner pumps it
|
||||
/// before invoking the callback). In [`WindowApp::update`] it is the
|
||||
/// accumulated state for the new frame; the runner clears edges (pressed/
|
||||
/// released, mouse delta, scroll) automatically after `update` returns.
|
||||
pub fn input(&self) -> &InputState {
|
||||
self.input
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
//! Windowing and the application event loop.
|
||||
//!
|
||||
//! Stage 2 scope: open a window via `winit`, hand its surface to the
|
||||
//! [`render`](crate::render) module, and run a clear-color render loop.
|
||||
//! Applications implement [`WindowApp`] and are driven by [`run`]; raw window
|
||||
//! events (keyboard, mouse, resize, …) are forwarded to
|
||||
//! [`WindowApp::event`] untranslated — input abstraction arrives in Stage 5.
|
||||
//!
|
||||
//! Stage 6 renamed this trait from `App` to `WindowApp` so the engine's core
|
||||
//! [`App`](crate::app::App) container — the owner of scene, assets, and
|
||||
//! scheduled systems — can live in the prelude unambiguously. The two are
|
||||
//! distinct roles: this trait is the **window-event handler** the editor and
|
||||
//! examples implement; the core `App` is the engine state they typically wrap
|
||||
//! around.
|
||||
|
||||
mod app;
|
||||
mod runner;
|
||||
|
||||
pub use app::{AppCtx, RenderCtx, WindowApp};
|
||||
pub use runner::run;
|
||||
|
||||
pub mod event {
|
||||
//! Raw window/input event types, re-exported from `winit`.
|
||||
//!
|
||||
//! Stage 2 deliberately exposes events untranslated; the Stage 5 input
|
||||
//! system will layer action mapping on top of these.
|
||||
pub use winit::dpi::{PhysicalPosition, PhysicalSize};
|
||||
pub use winit::event::{
|
||||
DeviceEvent, DeviceId, ElementState, KeyEvent, Modifiers, MouseButton, MouseScrollDelta,
|
||||
WindowEvent,
|
||||
};
|
||||
pub use winit::keyboard::{Key, KeyCode, ModifiersState, NamedKey, PhysicalKey};
|
||||
}
|
||||
|
||||
use crate::math::Color;
|
||||
|
||||
/// Initial window settings, consumed by [`run`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WindowConfig {
|
||||
/// Window title.
|
||||
pub title: String,
|
||||
/// Initial inner width in logical pixels.
|
||||
pub width: u32,
|
||||
/// Initial inner height in logical pixels.
|
||||
pub height: u32,
|
||||
/// Whether the user can resize the window.
|
||||
pub resizable: bool,
|
||||
/// Color the surface is cleared to each frame (changeable at runtime via
|
||||
/// [`AppCtx::set_clear_color`]).
|
||||
pub clear_color: Color,
|
||||
}
|
||||
|
||||
impl Default for WindowConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
title: "Oxide".to_string(),
|
||||
width: 1280,
|
||||
height: 720,
|
||||
resizable: true,
|
||||
clear_color: Color::BLACK,
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user