Skip to main content

khora_editor/
commands.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 command dispatch — file I/O, build-game, menu actions.
16//!
17//! Free functions invoked from `EditorApp::update`. They take only the
18//! state they actually need so each one is independently testable and
19//! easy to refactor as commands grow.
20
21use std::sync::{Arc, Mutex};
22
23use khora_sdk::prelude::ecs::*;
24use khora_sdk::{
25    instantiate_subtree, serialize_subtree, CommandHistory, EditorState, GameWorld, PlayMode,
26    SerializationGoal,
27};
28
29use crate::build_game;
30use crate::hot_reload;
31use crate::ops;
32use crate::project_vfs::ProjectVfs;
33use crate::scene_io;
34
35/// Save dispatch: routes through the project VFS when the target path
36/// lives under `<project>/assets/`, falls back to direct `std::fs` for
37/// arbitrary out-of-project Save-As destinations. Defaults to the
38/// `EditorInterchange` strategy.
39/// Returns whether the scene reached disk. Failures are logged by the write
40/// paths themselves; the flag lets a caller react instead of assuming success.
41pub fn save_scene_dispatch(
42    project_vfs: Option<&Arc<Mutex<ProjectVfs>>>,
43    world: &GameWorld,
44    path_str: &str,
45) -> bool {
46    save_scene_dispatch_with_goal(
47        project_vfs,
48        world,
49        path_str,
50        SerializationGoal::EditorInterchange,
51    )
52}
53
54/// Same as [`save_scene_dispatch`] but with an explicit serialization
55/// goal. Used by "Export Scene as RON" (`HumanReadableDebug`) and any
56/// future Save-As strategy picker.
57pub fn save_scene_dispatch_with_goal(
58    project_vfs: Option<&Arc<Mutex<ProjectVfs>>>,
59    world: &GameWorld,
60    path_str: &str,
61    goal: SerializationGoal,
62) -> bool {
63    let abs = std::path::Path::new(path_str);
64    if let Some(pvfs_arc) = project_vfs {
65        if let Ok(mut pvfs) = pvfs_arc.lock() {
66            let assets_root = pvfs.assets_root.clone();
67            if let Some(rel_fwd) = scene_io::rel_inside_project(abs, &assets_root) {
68                // `save_scene_in_project_with_goal` logs its own failures; the
69                // flag is propagated so a caller can react to the outcome
70                // rather than assume success.
71                return scene_io::save_scene_in_project_with_goal(
72                    &mut pvfs,
73                    world,
74                    std::path::Path::new(&rel_fwd),
75                    goal,
76                );
77            }
78        }
79    }
80    scene_io::save_scene_to_path_with_goal(world, path_str, goal)
81}
82
83/// Load dispatch: same shape as `save_scene_dispatch`.
84/// Loads a scene, clearing every entity reference the editor still holds.
85///
86/// The clearing happens here rather than at each call site: loading always
87/// repopulates the world with fresh ids, so a caller that forgot would leave a
88/// selection naming entities from the previous scene — and once a slot is
89/// recycled, naming *different* ones.
90pub fn load_scene_dispatch(
91    project_vfs: Option<&Arc<Mutex<ProjectVfs>>>,
92    world: &mut GameWorld,
93    editor_state: &Arc<Mutex<EditorState>>,
94    abs: &std::path::Path,
95) {
96    if let Ok(mut state) = editor_state.lock() {
97        state.clear_entity_references();
98    }
99    if let Some(pvfs_arc) = project_vfs {
100        if let Ok(mut pvfs) = pvfs_arc.lock() {
101            let assets_root = pvfs.assets_root.clone();
102            if let Some(rel_fwd) = scene_io::rel_inside_project(abs, &assets_root) {
103                scene_io::load_scene_in_project(&mut pvfs, world, &rel_fwd);
104                return;
105            }
106        }
107    }
108    scene_io::load_scene_from_path(world, &abs.to_string_lossy());
109}
110
111/// Build Game dispatch: packs the project's assets, copies the host-target
112/// `khora-runtime` binary into `<project>/dist/<target>/`, and writes the
113/// runtime config so the staged binary auto-loads the project's default
114/// scene. Reports progress + final path through the editor's logger.
115pub fn run_build_game(
116    project_vfs: Option<&Arc<Mutex<ProjectVfs>>>,
117    editor_state: &Arc<Mutex<EditorState>>,
118) {
119    let Some(pvfs_arc) = project_vfs else {
120        log::error!("Build Game: no project is open");
121        return;
122    };
123    let project_name = editor_state
124        .lock()
125        .ok()
126        .and_then(|s| s.project_name.clone())
127        .unwrap_or_else(|| "Game".to_owned());
128
129    let pvfs = match pvfs_arc.lock() {
130        Ok(p) => p,
131        Err(_) => {
132            log::error!("Build Game: project_vfs lock poisoned");
133            return;
134        }
135    };
136
137    log::info!(
138        "Build Game: starting for project '{}' (host target: {:?})",
139        project_name,
140        build_game::BuildTarget::host()
141    );
142    match build_game::build_for_host(&pvfs, &project_name) {
143        Ok(out) => {
144            log::info!(
145                "Build Game: success via {} — {} ({} assets, {} bytes packed)",
146                out.strategy.label(),
147                out.output_dir.display(),
148                out.asset_count,
149                out.pack_bytes
150            );
151        }
152        Err(e) => {
153            log::error!("Build Game: failed — {:#}", e);
154        }
155    }
156}
157
158/// Bridge: open a folder picker and rebuild the project VFS at the new
159/// location. Used when the user invokes "File > Open Project…" while
160/// another project is already loaded.
161pub fn browse_and_open_project(
162    editor_state: &Arc<Mutex<EditorState>>,
163) -> Option<Arc<Mutex<ProjectVfs>>> {
164    let path = rfd::FileDialog::new().pick_folder()?;
165
166    let metrics = std::sync::Arc::new(khora_sdk::MetricsRegistry::new());
167    match ProjectVfs::open(path.clone(), metrics) {
168        Ok(pvfs) => {
169            let entries = hot_reload::collect_asset_entries(&pvfs);
170            let dirs = pvfs.list_dirs();
171            if let Ok(mut state) = editor_state.lock() {
172                state.project_folder = Some(path.to_string_lossy().to_string());
173                state.asset_entries = entries;
174                state.asset_dirs = dirs;
175                state.asset_epoch = state.asset_epoch.wrapping_add(1);
176                log::info!(
177                    "Asset browser: scanned '{}' - {} assets found",
178                    path.display(),
179                    state.asset_entries.len()
180                );
181            }
182            Some(Arc::new(Mutex::new(pvfs)))
183        }
184        Err(e) => {
185            log::error!(
186                "Failed to open ProjectVfs for '{}': {:#}",
187                path.display(),
188                e
189            );
190            None
191        }
192    }
193}
194
195/// Process every pending menu action queued in `EditorState`. Drains
196/// `pending_browse_project_folder` and `pending_menu_action` in turn.
197pub fn process_menu_actions(
198    project_vfs: &mut Option<Arc<Mutex<ProjectVfs>>>,
199    editor_state: &Arc<Mutex<EditorState>>,
200    command_history: &Arc<Mutex<CommandHistory>>,
201    world: &mut GameWorld,
202) {
203    let wants_browse = editor_state
204        .lock()
205        .ok()
206        .map(|mut s| std::mem::replace(&mut s.pending_browse_project_folder, false))
207        .unwrap_or(false);
208
209    if wants_browse {
210        if let Some(new_pvfs) = browse_and_open_project(editor_state) {
211            *project_vfs = Some(new_pvfs);
212        }
213    }
214
215    let action = editor_state
216        .lock()
217        .ok()
218        .and_then(|mut s| s.pending_menu_action.take());
219
220    let Some(action) = action else { return };
221
222    match action.as_str() {
223        "new_scene" => apply_new_scene(world, editor_state),
224        "undo" => apply_undo(editor_state, command_history),
225        "redo" => apply_redo(editor_state, command_history),
226        "delete" => apply_delete(world, editor_state),
227        "quit" => {
228            log::info!("Quit requested from menu");
229            std::process::exit(0);
230        }
231        "play" => apply_play(world, editor_state),
232        "pause" => apply_pause(editor_state),
233        "stop" => apply_stop(world, editor_state),
234        "save" => apply_save(project_vfs.as_ref(), world, editor_state),
235        // Save-As does NOT expose a strategy picker. The engine knows
236        // which `SerializationGoal` is right for each context — for
237        // editor saves that's `EditorInterchange` (Recipe / bincode).
238        // Code paths that need a different goal (build pipeline, RON
239        // export, etc.) call `save_scene_dispatch_with_goal` directly.
240        "save_as" => apply_save_as(project_vfs.as_ref(), world, editor_state),
241        "open" => apply_open(project_vfs.as_ref(), world, editor_state),
242        "spawn_empty" => {
243            if let Ok(mut state) = editor_state.lock() {
244                state.pending_spawn = Some("Empty".to_owned());
245            }
246        }
247        "build_game" => run_build_game(project_vfs.as_ref(), editor_state),
248        "documentation" => {
249            let _ = open::that("https://github.com/eraflo/KhoraEngine");
250            log::info!("Opening documentation in browser");
251        }
252        "about" => {
253            log::info!("Khora Engine v0.1.0-dev - experimental game engine");
254        }
255        "preferences" | "reset_layout" => {
256            log::info!("Menu action '{}' (not yet implemented)", action);
257        }
258        other => {
259            log::info!("Unhandled menu action: {}", other);
260        }
261    }
262}
263
264fn apply_new_scene(world: &mut GameWorld, editor_state: &Arc<Mutex<EditorState>>) {
265    if let Ok(mut state) = editor_state.lock() {
266        let all: Vec<EntityId> = world.iter_entities().collect();
267        for entity in &all {
268            world.despawn(*entity);
269        }
270        state.clear_entity_references();
271        state.play_mode = PlayMode::Editing;
272        state.scene_snapshot = None;
273        log::info!("New scene created (cleared {} entities)", all.len());
274    }
275}
276
277fn apply_undo(
278    editor_state: &Arc<Mutex<EditorState>>,
279    command_history: &Arc<Mutex<CommandHistory>>,
280) {
281    if let Ok(mut history) = command_history.lock() {
282        if let Some(edit) = history.undo() {
283            if let Ok(mut state) = editor_state.lock() {
284                state.push_edit(edit);
285            }
286        }
287    }
288}
289
290fn apply_redo(
291    editor_state: &Arc<Mutex<EditorState>>,
292    command_history: &Arc<Mutex<CommandHistory>>,
293) {
294    if let Ok(mut history) = command_history.lock() {
295        if let Some(edit) = history.redo() {
296            if let Ok(mut state) = editor_state.lock() {
297                state.push_edit(edit);
298            }
299        }
300    }
301}
302
303fn apply_delete(world: &mut GameWorld, editor_state: &Arc<Mutex<EditorState>>) {
304    if let Ok(mut state) = editor_state.lock() {
305        ops::delete_selection(world, &mut state);
306    }
307}
308
309fn apply_play(world: &mut GameWorld, editor_state: &Arc<Mutex<EditorState>>) {
310    if let Ok(mut state) = editor_state.lock() {
311        match state.play_mode {
312            PlayMode::Editing => {
313                state.scene_snapshot = Some(scene_io::snapshot_scene(world));
314                state.play_mode = PlayMode::Playing;
315                log::info!("Play mode: started");
316            }
317            PlayMode::Paused => {
318                state.play_mode = PlayMode::Playing;
319                log::info!("Play mode: resumed");
320            }
321            _ => {}
322        }
323    }
324}
325
326fn apply_pause(editor_state: &Arc<Mutex<EditorState>>) {
327    if let Ok(mut state) = editor_state.lock() {
328        if state.play_mode == PlayMode::Playing {
329            state.play_mode = PlayMode::Paused;
330            log::info!("Play mode: paused");
331        }
332    }
333}
334
335fn apply_stop(world: &mut GameWorld, editor_state: &Arc<Mutex<EditorState>>) {
336    if let Ok(mut state) = editor_state.lock() {
337        if state.play_mode == PlayMode::Playing || state.play_mode == PlayMode::Paused {
338            if let Some(snapshot) = state.scene_snapshot.take() {
339                drop(state);
340                scene_io::restore_scene(world, &snapshot);
341                if let Ok(mut state) = editor_state.lock() {
342                    // The restore respawns everything with fresh ids, so any
343                    // selection made while playing now names entities that no
344                    // longer exist — or, once a slot is recycled, different
345                    // ones entirely.
346                    state.clear_entity_references();
347                    state.play_mode = PlayMode::Editing;
348                }
349                log::info!("Play mode: stopped - scene restored");
350            } else {
351                state.play_mode = PlayMode::Editing;
352                log::info!("Play mode: stopped - no snapshot to restore");
353            }
354        }
355    }
356}
357
358fn apply_save(
359    project_vfs: Option<&Arc<Mutex<ProjectVfs>>>,
360    world: &mut GameWorld,
361    editor_state: &Arc<Mutex<EditorState>>,
362) {
363    let path = editor_state
364        .lock()
365        .ok()
366        .and_then(|s| s.current_scene_path.clone());
367    if let Some(path) = path {
368        save_scene_dispatch(project_vfs, world, &path);
369    } else if let Some(path) = rfd::FileDialog::new()
370        .add_filter("Khora Scene", &["kscene"])
371        .save_file()
372    {
373        let path = path.to_string_lossy().to_string();
374        save_scene_dispatch(project_vfs, world, &path);
375        if let Ok(mut state) = editor_state.lock() {
376            state.current_scene_path = Some(path);
377        }
378    }
379}
380
381fn apply_save_as(
382    project_vfs: Option<&Arc<Mutex<ProjectVfs>>>,
383    world: &mut GameWorld,
384    editor_state: &Arc<Mutex<EditorState>>,
385) {
386    if let Some(path) = rfd::FileDialog::new()
387        .add_filter("Khora Scene", &["kscene"])
388        .save_file()
389    {
390        let path = path.to_string_lossy().to_string();
391        save_scene_dispatch(project_vfs, world, &path);
392        if let Ok(mut state) = editor_state.lock() {
393            state.current_scene_path = Some(path);
394        }
395    }
396}
397
398/// Drains [`EditorState::pending_save_as_prefab`] and
399/// [`EditorState::pending_save_as_prefab_at`], writing the entity's
400/// subtree as a `.kprefab` (Recipe-encoded).
401///
402/// - The `_at` variant carries a pre-chosen forward-slash relative
403///   path under `<project>/assets/`; the dispatcher writes directly
404///   without showing a dialog. Set by drag-drop (entity → asset
405///   browser folder).
406/// - The plain variant opens an `rfd::FileDialog`. Set by the scene
407///   tree's "Save as Prefab…" context entry.
408pub fn process_pending_save_as_prefab(
409    project_vfs: Option<&Arc<Mutex<ProjectVfs>>>,
410    world: &GameWorld,
411    editor_state: &Arc<Mutex<EditorState>>,
412) {
413    // Drag-drop path: pre-chosen folder, no dialog.
414    let auto = match editor_state.lock() {
415        Ok(mut s) => s.pending_save_as_prefab_at.take(),
416        Err(_) => None,
417    };
418    if let Some((entity, rel_path)) = auto {
419        save_prefab_to_project_path(project_vfs, world, entity, &rel_path);
420    }
421
422    // Right-click path: file dialog.
423    let entity = match editor_state.lock() {
424        Ok(mut s) => s.pending_save_as_prefab.take(),
425        Err(_) => None,
426    };
427    let Some(entity) = entity else {
428        return;
429    };
430
431    let bytes = match serialize_subtree(world.inner_world(), entity) {
432        Ok(b) => b,
433        Err(e) => {
434            log::error!("Failed to serialize prefab subtree: {:?}", e);
435            return;
436        }
437    };
438
439    let Some(path) = rfd::FileDialog::new()
440        .add_filter("Khora Prefab", &["kprefab"])
441        .set_file_name("prefab.kprefab")
442        .save_file()
443    else {
444        return;
445    };
446    let abs = path.clone();
447    let path_str = path.to_string_lossy().to_string();
448
449    if let Some(pvfs_arc) = project_vfs {
450        if let Ok(mut pvfs) = pvfs_arc.lock() {
451            let assets_root = pvfs.assets_root.clone();
452            if let Some(rel_fwd) = scene_io::rel_inside_project(&abs, &assets_root) {
453                write_prefab_through_vfs(&mut pvfs, &rel_fwd, &bytes);
454                return;
455            }
456        }
457    }
458
459    match std::fs::write(&path_str, &bytes) {
460        Ok(()) => log::warn!(
461            "Prefab saved to '{}' ({} bytes) — outside project, not VFS-managed.",
462            path_str,
463            bytes.len()
464        ),
465        Err(e) => log::error!("Failed to write prefab '{}': {}", path_str, e),
466    }
467}
468
469fn save_prefab_to_project_path(
470    project_vfs: Option<&Arc<Mutex<ProjectVfs>>>,
471    world: &GameWorld,
472    entity: EntityId,
473    rel_path: &str,
474) {
475    let Some(pvfs_arc) = project_vfs else {
476        log::warn!("Prefab drop ignored: no project is open");
477        return;
478    };
479    let bytes = match serialize_subtree(world.inner_world(), entity) {
480        Ok(b) => b,
481        Err(e) => {
482            log::error!("Failed to serialize prefab subtree: {:?}", e);
483            return;
484        }
485    };
486    let Ok(mut pvfs) = pvfs_arc.lock() else {
487        log::error!("Project VFS mutex poisoned");
488        return;
489    };
490    write_prefab_through_vfs(&mut pvfs, rel_path, &bytes);
491}
492
493fn write_prefab_through_vfs(pvfs: &mut ProjectVfs, rel_fwd: &str, bytes: &[u8]) {
494    if let Err(e) = pvfs.write_asset(std::path::Path::new(rel_fwd), bytes) {
495        log::error!("Failed to write prefab '{}': {:#}", rel_fwd, e);
496        return;
497    }
498    if let Err(e) = pvfs.rebuild_index() {
499        log::warn!("Prefab saved but index rebuild failed: {:#}", e);
500    }
501    log::info!("Prefab saved to '{}' ({} bytes)", rel_fwd, bytes.len());
502}
503
504/// Drains [`EditorState::pending_prefab_spawn`] and instantiates the
505/// referenced `.kprefab` into the live world via
506/// [`instantiate_subtree`]. The forward-slash relative path resolves
507/// through the project's VFS / `AssetService`.
508pub fn process_pending_prefab_spawn(
509    project_vfs: Option<&Arc<Mutex<ProjectVfs>>>,
510    world: &mut GameWorld,
511    editor_state: &Arc<Mutex<EditorState>>,
512) {
513    let pending = match editor_state.lock() {
514        Ok(mut s) => s.pending_prefab_spawn.take(),
515        Err(_) => None,
516    };
517    let Some((rel, parent)) = pending else {
518        return;
519    };
520
521    let Some(pvfs_arc) = project_vfs else {
522        log::warn!("Prefab spawn requested ('{}') but no project is open", rel);
523        return;
524    };
525
526    let bytes = {
527        let Ok(mut pvfs) = pvfs_arc.lock() else {
528            log::error!("Project VFS mutex poisoned");
529            return;
530        };
531        let uuid = pvfs.resolve_uuid(&rel);
532        match pvfs.asset_service.load_raw(&uuid) {
533            Ok(b) => b,
534            Err(e) => {
535                log::error!("Failed to read prefab '{}': {:#}", rel, e);
536                return;
537            }
538        }
539    };
540
541    match instantiate_subtree(world.inner_world_mut(), &bytes) {
542        Ok(new_root) => {
543            // Parent under the hierarchy row it was dropped on, if any.
544            if let Some(parent) = parent {
545                world.set_parent(new_root, Some(parent));
546            }
547            log::info!(
548                "Prefab '{}' instantiated (root entity index={}{})",
549                rel,
550                new_root.index,
551                if parent.is_some() { ", parented" } else { "" }
552            );
553        }
554        Err(e) => log::error!("Failed to instantiate prefab '{}': {:?}", rel, e),
555    }
556}
557
558/// Drains [`EditorState::pending_save_as_material`]: serializes the
559/// entity's inline material to a `.kmat` (RON), writes it under
560/// `assets/materials/<name>.kmat`, reindexes the VFS, then rewrites the
561/// entity's component to `MaterialRef::Asset(uuid)` so it references the
562/// freshly-saved shared, reloadable asset.
563///
564/// The `.kmat` bytes are exactly what the material decoder expects:
565/// `material_to_json(&dyn Material)` (the `{ type_name, material }` value
566/// split) RON-encoded. No project open → logged warning, no-op.
567pub fn process_pending_save_as_material(
568    project_vfs: Option<&Arc<Mutex<ProjectVfs>>>,
569    world: &mut GameWorld,
570    editor_state: &Arc<Mutex<EditorState>>,
571) {
572    let pending = match editor_state.lock() {
573        Ok(mut s) => s.pending_save_as_material.take(),
574        Err(_) => None,
575    };
576    let Some((entity, name)) = pending else {
577        return;
578    };
579
580    let Some(pvfs_arc) = project_vfs else {
581        log::warn!("Save material as .kmat ignored: no project is open");
582        return;
583    };
584
585    // Serialize the entity's inline material to the decoder's RON shape.
586    let json = {
587        let mref = match world.get_component::<MaterialRef>(entity) {
588            Some(m) => m,
589            None => {
590                log::warn!("Save material: entity {:?} has no MaterialRef", entity);
591                return;
592            }
593        };
594        let material = match mref {
595            MaterialRef::Inline { material, .. } => material,
596            MaterialRef::Asset(_) => {
597                log::warn!(
598                    "Save material: entity {:?} already references a .kmat asset",
599                    entity
600                );
601                return;
602            }
603        };
604        match khora_sdk::khora_data::ecs::material_to_json(&**material) {
605            Some(value) => value,
606            None => {
607                log::error!("Save material: failed to serialize material to JSON");
608                return;
609            }
610        }
611    };
612
613    let ron_text = match ron::ser::to_string(&json) {
614        Ok(text) => text,
615        Err(e) => {
616            log::error!("Save material: failed to encode material as RON: {e}");
617            return;
618        }
619    };
620
621    let stem = sanitize_material_name(&name);
622    let rel_fwd = format!("materials/{stem}.kmat");
623
624    let uuid = {
625        let Ok(mut pvfs) = pvfs_arc.lock() else {
626            log::error!("Save material: project VFS mutex poisoned");
627            return;
628        };
629        if let Err(e) = pvfs.write_asset(std::path::Path::new(&rel_fwd), ron_text.as_bytes()) {
630            log::error!("Save material: failed to write '{rel_fwd}': {e:#}");
631            return;
632        }
633        if let Err(e) = pvfs.rebuild_index() {
634            log::warn!("Save material: wrote '{rel_fwd}' but index rebuild failed: {e:#}");
635        }
636        pvfs.resolve_uuid(&rel_fwd)
637    };
638
639    // Convert the entity from an inline material to a reference to the
640    // saved asset, so it is now shared and reloadable. `add_component`
641    // replaces the existing `MaterialRef` of the same type.
642    world.add_component(entity, MaterialRef::Asset(uuid));
643    log::info!(
644        "Material saved to '{}' ({} bytes); entity {:?} now references it",
645        rel_fwd,
646        ron_text.len(),
647        entity
648    );
649}
650
651/// Drains [`EditorState::pending_assign_material`]: sets
652/// `MaterialRef::Asset(uuid)` on every selected entity, where `uuid` is
653/// derived from the chosen `.kmat`'s forward-slash relative path.
654pub fn process_pending_assign_material(
655    project_vfs: Option<&Arc<Mutex<ProjectVfs>>>,
656    world: &mut GameWorld,
657    editor_state: &Arc<Mutex<EditorState>>,
658) {
659    let (rel, targets) = match editor_state.lock() {
660        Ok(mut s) => {
661            let rel = s.pending_assign_material.take();
662            let targets: Vec<EntityId> = s.selection.iter().copied().collect();
663            (rel, targets)
664        }
665        Err(_) => return,
666    };
667    let Some(rel) = rel else {
668        return;
669    };
670    if targets.is_empty() {
671        log::warn!("Assign material '{rel}' ignored: no entity selected");
672        return;
673    }
674
675    // Registry-aware so a renamed `.kmat` resolves to its frozen UUID.
676    let uuid = resolve_asset_uuid(project_vfs, &rel);
677    for entity in targets {
678        world.add_component(entity, MaterialRef::Asset(uuid));
679        log::info!("Assigned material '{rel}' to entity {entity:?}");
680    }
681}
682
683/// Resolves a forward-slash relative asset path to its UUID through the open
684/// project's identity registry, falling back to the path-derived default when
685/// no project is open.
686fn resolve_asset_uuid(
687    project_vfs: Option<&Arc<Mutex<ProjectVfs>>>,
688    rel_fwd: &str,
689) -> khora_sdk::khora_core::asset::AssetUUID {
690    if let Some(pvfs_arc) = project_vfs {
691        if let Ok(pvfs) = pvfs_arc.lock() {
692            return pvfs.resolve_uuid(rel_fwd);
693        }
694    }
695    ProjectVfs::uuid_for_rel_path(rel_fwd)
696}
697
698/// Recomputes the asset-browser cache (entries + directories) after a file
699/// operation and bumps the epoch so the panel rescans its flattened view.
700fn refresh_asset_cache(pvfs_arc: &Arc<Mutex<ProjectVfs>>, editor_state: &Arc<Mutex<EditorState>>) {
701    let refreshed = match pvfs_arc.lock() {
702        Ok(pvfs) => Some((hot_reload::collect_asset_entries(&pvfs), pvfs.list_dirs())),
703        Err(_) => None,
704    };
705    if let Some((entries, dirs)) = refreshed {
706        if let Ok(mut s) = editor_state.lock() {
707            s.asset_entries = entries;
708            s.asset_dirs = dirs;
709            s.asset_epoch = s.asset_epoch.wrapping_add(1);
710        }
711    }
712}
713
714/// Drains the asset-explorer file operations (new folder / rename / move /
715/// delete-to-trash / duplicate). Each routes through [`ProjectVfs`], which keeps
716/// the identity registry consistent so references survive renames and moves.
717pub fn process_pending_asset_file_ops(
718    project_vfs: Option<&Arc<Mutex<ProjectVfs>>>,
719    editor_state: &Arc<Mutex<EditorState>>,
720) {
721    let (create, rename, move_op, delete, duplicate) = match editor_state.lock() {
722        Ok(mut s) => (
723            s.pending_create_folder.take(),
724            s.pending_rename_asset.take(),
725            s.pending_move_asset.take(),
726            s.pending_delete_asset.take(),
727            s.pending_duplicate_asset.take(),
728        ),
729        Err(_) => return,
730    };
731    if create.is_none()
732        && rename.is_none()
733        && move_op.is_none()
734        && delete.is_none()
735        && duplicate.is_none()
736    {
737        return;
738    }
739    let Some(pvfs_arc) = project_vfs else {
740        log::warn!("Asset file operation ignored: no project is open");
741        return;
742    };
743
744    {
745        let Ok(mut pvfs) = pvfs_arc.lock() else {
746            log::error!("Asset file op: project VFS mutex poisoned");
747            return;
748        };
749        if let Some(dir) = create {
750            match pvfs.create_folder(&dir) {
751                Ok(()) => log::info!("Created folder '{dir}'"),
752                Err(e) => log::error!("Create folder '{dir}' failed: {e:#}"),
753            }
754        }
755        if let Some((old, new)) = rename {
756            match pvfs.rename_asset(&old, &new) {
757                Ok(()) => log::info!("Renamed '{old}' → '{new}'"),
758                Err(e) => log::error!("Rename '{old}' → '{new}' failed: {e:#}"),
759            }
760        }
761        if let Some((src, dest)) = move_op {
762            match pvfs.move_asset(&src, &dest) {
763                Ok(()) => log::info!("Moved '{src}' → '{dest}/'"),
764                Err(e) => log::error!("Move '{src}' → '{dest}' failed: {e:#}"),
765            }
766        }
767        if let Some(rel) = delete {
768            match pvfs.delete_to_trash(&rel) {
769                Ok(()) => log::info!("Moved '{rel}' to the recycle bin"),
770                Err(e) => log::error!("Delete '{rel}' failed: {e:#}"),
771            }
772        }
773        if let Some(rel) = duplicate {
774            match pvfs.duplicate_asset(&rel) {
775                Ok(new_rel) => log::info!("Duplicated '{rel}' → '{new_rel}'"),
776                Err(e) => log::error!("Duplicate '{rel}' failed: {e:#}"),
777            }
778        }
779    }
780
781    refresh_asset_cache(pvfs_arc, editor_state);
782}
783
784/// Drains [`EditorState::pending_spawn_mesh_asset`]: spawns a `MeshRef::Asset`
785/// entity at the drop point. Set by dragging a mesh tile onto the viewport; the
786/// `asset_resolver_system` loads the mesh next tick.
787pub fn process_pending_spawn_mesh_asset(
788    project_vfs: Option<&Arc<Mutex<ProjectVfs>>>,
789    world: &mut GameWorld,
790    editor_state: &Arc<Mutex<EditorState>>,
791) {
792    let pending = match editor_state.lock() {
793        Ok(mut s) => s.pending_spawn_mesh_asset.take(),
794        Err(_) => None,
795    };
796    let Some((rel, point, parent)) = pending else {
797        return;
798    };
799    let uuid = resolve_asset_uuid(project_vfs, &rel);
800    let entity = ops::spawn_mesh_asset(world, uuid, point, &rel);
801    // Parent under the hierarchy row it was dropped on, if any.
802    if let Some(parent) = parent {
803        world.set_parent(entity, Some(parent));
804    }
805    if let Ok(mut s) = editor_state.lock() {
806        s.select(entity);
807    }
808    log::info!("Spawned mesh '{rel}' as entity {entity:?} at {point:?}");
809}
810
811/// Drains [`EditorState::pending_assign_texture`]: assigns a dropped texture or
812/// `.kmat` to a specific entity. A `.kmat` becomes a `MaterialRef::Asset`; an
813/// image becomes the `base_color_texture` of a fresh inline standard material.
814pub fn process_pending_assign_texture(
815    project_vfs: Option<&Arc<Mutex<ProjectVfs>>>,
816    world: &mut GameWorld,
817    editor_state: &Arc<Mutex<EditorState>>,
818) {
819    let pending = match editor_state.lock() {
820        Ok(mut s) => s.pending_assign_texture.take(),
821        Err(_) => None,
822    };
823    let Some((rel, entity)) = pending else {
824        return;
825    };
826    let uuid = resolve_asset_uuid(project_vfs, &rel);
827
828    let ext = rel.rsplit('.').next().unwrap_or("").to_ascii_lowercase();
829    match ext.as_str() {
830        "kmat" | "mat" => {
831            world.add_component(entity, MaterialRef::Asset(uuid));
832            log::info!("Assigned material '{rel}' to entity {entity:?}");
833        }
834        "png" | "jpg" | "jpeg" | "tga" | "bmp" | "hdr" => {
835            let std_mat = khora_sdk::prelude::materials::StandardMaterial {
836                base_color_texture: Some(uuid),
837                ..Default::default()
838            };
839            world.add_component(entity, MaterialRef::inline(Box::new(std_mat)));
840            log::info!("Assigned texture '{rel}' to entity {entity:?} (base color)");
841        }
842        _ => {
843            log::warn!("Drop of '{rel}' on entity {entity:?} ignored: not a texture or material")
844        }
845    }
846}
847
848/// Strips characters unsafe in cross-platform file names from a material
849/// name, falling back to `material` for an empty result.
850fn sanitize_material_name(name: &str) -> String {
851    let mut out = String::with_capacity(name.len());
852    let mut last_was_replacement = false;
853    for ch in name.chars() {
854        let safe = ch.is_alphanumeric() || matches!(ch, '_' | '-' | '.' | ' ');
855        if safe {
856            out.push(ch);
857            last_was_replacement = false;
858        } else if !last_was_replacement {
859            out.push('_');
860            last_was_replacement = true;
861        }
862    }
863    let trimmed = out.trim_matches(&[' ', '.', '_'][..]).to_string();
864    if trimmed.is_empty() {
865        "material".to_string()
866    } else {
867        trimmed
868    }
869}
870
871fn apply_open(
872    project_vfs: Option<&Arc<Mutex<ProjectVfs>>>,
873    world: &mut GameWorld,
874    editor_state: &Arc<Mutex<EditorState>>,
875) {
876    if let Some(path) = rfd::FileDialog::new()
877        .add_filter("Khora Scene", &["kscene"])
878        .pick_file()
879    {
880        let path_str = path.to_string_lossy().to_string();
881        load_scene_dispatch(project_vfs, world, editor_state, &path);
882        if let Ok(mut state) = editor_state.lock() {
883            state.current_scene_path = Some(path_str);
884        }
885    }
886}