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>
This commit is contained in:
Homer Simpson
2026-07-05 20:41:02 +02:00
parent 2afb56b329
commit 9eead719b0
157 changed files with 47270 additions and 2 deletions
+593
View File
@@ -0,0 +1,593 @@
//! The editor's mutable runtime state.
//!
//! Split out from the shell so [commands](crate::commands) can mutate exactly
//! the data that participates in undo/redo without taking a borrow of the
//! whole shell (which also owns dock layout, dialog flags, and UI buffers).
//!
//! `EditorState` is the `C` parameter every editor `Command<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()
}
}
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);
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());
}
}