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:
@@ -0,0 +1,34 @@
|
||||
[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
|
||||
# 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
|
||||
|
||||
# PTY-backed terminal panel (Stage 10): run interactive/TUI programs — shells,
|
||||
# REPLs, and AI-agent CLIs like `claude` — inside the editor. `portable-pty`
|
||||
# opens a real pseudo-terminal (cross-platform: Linux now, Windows later);
|
||||
# `vt100` parses the program's byte stream into a screen grid the panel renders.
|
||||
portable-pty = "0.9"
|
||||
vt100 = "0.16"
|
||||
@@ -0,0 +1,127 @@
|
||||
//! Bundled editor assets — locating the shared `assets/` tree and seeding a
|
||||
//! new project's default content (currently the default UI font).
|
||||
//!
|
||||
//! 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)
|
||||
}
|
||||
|
||||
#[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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
//! Default editor input bindings + the action-name constants the bindings
|
||||
//! UI and any debug overlay address.
|
||||
//!
|
||||
//! Living in the editor library (not the binary) so the Stage-7
|
||||
//! [`InputBindings`](crate::shell::Shell) preferences page and any future
|
||||
//! editor module can re-register or remap the same actions without
|
||||
//! depending on the binary's private module.
|
||||
|
||||
use oxide_engine::input::{ActionMap, AxisBinding, Binding};
|
||||
use oxide_engine::winit::keyboard::KeyCode;
|
||||
|
||||
/// The settings-section name under which the editor's
|
||||
/// [`ActionOverrides`](oxide_engine::input::ActionOverrides) are persisted.
|
||||
///
|
||||
/// The shell registers this section automatically in
|
||||
/// [`EditorState::new`](crate::state::EditorState::new); UI code that wants
|
||||
/// to refresh the section after a binding change addresses it by this name.
|
||||
pub const SETTINGS_SECTION: &str = "input.bindings";
|
||||
|
||||
/// Stable action names addressed throughout the editor — the bindings
|
||||
/// preferences page, the camera input poll in the runner, and any future
|
||||
/// debug overlay all reference these strings.
|
||||
pub mod action {
|
||||
/// Button: toggle between orbit and flythrough viewport cameras.
|
||||
pub const TOGGLE_FLYTHROUGH: &str = "editor.camera.toggle_flythrough";
|
||||
/// Button (held): accelerate flythrough translation while engaged.
|
||||
pub const SPRINT: &str = "editor.camera.sprint";
|
||||
/// 1D axis: strafe right (+) / strafe left (−) in flythrough mode.
|
||||
pub const MOVE_RIGHT: &str = "editor.camera.move_right";
|
||||
/// 1D axis: forward (+) / back (−) in flythrough mode.
|
||||
pub const MOVE_FORWARD: &str = "editor.camera.move_forward";
|
||||
/// 1D axis: ascend (+) / descend (−) in flythrough mode.
|
||||
pub const MOVE_UP: &str = "editor.camera.move_up";
|
||||
|
||||
/// Button: switch the transform gizmo to Translate mode (orbit camera only).
|
||||
pub const GIZMO_TRANSLATE: &str = "editor.gizmo.translate";
|
||||
/// Button: switch the transform gizmo to Rotate mode (orbit camera only).
|
||||
pub const GIZMO_ROTATE: &str = "editor.gizmo.rotate";
|
||||
/// Button: switch the transform gizmo to Scale mode (orbit camera only).
|
||||
pub const GIZMO_SCALE: &str = "editor.gizmo.scale";
|
||||
}
|
||||
|
||||
/// Registers the editor's default action set on `actions`. Defaults follow
|
||||
/// the DCC-tools convention (WASD + QE, Shift sprint, F toggles the
|
||||
/// flythrough camera) so users coming from Blender / Maya / Unity feel at
|
||||
/// home.
|
||||
///
|
||||
/// Idempotent on the action names — re-registering preserves any user-
|
||||
/// remapped current bindings while refreshing the defaults that the
|
||||
/// "Restore defaults" button reverts to.
|
||||
pub fn register_defaults(actions: &mut ActionMap) {
|
||||
actions
|
||||
.register(action::TOGGLE_FLYTHROUGH, [Binding::Key(KeyCode::KeyF)])
|
||||
.register(
|
||||
action::SPRINT,
|
||||
[
|
||||
Binding::Key(KeyCode::ShiftLeft),
|
||||
Binding::Key(KeyCode::ShiftRight),
|
||||
],
|
||||
)
|
||||
.register_axis(
|
||||
action::MOVE_RIGHT,
|
||||
AxisBinding::new([Binding::Key(KeyCode::KeyD)], [Binding::Key(KeyCode::KeyA)]),
|
||||
)
|
||||
.register_axis(
|
||||
action::MOVE_FORWARD,
|
||||
AxisBinding::new([Binding::Key(KeyCode::KeyW)], [Binding::Key(KeyCode::KeyS)]),
|
||||
)
|
||||
.register_axis(
|
||||
action::MOVE_UP,
|
||||
AxisBinding::new([Binding::Key(KeyCode::KeyE)], [Binding::Key(KeyCode::KeyQ)]),
|
||||
)
|
||||
// Gizmo tool hotkeys (W/E/R). These share physical keys with
|
||||
// flythrough movement, so the host gates them on the camera being
|
||||
// in orbit mode — in flythrough W/E move the camera, in orbit
|
||||
// they switch the gizmo tool.
|
||||
.register(action::GIZMO_TRANSLATE, [Binding::Key(KeyCode::KeyW)])
|
||||
.register(action::GIZMO_ROTATE, [Binding::Key(KeyCode::KeyE)])
|
||||
.register(action::GIZMO_SCALE, [Binding::Key(KeyCode::KeyR)]);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn defaults_register_every_advertised_action() {
|
||||
let mut actions = ActionMap::new();
|
||||
register_defaults(&mut actions);
|
||||
|
||||
assert!(actions.has(action::TOGGLE_FLYTHROUGH));
|
||||
assert!(actions.has(action::SPRINT));
|
||||
assert!(actions.has_axis(action::MOVE_RIGHT));
|
||||
assert!(actions.has_axis(action::MOVE_FORWARD));
|
||||
assert!(actions.has_axis(action::MOVE_UP));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_are_idempotent_and_preserve_remaps() {
|
||||
let mut actions = ActionMap::new();
|
||||
register_defaults(&mut actions);
|
||||
|
||||
// User remaps Toggle to Tab.
|
||||
actions.set_bindings(action::TOGGLE_FLYTHROUGH, vec![Binding::Key(KeyCode::Tab)]);
|
||||
|
||||
// Re-running register_defaults must not stomp the user's remap.
|
||||
register_defaults(&mut actions);
|
||||
assert_eq!(
|
||||
actions.bindings(action::TOGGLE_FLYTHROUGH),
|
||||
&[Binding::Key(KeyCode::Tab)]
|
||||
);
|
||||
// But the defaults — what "Restore defaults" reverts to — are still F.
|
||||
assert_eq!(
|
||||
actions.defaults(action::TOGGLE_FLYTHROUGH),
|
||||
&[Binding::Key(KeyCode::KeyF)]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
//! The editor's central undo/redo command stack.
|
||||
//!
|
||||
//! Every editor mutation that should be undoable — a transform edit, a rename, a
|
||||
//! spawn/despawn, and later sculpt/paint/scatter brush strokes — is expressed as
|
||||
//! a [`Command`] and pushed onto a [`CommandStack`]. Routing *all* edits through
|
||||
//! one stack is what makes undo/redo consistent across the whole editor, and it
|
||||
//! is why the Stage-7 gizmos and every later tool get undo "for free".
|
||||
//!
|
||||
//! The stack is generic over the context `C` a command mutates (in the editor
|
||||
//! that is the scene + editor state), which keeps it decoupled and unit-testable
|
||||
//! against a trivial context.
|
||||
|
||||
use std::any::Any;
|
||||
|
||||
/// A reversible editor action over a context `C`.
|
||||
///
|
||||
/// A command must be able to [`apply`](Self::apply) its effect and exactly
|
||||
/// [`undo`](Self::undo) it. Commands are stored boxed on the [`CommandStack`].
|
||||
pub trait Command<C>: 'static {
|
||||
/// Performs the action, mutating `ctx`.
|
||||
fn apply(&mut self, ctx: &mut C);
|
||||
|
||||
/// Reverses the action, restoring `ctx` to its pre-[`apply`](Self::apply) state.
|
||||
fn undo(&mut self, ctx: &mut C);
|
||||
|
||||
/// A short human-readable label (shown in the Edit menu / history).
|
||||
fn label(&self) -> String;
|
||||
|
||||
/// Upcast for [`merge`](Self::merge) to downcast a following command.
|
||||
/// Implement as `self`.
|
||||
fn as_any_mut(&mut self) -> &mut dyn Any;
|
||||
|
||||
/// Tries to fold the immediately-following command `next` into this one so
|
||||
/// they share a single undo entry (e.g. every frame of a gizmo drag becomes
|
||||
/// one undoable move). Return `true` if absorbed; the default never merges.
|
||||
///
|
||||
/// When merging, update `self` so that undoing it reverses *both* effects.
|
||||
fn merge(&mut self, next: &mut dyn Command<C>) -> bool {
|
||||
let _ = next;
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// A composite command: several commands grouped into one undo entry.
|
||||
///
|
||||
/// Applied front-to-back and undone back-to-front, so a multi-step operation
|
||||
/// (e.g. "duplicate and offset") is a single, atomic undo.
|
||||
pub struct Group<C> {
|
||||
label: String,
|
||||
commands: Vec<Box<dyn Command<C>>>,
|
||||
}
|
||||
|
||||
impl<C: 'static> Group<C> {
|
||||
/// A new, empty group with the given label.
|
||||
pub fn new(label: impl Into<String>) -> Self {
|
||||
Self {
|
||||
label: label.into(),
|
||||
commands: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds a command to the group (not yet applied).
|
||||
pub fn push(&mut self, command: impl Command<C> + 'static) {
|
||||
self.commands.push(Box::new(command));
|
||||
}
|
||||
|
||||
/// Whether the group has no commands.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.commands.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl<C: 'static> Command<C> for Group<C> {
|
||||
fn apply(&mut self, ctx: &mut C) {
|
||||
for command in &mut self.commands {
|
||||
command.apply(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
fn undo(&mut self, ctx: &mut C) {
|
||||
for command in self.commands.iter_mut().rev() {
|
||||
command.undo(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
fn label(&self) -> String {
|
||||
self.label.clone()
|
||||
}
|
||||
|
||||
fn as_any_mut(&mut self) -> &mut dyn Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// A bounded undo/redo stack of [`Command`]s over a context `C`.
|
||||
///
|
||||
/// Pushing a command applies it and clears the redo history. Capacity caps how
|
||||
/// many undo entries are retained (oldest dropped first) so the history cannot
|
||||
/// grow without bound.
|
||||
pub struct CommandStack<C> {
|
||||
undo: Vec<Box<dyn Command<C>>>,
|
||||
redo: Vec<Box<dyn Command<C>>>,
|
||||
capacity: usize,
|
||||
}
|
||||
|
||||
impl<C: 'static> CommandStack<C> {
|
||||
/// The default maximum number of retained undo entries.
|
||||
pub const DEFAULT_CAPACITY: usize = 256;
|
||||
|
||||
/// A stack with the [default capacity](Self::DEFAULT_CAPACITY).
|
||||
pub fn new() -> Self {
|
||||
Self::with_capacity(Self::DEFAULT_CAPACITY)
|
||||
}
|
||||
|
||||
/// A stack retaining at most `capacity` undo entries (minimum 1).
|
||||
pub fn with_capacity(capacity: usize) -> Self {
|
||||
Self {
|
||||
undo: Vec::new(),
|
||||
redo: Vec::new(),
|
||||
capacity: capacity.max(1),
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies `command` and records it, clearing the redo history.
|
||||
///
|
||||
/// If the previous top entry [`merge`](Command::merge)s this command, the two
|
||||
/// share one undo entry instead of pushing a new one.
|
||||
pub fn push(&mut self, command: impl Command<C> + 'static, ctx: &mut C) {
|
||||
self.push_boxed(Box::new(command), ctx);
|
||||
}
|
||||
|
||||
/// Applies and records an already-boxed command (e.g. a [`Group`]).
|
||||
pub fn push_boxed(&mut self, mut command: Box<dyn Command<C>>, ctx: &mut C) {
|
||||
command.apply(ctx);
|
||||
self.redo.clear();
|
||||
if let Some(top) = self.undo.last_mut() {
|
||||
if top.merge(command.as_mut()) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
self.undo.push(command);
|
||||
while self.undo.len() > self.capacity {
|
||||
self.undo.remove(0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Undoes the most recent command, moving it to the redo history. Returns its
|
||||
/// label, or `None` if there was nothing to undo.
|
||||
pub fn undo(&mut self, ctx: &mut C) -> Option<String> {
|
||||
let mut command = self.undo.pop()?;
|
||||
command.undo(ctx);
|
||||
let label = command.label();
|
||||
self.redo.push(command);
|
||||
Some(label)
|
||||
}
|
||||
|
||||
/// Redoes the most recently undone command. Returns its label, or `None`.
|
||||
pub fn redo(&mut self, ctx: &mut C) -> Option<String> {
|
||||
let mut command = self.redo.pop()?;
|
||||
command.apply(ctx);
|
||||
let label = command.label();
|
||||
self.undo.push(command);
|
||||
Some(label)
|
||||
}
|
||||
|
||||
/// Whether there is anything to undo.
|
||||
pub fn can_undo(&self) -> bool {
|
||||
!self.undo.is_empty()
|
||||
}
|
||||
|
||||
/// Whether there is anything to redo.
|
||||
pub fn can_redo(&self) -> bool {
|
||||
!self.redo.is_empty()
|
||||
}
|
||||
|
||||
/// The label of the next undo, if any (for the Edit menu).
|
||||
pub fn undo_label(&self) -> Option<String> {
|
||||
self.undo.last().map(|c| c.label())
|
||||
}
|
||||
|
||||
/// The label of the next redo, if any.
|
||||
pub fn redo_label(&self) -> Option<String> {
|
||||
self.redo.last().map(|c| c.label())
|
||||
}
|
||||
|
||||
/// The number of retained undo entries.
|
||||
pub fn undo_depth(&self) -> usize {
|
||||
self.undo.len()
|
||||
}
|
||||
|
||||
/// Clears all history (e.g. on project close).
|
||||
pub fn clear(&mut self) {
|
||||
self.undo.clear();
|
||||
self.redo.clear();
|
||||
}
|
||||
}
|
||||
|
||||
impl<C: 'static> Default for CommandStack<C> {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A trivial context: a single integer the test commands mutate.
|
||||
type Ctx = i32;
|
||||
|
||||
/// Adds `amount` to the context; undo subtracts it. Consecutive `Add`s merge
|
||||
/// into one undo entry (modeling a continuous drag).
|
||||
struct Add {
|
||||
amount: i32,
|
||||
mergeable: bool,
|
||||
}
|
||||
|
||||
impl Add {
|
||||
fn new(amount: i32) -> Self {
|
||||
Self {
|
||||
amount,
|
||||
mergeable: true,
|
||||
}
|
||||
}
|
||||
fn standalone(amount: i32) -> Self {
|
||||
Self {
|
||||
amount,
|
||||
mergeable: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Command<Ctx> for Add {
|
||||
fn apply(&mut self, ctx: &mut Ctx) {
|
||||
*ctx += self.amount;
|
||||
}
|
||||
fn undo(&mut self, ctx: &mut Ctx) {
|
||||
*ctx -= self.amount;
|
||||
}
|
||||
fn label(&self) -> String {
|
||||
format!("Add {}", self.amount)
|
||||
}
|
||||
fn as_any_mut(&mut self) -> &mut dyn Any {
|
||||
self
|
||||
}
|
||||
fn merge(&mut self, next: &mut dyn Command<Ctx>) -> bool {
|
||||
if !self.mergeable {
|
||||
return false;
|
||||
}
|
||||
if let Some(other) = next.as_any_mut().downcast_mut::<Add>() {
|
||||
if other.mergeable {
|
||||
// Fold next's effect into this entry: undoing reverses both.
|
||||
self.amount += other.amount;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_undo_redo_round_trip() {
|
||||
let mut ctx: Ctx = 0;
|
||||
let mut stack = CommandStack::new();
|
||||
stack.push(Add::standalone(5), &mut ctx);
|
||||
stack.push(Add::standalone(3), &mut ctx);
|
||||
assert_eq!(ctx, 8);
|
||||
assert_eq!(stack.undo_depth(), 2);
|
||||
|
||||
assert_eq!(stack.undo(&mut ctx).as_deref(), Some("Add 3"));
|
||||
assert_eq!(ctx, 5);
|
||||
assert_eq!(stack.undo(&mut ctx).as_deref(), Some("Add 5"));
|
||||
assert_eq!(ctx, 0);
|
||||
assert!(!stack.can_undo());
|
||||
|
||||
assert_eq!(stack.redo(&mut ctx).as_deref(), Some("Add 5"));
|
||||
assert_eq!(ctx, 5);
|
||||
assert!(stack.can_redo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pushing_clears_redo() {
|
||||
let mut ctx: Ctx = 0;
|
||||
let mut stack = CommandStack::new();
|
||||
stack.push(Add::standalone(1), &mut ctx);
|
||||
stack.undo(&mut ctx);
|
||||
assert!(stack.can_redo());
|
||||
stack.push(Add::standalone(10), &mut ctx); // new edit invalidates redo
|
||||
assert!(!stack.can_redo());
|
||||
assert_eq!(ctx, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consecutive_mergeable_commands_share_one_entry() {
|
||||
let mut ctx: Ctx = 0;
|
||||
let mut stack = CommandStack::new();
|
||||
// Simulate a drag: many small mergeable adds.
|
||||
for _ in 0..5 {
|
||||
stack.push(Add::new(2), &mut ctx);
|
||||
}
|
||||
assert_eq!(ctx, 10);
|
||||
assert_eq!(stack.undo_depth(), 1, "drag should be one undo entry");
|
||||
// A single undo reverses the whole drag.
|
||||
stack.undo(&mut ctx);
|
||||
assert_eq!(ctx, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_is_atomic() {
|
||||
let mut ctx: Ctx = 0;
|
||||
let mut stack = CommandStack::new();
|
||||
let mut group = Group::new("Duplicate+Offset");
|
||||
group.push(Add::standalone(4));
|
||||
group.push(Add::standalone(6));
|
||||
stack.push_boxed(Box::new(group), &mut ctx);
|
||||
assert_eq!(ctx, 10);
|
||||
assert_eq!(stack.undo_depth(), 1);
|
||||
assert_eq!(stack.undo(&mut ctx).as_deref(), Some("Duplicate+Offset"));
|
||||
assert_eq!(ctx, 0, "group undoes as one atomic step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capacity_drops_oldest_entries() {
|
||||
let mut ctx: Ctx = 0;
|
||||
let mut stack = CommandStack::with_capacity(3);
|
||||
for i in 1..=5 {
|
||||
stack.push(Add::standalone(i), &mut ctx);
|
||||
}
|
||||
// Only the last 3 entries are retained for undo.
|
||||
assert_eq!(stack.undo_depth(), 3);
|
||||
// Undoing all retained entries removes 3+4+5 = 12 from the final 15.
|
||||
while stack.undo(&mut ctx).is_some() {}
|
||||
assert_eq!(ctx, 1 + 2); // the dropped 1 and 2 can't be undone
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
//! Concrete editor commands that mutate the [`EditorState`](crate::state::EditorState).
|
||||
//!
|
||||
//! Routed through the [`CommandStack`](crate::command::CommandStack) so every
|
||||
//! one is undoable through the Edit menu, `Ctrl+Z`/`Ctrl+Y`, and the same path
|
||||
//! that future tools (transform gizmos, sculpt, paint) will use.
|
||||
//!
|
||||
//! Piece 6 ships the **first** commands so the undo plumbing is exercised
|
||||
//! end-to-end:
|
||||
//!
|
||||
//! - [`SetTransformCmd`] — change an entity's local [`Transform`]. Consecutive
|
||||
//! edits to the same entity coalesce via [`Command::merge`] so a slider drag
|
||||
//! or a (future) gizmo drag becomes one undo entry.
|
||||
//! - [`RenameCmd`] — rename an entity.
|
||||
//!
|
||||
//! Spawn/despawn aren't wired yet: round-tripping a despawn would need stable
|
||||
//! entity ids across re-spawn (the scene reuses ids), which is a Stage-7
|
||||
//! design step. The hierarchy panel still offers Add/Delete; they bypass the
|
||||
//! stack today and are clearly labeled as "not undoable" in the shell.
|
||||
|
||||
use std::any::Any;
|
||||
|
||||
use oxide_engine::prelude::*;
|
||||
|
||||
use crate::command::Command;
|
||||
use crate::state::EditorState;
|
||||
|
||||
/// Replaces the open UI document's panel wholesale (widget tree + sizes).
|
||||
///
|
||||
/// The UI canvas snapshots the panel before an edit and again after, so any
|
||||
/// structural change (add / remove / move a widget) or property change goes
|
||||
/// through one undoable command without per-operation bookkeeping. A panel is a
|
||||
/// small data tree, so cloning it for the snapshots is cheap.
|
||||
pub struct SetUiPanelCmd {
|
||||
/// The panel before the edit.
|
||||
pub before: UiPanel,
|
||||
/// The panel after the edit.
|
||||
pub after: UiPanel,
|
||||
/// Human-readable description for the Edit menu.
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
impl Command<EditorState> for SetUiPanelCmd {
|
||||
fn apply(&mut self, state: &mut EditorState) {
|
||||
if let Some(doc) = &mut state.ui_doc {
|
||||
doc.panel = self.after.clone();
|
||||
doc.dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn undo(&mut self, state: &mut EditorState) {
|
||||
if let Some(doc) = &mut state.ui_doc {
|
||||
doc.panel = self.before.clone();
|
||||
doc.dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn label(&self) -> String {
|
||||
self.label.clone()
|
||||
}
|
||||
|
||||
fn as_any_mut(&mut self) -> &mut dyn Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Replaces an entity's local [`Transform`]. Coalesces consecutive edits to
|
||||
/// the same entity so an interactive drag is one undo entry.
|
||||
pub struct SetTransformCmd {
|
||||
pub entity: Entity,
|
||||
/// The transform before the first apply — preserved through merges so
|
||||
/// undo reverses the whole drag at once.
|
||||
pub before: Transform,
|
||||
/// The transform after the most recent apply.
|
||||
pub after: Transform,
|
||||
}
|
||||
|
||||
impl SetTransformCmd {
|
||||
/// Builds the command, snapshotting the entity's current transform as the
|
||||
/// pre-edit state. Returns `None` if the entity has no transform (e.g. it
|
||||
/// was just despawned).
|
||||
pub fn new(state: &EditorState, entity: Entity, after: Transform) -> Option<Self> {
|
||||
let before = state.scene.local_transform(entity)?;
|
||||
Some(Self {
|
||||
entity,
|
||||
before,
|
||||
after,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Command<EditorState> for SetTransformCmd {
|
||||
fn apply(&mut self, state: &mut EditorState) {
|
||||
state.scene.set_local_transform(self.entity, self.after);
|
||||
}
|
||||
|
||||
fn undo(&mut self, state: &mut EditorState) {
|
||||
state.scene.set_local_transform(self.entity, self.before);
|
||||
}
|
||||
|
||||
fn label(&self) -> String {
|
||||
"Edit Transform".to_owned()
|
||||
}
|
||||
|
||||
fn as_any_mut(&mut self) -> &mut dyn Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn merge(&mut self, next: &mut dyn Command<EditorState>) -> bool {
|
||||
let Some(next) = next.as_any_mut().downcast_mut::<SetTransformCmd>() else {
|
||||
return false;
|
||||
};
|
||||
if next.entity != self.entity {
|
||||
return false;
|
||||
}
|
||||
// Absorb `next` by extending our `after` while preserving `before`,
|
||||
// so a long drag remains a single undo step.
|
||||
self.after = next.after;
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets a single **reflected field** of a component on an entity, addressed by
|
||||
/// type name + field name and carried as RON.
|
||||
///
|
||||
/// This is the generic counterpart to [`SetTransformCmd`]: the
|
||||
/// reflection-driven inspector emits one of these for *any* registered
|
||||
/// component's field, so a new component type becomes undoably editable with no
|
||||
/// new command type. Consecutive edits to the same `(entity, type, field)`
|
||||
/// coalesce via [`Command::merge`], so dragging a value slider is one undo
|
||||
/// entry.
|
||||
pub struct SetFieldCmd {
|
||||
pub entity: Entity,
|
||||
/// The registered type name (e.g. `"Transform"`).
|
||||
pub type_name: &'static str,
|
||||
/// The reflected field name (e.g. `"translation"`).
|
||||
pub field: &'static str,
|
||||
/// The field's RON before the first apply — preserved through merges.
|
||||
pub before: String,
|
||||
/// The field's RON after the most recent apply.
|
||||
pub after: String,
|
||||
}
|
||||
|
||||
impl SetFieldCmd {
|
||||
/// Builds the command, snapshotting the field's current RON as the
|
||||
/// pre-edit state. Returns `None` if the field can't be read (unknown
|
||||
/// type/field, or the entity lacks the component).
|
||||
pub fn new(
|
||||
state: &EditorState,
|
||||
entity: Entity,
|
||||
type_name: &'static str,
|
||||
field: &'static str,
|
||||
after: String,
|
||||
) -> Option<Self> {
|
||||
let before = state
|
||||
.registry
|
||||
.get_field(state.scene.world(), entity, type_name, field)
|
||||
.ok()?;
|
||||
Some(Self {
|
||||
entity,
|
||||
type_name,
|
||||
field,
|
||||
before,
|
||||
after,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Command<EditorState> for SetFieldCmd {
|
||||
fn apply(&mut self, state: &mut EditorState) {
|
||||
// Disjoint borrows of EditorState: ®istry (receiver) + &mut scene
|
||||
// (the world). A write only fails if the entity/component vanished
|
||||
// between snapshot and apply, in which case there's nothing to do.
|
||||
let _ = state.registry.set_field(
|
||||
state.scene.world_mut(),
|
||||
self.entity,
|
||||
self.type_name,
|
||||
self.field,
|
||||
&self.after,
|
||||
);
|
||||
}
|
||||
|
||||
fn undo(&mut self, state: &mut EditorState) {
|
||||
let _ = state.registry.set_field(
|
||||
state.scene.world_mut(),
|
||||
self.entity,
|
||||
self.type_name,
|
||||
self.field,
|
||||
&self.before,
|
||||
);
|
||||
}
|
||||
|
||||
fn label(&self) -> String {
|
||||
format!("Edit {}.{}", self.type_name, self.field)
|
||||
}
|
||||
|
||||
fn as_any_mut(&mut self) -> &mut dyn Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn merge(&mut self, next: &mut dyn Command<EditorState>) -> bool {
|
||||
let Some(next) = next.as_any_mut().downcast_mut::<SetFieldCmd>() else {
|
||||
return false;
|
||||
};
|
||||
// Only coalesce edits to the *same* field of the same component on the
|
||||
// same entity; preserve `before` so undo reverses the whole drag.
|
||||
if next.entity != self.entity
|
||||
|| next.type_name != self.type_name
|
||||
|| next.field != self.field
|
||||
{
|
||||
return false;
|
||||
}
|
||||
self.after = std::mem::take(&mut next.after);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Renames an entity.
|
||||
pub struct RenameCmd {
|
||||
pub entity: Entity,
|
||||
pub before: String,
|
||||
pub after: String,
|
||||
}
|
||||
|
||||
impl RenameCmd {
|
||||
/// Snapshots the entity's current name as the pre-edit state.
|
||||
pub fn new(state: &EditorState, entity: Entity, after: String) -> Self {
|
||||
let before = state.scene.name(entity).unwrap_or_default();
|
||||
Self {
|
||||
entity,
|
||||
before,
|
||||
after,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Command<EditorState> for RenameCmd {
|
||||
fn apply(&mut self, state: &mut EditorState) {
|
||||
state.scene.set_name(self.entity, self.after.clone());
|
||||
}
|
||||
|
||||
fn undo(&mut self, state: &mut EditorState) {
|
||||
state.scene.set_name(self.entity, self.before.clone());
|
||||
}
|
||||
|
||||
fn label(&self) -> String {
|
||||
format!("Rename to '{}'", self.after)
|
||||
}
|
||||
|
||||
fn as_any_mut(&mut self) -> &mut dyn Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::command::CommandStack;
|
||||
|
||||
fn state_with_entity() -> (EditorState, Entity) {
|
||||
let mut state = EditorState::new();
|
||||
let e = state
|
||||
.scene
|
||||
.spawn("alpha", Transform::from_translation(Vec3::ZERO));
|
||||
(state, e)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transform_undo_redo_round_trips() {
|
||||
let (mut state, e) = state_with_entity();
|
||||
let target = Transform::from_translation(Vec3::new(1.0, 2.0, 3.0));
|
||||
let cmd = SetTransformCmd::new(&state, e, target).expect("transform present");
|
||||
|
||||
let mut stack: CommandStack<EditorState> = CommandStack::with_capacity(32);
|
||||
stack.push(cmd, &mut state);
|
||||
assert_eq!(
|
||||
state.scene.local_transform(e).unwrap().translation,
|
||||
target.translation
|
||||
);
|
||||
|
||||
assert!(stack.undo(&mut state).is_some());
|
||||
assert_eq!(
|
||||
state.scene.local_transform(e).unwrap().translation,
|
||||
Vec3::ZERO
|
||||
);
|
||||
|
||||
assert!(stack.redo(&mut state).is_some());
|
||||
assert_eq!(
|
||||
state.scene.local_transform(e).unwrap().translation,
|
||||
target.translation
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consecutive_transform_edits_coalesce_into_one_undo() {
|
||||
// Mirrors the "interactive drag" case: dozens of per-frame edits, one
|
||||
// undo step that returns to the pre-drag state.
|
||||
let (mut state, e) = state_with_entity();
|
||||
let mut stack: CommandStack<EditorState> = CommandStack::with_capacity(32);
|
||||
|
||||
for step in 1..=5 {
|
||||
let target = Transform::from_translation(Vec3::splat(step as f32));
|
||||
let cmd = SetTransformCmd::new(&state, e, target).unwrap();
|
||||
stack.push(cmd, &mut state);
|
||||
}
|
||||
assert_eq!(
|
||||
state.scene.local_transform(e).unwrap().translation,
|
||||
Vec3::splat(5.0)
|
||||
);
|
||||
|
||||
// A single undo wipes the whole drag — that's the merge contract.
|
||||
assert!(stack.undo(&mut state).is_some());
|
||||
assert_eq!(
|
||||
state.scene.local_transform(e).unwrap().translation,
|
||||
Vec3::ZERO
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_field_undo_redo_round_trips() {
|
||||
let (mut state, e) = state_with_entity();
|
||||
let cmd = SetFieldCmd::new(
|
||||
&state,
|
||||
e,
|
||||
"Transform",
|
||||
"translation",
|
||||
"(1.0,2.0,3.0)".into(),
|
||||
)
|
||||
.expect("transform field readable");
|
||||
|
||||
let mut stack: CommandStack<EditorState> = CommandStack::with_capacity(32);
|
||||
stack.push(cmd, &mut state);
|
||||
assert_eq!(
|
||||
state.scene.local_transform(e).unwrap().translation,
|
||||
Vec3::new(1.0, 2.0, 3.0)
|
||||
);
|
||||
|
||||
assert!(stack.undo(&mut state).is_some());
|
||||
assert_eq!(
|
||||
state.scene.local_transform(e).unwrap().translation,
|
||||
Vec3::ZERO
|
||||
);
|
||||
|
||||
assert!(stack.redo(&mut state).is_some());
|
||||
assert_eq!(
|
||||
state.scene.local_transform(e).unwrap().translation,
|
||||
Vec3::new(1.0, 2.0, 3.0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consecutive_field_edits_to_same_field_coalesce() {
|
||||
// A value-slider drag: many per-frame edits, one undo back to start.
|
||||
let (mut state, e) = state_with_entity();
|
||||
let mut stack: CommandStack<EditorState> = CommandStack::with_capacity(32);
|
||||
for step in 1..=5 {
|
||||
let ron = format!("({0}.0,{0}.0,{0}.0)", step);
|
||||
let cmd = SetFieldCmd::new(&state, e, "Transform", "translation", ron).unwrap();
|
||||
stack.push(cmd, &mut state);
|
||||
}
|
||||
assert_eq!(
|
||||
state.scene.local_transform(e).unwrap().translation,
|
||||
Vec3::splat(5.0)
|
||||
);
|
||||
assert!(stack.undo(&mut state).is_some());
|
||||
assert_eq!(
|
||||
state.scene.local_transform(e).unwrap().translation,
|
||||
Vec3::ZERO
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_field_edits_to_different_fields_do_not_coalesce() {
|
||||
// Editing translation then scale must be two undo steps, not one.
|
||||
let (mut state, e) = state_with_entity();
|
||||
let mut stack: CommandStack<EditorState> = CommandStack::with_capacity(32);
|
||||
stack.push(
|
||||
SetFieldCmd::new(
|
||||
&state,
|
||||
e,
|
||||
"Transform",
|
||||
"translation",
|
||||
"(1.0,0.0,0.0)".into(),
|
||||
)
|
||||
.unwrap(),
|
||||
&mut state,
|
||||
);
|
||||
stack.push(
|
||||
SetFieldCmd::new(&state, e, "Transform", "scale", "(2.0,2.0,2.0)".into()).unwrap(),
|
||||
&mut state,
|
||||
);
|
||||
// Undo reverses scale only.
|
||||
assert!(stack.undo(&mut state).is_some());
|
||||
let t = state.scene.local_transform(e).unwrap();
|
||||
assert_eq!(t.scale, Vec3::ONE);
|
||||
assert_eq!(t.translation, Vec3::new(1.0, 0.0, 0.0));
|
||||
// A second undo reverses translation.
|
||||
assert!(stack.undo(&mut state).is_some());
|
||||
assert_eq!(
|
||||
state.scene.local_transform(e).unwrap().translation,
|
||||
Vec3::ZERO
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_undo_restores_previous_name() {
|
||||
let (mut state, e) = state_with_entity();
|
||||
let cmd = RenameCmd::new(&state, e, "beta".into());
|
||||
let mut stack: CommandStack<EditorState> = CommandStack::with_capacity(32);
|
||||
stack.push(cmd, &mut state);
|
||||
assert_eq!(state.scene.name(e).as_deref(), Some("beta"));
|
||||
|
||||
assert!(stack.undo(&mut state).is_some());
|
||||
assert_eq!(state.scene.name(e).as_deref(), Some("alpha"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
//! Editor console: captures `log` records into a ring buffer the Console panel
|
||||
//! renders.
|
||||
//!
|
||||
//! The engine and modules already speak through the `log` crate — in particular
|
||||
//! the scripting layer routes script `print`/`debug` and "script paused: …"
|
||||
//! errors to `target: "oxide_script"` (see `oxide-script`). This module installs
|
||||
//! a logger that mirrors every record into an in-memory ring buffer *and* still
|
||||
//! forwards it to `env_logger` for the terminal, so the editor's Console panel
|
||||
//! can show script output and errors without the engine knowing about the editor.
|
||||
//!
|
||||
//! The buffer is a process global (the `log` facade allows only one logger, set
|
||||
//! once at startup), reached by the panel through [`log_buffer`] — so wiring it
|
||||
//! in touches neither `Shell::new` nor its many test call sites.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
use log::{Level, Log, Metadata, Record};
|
||||
|
||||
/// How many recent log lines the console keeps. Older lines are dropped.
|
||||
const CAPACITY: usize = 2000;
|
||||
|
||||
/// One captured log record, flattened to what the panel renders.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LogLine {
|
||||
/// Severity, used to colour the line.
|
||||
pub level: Level,
|
||||
/// The record's target (e.g. `oxide_script`), shown dimmed before the text.
|
||||
pub target: String,
|
||||
/// The formatted message.
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// A bounded ring buffer of the most recent [`LogLine`]s.
|
||||
#[derive(Default)]
|
||||
pub struct LogBuffer {
|
||||
lines: VecDeque<LogLine>,
|
||||
}
|
||||
|
||||
impl LogBuffer {
|
||||
/// Appends a line, evicting the oldest if at capacity.
|
||||
fn push(&mut self, line: LogLine) {
|
||||
if self.lines.len() == CAPACITY {
|
||||
self.lines.pop_front();
|
||||
}
|
||||
self.lines.push_back(line);
|
||||
}
|
||||
|
||||
/// Iterates the buffered lines, oldest first.
|
||||
pub fn iter(&self) -> impl Iterator<Item = &LogLine> {
|
||||
self.lines.iter()
|
||||
}
|
||||
|
||||
/// The number of buffered lines.
|
||||
pub fn len(&self) -> usize {
|
||||
self.lines.len()
|
||||
}
|
||||
|
||||
/// Whether the buffer is empty.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.lines.is_empty()
|
||||
}
|
||||
|
||||
/// Drops all buffered lines (the panel's Clear button).
|
||||
pub fn clear(&mut self) {
|
||||
self.lines.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// The process-wide capture buffer, set by [`init`].
|
||||
static LOG_BUFFER: OnceLock<Arc<Mutex<LogBuffer>>> = OnceLock::new();
|
||||
|
||||
/// The shared capture buffer, if logging has been initialised.
|
||||
pub fn log_buffer() -> Option<&'static Arc<Mutex<LogBuffer>>> {
|
||||
LOG_BUFFER.get()
|
||||
}
|
||||
|
||||
/// Appends a line to the console from outside the `log` stream — used by the
|
||||
/// command terminal to echo commands and stream a process's output into the
|
||||
/// same panel. No-op if logging is not initialised.
|
||||
pub fn append(level: Level, target: &str, message: impl Into<String>) {
|
||||
if let Some(buffer) = LOG_BUFFER.get() {
|
||||
if let Ok(mut buffer) = buffer.lock() {
|
||||
buffer.push(LogLine {
|
||||
level,
|
||||
target: target.to_string(),
|
||||
message: message.into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A logger that mirrors records into [`LOG_BUFFER`] and forwards them to an
|
||||
/// inner `env_logger` for the terminal.
|
||||
struct CaptureLogger {
|
||||
inner: env_logger::Logger,
|
||||
buffer: Arc<Mutex<LogBuffer>>,
|
||||
}
|
||||
|
||||
impl Log for CaptureLogger {
|
||||
fn enabled(&self, metadata: &Metadata) -> bool {
|
||||
self.inner.enabled(metadata)
|
||||
}
|
||||
|
||||
fn log(&self, record: &Record) {
|
||||
// Honour the env filter for both the terminal and the buffer, so
|
||||
// RUST_LOG controls the console too.
|
||||
if !self.inner.enabled(record.metadata()) {
|
||||
return;
|
||||
}
|
||||
if let Ok(mut buffer) = self.buffer.lock() {
|
||||
buffer.push(LogLine {
|
||||
level: record.level(),
|
||||
target: record.target().to_string(),
|
||||
message: record.args().to_string(),
|
||||
});
|
||||
}
|
||||
self.inner.log(record);
|
||||
}
|
||||
|
||||
fn flush(&self) {
|
||||
self.inner.flush();
|
||||
}
|
||||
}
|
||||
|
||||
/// Installs the capturing logger and returns the shared buffer. Mirrors the old
|
||||
/// `env_logger` setup (honours `RUST_LOG`, default `info`) but also feeds the
|
||||
/// editor Console. Call once at startup, before any logging.
|
||||
pub fn init() {
|
||||
let inner =
|
||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).build();
|
||||
let max = inner.filter();
|
||||
let buffer = Arc::new(Mutex::new(LogBuffer::default()));
|
||||
let _ = LOG_BUFFER.set(buffer.clone());
|
||||
|
||||
if log::set_boxed_logger(Box::new(CaptureLogger { inner, buffer })).is_ok() {
|
||||
log::set_max_level(max);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ring_buffer_evicts_oldest_past_capacity() {
|
||||
let mut buf = LogBuffer::default();
|
||||
for i in 0..(CAPACITY + 10) {
|
||||
buf.push(LogLine {
|
||||
level: Level::Info,
|
||||
target: "t".into(),
|
||||
message: format!("line {i}"),
|
||||
});
|
||||
}
|
||||
assert_eq!(buf.len(), CAPACITY);
|
||||
// The oldest 10 were evicted, so the first surviving line is "line 10".
|
||||
assert_eq!(buf.iter().next().unwrap().message, "line 10");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_empties_the_buffer() {
|
||||
let mut buf = LogBuffer::default();
|
||||
buf.push(LogLine {
|
||||
level: Level::Warn,
|
||||
target: "t".into(),
|
||||
message: "x".into(),
|
||||
});
|
||||
assert!(!buf.is_empty());
|
||||
buf.clear();
|
||||
assert!(buf.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
//! egui ⇄ engine glue for the editor.
|
||||
//!
|
||||
//! The engine core stays UI-agnostic; all egui wiring lives here in the editor.
|
||||
//! [`EguiLayer`] owns the [`egui_winit`] input state and the [`egui_wgpu`]
|
||||
//! renderer, translates window events, and paints a built UI into the frame's
|
||||
//! surface view (recorded with `LoadOp::Load`, so it composites on top of the
|
||||
//! engine's clear).
|
||||
|
||||
use egui_wgpu::{Renderer, RendererOptions, ScreenDescriptor};
|
||||
use egui_winit::State;
|
||||
use oxide_engine::wgpu;
|
||||
use oxide_engine::winit::event::WindowEvent;
|
||||
use oxide_engine::winit::window::Window;
|
||||
|
||||
/// Holds the egui input state and GPU renderer for one window.
|
||||
pub struct EguiLayer {
|
||||
state: State,
|
||||
renderer: Renderer,
|
||||
}
|
||||
|
||||
impl EguiLayer {
|
||||
/// Creates the layer for `window`, building a renderer that targets the
|
||||
/// given surface format.
|
||||
pub fn new(
|
||||
window: &Window,
|
||||
device: &wgpu::Device,
|
||||
surface_format: wgpu::TextureFormat,
|
||||
) -> Self {
|
||||
let context = egui::Context::default();
|
||||
let state = State::new(
|
||||
context,
|
||||
egui::ViewportId::ROOT,
|
||||
window,
|
||||
Some(window.scale_factor() as f32),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
// Defaults: no MSAA, no depth/stencil, dithering on — matches the
|
||||
// editor's flat clear-color surface.
|
||||
let renderer = Renderer::new(device, surface_format, RendererOptions::default());
|
||||
Self { state, renderer }
|
||||
}
|
||||
|
||||
/// Feeds a window event to egui. Returns `true` if egui consumed it (e.g.
|
||||
/// a click landed on a panel), so the caller can suppress its own handling.
|
||||
pub fn on_window_event(&mut self, window: &Window, event: &WindowEvent) -> bool {
|
||||
self.state.on_window_event(window, event).consumed
|
||||
}
|
||||
|
||||
/// Whether the pointer is currently over a **floating** egui area — a
|
||||
/// `Window` (Preferences, Layer Names, Groups, …) or other non-background
|
||||
/// layer — rather than empty space or the background dock.
|
||||
///
|
||||
/// The viewport is painted under a transparent dock area (background
|
||||
/// order), so a geometric "cursor inside the viewport rect" test can't tell
|
||||
/// that a floating panel is sitting on top of it. The host uses this to
|
||||
/// suppress viewport orbit/pan/zoom (and stray WASD while typing in a panel
|
||||
/// that overlaps the viewport).
|
||||
pub fn pointer_over_floating(&self) -> bool {
|
||||
let ctx = self.state.egui_ctx();
|
||||
let Some(pos) = ctx.pointer_latest_pos() else {
|
||||
return false;
|
||||
};
|
||||
ctx.layer_id_at(pos)
|
||||
.map(|layer| layer.order > egui::Order::Background)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Builds the UI via `build_ui` and paints it into `view`.
|
||||
///
|
||||
/// `build_ui` receives the root [`egui::Ui`]; panels are shown inside it
|
||||
/// (egui 0.34's `show_inside` model). It may be called more than once per
|
||||
/// frame if egui needs an extra layout pass, so it must be idempotent.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn paint(
|
||||
&mut self,
|
||||
window: &Window,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
view: &wgpu::TextureView,
|
||||
size: (u32, u32),
|
||||
build_ui: impl FnMut(&mut egui::Ui),
|
||||
) {
|
||||
let raw_input = self.state.take_egui_input(window);
|
||||
let context = self.state.egui_ctx().clone();
|
||||
let output = context.run_ui(raw_input, build_ui);
|
||||
self.state
|
||||
.handle_platform_output(window, output.platform_output);
|
||||
|
||||
let primitives = context.tessellate(output.shapes, output.pixels_per_point);
|
||||
let screen = ScreenDescriptor {
|
||||
size_in_pixels: [size.0.max(1), size.1.max(1)],
|
||||
pixels_per_point: output.pixels_per_point,
|
||||
};
|
||||
|
||||
for (id, delta) in &output.textures_delta.set {
|
||||
self.renderer.update_texture(device, queue, *id, delta);
|
||||
}
|
||||
|
||||
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
||||
label: Some("oxide.editor.egui.encoder"),
|
||||
});
|
||||
// egui may emit its own command buffers (for paint callbacks); submit
|
||||
// those ahead of our pass.
|
||||
let user_buffers =
|
||||
self.renderer
|
||||
.update_buffers(device, queue, &mut encoder, &primitives, &screen);
|
||||
{
|
||||
let pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("oxide.editor.egui.pass"),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
// Load: keep the engine's clear; draw the UI over it.
|
||||
load: wgpu::LoadOp::Load,
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
depth_stencil_attachment: None,
|
||||
timestamp_writes: None,
|
||||
occlusion_query_set: None,
|
||||
multiview_mask: None,
|
||||
});
|
||||
// egui-wgpu wants a 'static pass; the encoder outlives it here.
|
||||
let mut pass = pass.forget_lifetime();
|
||||
self.renderer.render(&mut pass, &primitives, &screen);
|
||||
}
|
||||
|
||||
for id in &output.textures_delta.free {
|
||||
self.renderer.free_texture(id);
|
||||
}
|
||||
queue.submit(
|
||||
user_buffers
|
||||
.into_iter()
|
||||
.chain(std::iter::once(encoder.finish())),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,655 @@
|
||||
//! Module → editor extension API.
|
||||
//!
|
||||
//! The engine's [`Module`](oxide_engine::app::Module) trait registers systems,
|
||||
//! component types, asset loaders, and resources on an
|
||||
//! [`App`](oxide_engine::app::App). This module is its **editor-side companion**:
|
||||
//! one trait — [`EditorModule`] — through which a module contributes the UI it
|
||||
//! needs the editor to host on its behalf.
|
||||
//!
|
||||
//! Specifically, a module can add:
|
||||
//!
|
||||
//! - **Menu items** in the top menu bar (e.g. `"File/Open Recent"`),
|
||||
//! - **Dockable panels** in the docking shell (e.g. an "Audio Mixer"),
|
||||
//! - **Viewport tools** that take over input on the 3D viewport (gizmos,
|
||||
//! measurement, paint),
|
||||
//! - **Component inspectors** that render rich editors for the module's
|
||||
//! component types (keyed by their
|
||||
//! [`TypeRegistry`](oxide_engine::reflect::TypeRegistry) name), and
|
||||
//! - **Settings pages** that drive the module's
|
||||
//! [`Settings`](oxide_engine::settings::Settings) section in the Preferences
|
||||
//! window.
|
||||
//!
|
||||
//! All five plug into the editor through one registry — [`EditorExtensions`] —
|
||||
//! consumed by the docking shell. The shell never edits its own source to host
|
||||
//! a new module's UI; this is *the* mechanism by which "anyone can write a
|
||||
//! module" that extends both engine logic and the editor.
|
||||
//!
|
||||
//! ## Why a separate trait
|
||||
//!
|
||||
//! The engine has no egui dependency, so the editor hook can't live on the
|
||||
//! engine's `Module` trait without dragging UI types into the engine. Two
|
||||
//! traits implemented on the same struct keeps the engine GUI-free and lets the
|
||||
//! editor binary register the same module on both sides:
|
||||
//!
|
||||
//! ```ignore
|
||||
//! struct MyModule;
|
||||
//! impl oxide_engine::app::Module for MyModule { /* … systems, types */ }
|
||||
//! impl oxide_editor::extension::EditorModule for MyModule { /* … panels */ }
|
||||
//! ```
|
||||
//!
|
||||
//! ## Attribution
|
||||
//!
|
||||
//! Every contribution remembers which module added it. Removing a module
|
||||
//! ([`EditorExtensions::remove_module`]) removes all of its contributions in
|
||||
//! one shot — the same lifecycle the engine's
|
||||
//! [`App::remove_module`](oxide_engine::app::App::remove_module) gives systems,
|
||||
//! types, and loaders. Disabling a module
|
||||
//! ([`set_module_enabled`](EditorExtensions::set_module_enabled)) keeps the
|
||||
//! contributions registered but hides them from the shell, so toggling a
|
||||
//! module in Preferences is reversible without rebuilding the registry.
|
||||
//!
|
||||
//! ## Render closures
|
||||
//!
|
||||
//! Panel / inspector / settings-page closures take only `&mut egui::Ui` in
|
||||
//! Stage 6 piece 5 (registration). Piece 6 — the docking shell — refines the
|
||||
//! signatures to pass through the editor's runtime context. Modules that need
|
||||
//! shared state today should capture it through interior mutability
|
||||
//! (`Rc<RefCell<...>>`).
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// Where a panel prefers to be docked the first time the user opens it.
|
||||
///
|
||||
/// The shell may override this when restoring a saved layout; it is only a
|
||||
/// hint, not a guarantee.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum DockLocation {
|
||||
/// Pinned to the left side of the main area (hierarchies, project browser).
|
||||
Left,
|
||||
/// Pinned to the right side (properties / inspector).
|
||||
Right,
|
||||
/// Pinned to the bottom (console, logs, timeline).
|
||||
Bottom,
|
||||
/// The main central tab area (viewport, code, asset preview).
|
||||
Center,
|
||||
/// A floating window outside the dock layout.
|
||||
Floating,
|
||||
}
|
||||
|
||||
/// One top-menu-bar item contributed by a module.
|
||||
///
|
||||
/// `path` uses `/` as a separator and identifies the menu tree, e.g.
|
||||
/// `"File/New Project"` or `"View/Layout/Default"`. The shell groups items by
|
||||
/// their leading segments.
|
||||
pub struct MenuItem {
|
||||
/// Slash-separated path through the menu tree.
|
||||
pub path: String,
|
||||
/// Optional human-readable shortcut hint (e.g. `"Ctrl+N"`). Not bound by
|
||||
/// this API — the actual key binding lives in the Stage-7 input map.
|
||||
pub shortcut: Option<String>,
|
||||
/// Invoked when the item is clicked. The shell decides when to call it.
|
||||
pub action: Box<dyn FnMut()>,
|
||||
}
|
||||
|
||||
/// A dockable panel contributed by a module.
|
||||
pub struct Panel {
|
||||
/// Stable name; doubles as the tab title and the lookup key.
|
||||
pub name: String,
|
||||
/// Where the panel prefers to dock initially.
|
||||
pub default_dock: DockLocation,
|
||||
/// Renders the panel's contents into `ui` each frame the panel is visible.
|
||||
pub render: Box<dyn FnMut(&mut egui::Ui)>,
|
||||
}
|
||||
|
||||
/// A viewport tool — usually a gizmo or a brush — that takes over the 3D
|
||||
/// viewport's input while active.
|
||||
pub struct ViewportTool {
|
||||
/// Stable name (e.g. `"Translate"`, `"Sculpt"`); identifies the tool in
|
||||
/// menus, toolbars, and shortcut tables.
|
||||
pub name: String,
|
||||
/// Called once when the tool becomes the active viewport tool. Use it to
|
||||
/// reset transient state or hook into the editor's command stack.
|
||||
pub on_activate: Box<dyn FnMut()>,
|
||||
}
|
||||
|
||||
/// An editor for one reflected component type, keyed by the same name the
|
||||
/// component is registered under in the
|
||||
/// [`TypeRegistry`](oxide_engine::reflect::TypeRegistry). The shell calls
|
||||
/// `render` from the Inspector panel when a selected entity has the component.
|
||||
pub struct ComponentInspector {
|
||||
/// Matches the `name` passed to
|
||||
/// [`App::register_type`](oxide_engine::app::App::register_type).
|
||||
pub type_name: String,
|
||||
/// Renders an editor for the component into `ui`.
|
||||
pub render: Box<dyn FnMut(&mut egui::Ui)>,
|
||||
}
|
||||
|
||||
/// A page in the Preferences window driving one
|
||||
/// [`Settings`](oxide_engine::settings::Settings) section.
|
||||
pub struct SettingsPage {
|
||||
/// Matches the `name` passed to
|
||||
/// [`Settings::register`](oxide_engine::settings::Settings::register).
|
||||
pub section_name: String,
|
||||
/// Title shown in the Preferences sidebar (defaults to `section_name` when
|
||||
/// the contributor leaves it empty).
|
||||
pub title: String,
|
||||
/// Renders the page's controls into `ui`.
|
||||
pub render: Box<dyn FnMut(&mut egui::Ui)>,
|
||||
}
|
||||
|
||||
/// Editor-side companion to the engine's
|
||||
/// [`Module`](oxide_engine::app::Module) trait.
|
||||
///
|
||||
/// Implement on the same type that implements `Module` (or on a separate
|
||||
/// editor-only struct) and pass it to
|
||||
/// [`EditorExtensions::add_module`]. Everything `build_editor` registers is
|
||||
/// attributed to this module and can be removed atomically with
|
||||
/// [`EditorExtensions::remove_module`].
|
||||
pub trait EditorModule: 'static {
|
||||
/// A stable, unique name — should match the paired engine `Module::name`
|
||||
/// when both halves describe the same module, so the editor and engine
|
||||
/// agree on enable/disable.
|
||||
fn name(&self) -> &'static str;
|
||||
|
||||
/// Registers UI contributions on `ext`.
|
||||
fn build_editor(&self, ext: &mut EditorExtensions);
|
||||
}
|
||||
|
||||
/// Internal record tying any contribution to its source module and an
|
||||
/// enabled/disabled flag inherited from the module.
|
||||
struct Entry<T> {
|
||||
module: &'static str,
|
||||
value: T,
|
||||
}
|
||||
|
||||
/// Registry of every UI contribution made by every editor module. The docking
|
||||
/// shell reads this in Piece 6 to assemble the menu bar, dock layout, viewport
|
||||
/// toolbox, inspector, and Preferences window.
|
||||
#[derive(Default)]
|
||||
pub struct EditorExtensions {
|
||||
menu_items: Vec<Entry<MenuItem>>,
|
||||
panels: Vec<Entry<Panel>>,
|
||||
viewport_tools: Vec<Entry<ViewportTool>>,
|
||||
inspectors: BTreeMap<String, Entry<ComponentInspector>>,
|
||||
settings_pages: BTreeMap<String, Entry<SettingsPage>>,
|
||||
modules: Vec<&'static str>,
|
||||
enabled: BTreeMap<&'static str, bool>,
|
||||
/// Set only while a module's `build_editor` is running, so individual
|
||||
/// `add_*` helpers can attribute the contribution without taking the
|
||||
/// module name as an argument.
|
||||
current_module: Option<&'static str>,
|
||||
}
|
||||
|
||||
impl EditorExtensions {
|
||||
/// A fresh, empty registry.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Registers `module` and runs its
|
||||
/// [`build_editor`](EditorModule::build_editor). Re-adding a module with
|
||||
/// the same name first removes the old one, so callers don't have to dance
|
||||
/// around stale contributions when reloading.
|
||||
pub fn add_module<M: EditorModule>(&mut self, module: M) {
|
||||
let name = module.name();
|
||||
if self.modules.contains(&name) {
|
||||
self.remove_module(name);
|
||||
}
|
||||
self.modules.push(name);
|
||||
self.enabled.insert(name, true);
|
||||
self.current_module = Some(name);
|
||||
module.build_editor(self);
|
||||
self.current_module = None;
|
||||
}
|
||||
|
||||
/// Removes every contribution registered by the named module. Returns
|
||||
/// whether the module was present.
|
||||
pub fn remove_module(&mut self, name: &str) -> bool {
|
||||
if !self.modules.contains(&name) {
|
||||
return false;
|
||||
}
|
||||
self.menu_items.retain(|e| e.module != name);
|
||||
self.panels.retain(|e| e.module != name);
|
||||
self.viewport_tools.retain(|e| e.module != name);
|
||||
self.inspectors.retain(|_, e| e.module != name);
|
||||
self.settings_pages.retain(|_, e| e.module != name);
|
||||
self.modules.retain(|m| *m != name);
|
||||
self.enabled.remove(name);
|
||||
true
|
||||
}
|
||||
|
||||
/// Whether the named module is currently registered (independent of
|
||||
/// enabled-state).
|
||||
pub fn has_module(&self, name: &str) -> bool {
|
||||
self.modules.contains(&name)
|
||||
}
|
||||
|
||||
/// Toggles whether contributions from the named module are visible to the
|
||||
/// shell. The contributions stay registered so re-enabling is instant.
|
||||
/// Returns whether the module was present.
|
||||
pub fn set_module_enabled(&mut self, name: &str, enabled: bool) -> bool {
|
||||
if let Some(slot) = self.enabled.get_mut(name) {
|
||||
*slot = enabled;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the named module's contributions are currently enabled. Returns
|
||||
/// `false` for unknown modules.
|
||||
pub fn is_module_enabled(&self, name: &str) -> bool {
|
||||
self.enabled.get(name).copied().unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Registered module names, in insertion order.
|
||||
pub fn modules(&self) -> impl Iterator<Item = &'static str> + '_ {
|
||||
self.modules.iter().copied()
|
||||
}
|
||||
|
||||
// --- contribution helpers (called from `build_editor`) -----------------
|
||||
|
||||
/// Adds a menu item. Panics if called outside a module's `build_editor` —
|
||||
/// every contribution must be attributable to some module.
|
||||
pub fn add_menu_item(
|
||||
&mut self,
|
||||
path: impl Into<String>,
|
||||
action: impl FnMut() + 'static,
|
||||
) -> &mut Self {
|
||||
self.add_menu_item_full(MenuItem {
|
||||
path: path.into(),
|
||||
shortcut: None,
|
||||
action: Box::new(action),
|
||||
})
|
||||
}
|
||||
|
||||
/// Adds a menu item with a fully-specified [`MenuItem`] (lets the caller
|
||||
/// set a shortcut hint).
|
||||
pub fn add_menu_item_full(&mut self, item: MenuItem) -> &mut Self {
|
||||
let module = self.expect_module("add_menu_item");
|
||||
self.menu_items.push(Entry {
|
||||
module,
|
||||
value: item,
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
/// Adds a dockable panel. `default_dock` is a placement hint; the shell
|
||||
/// may override when restoring a saved layout.
|
||||
pub fn add_panel(
|
||||
&mut self,
|
||||
name: impl Into<String>,
|
||||
default_dock: DockLocation,
|
||||
render: impl FnMut(&mut egui::Ui) + 'static,
|
||||
) -> &mut Self {
|
||||
let module = self.expect_module("add_panel");
|
||||
self.panels.push(Entry {
|
||||
module,
|
||||
value: Panel {
|
||||
name: name.into(),
|
||||
default_dock,
|
||||
render: Box::new(render),
|
||||
},
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
/// Adds a viewport tool (gizmo, brush, …).
|
||||
pub fn add_viewport_tool(
|
||||
&mut self,
|
||||
name: impl Into<String>,
|
||||
on_activate: impl FnMut() + 'static,
|
||||
) -> &mut Self {
|
||||
let module = self.expect_module("add_viewport_tool");
|
||||
self.viewport_tools.push(Entry {
|
||||
module,
|
||||
value: ViewportTool {
|
||||
name: name.into(),
|
||||
on_activate: Box::new(on_activate),
|
||||
},
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
/// Adds a component inspector keyed by the type's reflection name.
|
||||
/// Re-registering a name overwrites the previous inspector (most-recently-
|
||||
/// added module wins; this lets a project override a base module's
|
||||
/// inspector if it has reason to).
|
||||
pub fn add_inspector(
|
||||
&mut self,
|
||||
type_name: impl Into<String>,
|
||||
render: impl FnMut(&mut egui::Ui) + 'static,
|
||||
) -> &mut Self {
|
||||
let module = self.expect_module("add_inspector");
|
||||
let type_name = type_name.into();
|
||||
self.inspectors.insert(
|
||||
type_name.clone(),
|
||||
Entry {
|
||||
module,
|
||||
value: ComponentInspector {
|
||||
type_name,
|
||||
render: Box::new(render),
|
||||
},
|
||||
},
|
||||
);
|
||||
self
|
||||
}
|
||||
|
||||
/// Adds a Preferences page driving the named settings section.
|
||||
pub fn add_settings_page(
|
||||
&mut self,
|
||||
section_name: impl Into<String>,
|
||||
title: impl Into<String>,
|
||||
render: impl FnMut(&mut egui::Ui) + 'static,
|
||||
) -> &mut Self {
|
||||
let module = self.expect_module("add_settings_page");
|
||||
let section_name = section_name.into();
|
||||
let title = title.into();
|
||||
let title = if title.is_empty() {
|
||||
section_name.clone()
|
||||
} else {
|
||||
title
|
||||
};
|
||||
self.settings_pages.insert(
|
||||
section_name.clone(),
|
||||
Entry {
|
||||
module,
|
||||
value: SettingsPage {
|
||||
section_name,
|
||||
title,
|
||||
render: Box::new(render),
|
||||
},
|
||||
},
|
||||
);
|
||||
self
|
||||
}
|
||||
|
||||
// --- shell-facing lookups ---------------------------------------------
|
||||
|
||||
/// Slash-separated paths of every currently-enabled menu item, in the
|
||||
/// order they were contributed.
|
||||
pub fn menu_item_paths(&self) -> impl Iterator<Item = &str> {
|
||||
self.iter_menu_items().map(|i| i.path.as_str())
|
||||
}
|
||||
|
||||
/// Names of every currently-enabled panel.
|
||||
pub fn panel_names(&self) -> impl Iterator<Item = &str> {
|
||||
self.iter_panels().map(|p| p.name.as_str())
|
||||
}
|
||||
|
||||
/// Names of every currently-enabled viewport tool.
|
||||
pub fn viewport_tool_names(&self) -> impl Iterator<Item = &str> {
|
||||
self.iter_viewport_tools().map(|t| t.name.as_str())
|
||||
}
|
||||
|
||||
/// Reflection-keyed type names that currently have an inspector
|
||||
/// registered.
|
||||
pub fn inspector_type_names(&self) -> impl Iterator<Item = &str> {
|
||||
self.iter_inspectors().map(|i| i.type_name.as_str())
|
||||
}
|
||||
|
||||
/// Settings-section names that currently have a Preferences page
|
||||
/// registered.
|
||||
pub fn settings_page_names(&self) -> impl Iterator<Item = &str> {
|
||||
self.iter_settings_pages().map(|p| p.section_name.as_str())
|
||||
}
|
||||
|
||||
/// Whether an inspector is registered for the given reflection name and
|
||||
/// the contributing module is enabled.
|
||||
pub fn has_inspector_for(&self, type_name: &str) -> bool {
|
||||
self.inspectors
|
||||
.get(type_name)
|
||||
.map(|e| self.is_enabled(e.module))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Whether a Preferences page is registered for the given section name
|
||||
/// and the contributing module is enabled.
|
||||
pub fn has_settings_page_for(&self, section_name: &str) -> bool {
|
||||
self.settings_pages
|
||||
.get(section_name)
|
||||
.map(|e| self.is_enabled(e.module))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Iterates the enabled menu items themselves (gives the shell direct
|
||||
/// access to actions/shortcuts when rendering).
|
||||
pub fn iter_menu_items(&self) -> impl Iterator<Item = &MenuItem> {
|
||||
self.menu_items
|
||||
.iter()
|
||||
.filter(|e| self.is_enabled(e.module))
|
||||
.map(|e| &e.value)
|
||||
}
|
||||
|
||||
/// Mutably iterates the enabled menu items so the shell can invoke each
|
||||
/// item's `FnMut` action when the user clicks it.
|
||||
pub fn iter_menu_items_mut(&mut self) -> impl Iterator<Item = &mut MenuItem> {
|
||||
let enabled = &self.enabled;
|
||||
self.menu_items
|
||||
.iter_mut()
|
||||
.filter(move |e| enabled.get(e.module).copied().unwrap_or(false))
|
||||
.map(|e| &mut e.value)
|
||||
}
|
||||
|
||||
/// Iterates the enabled panels.
|
||||
pub fn iter_panels(&self) -> impl Iterator<Item = &Panel> {
|
||||
self.panels
|
||||
.iter()
|
||||
.filter(|e| self.is_enabled(e.module))
|
||||
.map(|e| &e.value)
|
||||
}
|
||||
|
||||
/// Mutably iterates the enabled panels so the shell can call each panel's
|
||||
/// `FnMut` render closure each frame.
|
||||
pub fn iter_panels_mut(&mut self) -> impl Iterator<Item = &mut Panel> {
|
||||
let enabled = &self.enabled;
|
||||
self.panels
|
||||
.iter_mut()
|
||||
.filter(move |e| enabled.get(e.module).copied().unwrap_or(false))
|
||||
.map(|e| &mut e.value)
|
||||
}
|
||||
|
||||
/// Iterates the enabled viewport tools.
|
||||
pub fn iter_viewport_tools(&self) -> impl Iterator<Item = &ViewportTool> {
|
||||
self.viewport_tools
|
||||
.iter()
|
||||
.filter(|e| self.is_enabled(e.module))
|
||||
.map(|e| &e.value)
|
||||
}
|
||||
|
||||
/// Iterates the enabled component inspectors (in stable name order).
|
||||
pub fn iter_inspectors(&self) -> impl Iterator<Item = &ComponentInspector> {
|
||||
self.inspectors
|
||||
.values()
|
||||
.filter(|e| self.is_enabled(e.module))
|
||||
.map(|e| &e.value)
|
||||
}
|
||||
|
||||
/// Iterates the enabled settings pages (in stable section-name order).
|
||||
pub fn iter_settings_pages(&self) -> impl Iterator<Item = &SettingsPage> {
|
||||
self.settings_pages
|
||||
.values()
|
||||
.filter(|e| self.is_enabled(e.module))
|
||||
.map(|e| &e.value)
|
||||
}
|
||||
|
||||
/// The total number of contributions of every kind, across every
|
||||
/// registered module. Mostly for tests and diagnostics.
|
||||
pub fn contribution_count(&self) -> usize {
|
||||
self.menu_items.len()
|
||||
+ self.panels.len()
|
||||
+ self.viewport_tools.len()
|
||||
+ self.inspectors.len()
|
||||
+ self.settings_pages.len()
|
||||
}
|
||||
|
||||
fn is_enabled(&self, module: &str) -> bool {
|
||||
self.enabled.get(module).copied().unwrap_or(false)
|
||||
}
|
||||
|
||||
fn expect_module(&self, helper: &str) -> &'static str {
|
||||
self.current_module.unwrap_or_else(|| {
|
||||
panic!("EditorExtensions::{helper} called outside a module's build_editor")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A minimal module that exercises every contribution kind. Used both by
|
||||
/// the unit tests here and by the integration test in `tests/src/lib.rs`
|
||||
/// (where it proves the Stage-6 criterion: a module adds a menu item, a
|
||||
/// panel, and a settings page through the public API with no editor-core
|
||||
/// edits).
|
||||
struct DemoModule;
|
||||
impl EditorModule for DemoModule {
|
||||
fn name(&self) -> &'static str {
|
||||
"demo"
|
||||
}
|
||||
fn build_editor(&self, ext: &mut EditorExtensions) {
|
||||
ext.add_menu_item("Demo/Hello", || {});
|
||||
ext.add_panel("Demo Panel", DockLocation::Right, |_ui| {});
|
||||
ext.add_viewport_tool("Demo Tool", || {});
|
||||
ext.add_inspector("DemoComponent", |_ui| {});
|
||||
ext.add_settings_page("demo", "Demo", |_ui| {});
|
||||
}
|
||||
}
|
||||
|
||||
struct OverlapModule;
|
||||
impl EditorModule for OverlapModule {
|
||||
fn name(&self) -> &'static str {
|
||||
"overlap"
|
||||
}
|
||||
fn build_editor(&self, ext: &mut EditorExtensions) {
|
||||
ext.add_menu_item("File/Quit", || {});
|
||||
ext.add_inspector("DemoComponent", |_ui| {});
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_module_registers_each_contribution_kind() {
|
||||
let mut ext = EditorExtensions::new();
|
||||
ext.add_module(DemoModule);
|
||||
|
||||
assert!(ext.has_module("demo"));
|
||||
assert!(ext.is_module_enabled("demo"));
|
||||
assert_eq!(ext.modules().collect::<Vec<_>>(), vec!["demo"]);
|
||||
|
||||
assert_eq!(
|
||||
ext.menu_item_paths().collect::<Vec<_>>(),
|
||||
vec!["Demo/Hello"]
|
||||
);
|
||||
assert_eq!(ext.panel_names().collect::<Vec<_>>(), vec!["Demo Panel"]);
|
||||
assert_eq!(
|
||||
ext.viewport_tool_names().collect::<Vec<_>>(),
|
||||
vec!["Demo Tool"]
|
||||
);
|
||||
assert!(ext.has_inspector_for("DemoComponent"));
|
||||
assert!(ext.has_settings_page_for("demo"));
|
||||
assert_eq!(ext.contribution_count(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_module_drops_every_contribution() {
|
||||
let mut ext = EditorExtensions::new();
|
||||
ext.add_module(DemoModule);
|
||||
assert_eq!(ext.contribution_count(), 5);
|
||||
|
||||
assert!(ext.remove_module("demo"));
|
||||
assert!(!ext.has_module("demo"));
|
||||
assert_eq!(ext.contribution_count(), 0);
|
||||
assert!(!ext.has_inspector_for("DemoComponent"));
|
||||
assert!(!ext.has_settings_page_for("demo"));
|
||||
|
||||
// Removing twice is a no-op.
|
||||
assert!(!ext.remove_module("demo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabling_a_module_hides_its_contributions_without_removing() {
|
||||
let mut ext = EditorExtensions::new();
|
||||
ext.add_module(DemoModule);
|
||||
assert!(ext.set_module_enabled("demo", false));
|
||||
assert!(!ext.is_module_enabled("demo"));
|
||||
|
||||
// Hidden from every shell-facing lookup…
|
||||
assert_eq!(ext.menu_item_paths().count(), 0);
|
||||
assert_eq!(ext.panel_names().count(), 0);
|
||||
assert!(!ext.has_inspector_for("DemoComponent"));
|
||||
assert!(!ext.has_settings_page_for("demo"));
|
||||
// …but still registered, so re-enabling is instant.
|
||||
assert_eq!(ext.contribution_count(), 5);
|
||||
|
||||
assert!(ext.set_module_enabled("demo", true));
|
||||
assert_eq!(ext.panel_names().count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn re_adding_a_module_replaces_its_contributions() {
|
||||
let mut ext = EditorExtensions::new();
|
||||
ext.add_module(DemoModule);
|
||||
ext.add_module(DemoModule);
|
||||
// Still one module, contributions are not duplicated.
|
||||
assert_eq!(ext.modules().collect::<Vec<_>>(), vec!["demo"]);
|
||||
assert_eq!(ext.contribution_count(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn later_module_overrides_inspector_for_same_type() {
|
||||
// Both modules register an inspector for "DemoComponent". The
|
||||
// last-registered wins, but attribution remains correct: removing the
|
||||
// override exposes nothing (the original was overwritten, not
|
||||
// stacked), which is the simple-and-predictable behavior to ship for
|
||||
// piece 5. Stacking would let a project layer multiple inspectors on
|
||||
// one type — possible future refinement, not needed now.
|
||||
let mut ext = EditorExtensions::new();
|
||||
ext.add_module(DemoModule);
|
||||
ext.add_module(OverlapModule);
|
||||
|
||||
assert!(ext.has_inspector_for("DemoComponent"));
|
||||
let owners: Vec<&'static str> = ext.inspectors.values().map(|e| e.module).collect();
|
||||
assert_eq!(owners, vec!["overlap"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modules_dont_see_each_others_contributions_when_disabled() {
|
||||
let mut ext = EditorExtensions::new();
|
||||
ext.add_module(DemoModule);
|
||||
ext.add_module(OverlapModule);
|
||||
|
||||
// Two menu items total; disabling overlap hides only its item.
|
||||
assert_eq!(ext.menu_item_paths().count(), 2);
|
||||
ext.set_module_enabled("overlap", false);
|
||||
let visible: Vec<&str> = ext.menu_item_paths().collect();
|
||||
assert_eq!(visible, vec!["Demo/Hello"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "outside a module's build_editor")]
|
||||
fn contributing_outside_build_editor_panics() {
|
||||
// Catches the easy mistake of calling add_panel on a bare
|
||||
// EditorExtensions — every contribution must be attributable to a
|
||||
// module, otherwise remove_module would leave orphans behind.
|
||||
let mut ext = EditorExtensions::new();
|
||||
ext.add_panel("Orphan", DockLocation::Center, |_ui| {});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_page_defaults_title_to_section_name() {
|
||||
struct M;
|
||||
impl EditorModule for M {
|
||||
fn name(&self) -> &'static str {
|
||||
"m"
|
||||
}
|
||||
fn build_editor(&self, ext: &mut EditorExtensions) {
|
||||
ext.add_settings_page("audio", "", |_ui| {});
|
||||
}
|
||||
}
|
||||
let mut ext = EditorExtensions::new();
|
||||
ext.add_module(M);
|
||||
let page = ext.iter_settings_pages().next().unwrap();
|
||||
assert_eq!(page.title, "audio");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,886 @@
|
||||
//! Transform gizmo math: hit testing, drag projection, and snap rounding.
|
||||
//!
|
||||
//! Stage 7 piece 6 (a): rays in, transforms out. The viewport piece
|
||||
//! renders the handles and feeds rays into [`hit_test`] and
|
||||
//! [`apply_drag`]; this module owns the geometry so all of it can be
|
||||
//! unit-tested without a window.
|
||||
//!
|
||||
//! Three modes ([`GizmoMode`]) each expose a small set of [`GizmoHandle`]s:
|
||||
//!
|
||||
//! - **Translate** — one axis arrow per world axis, plus three "plane
|
||||
//! quads" (XY/XZ/YZ) that drag along two axes at once.
|
||||
//! - **Rotate** — one circle per world axis, dragged around its normal.
|
||||
//! - **Scale** — one axis cube per world axis (non-uniform along that
|
||||
//! axis) plus one center handle for uniform scale.
|
||||
//!
|
||||
//! Holding the snap modifier rounds the drag result to a configurable
|
||||
//! step ([`SnapSettings`]): grid distance for translate, angle for
|
||||
//! rotate, factor step for scale. Snap is applied to the *delta* from
|
||||
//! the drag's starting transform, never to the starting transform
|
||||
//! itself, so the result lines up with a fresh selection that already
|
||||
//! sits between grid points.
|
||||
//!
|
||||
//! The gizmo lives at the entity's translation (its rotation and scale
|
||||
//! do not transform the handles — they always point along world axes).
|
||||
//! The shipped viewport renders this "world-space" gizmo; a future
|
||||
//! "local-space" toggle would orient the handles by the entity rotation
|
||||
//! before hit testing, which is a small change in [`world_axis`] /
|
||||
//! [`world_plane`].
|
||||
|
||||
use oxide_engine::math::{Plane, Quat, Ray, Transform, Vec3};
|
||||
|
||||
/// Which transform tool the gizmo is showing.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum GizmoMode {
|
||||
/// Axis arrows + plane quads. Hotkey **W**.
|
||||
Translate,
|
||||
/// Axis circles. Hotkey **E**.
|
||||
Rotate,
|
||||
/// Axis cubes + center uniform. Hotkey **R**.
|
||||
Scale,
|
||||
}
|
||||
|
||||
impl GizmoMode {
|
||||
/// The label shown in the status bar / toolbar.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
GizmoMode::Translate => "Translate",
|
||||
GizmoMode::Rotate => "Rotate",
|
||||
GizmoMode::Scale => "Scale",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One of the three world axes.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Axis3 {
|
||||
X,
|
||||
Y,
|
||||
Z,
|
||||
}
|
||||
|
||||
impl Axis3 {
|
||||
/// All three axes in stable order.
|
||||
pub const ALL: [Axis3; 3] = [Axis3::X, Axis3::Y, Axis3::Z];
|
||||
|
||||
/// Unit vector along this axis.
|
||||
pub fn unit(self) -> Vec3 {
|
||||
match self {
|
||||
Axis3::X => Vec3::X,
|
||||
Axis3::Y => Vec3::Y,
|
||||
Axis3::Z => Vec3::Z,
|
||||
}
|
||||
}
|
||||
|
||||
/// Zero-based index for indexing into per-component arrays.
|
||||
pub fn index(self) -> usize {
|
||||
match self {
|
||||
Axis3::X => 0,
|
||||
Axis3::Y => 1,
|
||||
Axis3::Z => 2,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One of the three world-aligned planes (XY = plane whose normal is Z, …).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PlaneAxis {
|
||||
XY,
|
||||
XZ,
|
||||
YZ,
|
||||
}
|
||||
|
||||
impl PlaneAxis {
|
||||
/// All three planes in stable order.
|
||||
pub const ALL: [PlaneAxis; 3] = [PlaneAxis::XY, PlaneAxis::XZ, PlaneAxis::YZ];
|
||||
|
||||
/// Unit normal to the plane.
|
||||
pub fn normal(self) -> Vec3 {
|
||||
match self {
|
||||
PlaneAxis::XY => Vec3::Z,
|
||||
PlaneAxis::XZ => Vec3::Y,
|
||||
PlaneAxis::YZ => Vec3::X,
|
||||
}
|
||||
}
|
||||
|
||||
/// The two axes that lie in this plane (in stable order).
|
||||
pub fn axes(self) -> (Vec3, Vec3) {
|
||||
match self {
|
||||
PlaneAxis::XY => (Vec3::X, Vec3::Y),
|
||||
PlaneAxis::XZ => (Vec3::X, Vec3::Z),
|
||||
PlaneAxis::YZ => (Vec3::Y, Vec3::Z),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One interactive gizmo handle.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum GizmoHandle {
|
||||
TranslateAxis(Axis3),
|
||||
TranslatePlane(PlaneAxis),
|
||||
RotateAxis(Axis3),
|
||||
ScaleAxis(Axis3),
|
||||
/// The center "uniform scale" cube.
|
||||
ScaleUniform,
|
||||
}
|
||||
|
||||
impl GizmoHandle {
|
||||
/// The mode this handle belongs to.
|
||||
pub fn mode(self) -> GizmoMode {
|
||||
match self {
|
||||
GizmoHandle::TranslateAxis(_) | GizmoHandle::TranslatePlane(_) => GizmoMode::Translate,
|
||||
GizmoHandle::RotateAxis(_) => GizmoMode::Rotate,
|
||||
GizmoHandle::ScaleAxis(_) | GizmoHandle::ScaleUniform => GizmoMode::Scale,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Snap step sizes applied during a drag while the snap modifier is held.
|
||||
///
|
||||
/// Each step is applied to the **delta** the drag has accumulated — never
|
||||
/// to the starting transform — so a selection that already sits between
|
||||
/// grid points keeps its starting offset.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct SnapSettings {
|
||||
/// Translation grid in world units (default `0.25`).
|
||||
pub distance: f32,
|
||||
/// Rotation step in degrees (default `15`).
|
||||
pub angle_deg: f32,
|
||||
/// Scale step (default `0.1` — factors round to the nearest `0.1`).
|
||||
pub scale: f32,
|
||||
}
|
||||
|
||||
impl Default for SnapSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
distance: 0.25,
|
||||
angle_deg: 15.0,
|
||||
scale: 0.1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One in-progress gizmo drag.
|
||||
///
|
||||
/// Created by the viewport when the user clicks a handle, kept alive while
|
||||
/// the button is held, and dropped on release. Each frame the viewport
|
||||
/// calls [`apply_drag`] with the new pointer ray to compute the new
|
||||
/// transform.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct GizmoDrag {
|
||||
/// The handle the user grabbed.
|
||||
pub handle: GizmoHandle,
|
||||
/// The entity's transform when the drag started — never mutated; the
|
||||
/// drag computes a delta from this and applies it fresh each frame.
|
||||
pub start_transform: Transform,
|
||||
/// The world-space point where the drag began. For an axis handle
|
||||
/// this is the closest point on the axis to the click ray; for a
|
||||
/// plane handle, the ray-plane intersection; for a circle handle,
|
||||
/// the projection of the ray hit onto the rotation plane.
|
||||
pub start_anchor: Vec3,
|
||||
/// Handle-specific reference scalar set at drag start. For
|
||||
/// [`GizmoHandle::ScaleUniform`] it is the world-space distance that
|
||||
/// corresponds to one *factor* of change — the gizmo size — so a
|
||||
/// drag away from the entity by that much grows the scale by ~1.0.
|
||||
/// Unused (set to `1.0`) for every other handle.
|
||||
pub reference: f32,
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Hit testing
|
||||
// =====================================================================
|
||||
|
||||
/// Tries every handle the given mode exposes and returns the one closest
|
||||
/// to `ray`, or `None` if none are within `pixel_tolerance_world` of any
|
||||
/// handle. `gizmo_size` is the per-axis world length of the arrow / cube
|
||||
/// handles; both inputs are computed by the viewport based on the
|
||||
/// camera's distance to the gizmo origin (so the gizmo stays the same
|
||||
/// pixel size at any zoom).
|
||||
pub fn hit_test(
|
||||
ray: &Ray,
|
||||
transform: &Transform,
|
||||
mode: GizmoMode,
|
||||
gizmo_size: f32,
|
||||
pixel_tolerance_world: f32,
|
||||
) -> Option<GizmoHandle> {
|
||||
let origin = transform.translation;
|
||||
let mut best: Option<(f32, GizmoHandle)> = None;
|
||||
let mut consider = |dist_sq: f32, handle: GizmoHandle| {
|
||||
if dist_sq.is_finite() && dist_sq < pixel_tolerance_world * pixel_tolerance_world {
|
||||
match best {
|
||||
Some((b, _)) if b <= dist_sq => {}
|
||||
_ => best = Some((dist_sq, handle)),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
match mode {
|
||||
GizmoMode::Translate => {
|
||||
for axis in Axis3::ALL {
|
||||
let d = axis_segment_distance_sq(origin, axis.unit(), gizmo_size, ray);
|
||||
consider(d, GizmoHandle::TranslateAxis(axis));
|
||||
}
|
||||
for plane in PlaneAxis::ALL {
|
||||
if let Some(d) = plane_quad_distance_sq(origin, plane, gizmo_size, ray) {
|
||||
consider(d, GizmoHandle::TranslatePlane(plane));
|
||||
}
|
||||
}
|
||||
}
|
||||
GizmoMode::Rotate => {
|
||||
for axis in Axis3::ALL {
|
||||
if let Some(d) = circle_distance_sq(origin, axis.unit(), gizmo_size, ray) {
|
||||
consider(d, GizmoHandle::RotateAxis(axis));
|
||||
}
|
||||
}
|
||||
}
|
||||
GizmoMode::Scale => {
|
||||
for axis in Axis3::ALL {
|
||||
let d = axis_segment_distance_sq(origin, axis.unit(), gizmo_size, ray);
|
||||
consider(d, GizmoHandle::ScaleAxis(axis));
|
||||
}
|
||||
// Uniform handle: the center cube.
|
||||
let d = ray.distance_to_point(origin).powi(2);
|
||||
consider(d, GizmoHandle::ScaleUniform);
|
||||
}
|
||||
}
|
||||
|
||||
best.map(|(_, h)| h)
|
||||
}
|
||||
|
||||
/// Squared distance from `ray` to the segment from `origin + axis * inner`
|
||||
/// to `origin + axis * length`, with the closest point clamped to the
|
||||
/// segment. Used for axis arrows.
|
||||
///
|
||||
/// The leading `inner` offset (~20% of length) keeps the segment clear of
|
||||
/// the central cube area, so a ray that pierces the gizmo's center is
|
||||
/// claimed by the uniform / center handle rather than by every axis at
|
||||
/// once.
|
||||
fn axis_segment_distance_sq(origin: Vec3, axis: Vec3, length: f32, ray: &Ray) -> f32 {
|
||||
let inner = length * 0.2;
|
||||
let pt_on_axis = closest_point_on_line(origin, axis, ray);
|
||||
let along = (pt_on_axis - origin).dot(axis).clamp(inner, length);
|
||||
let clamped = origin + axis * along;
|
||||
ray.distance_to_point(clamped).powi(2)
|
||||
}
|
||||
|
||||
/// Distance² from `ray` to a square plane quad at `origin` (size × size),
|
||||
/// or `None` when the ray is parallel to the plane. Used for translate
|
||||
/// plane handles.
|
||||
fn plane_quad_distance_sq(origin: Vec3, plane: PlaneAxis, size: f32, ray: &Ray) -> Option<f32> {
|
||||
let p = Plane::from_point_normal(origin, plane.normal());
|
||||
let t = p.ray_intersection(ray)?;
|
||||
let hit = ray.at(t);
|
||||
let (a, b) = plane.axes();
|
||||
// The plane quad spans roughly the *outer* part of the gizmo: from
|
||||
// ~0.3*size to ~0.7*size on each axis, away from the central cube
|
||||
// and clear of the axis arrows.
|
||||
let inner = size * 0.3;
|
||||
let outer = size * 0.7;
|
||||
let da = (hit - origin).dot(a);
|
||||
let db = (hit - origin).dot(b);
|
||||
if da >= inner && da <= outer && db >= inner && db <= outer {
|
||||
// Inside the quad — perfect hit, no distance penalty.
|
||||
Some(0.0)
|
||||
} else {
|
||||
// Outside — penalize by distance from the nearest edge so handle
|
||||
// priority degrades smoothly with miss distance.
|
||||
let clamped = origin + a * da.clamp(inner, outer) + b * db.clamp(inner, outer);
|
||||
Some(ray.distance_to_point(clamped).powi(2))
|
||||
}
|
||||
}
|
||||
|
||||
/// Distance² from `ray` to the circle of radius `r` lying in the plane
|
||||
/// through `origin` with the given `axis` as normal, or `None` when the
|
||||
/// ray is parallel to the plane. Used for rotate circles.
|
||||
fn circle_distance_sq(origin: Vec3, axis: Vec3, r: f32, ray: &Ray) -> Option<f32> {
|
||||
let p = Plane::from_point_normal(origin, axis);
|
||||
let t = p.ray_intersection(ray)?;
|
||||
let hit = ray.at(t);
|
||||
// Project onto the plane and find the closest circle point.
|
||||
let v = hit - origin;
|
||||
let in_plane = v - axis * v.dot(axis);
|
||||
let len = in_plane.length();
|
||||
if len < 1e-6 {
|
||||
// Right at the center — distance to circle is `r` itself.
|
||||
return Some(r * r);
|
||||
}
|
||||
let on_circle = origin + in_plane * (r / len);
|
||||
Some(ray.distance_to_point(on_circle).powi(2))
|
||||
}
|
||||
|
||||
/// Closest point on the line through `origin` along the unit `dir`
|
||||
/// vector to `ray`. Result is unconstrained — clamping to a segment is
|
||||
/// the caller's job.
|
||||
pub fn closest_point_on_line(origin: Vec3, dir: Vec3, ray: &Ray) -> Vec3 {
|
||||
let r = ray.direction;
|
||||
let w = origin - ray.origin;
|
||||
let d = dir.dot(r);
|
||||
let denom = 1.0 - d * d;
|
||||
if denom.abs() < 1e-6 {
|
||||
// Ray parallel to line — closest point on the line is the origin.
|
||||
return origin;
|
||||
}
|
||||
let s = (dir.dot(-w) - r.dot(-w) * d) / denom;
|
||||
origin + dir * s
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Drag application
|
||||
// =====================================================================
|
||||
|
||||
/// Applies the in-progress `drag` to its starting transform using the
|
||||
/// pointer's current ray, returning the new transform. Pure: same inputs
|
||||
/// always yield the same output.
|
||||
///
|
||||
/// When `snap` is `Some`, the per-mode delta is rounded to the appropriate
|
||||
/// step before being applied (so the snap modifier can be toggled mid-
|
||||
/// drag and the result lines up to the grid regardless of how the user
|
||||
/// got there).
|
||||
pub fn apply_drag(drag: &GizmoDrag, current_ray: &Ray, snap: Option<&SnapSettings>) -> Transform {
|
||||
match drag.handle {
|
||||
GizmoHandle::TranslateAxis(axis) => translate_along_axis(drag, current_ray, axis, snap),
|
||||
GizmoHandle::TranslatePlane(plane) => translate_in_plane(drag, current_ray, plane, snap),
|
||||
GizmoHandle::RotateAxis(axis) => rotate_around_axis(drag, current_ray, axis, snap),
|
||||
GizmoHandle::ScaleAxis(axis) => scale_along_axis(drag, current_ray, axis, snap),
|
||||
GizmoHandle::ScaleUniform => scale_uniform(drag, current_ray, snap),
|
||||
}
|
||||
}
|
||||
|
||||
/// Rounds `value` to the nearest integer multiple of `step`. Returns
|
||||
/// `value` unchanged when `step` is non-positive.
|
||||
pub fn snap_round(value: f32, step: f32) -> f32 {
|
||||
if step <= 0.0 {
|
||||
return value;
|
||||
}
|
||||
(value / step).round() * step
|
||||
}
|
||||
|
||||
fn translate_along_axis(
|
||||
drag: &GizmoDrag,
|
||||
current_ray: &Ray,
|
||||
axis: Axis3,
|
||||
snap: Option<&SnapSettings>,
|
||||
) -> Transform {
|
||||
let dir = axis.unit();
|
||||
let now = closest_point_on_line(drag.start_transform.translation, dir, current_ray);
|
||||
let mut delta = (now - drag.start_anchor).dot(dir);
|
||||
if let Some(s) = snap {
|
||||
delta = snap_round(delta, s.distance);
|
||||
}
|
||||
let mut t = drag.start_transform;
|
||||
t.translation += dir * delta;
|
||||
t
|
||||
}
|
||||
|
||||
fn translate_in_plane(
|
||||
drag: &GizmoDrag,
|
||||
current_ray: &Ray,
|
||||
plane: PlaneAxis,
|
||||
snap: Option<&SnapSettings>,
|
||||
) -> Transform {
|
||||
let p = Plane::from_point_normal(drag.start_transform.translation, plane.normal());
|
||||
let Some(t) = p.ray_intersection(current_ray) else {
|
||||
return drag.start_transform;
|
||||
};
|
||||
let now = current_ray.at(t);
|
||||
let (a, b) = plane.axes();
|
||||
let mut da = (now - drag.start_anchor).dot(a);
|
||||
let mut db = (now - drag.start_anchor).dot(b);
|
||||
if let Some(s) = snap {
|
||||
da = snap_round(da, s.distance);
|
||||
db = snap_round(db, s.distance);
|
||||
}
|
||||
let mut out = drag.start_transform;
|
||||
out.translation += a * da + b * db;
|
||||
out
|
||||
}
|
||||
|
||||
fn rotate_around_axis(
|
||||
drag: &GizmoDrag,
|
||||
current_ray: &Ray,
|
||||
axis: Axis3,
|
||||
snap: Option<&SnapSettings>,
|
||||
) -> Transform {
|
||||
let axis_dir = axis.unit();
|
||||
let origin = drag.start_transform.translation;
|
||||
let plane = Plane::from_point_normal(origin, axis_dir);
|
||||
let Some(t) = plane.ray_intersection(current_ray) else {
|
||||
return drag.start_transform;
|
||||
};
|
||||
let now = current_ray.at(t);
|
||||
// Vectors from origin to start / current points, both already lying
|
||||
// in the rotation plane.
|
||||
let from = (drag.start_anchor - origin).normalize_or_zero();
|
||||
let to = (now - origin).normalize_or_zero();
|
||||
if from.length_squared() < 1e-6 || to.length_squared() < 1e-6 {
|
||||
return drag.start_transform;
|
||||
}
|
||||
// Signed angle around `axis_dir`.
|
||||
let cross = from.cross(to);
|
||||
let sin = cross.dot(axis_dir);
|
||||
let cos = from.dot(to).clamp(-1.0, 1.0);
|
||||
let mut angle = sin.atan2(cos);
|
||||
if let Some(s) = snap {
|
||||
let step = s.angle_deg.to_radians();
|
||||
angle = snap_round(angle, step);
|
||||
}
|
||||
let rotation = Quat::from_axis_angle(axis_dir, angle);
|
||||
let mut out = drag.start_transform;
|
||||
out.rotation = rotation * drag.start_transform.rotation;
|
||||
out
|
||||
}
|
||||
|
||||
fn scale_along_axis(
|
||||
drag: &GizmoDrag,
|
||||
current_ray: &Ray,
|
||||
axis: Axis3,
|
||||
snap: Option<&SnapSettings>,
|
||||
) -> Transform {
|
||||
let dir = axis.unit();
|
||||
let origin = drag.start_transform.translation;
|
||||
let now = closest_point_on_line(origin, dir, current_ray);
|
||||
let start_along = (drag.start_anchor - origin).dot(dir);
|
||||
if start_along.abs() < 1e-4 {
|
||||
return drag.start_transform;
|
||||
}
|
||||
let now_along = (now - origin).dot(dir);
|
||||
let mut factor = now_along / start_along;
|
||||
if let Some(s) = snap {
|
||||
factor = snap_round(factor, s.scale);
|
||||
}
|
||||
// Clamp to a small positive floor so a runaway drag can't flip scale
|
||||
// to zero / negative (which crashes inverse-transform math elsewhere).
|
||||
factor = factor.max(0.001);
|
||||
let mut out = drag.start_transform;
|
||||
let mut s = drag.start_transform.scale.to_array();
|
||||
s[axis.index()] *= factor;
|
||||
out.scale = Vec3::from_array(s);
|
||||
out
|
||||
}
|
||||
|
||||
fn scale_uniform(drag: &GizmoDrag, current_ray: &Ray, snap: Option<&SnapSettings>) -> Transform {
|
||||
let origin = drag.start_transform.translation;
|
||||
// Perpendicular distance from the current ray to the entity, in world
|
||||
// units. The *delta* from the click's perpendicular distance, divided
|
||||
// by `drag.reference` (the gizmo size), is the additive change in
|
||||
// scale factor. Avoids the previous `now_dist / start_dist` formula's
|
||||
// blow-up when the click landed near the gizmo center (start_dist
|
||||
// ≈ 0) and the divide spiked the factor.
|
||||
let start_perp = (drag.start_anchor - origin).length();
|
||||
let now_perp = current_ray.distance_to_point(origin);
|
||||
let reference = drag.reference.max(1e-4);
|
||||
let mut factor = 1.0 + (now_perp - start_perp) / reference;
|
||||
if let Some(s) = snap {
|
||||
factor = snap_round(factor, s.scale);
|
||||
}
|
||||
// Floor at a small positive value so a runaway drag past the origin
|
||||
// can't flip scale negative (which crashes inverse-transform math).
|
||||
factor = factor.max(0.001);
|
||||
let mut out = drag.start_transform;
|
||||
out.scale = drag.start_transform.scale * factor;
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use oxide_engine::math::Vec3;
|
||||
use std::f32::consts::FRAC_PI_2;
|
||||
|
||||
fn id_transform_at(p: Vec3) -> Transform {
|
||||
Transform {
|
||||
translation: p,
|
||||
rotation: Quat::IDENTITY,
|
||||
scale: Vec3::ONE,
|
||||
}
|
||||
}
|
||||
|
||||
// --- Hit testing ----------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn hit_test_picks_translate_axis_under_cursor() {
|
||||
// Camera looking straight down -Z at origin.
|
||||
let ray = Ray::new(Vec3::new(0.5, 0.0, 5.0), Vec3::new(0.0, 0.0, -1.0));
|
||||
let hit = hit_test(
|
||||
&ray,
|
||||
&id_transform_at(Vec3::ZERO),
|
||||
GizmoMode::Translate,
|
||||
1.0,
|
||||
0.1,
|
||||
);
|
||||
assert_eq!(hit, Some(GizmoHandle::TranslateAxis(Axis3::X)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hit_test_picks_translate_plane_inside_quad() {
|
||||
let ray = Ray::new(Vec3::new(0.5, 0.5, 5.0), Vec3::new(0.0, 0.0, -1.0));
|
||||
let hit = hit_test(
|
||||
&ray,
|
||||
&id_transform_at(Vec3::ZERO),
|
||||
GizmoMode::Translate,
|
||||
1.0,
|
||||
0.1,
|
||||
);
|
||||
assert_eq!(hit, Some(GizmoHandle::TranslatePlane(PlaneAxis::XY)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hit_test_picks_rotate_circle_on_radius() {
|
||||
// Camera looking down +X, so the rotate-X circle is in YZ plane.
|
||||
// Aim at a point on that circle of radius 1.
|
||||
let ray = Ray::new(Vec3::new(5.0, 1.0, 0.0), Vec3::new(-1.0, 0.0, 0.0));
|
||||
let hit = hit_test(
|
||||
&ray,
|
||||
&id_transform_at(Vec3::ZERO),
|
||||
GizmoMode::Rotate,
|
||||
1.0,
|
||||
0.1,
|
||||
);
|
||||
assert_eq!(hit, Some(GizmoHandle::RotateAxis(Axis3::X)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hit_test_misses_when_ray_far_from_handles() {
|
||||
let ray = Ray::new(Vec3::new(50.0, 50.0, 5.0), Vec3::new(0.0, 0.0, -1.0));
|
||||
let hit = hit_test(
|
||||
&ray,
|
||||
&id_transform_at(Vec3::ZERO),
|
||||
GizmoMode::Translate,
|
||||
1.0,
|
||||
0.1,
|
||||
);
|
||||
assert!(hit.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hit_test_picks_scale_uniform_at_center() {
|
||||
let ray = Ray::new(Vec3::ZERO + Vec3::Z * 5.0, -Vec3::Z);
|
||||
let hit = hit_test(
|
||||
&ray,
|
||||
&id_transform_at(Vec3::ZERO),
|
||||
GizmoMode::Scale,
|
||||
1.0,
|
||||
0.1,
|
||||
);
|
||||
assert_eq!(hit, Some(GizmoHandle::ScaleUniform));
|
||||
}
|
||||
|
||||
// --- Translate drag -------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn translate_axis_drag_moves_along_axis_only() {
|
||||
let start = id_transform_at(Vec3::ZERO);
|
||||
let drag = GizmoDrag {
|
||||
handle: GizmoHandle::TranslateAxis(Axis3::X),
|
||||
start_transform: start,
|
||||
start_anchor: Vec3::ZERO,
|
||||
reference: 1.0,
|
||||
};
|
||||
// Ray that closest-approaches X at x = 3.
|
||||
let cur = Ray::new(Vec3::new(3.0, 0.0, 5.0), Vec3::new(0.0, 0.0, -1.0));
|
||||
let out = apply_drag(&drag, &cur, None);
|
||||
assert!((out.translation.x - 3.0).abs() < 1e-4);
|
||||
assert!(out.translation.y.abs() < 1e-4);
|
||||
assert!(out.translation.z.abs() < 1e-4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn translate_axis_snap_rounds_to_distance_step() {
|
||||
let start = id_transform_at(Vec3::ZERO);
|
||||
let drag = GizmoDrag {
|
||||
handle: GizmoHandle::TranslateAxis(Axis3::X),
|
||||
start_transform: start,
|
||||
start_anchor: Vec3::ZERO,
|
||||
reference: 1.0,
|
||||
};
|
||||
let cur = Ray::new(Vec3::new(0.74, 0.0, 5.0), Vec3::new(0.0, 0.0, -1.0));
|
||||
let snap = SnapSettings {
|
||||
distance: 0.25,
|
||||
..SnapSettings::default()
|
||||
};
|
||||
let out = apply_drag(&drag, &cur, Some(&snap));
|
||||
// 0.74 rounds to 0.75.
|
||||
assert!((out.translation.x - 0.75).abs() < 1e-4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn translate_plane_drag_moves_in_both_axes() {
|
||||
let start = id_transform_at(Vec3::ZERO);
|
||||
let drag = GizmoDrag {
|
||||
handle: GizmoHandle::TranslatePlane(PlaneAxis::XY),
|
||||
start_transform: start,
|
||||
start_anchor: Vec3::ZERO,
|
||||
reference: 1.0,
|
||||
};
|
||||
let cur = Ray::new(Vec3::new(2.0, 3.0, 5.0), Vec3::new(0.0, 0.0, -1.0));
|
||||
let out = apply_drag(&drag, &cur, None);
|
||||
assert!((out.translation.x - 2.0).abs() < 1e-4);
|
||||
assert!((out.translation.y - 3.0).abs() < 1e-4);
|
||||
assert!(out.translation.z.abs() < 1e-4);
|
||||
}
|
||||
|
||||
// --- Rotate drag ----------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn rotate_around_x_axis_produces_quarter_turn() {
|
||||
let start = id_transform_at(Vec3::ZERO);
|
||||
// Click at the +Y point on the YZ circle.
|
||||
let drag = GizmoDrag {
|
||||
handle: GizmoHandle::RotateAxis(Axis3::X),
|
||||
start_transform: start,
|
||||
start_anchor: Vec3::new(0.0, 1.0, 0.0),
|
||||
reference: 1.0,
|
||||
};
|
||||
// Drag to the +Z point — 90° around +X (right-hand rule from +Y → +Z).
|
||||
let cur = Ray::new(Vec3::new(5.0, 0.0, 1.0), Vec3::new(-1.0, 0.0, 0.0));
|
||||
let out = apply_drag(&drag, &cur, None);
|
||||
// Apply the rotation to Y and confirm it lands on Z.
|
||||
let rotated = out.rotation * Vec3::Y;
|
||||
assert!((rotated - Vec3::Z).length() < 1e-4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotate_snap_rounds_to_angle_step() {
|
||||
let start = id_transform_at(Vec3::ZERO);
|
||||
let drag = GizmoDrag {
|
||||
handle: GizmoHandle::RotateAxis(Axis3::X),
|
||||
start_transform: start,
|
||||
start_anchor: Vec3::new(0.0, 1.0, 0.0),
|
||||
reference: 1.0,
|
||||
};
|
||||
// Drag to ~89°: should snap to 90° with a 15° step.
|
||||
let cur = Ray::new(Vec3::new(5.0, 0.0175, 0.9998), Vec3::new(-1.0, 0.0, 0.0));
|
||||
let snap = SnapSettings {
|
||||
angle_deg: 15.0,
|
||||
..SnapSettings::default()
|
||||
};
|
||||
let out = apply_drag(&drag, &cur, Some(&snap));
|
||||
let rotated = out.rotation * Vec3::Y;
|
||||
// A 90° rotation around X maps Y → Z exactly.
|
||||
assert!(
|
||||
(rotated - Vec3::Z).length() < 1e-3,
|
||||
"expected snap to 90°, got {rotated:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotate_no_movement_returns_start_transform() {
|
||||
let start = id_transform_at(Vec3::ZERO);
|
||||
let drag = GizmoDrag {
|
||||
handle: GizmoHandle::RotateAxis(Axis3::Y),
|
||||
start_transform: start,
|
||||
start_anchor: Vec3::new(1.0, 0.0, 0.0),
|
||||
reference: 1.0,
|
||||
};
|
||||
// Ray pointing back at the anchor (no rotation).
|
||||
let cur = Ray::new(Vec3::new(5.0, 0.0, 0.0), Vec3::new(-1.0, 0.0, 0.0));
|
||||
let out = apply_drag(&drag, &cur, None);
|
||||
// Quaternion should be ~identity.
|
||||
let rotated = out.rotation * Vec3::Z;
|
||||
assert!((rotated - Vec3::Z).length() < 1e-3);
|
||||
}
|
||||
|
||||
// --- Scale drag -----------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn scale_axis_doubles_when_pointer_moves_to_2x_anchor() {
|
||||
let start = id_transform_at(Vec3::ZERO);
|
||||
let drag = GizmoDrag {
|
||||
handle: GizmoHandle::ScaleAxis(Axis3::X),
|
||||
start_transform: start,
|
||||
start_anchor: Vec3::new(1.0, 0.0, 0.0),
|
||||
reference: 1.0,
|
||||
};
|
||||
let cur = Ray::new(Vec3::new(2.0, 0.0, 5.0), Vec3::new(0.0, 0.0, -1.0));
|
||||
let out = apply_drag(&drag, &cur, None);
|
||||
assert!((out.scale.x - 2.0).abs() < 1e-4);
|
||||
assert!((out.scale.y - 1.0).abs() < 1e-4);
|
||||
assert!((out.scale.z - 1.0).abs() < 1e-4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_axis_floors_at_small_positive_value() {
|
||||
let start = id_transform_at(Vec3::ZERO);
|
||||
let drag = GizmoDrag {
|
||||
handle: GizmoHandle::ScaleAxis(Axis3::Y),
|
||||
start_transform: start,
|
||||
start_anchor: Vec3::new(0.0, 1.0, 0.0),
|
||||
reference: 1.0,
|
||||
};
|
||||
// Drag well past the origin — would naively give factor = -3.
|
||||
let cur = Ray::new(Vec3::new(0.0, -3.0, 5.0), Vec3::new(0.0, 0.0, -1.0));
|
||||
let out = apply_drag(&drag, &cur, None);
|
||||
// Clamped to a small positive floor — never negative.
|
||||
assert!(out.scale.y > 0.0);
|
||||
assert!(out.scale.y < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_uniform_doubles_along_every_axis() {
|
||||
let mut start = id_transform_at(Vec3::ZERO);
|
||||
start.scale = Vec3::new(1.0, 2.0, 3.0);
|
||||
// With reference = 1.0, dragging the perpendicular distance from
|
||||
// 1.0 (the start anchor) to 2.0 grows the factor by exactly 1.0.
|
||||
let drag = GizmoDrag {
|
||||
handle: GizmoHandle::ScaleUniform,
|
||||
start_transform: start,
|
||||
start_anchor: Vec3::new(1.0, 0.0, 0.0),
|
||||
reference: 1.0,
|
||||
};
|
||||
let cur = Ray::new(Vec3::new(2.0, 0.0, 5.0), Vec3::new(0.0, 0.0, -1.0));
|
||||
let out = apply_drag(&drag, &cur, None);
|
||||
assert!((out.scale.x - 2.0).abs() < 1e-4);
|
||||
assert!((out.scale.y - 4.0).abs() < 1e-4);
|
||||
assert!((out.scale.z - 6.0).abs() < 1e-4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_uniform_is_not_supersensitive_when_click_lands_near_center() {
|
||||
// The old `now_dist / start_dist` formula blew up when a click
|
||||
// landed near the gizmo center (start_dist ≈ 0). The new formula
|
||||
// is additive in the perpendicular delta, so a tiny start_dist
|
||||
// does not amplify the factor.
|
||||
let start = id_transform_at(Vec3::ZERO);
|
||||
let drag = GizmoDrag {
|
||||
handle: GizmoHandle::ScaleUniform,
|
||||
start_transform: start,
|
||||
// Click landed near the center (perp distance 0.05).
|
||||
start_anchor: Vec3::new(0.05, 0.0, 0.0),
|
||||
reference: 1.0,
|
||||
};
|
||||
// Drag the pointer to a new perp distance of 0.5 (so delta = 0.45).
|
||||
let cur = Ray::new(Vec3::new(0.5, 0.0, 5.0), Vec3::new(0.0, 0.0, -1.0));
|
||||
let out = apply_drag(&drag, &cur, None);
|
||||
// factor = 1.0 + 0.45 / 1.0 = 1.45 — gentle. The old formula would
|
||||
// give 0.5 / 0.05 = 10.0, which is what the maintainer reported.
|
||||
assert!(
|
||||
(out.scale.x - 1.45).abs() < 1e-3,
|
||||
"expected gentle factor 1.45, got scale {:?}",
|
||||
out.scale
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_uniform_snap_rounds_factor() {
|
||||
// Reported by the maintainer: uniform-scale snap did nothing. The
|
||||
// old formula's runaway factor swamped the snap step; the new
|
||||
// additive formula puts the factor in a sane range so snap_round
|
||||
// can hit a sensible step.
|
||||
let start = id_transform_at(Vec3::ZERO);
|
||||
let drag = GizmoDrag {
|
||||
handle: GizmoHandle::ScaleUniform,
|
||||
start_transform: start,
|
||||
start_anchor: Vec3::new(0.5, 0.0, 0.0),
|
||||
reference: 1.0,
|
||||
};
|
||||
// Pointer at perp distance ~1.32 → factor 1 + (1.32 - 0.5) = 1.82
|
||||
// → snaps to 1.8 (step 0.1).
|
||||
let cur = Ray::new(Vec3::new(1.32, 0.0, 5.0), Vec3::new(0.0, 0.0, -1.0));
|
||||
let snap = SnapSettings {
|
||||
scale: 0.1,
|
||||
..SnapSettings::default()
|
||||
};
|
||||
let out = apply_drag(&drag, &cur, Some(&snap));
|
||||
assert!(
|
||||
(out.scale.x - 1.8).abs() < 1e-3,
|
||||
"uniform-scale snap should round 1.82 to 1.8, got {:?}",
|
||||
out.scale
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_snap_rounds_factor_to_step() {
|
||||
let start = id_transform_at(Vec3::ZERO);
|
||||
let drag = GizmoDrag {
|
||||
handle: GizmoHandle::ScaleAxis(Axis3::X),
|
||||
start_transform: start,
|
||||
start_anchor: Vec3::new(1.0, 0.0, 0.0),
|
||||
reference: 1.0,
|
||||
};
|
||||
// Pointer at 1.83 → factor 1.83 → snaps to 1.8 (step 0.1).
|
||||
let cur = Ray::new(Vec3::new(1.83, 0.0, 5.0), Vec3::new(0.0, 0.0, -1.0));
|
||||
let snap = SnapSettings {
|
||||
scale: 0.1,
|
||||
..SnapSettings::default()
|
||||
};
|
||||
let out = apply_drag(&drag, &cur, Some(&snap));
|
||||
assert!((out.scale.x - 1.8).abs() < 1e-4);
|
||||
}
|
||||
|
||||
// --- Helpers --------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn closest_point_on_axis_recovers_perpendicular_drop() {
|
||||
let ray = Ray::new(Vec3::new(3.0, 4.0, 5.0), Vec3::new(0.0, 0.0, -1.0));
|
||||
let pt = closest_point_on_line(Vec3::ZERO, Vec3::X, &ray);
|
||||
// Drop a perpendicular onto the X axis — should land at (3, 0, 0).
|
||||
assert!((pt - Vec3::new(3.0, 0.0, 0.0)).length() < 1e-4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closest_point_on_axis_handles_parallel_ray() {
|
||||
// Ray along X overlaps the X axis exactly — returns the axis origin.
|
||||
let ray = Ray::new(Vec3::new(0.0, 2.0, 0.0), Vec3::X);
|
||||
let pt = closest_point_on_line(Vec3::ZERO, Vec3::X, &ray);
|
||||
assert_eq!(pt, Vec3::ZERO);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snap_round_to_step() {
|
||||
assert_eq!(snap_round(0.74, 0.25), 0.75);
|
||||
assert_eq!(snap_round(0.12, 0.25), 0.0);
|
||||
assert_eq!(snap_round(-0.74, 0.25), -0.75);
|
||||
// Zero / negative step disables snapping.
|
||||
assert_eq!(snap_round(0.74, 0.0), 0.74);
|
||||
assert_eq!(snap_round(0.74, -0.5), 0.74);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gizmo_handle_maps_to_mode() {
|
||||
assert_eq!(
|
||||
GizmoHandle::TranslateAxis(Axis3::X).mode(),
|
||||
GizmoMode::Translate
|
||||
);
|
||||
assert_eq!(
|
||||
GizmoHandle::TranslatePlane(PlaneAxis::XY).mode(),
|
||||
GizmoMode::Translate
|
||||
);
|
||||
assert_eq!(GizmoHandle::RotateAxis(Axis3::Z).mode(), GizmoMode::Rotate);
|
||||
assert_eq!(GizmoHandle::ScaleAxis(Axis3::Y).mode(), GizmoMode::Scale);
|
||||
assert_eq!(GizmoHandle::ScaleUniform.mode(), GizmoMode::Scale);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn axis3_unit_and_index_align() {
|
||||
for axis in Axis3::ALL {
|
||||
let unit = axis.unit();
|
||||
let idx = axis.index();
|
||||
let mut expected = [0.0; 3];
|
||||
expected[idx] = 1.0;
|
||||
assert_eq!(unit.to_array(), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plane_axis_normal_is_orthogonal_to_its_axes() {
|
||||
for plane in PlaneAxis::ALL {
|
||||
let n = plane.normal();
|
||||
let (a, b) = plane.axes();
|
||||
assert!(n.dot(a).abs() < 1e-6);
|
||||
assert!(n.dot(b).abs() < 1e-6);
|
||||
// The two axes within the plane are also orthogonal.
|
||||
assert!(a.dot(b).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
// The rotation around X used FRAC_PI_2 indirectly via 90° axis drag.
|
||||
// This second test just confirms a clean 90° around Y matches the
|
||||
// expected matrix-applied direction.
|
||||
#[test]
|
||||
fn rotate_y_90_maps_x_to_minus_z() {
|
||||
// Manually construct a 90° Y rotation and confirm orientation.
|
||||
let q = Quat::from_axis_angle(Vec3::Y, FRAC_PI_2);
|
||||
let v = q * Vec3::X;
|
||||
assert!((v - Vec3::new(0.0, 0.0, -1.0)).length() < 1e-4);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
//! 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 extension;
|
||||
pub mod gizmo;
|
||||
pub mod play;
|
||||
pub mod preferences;
|
||||
pub mod pty;
|
||||
pub mod shell;
|
||||
pub mod state;
|
||||
pub mod terminal;
|
||||
@@ -0,0 +1,720 @@
|
||||
//! Oxide Editor — entry point.
|
||||
//!
|
||||
//! The in-engine editor is built as a first-class part of the Oxide project.
|
||||
//! It grows alongside the engine, gaining new panels and tools at each stage.
|
||||
//!
|
||||
//! Stage 6 wires the framework pieces (command stack, project system, settings
|
||||
//! framework, extension API, file watcher) into a docking
|
||||
//! [`Shell`](oxide_editor::shell::Shell). The shell hosts the hierarchy,
|
||||
//! inspector, viewport, project browser, and console as resizable dockable
|
||||
//! panels under a top menu bar + bottom status bar, with a Preferences window
|
||||
//! driven by `Settings`. This binary is glue: window/event loop, the 3D
|
||||
//! viewport renderer + camera, and the egui paint pump.
|
||||
|
||||
#![deny(warnings)]
|
||||
|
||||
mod egui_layer;
|
||||
mod viewport;
|
||||
|
||||
use egui_layer::EguiLayer;
|
||||
use oxide_editor::bindings::action;
|
||||
use oxide_editor::commands::SetTransformCmd;
|
||||
use oxide_editor::gizmo::{self, GizmoDrag, GizmoMode};
|
||||
use oxide_editor::play::{self, Tick};
|
||||
use oxide_editor::{preferences, shell::Shell};
|
||||
use oxide_engine::app::{App, DefaultModules};
|
||||
use oxide_engine::prelude::*;
|
||||
use oxide_engine::window::event::{
|
||||
ElementState, KeyCode, ModifiersState, MouseButton, MouseScrollDelta, PhysicalKey, WindowEvent,
|
||||
};
|
||||
use oxide_engine::window::RenderCtx;
|
||||
use viewport::{CameraMode, Viewport};
|
||||
|
||||
/// World-space length of the gizmo arrows / handles, scaled per-frame by
|
||||
/// camera distance so the gizmo stays roughly the same pixel size at any
|
||||
/// zoom level. The pure-logic gizmo math is agnostic to this scale — it
|
||||
/// just takes whatever value the host passes.
|
||||
const GIZMO_SCREEN_HEIGHT_FRACTION: f32 = 0.13;
|
||||
|
||||
/// Pixel-distance threshold for a gizmo handle to count as "hit" by a
|
||||
/// click. Converted to world units per-frame using the camera distance so
|
||||
/// the same screen tolerance applies at any zoom.
|
||||
const GIZMO_HIT_PIXEL_TOLERANCE: f32 = 10.0;
|
||||
|
||||
/// Background color of the 3D viewport (dark neutral gray).
|
||||
const VIEWPORT_CLEAR: Color = Color::rgb(0.08, 0.08, 0.10);
|
||||
|
||||
struct EditorApp {
|
||||
shell: Shell,
|
||||
egui_layer: Option<EguiLayer>,
|
||||
viewport: Option<Viewport>,
|
||||
/// The play-mode runtime (Stage 8.7). `Some` exactly while the editor is
|
||||
/// playing or paused: built when Play starts (engine `App` + default
|
||||
/// modules), ticked each frame, and dropped when Stop returns to editing.
|
||||
/// The editor's scene is swapped into it for each tick and back out again,
|
||||
/// so `shell.state.scene` stays the single source of truth between frames.
|
||||
play_app: Option<App>,
|
||||
modifiers: ModifiersState,
|
||||
/// Last cursor position (physical px), for computing drag deltas.
|
||||
last_cursor: Option<(f32, f32)>,
|
||||
/// Left mouse held over the viewport — orbit (or pick on release).
|
||||
orbiting: bool,
|
||||
/// Right/middle mouse held over the viewport — pan (orbit mode) or
|
||||
/// look around (flythrough mode); the camera-mode dispatch happens in
|
||||
/// the cursor-moved handler.
|
||||
panning: bool,
|
||||
/// Accumulated cursor travel since the left press, to tell a click (select)
|
||||
/// from a drag (orbit).
|
||||
left_drag_dist: f32,
|
||||
}
|
||||
|
||||
impl EditorApp {
|
||||
fn new() -> Self {
|
||||
let mut shell = Shell::new();
|
||||
// Defaults are registered by EditorState::new; layer any saved user
|
||||
// remap from `~/.config/oxide/editor.ron` on top before the first
|
||||
// input poll runs.
|
||||
if let Some(saved) = preferences::load() {
|
||||
shell.state.settings.import(&saved);
|
||||
shell.state.apply_action_overrides_from_settings();
|
||||
}
|
||||
Self {
|
||||
shell,
|
||||
egui_layer: None,
|
||||
viewport: None,
|
||||
play_app: None,
|
||||
modifiers: ModifiersState::empty(),
|
||||
last_cursor: None,
|
||||
orbiting: false,
|
||||
panning: false,
|
||||
left_drag_dist: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Constructs the world-space cursor ray using the active viewport
|
||||
/// camera + the Viewport tab's sub-rect. Returns `None` if the viewport
|
||||
/// hasn't been initialized yet or the last cursor is unknown.
|
||||
fn cursor_ray(&self, size: (u32, u32)) -> Option<oxide_engine::math::Ray> {
|
||||
let cursor = self.last_cursor?;
|
||||
let vp = self.viewport.as_ref()?;
|
||||
Some(vp.ray_from_cursor(cursor, size, self.shell.viewport_rect()))
|
||||
}
|
||||
|
||||
/// World-space gizmo size that the maths uses for both rendering and
|
||||
/// hit testing. Scaled by the camera's distance to the selection so the
|
||||
/// gizmo keeps a stable pixel size at any zoom level.
|
||||
fn gizmo_world_size(&self, target: oxide_engine::math::Vec3) -> f32 {
|
||||
let Some(vp) = self.viewport.as_ref() else {
|
||||
return 1.0;
|
||||
};
|
||||
let eye = match vp.mode {
|
||||
CameraMode::Orbit => vp.orbit.view_transform().translation,
|
||||
CameraMode::Flythrough => vp.flythrough.position,
|
||||
};
|
||||
let d = (target - eye).length().max(0.1);
|
||||
d * GIZMO_SCREEN_HEIGHT_FRACTION
|
||||
}
|
||||
|
||||
/// Tries to start a gizmo drag at the cursor. Returns `true` if a
|
||||
/// handle was hit (so the caller can skip orbit/look for this click).
|
||||
fn try_begin_gizmo_drag(&mut self, size: (u32, u32)) -> bool {
|
||||
let Some(selected) = self.shell.state.selected else {
|
||||
return false;
|
||||
};
|
||||
let Some(transform) = self.shell.state.scene.world_transform(selected) else {
|
||||
return false;
|
||||
};
|
||||
let Some(ray) = self.cursor_ray(size) else {
|
||||
return false;
|
||||
};
|
||||
let world_size = self.gizmo_world_size(transform.translation);
|
||||
// Hit tolerance is a fixed pixel size; convert to world units the
|
||||
// same way the gizmo size is scaled (the math is approximate but
|
||||
// good enough for the few-pixel target zone).
|
||||
let tolerance = world_size * (GIZMO_HIT_PIXEL_TOLERANCE / 100.0);
|
||||
let mode = self.shell.state.gizmo.mode;
|
||||
let Some(handle) = gizmo::hit_test(&ray, &transform, mode, world_size, tolerance) else {
|
||||
return false;
|
||||
};
|
||||
// Compute the drag's start anchor — the point on the engaged
|
||||
// handle the click corresponds to. Mirrors what `apply_drag`
|
||||
// expects on subsequent frames.
|
||||
let start_anchor = match handle {
|
||||
gizmo::GizmoHandle::TranslateAxis(axis) | gizmo::GizmoHandle::ScaleAxis(axis) => {
|
||||
gizmo::closest_point_on_line(transform.translation, axis.unit(), &ray)
|
||||
}
|
||||
gizmo::GizmoHandle::TranslatePlane(plane) => {
|
||||
let p = oxide_engine::math::Plane::from_point_normal(
|
||||
transform.translation,
|
||||
plane.normal(),
|
||||
);
|
||||
p.ray_intersection(&ray)
|
||||
.map(|t| ray.at(t))
|
||||
.unwrap_or(transform.translation)
|
||||
}
|
||||
gizmo::GizmoHandle::RotateAxis(axis) => {
|
||||
let p = oxide_engine::math::Plane::from_point_normal(
|
||||
transform.translation,
|
||||
axis.unit(),
|
||||
);
|
||||
p.ray_intersection(&ray)
|
||||
.map(|t| ray.at(t))
|
||||
.unwrap_or(transform.translation)
|
||||
}
|
||||
gizmo::GizmoHandle::ScaleUniform => ray.closest_point(transform.translation),
|
||||
};
|
||||
// The uniform-scale handle uses `reference` as the world distance
|
||||
// corresponding to one factor of change — match it to the gizmo
|
||||
// size so dragging by ~one arm's length doubles the scale.
|
||||
let reference = if matches!(handle, gizmo::GizmoHandle::ScaleUniform) {
|
||||
world_size
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
self.shell.state.gizmo.drag = Some(GizmoDrag {
|
||||
handle,
|
||||
start_transform: transform,
|
||||
start_anchor,
|
||||
reference,
|
||||
});
|
||||
true
|
||||
}
|
||||
|
||||
/// Updates the in-progress drag against the current cursor position,
|
||||
/// applying the new transform directly to the selected entity. The
|
||||
/// command stack is only touched on release; intermediate frames just
|
||||
/// mutate the scene so the gizmo follows the pointer fluidly.
|
||||
fn advance_gizmo_drag(&mut self, size: (u32, u32), cursor: (f32, f32)) {
|
||||
let Some(drag) = self.shell.state.gizmo.drag else {
|
||||
return;
|
||||
};
|
||||
let Some(selected) = self.shell.state.selected else {
|
||||
return;
|
||||
};
|
||||
let Some(vp) = self.viewport.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let ray = vp.ray_from_cursor(cursor, size, self.shell.viewport_rect());
|
||||
// Ctrl-held → snap; the snap settings live on the editor state so
|
||||
// a future preferences page can tune the steps.
|
||||
let snap = self
|
||||
.modifiers
|
||||
.control_key()
|
||||
.then_some(&self.shell.state.gizmo.snap);
|
||||
let next = gizmo::apply_drag(&drag, &ray, snap);
|
||||
// Drag math operates in world space (start_transform was the
|
||||
// entity's *world* transform); for an entity with parents the
|
||||
// computed `next` lives in world space too, so writing it as the
|
||||
// local transform is only exact when the entity has no parent.
|
||||
// Hierarchy-aware gizmo math is a refinement for a later piece.
|
||||
self.shell.state.scene.set_local_transform(selected, next);
|
||||
}
|
||||
|
||||
/// Commits the in-progress drag (if any) by pushing a `SetTransformCmd`
|
||||
/// onto the command stack and clearing the drag — making the whole
|
||||
/// drag one undo entry.
|
||||
fn end_gizmo_drag(&mut self) {
|
||||
let Some(drag) = self.shell.state.gizmo.drag.take() else {
|
||||
return;
|
||||
};
|
||||
let Some(selected) = self.shell.state.selected else {
|
||||
return;
|
||||
};
|
||||
let Some(after) = self.shell.state.scene.local_transform(selected) else {
|
||||
return;
|
||||
};
|
||||
// Skip the command when nothing actually changed (the user clicked
|
||||
// a handle but didn't drag).
|
||||
let before = drag.start_transform;
|
||||
if before == after {
|
||||
return;
|
||||
}
|
||||
self.shell.push_command(SetTransformCmd {
|
||||
entity: selected,
|
||||
before,
|
||||
after,
|
||||
});
|
||||
}
|
||||
|
||||
/// Builds the per-frame [`ViewportOverlay`] the Shell's Viewport tab paints
|
||||
/// every world-space overlay with (transform gizmo, collider wireframes, the
|
||||
/// raycast probe). `None` only when the viewport isn't initialized yet — the
|
||||
/// `view_proj` is always available, so colliders/probe show without a
|
||||
/// selection; `gizmo_size` falls back to a unit when nothing is selected
|
||||
/// (the gizmo itself isn't painted then, so the value is unused there).
|
||||
fn build_gizmo_overlay(
|
||||
&self,
|
||||
size: (u32, u32),
|
||||
rect: Option<oxide_engine::math::Rect>,
|
||||
) -> Option<oxide_editor::shell::ViewportOverlay> {
|
||||
let vp = self.viewport.as_ref()?;
|
||||
let gizmo_size = self
|
||||
.shell
|
||||
.state
|
||||
.selected
|
||||
.and_then(|e| self.shell.state.scene.world_transform(e))
|
||||
.map(|t| self.gizmo_world_size(t.translation))
|
||||
.unwrap_or(1.0);
|
||||
Some(oxide_editor::shell::ViewportOverlay {
|
||||
view_proj: vp.view_projection_for(rect, size),
|
||||
gizmo_size,
|
||||
})
|
||||
}
|
||||
|
||||
/// **Freezes** a raycast probe: casts the editor camera→cursor ray against
|
||||
/// the *edited* scene's colliders right now and stores the result on the
|
||||
/// Shell so the Viewport tab keeps drawing it in world space (Stage 9 piece
|
||||
/// 8c). Because the ray is frozen into the world, orbiting the camera reveals
|
||||
/// it as a real 3D line — a ray cast from the live camera is otherwise just a
|
||||
/// point in that same camera's view. Builds a transient [`PhysicsWorld`] from
|
||||
/// the scene via [`sync_to_scene`](oxide_physics::PhysicsWorld::sync_to_scene)
|
||||
/// so the probe reflects unsaved edits without requiring Play. No-op if the
|
||||
/// cursor or viewport is unavailable or the ray is degenerate.
|
||||
fn cast_probe_ray(&mut self, size: (u32, u32)) {
|
||||
use oxide_editor::shell::{RaycastProbeHit, RaycastProbeViz};
|
||||
let rect = self.shell.viewport_rect();
|
||||
let Some(cursor) = self.last_cursor else {
|
||||
return;
|
||||
};
|
||||
let Some(vp) = self.viewport.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let ray = vp.ray_from_cursor(cursor, size, rect);
|
||||
if ray.direction == oxide_engine::math::Vec3::ZERO {
|
||||
return;
|
||||
}
|
||||
|
||||
const PROBE_DISTANCE: f32 = 1000.0;
|
||||
let mut world = oxide_physics::PhysicsWorld::new();
|
||||
world.sync_to_scene(&self.shell.state.scene);
|
||||
let hit = world.raycast(
|
||||
ray.origin,
|
||||
ray.direction,
|
||||
PROBE_DISTANCE,
|
||||
oxide_engine::layer::LayerMask::ALL,
|
||||
);
|
||||
// Surface a one-line result so the cast gives feedback even before the
|
||||
// user orbits to look at the frozen ray.
|
||||
match hit {
|
||||
Some(h) => {
|
||||
let name = self
|
||||
.shell
|
||||
.state
|
||||
.scene
|
||||
.name(h.entity)
|
||||
.unwrap_or_else(|| "<entity>".to_string());
|
||||
self.shell
|
||||
.set_status_hint(format!("Raycast probe: hit {name}"));
|
||||
}
|
||||
None => self.shell.set_status_hint("Raycast probe: miss"),
|
||||
}
|
||||
self.shell.set_raycast_probe_viz(Some(RaycastProbeViz {
|
||||
origin: ray.origin,
|
||||
end: hit
|
||||
.map(|h| h.point)
|
||||
.unwrap_or_else(|| ray.at(PROBE_DISTANCE)),
|
||||
hit: hit.map(|h| RaycastProbeHit {
|
||||
point: h.point,
|
||||
normal: h.normal,
|
||||
}),
|
||||
}));
|
||||
}
|
||||
|
||||
/// Writes the editor's preferences file to disk when the shell flagged
|
||||
/// a binding edit since the last call. Logs (but does not panic on) I/O
|
||||
/// errors — losing one save is recoverable; crashing the editor is not.
|
||||
fn save_preferences_if_dirty(&mut self) {
|
||||
if !self.shell.take_bindings_dirty() {
|
||||
return;
|
||||
}
|
||||
let snapshot = self.shell.state.settings.export();
|
||||
if let Err(err) = preferences::save(&snapshot) {
|
||||
log::warn!("failed to save editor preferences: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Drives the play-mode runtime (Stage 8.7). Reconciles the play `App`'s
|
||||
/// existence with the editor's [`PlayState`] (build it on Play, drop it on
|
||||
/// Stop), then advances the simulation as far as
|
||||
/// [`play::tick_for`](oxide_editor::play::tick_for) decides — swapping the
|
||||
/// editor scene into the `App` for the tick and back out so the rest of the
|
||||
/// editor keeps seeing `shell.state.scene`.
|
||||
///
|
||||
/// Called unconditionally each frame, before the cursor-gated editor input,
|
||||
/// so play continues regardless of where the pointer is.
|
||||
fn drive_play(&mut self, dt: f32) {
|
||||
let in_play = self.shell.state.is_in_play();
|
||||
// Build the runtime when play starts; tear it down when it stops. The
|
||||
// engine `App` carries the default modules plus physics (Stage 9) and
|
||||
// scripting (Stage 10); the project's own modules register here too in a
|
||||
// later stage.
|
||||
if in_play && self.play_app.is_none() {
|
||||
let mut app = App::new();
|
||||
// Share the editor's asset server so the play app resolves the same
|
||||
// assets *and* the file watcher's in-place reloads (which target the
|
||||
// editor server) reach a **playing** scene — live-reloading a script
|
||||
// while the scene runs.
|
||||
app.assets = self.shell.state.assets.clone();
|
||||
app.add_modules(DefaultModules);
|
||||
app.add_module(oxide_physics::PhysicsModule);
|
||||
app.add_module(oxide_script::ScriptModule);
|
||||
// Scripts resolve their `AssetRef<ScriptAsset>` through the project's
|
||||
// asset database; hand the play app a snapshot so uids map to files.
|
||||
if let Some(db) = &self.shell.state.asset_db {
|
||||
app.insert_resource(db.clone());
|
||||
}
|
||||
self.play_app = Some(app);
|
||||
} else if !in_play && self.play_app.is_some() {
|
||||
self.play_app = None;
|
||||
}
|
||||
|
||||
let tick = play::tick_for(self.shell.state.play, self.shell.take_step_request());
|
||||
if matches!(tick, Tick::Idle) {
|
||||
return;
|
||||
}
|
||||
let Some(app) = self.play_app.as_mut() else {
|
||||
return;
|
||||
};
|
||||
// Run the engine schedule against the editor's live scene, then hand it
|
||||
// back. `swap` is O(1) (two `Scene` moves), so the editor scene is only
|
||||
// "inside" the App for the duration of the tick.
|
||||
std::mem::swap(&mut self.shell.state.scene, &mut app.scene);
|
||||
match tick {
|
||||
Tick::Frame => app.update(dt),
|
||||
Tick::FixedStep => app.step(),
|
||||
Tick::Idle => {}
|
||||
}
|
||||
std::mem::swap(&mut self.shell.state.scene, &mut app.scene);
|
||||
}
|
||||
|
||||
/// Ray-picks the entity under the cursor and selects it (or clears the
|
||||
/// selection if the ray misses everything).
|
||||
fn pick_under_cursor(&mut self, size: (u32, u32)) {
|
||||
let Some(cursor) = self.last_cursor else {
|
||||
return;
|
||||
};
|
||||
let rect = self.shell.viewport_rect();
|
||||
let picked = self
|
||||
.viewport
|
||||
.as_ref()
|
||||
.and_then(|vp| vp.pick(&self.shell.state.scene, cursor, size, rect));
|
||||
self.shell.select(picked);
|
||||
}
|
||||
}
|
||||
|
||||
impl WindowApp for EditorApp {
|
||||
fn init(&mut self, ctx: &mut AppCtx<'_>) {
|
||||
let (w, h) = ctx.size();
|
||||
let device = ctx.render().gpu().device().clone();
|
||||
let format = ctx.render().surface_format();
|
||||
let layer = EguiLayer::new(ctx.window(), &device, format);
|
||||
self.egui_layer = Some(layer);
|
||||
self.viewport = Some(Viewport::new(&device, format));
|
||||
log::info!(
|
||||
"editor window open ({w}x{h}); docking shell active \
|
||||
(L-drag: orbit · R-drag: pan · scroll: zoom · F: toggle flythrough · \
|
||||
Ctrl+Z: undo · Ctrl+Q: quit)"
|
||||
);
|
||||
}
|
||||
|
||||
fn event(&mut self, ctx: &mut AppCtx<'_>, event: &WindowEvent) {
|
||||
// Let egui handle the event first (text fields, clicks, scrolling).
|
||||
// `consumed` is true when the pointer is over an egui widget, but
|
||||
// the Viewport tab is technically an egui widget too — so egui would
|
||||
// claim every click in the central area. Override that: if the cursor
|
||||
// is over the Viewport tab's rect we treat the event as ours, so
|
||||
// orbit/pan/zoom/pick work inside the dock.
|
||||
let egui_consumed = self
|
||||
.egui_layer
|
||||
.as_mut()
|
||||
.map(|layer| layer.on_window_event(ctx.window(), event))
|
||||
.unwrap_or(false);
|
||||
// A floating panel (egui Window) can overlap the viewport rect; when
|
||||
// the pointer is over one, the click belongs to egui, not the 3D view —
|
||||
// otherwise we'd drag the panel and orbit the camera at the same time.
|
||||
let over_floating = self
|
||||
.egui_layer
|
||||
.as_ref()
|
||||
.map(|layer| layer.pointer_over_floating())
|
||||
.unwrap_or(false);
|
||||
let over_viewport = !over_floating
|
||||
&& self
|
||||
.last_cursor
|
||||
.map(|c| self.shell.cursor_over_viewport(c))
|
||||
.unwrap_or(false);
|
||||
let consumed = egui_consumed && !over_viewport;
|
||||
|
||||
match event {
|
||||
WindowEvent::ModifiersChanged(modifiers) => {
|
||||
self.modifiers = modifiers.state();
|
||||
// If a gizmo drag is in flight, re-apply it with the new
|
||||
// modifier state so toggling Ctrl mid-drag snaps (or
|
||||
// unsnaps) the current position immediately — even when
|
||||
// the mouse hasn't moved since.
|
||||
if self.shell.state.gizmo.drag.is_some() {
|
||||
if let Some(cursor) = self.last_cursor {
|
||||
self.advance_gizmo_drag(ctx.size(), cursor);
|
||||
}
|
||||
}
|
||||
}
|
||||
WindowEvent::KeyboardInput { event, .. } => {
|
||||
if event.state != ElementState::Pressed {
|
||||
return;
|
||||
}
|
||||
let ctrl = self.modifiers.control_key();
|
||||
let shift = self.modifiers.shift_key();
|
||||
// Engine-global Ctrl+Q is handled here; everything else is
|
||||
// delegated to the shell so the same shortcut routing is
|
||||
// exercised by tests.
|
||||
if ctrl && event.physical_key == PhysicalKey::Code(KeyCode::KeyQ) {
|
||||
log::info!("Ctrl+Q — exiting editor");
|
||||
ctx.request_exit();
|
||||
return;
|
||||
}
|
||||
if ctrl {
|
||||
let ch = match event.physical_key {
|
||||
PhysicalKey::Code(KeyCode::KeyZ) => Some('z'),
|
||||
PhysicalKey::Code(KeyCode::KeyY) => Some('y'),
|
||||
PhysicalKey::Code(KeyCode::KeyS) => Some('s'),
|
||||
PhysicalKey::Code(KeyCode::Comma) => Some(','),
|
||||
PhysicalKey::Code(KeyCode::KeyP) => Some('p'),
|
||||
PhysicalKey::Code(KeyCode::Period) => Some('.'),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(ch) = ch {
|
||||
self.shell.try_consume_shortcut(true, shift, Some(ch));
|
||||
}
|
||||
}
|
||||
}
|
||||
WindowEvent::MouseInput { state, button, .. } => {
|
||||
let pressed = *state == ElementState::Pressed;
|
||||
match button {
|
||||
MouseButton::Left => {
|
||||
if pressed {
|
||||
// Try a gizmo handle first — if the click hit
|
||||
// one, we start a drag instead of orbiting.
|
||||
let on_gizmo = !consumed && self.try_begin_gizmo_drag(ctx.size());
|
||||
self.orbiting = !consumed && !on_gizmo;
|
||||
self.left_drag_dist = 0.0;
|
||||
} else {
|
||||
// Release: commit the gizmo drag if any (one
|
||||
// SetTransformCmd per drag = one undo entry).
|
||||
if self.shell.state.gizmo.drag.is_some() {
|
||||
self.end_gizmo_drag();
|
||||
} else if self.orbiting && self.left_drag_dist < 4.0 {
|
||||
// Click without drag → pick, and (if the raycast
|
||||
// probe is on) freeze a debug ray into the world
|
||||
// so it can be inspected by orbiting the camera.
|
||||
self.pick_under_cursor(ctx.size());
|
||||
if self.shell.raycast_probe_enabled() {
|
||||
self.cast_probe_ray(ctx.size());
|
||||
}
|
||||
}
|
||||
self.orbiting = false;
|
||||
}
|
||||
}
|
||||
MouseButton::Right | MouseButton::Middle => self.panning = pressed && !consumed,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
WindowEvent::CursorMoved { position, .. } => {
|
||||
let pos = (position.x as f32, position.y as f32);
|
||||
if let Some((lx, ly)) = self.last_cursor {
|
||||
let (dx, dy) = (pos.0 - lx, pos.1 - ly);
|
||||
if self.orbiting {
|
||||
self.left_drag_dist += dx.abs() + dy.abs();
|
||||
}
|
||||
|
||||
// If a gizmo drag is in flight, route the move into the
|
||||
// gizmo math and skip the camera controls entirely.
|
||||
if self.shell.state.gizmo.drag.is_some() {
|
||||
self.advance_gizmo_drag(ctx.size(), pos);
|
||||
} else if let Some(vp) = self.viewport.as_mut() {
|
||||
match vp.mode {
|
||||
CameraMode::Orbit => {
|
||||
if self.orbiting {
|
||||
vp.orbit.orbit(dx, dy);
|
||||
} else if self.panning {
|
||||
vp.orbit.pan(dx, dy);
|
||||
}
|
||||
}
|
||||
CameraMode::Flythrough => {
|
||||
// In flythrough the existing right-drag gesture
|
||||
// becomes mouse-look; left-drag is a no-op for
|
||||
// the camera (click-without-drag still picks).
|
||||
if self.panning {
|
||||
vp.flythrough.look(dx, dy);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.last_cursor = Some(pos);
|
||||
}
|
||||
WindowEvent::MouseWheel { delta, .. } if !consumed => {
|
||||
let amount = match delta {
|
||||
MouseScrollDelta::LineDelta(_, y) => *y,
|
||||
MouseScrollDelta::PixelDelta(p) => p.y as f32 / 40.0,
|
||||
};
|
||||
if let Some(vp) = self.viewport.as_mut() {
|
||||
// Scroll has different roles per mode: zoom-in/out for the
|
||||
// orbit subject, faster/slower travel for the flythrough.
|
||||
match vp.mode {
|
||||
CameraMode::Orbit => vp.orbit.zoom(amount),
|
||||
CameraMode::Flythrough => vp.flythrough.adjust_move_speed(amount),
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, ctx: &mut AppCtx<'_>) {
|
||||
// Drain the file watcher into AssetServer::reload_path and age out
|
||||
// the status bar's last hint.
|
||||
self.shell.frame_tick();
|
||||
// Propagate File → Quit (the shell can't reach the runner directly).
|
||||
if self.shell.take_quit_request() {
|
||||
ctx.request_exit();
|
||||
}
|
||||
|
||||
// Advance the play-mode simulation (if any) every frame, before the
|
||||
// cursor-gated editor input below — play must not depend on the pointer
|
||||
// being over the viewport.
|
||||
self.drive_play(ctx.dt);
|
||||
|
||||
// Bindings preferences page — when a capture is in progress, consume
|
||||
// the next pressed key/button into the targeted slot. Runs before
|
||||
// any other input poll so the captured press doesn't double-fire
|
||||
// a normal action.
|
||||
let input = ctx.input();
|
||||
if self.shell.capture_active() {
|
||||
self.shell.try_complete_capture(input);
|
||||
// Flush to disk if the capture committed a binding.
|
||||
self.save_preferences_if_dirty();
|
||||
return;
|
||||
}
|
||||
|
||||
// Editor input — polled per-frame from the Stage-7 InputState. Only
|
||||
// fires when the cursor is over the Viewport tab so the same keys do
|
||||
// not steal focus from a search box or text field elsewhere.
|
||||
let over_floating = self
|
||||
.egui_layer
|
||||
.as_ref()
|
||||
.map(|layer| layer.pointer_over_floating())
|
||||
.unwrap_or(false);
|
||||
let cursor_over_vp = !over_floating
|
||||
&& self
|
||||
.last_cursor
|
||||
.map(|c| self.shell.cursor_over_viewport(c))
|
||||
.unwrap_or(false);
|
||||
if !cursor_over_vp {
|
||||
// Even off the viewport, a "Restore defaults" click from the
|
||||
// preferences UI marks the bindings dirty — flush here.
|
||||
self.save_preferences_if_dirty();
|
||||
return;
|
||||
}
|
||||
|
||||
let actions = &self.shell.state.actions;
|
||||
if actions.action_pressed(action::TOGGLE_FLYTHROUGH, input) {
|
||||
if let Some(vp) = self.viewport.as_mut() {
|
||||
let new_mode = vp.toggle_camera_mode();
|
||||
let label = match new_mode {
|
||||
CameraMode::Orbit => "Camera: Orbit (L-drag orbit · R-drag pan · scroll zoom)",
|
||||
CameraMode::Flythrough => {
|
||||
"Camera: Flythrough (WASD/QE move · Shift sprint · R-drag look · scroll speed)"
|
||||
}
|
||||
};
|
||||
log::info!("{label}");
|
||||
self.shell.set_status_hint(label);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(vp) = self.viewport.as_mut() {
|
||||
if vp.mode == CameraMode::Flythrough {
|
||||
let actions = &self.shell.state.actions;
|
||||
let right = actions.axis(action::MOVE_RIGHT, input);
|
||||
let forward = actions.axis(action::MOVE_FORWARD, input);
|
||||
let up = actions.axis(action::MOVE_UP, input);
|
||||
// The camera's translate_local takes (right, up, -forward),
|
||||
// i.e. -Z is camera-forward, mirroring the OrbitCamera's
|
||||
// looking_at convention.
|
||||
let local = oxide_engine::math::Vec3::new(right, up, -forward);
|
||||
let sprint = actions.action_held(action::SPRINT, input);
|
||||
vp.flythrough.translate_local(local, ctx.dt, sprint);
|
||||
}
|
||||
}
|
||||
|
||||
// Gizmo tool hotkeys (W/E/R by default). Share keys with flythrough
|
||||
// movement, so they only fire in orbit mode — in flythrough WASD
|
||||
// moves the camera.
|
||||
let orbit_mode = matches!(
|
||||
self.viewport.as_ref().map(|v| v.mode),
|
||||
Some(CameraMode::Orbit)
|
||||
);
|
||||
if orbit_mode {
|
||||
let actions = &self.shell.state.actions;
|
||||
let new_mode = if actions.action_pressed(action::GIZMO_TRANSLATE, input) {
|
||||
Some(GizmoMode::Translate)
|
||||
} else if actions.action_pressed(action::GIZMO_ROTATE, input) {
|
||||
Some(GizmoMode::Rotate)
|
||||
} else if actions.action_pressed(action::GIZMO_SCALE, input) {
|
||||
Some(GizmoMode::Scale)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(mode) = new_mode {
|
||||
self.shell.state.gizmo.mode = mode;
|
||||
log::info!("Gizmo tool: {}", mode.label());
|
||||
self.shell
|
||||
.set_status_hint(format!("Gizmo: {}", mode.label()));
|
||||
}
|
||||
}
|
||||
|
||||
self.save_preferences_if_dirty();
|
||||
}
|
||||
|
||||
fn render(&mut self, ctx: &RenderCtx<'_>) {
|
||||
// Draw the 3D scene first; egui then composites its panels on top
|
||||
// (both record with `LoadOp::Load` over the engine's clear). Taken out
|
||||
// and back so the immutable scene borrow doesn't clash with `&mut self`.
|
||||
let rect = self.shell.viewport_rect();
|
||||
if let Some(mut vp) = self.viewport.take() {
|
||||
vp.render(&self.shell.state.scene, ctx, rect);
|
||||
self.viewport = Some(vp);
|
||||
}
|
||||
|
||||
// Hand the Shell the data its Viewport tab needs to paint the gizmo
|
||||
// overlay using the same projection the scene was drawn with.
|
||||
self.shell
|
||||
.set_viewport_overlay(self.build_gizmo_overlay(ctx.size, rect));
|
||||
|
||||
let Some(mut layer) = self.egui_layer.take() else {
|
||||
return;
|
||||
};
|
||||
let shell = &mut self.shell;
|
||||
layer.paint(
|
||||
ctx.window,
|
||||
ctx.gpu.device(),
|
||||
ctx.gpu.queue(),
|
||||
ctx.view,
|
||||
ctx.size,
|
||||
|ui| shell.build(ui),
|
||||
);
|
||||
self.egui_layer = Some(layer);
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
// Install the capturing logger so script output/errors also reach the
|
||||
// editor Console panel (still prints to the terminal, honours RUST_LOG).
|
||||
oxide_editor::console::init();
|
||||
log::info!("Oxide Editor starting…");
|
||||
|
||||
let config = WindowConfig {
|
||||
title: "Oxide Editor".to_string(),
|
||||
clear_color: VIEWPORT_CLEAR,
|
||||
..Default::default()
|
||||
};
|
||||
run(config, EditorApp::new())
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
//! The play-mode tick decision (Stage 8.7).
|
||||
//!
|
||||
//! The host runner ([`oxide_editor::main`](crate)) owns the play [`App`] and the
|
||||
//! window loop; this module isolates the one piece of that loop worth testing on
|
||||
//! its own: **how far to advance the simulation this frame** given the current
|
||||
//! [`PlayState`] and whether a single **Step** was requested.
|
||||
//!
|
||||
//! Keeping it a pure function pins the play-mode contract in a unit test instead
|
||||
//! of burying it in the (un-testable) GUI runner:
|
||||
//!
|
||||
//! - [`Playing`](PlayState::Playing) → advance one real frame ([`Tick::Frame`]).
|
||||
//! - [`Paused`](PlayState::Paused) + Step → advance exactly one fixed tick
|
||||
//! ([`Tick::FixedStep`]); a stray Step while *Playing* is ignored (the frame
|
||||
//! already advances).
|
||||
//! - [`Editing`](PlayState::Editing), or Paused with no Step → do nothing
|
||||
//! ([`Tick::Idle`]).
|
||||
//!
|
||||
//! [`App`]: oxide_engine::app::App
|
||||
|
||||
use crate::state::PlayState;
|
||||
|
||||
/// How the host runner should advance the play [`App`](oxide_engine::app::App)
|
||||
/// this frame. The runner maps each variant onto an engine call: `Frame` →
|
||||
/// [`App::update`](oxide_engine::app::App::update), `FixedStep` →
|
||||
/// [`App::step`](oxide_engine::app::App::step), `Idle` → no call.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Tick {
|
||||
/// Do not advance the simulation (editing, or paused with no step queued).
|
||||
Idle,
|
||||
/// Advance one normal frame by the real delta (playing).
|
||||
Frame,
|
||||
/// Advance exactly one fixed timestep (a single step while paused).
|
||||
FixedStep,
|
||||
}
|
||||
|
||||
/// Decides how to advance the simulation this frame. `step_requested` is whether
|
||||
/// the user asked for a single **Step** since the last frame; it is honoured
|
||||
/// only while [`Paused`](PlayState::Paused). See the [module docs](self).
|
||||
pub fn tick_for(play: PlayState, step_requested: bool) -> Tick {
|
||||
match play {
|
||||
PlayState::Playing => Tick::Frame,
|
||||
PlayState::Paused if step_requested => Tick::FixedStep,
|
||||
PlayState::Paused | PlayState::Editing => Tick::Idle,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn playing_advances_a_frame_regardless_of_step() {
|
||||
assert_eq!(tick_for(PlayState::Playing, false), Tick::Frame);
|
||||
// A stray step while playing is ignored — the frame already advances.
|
||||
assert_eq!(tick_for(PlayState::Playing, true), Tick::Frame);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paused_steps_only_when_requested() {
|
||||
assert_eq!(tick_for(PlayState::Paused, false), Tick::Idle);
|
||||
assert_eq!(tick_for(PlayState::Paused, true), Tick::FixedStep);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_never_advances() {
|
||||
assert_eq!(tick_for(PlayState::Editing, false), Tick::Idle);
|
||||
assert_eq!(tick_for(PlayState::Editing, true), Tick::Idle);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
//! Editor-wide preferences persistence on disk.
|
||||
//!
|
||||
//! The Stage-6 [`Settings`](oxide_engine::settings::Settings) framework
|
||||
//! defines *what* is persisted (named sections, each owning a typed value).
|
||||
//! This module defines *where* — the user-scoped file the editor reads on
|
||||
//! startup and writes on every change, so a binding remap or theme tweak
|
||||
//! survives a restart.
|
||||
//!
|
||||
//! # Location
|
||||
//!
|
||||
//! Linux: `$XDG_CONFIG_HOME/oxide/editor.ron`, falling back to
|
||||
//! `$HOME/.config/oxide/editor.ron`. The directory is created on demand;
|
||||
//! the path is the same one a Windows port would use once Stage-16 ships
|
||||
//! game export (Windows resolution lands then, not here).
|
||||
//!
|
||||
//! # Format
|
||||
//!
|
||||
//! The file is exactly the RON map [`Settings::export`] produces:
|
||||
//! `{ "section.name": "(field: value, …)", … }`. Each value is itself a
|
||||
//! RON-encoded string of that section's typed value. Loading does no
|
||||
//! schema validation — unknown sections are skipped by `Settings::import`,
|
||||
//! so removing a section in code never breaks an old file.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::ffi::OsString;
|
||||
use std::io;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Resolves the absolute path to the editor's preferences file, or `None`
|
||||
/// if the OS provides no usable home / config directory (a stripped-down
|
||||
/// container, an unusual launcher environment, …).
|
||||
pub fn config_path() -> Option<PathBuf> {
|
||||
resolve_config_path(|k| std::env::var_os(k))
|
||||
}
|
||||
|
||||
/// Resolution rules, factored so tests can inject env state without racing
|
||||
/// on the real process environment. Returns the first of:
|
||||
///
|
||||
/// 1. `$XDG_CONFIG_HOME/oxide/editor.ron`
|
||||
/// 2. `$HOME/.config/oxide/editor.ron`
|
||||
/// 3. `None` if neither is set.
|
||||
fn resolve_config_path(env: impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
|
||||
let base = env("XDG_CONFIG_HOME")
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| env("HOME").map(|h| PathBuf::from(h).join(".config")))?;
|
||||
Some(base.join("oxide").join("editor.ron"))
|
||||
}
|
||||
|
||||
/// Loads the preferences file, returning the same `BTreeMap` shape
|
||||
/// [`Settings::import`](oxide_engine::settings::Settings::import) consumes.
|
||||
///
|
||||
/// Returns `None` when no file exists yet (a fresh install) or it can't be
|
||||
/// parsed — both cases are silently treated as "no saved preferences" so
|
||||
/// the editor falls back to the code-defined defaults. A returned `Some`
|
||||
/// is the file's contents verbatim; the caller decides what to import.
|
||||
pub fn load() -> Option<BTreeMap<String, String>> {
|
||||
let path = config_path()?;
|
||||
let text = std::fs::read_to_string(&path).ok()?;
|
||||
ron::from_str(&text).ok()
|
||||
}
|
||||
|
||||
/// Writes `map` to the preferences file, creating the parent directory if
|
||||
/// necessary. The map is the output of
|
||||
/// [`Settings::export`](oxide_engine::settings::Settings::export); the
|
||||
/// editor calls this from the host runner whenever a binding edit or
|
||||
/// other settings change flips a dirty flag.
|
||||
pub fn save(map: &BTreeMap<String, String>) -> io::Result<()> {
|
||||
let path = config_path().ok_or_else(|| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::NotFound,
|
||||
"no $XDG_CONFIG_HOME or $HOME — cannot resolve editor preferences path",
|
||||
)
|
||||
})?;
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let text = ron::ser::to_string_pretty(map, ron::ser::PrettyConfig::default())
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
|
||||
std::fs::write(path, text)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A throw-away env stub built from a closure — keeps each test free of
|
||||
/// process-global env mutation, so the suite can run in parallel.
|
||||
fn env<'a>(map: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<OsString> + 'a {
|
||||
move |k| {
|
||||
map.iter()
|
||||
.find(|(kk, _)| *kk == k)
|
||||
.map(|(_, v)| OsString::from(*v))
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_path_uses_xdg_when_set() {
|
||||
let p = resolve_config_path(env(&[("XDG_CONFIG_HOME", "/tmp/x")])).unwrap();
|
||||
assert_eq!(p, PathBuf::from("/tmp/x/oxide/editor.ron"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_path_prefers_xdg_over_home_when_both_set() {
|
||||
let p =
|
||||
resolve_config_path(env(&[("XDG_CONFIG_HOME", "/tmp/x"), ("HOME", "/tmp/h")])).unwrap();
|
||||
assert_eq!(p, PathBuf::from("/tmp/x/oxide/editor.ron"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_path_falls_back_to_home_dot_config() {
|
||||
let p = resolve_config_path(env(&[("HOME", "/tmp/h")])).unwrap();
|
||||
assert_eq!(p, PathBuf::from("/tmp/h/.config/oxide/editor.ron"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_path_is_none_when_no_env_available() {
|
||||
let p = resolve_config_path(env(&[]));
|
||||
assert!(p.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_then_load_round_trips_the_exported_map() {
|
||||
// Direct file I/O test that doesn't go through config_path — write
|
||||
// to a temp file with a known shape and confirm the RON round-trip
|
||||
// matches what `Settings::export` produces.
|
||||
let scratch = std::env::temp_dir().join(format!(
|
||||
"oxide_editor_prefs_roundtrip_{}.ron",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_file(&scratch);
|
||||
|
||||
let mut map = BTreeMap::new();
|
||||
map.insert(
|
||||
"input.bindings".to_string(),
|
||||
"(bindings: {\"Jump\": [Key(KeyW)]})".to_string(),
|
||||
);
|
||||
let text = ron::ser::to_string_pretty(&map, ron::ser::PrettyConfig::default()).unwrap();
|
||||
std::fs::write(&scratch, &text).unwrap();
|
||||
|
||||
let read_back = std::fs::read_to_string(&scratch).unwrap();
|
||||
let parsed: BTreeMap<String, String> = ron::from_str(&read_back).unwrap();
|
||||
assert_eq!(parsed, map);
|
||||
|
||||
let _ = std::fs::remove_file(&scratch);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
//! A PTY-backed terminal: runs an interactive program (a shell, a REPL, an
|
||||
//! AI-agent CLI like `claude`) inside the editor.
|
||||
//!
|
||||
//! This is the interactive counterpart to the command [console](crate::console).
|
||||
//! The console pipes a one-shot command's output; a real terminal needs a
|
||||
//! **pseudo-terminal**: programs detect a tty and switch to full-screen/TUI mode,
|
||||
//! read raw keystrokes from stdin, and drive the screen with ANSI/VT escape
|
||||
//! sequences. So this module:
|
||||
//!
|
||||
//! - opens a PTY with [`portable-pty`] (cross-platform — Linux now, Windows
|
||||
//! later) and spawns the program attached to it;
|
||||
//! - feeds the program's byte stream into a [`vt100`] parser on a reader thread,
|
||||
//! which maintains the on-screen grid (cells, colours, cursor);
|
||||
//! - exposes the grid for the egui panel to render, and [`send_input`] to write
|
||||
//! keystrokes back to the program.
|
||||
//!
|
||||
//! [`send_input`]: PtyTerminal::send_input
|
||||
//!
|
||||
//! The two pure pieces — encoding an egui key press into the bytes a terminal
|
||||
//! expects ([`encode_key`]) and mapping a [`vt100`] colour to an egui colour
|
||||
//! ([`vt_color`]) — are unit-tested; the rendering/input loop itself is the
|
||||
//! eye-checked part.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use egui::{Key, Modifiers};
|
||||
use portable_pty::{Child, CommandBuilder, MasterPty, PtySize};
|
||||
|
||||
/// A live terminal session: the spawned child, its PTY, and the parsed screen.
|
||||
pub struct PtyTerminal {
|
||||
/// A short label for the session (e.g. the program name) shown on the tab.
|
||||
pub title: String,
|
||||
/// The parsed terminal screen, updated by the reader thread.
|
||||
parser: Arc<Mutex<vt100::Parser>>,
|
||||
/// The PTY master — kept for resizing.
|
||||
master: Box<dyn MasterPty + Send>,
|
||||
/// Writes keystrokes to the program (the PTY input side).
|
||||
writer: Box<dyn Write + Send>,
|
||||
/// The spawned child — killed on drop so closing the panel ends the program.
|
||||
child: Box<dyn Child + Send + Sync>,
|
||||
/// Current grid size, so we only resize on an actual change.
|
||||
rows: u16,
|
||||
cols: u16,
|
||||
}
|
||||
|
||||
impl PtyTerminal {
|
||||
/// Spawns `program` (with `args`) attached to a fresh PTY of `rows`×`cols`,
|
||||
/// running in `cwd`. `title` labels the session.
|
||||
pub fn spawn(
|
||||
title: impl Into<String>,
|
||||
program: &str,
|
||||
args: &[&str],
|
||||
cwd: &std::path::Path,
|
||||
rows: u16,
|
||||
cols: u16,
|
||||
) -> std::io::Result<Self> {
|
||||
let pty_system = portable_pty::native_pty_system();
|
||||
let pair = pty_system
|
||||
.openpty(PtySize {
|
||||
rows,
|
||||
cols,
|
||||
pixel_width: 0,
|
||||
pixel_height: 0,
|
||||
})
|
||||
.map_err(to_io)?;
|
||||
|
||||
let mut cmd = CommandBuilder::new(program);
|
||||
cmd.args(args);
|
||||
cmd.cwd(cwd);
|
||||
// Advertise a capable terminal so programs emit colour + use full-screen
|
||||
// mode; without this many tools fall back to dumb output.
|
||||
cmd.env("TERM", "xterm-256color");
|
||||
|
||||
let child = pair.slave.spawn_command(cmd).map_err(to_io)?;
|
||||
// Drop the slave handle so the master sees EOF when the child exits.
|
||||
drop(pair.slave);
|
||||
|
||||
let reader = pair.master.try_clone_reader().map_err(to_io)?;
|
||||
let writer = pair.master.take_writer().map_err(to_io)?;
|
||||
|
||||
let parser = Arc::new(Mutex::new(vt100::Parser::new(rows, cols, 0)));
|
||||
spawn_reader(reader, parser.clone());
|
||||
|
||||
Ok(Self {
|
||||
title: title.into(),
|
||||
parser,
|
||||
master: pair.master,
|
||||
writer,
|
||||
child,
|
||||
rows,
|
||||
cols,
|
||||
})
|
||||
}
|
||||
|
||||
/// Borrows the parsed screen state for rendering (locks the parser).
|
||||
pub fn with_screen<R>(&self, f: impl FnOnce(&vt100::Screen) -> R) -> R {
|
||||
let parser = self.parser.lock().unwrap();
|
||||
f(parser.screen())
|
||||
}
|
||||
|
||||
/// The current grid size in (rows, cols).
|
||||
pub fn size(&self) -> (u16, u16) {
|
||||
(self.rows, self.cols)
|
||||
}
|
||||
|
||||
/// Writes raw bytes (already terminal-encoded) to the program's input.
|
||||
pub fn send_input(&mut self, bytes: &[u8]) {
|
||||
let _ = self.writer.write_all(bytes);
|
||||
let _ = self.writer.flush();
|
||||
}
|
||||
|
||||
/// Resizes the PTY and parser to `rows`×`cols` (no-op if unchanged). Programs
|
||||
/// receive `SIGWINCH` and redraw to the new size.
|
||||
pub fn resize(&mut self, rows: u16, cols: u16) {
|
||||
if rows == 0 || cols == 0 || (rows == self.rows && cols == self.cols) {
|
||||
return;
|
||||
}
|
||||
self.rows = rows;
|
||||
self.cols = cols;
|
||||
let _ = self.master.resize(PtySize {
|
||||
rows,
|
||||
cols,
|
||||
pixel_width: 0,
|
||||
pixel_height: 0,
|
||||
});
|
||||
self.parser
|
||||
.lock()
|
||||
.unwrap()
|
||||
.screen_mut()
|
||||
.set_size(rows, cols);
|
||||
}
|
||||
|
||||
/// Whether the child program has exited.
|
||||
pub fn has_exited(&mut self) -> bool {
|
||||
matches!(self.child.try_wait(), Ok(Some(_)))
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PtyTerminal {
|
||||
fn drop(&mut self) {
|
||||
// End the program when the panel/session goes away.
|
||||
let _ = self.child.kill();
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawns the reader thread: pumps the PTY's output into the `vt100` parser until
|
||||
/// EOF (the child exited / the master closed).
|
||||
fn spawn_reader(mut reader: Box<dyn Read + Send>, parser: Arc<Mutex<vt100::Parser>>) {
|
||||
std::thread::spawn(move || {
|
||||
let mut buf = [0u8; 8192];
|
||||
loop {
|
||||
match reader.read(&mut buf) {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => parser.lock().unwrap().process(&buf[..n]),
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Adapts a `portable_pty` error into `std::io::Error`.
|
||||
fn to_io(err: impl std::fmt::Display) -> std::io::Error {
|
||||
std::io::Error::other(err.to_string())
|
||||
}
|
||||
|
||||
/// Encodes an egui [`Key`] press (with modifiers) into the byte sequence a
|
||||
/// terminal program expects on stdin, or `None` for keys we don't translate
|
||||
/// (printable characters arrive separately as text input events).
|
||||
///
|
||||
/// Covers the control keys a TUI needs: Enter, Backspace, Tab, Esc, the arrows
|
||||
/// and navigation keys (as ANSI CSI sequences), and `Ctrl`+letter (which maps to
|
||||
/// control codes 0x01–0x1A — e.g. `Ctrl+C` → `0x03`).
|
||||
pub fn encode_key(key: Key, mods: Modifiers) -> Option<Vec<u8>> {
|
||||
// Ctrl + A..Z -> 0x01..0x1A (Ctrl+C = ETX = 0x03, etc.).
|
||||
if mods.ctrl && !mods.alt {
|
||||
if let Some(letter) = letter_index(key) {
|
||||
return Some(vec![letter + 1]); // 'a' -> 1
|
||||
}
|
||||
}
|
||||
let bytes: &[u8] = match key {
|
||||
Key::Enter => b"\r",
|
||||
Key::Backspace => b"\x7f",
|
||||
Key::Tab => b"\t",
|
||||
Key::Escape => b"\x1b",
|
||||
Key::ArrowUp => b"\x1b[A",
|
||||
Key::ArrowDown => b"\x1b[B",
|
||||
Key::ArrowRight => b"\x1b[C",
|
||||
Key::ArrowLeft => b"\x1b[D",
|
||||
Key::Home => b"\x1b[H",
|
||||
Key::End => b"\x1b[F",
|
||||
Key::PageUp => b"\x1b[5~",
|
||||
Key::PageDown => b"\x1b[6~",
|
||||
Key::Delete => b"\x1b[3~",
|
||||
Key::Insert => b"\x1b[2~",
|
||||
_ => return None,
|
||||
};
|
||||
Some(bytes.to_vec())
|
||||
}
|
||||
|
||||
/// The 0-based index (`a`=0 … `z`=25) of an alphabetic [`Key`], else `None`.
|
||||
/// Used to map `Ctrl`+letter to its control code.
|
||||
fn letter_index(key: Key) -> Option<u8> {
|
||||
let name = key.name(); // "A".."Z" for letter keys
|
||||
let bytes = name.as_bytes();
|
||||
if bytes.len() == 1 && bytes[0].is_ascii_uppercase() {
|
||||
Some(bytes[0] - b'A')
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps a [`vt100`] colour to an egui colour, given the default foreground to use
|
||||
/// for [`vt100::Color::Default`].
|
||||
pub fn vt_color(color: vt100::Color, default: egui::Color32) -> egui::Color32 {
|
||||
match color {
|
||||
vt100::Color::Default => default,
|
||||
vt100::Color::Rgb(r, g, b) => egui::Color32::from_rgb(r, g, b),
|
||||
vt100::Color::Idx(i) => ansi_indexed(i),
|
||||
}
|
||||
}
|
||||
|
||||
/// The RGB for an ANSI 256-colour palette index: the 16 base colours, the
|
||||
/// 6×6×6 colour cube, and the 24-step grey ramp.
|
||||
fn ansi_indexed(i: u8) -> egui::Color32 {
|
||||
match i {
|
||||
// Standard + bright 16-colour palette.
|
||||
0 => egui::Color32::from_rgb(0x00, 0x00, 0x00),
|
||||
1 => egui::Color32::from_rgb(0xCD, 0x00, 0x00),
|
||||
2 => egui::Color32::from_rgb(0x00, 0xCD, 0x00),
|
||||
3 => egui::Color32::from_rgb(0xCD, 0xCD, 0x00),
|
||||
4 => egui::Color32::from_rgb(0x00, 0x00, 0xEE),
|
||||
5 => egui::Color32::from_rgb(0xCD, 0x00, 0xCD),
|
||||
6 => egui::Color32::from_rgb(0x00, 0xCD, 0xCD),
|
||||
7 => egui::Color32::from_rgb(0xE5, 0xE5, 0xE5),
|
||||
8 => egui::Color32::from_rgb(0x7F, 0x7F, 0x7F),
|
||||
9 => egui::Color32::from_rgb(0xFF, 0x00, 0x00),
|
||||
10 => egui::Color32::from_rgb(0x00, 0xFF, 0x00),
|
||||
11 => egui::Color32::from_rgb(0xFF, 0xFF, 0x00),
|
||||
12 => egui::Color32::from_rgb(0x5C, 0x5C, 0xFF),
|
||||
13 => egui::Color32::from_rgb(0xFF, 0x00, 0xFF),
|
||||
14 => egui::Color32::from_rgb(0x00, 0xFF, 0xFF),
|
||||
15 => egui::Color32::from_rgb(0xFF, 0xFF, 0xFF),
|
||||
// 6×6×6 colour cube (indices 16..=231).
|
||||
16..=231 => {
|
||||
let i = i - 16;
|
||||
let steps = [0u8, 95, 135, 175, 215, 255];
|
||||
let r = steps[(i / 36) as usize];
|
||||
let g = steps[((i / 6) % 6) as usize];
|
||||
let b = steps[(i % 6) as usize];
|
||||
egui::Color32::from_rgb(r, g, b)
|
||||
}
|
||||
// 24-step grey ramp (indices 232..=255).
|
||||
_ => {
|
||||
let level = 8 + (i - 232) * 10;
|
||||
egui::Color32::from_gray(level)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ctrl_c_encodes_to_etx() {
|
||||
assert_eq!(encode_key(Key::C, Modifiers::CTRL), Some(vec![0x03]));
|
||||
assert_eq!(encode_key(Key::A, Modifiers::CTRL), Some(vec![0x01]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_keys_encode_to_their_sequences() {
|
||||
assert_eq!(
|
||||
encode_key(Key::Enter, Modifiers::NONE),
|
||||
Some(b"\r".to_vec())
|
||||
);
|
||||
assert_eq!(
|
||||
encode_key(Key::Backspace, Modifiers::NONE),
|
||||
Some(b"\x7f".to_vec())
|
||||
);
|
||||
assert_eq!(
|
||||
encode_key(Key::ArrowUp, Modifiers::NONE),
|
||||
Some(b"\x1b[A".to_vec())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_letters_are_not_encoded_here() {
|
||||
// Printable text comes through egui text-input events, not key encoding.
|
||||
assert_eq!(encode_key(Key::A, Modifiers::NONE), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vt_default_color_uses_the_supplied_default() {
|
||||
let dflt = egui::Color32::from_rgb(1, 2, 3);
|
||||
assert_eq!(vt_color(vt100::Color::Default, dflt), dflt);
|
||||
assert_eq!(
|
||||
vt_color(vt100::Color::Rgb(10, 20, 30), dflt),
|
||||
egui::Color32::from_rgb(10, 20, 30)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ansi_cube_and_grey_indices_map_in_range() {
|
||||
// Index 16 is the bottom of the cube = black; 231 is white.
|
||||
assert_eq!(ansi_indexed(16), egui::Color32::from_rgb(0, 0, 0));
|
||||
assert_eq!(ansi_indexed(231), egui::Color32::from_rgb(255, 255, 255));
|
||||
// Greyscale ramp stays grey (r == g == b).
|
||||
let g = ansi_indexed(240);
|
||||
assert_eq!(g.r(), g.g());
|
||||
assert_eq!(g.g(), g.b());
|
||||
}
|
||||
}
|
||||
+6042
File diff suppressed because it is too large
Load Diff
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
//! The editor's command terminal: runs a shell command and streams its output
|
||||
//! into the [Console](crate::console) panel.
|
||||
//!
|
||||
//! This is the second half of the Stage-10 editor terminal — the log-capture
|
||||
//! Console shows engine/script output, and this adds **command execution**: type
|
||||
//! a command, it runs (via `sh -c`) with the working directory set to the open
|
||||
//! project, and its stdout/stderr stream back into the same panel as they
|
||||
//! arrive. Long-running commands (a build, a watcher, an AI-agent CLI) stream
|
||||
//! line by line rather than blocking the editor — each line is pushed to the
|
||||
//! shared console buffer from a reader thread, and the panel re-renders it next
|
||||
//! frame.
|
||||
//!
|
||||
//! Running arbitrary commands from the editor is intended: the terminal is the
|
||||
//! drop-in surface for dev tools and AI agents that edit the watched scripts
|
||||
//! (whose edits then flow back through live reload).
|
||||
|
||||
use std::io::{BufRead, BufReader, Read};
|
||||
use std::path::Path;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use log::Level;
|
||||
|
||||
use crate::console;
|
||||
|
||||
/// The console target terminal lines are tagged with (distinguishes shell output
|
||||
/// from engine `log` records in the panel).
|
||||
const TARGET: &str = "terminal";
|
||||
|
||||
/// Spawns `command` with `sh -c` in `cwd`, streaming its stdout/stderr into the
|
||||
/// console. Returns immediately; output arrives asynchronously. A blank command
|
||||
/// is ignored.
|
||||
///
|
||||
/// The command is echoed first (`$ <command>`); stdout lines log at info level,
|
||||
/// stderr at warn (so errors stand out), and the exit status is reported when
|
||||
/// the process finishes.
|
||||
pub fn run(command: &str, cwd: &Path) {
|
||||
let command = command.trim();
|
||||
if command.is_empty() {
|
||||
return;
|
||||
}
|
||||
console::append(Level::Info, TARGET, format!("$ {command}"));
|
||||
|
||||
let child = Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg(command)
|
||||
.current_dir(cwd)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn();
|
||||
|
||||
let mut child = match child {
|
||||
Ok(child) => child,
|
||||
Err(err) => {
|
||||
console::append(Level::Error, TARGET, format!("failed to start: {err}"));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let stdout = child.stdout.take();
|
||||
let stderr = child.stderr.take();
|
||||
|
||||
// One supervisor thread owns the child: it streams both pipes (stderr on its
|
||||
// own thread so the two don't deadlock on full buffers), waits, and reports
|
||||
// the exit status. Detached — the panel reads results from the shared buffer.
|
||||
std::thread::spawn(move || {
|
||||
let err_thread = stderr.map(|e| std::thread::spawn(move || stream(e, Level::Warn)));
|
||||
if let Some(out) = stdout {
|
||||
stream(out, Level::Info);
|
||||
}
|
||||
if let Some(handle) = err_thread {
|
||||
let _ = handle.join();
|
||||
}
|
||||
match child.wait() {
|
||||
Ok(status) if status.success() => {
|
||||
console::append(Level::Info, TARGET, "(exit 0)");
|
||||
}
|
||||
Ok(status) => {
|
||||
let code = status
|
||||
.code()
|
||||
.map(|c| c.to_string())
|
||||
.unwrap_or_else(|| "signal".to_string());
|
||||
console::append(Level::Warn, TARGET, format!("(exit {code})"));
|
||||
}
|
||||
Err(err) => console::append(Level::Error, TARGET, format!("wait failed: {err}")),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Reads `reader` line by line, pushing each line into the console at `level`.
|
||||
fn stream<R: Read>(reader: R, level: Level) {
|
||||
let mut buf = BufReader::new(reader);
|
||||
let mut line = String::new();
|
||||
loop {
|
||||
line.clear();
|
||||
match buf.read_line(&mut line) {
|
||||
Ok(0) => break, // EOF
|
||||
Ok(_) => console::append(
|
||||
level,
|
||||
TARGET,
|
||||
line.trim_end_matches(['\n', '\r']).to_string(),
|
||||
),
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
//! The editor's 3D viewport: an orbit / flythrough camera and a forward
|
||||
//! render of the scene.
|
||||
//!
|
||||
//! The engine stays UI-agnostic; this glue lives in the editor. [`Viewport`]
|
||||
//! owns a [`ForwardRenderer`], a small cache of primitive [`GpuMesh`]es, two
|
||||
//! camera modes ([`OrbitCamera`] for inspecting a target,
|
||||
//! [`FlythroughCamera`] for free-look navigation), and draws every scene
|
||||
//! entity that carries a [`MeshRenderer`](oxide_engine::render::MeshRenderer)
|
||||
//! component. The mode toggle preserves pose so the camera does not snap
|
||||
//! when switching.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use oxide_engine::hecs::Entity;
|
||||
use oxide_engine::math::{EulerRot, Quat, Transform, Vec3};
|
||||
use oxide_engine::prelude::*;
|
||||
use oxide_engine::wgpu;
|
||||
use oxide_engine::window::RenderCtx;
|
||||
|
||||
/// An orbit camera: looks at `target` from a yaw/pitch/distance offset.
|
||||
pub struct OrbitCamera {
|
||||
/// The point the camera orbits and looks at.
|
||||
pub target: Vec3,
|
||||
/// Horizontal angle (radians) around `+Y`.
|
||||
pub yaw: f32,
|
||||
/// Vertical angle (radians); clamped to avoid flipping over the poles.
|
||||
pub pitch: f32,
|
||||
/// Distance from `target` to the eye.
|
||||
pub distance: f32,
|
||||
}
|
||||
|
||||
impl Default for OrbitCamera {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
target: Vec3::new(0.0, 0.8, 0.0),
|
||||
yaw: 0.6,
|
||||
pitch: -0.45,
|
||||
distance: 12.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl OrbitCamera {
|
||||
/// The camera's orientation as a quaternion.
|
||||
fn rotation(&self) -> Quat {
|
||||
Quat::from_euler(EulerRot::YXZ, self.yaw, self.pitch, 0.0)
|
||||
}
|
||||
|
||||
/// The eye position in world space.
|
||||
fn eye(&self) -> Vec3 {
|
||||
self.target + self.rotation() * Vec3::new(0.0, 0.0, self.distance)
|
||||
}
|
||||
|
||||
/// The camera's world transform (what the renderer takes as the view).
|
||||
pub fn view_transform(&self) -> Transform {
|
||||
Transform::looking_at(self.eye(), self.target, Vec3::Y)
|
||||
}
|
||||
|
||||
/// Orbit by a pixel drag delta.
|
||||
pub fn orbit(&mut self, dx: f32, dy: f32) {
|
||||
const SENS: f32 = 0.005;
|
||||
self.yaw -= dx * SENS;
|
||||
self.pitch = (self.pitch - dy * SENS).clamp(-1.54, 1.54);
|
||||
}
|
||||
|
||||
/// Pan the target in the camera's screen plane by a pixel drag delta.
|
||||
pub fn pan(&mut self, dx: f32, dy: f32) {
|
||||
let rot = self.rotation();
|
||||
let right = rot * Vec3::X;
|
||||
let up = rot * Vec3::Y;
|
||||
// Scale panning with distance so it feels consistent at any zoom.
|
||||
let speed = self.distance * 0.0015;
|
||||
self.target += (-right * dx + up * dy) * speed;
|
||||
}
|
||||
|
||||
/// Zoom by a scroll delta (positive = closer).
|
||||
pub fn zoom(&mut self, amount: f32) {
|
||||
self.distance = (self.distance * (1.0 - amount * 0.1)).clamp(0.5, 500.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// A free-look "flythrough" camera: a position in world space plus a
|
||||
/// yaw/pitch orientation, driven by WASD/QE translation + mouse-look in the
|
||||
/// usual first-person convention.
|
||||
///
|
||||
/// Distinct from [`OrbitCamera`] because the two modes have fundamentally
|
||||
/// different controls; switching between them preserves the camera pose via
|
||||
/// [`FlythroughCamera::from_orbit`] / [`OrbitCamera::from_flythrough`] so the
|
||||
/// view doesn't snap on toggle.
|
||||
pub struct FlythroughCamera {
|
||||
/// Eye position in world space.
|
||||
pub position: Vec3,
|
||||
/// Horizontal angle (radians) around `+Y`, matching [`OrbitCamera::yaw`].
|
||||
pub yaw: f32,
|
||||
/// Vertical angle (radians); clamped to avoid flipping over the poles.
|
||||
pub pitch: f32,
|
||||
/// Translation speed in world units per second at the base (non-sprint)
|
||||
/// rate. Adjustable at runtime — the editor binds scroll-wheel to this.
|
||||
pub move_speed: f32,
|
||||
/// Multiplier applied while the "sprint" action is held.
|
||||
pub sprint_multiplier: f32,
|
||||
}
|
||||
|
||||
impl Default for FlythroughCamera {
|
||||
fn default() -> Self {
|
||||
// Place the eye where the default OrbitCamera would put it, so a
|
||||
// fresh project that starts in flythrough mode (a future preference)
|
||||
// sees the same opening view.
|
||||
let orbit = OrbitCamera::default();
|
||||
Self::from_orbit(&orbit)
|
||||
}
|
||||
}
|
||||
|
||||
impl FlythroughCamera {
|
||||
/// Position the flythrough camera to look at the same view the given
|
||||
/// orbit camera is showing. The eye lands at the orbit camera's eye
|
||||
/// position and the yaw/pitch are copied verbatim.
|
||||
pub fn from_orbit(orbit: &OrbitCamera) -> Self {
|
||||
Self {
|
||||
position: orbit.eye(),
|
||||
yaw: orbit.yaw,
|
||||
pitch: orbit.pitch,
|
||||
move_speed: 5.0,
|
||||
sprint_multiplier: 4.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The camera's orientation as a quaternion (same Y-yaw-then-X-pitch
|
||||
/// convention as [`OrbitCamera::rotation`]).
|
||||
fn rotation(&self) -> Quat {
|
||||
Quat::from_euler(EulerRot::YXZ, self.yaw, self.pitch, 0.0)
|
||||
}
|
||||
|
||||
/// Unit world-space forward direction (where the camera looks).
|
||||
pub fn forward(&self) -> Vec3 {
|
||||
self.rotation() * Vec3::new(0.0, 0.0, -1.0)
|
||||
}
|
||||
|
||||
/// Unit world-space right direction (camera's screen-right).
|
||||
pub fn right(&self) -> Vec3 {
|
||||
self.rotation() * Vec3::X
|
||||
}
|
||||
|
||||
/// Unit world-space up direction.
|
||||
pub fn up(&self) -> Vec3 {
|
||||
self.rotation() * Vec3::Y
|
||||
}
|
||||
|
||||
/// The camera's world transform (what the renderer takes as the view).
|
||||
pub fn view_transform(&self) -> Transform {
|
||||
Transform::looking_at(self.position, self.position + self.forward(), Vec3::Y)
|
||||
}
|
||||
|
||||
/// Mouse-look by a pixel drag delta. Same sensitivity as
|
||||
/// [`OrbitCamera::orbit`] so the gesture feels identical in both modes.
|
||||
pub fn look(&mut self, dx: f32, dy: f32) {
|
||||
const SENS: f32 = 0.005;
|
||||
self.yaw -= dx * SENS;
|
||||
self.pitch = (self.pitch - dy * SENS).clamp(-1.54, 1.54);
|
||||
}
|
||||
|
||||
/// Translate by a per-frame move vector in **camera-local** axes (`+X`
|
||||
/// right, `+Y` up, `-Z` forward — the same convention game code uses for
|
||||
/// a first-person move input). Each axis is expected to be in `[-1, 1]`,
|
||||
/// the natural range of an [`AxisBinding`](oxide_engine::input::AxisBinding).
|
||||
pub fn translate_local(&mut self, local: Vec3, dt: f32, sprint: bool) {
|
||||
if local.length_squared() == 0.0 {
|
||||
return;
|
||||
}
|
||||
let speed = if sprint {
|
||||
self.move_speed * self.sprint_multiplier
|
||||
} else {
|
||||
self.move_speed
|
||||
};
|
||||
// `local` is in camera-local axes (right / up / forward). Convert to
|
||||
// world by combining with the camera basis. `-Z` is forward, so a
|
||||
// local.z of `-1.0` (from a "forward" axis) moves along +forward.
|
||||
let world = self.right() * local.x + self.up() * local.y + self.forward() * (-local.z);
|
||||
self.position += world * (speed * dt);
|
||||
}
|
||||
|
||||
/// Adjust the base move speed by a scroll-wheel delta. Clamped so the
|
||||
/// camera never becomes immobile or too fast to control.
|
||||
pub fn adjust_move_speed(&mut self, scroll_lines: f32) {
|
||||
let factor = (1.0 + scroll_lines * 0.1).max(0.1);
|
||||
self.move_speed = (self.move_speed * factor).clamp(0.5, 200.0);
|
||||
}
|
||||
}
|
||||
|
||||
impl OrbitCamera {
|
||||
/// Position an orbit camera so it shows the same view as the given
|
||||
/// flythrough camera. The target is placed [`OrbitCamera::distance`]
|
||||
/// units in front of the flythrough's eye along its forward direction.
|
||||
pub fn from_flythrough(fly: &FlythroughCamera) -> Self {
|
||||
let distance = OrbitCamera::default().distance;
|
||||
Self {
|
||||
target: fly.position + fly.forward() * distance,
|
||||
yaw: fly.yaw,
|
||||
pitch: fly.pitch,
|
||||
distance,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Which input scheme drives the viewport camera.
|
||||
///
|
||||
/// [`Orbit`](Self::Orbit) is the default editor convention — useful for
|
||||
/// inspecting a single subject. [`Flythrough`](Self::Flythrough) is a
|
||||
/// first-person fly: WASD/QE translate, right-drag looks around, scroll
|
||||
/// adjusts move speed; better for navigating a level or open scene.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CameraMode {
|
||||
Orbit,
|
||||
Flythrough,
|
||||
}
|
||||
|
||||
/// Owns the renderer, primitive mesh cache, camera, and lighting for the editor
|
||||
/// viewport.
|
||||
pub struct Viewport {
|
||||
pipeline: RenderPipeline,
|
||||
meshes: HashMap<PrimitiveShape, GpuMesh>,
|
||||
pub camera: Camera,
|
||||
pub orbit: OrbitCamera,
|
||||
pub flythrough: FlythroughCamera,
|
||||
pub mode: CameraMode,
|
||||
pub lighting: Lighting,
|
||||
}
|
||||
|
||||
impl Viewport {
|
||||
/// Builds the viewport, uploading a GPU mesh for every primitive shape.
|
||||
pub fn new(device: &wgpu::Device, color_format: wgpu::TextureFormat) -> Self {
|
||||
let meshes = PrimitiveShape::ALL
|
||||
.iter()
|
||||
.map(|&shape| (shape, shape.mesh().upload(device, shape.label())))
|
||||
.collect();
|
||||
// The editor clears the frame before drawing the scene, so the viewport
|
||||
// pipeline is just the forward pass; post passes slot in here later.
|
||||
let mut pipeline = RenderPipeline::new();
|
||||
pipeline.add_pass("forward", ForwardPass::new(device, color_format));
|
||||
let orbit = OrbitCamera::default();
|
||||
let flythrough = FlythroughCamera::from_orbit(&orbit);
|
||||
Self {
|
||||
pipeline,
|
||||
meshes,
|
||||
camera: Camera::default(),
|
||||
orbit,
|
||||
flythrough,
|
||||
mode: CameraMode::Orbit,
|
||||
lighting: Lighting::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The view transform of the **active** camera (whichever mode is
|
||||
/// currently selected).
|
||||
pub fn view_transform(&self) -> Transform {
|
||||
match self.mode {
|
||||
CameraMode::Orbit => self.orbit.view_transform(),
|
||||
CameraMode::Flythrough => self.flythrough.view_transform(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Swaps between orbit and flythrough modes while preserving pose, so
|
||||
/// the visible scene does not jump when the user toggles. Returns the
|
||||
/// new mode for the caller to surface in the status bar.
|
||||
pub fn toggle_camera_mode(&mut self) -> CameraMode {
|
||||
match self.mode {
|
||||
CameraMode::Orbit => {
|
||||
self.flythrough = FlythroughCamera::from_orbit(&self.orbit);
|
||||
self.mode = CameraMode::Flythrough;
|
||||
}
|
||||
CameraMode::Flythrough => {
|
||||
self.orbit = OrbitCamera::from_flythrough(&self.flythrough);
|
||||
self.mode = CameraMode::Orbit;
|
||||
}
|
||||
}
|
||||
self.mode
|
||||
}
|
||||
|
||||
/// Renders the scene's renderable entities into the frame, before the editor
|
||||
/// UI is painted on top.
|
||||
///
|
||||
/// `viewport_rect` restricts drawing and projection to the Viewport
|
||||
/// tab's sub-rectangle of the surface (in physical pixels). `None`
|
||||
/// falls back to the full surface — handy for early frames before
|
||||
/// egui has reported a rect, and for any host that wants to render
|
||||
/// edge-to-edge.
|
||||
pub fn render(
|
||||
&mut self,
|
||||
scene: &Scene,
|
||||
ctx: &RenderCtx<'_>,
|
||||
viewport_rect: Option<oxide_engine::math::Rect>,
|
||||
) {
|
||||
// Snapshot renderables first so the query borrow is released before we
|
||||
// resolve world transforms.
|
||||
let renderables: Vec<(Entity, MeshRenderer)> = scene
|
||||
.world()
|
||||
.query::<&MeshRenderer>()
|
||||
.iter()
|
||||
.map(|(e, mr)| (e, *mr))
|
||||
.collect();
|
||||
|
||||
let view = self.view_transform();
|
||||
let mut objects = Vec::with_capacity(renderables.len());
|
||||
for (entity, mr) in &renderables {
|
||||
// Hierarchical: a disabled ancestor hides its whole subtree.
|
||||
if !scene.is_effectively_enabled(*entity).unwrap_or(true) {
|
||||
continue;
|
||||
}
|
||||
// Per-component: the MeshRenderer itself may be marked disabled
|
||||
// (e.g. by a script before a trigger fires).
|
||||
if scene.is_component_disabled(*entity, "MeshRenderer") {
|
||||
continue;
|
||||
}
|
||||
// Honor the camera's layer visibility: entities default to the
|
||||
// Default layer when they carry no explicit `Layer` component.
|
||||
let layers = scene.get::<Layer>(*entity).map(|l| *l).unwrap_or_default();
|
||||
if !self.camera.sees(layers) {
|
||||
continue;
|
||||
}
|
||||
let Some(world) = scene.world_transform(*entity) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(mesh) = self.meshes.get(&mr.shape) {
|
||||
objects.push(RenderObject {
|
||||
mesh,
|
||||
material: mr.material,
|
||||
transform: world,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
self.pipeline.render(&mut FrameContext {
|
||||
device: ctx.gpu.device(),
|
||||
queue: ctx.gpu.queue(),
|
||||
color: ctx.view,
|
||||
size: ctx.size,
|
||||
viewport_rect,
|
||||
clear_color: Color::BLACK, // editor clears separately; unused here
|
||||
camera: &self.camera,
|
||||
view_transform: &view,
|
||||
lighting: &self.lighting,
|
||||
objects: &objects,
|
||||
});
|
||||
}
|
||||
|
||||
/// Builds the world-space ray from `cursor` (window-physical pixels)
|
||||
/// through the viewport using the active camera's projection. The same
|
||||
/// helper feeds both entity picking and gizmo handle hit-testing — they
|
||||
/// must agree on the math or a click on a handle won't line up with
|
||||
/// what the user sees.
|
||||
pub fn ray_from_cursor(
|
||||
&self,
|
||||
cursor: (f32, f32),
|
||||
size: (u32, u32),
|
||||
viewport_rect: Option<oxide_engine::math::Rect>,
|
||||
) -> Ray {
|
||||
let rect = viewport_rect.unwrap_or_else(|| {
|
||||
oxide_engine::math::Rect::from_min_size(
|
||||
oxide_engine::math::Vec2::ZERO,
|
||||
oxide_engine::math::Vec2::new(size.0.max(1) as f32, size.1.max(1) as f32),
|
||||
)
|
||||
});
|
||||
let (w, h) = (rect.width().max(1.0), rect.height().max(1.0));
|
||||
// Cursor is in window coords; rebase to viewport-local before NDC.
|
||||
let local_x = cursor.0 - rect.min.x;
|
||||
let local_y = cursor.1 - rect.min.y;
|
||||
// Cursor → normalized device coordinates (flip Y: screen down, NDC up).
|
||||
let ndc_x = 2.0 * local_x / w - 1.0;
|
||||
let ndc_y = 1.0 - 2.0 * local_y / h;
|
||||
|
||||
let view = self.view_transform();
|
||||
let inv_vp = self.camera.view_projection(w / h, &view).inverse();
|
||||
// Unproject the near and far points of the pixel into world space.
|
||||
let near = inv_vp.project_point3(Vec3::new(ndc_x, ndc_y, 0.0));
|
||||
let far = inv_vp.project_point3(Vec3::new(ndc_x, ndc_y, 1.0));
|
||||
Ray::new(near, (far - near).normalize_or_zero())
|
||||
}
|
||||
|
||||
/// The combined view-projection matrix the viewport uses for `viewport_rect`'s
|
||||
/// aspect ratio. Exposed so the gizmo overlay can project world points
|
||||
/// to screen pixels with the same math the renderer drew with.
|
||||
pub fn view_projection_for(
|
||||
&self,
|
||||
viewport_rect: Option<oxide_engine::math::Rect>,
|
||||
size: (u32, u32),
|
||||
) -> oxide_engine::math::Mat4 {
|
||||
let rect = viewport_rect.unwrap_or_else(|| {
|
||||
oxide_engine::math::Rect::from_min_size(
|
||||
oxide_engine::math::Vec2::ZERO,
|
||||
oxide_engine::math::Vec2::new(size.0.max(1) as f32, size.1.max(1) as f32),
|
||||
)
|
||||
});
|
||||
let aspect = rect.width().max(1.0) / rect.height().max(1.0);
|
||||
let view = self.view_transform();
|
||||
self.camera.view_projection(aspect, &view)
|
||||
}
|
||||
|
||||
/// Picks the nearest renderable entity under the cursor (physical pixels),
|
||||
/// by casting a ray through the viewport and testing each entity's
|
||||
/// world-space bounds. Returns `None` if the ray hits nothing.
|
||||
///
|
||||
/// `viewport_rect` is the same sub-rectangle the render path used (the
|
||||
/// Viewport tab in the editor's case); the cursor is converted to NDC
|
||||
/// relative to it so a click at the tab's edge corresponds to the ray
|
||||
/// through that edge — not through the corresponding spot in a full-
|
||||
/// window projection. `None` falls back to the full window.
|
||||
pub fn pick(
|
||||
&self,
|
||||
scene: &Scene,
|
||||
cursor: (f32, f32),
|
||||
size: (u32, u32),
|
||||
viewport_rect: Option<oxide_engine::math::Rect>,
|
||||
) -> Option<Entity> {
|
||||
let ray = self.ray_from_cursor(cursor, size, viewport_rect);
|
||||
|
||||
let renderables: Vec<(Entity, MeshRenderer)> = scene
|
||||
.world()
|
||||
.query::<&MeshRenderer>()
|
||||
.iter()
|
||||
.map(|(e, mr)| (e, *mr))
|
||||
.collect();
|
||||
|
||||
let mut best: Option<(f32, Entity)> = None;
|
||||
for (entity, mr) in renderables {
|
||||
// Don't pick what isn't visible (effectively disabled subtree, or
|
||||
// a per-component disable on the MeshRenderer).
|
||||
if scene.is_component_disabled(entity, "MeshRenderer") {
|
||||
continue;
|
||||
}
|
||||
if !scene.is_effectively_enabled(entity).unwrap_or(true) {
|
||||
continue;
|
||||
}
|
||||
let Some(world) = scene.world_transform(entity) else {
|
||||
continue;
|
||||
};
|
||||
let aabb = transform_aabb(&world, &mr.shape.local_bounds());
|
||||
if let Some(t) = aabb.ray_intersection(&ray) {
|
||||
if best.map_or(true, |(bt, _)| t < bt) {
|
||||
best = Some((t, entity));
|
||||
}
|
||||
}
|
||||
}
|
||||
best.map(|(_, e)| e)
|
||||
}
|
||||
}
|
||||
|
||||
/// The world-space AABB of a local AABB transformed by `t` (transform its 8
|
||||
/// corners and re-fit).
|
||||
fn transform_aabb(t: &Transform, local: &oxide_engine::math::Aabb) -> oxide_engine::math::Aabb {
|
||||
oxide_engine::math::Aabb::from_points(local.corners().iter().map(|&c| t.transform_point(c)))
|
||||
}
|
||||
Reference in New Issue
Block a user