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,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())
|
||||
}
|
||||
Reference in New Issue
Block a user