Files
Oxide/editor/src/state.rs
T
Homer Simpson 9eead719b0 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

594 lines
25 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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());
}
}