//! 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, viewport: Option, /// 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, 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 { 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, ) -> Option { 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(|| "".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` 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()) }