1use 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
35pub 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
54pub 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 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
83pub 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
111pub 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
158pub 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
195pub 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" => 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 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
398pub fn process_pending_save_as_prefab(
409 project_vfs: Option<&Arc<Mutex<ProjectVfs>>>,
410 world: &GameWorld,
411 editor_state: &Arc<Mutex<EditorState>>,
412) {
413 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 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
504pub 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 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
558pub 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 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 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
651pub 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 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
683fn 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
698fn 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
714pub 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
784pub 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 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
811pub 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
848fn 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}