Skip to main content

khora_editor/
scene_io.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//! Scene serialization helpers.
16//!
17//! All scene I/O is **project-relative when possible** — saves and loads route
18//! through [`crate::project_vfs::ProjectVfs`] so the editor uses the same
19//! `AssetService` + `FileLoader` contract as a future runtime. The "Save As" /
20//! "Open" file-dialog flows still accept arbitrary paths (that's intentional —
21//! you might want to open a scene from outside the current project) and fall
22//! back to direct `std::fs` only in that case.
23
24use crate::project_vfs::ProjectVfs;
25use khora_sdk::prelude::ecs::*;
26use khora_sdk::prelude::math::{LinearRgba, Vec3};
27use khora_sdk::{GameWorld, SceneFile, SerializationGoal, SerializationService};
28use std::path::Path;
29
30/// Canonical relative path of the auto-created default scene.
31pub const DEFAULT_SCENE_REL: &str = "scenes/default.kscene";
32
33// ─────────────────────────────────────────────────────────────────────────────
34// Play-mode snapshot — full-world capture via SerializationService.
35//
36// Routed through `SerializationGoal::EditorInterchange` (the Recipe / bincode
37// strategy) because it's the only strategy guaranteed to round-trip every
38// registered component lossless-ly. `FastestLoad` (Archetype) was tried first
39// but lost the `Name` component on restore (entities ended up showing
40// "Entity N" in the scene tree after Stop) and triggered heap corruption in
41// some edge cases. The snapshot is throw-away in-memory bytes so the marginal
42// cost of Recipe over Archetype is irrelevant.
43// ─────────────────────────────────────────────────────────────────────────────
44
45/// Serializes the entire world to an in-memory byte buffer for play-mode
46/// restore. Returns an empty `Vec` on failure (caller logs and proceeds —
47/// the worst case is "Stop button leaves the live state in place", which is
48/// preferable to a panic mid-play).
49pub fn snapshot_scene(world: &GameWorld) -> Vec<u8> {
50    let svc = SerializationService::new();
51    match svc.save_world(world.inner_world(), SerializationGoal::EditorInterchange) {
52        Ok(scene) => scene.to_bytes(),
53        Err(e) => {
54            log::error!("Play-mode snapshot failed: {:?}", e);
55            Vec::new()
56        }
57    }
58}
59
60/// Restores the world from a snapshot produced by [`snapshot_scene`].
61///
62/// Despawns every existing entity before deserializing — the live world after
63/// gameplay may have spawned new entities or destroyed old ones, so we
64/// rebuild from the snapshot rather than diff against it.
65pub fn restore_scene(world: &mut GameWorld, snapshot: &[u8]) {
66    if snapshot.is_empty() {
67        return;
68    }
69    let scene = match SceneFile::from_bytes(snapshot) {
70        Ok(f) => f,
71        Err(e) => {
72            log::error!("Play-mode restore: invalid snapshot: {:?}", e);
73            return;
74        }
75    };
76
77    let all: Vec<_> = world.iter_entities().collect();
78    for e in all {
79        world.despawn(e);
80    }
81
82    let svc = SerializationService::new();
83    if let Err(e) = svc.load_world(&scene, world.inner_world_mut()) {
84        log::error!("Play-mode restore failed: {:?}", e);
85    }
86}
87
88// ─────────────────────────────────────────────────────────────────────────────
89// Project-relative saves and loads — primary path used by Save / Open / auto-load
90// ─────────────────────────────────────────────────────────────────────────────
91
92/// Serializes the current world to a `.kscene` file at `rel_path` (relative
93/// to the project's `assets/` root) via the project's `AssetService`. Re-
94/// indexes on success so the new scene is immediately resolvable by UUID.
95///
96/// Default callers should use [`save_scene_in_project`] which selects
97/// `EditorInterchange` (Recipe / bincode). Use
98/// [`save_scene_in_project_with_goal`] to pick a different strategy
99/// (`HumanReadableDebug` for RON export, `FastestLoad` for archetype).
100#[allow(dead_code)]
101pub fn save_scene_in_project(pvfs: &mut ProjectVfs, world: &GameWorld, rel_path: &Path) -> bool {
102    save_scene_in_project_with_goal(pvfs, world, rel_path, SerializationGoal::EditorInterchange)
103}
104
105/// Same as [`save_scene_in_project`] with an explicit serialization goal.
106pub fn save_scene_in_project_with_goal(
107    pvfs: &mut ProjectVfs,
108    world: &GameWorld,
109    rel_path: &Path,
110    goal: SerializationGoal,
111) -> bool {
112    let agent = SerializationService::new();
113    let scene_file = match agent.save_world(world.inner_world(), goal) {
114        Ok(f) => f,
115        Err(e) => {
116            log::error!("Failed to serialize scene: {:?}", e);
117            return false;
118        }
119    };
120    let bytes = scene_file.to_bytes();
121    if let Err(e) = pvfs.write_asset(rel_path, &bytes) {
122        log::error!("Failed to write scene to {:?}: {:#}", rel_path, e);
123        return false;
124    }
125    if let Err(e) = pvfs.rebuild_index() {
126        log::warn!("Scene saved but index rebuild failed: {:#}", e);
127    }
128    log::info!(
129        "Scene saved to '{}' ({} bytes, goal={:?}) via ProjectVfs",
130        rel_path.display(),
131        bytes.len(),
132        goal,
133    );
134    true
135}
136
137/// Loads a scene by its relative path under `<project>/assets/` via the
138/// project's `AssetService::load_raw`. Falls back to a fresh reindex + retry
139/// once if the path isn't yet known to the VFS (e.g. just-saved).
140pub fn load_scene_in_project(
141    pvfs: &mut ProjectVfs,
142    world: &mut GameWorld,
143    rel_path_fwd_slash: &str,
144) -> bool {
145    // Registry-aware: a scene that was renamed keeps its frozen UUID.
146    let uuid = pvfs.resolve_uuid(rel_path_fwd_slash);
147
148    // Try the existing index first; if absent, reindex once and retry.
149    let bytes = match pvfs.asset_service.load_raw(&uuid) {
150        Ok(b) => b,
151        Err(_) => {
152            if let Err(e) = pvfs.rebuild_index() {
153                log::error!(
154                    "Failed to rebuild index while resolving '{}': {:#}",
155                    rel_path_fwd_slash,
156                    e
157                );
158                return false;
159            }
160            match pvfs.asset_service.load_raw(&uuid) {
161                Ok(b) => b,
162                Err(e) => {
163                    log::error!(
164                        "Failed to load scene '{}' from ProjectVfs: {:#}",
165                        rel_path_fwd_slash,
166                        e
167                    );
168                    return false;
169                }
170            }
171        }
172    };
173
174    let scene_file = match SceneFile::from_bytes(&bytes) {
175        Ok(f) => f,
176        Err(e) => {
177            log::error!("Invalid scene file '{}': {:?}", rel_path_fwd_slash, e);
178            return false;
179        }
180    };
181
182    // Despawn current world before deserializing.
183    let all_entities: Vec<_> = world.iter_entities().collect();
184    for entity in all_entities {
185        world.despawn(entity);
186    }
187
188    let agent = SerializationService::new();
189    match agent.load_world(&scene_file, world.inner_world_mut()) {
190        Ok(()) => {
191            log::info!(
192                "Scene loaded from '{}' ({} bytes) via ProjectVfs",
193                rel_path_fwd_slash,
194                bytes.len()
195            );
196            true
197        }
198        Err(e) => {
199            log::error!(
200                "Failed to deserialize scene '{}': {:?}",
201                rel_path_fwd_slash,
202                e
203            );
204            false
205        }
206    }
207}
208
209/// Auto-loads the project's default scene (`assets/scenes/default.kscene`),
210/// creating it from a Camera + Light template if it doesn't exist yet.
211pub fn auto_load_or_create_default_scene(pvfs: &mut ProjectVfs, world: &mut GameWorld) {
212    let rel = DEFAULT_SCENE_REL;
213    let abs = pvfs.assets_root.join(Path::new(rel));
214    if abs.exists() {
215        load_scene_in_project(pvfs, world, rel);
216    } else {
217        create_default_scene_in_project(pvfs, world, rel);
218    }
219}
220
221/// Spawns Main Camera + Directional Light entities, then saves the world to
222/// the relative path inside the project (default `scenes/default.kscene`).
223fn create_default_scene_in_project(pvfs: &mut ProjectVfs, world: &mut GameWorld, rel_path: &str) {
224    world.spawn((
225        Transform {
226            translation: Vec3::new(0.0, 5.0, 10.0),
227            ..Default::default()
228        },
229        GlobalTransform::identity(),
230        Camera::default(),
231        Name("Main Camera".to_string()),
232    ));
233
234    world.spawn((
235        Transform {
236            translation: Vec3::new(0.0, 10.0, 0.0),
237            ..Default::default()
238        },
239        GlobalTransform::identity(),
240        Light::new(LightType::Directional(DirectionalLight {
241            direction: Vec3::new(-0.4, -0.8, -0.45),
242            color: LinearRgba::WHITE,
243            intensity: 1.0,
244            ..Default::default()
245        })),
246        Name("Directional Light".to_string()),
247    ));
248
249    if !save_scene_in_project(pvfs, world, Path::new(rel_path)) {
250        log::error!("Failed to seed default scene at '{}'", rel_path);
251    }
252}
253
254// ─────────────────────────────────────────────────────────────────────────────
255// Arbitrary-path fallback — for File→Save As / Open dialogs that target a
256// location outside the current project. Bypasses the VFS by design.
257// ─────────────────────────────────────────────────────────────────────────────
258
259/// Serializes the world to a `.kscene` at an absolute path. Used by the
260/// "Save As..." dialog when the user picks a destination outside
261/// `<project>/assets/`. Logs a warning so the divergence from VFS-managed
262/// I/O is visible.
263#[allow(dead_code)]
264pub fn save_scene_to_path(world: &GameWorld, path: &str) -> bool {
265    save_scene_to_path_with_goal(world, path, SerializationGoal::EditorInterchange)
266}
267
268/// Same as [`save_scene_to_path`] with an explicit serialization goal.
269pub fn save_scene_to_path_with_goal(
270    world: &GameWorld,
271    path: &str,
272    goal: SerializationGoal,
273) -> bool {
274    let agent = SerializationService::new();
275    match agent.save_world(world.inner_world(), goal) {
276        Ok(scene_file) => {
277            let bytes = scene_file.to_bytes();
278            match std::fs::write(path, &bytes) {
279                Ok(()) => {
280                    log::warn!(
281                        "Scene saved to '{}' ({} bytes, goal={:?}) — outside project, not VFS-managed.",
282                        path,
283                        bytes.len(),
284                        goal,
285                    );
286                    true
287                }
288                Err(e) => {
289                    log::error!("Failed to write scene file '{}': {}", path, e);
290                    false
291                }
292            }
293        }
294        Err(e) => {
295            log::error!("Failed to serialize scene: {:?}", e);
296            false
297        }
298    }
299}
300
301/// Loads a scene from an absolute path. Used by the "Open..." dialog when
302/// the user picks a file outside `<project>/assets/`.
303pub fn load_scene_from_path(world: &mut GameWorld, path: &str) -> bool {
304    let bytes = match std::fs::read(path) {
305        Ok(bytes) => bytes,
306        Err(e) => {
307            log::error!("Failed to read scene file '{}': {}", path, e);
308            return false;
309        }
310    };
311
312    let scene_file = match SceneFile::from_bytes(&bytes) {
313        Ok(file) => file,
314        Err(e) => {
315            log::error!("Invalid scene file '{}': {:?}", path, e);
316            return false;
317        }
318    };
319
320    let all_entities: Vec<_> = world.iter_entities().collect();
321    for entity in all_entities {
322        world.despawn(entity);
323    }
324
325    let agent = SerializationService::new();
326    match agent.load_world(&scene_file, world.inner_world_mut()) {
327        Ok(()) => {
328            log::warn!(
329                "Scene loaded from '{}' ({} bytes) — outside project, not VFS-managed.",
330                path,
331                bytes.len()
332            );
333            true
334        }
335        Err(e) => {
336            log::error!("Failed to deserialize scene '{}': {:?}", path, e);
337            false
338        }
339    }
340}
341
342// ─────────────────────────────────────────────────────────────────────────────
343// Path utilities
344// ─────────────────────────────────────────────────────────────────────────────
345
346/// If `abs_path` lives under `<project>/assets/`, returns the relative path
347/// in forward-slash form ready for [`load_scene_in_project`]. Otherwise
348/// returns `None` — callers should fall back to [`load_scene_from_path`].
349pub fn rel_inside_project(abs_path: &Path, assets_root: &Path) -> Option<String> {
350    let rel = abs_path.strip_prefix(assets_root).ok()?;
351    Some(
352        rel.components()
353            .map(|c| c.as_os_str().to_string_lossy().into_owned())
354            .collect::<Vec<_>>()
355            .join("/"),
356    )
357}
358
359#[cfg(test)]
360mod tests {
361    use super::*;
362
363    /// Regression: snapshot/restore must round-trip the `Name` component.
364    /// Symptom of a broken implementation: entities show "Entity N" in the
365    /// scene tree after Stop because the fallback in `extract_scene_tree`
366    /// kicks in.
367    #[test]
368    fn snapshot_restore_preserves_name() {
369        let mut world = GameWorld::new();
370        world.spawn((
371            Transform {
372                translation: Vec3::new(1.0, 2.0, 3.0),
373                ..Default::default()
374            },
375            GlobalTransform::identity(),
376            Name::new("TestCube"),
377        ));
378
379        let snap = snapshot_scene(&world);
380        assert!(!snap.is_empty(), "snapshot should not be empty");
381
382        // Despawn everything to simulate gameplay.
383        let all: Vec<_> = world.iter_entities().collect();
384        for e in all {
385            world.despawn(e);
386        }
387        assert_eq!(world.iter_entities().count(), 0);
388
389        restore_scene(&mut world, &snap);
390
391        let names: Vec<String> = world
392            .iter_entities()
393            .filter_map(|e| {
394                world
395                    .get_component::<Name>(e)
396                    .map(|n| n.as_str().to_owned())
397            })
398            .collect();
399        assert!(
400            names.contains(&"TestCube".to_string()),
401            "expected 'TestCube' Name to survive restore, got {:?}",
402            names
403        );
404    }
405
406    /// Save/load round-trip beyond the single-`Name` case: several entities,
407    /// each carrying multiple components with non-default field values, must
408    /// survive snapshot → restore with entity count and key field values
409    /// intact. Covers Transform + Name + Light + Camera.
410    #[test]
411    fn snapshot_restore_preserves_multiple_components() {
412        let mut world = GameWorld::new();
413
414        let cube = world.spawn((
415            Transform::from_translation(Vec3::new(4.0, 5.0, 6.0)),
416            GlobalTransform::identity(),
417            Name::new("Cube"),
418        ));
419        let _light = world.spawn((
420            Transform::from_translation(Vec3::new(0.0, 10.0, 0.0)),
421            GlobalTransform::identity(),
422            Name::new("Sun"),
423            Light::directional(),
424        ));
425        let _camera = world.spawn((
426            Transform::from_translation(Vec3::new(0.0, 2.0, 9.0)),
427            GlobalTransform::identity(),
428            Name::new("Cam"),
429            Camera::new_perspective(std::f32::consts::FRAC_PI_4, 1.5, 0.1, 800.0),
430        ));
431        let _ = cube;
432
433        let before = world.iter_entities().count();
434        assert_eq!(before, 3);
435
436        let snap = snapshot_scene(&world);
437        assert!(!snap.is_empty());
438
439        // Throw the live world away and rebuild from the snapshot.
440        let all: Vec<_> = world.iter_entities().collect();
441        for e in all {
442            world.despawn(e);
443        }
444        restore_scene(&mut world, &snap);
445
446        assert_eq!(
447            world.iter_entities().count(),
448            before,
449            "entity count must survive the round-trip"
450        );
451
452        // The cube's translation and name must come back exactly.
453        let restored: Vec<_> = world.iter_entities().collect();
454        let cube_back = restored
455            .iter()
456            .find(|&&e| {
457                world
458                    .get_component::<Name>(e)
459                    .is_some_and(|n| n.as_str() == "Cube")
460            })
461            .copied()
462            .expect("the 'Cube' entity must survive restore");
463        let t = world
464            .get_component::<Transform>(cube_back)
465            .expect("restored cube keeps its Transform");
466        assert_eq!(t.translation, Vec3::new(4.0, 5.0, 6.0));
467
468        // The Light and Camera components must come back on their entities.
469        let has_light = restored
470            .iter()
471            .any(|&e| world.get_component::<Light>(e).is_some());
472        let has_camera = restored
473            .iter()
474            .any(|&e| world.get_component::<Camera>(e).is_some());
475        assert!(has_light, "a Light must survive the round-trip");
476        assert!(has_camera, "a Camera must survive the round-trip");
477    }
478
479    /// Play/stop guard: entities that share storage pages across domains
480    /// (mesh entities carry Spatial + Render components) are snapshotted,
481    /// despawned wholesale during "play", then restored. The pre-play state
482    /// must come back fully — this exercises the multi-domain despawn path
483    /// that previously corrupted survivor rows.
484    #[test]
485    fn play_stop_restores_state_after_multi_domain_despawn() {
486        let mut world = GameWorld::new();
487
488        // Two mesh entities (Spatial Transform + Render MeshRef) plus a light,
489        // so the snapshot spans pages in more than one semantic domain.
490        let a = world.spawn((
491            Transform::from_translation(Vec3::new(1.0, 0.0, 0.0)),
492            GlobalTransform::identity(),
493            Name::new("MeshA"),
494            MeshRef::procedural(ProceduralMeshKind::Cube, [1.0, 0.0, 0.0, 0.0]),
495        ));
496        let _b = world.spawn((
497            Transform::from_translation(Vec3::new(2.0, 0.0, 0.0)),
498            GlobalTransform::identity(),
499            Name::new("MeshB"),
500            MeshRef::procedural(ProceduralMeshKind::Sphere, [0.5, 16.0, 16.0, 0.0]),
501        ));
502        let _light = world.spawn((
503            Transform::identity(),
504            GlobalTransform::identity(),
505            Name::new("Light"),
506            Light::point(),
507        ));
508        let _ = a;
509
510        let before = world.iter_entities().count();
511        assert_eq!(before, 3);
512
513        // Enter play: snapshot the authoring state.
514        let snap = snapshot_scene(&world);
515        assert!(!snap.is_empty());
516
517        // During play, the simulation despawns everything (and could spawn more).
518        let all: Vec<_> = world.iter_entities().collect();
519        for e in all {
520            world.despawn(e);
521        }
522        world.spawn((Transform::identity(), GlobalTransform::identity()));
523        assert_ne!(world.iter_entities().count(), before);
524
525        // Stop: restore the pre-play state.
526        restore_scene(&mut world, &snap);
527
528        assert_eq!(
529            world.iter_entities().count(),
530            before,
531            "stop must restore the exact pre-play entity count"
532        );
533
534        // A survivor's data across both domains must be intact.
535        let restored: Vec<_> = world.iter_entities().collect();
536        let mesh_a = restored
537            .iter()
538            .find(|&&e| {
539                world
540                    .get_component::<Name>(e)
541                    .is_some_and(|n| n.as_str() == "MeshA")
542            })
543            .copied()
544            .expect("'MeshA' must survive restore");
545        let t = world
546            .get_component::<Transform>(mesh_a)
547            .expect("restored MeshA keeps its Spatial Transform");
548        assert_eq!(t.translation, Vec3::new(1.0, 0.0, 0.0));
549        assert!(
550            world.get_component::<MeshRef>(mesh_a).is_some(),
551            "restored MeshA keeps its Render-domain MeshRef"
552        );
553    }
554}