Skip to main content

editor/
editor_scene.rs

1use std::collections::{HashMap, HashSet, VecDeque};
2use std::path::{Path, PathBuf};
3use std::time::{Duration, Instant};
4
5use libdqg::camera::Camera;
6use libdqg::ecs::Entity;
7use libdqg::glam;
8use libdqg::input::{InputState, MouseState};
9use libdqg::renderer::{DrawPass, Renderer};
10use libdqg::scene::{Scene, SceneTransition};
11use libdqg::scripting::{ScriptError, ScriptRuntime};
12use libdqg::types::Color;
13use libdqg::world::{CameraComponent, Renderable, Transform, World};
14
15use crate::editor_settings::EditorSettings;
16use crate::egui_layer::EguiLayer;
17use crate::fly_camera::FlyCamera;
18use crate::picking;
19use crate::project::{EntityRecord, Project, RenderableAsset, RenderableKind};
20use crate::recent_projects::RecentProjects;
21use crate::ui;
22
23/// Longest a single `update` is allowed to spend loading entity renderables before yielding a
24/// frame back to the event loop, so the loading screen keeps animating and the window stays
25/// responsive on a project with a lot of assets instead of freezing until it's all done.
26const LOAD_BUDGET_PER_FRAME: Duration = Duration::from_millis(8);
27
28/// How long a script error stays in the overlay (see [`EditorScene::script_errors`]) before it
29/// ages out on its own, so a one-off error doesn't linger forever if the user doesn't hit Stop.
30const SCRIPT_ERROR_DISPLAY: Duration = Duration::from_secs(6);
31
32/// How long the last Export result stays in the overlay (see [`EditorScene::export_status`])
33/// before it ages out on its own.
34const EXPORT_STATUS_DISPLAY: Duration = Duration::from_secs(6);
35
36/// Whether the editor is authoring the scene or running it live. Play snapshots the whole
37/// [`World`] and starts a [`ScriptRuntime`]; Stop restores the snapshot and drops the runtime
38/// (and every script's state with it) — see [`EditorScene::start_play`]/[`stop_play`]. A
39/// whole-`World` snapshot (not just `Transform`) is what makes scripts that spawn/despawn/rename
40/// entities or attach scripts fully revert on Stop, the same way a Transform-only snapshot
41/// already made translate/rotate/scale revert. Always targets whichever tab was active when Play
42/// started — see [`EditorScene::switch_or_open_scene`]/[`close_scene_tab`] for why switching or
43/// closing tabs stops Play first.
44enum EditorMode {
45    Edit,
46    Playing { world_snapshot: World, runtime: ScriptRuntime },
47}
48
49/// What the editor scene should do with a project root on its first `update`, once a
50/// `Renderer` (and therefore GPU access for loading assets) is available. Set either by the
51/// menu scene handing off to a freshly-built `EditorScene`, or by the editor's own File menu.
52pub enum PendingAction {
53    New(PathBuf),
54    Open(PathBuf),
55}
56
57/// Where a load currently stands, so it can be spread across several frames instead of blocking
58/// the main thread for its whole duration.
59enum LoadState {
60    /// Requested but not started; the project itself hasn't been created/opened yet.
61    Pending(PendingAction),
62    /// A New/Open Project action resolved into `project`; `remaining` entities for its start
63    /// scene (if any) are drained a few at a time (see [`LOAD_BUDGET_PER_FRAME`]) into `world`/
64    /// `entity_assets` each frame until empty, at which point this *replaces* `open_scenes`
65    /// wholesale — a different project's tabs aren't valid to keep around.
66    LoadingProject {
67        project: Project,
68        path: PathBuf,
69        world: World,
70        entity_assets: HashMap<Entity, RenderableAsset>,
71        remaining: VecDeque<EntityRecord>,
72        total: usize,
73    },
74    /// An additional scene in the *current* project is being opened as a new tab; `remaining` is
75    /// drained the same way, then the finished [`OpenScene`] is appended to `open_scenes` and
76    /// made active — every other already-open tab is left untouched throughout.
77    LoadingScene {
78        path: PathBuf,
79        world: World,
80        entity_assets: HashMap<Entity, RenderableAsset>,
81        remaining: VecDeque<EntityRecord>,
82        total: usize,
83    },
84}
85
86/// Spawns up to [`LOAD_BUDGET_PER_FRAME`] worth of `remaining` entities into `world`/
87/// `entity_assets`, returning `true` once `remaining` is empty (the load is finished) — shared by
88/// the "New/Open Project" and "open an additional scene as a new tab" load paths.
89fn spawn_budgeted(
90    world: &mut World,
91    entity_assets: &mut HashMap<Entity, RenderableAsset>,
92    remaining: &mut VecDeque<EntityRecord>,
93    renderer: &Renderer,
94    project_root: &Path,
95) -> bool {
96    let deadline = Instant::now() + LOAD_BUDGET_PER_FRAME;
97    while Instant::now() < deadline {
98        let Some(record) = remaining.pop_front() else { break };
99        let (entity, asset) = record.spawn_into(world, Some(renderer), project_root);
100        if let Some(asset) = asset {
101            entity_assets.insert(entity, asset);
102        }
103    }
104    remaining.is_empty()
105}
106
107/// One open scene tab: its own `World`, entity-asset tracking, hierarchy/inspector selection
108/// state, and viewport camera — everything that used to be a single set of fields directly on
109/// `EditorScene` before a project could have more than one scene open for editing at once. Kept
110/// fully loaded in memory for as long as its tab stays open (see `EditorScene::open_scenes`),
111/// unlike the old single-scene design, which discarded and reloaded a scene's `World` from disk
112/// on every switch.
113struct OpenScene {
114    /// Project-relative path, e.g. `"scenes/main.ron"` — this tab's identity; a scene can only be
115    /// open in one tab at a time (see `EditorScene::switch_or_open_scene`).
116    path: PathBuf,
117    world: World,
118    entity_assets: HashMap<Entity, RenderableAsset>,
119    selected: Option<Entity>,
120    /// The entity currently under the mouse cursor in the 3D viewport, if any. Recomputed every
121    /// frame this tab is active (unlike `selected`, which only changes on click) so the hovered
122    /// model can blink.
123    hovered: Option<Entity>,
124    renaming: Option<Entity>,
125    rename_buffer: String,
126    /// This tab's own viewport camera controller — kept per-tab (rather than shared) so switching
127    /// tabs doesn't disturb whatever framing you last left each scene at.
128    fly_camera: FlyCamera,
129    /// Whether this tab has edits not yet written to disk — set whenever the UI mutates its
130    /// `world`/`entity_assets` (see `ui::UiRequests::edited`), cleared by a successful Save/
131    /// Export/tab-close autosave. Surfaced as a `*` on its tab (see `ui::draw_scene_tabs`) and
132    /// drives the pre-Play "unsaved changes" prompt (see `EditorScene::play_confirmation`):
133    /// `scene.change(...)` reads a scene's `.ron` straight off disk (see
134    /// `libdqg::scripting`'s `WorldCommand::ChangeScene`), so a dirty *other* open tab would
135    /// otherwise be silently stale to a script that jumps to it mid-Play, even though its real,
136    /// unsaved contents are fully visible on screen right now.
137    dirty: bool,
138}
139
140impl OpenScene {
141    fn new(path: PathBuf, world: World, entity_assets: HashMap<Entity, RenderableAsset>) -> Self {
142        Self {
143            path,
144            world,
145            entity_assets,
146            selected: None,
147            hovered: None,
148            renaming: None,
149            rename_buffer: String::new(),
150            fly_camera: FlyCamera::new(6.0),
151            dirty: false,
152        }
153    }
154}
155
156/// The starting viewport camera every fresh [`World`] gets — extracted so [`EditorScene::opening`]
157/// and [`EditorScene::start_load`] (which each build a brand new `World`) don't duplicate it.
158fn default_camera() -> Camera {
159    let mut camera = Camera {
160        position: glam::Vec3::new(0.0, 1.5, 4.0),
161        yaw: 0.0,
162        pitch: 0.0,
163        aspect: 1.0,
164        fov: 45.0,
165        znear: 0.1,
166        zfar: 100.0,
167    };
168    camera.look_at(glam::Vec3::ZERO);
169    camera
170}
171
172pub struct EditorScene {
173    /// Every scene currently open for editing — always has at least one entry (a blank
174    /// placeholder before any project is opened, same as the editor's old always-present single
175    /// `World`), so [`Self::active_scene`] is always a valid index into it.
176    open_scenes: Vec<OpenScene>,
177    active_scene: usize,
178    /// Elapsed time accumulator driving the hover blink, shared across tabs since it's a plain
179    /// animation clock, not per-scene state.
180    hover_blink_time: f32,
181    project: Option<Project>,
182    last_mouse_pos: (f32, f32),
183    recent: RecentProjects,
184    load: Option<LoadState>,
185    egui: EguiLayer,
186    assets_expanded: bool,
187    /// Cached texture thumbnails for the Assets panel, keyed by project-relative path — global
188    /// rather than per-tab since it's a decode cache for the project's `assets/` folder, not
189    /// scene-specific state; cleared only when a different project is opened.
190    texture_previews: HashMap<PathBuf, egui::TextureHandle>,
191    mode: EditorMode,
192    /// Recent script compile/runtime errors and when each was recorded, for the overlay in
193    /// [`ui::draw_script_error_overlay`] — pruned by [`SCRIPT_ERROR_DISPLAY`] each frame and
194    /// cleared outright on Stop.
195    script_errors: Vec<(String, Instant)>,
196    /// The outcome of the last Export (see `ui::draw_export_status_overlay`), and when it was
197    /// recorded — aged out after [`EXPORT_STATUS_DISPLAY`], same shape as `script_errors` but for
198    /// one message instead of a list.
199    export_status: Option<(String, Instant)>,
200    /// Editor-wide preferences (currently just the external editor "Open Script" launches),
201    /// persisted next to the executable like [`RecentProjects`].
202    settings: EditorSettings,
203    /// The script currently being renamed inline in the Assets panel, if any — mirrors
204    /// `renaming`/`rename_buffer` above, just keyed by a script's project-relative path instead
205    /// of an [`Entity`] since scripts aren't ECS entities.
206    renaming_script: Option<PathBuf>,
207    script_rename_buffer: String,
208    /// Mirrors `renaming_script`/`script_rename_buffer`, for a scene tile in the Assets panel's
209    /// Scenes section.
210    renaming_scene: Option<PathBuf>,
211    scene_rename_buffer: String,
212    /// `Some(dirty tab paths)` while the pre-Play "unsaved changes" dialog (see
213    /// [`draw_play_confirmation`]) is up, blocking the rest of `update` until resolved — set when
214    /// Play is requested while any tab is dirty, cleared once the user picks Save & Play, Play
215    /// Without Saving, or Cancel.
216    play_confirmation: Option<Vec<PathBuf>>,
217}
218
219impl EditorScene {
220    /// Builds a scene that will create or open `action`'s project root on its first `update`.
221    pub fn opening(action: PendingAction) -> Self {
222        Self {
223            open_scenes: vec![OpenScene::new(PathBuf::new(), World::new(default_camera()), HashMap::new())],
224            active_scene: 0,
225            hover_blink_time: 0.0,
226            project: None,
227            last_mouse_pos: (0.0, 0.0),
228            recent: RecentProjects::load(),
229            load: Some(LoadState::Pending(action)),
230            egui: EguiLayer::new(),
231            assets_expanded: true,
232            texture_previews: HashMap::new(),
233            mode: EditorMode::Edit,
234            script_errors: Vec::new(),
235            export_status: None,
236            settings: EditorSettings::load(),
237            renaming_script: None,
238            script_rename_buffer: String::new(),
239            renaming_scene: None,
240            scene_rename_buffer: String::new(),
241            play_confirmation: None,
242        }
243    }
244
245    /// Queues `action` to start on the next `update`.
246    fn queue_action(&mut self, action: PendingAction) {
247        self.load = Some(LoadState::Pending(action));
248    }
249
250    /// Resolves `action` into an open [`Project`] (fast: just filesystem/manifest/scene-file
251    /// work, no asset decoding), replacing every currently open tab with the project's start
252    /// scene and queuing its entities — if any — to be loaded incrementally afterward. Records
253    /// the project in the recent-projects list. A different project's tabs are meaningless once
254    /// the project changes, so unlike [`Self::switch_or_open_scene`] there's nothing to preserve
255    /// here.
256    fn start_load(&mut self, action: PendingAction) {
257        let (root, create) = match action {
258            PendingAction::New(root) => (root, true),
259            PendingAction::Open(root) => (root, false),
260        };
261
262        let result = if create { Project::create(root.clone()) } else { Project::open(root.clone()) };
263        let project = match result {
264            Ok(project) => project,
265            Err(e) => {
266                eprintln!("Failed to {} project: {e}", if create { "create" } else { "open" });
267                return;
268            }
269        };
270
271        let scene_path = project.manifest.start_scene.clone();
272        let entities: VecDeque<EntityRecord> = if create {
273            VecDeque::new()
274        } else {
275            match project.load_scene(&scene_path) {
276                Ok(scene_file) => scene_file.entities.into(),
277                Err(e) => {
278                    // Same "don't touch anything" abort as `switch_or_open_scene`'s own
279                    // load-failure path: falling through to an empty scene here would let a
280                    // later Save silently overwrite whatever's actually in this corrupt/
281                    // unreadable file, and (for an already-open project) would also wipe out
282                    // every existing tab for no reason.
283                    eprintln!("Failed to load scene: {e}");
284                    return;
285                }
286            }
287        };
288
289        self.recent.add(root);
290        let _ = self.recent.save();
291
292        // A live ScriptRuntime holds Entity handles into an old World; opening a different
293        // project invalidates every current tab, so stop Play first.
294        self.mode = EditorMode::Edit;
295        self.script_errors.clear();
296        self.texture_previews.clear();
297
298        let world = World::new(default_camera());
299        if entities.is_empty() {
300            self.open_scenes = vec![OpenScene::new(scene_path, world, HashMap::new())];
301            self.active_scene = 0;
302            self.project = Some(project);
303        } else {
304            let total = entities.len();
305            self.load = Some(LoadState::LoadingProject { project, path: scene_path, world, entity_assets: HashMap::new(), remaining: entities, total });
306        }
307    }
308
309    /// Switches to `path` (project-relative) within the currently open project: if it's already
310    /// open in a tab, just activates that tab (no reload — the whole point of keeping scenes
311    /// loaded). Otherwise loads it as a brand new tab, appended once loaded, leaving every other
312    /// tab untouched. A no-op if no project is open, or if `path` fails to load (reported, no tab
313    /// opened, so a corrupt scene file doesn't risk getting silently overwritten by a later Save).
314    /// Stops Play first if it's running — a live `ScriptRuntime` holds `Entity` handles into
315    /// whichever tab was active when Play started, and switching away from it would leave those
316    /// pointing at nothing.
317    fn switch_or_open_scene(&mut self, path: PathBuf) {
318        if matches!(self.mode, EditorMode::Playing { .. }) {
319            self.stop_play();
320        }
321
322        if let Some(index) = self.open_scenes.iter().position(|scene| scene.path == path) {
323            self.active_scene = index;
324            return;
325        }
326
327        let Some(project) = self.project.as_ref() else { return };
328        let scene_file = match project.load_scene(&path) {
329            Ok(scene_file) => scene_file,
330            Err(e) => {
331                eprintln!("Failed to load scene {}: {e}", path.display());
332                return;
333            }
334        };
335
336        let entities: VecDeque<EntityRecord> = scene_file.entities.into();
337        let world = World::new(default_camera());
338        if entities.is_empty() {
339            self.open_scenes.push(OpenScene::new(path, world, HashMap::new()));
340            self.active_scene = self.open_scenes.len() - 1;
341        } else {
342            let total = entities.len();
343            self.load = Some(LoadState::LoadingScene { path, world, entity_assets: HashMap::new(), remaining: entities, total });
344        }
345    }
346
347    /// Closes the tab for `path`, autosaving it first — a no-op if it's the only tab open (a
348    /// project always keeps at least one scene open for editing) or if `path` isn't open. Stops
349    /// Play first if the tab being closed is the one Play is currently targeting.
350    fn close_scene_tab(&mut self, path: PathBuf) {
351        if self.open_scenes.len() <= 1 {
352            return;
353        }
354        let Some(index) = self.open_scenes.iter().position(|scene| scene.path == path) else { return };
355
356        if index == self.active_scene && matches!(self.mode, EditorMode::Playing { .. }) {
357            self.stop_play();
358        }
359
360        if let Some(project) = self.project.as_ref() {
361            let scene = &self.open_scenes[index];
362            if let Err(e) = project.save_scene(&scene.path, &scene.world, &scene.entity_assets) {
363                // Keep the tab open on a failed autosave — removing it anyway would discard the
364                // only copy of whatever wasn't saved, contradicting this method's own "autosaving
365                // it first" contract. Lets the user retry the close (or just Save) once whatever
366                // caused the failure (disk full, permissions, ...) is resolved.
367                eprintln!("Failed to save {}: {e}", scene.path.display());
368                return;
369            }
370        }
371
372        self.open_scenes.remove(index);
373        if index < self.active_scene {
374            self.active_scene -= 1;
375        } else if self.active_scene >= self.open_scenes.len() {
376            self.active_scene = self.open_scenes.len() - 1;
377        }
378    }
379
380    /// Renames the scene file at `old_path` (project-relative) to `new_stem`, keeping its
381    /// extension, then updates `ProjectManifest.start_scene` (if it named the renamed scene) and
382    /// any open tab's identity to match. Refuses (and reports, rather than silently overwriting)
383    /// if something is already using the target name. Doesn't fix up `scene.change("...")` calls
384    /// inside script source — see `ui::draw_scene_tile`'s doc comment for why that's not possible
385    /// the way [`Self::rename_script`] fixes up `ScriptAttachment`s.
386    fn rename_scene(&mut self, old_path: PathBuf, new_stem: String) {
387        let new_stem = new_stem.trim();
388        if !is_valid_rename_stem(new_stem) {
389            eprintln!("Failed to rename scene: \"{new_stem}\" is not a valid file name");
390            return;
391        }
392        let extension = old_path.extension().map(|ext| ext.to_string_lossy().into_owned()).unwrap_or_default();
393        let new_path = old_path.with_file_name(format!("{new_stem}.{extension}"));
394        if new_path == old_path {
395            return;
396        }
397
398        let Some(project) = self.project.as_mut() else { return };
399        let old_absolute = project.root.join(&old_path);
400        let new_absolute = project.root.join(&new_path);
401        if new_absolute.exists() {
402            eprintln!("Failed to rename scene: {} already exists", new_path.display());
403            return;
404        }
405        if let Err(e) = std::fs::rename(&old_absolute, &new_absolute) {
406            eprintln!("Failed to rename scene: {e}");
407            return;
408        }
409
410        if project.manifest.start_scene == old_path {
411            if let Err(e) = project.set_start_scene(new_path.clone()) {
412                // `set_start_scene` already reverted `manifest.start_scene` back to `old_path` on
413                // its own write failure (see its own doc comment) — so undoing the file rename is
414                // all that's needed to bring the actual file back in line with that. But if *this*
415                // rename-back also fails, the file is stuck at `new_path` while memory now says
416                // `old_path` — worse than before, since nothing points at the truth anymore. Force
417                // memory back to `new_path` in that case so it at least matches where the file
418                // really is; the on-disk manifest itself is still whatever `set_start_scene`'s own
419                // failed write left it as, which this can't resolve any further from here.
420                eprintln!("Failed to update start scene after rename: {e}");
421                if let Err(rollback_err) = std::fs::rename(&new_absolute, &old_absolute) {
422                    eprintln!(
423                        "Failed to restore scene after manifest error: {rollback_err} — the file remains at {}",
424                        new_path.display()
425                    );
426                    project.manifest.start_scene = new_path.clone();
427                }
428                return;
429            }
430        }
431
432        for scene in &mut self.open_scenes {
433            if scene.path == old_path {
434                scene.path = new_path.clone();
435            }
436        }
437    }
438
439    /// Renames the script file at `old_path` (project-relative) to `new_stem`, keeping its
440    /// extension, then fixes up and saves every [`libdqg::scripting::ScriptAttachment`] that
441    /// referenced the old path — in every open tab's live `World` (saved immediately, not just
442    /// left dirty) *and* every scene file on disk that isn't currently open. A script can be
443    /// attached to entities in any scene in the project, not just whichever one happens to be the
444    /// active tab, so both are needed — fixing up only the active `World` (as an earlier,
445    /// single-scene version of this did) would silently leave every other scene's reference
446    /// pointing at a file that no longer exists. Refuses (and reports, rather than silently
447    /// overwriting) if something is already using the target name.
448    ///
449    /// Copies rather than renames the file up front, deleting the original only once every scene
450    /// has been migrated (or confirmed not to reference it) — a scene file that fails to load or
451    /// save partway through this (corrupt on disk, a failed write, ...) is rare but not
452    /// impossible, and with a plain rename that failure would leave that scene's attachment
453    /// pointing at a file that's already gone. Copying first means both names stay valid on disk
454    /// for the duration, so a partial failure just leaves some scenes referencing the old name
455    /// and some the new one — both of which still resolve — rather than a dangling reference.
456    fn rename_script(&mut self, old_path: PathBuf, new_stem: String) {
457        let new_stem = new_stem.trim();
458        if !is_valid_rename_stem(new_stem) {
459            eprintln!("Failed to rename script: \"{new_stem}\" is not a valid file name");
460            return;
461        }
462        let extension = old_path.extension().map(|ext| ext.to_string_lossy().into_owned()).unwrap_or_default();
463        let new_path = old_path.with_file_name(format!("{new_stem}.{extension}"));
464        if new_path == old_path {
465            return;
466        }
467
468        let old_absolute = {
469            let Some(project) = self.project.as_ref() else { return };
470            let old_absolute = project.root.join(&old_path);
471            let new_absolute = project.root.join(&new_path);
472            if new_absolute.exists() {
473                eprintln!("Failed to rename script: {} already exists", new_path.display());
474                return;
475            }
476            if let Err(e) = std::fs::copy(&old_absolute, &new_absolute) {
477                eprintln!("Failed to rename script: {e}");
478                return;
479            }
480            old_absolute
481        };
482
483        let mut all_migrated = true;
484
485        // Every open tab's live World, saved immediately (not just marked dirty for some later,
486        // unpredictable Save/close/export) — deleting `old_absolute` below happens as soon as
487        // `all_migrated` holds, and an in-memory-only fixup wouldn't actually be on disk yet: if
488        // the editor exited or crashed before that tab was next saved, its persisted scene file
489        // would still reference `old_path`, which by then no longer exists.
490        let open_paths: HashSet<PathBuf> = self.open_scenes.iter().map(|scene| scene.path.clone()).collect();
491        if let Some(project) = self.project.as_ref() {
492            for scene in self.open_scenes.iter_mut() {
493                let mut changed = false;
494                for (_, list) in scene.world.scripts.iter_mut() {
495                    for attachment in list.0.iter_mut() {
496                        if attachment.path == old_path {
497                            attachment.path = new_path.clone();
498                            changed = true;
499                        }
500                    }
501                }
502                if changed {
503                    match project.save_scene(&scene.path, &scene.world, &scene.entity_assets) {
504                        Ok(()) => scene.dirty = false,
505                        Err(e) => {
506                            eprintln!("Renamed script but failed to save {}: {e}", scene.path.display());
507                            scene.dirty = true;
508                            all_migrated = false;
509                        }
510                    }
511                }
512            }
513        }
514
515        // Every other scene file on disk — anything already covered above via an open tab is
516        // skipped here, since that in-memory copy is the current source of truth until it's saved.
517        if let Some(project) = self.project.as_ref() {
518            for scene_path in project.list_scenes() {
519                if open_paths.contains(&scene_path) {
520                    continue;
521                }
522                let mut scene_file = match project.load_scene(&scene_path) {
523                    Ok(scene_file) => scene_file,
524                    Err(e) => {
525                        eprintln!("Failed to check {} for script references: {e}", scene_path.display());
526                        all_migrated = false;
527                        continue;
528                    }
529                };
530                let mut changed = false;
531                for record in scene_file.entities.iter_mut() {
532                    for attachment in record.scripts.iter_mut() {
533                        if attachment.path == old_path {
534                            attachment.path = new_path.clone();
535                            changed = true;
536                        }
537                    }
538                }
539                if changed {
540                    if let Err(e) = scene_file.save(&project.root.join(&scene_path)) {
541                        eprintln!("Failed to update script reference in {}: {e}", scene_path.display());
542                        all_migrated = false;
543                    }
544                }
545            }
546        }
547
548        if all_migrated {
549            if let Err(e) = std::fs::remove_file(&old_absolute) {
550                eprintln!("Renamed script to {}, but failed to remove the old file: {e}", new_path.display());
551            }
552        } else {
553            eprintln!(
554                "Some scenes still reference {} — left it alongside {} on disk until they're fixed up \
555                 (open the affected scene and re-save, or reattach {})",
556                old_path.display(),
557                new_path.display(),
558                new_path.display()
559            );
560        }
561    }
562
563    /// Snapshots the active tab's whole [`World`] and starts every enabled script attachment (in
564    /// order) on every entity that has one, then switches to [`EditorMode::Playing`]. A no-op if
565    /// there's no open project (the Play button is disabled in that case anyway — see
566    /// `ui::draw_menu_bar`).
567    fn start_play(&mut self, renderer: &Renderer) {
568        let Some(project) = self.project.as_ref() else { return };
569        let scene = &mut self.open_scenes[self.active_scene];
570
571        let world_snapshot = scene.world.clone();
572        let mut runtime = ScriptRuntime::new(project.root.clone());
573        // Without this, `world.find(...)` inside an `on_start` hook would see nothing but a
574        // default-empty snapshot (only populated per-frame from here on, right before
575        // `update_entity`) and always return `()` — `on_start` gets the same start-of-Play
576        // snapshot guarantee `on_update` already has for cross-entity reads.
577        runtime.begin_frame(&scene.world);
578
579        for error in runtime.start_all_scripts(&mut scene.world, &project.root, Some(renderer)) {
580            self.script_errors.push((format_script_error(&error), Instant::now()));
581        }
582
583        self.mode = EditorMode::Playing { world_snapshot, runtime };
584    }
585
586    /// Restores the whole [`World`] [`start_play`](Self::start_play) snapshotted (into whichever
587    /// tab was active when Play started) and drops the [`ScriptRuntime`] (and with it every
588    /// script's persistent state), switching back to [`EditorMode::Edit`].
589    fn stop_play(&mut self) {
590        if let EditorMode::Playing { world_snapshot, .. } = std::mem::replace(&mut self.mode, EditorMode::Edit) {
591            self.open_scenes[self.active_scene].world = world_snapshot;
592        }
593        self.script_errors.clear();
594    }
595
596    /// Handles a Play click: if any tab is dirty, holds off starting Play and instead raises the
597    /// confirmation dialog (see [`Self::play_confirmation`]) — `scene.change(...)` reads straight
598    /// off disk, so a script that jumps to a dirty *other* tab during this session would otherwise
599    /// silently see whatever was last saved there, not what's on screen right now. Starts Play
600    /// immediately, with no prompt, if nothing is dirty.
601    fn request_play(&mut self, renderer: &Renderer) {
602        let dirty_paths: Vec<PathBuf> = self.open_scenes.iter().filter(|scene| scene.dirty).map(|scene| scene.path.clone()).collect();
603        if dirty_paths.is_empty() {
604            self.start_play(renderer);
605        } else {
606            self.play_confirmation = Some(dirty_paths);
607        }
608    }
609
610    /// Saves every dirty open tab (not just the active one) — the "Save & Play" choice in the
611    /// pre-Play confirmation dialog, and also what a successful Export does before packaging (see
612    /// `Scene::update`'s `export_project` handling) — both need every tab's on-disk copy current,
613    /// not just whichever one is active.
614    /// Returns `true` only if every dirty tab was actually saved — callers that need every
615    /// on-disk scene current before proceeding (Export; the pre-Play confirmation's "Save &
616    /// Play") must check this rather than assuming a save attempt is the same as a successful
617    /// one.
618    fn save_all_dirty(&mut self) -> bool {
619        let Some(project) = self.project.as_ref() else { return true };
620        let mut all_saved = true;
621        for scene in self.open_scenes.iter_mut() {
622            if !scene.dirty {
623                continue;
624            }
625            match project.save_scene(&scene.path, &scene.world, &scene.entity_assets) {
626                Ok(()) => scene.dirty = false,
627                Err(e) => {
628                    eprintln!("Failed to save {}: {e}", scene.path.display());
629                    all_saved = false;
630                }
631            }
632        }
633        all_saved
634    }
635}
636
637/// What the user chose in the pre-Play "unsaved changes" dialog (see [`draw_play_confirmation`]).
638enum PlayConfirmAction {
639    SaveAndPlay,
640    PlayWithoutSaving,
641    Cancel,
642}
643
644/// Shown instead of the normal editor UI while [`EditorScene::play_confirmation`] is set — Play
645/// was requested while one or more tabs have unsaved edits. Lists which scenes are dirty so the
646/// choice is informed, then lets the user save everything first, play anyway (accepting that a
647/// `scene.change(...)` to one of these would load its last-saved, not current, contents), or
648/// cancel.
649fn draw_play_confirmation(ui: &mut egui::Ui, dirty_paths: &[PathBuf]) -> Option<PlayConfirmAction> {
650    let mut action = None;
651    egui::CentralPanel::default().show(ui, |ui| {
652        ui.centered_and_justified(|ui| {
653            ui.vertical_centered(|ui| {
654                ui.label("Unsaved changes in:");
655                for path in dirty_paths {
656                    ui.label(path.display().to_string());
657                }
658                ui.add_space(8.0);
659                ui.label("A script that switches to one of these during Play would load its last-saved contents, not what's shown here.");
660                ui.add_space(8.0);
661                ui.horizontal(|ui| {
662                    if ui.button("Save & Play").clicked() {
663                        action = Some(PlayConfirmAction::SaveAndPlay);
664                    }
665                    if ui.button("Play Without Saving").clicked() {
666                        action = Some(PlayConfirmAction::PlayWithoutSaving);
667                    }
668                    if ui.button("Cancel").clicked() {
669                        action = Some(PlayConfirmAction::Cancel);
670                    }
671                });
672            });
673        });
674    });
675    action
676}
677
678fn format_script_error(error: &ScriptError) -> String {
679    format!("{}: {}", error.script.display(), error.message)
680}
681
682/// Whether `stem` is safe to use as a single filename component in [`EditorScene::rename_scene`]/
683/// [`EditorScene::rename_script`] — both come straight from a text field the user typed into, with
684/// no validation of their own. `PathBuf::with_file_name` doesn't resolve or reject `/`/`\` or
685/// `.`/`..`, it just folds them into the resulting path verbatim — so an untrusted stem like
686/// `"../../outside"` would let `project.root.join(&new_path)` resolve outside the project
687/// entirely. Rejecting anything but a single normal path component here keeps every renamed
688/// scene/script path project-root-relative, matching every other path this module hands out.
689fn is_valid_rename_stem(stem: &str) -> bool {
690    // `Path::components` only recognizes `\` as a separator on Windows — on Unix it's just an
691    // ordinary filename character, so a stem like `"chapter\final"` would otherwise pass the
692    // component check below there but not on Windows (or once such a path round-trips through a
693    // project shared between the two). Rejecting both explicitly, on every platform, keeps a
694    // rename's result identical regardless of where it happened.
695    if stem.is_empty() || stem.contains('/') || stem.contains('\\') {
696        return false;
697    }
698    let mut components = Path::new(stem).components();
699    matches!(components.next(), Some(std::path::Component::Normal(_))) && components.next().is_none()
700}
701
702/// Fixed visual size for a camera entity's frustum gizmo, in world units — deliberately not
703/// derived from the component's real `znear`/`zfar` (which can be arbitrarily large), since the
704/// gizmo is an at-a-glance orientation indicator, not a literal clip-volume outline.
705const CAMERA_GIZMO_DISTANCE: f32 = 0.6;
706/// Placeholder aspect ratio for the gizmo's proportions — `CameraComponent` doesn't store aspect
707/// (it's viewport-derived, only meaningful once Play assigns a real camera), so the gizmo just
708/// assumes a common 16:9 shape.
709const CAMERA_GIZMO_ASPECT: f32 = 16.0 / 9.0;
710
711/// Draws an 8-line wireframe pyramid (eye to 4 far-plane corners, plus the far rectangle)
712/// representing `component`'s frustum at `transform`, projected through `view_camera` (the
713/// camera currently driving the viewport).
714fn draw_camera_gizmo(pass: &mut DrawPass, view_camera: &Camera, transform: &Transform, component: &CameraComponent, color: Color) {
715    let forward = transform.rotation * glam::Vec3::NEG_Z;
716    let up = transform.rotation * glam::Vec3::Y;
717    let right = transform.rotation * glam::Vec3::X;
718
719    let half_height = (component.fov.to_radians() * 0.5).tan() * CAMERA_GIZMO_DISTANCE;
720    let half_width = half_height * CAMERA_GIZMO_ASPECT;
721
722    let eye = transform.position;
723    let far_center = eye + forward * CAMERA_GIZMO_DISTANCE;
724    let corners = [
725        far_center + up * half_height + right * half_width,
726        far_center + up * half_height - right * half_width,
727        far_center - up * half_height - right * half_width,
728        far_center - up * half_height + right * half_width,
729    ];
730
731    for corner in corners {
732        pass.draw_world_line(view_camera, eye, corner, 1.5, color);
733    }
734    for i in 0..4 {
735        pass.draw_world_line(view_camera, corners[i], corners[(i + 1) % 4], 1.5, color);
736    }
737}
738
739impl Scene for EditorScene {
740    fn update(
741        &mut self,
742        delta_time: f32,
743        input_state: &InputState,
744        mouse_state: &MouseState,
745        renderer: Option<&mut Renderer>,
746    ) -> SceneTransition {
747        let Some(renderer) = renderer else { return SceneTransition::None };
748
749        if let Some(state) = self.load.take() {
750            match state {
751                LoadState::Pending(action) => self.start_load(action),
752                LoadState::LoadingProject { project, path, mut world, mut entity_assets, mut remaining, total } => {
753                    let done = spawn_budgeted(&mut world, &mut entity_assets, &mut remaining, renderer, &project.root);
754                    if done {
755                        self.open_scenes = vec![OpenScene::new(path, world, entity_assets)];
756                        self.active_scene = 0;
757                        self.project = Some(project);
758                    } else {
759                        self.load = Some(LoadState::LoadingProject { project, path, world, entity_assets, remaining, total });
760                    }
761                }
762                LoadState::LoadingScene { path, mut world, mut entity_assets, mut remaining, total } => {
763                    if let Some(project) = self.project.as_ref() {
764                        let done = spawn_budgeted(&mut world, &mut entity_assets, &mut remaining, renderer, &project.root);
765                        if done {
766                            self.open_scenes.push(OpenScene::new(path, world, entity_assets));
767                            self.active_scene = self.open_scenes.len() - 1;
768                        } else {
769                            self.load = Some(LoadState::LoadingScene { path, world, entity_assets, remaining, total });
770                        }
771                    }
772                }
773            }
774
775            if let Some(load_state) = &self.load {
776                let (loaded, total) = match load_state {
777                    LoadState::LoadingProject { remaining, total, .. } => (*total - remaining.len(), *total),
778                    LoadState::LoadingScene { remaining, total, .. } => (*total - remaining.len(), *total),
779                    LoadState::Pending(_) => (0, 0),
780                };
781                self.egui.run(renderer, |ui| draw_loading_screen(ui, loaded, total));
782                return SceneTransition::None;
783            }
784        }
785
786        if let Some(dirty_paths) = self.play_confirmation.clone() {
787            let mut action = None;
788            self.egui.run(renderer, |ui| action = draw_play_confirmation(ui, &dirty_paths));
789            match action {
790                Some(PlayConfirmAction::SaveAndPlay) => {
791                    self.save_all_dirty();
792                    // save_all_dirty leaves a tab dirty if its save failed — starting Play
793                    // unconditionally here would reintroduce exactly the risk this whole dialog
794                    // exists to prevent (a stale on-disk scene.change target), just silently.
795                    // Re-raise the confirmation with whichever tabs are still actually dirty
796                    // instead of assuming the save worked.
797                    let still_dirty: Vec<PathBuf> = self.open_scenes.iter().filter(|scene| scene.dirty).map(|scene| scene.path.clone()).collect();
798                    if still_dirty.is_empty() {
799                        self.play_confirmation = None;
800                        self.start_play(renderer);
801                    } else {
802                        self.play_confirmation = Some(still_dirty);
803                    }
804                }
805                Some(PlayConfirmAction::PlayWithoutSaving) => {
806                    self.play_confirmation = None;
807                    self.start_play(renderer);
808                }
809                Some(PlayConfirmAction::Cancel) => {
810                    self.play_confirmation = None;
811                }
812                None => {}
813            }
814            // Always yield the frame here, whether or not the dialog was just resolved — an
815            // `egui::run` call was already spent drawing it above (only one is allowed per frame,
816            // same constraint the loading screen has), so the normal UI can't also render this
817            // same frame. One frame's delay picking up Play/Edit mode is imperceptible.
818            return SceneTransition::None;
819        }
820
821        let mouse_pos = mouse_state.position();
822        let mouse_delta = (mouse_pos.0 - self.last_mouse_pos.0, mouse_pos.1 - self.last_mouse_pos.1);
823        self.last_mouse_pos = mouse_pos;
824
825        self.script_errors.retain(|(_, at)| at.elapsed() < SCRIPT_ERROR_DISPLAY);
826        if self.export_status.as_ref().is_some_and(|(_, at)| at.elapsed() >= EXPORT_STATUS_DISPLAY) {
827            self.export_status = None;
828        }
829        let is_playing = matches!(self.mode, EditorMode::Playing { .. });
830        let script_error_messages: Vec<String> = self.script_errors.iter().map(|(message, _)| message.clone()).collect();
831        let export_status_message = self.export_status.as_ref().map(|(message, _)| message.as_str());
832        let open_scene_tabs: Vec<(PathBuf, bool)> = self.open_scenes.iter().map(|scene| (scene.path.clone(), scene.dirty)).collect();
833        let active_scene = self.active_scene;
834
835        let active_index = self.active_scene;
836        let (world, selected, renaming, rename_buffer, entity_assets, current_scene, current_scene_dirty) = {
837            let scene = &mut self.open_scenes[active_index];
838            (&mut scene.world, &mut scene.selected, &mut scene.renaming, &mut scene.rename_buffer, &mut scene.entity_assets, scene.path.clone(), scene.dirty)
839        };
840        let project = self.project.as_ref();
841        let assets_expanded = &mut self.assets_expanded;
842        let texture_previews = &mut self.texture_previews;
843        let settings = &mut self.settings;
844        let renaming_script = &mut self.renaming_script;
845        let script_rename_buffer = &mut self.script_rename_buffer;
846        let renaming_scene = &mut self.renaming_scene;
847        let scene_rename_buffer = &mut self.scene_rename_buffer;
848        let mut requests = ui::UiRequests::default();
849        self.egui.run(renderer, |ui| {
850            ui::draw(
851                ui,
852                world,
853                selected,
854                renaming,
855                rename_buffer,
856                project,
857                entity_assets,
858                assets_expanded,
859                texture_previews,
860                is_playing,
861                &script_error_messages,
862                settings,
863                renaming_script,
864                script_rename_buffer,
865                renaming_scene,
866                scene_rename_buffer,
867                export_status_message,
868                &current_scene,
869                current_scene_dirty,
870                &open_scene_tabs,
871                active_scene,
872                &mut requests,
873            );
874        });
875
876        if requests.edited {
877            self.open_scenes[active_index].dirty = true;
878        }
879
880        if requests.save_scene {
881            if let Some(project) = self.project.as_ref() {
882                let scene = &self.open_scenes[active_index];
883                match project.save_scene(&scene.path, &scene.world, &scene.entity_assets) {
884                    Ok(()) => self.open_scenes[active_index].dirty = false,
885                    Err(e) => eprintln!("Failed to save project: {e}"),
886                }
887            }
888        }
889
890        if let Some(root) = requests.new_project.take() {
891            self.queue_action(PendingAction::New(root));
892        }
893
894        if let Some(output_dir) = requests.export_project.take() {
895            if self.project.is_some() {
896                // Export packages the scene files off disk, not any tab's `World` directly — save
897                // every dirty tab first (not just the active one) so unsaved edits anywhere
898                // actually make it into the exported game. If any of those saves failed, the
899                // on-disk project would still have stale scene data — export would then succeed
900                // while silently packaging the wrong thing, so skip it and report the save
901                // failure instead (already `eprintln!`'d per-scene by `save_all_dirty`) rather
902                // than a possibly-misleading "Exported to ..." success message.
903                let saved = self.save_all_dirty();
904                if let Some(project) = self.project.as_ref() {
905                    if saved {
906                        let result = crate::export::export_project(project, &output_dir);
907                        let message = match &result {
908                            Ok(()) => format!("Exported to {}", output_dir.display()),
909                            Err(e) => format!("Export failed: {e}"),
910                        };
911                        if let Err(e) = &result {
912                            eprintln!("Export failed: {e}");
913                        }
914                        self.export_status = Some((message, Instant::now()));
915                    } else {
916                        self.export_status = Some((
917                            "Export aborted: one or more open scenes failed to save".to_string(),
918                            Instant::now(),
919                        ));
920                    }
921                }
922            }
923        }
924
925        if let Some(root) = requests.open_project.take() {
926            self.queue_action(PendingAction::Open(root));
927        }
928
929        if let Some(path) = requests.switch_scene.take() {
930            self.switch_or_open_scene(path);
931        }
932
933        if let Some(path) = requests.close_scene_tab.take() {
934            self.close_scene_tab(path);
935        }
936
937        if let Some((old_path, new_stem)) = requests.rename_scene.take() {
938            self.rename_scene(old_path, new_stem);
939        }
940
941        if let Some((old_path, new_stem)) = requests.rename_script.take() {
942            self.rename_script(old_path, new_stem);
943        }
944
945        if let Some(path) = requests.set_start_scene.take() {
946            if let Some(project) = self.project.as_mut() {
947                if let Err(e) = project.set_start_scene(path) {
948                    eprintln!("Failed to set start scene: {e}");
949                }
950            }
951        }
952
953        if requests.toggle_play {
954            if is_playing {
955                self.stop_play();
956            } else {
957                self.request_play(renderer);
958            }
959        }
960
961        if let Some((entity, kind, path)) = requests.attach_renderable.take() {
962            if let Some(project) = self.project.as_ref() {
963                let probe_asset = match kind {
964                    RenderableKind::Sprite => {
965                        RenderableAsset::Sprite { texture_path: path.clone(), width: 0.0, height: 0.0 }
966                    }
967                    RenderableKind::Model => RenderableAsset::Model { model_path: path.clone() },
968                };
969                match probe_asset.load(renderer, &project.root) {
970                    Ok(renderable) => {
971                        // Re-derive the sprite's width/height from the loaded texture's native
972                        // size rather than trusting the placeholder above.
973                        let asset = match &renderable {
974                            Renderable::Sprite(sprite) => {
975                                RenderableAsset::Sprite { texture_path: path, width: sprite.width, height: sprite.height }
976                            }
977                            Renderable::Model(_) => RenderableAsset::Model { model_path: path },
978                        };
979                        let scene = &mut self.open_scenes[self.active_scene];
980                        scene.world.set_renderable(entity, renderable);
981                        scene.entity_assets.insert(entity, asset);
982                        scene.dirty = true;
983                    }
984                    Err(e) => eprintln!("Failed to attach renderable: {e}"),
985                }
986            }
987        }
988
989        self.hover_blink_time += delta_time;
990
991        let active_index = self.active_scene;
992        match &mut self.mode {
993            EditorMode::Edit => {
994                if !self.egui.wants_pointer_input() {
995                    let scene = &mut self.open_scenes[active_index];
996                    scene.fly_camera.update(delta_time, input_state, mouse_state, &mut scene.world.camera, mouse_delta);
997
998                    let size = renderer.size();
999                    let ndc_x = (mouse_pos.0 / (size.width.max(1) as f32)) * 2.0 - 1.0;
1000                    let ndc_y = 1.0 - (mouse_pos.1 / (size.height.max(1) as f32)) * 2.0;
1001                    scene.hovered = picking::pick(&scene.world, &scene.world.camera, ndc_x, ndc_y);
1002
1003                    if mouse_state.is_button_pressed(winit::event::MouseButton::Left) {
1004                        scene.selected = scene.hovered;
1005                    }
1006                } else {
1007                    self.open_scenes[active_index].hovered = None;
1008                }
1009            }
1010            EditorMode::Playing { runtime, .. } => {
1011                // Camera flying and viewport picking are edit-mode-only for v1 — playing just
1012                // runs scripts against the live World; see the implementation plan's
1013                // "Camera/picking fully disabled during Play" note.
1014                let scene = &mut self.open_scenes[active_index];
1015                scene.hovered = None;
1016                runtime.begin_frame(&scene.world);
1017                for entity in scene.world.iter_entities().collect::<Vec<_>>() {
1018                    for error in runtime.update_entity(&mut scene.world, entity, delta_time, input_state, mouse_state, mouse_delta, Some(&*renderer)) {
1019                        self.script_errors.push((format_script_error(&error), Instant::now()));
1020                    }
1021                }
1022            }
1023        }
1024
1025        let scene = &mut self.open_scenes[active_index];
1026        scene.world.sync_transforms();
1027        let size = renderer.size();
1028        let aspect = size.width as f32 / (size.height.max(1) as f32);
1029        scene.world.camera.aspect = aspect;
1030
1031        let push_camera = if is_playing {
1032            scene.world.active_camera(aspect).unwrap_or(scene.world.camera)
1033        } else {
1034            scene.world.camera
1035        };
1036        *renderer.camera_mut() = push_camera;
1037
1038        SceneTransition::None
1039    }
1040
1041    fn render(&mut self, pass: &mut DrawPass) {
1042        let blink_alpha = 0.15 + 0.35 * (self.hover_blink_time * 6.0).sin().abs();
1043        let hover_highlight = Color::new(1.0, 1.0, 1.0, blink_alpha as f64);
1044        let no_highlight = Color::new(1.0, 1.0, 1.0, 0.0);
1045        let is_edit_mode = matches!(self.mode, EditorMode::Edit);
1046        let scene = &mut self.open_scenes[self.active_scene];
1047
1048        for (entity, renderable) in scene.world.renderables.iter_mut() {
1049            let highlight = if scene.hovered == Some(entity) { hover_highlight } else { no_highlight };
1050            let is_selected = scene.selected == Some(entity);
1051
1052            match renderable {
1053                Renderable::Sprite(sprite) => {
1054                    sprite.highlight = highlight;
1055                    // The outline is coplanar with the sprite and relies on draw order (not
1056                    // depth testing) to stay confined to a border — see
1057                    // `draw_world_sprite_outline`'s doc comment — so it must be drawn first.
1058                    if is_selected {
1059                        pass.draw_world_sprite_outline(sprite.x, sprite.y, sprite.z, sprite.width, sprite.height, sprite.transform);
1060                    }
1061                    sprite.draw_world(pass);
1062                }
1063                Renderable::Model(model) => {
1064                    model.highlight = highlight;
1065                    pass.draw_model(model);
1066                    if is_selected {
1067                        pass.draw_model_outline(model);
1068                    }
1069                }
1070            }
1071        }
1072
1073        if is_edit_mode {
1074            for (entity, component) in scene.world.cameras.iter() {
1075                let Some(transform) = scene.world.transforms.get(entity) else { continue };
1076                let color = if scene.selected == Some(entity) {
1077                    Color::new(0.3, 0.7, 1.0, 1.0)
1078                } else {
1079                    Color::new(0.6, 0.6, 0.6, 1.0)
1080                };
1081                draw_camera_gizmo(pass, &scene.world.camera, transform, component, color);
1082            }
1083        }
1084    }
1085
1086    fn on_window_event(&mut self, event: &winit::event::WindowEvent) {
1087        self.egui.on_window_event(event);
1088    }
1089
1090    fn render_overlay(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, encoder: &mut wgpu::CommandEncoder, view: &wgpu::TextureView) {
1091        self.egui.render_overlay(device, queue, encoder, view);
1092    }
1093}
1094
1095fn draw_loading_screen(ui: &mut egui::Ui, loaded: usize, total: usize) {
1096    egui::CentralPanel::default().show(ui, |ui| {
1097        ui.centered_and_justified(|ui| {
1098            ui.vertical_centered(|ui| {
1099                ui.spinner();
1100                ui.add_space(8.0);
1101                ui.label(format!("Loading scene... ({loaded}/{total})"));
1102            });
1103        });
1104    });
1105}