1use std::collections::HashMap;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use libdqg::ecs::Entity;
6use libdqg::world::World;
7
8pub 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
20fn to_portable_path(path: &Path) -> PathBuf {
33 PathBuf::from(path.to_string_lossy().replace('\\', "/"))
34}
35
36const 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
52pub 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 #[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 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 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 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 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 pub fn load_scene(&self, path: &Path) -> anyhow::Result<SceneFile> {
152 SceneFile::load(&self.root.join(path))
153 }
154
155 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 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 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 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 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 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 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 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 #[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}