Import Oxide engine (Stages 0–10) under MIT license

Full project snapshot migrated to new Gitea remote without history:
engine, editor, physics, script, examples, tests, docs, and assets.
Relicensed from GPLv3 to MIT and updated repo URLs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Homer Simpson
2026-07-05 20:41:02 +02:00
parent 2afb56b329
commit f56a1eea3b
128 changed files with 40493 additions and 2 deletions
+720
View File
@@ -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())
}