Skip to main content

editor/
project.rs

1use std::collections::HashMap;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use libdqg::ecs::Entity;
6use libdqg::world::World;
7
8/// [`SceneFile`]/[`EntityRecord`]/[`RenderableAsset`]/[`RenderableKind`] live in `libdqg::scene_file`
9/// so the standalone `runtime` player crate can deserialize the exact same scene format without
10/// depending on this (egui/rfd-heavy) editor crate — see the export/compile implementation plan.
11pub use libdqg::scene_file::{EntityRecord, RenderableAsset, RenderableKind, SceneFile};
12
13pub const MANIFEST_FILE: &str = "project.ron";
14const DEFAULT_SCENE_FILE: &str = "scenes/main.ron";
15
16fn default_start_scene() -> PathBuf {
17    PathBuf::from(DEFAULT_SCENE_FILE)
18}
19
20/// Rewrites `path`'s separators to `/`, regardless of the host OS's own convention. Every
21/// project-relative path this module hands back or persists (`list_scenes`/`list_scripts`/
22/// `list_assets`'s `strip_prefix` results, `create_scene`/`create_script`/`import_asset`'s
23/// returned paths, `set_start_scene`'s argument) must round-trip identically whether the project
24/// is saved on Windows and later opened/exported-and-run on Linux (`runtime`, CI) or vice versa.
25/// `Path::join` and component parsing accept `/` as a separator on Windows too, but Unix treats a
26/// literal `\` as an ordinary filename character, not a separator — so a path built with native
27/// separators on Windows (`scenes\main.ron`) silently fails to resolve at all once read back on
28/// Linux. Rebuilding the `PathBuf` from the normalized string (rather than via `.join(...)`,
29/// which would reintroduce native separators) is what makes it stick: `Path`'s `Display`/
30/// `to_str()` never rewrites an already-`/`-separated string on Windows, only `.join()` inserts a
31/// native separator between components.
32fn to_portable_path(path: &Path) -> PathBuf {
33    PathBuf::from(path.to_string_lossy().replace('\\', "/"))
34}
35
36/// Starter content for [`Project::create_script`]'s "New Script" boilerplate — both hooks
37/// [`libdqg::scripting::ScriptRuntime`] looks for, stubbed out. `on_start`/`on_update` must stay
38/// `let`-bound closures rather than plain `fn`s (see `ScriptRuntime::start_script`'s doc comment
39/// on why) — this template exists partly so a new script starts from a working example of that,
40/// not just a blank file.
41const SCRIPT_BOILERPLATE: &str = r#"// Called once when this script starts (Play begins, or the script is attached mid-Play).
42let on_start = || {
43};
44
45// Called every frame while playing.
46// `dt` is the elapsed time in seconds since the last frame.
47// `input` lets you check keys, e.g. input.is_held("KeyW") or input.is_pressed("Space").
48let on_update = |dt, input| {
49};
50"#;
51
52/// A project on disk: a folder containing a manifest plus `assets/`, `scenes/`, `scripts/`
53/// subfolders. Entities can attach one or more `.rhai` files from `scripts/` — see
54/// [`Project::list_scripts`]/[`Project::create_script`] and
55/// [`libdqg::scripting::ScriptRuntime`].
56pub struct Project {
57    pub root: PathBuf,
58    pub manifest: ProjectManifest,
59}
60
61#[derive(serde::Serialize, serde::Deserialize)]
62pub struct ProjectManifest {
63    pub format_version: u32,
64    pub name: String,
65    /// Project-relative path to the scene a new `EditorScene`/export boots into — e.g.
66    /// `"scenes/main.ron"`. `#[serde(default)]`d since projects saved before multi-scene support
67    /// predate this field.
68    #[serde(default = "default_start_scene")]
69    pub start_scene: PathBuf,
70}
71
72impl Project {
73    fn assets_dir(&self) -> PathBuf {
74        self.root.join("assets")
75    }
76
77    pub fn textures_dir(&self) -> PathBuf {
78        self.assets_dir().join("textures")
79    }
80
81    pub fn models_dir(&self) -> PathBuf {
82        self.assets_dir().join("models")
83    }
84
85    pub fn scripts_dir(&self) -> PathBuf {
86        self.root.join("scripts")
87    }
88
89    fn scenes_dir(&self) -> PathBuf {
90        self.root.join("scenes")
91    }
92
93    /// The absolute path to the project's start scene — see [`ProjectManifest::start_scene`].
94    pub fn start_scene_path(&self) -> PathBuf {
95        self.root.join(&self.manifest.start_scene)
96    }
97
98    fn manifest_path(&self) -> PathBuf {
99        self.root.join(MANIFEST_FILE)
100    }
101
102    /// Creates a new project folder at `root`: `assets/{textures,models}/`, `scenes/`,
103    /// `scripts/`, a manifest, and an empty scene file so [`Project::open`]/[`Project::load_scene`]
104    /// always find a valid file even before the first save.
105    pub fn create(root: PathBuf) -> anyhow::Result<Self> {
106        let name = root
107            .file_name()
108            .map(|n| n.to_string_lossy().into_owned())
109            .unwrap_or_else(|| "Untitled Project".to_string());
110
111        let project = Project {
112            root,
113            manifest: ProjectManifest { format_version: 1, name, start_scene: PathBuf::from(DEFAULT_SCENE_FILE) },
114        };
115
116        fs::create_dir_all(project.textures_dir())?;
117        fs::create_dir_all(project.models_dir())?;
118        fs::create_dir_all(project.scenes_dir())?;
119        fs::create_dir_all(project.scripts_dir())?;
120
121        fs::write(project.manifest_path(), ron::ser::to_string_pretty(&project.manifest, Default::default())?)?;
122        SceneFile::default().save(&project.start_scene_path())?;
123
124        Ok(project)
125    }
126
127    /// Opens an existing project folder by reading its manifest.
128    pub fn open(root: PathBuf) -> anyhow::Result<Self> {
129        let manifest_path = root.join(MANIFEST_FILE);
130        let manifest: ProjectManifest = ron::from_str(&fs::read_to_string(manifest_path)?)?;
131        Ok(Project { root, manifest })
132    }
133
134    /// Saves `world` to the scene file at `path` (project-relative).
135    pub fn save_scene(&self, path: &Path, world: &World, assets: &HashMap<Entity, RenderableAsset>) -> anyhow::Result<()> {
136        let entities = world
137            .iter_entities()
138            .map(|entity| EntityRecord {
139                name: world.names.get(entity).map(|n| n.0.clone()).unwrap_or_default(),
140                transform: *world.transforms.get(entity).unwrap(),
141                renderable: assets.get(&entity).cloned(),
142                scripts: world.scripts.get(entity).map(|list| list.0.clone()).unwrap_or_default(),
143                camera: world.cameras.get(entity).copied(),
144            })
145            .collect();
146
147        SceneFile { entities }.save(&self.root.join(path))
148    }
149
150    /// Loads the scene file at `path` (project-relative).
151    pub fn load_scene(&self, path: &Path) -> anyhow::Result<SceneFile> {
152        SceneFile::load(&self.root.join(path))
153    }
154
155    /// Lists `.ron` scene files already in the project (relative to the project root), for the
156    /// Assets panel's Scenes section. Same shape as [`Project::list_scripts`].
157    pub fn list_scenes(&self) -> Vec<PathBuf> {
158        let Ok(read_dir) = fs::read_dir(self.scenes_dir()) else { return Vec::new() };
159        let mut paths: Vec<PathBuf> = read_dir
160            .filter_map(|entry| entry.ok())
161            .map(|entry| entry.path())
162            .filter(|path| path.is_file())
163            .filter(|path| path.extension().and_then(|e| e.to_str()).is_some_and(|ext| ext.eq_ignore_ascii_case("ron")))
164            .filter_map(|path| path.strip_prefix(&self.root).ok().map(to_portable_path))
165            .collect();
166        paths.sort();
167        paths
168    }
169
170    /// Creates a new empty `.ron` scene file in `scenes/`, for the Assets panel's "New Scene"
171    /// button — the create-from-scratch counterpart to [`Project::create_script`]. Never
172    /// overwrites: tries `Scene.ron`, then `Scene2.ron`, ... Returns its project-relative path.
173    pub fn create_scene(&self) -> anyhow::Result<PathBuf> {
174        let dir = self.scenes_dir();
175        fs::create_dir_all(&dir)?;
176
177        let mut candidate = dir.join("Scene.ron");
178        let mut suffix = 2;
179        while candidate.exists() {
180            candidate = dir.join(format!("Scene{suffix}.ron"));
181            suffix += 1;
182        }
183
184        SceneFile::default().save(&candidate)?;
185        Ok(to_portable_path(candidate.strip_prefix(&self.root)?))
186    }
187
188    /// Marks `scene` (project-relative) as the project's start scene and persists the manifest
189    /// immediately, so it survives without an explicit project-level Save step. Normalizes
190    /// `scene`'s separators (see [`to_portable_path`]) in case the caller built it some way other
191    /// than one of this struct's own path-returning methods.
192    ///
193    /// Transactional: if serializing or writing the manifest fails, `self.manifest.start_scene`
194    /// is restored to whatever it was before this call, so a failure here never leaves the
195    /// in-memory manifest disagreeing with what's actually on disk — callers don't each need
196    /// their own copy of that rollback.
197    pub fn set_start_scene(&mut self, scene: PathBuf) -> anyhow::Result<()> {
198        let previous = self.manifest.start_scene.clone();
199        self.manifest.start_scene = to_portable_path(&scene);
200
201        let result = ron::ser::to_string_pretty(&self.manifest, Default::default())
202            .map_err(anyhow::Error::from)
203            .and_then(|text| fs::write(self.manifest_path(), text).map_err(anyhow::Error::from));
204
205        if result.is_err() {
206            self.manifest.start_scene = previous;
207        }
208        result
209    }
210
211    /// Lists assets of the given kind already in the project (relative to the project root),
212    /// for the Assets panel and the inspector's attach-renderable picker to share. Filtered to
213    /// each kind's own extension(s) — `models_dir()` also holds `.mtl` files and any textures an
214    /// imported `.obj` depends on (see `import_obj_dependencies`), neither of which is itself an
215    /// attachable model.
216    pub fn list_assets(&self, kind: RenderableKind) -> Vec<PathBuf> {
217        let (dir, extensions): (PathBuf, &[&str]) = match kind {
218            RenderableKind::Sprite => (self.textures_dir(), &["png", "jpg", "jpeg"]),
219            RenderableKind::Model => (self.models_dir(), &["obj"]),
220        };
221
222        let Ok(read_dir) = fs::read_dir(&dir) else { return Vec::new() };
223        let mut paths: Vec<PathBuf> = read_dir
224            .filter_map(|entry| entry.ok())
225            .map(|entry| entry.path())
226            .filter(|path| path.is_file())
227            .filter(|path| {
228                path.extension()
229                    .and_then(|ext| ext.to_str())
230                    .is_some_and(|ext| extensions.contains(&ext.to_lowercase().as_str()))
231            })
232            .filter_map(|path| path.strip_prefix(&self.root).ok().map(to_portable_path))
233            .collect();
234        paths.sort();
235        paths
236    }
237
238    /// Lists `.rhai` script files already in the project (relative to the project root), for
239    /// the Assets panel and the inspector's attach-script picker to share. Same shape as
240    /// [`Project::list_assets`], just for the one script kind instead of a
241    /// [`RenderableKind`]-style enum.
242    pub fn list_scripts(&self) -> Vec<PathBuf> {
243        let Ok(read_dir) = fs::read_dir(self.scripts_dir()) else { return Vec::new() };
244        let mut paths: Vec<PathBuf> = read_dir
245            .filter_map(|entry| entry.ok())
246            .map(|entry| entry.path())
247            .filter(|path| path.is_file())
248            .filter(|path| path.extension().and_then(|ext| ext.to_str()).is_some_and(|ext| ext.eq_ignore_ascii_case("rhai")))
249            .filter_map(|path| path.strip_prefix(&self.root).ok().map(to_portable_path))
250            .collect();
251        paths.sort();
252        paths
253    }
254
255    /// Creates a new `.rhai` file in the project's `scripts/` folder, pre-filled with
256    /// `on_start`/`on_update` boilerplate (see [`SCRIPT_BOILERPLATE`]), and returns its path
257    /// relative to the project root — for the Assets panel's "New Script" button, the
258    /// create-from-scratch counterpart to [`Project::import_asset`]. Never overwrites an
259    /// existing file: tries `Script.rhai`, then `Script2.rhai`, `Script3.rhai`, ... until it
260    /// finds a name nothing is using yet.
261    pub fn create_script(&self) -> anyhow::Result<PathBuf> {
262        let dir = self.scripts_dir();
263        fs::create_dir_all(&dir)?;
264
265        let mut candidate = dir.join("Script.rhai");
266        let mut suffix = 2;
267        while candidate.exists() {
268            candidate = dir.join(format!("Script{suffix}.rhai"));
269            suffix += 1;
270        }
271
272        fs::write(&candidate, SCRIPT_BOILERPLATE)?;
273        Ok(to_portable_path(candidate.strip_prefix(&self.root)?))
274    }
275
276    /// Copies `src` into the project's `assets/` folder, sorted into `textures/`/`models/`/
277    /// `scripts/` by extension (anything else falls back to a flat `assets/` folder). Importing
278    /// an `.obj` also copies its referenced `.mtl` file(s) and the textures those reference, so
279    /// the model still loads from its new location. Returns the new path, relative to the
280    /// project root.
281    pub fn import_asset(&self, src: &Path) -> anyhow::Result<PathBuf> {
282        let extension = src.extension().and_then(|e| e.to_str()).unwrap_or_default().to_lowercase();
283        let dest_dir = match extension.as_str() {
284            "png" | "jpg" | "jpeg" => self.textures_dir(),
285            "obj" | "mtl" => self.models_dir(),
286            "rhai" => self.scripts_dir(),
287            _ => self.assets_dir(),
288        };
289        fs::create_dir_all(&dest_dir)?;
290
291        let file_name = src.file_name().ok_or_else(|| anyhow::anyhow!("import source has no file name"))?;
292        let dest = dest_dir.join(file_name);
293        fs::copy(src, &dest)?;
294
295        if extension == "obj" {
296            Self::import_obj_dependencies(src, &dest_dir)?;
297        }
298
299        Ok(to_portable_path(dest.strip_prefix(&self.root)?))
300    }
301
302    /// Best-effort copy of an OBJ's `mtllib` material file(s) and the textures they reference
303    /// (`map_Kd`/`map_Ka`/`map_Ks`/`bump`/... lines), alongside it in `dest_dir`, mirroring
304    /// [`Model::load`]'s own path resolution (relative to the OBJ's/MTL's own directory) so the
305    /// copied files still resolve once loaded from the project. Not a full MTL parser — assumes
306    /// the referenced filename is the last whitespace-separated token on the line, which covers
307    /// typical exporter output.
308    fn import_obj_dependencies(obj_src: &Path, dest_dir: &Path) -> anyhow::Result<()> {
309        let obj_dir = obj_src.parent().unwrap_or_else(|| Path::new(""));
310        let obj_text = fs::read_to_string(obj_src)?;
311
312        for mtl_name in obj_text
313            .lines()
314            .filter_map(|line| line.trim().strip_prefix("mtllib"))
315            .flat_map(|rest| rest.split_whitespace())
316        {
317            let mtl_src = obj_dir.join(mtl_name);
318            if !mtl_src.is_file() {
319                continue;
320            }
321            fs::copy(&mtl_src, dest_dir.join(mtl_name))?;
322
323            let mtl_dir = mtl_src.parent().unwrap_or_else(|| Path::new(""));
324            let mtl_text = fs::read_to_string(&mtl_src)?;
325            for line in mtl_text.lines() {
326                let mut tokens = line.trim().split_whitespace();
327                let Some(directive) = tokens.next() else { continue };
328                let is_texture_map =
329                    directive.starts_with("map_") || matches!(directive, "bump" | "disp" | "decal");
330                if !is_texture_map {
331                    continue;
332                }
333                let Some(texture_name) = tokens.last() else { continue };
334
335                let texture_src = mtl_dir.join(texture_name);
336                if !texture_src.is_file() {
337                    continue;
338                }
339                let texture_dest = dest_dir.join(texture_name);
340                if let Some(parent) = texture_dest.parent() {
341                    fs::create_dir_all(parent)?;
342                }
343                fs::copy(&texture_src, &texture_dest)?;
344            }
345        }
346
347        Ok(())
348    }
349}
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354    use libdqg::camera::Camera;
355    use libdqg::glam;
356    use libdqg::world::Transform;
357
358    fn temp_dir(name: &str) -> PathBuf {
359        let dir = std::env::temp_dir().join("libdqg_project_tests").join(name);
360        let _ = fs::remove_dir_all(&dir);
361        fs::create_dir_all(&dir).unwrap();
362        dir
363    }
364
365    #[test]
366    fn a_new_project_defaults_to_scenes_main_ron_as_its_start_scene() {
367        let project = Project::create(temp_dir("default_start_scene")).expect("project should be created");
368        assert_eq!(project.manifest.start_scene, PathBuf::from(DEFAULT_SCENE_FILE));
369        assert!(project.start_scene_path().is_file(), "the default start scene should exist on disk");
370    }
371
372    #[test]
373    fn create_scene_never_overwrites_an_existing_one() {
374        let project = Project::create(temp_dir("create_scene_no_overwrite")).expect("project should be created");
375
376        let first = project.create_scene().expect("first create_scene should succeed");
377        let second = project.create_scene().expect("second create_scene should succeed");
378
379        assert_ne!(first, second, "a second New Scene click shouldn't overwrite the first");
380        assert!(project.root.join(&first).is_file());
381        assert!(project.root.join(&second).is_file());
382    }
383
384    #[test]
385    fn list_scenes_includes_every_ron_file_in_the_scenes_dir() {
386        let project = Project::create(temp_dir("list_scenes")).expect("project should be created");
387        let extra = project.create_scene().expect("create_scene should succeed");
388
389        let scenes = project.list_scenes();
390
391        assert!(scenes.contains(&PathBuf::from(DEFAULT_SCENE_FILE)));
392        assert!(scenes.contains(&extra));
393    }
394
395    #[test]
396    fn set_start_scene_persists_across_reopening_the_project() {
397        let root = temp_dir("set_start_scene_persists");
398        let mut project = Project::create(root.clone()).expect("project should be created");
399        let extra = project.create_scene().expect("create_scene should succeed");
400
401        project.set_start_scene(extra.clone()).expect("set_start_scene should succeed");
402
403        let reopened = Project::open(root).expect("project should reopen");
404        assert_eq!(reopened.manifest.start_scene, extra);
405    }
406
407    /// Regression test: `ProjectManifest`'s `start_scene` field was added after this format
408    /// shipped, so a `project.ron` saved before that must still open, falling back to the same
409    /// default a brand new project gets rather than failing to deserialize.
410    #[test]
411    fn a_manifest_saved_before_start_scene_existed_still_opens() {
412        let root = temp_dir("manifest_missing_start_scene");
413        fs::write(root.join(MANIFEST_FILE), "(format_version: 1, name: \"Old Project\")").unwrap();
414
415        let project = Project::open(root).expect("a manifest predating start_scene should still open");
416
417        assert_eq!(project.manifest.start_scene, PathBuf::from(DEFAULT_SCENE_FILE));
418    }
419
420    #[test]
421    fn save_scene_then_load_scene_round_trips_at_a_given_path() {
422        let project = Project::create(temp_dir("save_load_round_trip")).expect("project should be created");
423        let scene_path = project.create_scene().expect("create_scene should succeed");
424        let mut world = World::new(Camera {
425            position: glam::Vec3::ZERO,
426            yaw: 0.0,
427            pitch: 0.0,
428            aspect: 1.0,
429            fov: 45.0,
430            znear: 0.1,
431            zfar: 100.0,
432        });
433        world.spawn_empty("Placeholder", Transform::default());
434
435        project.save_scene(&scene_path, &world, &HashMap::new()).expect("save_scene should succeed");
436        let loaded = project.load_scene(&scene_path).expect("load_scene should succeed");
437
438        assert_eq!(loaded.entities.len(), 1);
439        assert_eq!(loaded.entities[0].name, "Placeholder");
440    }
441}