Skip to main content

khora_editor/
app.rs

1// Copyright 2025 eraflo
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Editor application root.
16//!
17//! Implements `EngineApp`, `AgentProvider`, `PhaseProvider`. Per-frame
18//! work is delegated to the focused modules (`input`, `commands`,
19//! `hot_reload`) so this file stays a composition layer rather than a
20//! 1000-line god struct.
21
22use std::sync::{Arc, Mutex};
23use std::time::Instant;
24
25use khora_sdk::editor_ui::{viewport_texture::ViewportTextureHandle, AssetEntry};
26use khora_sdk::khora_core::platform::KhoraWindow;
27use khora_sdk::khora_core::renderer::api::resource::ViewInfo;
28use khora_sdk::khora_core::ui::editor::dock::{DockTree, DropZone};
29use khora_sdk::khora_core::ui::{EditorOverlay, OverlayScreenDescriptor};
30use khora_sdk::prelude::ecs::*;
31use khora_sdk::prelude::*;
32use khora_sdk::winit;
33use khora_sdk::RenderSystem;
34use khora_sdk::WgpuRenderSystem;
35use khora_sdk::{AgentProvider, EngineApp, GameWorld, InputEvent, Runtime};
36use khora_sdk::{CommandHistory, DccService, PlayMode};
37use khora_sdk::{
38    EditorCamera, EditorLogCapture, EditorMode, EditorShell, EditorState, LogEntry, PanelLocation,
39};
40
41use crate::bootstrap::{load_logo_icon, PROJECT_PATH};
42use crate::chrome::{SpinePanel, StatusBarPanel, TitleBarPanel};
43use crate::cmd_palette::CommandPalettePanel;
44use crate::input::InputState;
45use crate::panels::{
46    AssetBrowserPanel, ConsolePanel, ControlPlanePanel, PropertiesPanel, SceneTreePanel,
47    ViewportPanel,
48};
49use crate::project_vfs::ProjectVfs;
50use crate::{commands, hot_reload, input, mod_agents, mod_gizmo, ops, scene_io, util};
51
52pub struct EditorApp {
53    camera: Arc<Mutex<EditorCamera>>,
54    editor_state: Arc<Mutex<EditorState>>,
55    command_history: Arc<Mutex<CommandHistory>>,
56    log_handle: Arc<Mutex<Vec<LogEntry>>>,
57    shell: Option<Arc<Mutex<Box<dyn EditorShell>>>>,
58    overlay: Option<Arc<Mutex<Box<dyn EditorOverlay>>>>,
59    /// Cached `Arc<winit::window::Window>` retrieved from services in
60    /// `setup()`. Needed because the overlay's `begin_frame` and
61    /// `handle_window_event` both expect a winit window reference.
62    raw_window: Option<Arc<winit::window::Window>>,
63    /// Live monitor handle exposed by the engine (Phase 2.1). Cloned in
64    /// `setup` from the ServiceRegistry; queried each frame to push
65    /// CPU/GPU load, VRAM, draw calls and triangles into
66    /// `EditorState::status`.
67    monitors: Option<khora_sdk::MonitorRegistry>,
68    /// Live agent registry (Phase 2.3). Used by the Control Plane
69    /// workspace to enumerate agents instead of the previous mock list.
70    agent_registry: Option<Arc<Mutex<khora_sdk::AgentRegistry>>>,
71    /// DCC context handle (Phase 2.2). Locked each frame for the Control
72    /// Plane summary bar — exposes mode, budget multiplier, hardware
73    /// load.
74    dcc_context: Option<Arc<std::sync::RwLock<khora_sdk::DccContext>>>,
75    /// View info computed in `before_agents` and re-used by
76    /// `after_agents` for gizmo rendering.
77    last_view_info: Option<ViewInfo>,
78    input: InputState,
79    /// Whether the wireframe debug overlay is on. Toggled from the command
80    /// palette ("Toggle Wireframe") and pushed into the shared
81    /// `WireframeConfig` each frame in `before_agents`.
82    wireframe_enabled: bool,
83    last_frame_time: Instant,
84    /// Editor viewport override — read by `RenderFlow` as fallback when
85    /// no active scene `Camera` exists (i.e. Editing mode). Updated each
86    /// frame in `before_agents` from the editor's free camera.
87    viewport_override: khora_sdk::khora_data::render::EditorViewportOverride,
88    /// Project-scoped VFS — instantiated in `setup` once the project
89    /// path resolves. All scene I/O, asset enumeration and hot-reload
90    /// routes through this. `None` when the editor was launched without
91    /// a project (rare; the hub always passes `--project`).
92    project_vfs: Option<Arc<Mutex<ProjectVfs>>>,
93}
94
95impl EngineApp for EditorApp {
96    fn window_config() -> WindowConfig {
97        WindowConfig {
98            title: "Khora Engine Editor".to_owned(),
99            icon: Some(load_logo_icon()),
100            ..WindowConfig::default()
101        }
102    }
103
104    fn new() -> Self {
105        let editor_state = Arc::new(Mutex::new(EditorState::default()));
106        let command_history = Arc::new(Mutex::new(CommandHistory::default()));
107
108        let (capture, log_handle) = EditorLogCapture::new();
109        let _ = log::set_boxed_logger(Box::new(capture));
110        log::set_max_level(log::LevelFilter::Debug);
111
112        Self {
113            camera: Arc::new(Mutex::new(EditorCamera::default())),
114            editor_state,
115            command_history,
116            log_handle,
117            shell: None,
118            overlay: None,
119            raw_window: None,
120            monitors: None,
121            agent_registry: None,
122            dcc_context: None,
123            last_view_info: None,
124            input: InputState::default(),
125            wireframe_enabled: false,
126            last_frame_time: Instant::now(),
127            viewport_override: khora_sdk::khora_data::render::EditorViewportOverride::new(),
128            project_vfs: None,
129        }
130    }
131
132    fn setup(&mut self, world: &mut GameWorld, runtime: &Runtime) {
133        self.cache_services(runtime);
134        self.register_panels(runtime);
135        self.open_cli_project(world);
136        // The editor ground grid (`GridConfig`) is toggled per-frame in
137        // `before_agents` based on `PlayMode` — it is editor chrome and
138        // must hide in Play / Paused, like the selection gizmos.
139    }
140
141    fn update(&mut self, world: &mut GameWorld, inputs: &[InputEvent]) {
142        // Consume pending scene load (from asset browser double-click).
143        let pending_load = self
144            .editor_state
145            .lock()
146            .ok()
147            .and_then(|mut s| s.pending_scene_load.take());
148        if let Some(path) = pending_load {
149            let abs = std::path::PathBuf::from(&path);
150            commands::load_scene_dispatch(
151                self.project_vfs.as_ref(),
152                world,
153                &self.editor_state,
154                &abs,
155            );
156            if let Ok(mut state) = self.editor_state.lock() {
157                state.current_scene_path = Some(path);
158            }
159        }
160
161        input::process_events(
162            &mut self.input,
163            inputs,
164            world,
165            &self.editor_state,
166            &self.camera,
167        );
168
169        // Wireframe toggle is editor-view state, not a scene command — handle
170        // it here (flipping our own flag) before the scene-command dispatch so
171        // it does not fall through to the "unhandled action" log. The flag is
172        // pushed into the shared `WireframeConfig` in `before_agents`.
173        if let Ok(mut s) = self.editor_state.lock() {
174            if s.pending_menu_action.as_deref() == Some("toggle_wireframe") {
175                s.pending_menu_action = None;
176                self.wireframe_enabled = !self.wireframe_enabled;
177            }
178        }
179
180        commands::process_menu_actions(
181            &mut self.project_vfs,
182            &self.editor_state,
183            &self.command_history,
184            world,
185        );
186
187        commands::process_pending_save_as_prefab(
188            self.project_vfs.as_ref(),
189            world,
190            &self.editor_state,
191        );
192        commands::process_pending_prefab_spawn(
193            self.project_vfs.as_ref(),
194            world,
195            &self.editor_state,
196        );
197        commands::process_pending_save_as_material(
198            self.project_vfs.as_ref(),
199            world,
200            &self.editor_state,
201        );
202        commands::process_pending_assign_material(
203            self.project_vfs.as_ref(),
204            world,
205            &self.editor_state,
206        );
207        commands::process_pending_asset_file_ops(self.project_vfs.as_ref(), &self.editor_state);
208        commands::process_pending_spawn_mesh_asset(
209            self.project_vfs.as_ref(),
210            world,
211            &self.editor_state,
212        );
213        commands::process_pending_assign_texture(
214            self.project_vfs.as_ref(),
215            world,
216            &self.editor_state,
217        );
218
219        if let Ok(mut state) = self.editor_state.lock() {
220            ops::apply_edits(world, &mut state);
221        }
222
223        let play_mode = self
224            .editor_state
225            .lock()
226            .map(|s| s.play_mode)
227            .unwrap_or(PlayMode::Editing);
228        ops::sync_scene_cameras_for_mode(world, play_mode);
229
230        self.tick_state(world);
231    }
232
233    fn on_shutdown(&mut self) {
234        log::info!("EditorApp: Shutting down");
235    }
236
237    fn intercept_window_event(
238        &mut self,
239        event: &dyn std::any::Any,
240        _window: &dyn KhoraWindow,
241    ) -> bool {
242        let Some(overlay_arc) = self.overlay.as_ref() else {
243            return false;
244        };
245        let Some(raw_window) = self.raw_window.as_ref() else {
246            return false;
247        };
248        let Ok(mut overlay) = overlay_arc.lock() else {
249            return false;
250        };
251        // egui *always* sees the event so it can keep its hover state in
252        // sync; the override below only changes the "consumed" verdict so
253        // the engine can ALSO process pointer events that land inside the
254        // viewport rect (without the override, egui's CentralPanel
255        // swallows every press/drag via `wants_pointer_input()`).
256        let consumed_by_egui =
257            overlay.handle_window_event((&**raw_window) as &dyn std::any::Any, event);
258
259        let we = event.downcast_ref::<winit::event::WindowEvent>();
260        let Some(we) = we else {
261            return consumed_by_egui;
262        };
263        use winit::event::WindowEvent;
264
265        // Track the cursor position so MouseInput events (which carry no
266        // position of their own) can be tested against the viewport rect.
267        if let WindowEvent::CursorMoved { position, .. } = we {
268            self.input.last_cursor_pos = Some((position.x as f32, position.y as f32));
269        }
270
271        // Losing focus swallows the release events, so drop every held button
272        // and modifier here rather than waiting for a release that will never
273        // arrive.
274        if matches!(we, WindowEvent::Focused(false)) {
275            self.input.release_all();
276        }
277
278        let is_pointer_event = matches!(
279            we,
280            WindowEvent::MouseInput { .. }
281                | WindowEvent::CursorMoved { .. }
282                | WindowEvent::MouseWheel { .. }
283        );
284        if !is_pointer_event {
285            return consumed_by_egui;
286        }
287
288        let viewport_rect = self
289            .editor_state
290            .lock()
291            .ok()
292            .and_then(|s| s.viewport_screen_rect);
293        let pos = match we {
294            WindowEvent::CursorMoved { position, .. } => {
295                Some((position.x as f32, position.y as f32))
296            }
297            _ => self.input.last_cursor_pos,
298        };
299
300        if let (Some([rx, ry, rw, rh]), Some((cx, cy))) = (viewport_rect, pos) {
301            let in_viewport = cx >= rx && cx < rx + rw && cy >= ry && cy < ry + rh;
302            if in_viewport {
303                return false;
304            }
305        }
306        consumed_by_egui
307    }
308
309    fn before_frame(
310        &mut self,
311        _world: &mut GameWorld,
312        runtime: &Runtime,
313        window: &dyn KhoraWindow,
314    ) {
315        // Switch the renderer to offscreen-viewport mode BEFORE
316        // `begin_render_frame` runs, so the per-frame `FrameContext`
317        // receives the viewport color/depth targets (instead of the
318        // swapchain) and agents paint into the texture displayed by the
319        // egui viewport panel.
320        if let Some(rs_arc) = runtime
321            .backends
322            .get::<Arc<Mutex<Box<dyn RenderSystem>>>>()
323            .cloned()
324        {
325            if let Ok(mut rs) = rs_arc.lock() {
326                rs.set_render_to_viewport(true);
327            }
328        }
329
330        let Some(overlay_arc) = self.overlay.as_ref() else {
331            return;
332        };
333        let Some(raw_window) = self.raw_window.as_ref() else {
334            return;
335        };
336
337        let (w_px, h_px) = window.inner_size();
338        let screen = OverlayScreenDescriptor {
339            width_px: w_px,
340            height_px: h_px,
341            scale_factor: window.scale_factor() as f32,
342        };
343
344        if let Ok(mut overlay) = overlay_arc.lock() {
345            overlay.begin_frame((&**raw_window) as &dyn std::any::Any, screen);
346        }
347
348        if let Some(shell) = self.shell.as_ref() {
349            if let Ok(mut shell) = shell.lock() {
350                shell.show_frame();
351            }
352        }
353    }
354
355    fn before_agents(&mut self, world: &mut GameWorld, runtime: &Runtime) {
356        if let Some(pvfs_arc) = self.project_vfs.as_ref() {
357            hot_reload::pump(pvfs_arc, &self.editor_state);
358        }
359
360        self.last_view_info = None;
361
362        let Some(rs_arc) = runtime
363            .backends
364            .get::<Arc<Mutex<Box<dyn RenderSystem>>>>()
365            .cloned()
366        else {
367            return;
368        };
369        let Ok(mut rs) = rs_arc.lock() else { return };
370        let Some(wgpu_rs) = rs.as_any_mut().downcast_mut::<WgpuRenderSystem>() else {
371            return;
372        };
373
374        let (vw, vh) = wgpu_rs.viewport_size();
375        let play_mode = self
376            .editor_state
377            .lock()
378            .ok()
379            .map(|s| s.play_mode)
380            .unwrap_or(PlayMode::Editing);
381
382        let view_info = match play_mode {
383            PlayMode::Editing => match self.camera.lock() {
384                Ok(cam) => cam.view_info(vw as f32, vh as f32),
385                Err(_) => ViewInfo::default(),
386            },
387            PlayMode::Playing | PlayMode::Paused => {
388                use khora_sdk::khora_data::render::extract_active_camera_view;
389                extract_active_camera_view(world.inner_world())
390                    .or_else(|| {
391                        // No active scene camera (shouldn't happen
392                        // because sync_scene_cameras_for_mode promotes
393                        // one) — fall back to the editor cam so the user
394                        // still sees something instead of a blank screen.
395                        self.camera
396                            .lock()
397                            .ok()
398                            .map(|cam| cam.view_info(vw as f32, vh as f32))
399                    })
400                    .unwrap_or_default()
401            }
402        };
403
404        // Publish the active view into the EditorViewportOverride so
405        // RenderFlow can fall back to it when no scene Camera is active
406        // (Editing mode forces every scene camera inactive). RenderFlow
407        // appends the override to RenderWorld.views during its `project`
408        // step on the next scheduler tick.
409        self.viewport_override
410            .set(Some(khora_sdk::khora_data::render::ExtractedView {
411                view_proj: view_info.view_projection_matrix(),
412                position: view_info.camera_position,
413            }));
414
415        // The viewport clear + ground grid are no longer drawn here:
416        // the scene pass (RenderAgent) clears the viewport, and the grid
417        // is now `GridLane` under `OverlayAgent` (rendered after the
418        // scene). The editor only feeds the camera to the renderer.
419        wgpu_rs.prepare_frame(&view_info);
420
421        // Toggle the editor ground grid per-frame: it is editor chrome,
422        // visible only while editing — hide it in Play / Paused (same
423        // gating as the selection gizmos below). The engine ships the
424        // `GridConfig` slot disabled; the editor drives it.
425        let grid_on = matches!(play_mode, PlayMode::Editing);
426        if let Some(grid_cfg) = runtime
427            .resources
428            .get::<khora_sdk::khora_lanes::render_lane::SharedGridConfig>()
429        {
430            if let Ok(mut cfg) = grid_cfg.lock() {
431                cfg.enabled = grid_on;
432            }
433        }
434
435        // Wireframe debug overlay — user-toggled (command palette), and like
436        // the grid it is editor chrome, hidden outside Editing mode.
437        if let Some(wf_cfg) = runtime
438            .resources
439            .get::<khora_sdk::khora_lanes::render_lane::SharedWireframeConfig>()
440        {
441            if let Ok(mut cfg) = wf_cfg.lock() {
442                cfg.enabled = self.wireframe_enabled && grid_on;
443            }
444        }
445
446        // Collect the current selection gizmos and publish them into the
447        // shared `GizmoFrame`. `OverlayAgent`'s `GizmoLane` renders them
448        // during the scheduler's OUTPUT phase — the editor is a pure data
449        // producer here, the engine owns the rendering mechanism. Gated
450        // on `PlayMode::Editing`: gizmos must not bleed into Play / Paused.
451        let gizmo_lines = if let Ok(state) = self.editor_state.lock() {
452            if state.play_mode != PlayMode::Editing || state.selection.is_empty() {
453                Vec::new()
454            } else {
455                mod_gizmo::collect_gizmo_lines(world, &state, &view_info)
456            }
457        } else {
458            Vec::new()
459        };
460        if let Some(shared) = runtime
461            .resources
462            .get::<khora_sdk::khora_lanes::render_lane::SharedGizmoFrame>()
463        {
464            if let Ok(mut frame) = shared.lock() {
465                frame.lines = gizmo_lines;
466            }
467        }
468
469        self.last_view_info = Some(view_info);
470    }
471
472    fn after_agents(&mut self, _world: &mut GameWorld, runtime: &Runtime) {
473        let Some(rs_arc) = runtime
474            .backends
475            .get::<Arc<Mutex<Box<dyn RenderSystem>>>>()
476            .cloned()
477        else {
478            return;
479        };
480
481        // Gizmo rendering now happens inside the scheduler via
482        // `OverlayAgent`'s `GizmoLane` (the lines are published into the
483        // shared `GizmoFrame` from `before_agents`). The editor no longer
484        // drives a separate gizmo render pass here.
485
486        // Present the egui overlay last so the dock + panels paint over
487        // the 3D scene encoded by the agents.
488        let Some(overlay_arc) = self.overlay.as_ref() else {
489            return;
490        };
491        let Some(raw_window) = self.raw_window.as_ref() else {
492            return;
493        };
494
495        let inner = (**raw_window).inner_size();
496        let screen = OverlayScreenDescriptor {
497            width_px: inner.width,
498            height_px: inner.height,
499            scale_factor: (**raw_window).scale_factor() as f32,
500        };
501
502        {
503            let mut rs = match rs_arc.lock() {
504                Ok(g) => g,
505                Err(_) => return,
506            };
507            // Switch back to swapchain so the egui overlay (dock + panels
508            // + viewport panel that displays the offscreen texture)
509            // paints onto the presented surface.
510            rs.set_render_to_viewport(false);
511            let mut overlay = match overlay_arc.lock() {
512                Ok(g) => g,
513                Err(_) => return,
514            };
515            let overlay_ref: &mut dyn EditorOverlay = &mut **overlay;
516            if let Err(e) = rs.render_overlay(overlay_ref, screen) {
517                log::error!("editor: render_overlay failed: {e:?}");
518            }
519        }
520    }
521}
522
523impl EditorApp {
524    fn cache_services(&mut self, runtime: &Runtime) {
525        if let Some(camera) = runtime
526            .resources
527            .get::<std::sync::Arc<std::sync::Mutex<EditorCamera>>>()
528            .cloned()
529        {
530            self.camera = camera;
531        }
532        self.overlay = runtime
533            .backends
534            .get::<Arc<Mutex<Box<dyn EditorOverlay>>>>()
535            .cloned();
536        self.monitors = runtime
537            .resources
538            .get::<khora_sdk::MonitorRegistry>()
539            .cloned();
540        self.agent_registry = runtime
541            .resources
542            .get::<Arc<Mutex<khora_sdk::AgentRegistry>>>()
543            .cloned();
544        self.dcc_context = runtime
545            .resources
546            .get::<Arc<std::sync::RwLock<khora_sdk::DccContext>>>()
547            .cloned();
548        self.raw_window = runtime
549            .resources
550            .get::<Arc<winit::window::Window>>()
551            .cloned();
552    }
553
554    /// Default Scene arrangement: hierarchy left, inspector right, viewport in
555    /// the middle, assets and console sharing a dock below it.
556    ///
557    /// Expressed as a sequence of drops because that is exactly what the user
558    /// would do by hand — which keeps the default honest about what the dock
559    /// can express.
560    fn scene_layout() -> DockTree {
561        const VIEWPORT: &str = "khora.editor.viewport";
562        let mut t = DockTree::single(VIEWPORT);
563
564        // Each drop nests inside the previous one, so the ratios are fractions
565        // of what is left rather than of the window — hence the second value
566        // being much larger than the sliver of screen the inspector gets.
567        let hierarchy = t.insert("khora.editor.scene_tree", Some(VIEWPORT), DropZone::Left);
568        let inspector = t.insert("khora.editor.properties", Some(VIEWPORT), DropZone::Right);
569        let bottom = t.insert(
570            "khora.editor.asset_browser",
571            Some(VIEWPORT),
572            DropZone::Bottom,
573        );
574        t.insert(
575            "khora.editor.console",
576            Some("khora.editor.asset_browser"),
577            DropZone::Center,
578        );
579
580        if let Some(id) = hierarchy {
581            t.set_ratio(id, 0.18); // hierarchy, of the whole width
582        }
583        if let Some(id) = inspector {
584            t.set_ratio(id, 0.74); // leaves the inspector ~21% of the window
585        }
586        if let Some(id) = bottom {
587            t.set_ratio(id, 0.72); // viewport keeps most of the centre column
588        }
589
590        // The asset browser is the tab that opens, matching the previous dock.
591        t.activate("khora.editor.asset_browser");
592        t
593    }
594
595    /// Control Plane is a single full-bleed workspace. The hierarchy and the
596    /// bottom dock used to stay on screen here with nothing to show, because
597    /// the old shell could only hide one slot.
598    fn control_plane_layout() -> DockTree {
599        DockTree::single("khora.editor.control_plane")
600    }
601
602    fn register_panels(&mut self, runtime: &Runtime) {
603        let viewport_handle = runtime
604            .resources
605            .get::<ViewportTextureHandle>()
606            .copied()
607            .unwrap_or(khora_sdk::PRIMARY_VIEWPORT);
608
609        let Some(shell_ref) = runtime
610            .backends
611            .get::<std::sync::Arc<std::sync::Mutex<Box<dyn EditorShell>>>>()
612            .cloned()
613        else {
614            log::warn!("EditorApp: no EditorShell found in runtime.backends.");
615            return;
616        };
617
618        if let Ok(mut shell) = shell_ref.lock() {
619            shell.set_editor_state(self.editor_state.clone());
620
621            // ── Brand identity (theme + typefaces) ─────────
622            let brand_theme = khora_tool_ui::khora_dark();
623            shell.set_theme(brand_theme.clone());
624            shell.set_fonts(crate::fonts::load_pack());
625
626            // ── Chrome (top, spine, status bar) ────────────
627            shell.register_panel(
628                PanelLocation::TopBar,
629                Box::new(TitleBarPanel::new(
630                    self.editor_state.clone(),
631                    brand_theme.clone(),
632                )),
633            );
634            shell.register_panel(
635                PanelLocation::Spine,
636                Box::new(SpinePanel::new(
637                    self.editor_state.clone(),
638                    brand_theme.clone(),
639                )),
640            );
641            shell.register_panel(
642                PanelLocation::StatusBar,
643                Box::new(StatusBarPanel::new(
644                    self.editor_state.clone(),
645                    brand_theme.clone(),
646                )),
647            );
648
649            // ── Workbench (the dockable area) ──────────────
650            // Every working panel lives in one dock rather than a fixed slot,
651            // so the user can rearrange them. The layouts below are only the
652            // defaults; the dock owns the arrangement from then on.
653            let mut workbench = crate::workbench::WorkbenchPanel::new(
654                self.editor_state.clone(),
655                brand_theme.clone(),
656            );
657            workbench.add_panel(Box::new(SceneTreePanel::new(
658                self.editor_state.clone(),
659                brand_theme.clone(),
660            )));
661            workbench.add_panel(Box::new(PropertiesPanel::new(
662                self.editor_state.clone(),
663                self.command_history.clone(),
664                brand_theme.clone(),
665            )));
666            workbench.add_panel(Box::new(AssetBrowserPanel::new(
667                self.editor_state.clone(),
668                brand_theme.clone(),
669            )));
670            workbench.add_panel(Box::new(ConsolePanel::new(
671                self.editor_state.clone(),
672                brand_theme.clone(),
673            )));
674            workbench.add_panel(Box::new(ViewportPanel::new(
675                viewport_handle,
676                self.editor_state.clone(),
677                self.camera.clone(),
678                brand_theme.clone(),
679            )));
680            workbench.add_panel(Box::new(ControlPlanePanel::new(
681                self.editor_state.clone(),
682                brand_theme.clone(),
683                self.agent_registry.clone(),
684                self.dcc_context.clone(),
685            )));
686            workbench.set_layout(EditorMode::Scene, Self::scene_layout());
687            workbench.set_layout(EditorMode::ControlPlane, Self::control_plane_layout());
688            shell.register_panel(PanelLocation::Center, Box::new(workbench));
689
690            // ── Floating overlays ──────────────────────────
691            shell.register_panel(
692                PanelLocation::Floating(100),
693                Box::new(CommandPalettePanel::new(
694                    self.editor_state.clone(),
695                    brand_theme.clone(),
696                )),
697            );
698            log::info!("EditorApp: panels registered with shell.");
699        }
700        self.shell = Some(shell_ref);
701    }
702
703    fn open_cli_project(&mut self, world: &mut GameWorld) {
704        let Some(Some(project_path)) = PROJECT_PATH.get() else {
705            return;
706        };
707        let path = std::path::PathBuf::from(project_path);
708        if !path.exists() {
709            log::warn!("--project path does not exist: {}", project_path);
710            return;
711        }
712
713        // Build the project-scoped VFS. This recursively scans the
714        // assets directory, builds a stable in-memory UUID index,
715        // registers all decoders, and arms the filesystem watcher.
716        let metrics = std::sync::Arc::new(khora_sdk::MetricsRegistry::new());
717        let mut pvfs = match ProjectVfs::open(path.clone(), metrics) {
718            Ok(p) => p,
719            Err(e) => {
720                log::error!(
721                    "Failed to open ProjectVfs for '{}': {:#}",
722                    path.display(),
723                    e
724                );
725                return;
726            }
727        };
728
729        // Read project metadata via the VFS helper instead of a raw
730        // `std::fs::read_to_string`.
731        let project_json: Option<serde_json::Value> = pvfs.read_project_json().ok();
732        let project_name = project_json
733            .as_ref()
734            .and_then(|v| v.get("name").and_then(|n| n.as_str()).map(String::from));
735        let project_engine_version = project_json.as_ref().and_then(|v| {
736            v.get("engine_version")
737                .and_then(|n| n.as_str())
738                .map(String::from)
739        });
740
741        let entries = hot_reload::collect_asset_entries(&pvfs);
742        let dirs = pvfs.list_dirs();
743        let git_branch = util::read_git_branch(&path);
744
745        if let Ok(mut state) = self.editor_state.lock() {
746            state.project_folder = Some(path.to_string_lossy().to_string());
747            state.project_name = project_name.clone();
748            state.project_engine_version = project_engine_version.clone();
749            state.asset_entries = entries;
750            state.asset_dirs = dirs;
751            state.asset_epoch = state.asset_epoch.wrapping_add(1);
752            state.current_git_branch = git_branch.clone();
753            log::info!(
754                "Opened project '{}' from CLI: '{}' ({} assets, git: {}, engine: {})",
755                project_name.as_deref().unwrap_or("<unknown>"),
756                path.display(),
757                state.asset_entries.len(),
758                git_branch.as_deref().unwrap_or("none"),
759                project_engine_version.as_deref().unwrap_or("<unknown>")
760            );
761        }
762
763        // Auto-load (or create) the default scene through the VFS so the
764        // new file shows up in the next index.
765        scene_io::auto_load_or_create_default_scene(&mut pvfs, world);
766
767        // Refresh the asset browser cache after the potential
768        // default-scene seed.
769        let entries: Vec<AssetEntry> = hot_reload::collect_asset_entries(&pvfs);
770        let dirs = pvfs.list_dirs();
771        if let Ok(mut state) = self.editor_state.lock() {
772            state.asset_entries = entries;
773            state.asset_dirs = dirs;
774            state.asset_epoch = state.asset_epoch.wrapping_add(1);
775        }
776
777        self.project_vfs = Some(Arc::new(Mutex::new(pvfs)));
778    }
779
780    fn tick_state(&mut self, world: &mut GameWorld) {
781        let mut state = match self.editor_state.lock() {
782            Ok(s) => s,
783            Err(_) => return,
784        };
785        state.ctrl_held = self.input.ctrl_held;
786
787        ops::process_spawns(world, &mut state);
788        ops::process_reparents(world, &mut state);
789
790        if let Some((entity, new_name)) = state.pending_rename.take() {
791            if let Some(name) = world.get_component_mut::<Name>(entity) {
792                *name = Name::new(&new_name);
793                log::info!("Renamed entity {:?} to '{}'", entity, new_name);
794            }
795        }
796
797        if let Some(entity) = state.pending_delete.take() {
798            world.despawn(entity);
799            state.selection.remove(&entity);
800            if state.inspected.as_ref().is_some_and(|i| i.entity == entity) {
801                state.inspected = None;
802            }
803            log::info!("Deleted entity {:?}", entity);
804        }
805
806        if let Some(entity) = state.pending_duplicate.take() {
807            ops::duplicate_entity(world, entity, &mut state);
808        }
809
810        if let Some((entity, type_name)) = state.pending_add_component.take() {
811            ops::add_component_to_entity(world, entity, &type_name);
812        }
813
814        ops::extract_scene_tree(world, &mut state);
815        ops::extract_inspected(world, &mut state);
816
817        if let Ok(log_entries) = self.log_handle.lock() {
818            state.log_entries.clone_from(&log_entries);
819        }
820
821        let now = Instant::now();
822        let dt = now.duration_since(self.last_frame_time).as_secs_f32();
823        self.last_frame_time = now;
824        state.status.frame_time_ms = dt * 1000.0;
825        state.status.fps = if dt > 0.0 { 1.0 / dt } else { 0.0 };
826        state.status.entity_count = state.entity_count;
827
828        // Phase 2.1 — pull live telemetry into status. Only the fields
829        // actually reported are updated; missing reports leave the
830        // previous value untouched (status fields default to 0).
831        if let Some(ref monitors) = self.monitors {
832            use khora_sdk::MonitoredResourceType;
833            for monitor in monitors.get_all_monitors() {
834                match monitor.resource_type() {
835                    MonitoredResourceType::Vram => {
836                        let r = monitor.get_usage_report();
837                        state.status.vram_mb = r.current_bytes as f32 / (1024.0 * 1024.0);
838                    }
839                    MonitoredResourceType::Gpu => {
840                        if let Some(g) = monitor.get_gpu_report() {
841                            state.status.draw_calls = g.draw_calls;
842                            state.status.triangles = g.triangles_rendered as u64;
843                        }
844                        if let Some(hw) = monitor.get_hardware_report() {
845                            state.status.gpu_load = hw.gpu_load.unwrap_or(0.0);
846                        }
847                    }
848                    MonitoredResourceType::Hardware => {
849                        if let Some(hw) = monitor.get_hardware_report() {
850                            state.status.cpu_load = hw.cpu_load;
851                            if let Some(g) = hw.gpu_load {
852                                state.status.gpu_load = g;
853                            }
854                        }
855                    }
856                    MonitoredResourceType::SystemRam => {
857                        let r = monitor.get_usage_report();
858                        state.status.memory_used_mb = r.current_bytes as f32 / (1024.0 * 1024.0);
859                    }
860                }
861            }
862        }
863
864        let status_copy = state.status.clone();
865        drop(state);
866
867        if let Some(ref shell) = self.shell {
868            if let Ok(mut shell) = shell.lock() {
869                shell.set_status(status_copy);
870            }
871        }
872    }
873}
874
875impl AgentProvider for EditorApp {
876    fn register_agents(&self, dcc: &DccService, runtime: &mut Runtime) {
877        runtime.resources.insert(self.editor_state.clone());
878        runtime.resources.insert(self.camera.clone());
879        // Editor viewport override — RenderFlow reads it as fallback
880        // when no scene Camera is active (Editing mode).
881        runtime.resources.insert(self.viewport_override.clone());
882        mod_agents::register_editor_agents(dcc);
883    }
884}
885
886impl khora_sdk::PhaseProvider for EditorApp {
887    fn custom_phases(&self) -> Vec<khora_sdk::ExecutionPhase> {
888        Vec::new()
889    }
890
891    fn removed_phases(&self) -> Vec<khora_sdk::ExecutionPhase> {
892        Vec::new()
893    }
894}