Compare commits

...

7 Commits

Author SHA1 Message Date
Homer bad780de9d Editor: Unity-style file explorer in the Project panel
The last item of the Stage-10 editor-UX batch. The Project panel's
fixed typed-folder listing becomes a real file explorer over assets/:

- breadcrumbs + double-click folder navigation, " New Folder", inline
  rename rows, context menus (Open / Rename / Delete), drag a row onto
  a folder (or "..") to move it, drag files in from the OS to import
  into the current folder, double-click to open (scripts via the
  external-editor flow, others via xdg-open).
- All behavior lives egui-free in editor/src/explorer.rs (listing,
  breadcrumbs, name validation/uniquing, create/rename/move/delete/
  import) and is unit-tested; the shell only renders it. Renames and
  moves ride the uid-preserving AssetDatabase ops so saved AssetRefs
  keep resolving; unregistered files fall back to fs::rename. Folders
  delete only when empty — no recursive asset deletion.
- Engine: AssetDatabase::scan now walks the WHOLE assets/ tree instead
  of just the typed folders, so assets organised into custom folders
  register and survive rescans (covered by updated unit tests).

File operations act immediately and bypass the undo stack, like the
hierarchy's structural edits. GUI piece — needs an eye-check before
promotion to main.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 21:01:05 +02:00
Homer 3c59faa506 Editor: native folder picker for New/Open Project
Stage-10 editor-UX follow-up ("typing the path by hand is very hard to
use"). Both project dialogs gain a Browse… button that opens the native
folder picker via rfd's xdg-portal backend — pure Rust, one build works
on Wayland and X11 through xdg-desktop-portal. The dialog runs on a
helper thread reporting over an mpsc channel (polled once per frame in
Shell::build), so the editor keeps rendering while the picker is up;
one pick at a time, and the typed path field remains as a fallback for
portal-less environments.

GUI piece — needs an eye-check (on both Wayland and Xorg) before
promotion to main.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 20:47:12 +02:00
Homer 608898411a Editor: open scripts in an external editor
Stage-10 editor-UX follow-up, second half of in-editor script authoring:

- "✏ Edit" button on the Script inspector section (enabled once a
  source is assigned) and double-click on any script in the Project
  panel open the .rhai in the user's editor. Non-script assets
  double-click through xdg-open.
- Resolution order: the new External Editor preference command (spawned
  detached as `<command> <file>`, flags allowed), else $VISUAL/$EDITOR
  in a new Terminal-panel tab (TUI editors work in-editor via the
  existing PTY widget), else xdg-open.
- New `editor.external_editor` settings section (ExternalEditorPrefs)
  registered by EditorState, persisted with the preferences file, with
  a rich row in the Preferences window; registration covered by a unit
  test. Editor crate now depends on serde directly for its own
  sections.

Saved edits flow back through the file watcher's live reload, including
into a playing scene. GUI piece — needs an eye-check before promotion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 20:41:37 +02:00
Homer fd25677631 Editor: "New Script" button on the Script inspector
Stage-10 editor-UX follow-up. The Script component's inspector section
now ends with a name field + " New Script" button (Enter in the field
also submits): it writes a fresh .rhai from the tested template into
assets/scripts/ via create_script_file (sanitized stem, collisions
suffixed), registers it in the asset database, saves the manifest, and
assigns it to the component's `source` through the normal edit path —
so the assignment is one undoable SetFieldCmd. Disabled with a hint
while no project is open; failures land in the Console via the log.

GUI piece — needs an eye-check before promotion to main.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 20:33:23 +02:00
Homer 1f3a034215 Asset database file ops + script-file creation helpers
Groundwork for the Stage-10 editor-UX batch (file explorer, New Script
button), all pure logic proven by unit tests:

- `AssetDatabase::move_asset` / `move_folder` / `delete_asset`: perform
  the disk operation and the uid-map update together, so renaming or
  moving an asset (or a whole folder) keeps its uid and every saved
  `AssetRef` resolving. Kinds re-classify from the new path; `..` and
  existing destinations are refused via the new `AssetDbError` enum;
  deleted uids are never reused.
- Editor `assets.rs`: `script_template` (compiles under the sandboxed
  ScriptEngine — covered by a test), `sanitize_script_stem`, and
  `create_script_file` which writes a collision-free
  `assets/scripts/<stem>.rhai` and returns the relative path for
  database registration.
- docs/assets.md: new "File operations" section + the missing Script
  row in the typed-folder table.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 20:29:08 +02:00
Homer b13e40770e Fix float-literal fallback lints for Rust 1.97
rustc 1.97 promotes the float-literal type-fallback check on generic
`impl Into<f32>` arguments (rust#154024) to a denied lint, which breaks
the editor's `#![deny(warnings)]` build: every bare literal passed to
`egui::Stroke::new` now needs an explicit type. Suffix the eleven
affected literals with `_f32`; no behavior change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 20:29:08 +02:00
Homer Simpson f56a1eea3b Import Oxide engine (Stages 0–10) under MIT license
Full project snapshot migrated to new Gitea remote without history:
engine, editor, physics, script, examples, tests, docs, and assets.
Relicensed from GPLv3 to MIT and updated repo URLs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 20:41:02 +02:00
129 changed files with 42096 additions and 2 deletions
+32
View File
@@ -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
View File
@@ -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
+251 -2
View File
@@ -1,3 +1,252 @@
# Oxide # Oxide Engine
Oxide general purpose 3D game engine ![Oxide Engine](assets/oxide.png)
> **Notice:** This project was developed with the assistance of [Claude Code](https://claude.ai/code) (Anthropic's AI coding assistant).
> All generated code, configuration, and documentation has been reviewed and tested by the author.
> Claude Code was used as a development tool; all design decisions, requirements, and sign-offs are the author's own.
A general-purpose 3D game engine written in Rust — built to make **any** 3D game, scaling from
stylized low-poly to realistic graphics, and shipping only what each game uses.
---
## Status
Oxide is built in two phases (see [PLAN.md](PLAN.md)): **Phase 1 (Stages 016)**
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 09 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 13)
- `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 46)
- `oxide_engine::ui` — in-game UI system (Stage-8 pieces 16): `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 (15/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.
+92
View File
@@ -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.
+93
View File
@@ -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"
}
]
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

+40
View File
@@ -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"
+248
View File
@@ -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");
}
}
+118
View File
@@ -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)]
);
}
}
+335
View File
@@ -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
}
}
+415
View File
@@ -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: &registry (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"));
}
}
+172
View File
@@ -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());
}
}
+140
View File
@@ -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())),
);
}
}
+524
View File
@@ -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();
}
}
+655
View File
@@ -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");
}
}
+886
View File
@@ -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);
}
}
+27
View File
@@ -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;
+720
View File
@@ -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())
}
+69
View File
@@ -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);
}
}
+146
View File
@@ -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);
}
}
+312
View File
@@ -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 0x010x1A — 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
View File
File diff suppressed because it is too large Load Diff
+621
View File
@@ -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)"
);
}
}
+105
View File
@@ -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,
}
}
}
+451
View File
@@ -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)))
}
+16
View File
@@ -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
+257
View File
@@ -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()
}
+41
View File
@@ -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
+31
View File
@@ -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);
+58
View File
@@ -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);
+57
View File
@@ -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);
+503
View File
@@ -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);
}
}
+165
View File
@@ -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"));
}
}
+146
View File
@@ -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
+215
View File
@@ -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(),
}
}
+199
View File
@@ -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()
}
}
+33
View File
@@ -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);
}
+508
View File
@@ -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
+212
View File
@@ -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);
}
}
+147
View File
@@ -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);
}
}
+72
View File
@@ -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;
+472
View File
@@ -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]));
}
}
+174
View File
@@ -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);
}
}
+115
View File
@@ -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(&reg).unwrap();
let back: GroupRegistry = ron::from_str(&ron).unwrap();
assert_eq!(reg, back);
}
}
+287
View File
@@ -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);
}
}
+42
View File
@@ -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;
+163
View File
@@ -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(&reg).unwrap();
let back: LayerRegistry = ron::from_str(&ron).unwrap();
assert_eq!(reg, back);
}
}
+81
View File
@@ -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};
}
+285
View File
@@ -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);
}
}
+194
View File
@@ -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);
}
}
+154
View File
@@ -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 GribbHartmann 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)));
}
}
+39
View File
@@ -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};
+154
View File
@@ -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);
}
}
+162
View File
@@ -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)));
}
}
+110
View File
@@ -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);
}
}
+205
View File
@@ -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)));
}
}
+372
View File
@@ -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));
}
}
+311
View File
@@ -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);
}
}
+399
View File
@@ -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
+142
View File
@@ -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.11000 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
);
}
}
+213
View File
@@ -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(())
}
+435
View File
@@ -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
}
+94
View File
@@ -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
}
}
+51
View File
@@ -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),
}
}
}
+256
View File
@@ -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,
}
+111
View File
@@ -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,
}
}
+233
View File
@@ -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,
);
}
}
+134
View File
@@ -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);
}
}
+69
View File
@@ -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);
}
+47
View File
@@ -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
+95
View File
@@ -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);
}
}
+722
View File
@@ -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);
}
}
+56
View File
@@ -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),
}
+46
View File
@@ -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)
}
}
+242
View File
@@ -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());
}
}
+377
View File
@@ -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(&reg);
let restored = snap.restore(&reg).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(&reg));
}
#[test]
fn restore_reinstates_a_modular_component() {
let reg = registry();
let scene = sample_scene();
let snap = scene.snapshot(&reg);
let restored = snap.restore(&reg).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(&reg);
// 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(&reg));
let reverted = snap.restore(&reg).unwrap();
assert_eq!(snap, reverted.snapshot(&reg));
}
#[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(&reg);
let restored = snap.restore(&reg).unwrap();
// Both engine-intrinsic, non-reflected components survive the round-trip
// even though neither is in the registry.
assert_eq!(snap, restored.snapshot(&reg));
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(&reg);
let ron = snap.to_ron().unwrap();
assert_eq!(snap, SceneSnapshot::from_ron(&ron).unwrap());
}
}
+273
View File
@@ -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"));
}
}
+763
View File
@@ -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()]);
}
}
+88
View File
@@ -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 79 — 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,
};
+319
View File
@@ -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, &params, 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());
}
}
+231
View File
@@ -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:?}"
);
}
}
+642
View File
@@ -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
}
}
+354
View File
@@ -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);
}
}
+426
View File
@@ -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");
}
}
+429
View File
@@ -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");
}
}
}
+60
View File
@@ -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,
};
+726
View File
@@ -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
/// 56.
#[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, &params, &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);
}
}
+217
View File
@@ -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"));
}
}
+158
View File
@@ -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);
}
}
}
+324
View File
@@ -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` 100900) 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);
}
}
+933
View File
@@ -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);
}
}
+477
View File
@@ -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");
}
}
}
+133
View File
@@ -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
}
}
+63
View File
@@ -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