BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / session.rs

session.rs

session.rs 23.1 KiB Download Raw
1//! Project navigation, dirty state and equipment records.
2//!
3//! The screen starts small. A project opens on Bodies. Equipment is one
4//! step down from Body. Chosen files are copied into the project so a later
5//! save stays inside the directory.
6
7use std::fs;
8use std::path::{Path, PathBuf};
9
10use serde::{Deserialize, Serialize};
11
12use crate::project::{
13 create_project, portable_relative, save_project, OpenedProject, ProjectError, ProjectFile,
14 MANIFEST_NAME,
15};
16
17const MALE_GLB: &[u8] = include_bytes!("../tests/fixtures/bodies/test-body-male.glb");
18const FEMALE_GLB: &[u8] = include_bytes!("../tests/fixtures/bodies/test-body-female.glb");
19
20pub const MALE_ASSET: &str = "assets/body/test-body-male.glb";
21pub const FEMALE_ASSET: &str = "assets/body/test-body-female.glb";
22
23pub const START_WINDOW: [f32; 2] = [480.0, 280.0];
24pub const PROJECT_WINDOW: [f32; 2] = [1100.0, 720.0];
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum Sex {
28 Male,
29 Female,
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum Screen {
34 Start,
35 Bodies,
36 Body(Sex),
37 EquipmentList,
38 Equipment(usize),
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum Intent {
43 Quit,
44 PickNew,
45 PickLoad,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum Choice {
50 Save,
51 Discard,
52 Cancel,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub enum AfterChoice {
57 Stay,
58 SaveThen(Intent),
59 Go(Intent),
60}
61
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63struct EquipmentFile {
64 name: String,
65 model: Option<String>,
66}
67
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct EquipmentItem {
70 pub slug: String,
71 pub model: Option<String>,
72 pub legacy_path: Option<String>,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct Session {
77 pub screen: Screen,
78 pub active_body: Option<Sex>,
79 pub opened: Option<OpenedProject>,
80 pub saved_document: Option<ProjectFile>,
81 pub equipment: Vec<EquipmentItem>,
82 pub saved_equipment: Vec<EquipmentItem>,
83 pub confirm: Option<Intent>,
84 pub notice: String,
85 pub notice_ok: bool,
86}
87
88impl Session {
89 pub fn new() -> Self {
90 Self {
91 screen: Screen::Start,
92 active_body: None,
93 opened: None,
94 saved_document: None,
95 equipment: Vec::new(),
96 saved_equipment: Vec::new(),
97 confirm: None,
98 notice: "Laad een project of maak een nieuwe projectmap.".to_string(),
99 notice_ok: true,
100 }
101 }
102
103 pub fn shell_size(&self) -> [f32; 2] {
104 match self.screen {
105 Screen::Start => START_WINDOW,
106 _ => PROJECT_WINDOW,
107 }
108 }
109
110 pub fn is_dirty(&self) -> bool {
111 self.opened.as_ref().map(|opened| &opened.document) != self.saved_document.as_ref()
112 || self.equipment != self.saved_equipment
113 }
114
115 pub fn gate(&mut self, intent: Intent) -> bool {
116 if self.is_dirty() {
117 self.confirm = Some(intent);
118 false
119 } else {
120 true
121 }
122 }
123
124 pub fn resolve(&mut self, choice: Choice) -> AfterChoice {
125 let Some(intent) = self.confirm.take() else {
126 return AfterChoice::Stay;
127 };
128 match choice {
129 Choice::Cancel => {
130 self.confirm = None;
131 AfterChoice::Stay
132 }
133 Choice::Discard => AfterChoice::Go(intent),
134 Choice::Save => {
135 self.confirm = Some(intent);
136 AfterChoice::SaveThen(intent)
137 }
138 }
139 }
140
141 pub fn note_saved(&mut self) {
142 if let Some(opened) = &self.opened {
143 self.saved_document = Some(opened.document.clone());
144 }
145 self.saved_equipment = self.equipment.clone();
146 self.confirm = None;
147 }
148
149 pub fn show_bodies(&mut self) {
150 if self.opened.is_some() {
151 self.screen = Screen::Bodies;
152 }
153 }
154
155 pub fn show_body(&mut self, sex: Sex) {
156 if self.opened.is_some() {
157 self.active_body = Some(sex);
158 self.screen = Screen::Body(sex);
159 }
160 }
161
162 pub fn show_equipment_list(&mut self) {
163 if self.opened.is_some() {
164 self.screen = Screen::EquipmentList;
165 }
166 }
167
168 pub fn show_equipment(&mut self, index: usize) {
169 if index < self.equipment.len() {
170 self.screen = Screen::Equipment(index);
171 }
172 }
173
174 pub fn back(&mut self) {
175 self.screen = match self.screen {
176 Screen::Body(_) => Screen::Bodies,
177 Screen::EquipmentList => self.active_body.map(Screen::Body).unwrap_or(Screen::Bodies),
178 Screen::Equipment(_) => Screen::EquipmentList,
179 other => other,
180 };
181 }
182
183 pub fn set_name(&mut self, name: String) {
184 if let Some(opened) = &mut self.opened {
185 opened.document.name = name;
186 }
187 }
188
189 pub fn set_render(&mut self, width: u32, height: u32, fps: u32) {
190 if let Some(opened) = &mut self.opened {
191 opened.document.render.target_width = width;
192 opened.document.render.target_height = height;
193 opened.document.render.fps = fps;
194 }
195 }
196
197 pub fn body_path(&self, sex: Sex) -> Option<&str> {
198 let document = &self.opened.as_ref()?.document;
199 match sex {
200 Sex::Male => document.male_model.as_deref(),
201 Sex::Female => document.female_model.as_deref(),
202 }
203 }
204
205 pub fn set_body_path(&mut self, sex: Sex, relative: String) {
206 let Some(opened) = &mut self.opened else {
207 return;
208 };
209 let slot = match sex {
210 Sex::Male => &mut opened.document.male_model,
211 Sex::Female => &mut opened.document.female_model,
212 };
213 *slot = Some(relative);
214 }
215
216 pub fn add_equipment(&mut self) -> usize {
217 let slug = next_slug(&self.equipment);
218 self.equipment.push(EquipmentItem {
219 slug,
220 model: None,
221 legacy_path: None,
222 });
223 let index = self.equipment.len() - 1;
224 self.screen = Screen::Equipment(index);
225 index
226 }
227
228 pub fn set_equipment_slug(&mut self, index: usize, slug: String) -> Result<(), String> {
229 let slug = slug.trim().to_string();
230 if !valid_slug(&slug) {
231 return Err(
232 "de naam gebruikt kleine letters, cijfers, '-' of '_', en is niet 'body'"
233 .to_string(),
234 );
235 }
236 if self
237 .equipment
238 .iter()
239 .enumerate()
240 .any(|(other, item)| other != index && item.slug == slug)
241 {
242 return Err("die equipmentnaam bestaat al".to_string());
243 }
244 if let Some(item) = self.equipment.get_mut(index) {
245 item.slug = slug;
246 item.legacy_path = None;
247 }
248 Ok(())
249 }
250
251 pub fn set_equipment_model(&mut self, index: usize, relative: String) {
252 if let Some(item) = self.equipment.get_mut(index) {
253 item.model = Some(relative);
254 item.legacy_path = None;
255 }
256 }
257
258 pub fn delete_equipment(&mut self, index: usize) {
259 if index < self.equipment.len() {
260 self.equipment.remove(index);
261 self.screen = Screen::EquipmentList;
262 }
263 }
264
265 pub fn install_new_project(&mut self, root: &Path) -> Result<(), ProjectError> {
266 let opened = create_seeded_project(root)?;
267 self.install_opened(opened);
268 self.ok("Projectmap aangemaakt, met de testlichamen in assets/body.");
269 Ok(())
270 }
271
272 pub fn install_loaded(&mut self, opened: OpenedProject) {
273 let root = opened
274 .manifest_path
275 .parent()
276 .map(Path::to_path_buf)
277 .unwrap_or_default();
278 let equipment = read_equipment(&root, &opened.document.equipment_presets);
279 self.equipment = equipment.clone();
280 self.saved_equipment = equipment;
281 self.saved_document = Some(opened.document.clone());
282 self.opened = Some(opened);
283 self.screen = Screen::Bodies;
284 self.active_body = None;
285 self.confirm = None;
286 self.ok("Project geopend.");
287 }
288
289 fn install_opened(&mut self, opened: OpenedProject) {
290 self.equipment.clear();
291 self.saved_equipment.clear();
292 self.saved_document = Some(opened.document.clone());
293 self.opened = Some(opened);
294 self.screen = Screen::Bodies;
295 self.active_body = None;
296 self.confirm = None;
297 }
298
299 pub fn save(&mut self) -> Result<(), ProjectError> {
300 let Some(opened) = self.opened.clone() else {
301 return Err(ProjectError::Validation(vec![
302 "er is geen project om op te slaan".to_string(),
303 ]));
304 };
305 let root = opened
306 .manifest_path
307 .parent()
308 .ok_or_else(|| ProjectError::Io {
309 path: opened.manifest_path.display().to_string(),
310 message: "het .ppd-bestand heeft geen projectmap".to_string(),
311 })?;
312 opened.document.validate_shape()?;
313 let presets = write_equipment(root, &self.equipment)
314 .map_err(|message| ProjectError::Validation(vec![message]))?;
315 let mut document = opened.document.clone();
316 document.equipment_presets = presets;
317 document.validate_shape()?;
318 let candidate = OpenedProject {
319 manifest_path: opened.manifest_path.clone(),
320 document,
321 disk_issues: Vec::new(),
322 };
323 save_project(&candidate)?;
324 self.opened = Some(candidate);
325 self.note_saved();
326 self.ok("Opgeslagen.");
327 Ok(())
328 }
329
330 pub fn project_root(&self) -> Option<PathBuf> {
331 self.opened
332 .as_ref()?
333 .manifest_path
334 .parent()
335 .map(PathBuf::from)
336 }
337
338 fn ok(&mut self, message: &str) {
339 self.notice = message.to_string();
340 self.notice_ok = true;
341 }
342
343 pub fn fail(&mut self, message: impl Into<String>) {
344 self.notice = message.into();
345 self.notice_ok = false;
346 }
347}
348
349impl Default for Session {
350 fn default() -> Self {
351 Self::new()
352 }
353}
354
355pub fn create_seeded_project(root: &Path) -> Result<OpenedProject, ProjectError> {
356 let mut opened = create_project(root, "Nieuw project")?;
357 for (relative, bytes) in [
358 (MALE_ASSET, MALE_GLB),
359 (FEMALE_ASSET, FEMALE_GLB),
360 (
361 "assets/body/test-bodies-LICENSE.txt",
362 include_bytes!("../tests/fixtures/bodies/LICENSE").as_slice(),
363 ),
364 ] {
365 let path = checked_project_path(root, relative)
366 .map_err(|error| ProjectError::Validation(vec![error]))?;
367 if !path.exists() {
368 write_bytes(&path, bytes)?;
369 }
370 }
371 opened.document.male_model = Some(MALE_ASSET.to_string());
372 opened.document.female_model = Some(FEMALE_ASSET.to_string());
373 save_project(&opened)?;
374 Ok(opened)
375}
376
377pub fn adopt_file(root: &Path, source: &Path, folder: &str) -> Result<String, String> {
378 if !source.is_file() {
379 return Err("kies een bestand".to_string());
380 }
381 let name = source
382 .file_name()
383 .and_then(|name| name.to_str())
384 .filter(|name| {
385 !name.is_empty() && !name.contains('/') && !name.contains('\\') && !name.contains('\0')
386 })
387 .ok_or_else(|| "de bestandsnaam is niet bruikbaar".to_string())?;
388 if let (Ok(source_full), Ok(root_full)) = (source.canonicalize(), root.canonicalize()) {
389 if let Ok(inside) = source_full.strip_prefix(&root_full) {
390 let relative = inside.to_string_lossy().replace('\\', "/");
391 portable_relative(&relative)?;
392 return Ok(relative);
393 }
394 }
395 let mut relative = format!("{folder}/{name}");
396 let stem = Path::new(name).file_stem().unwrap().to_string_lossy();
397 let ext = Path::new(name)
398 .extension()
399 .map(|x| format!(".{}", x.to_string_lossy()))
400 .unwrap_or_default();
401 let mut n = 2;
402 while root.join(&relative).exists() {
403 relative = format!("{folder}/{stem}-{n}{ext}");
404 n += 1;
405 }
406 portable_relative(&relative)?;
407 let dest = checked_project_path(root, &relative)?;
408 if let Some(parent) = dest.parent() {
409 fs::create_dir_all(parent).map_err(|err| err.to_string())?;
410 }
411 fs::copy(source, &dest).map_err(|err| err.to_string())?;
412 Ok(relative)
413}
414
415fn checked_project_path(root: &Path, relative: &str) -> Result<PathBuf, String> {
416 portable_relative(relative)?;
417 let root_full = root.canonicalize().map_err(|e| e.to_string())?;
418 let path = root.join(relative);
419 let mut ancestor = path.as_path();
420 while std::fs::symlink_metadata(ancestor).is_err() {
421 ancestor = ancestor.parent().ok_or("ongeldig projectpad")?;
422 }
423 let canonical = ancestor.canonicalize().map_err(|e| e.to_string())?;
424 if !canonical.starts_with(&root_full) {
425 return Err("het bestand ligt buiten de projectmap".into());
426 }
427 Ok(path)
428}
429
430fn write_bytes(path: &Path, bytes: &[u8]) -> Result<(), ProjectError> {
431 if let Some(parent) = path.parent() {
432 fs::create_dir_all(parent).map_err(|err| io_at(path, &err.to_string()))?;
433 }
434 fs::write(path, bytes).map_err(|err| io_at(path, &err.to_string()))
435}
436
437fn io_at(path: &Path, message: &str) -> ProjectError {
438 ProjectError::Io {
439 path: path.display().to_string(),
440 message: message.to_string(),
441 }
442}
443
444fn read_equipment(root: &Path, presets: &[String]) -> Vec<EquipmentItem> {
445 presets
446 .iter()
447 .map(|path| {
448 let file = match checked_project_path(root, path) {
449 Ok(path) => path,
450 Err(_) => {
451 return EquipmentItem {
452 slug: "onveilig".into(),
453 model: None,
454 legacy_path: Some(path.clone()),
455 }
456 }
457 };
458 if let Ok(text) = fs::read_to_string(&file) {
459 if let Ok(parsed) = serde_json::from_str::<EquipmentFile>(&text) {
460 if valid_slug(&parsed.name) {
461 return EquipmentItem {
462 slug: parsed.name,
463 model: parsed.model,
464 legacy_path: None,
465 };
466 }
467 }
468 }
469 let slug = path
470 .rsplit('/')
471 .next()
472 .unwrap_or(path)
473 .split('.')
474 .next()
475 .unwrap_or("item")
476 .to_string();
477 EquipmentItem {
478 slug,
479 model: None,
480 legacy_path: Some(path.clone()),
481 }
482 })
483 .collect()
484}
485
486fn write_equipment(root: &Path, items: &[EquipmentItem]) -> Result<Vec<String>, String> {
487 let mut paths = Vec::new();
488 let mut names = std::collections::BTreeSet::new();
489 // Validate every record before writing. Config files are immutable snapshots:
490 // a failed manifest save leaves its previous equipment bytes intact.
491 for item in items {
492 if !names.insert(&item.slug) {
493 return Err("dubbele equipmentnaam".into());
494 }
495 if item.legacy_path.is_some() && item.model.is_none() {
496 continue;
497 }
498 if !valid_slug(&item.slug) {
499 return Err(format!("equipment '{}' heeft geen geldige naam", item.slug));
500 }
501 if let Some(model) = &item.model {
502 portable_relative(model)?;
503 }
504 }
505 for item in items {
506 if item.model.is_none() {
507 if let Some(legacy) = &item.legacy_path {
508 paths.push(legacy.clone());
509 continue;
510 }
511 }
512 let file = EquipmentFile {
513 name: item.slug.clone(),
514 model: item.model.clone(),
515 };
516 let text = serde_json::to_string_pretty(&file).map_err(|err| err.to_string())?;
517 let mut number = 1;
518 loop {
519 let relative = format!("presets/{}-{number}.json", item.slug);
520 let path = checked_project_path(root, &relative)?;
521 if let Ok(existing) = fs::read_to_string(&path) {
522 if existing == text {
523 paths.push(relative);
524 break;
525 }
526 } else if !path.exists() {
527 fs::create_dir_all(path.parent().unwrap()).map_err(|err| err.to_string())?;
528 use std::io::Write;
529 let mut out = fs::OpenOptions::new()
530 .write(true)
531 .create_new(true)
532 .open(&path)
533 .map_err(|err| err.to_string())?;
534 out.write_all(text.as_bytes())
535 .map_err(|err| err.to_string())?;
536 out.sync_all().map_err(|err| err.to_string())?;
537 paths.push(relative);
538 break;
539 }
540 number += 1;
541 }
542 }
543 Ok(paths)
544}
545
546fn next_slug(items: &[EquipmentItem]) -> String {
547 let mut number = 1u32;
548 loop {
549 let slug = if number == 1 {
550 "nieuw".to_string()
551 } else {
552 format!("nieuw-{number}")
553 };
554 if !items.iter().any(|item| item.slug == slug) {
555 return slug;
556 }
557 number += 1;
558 }
559}
560
561fn valid_slug(slug: &str) -> bool {
562 !slug.is_empty()
563 && slug != "body"
564 && slug
565 .chars()
566 .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-' || ch == '_')
567}
568
569pub fn manifest_in(root: &Path) -> PathBuf {
570 root.join(MANIFEST_NAME)
571}
572
573#[cfg(test)]
574mod tests {
575 use super::*;
576 use std::time::{SystemTime, UNIX_EPOCH};
577
578 fn scratch() -> PathBuf {
579 let nanos = SystemTime::now()
580 .duration_since(UNIX_EPOCH)
581 .unwrap()
582 .as_nanos();
583 let path = std::env::temp_dir().join(format!("paperdoll-session-{nanos}"));
584 let _ = fs::remove_dir_all(&path);
585 path
586 }
587
588 #[test]
589 fn new_project_copies_both_test_bodies_and_can_be_overwritten() {
590 let root = scratch();
591 let mut session = Session::new();
592 session.install_new_project(&root).unwrap();
593 assert_eq!(session.screen, Screen::Bodies);
594 assert!(!session.is_dirty());
595 assert_eq!(fs::read(root.join(MALE_ASSET)).unwrap(), MALE_GLB);
596 assert_eq!(fs::read(root.join(FEMALE_ASSET)).unwrap(), FEMALE_GLB);
597 let outside = root
598 .parent()
599 .unwrap()
600 .join(format!("pd-src-{}", std::process::id()));
601 let _ = fs::remove_dir_all(&outside);
602 fs::create_dir_all(&outside).unwrap();
603 let source = outside.join("test-body-male.glb");
604 fs::write(&source, b"replacement-body").unwrap();
605 let relative = adopt_file(&root, &source, "assets/body").unwrap();
606 assert_ne!(relative, MALE_ASSET);
607 assert_eq!(fs::read(root.join(&relative)).unwrap(), b"replacement-body");
608 assert_eq!(fs::read(root.join(MALE_ASSET)).unwrap(), MALE_GLB);
609 let extra = outside.join("extra.glb");
610 fs::write(&extra, b"extra-body").unwrap();
611 let relative = adopt_file(&root, &extra, "assets/body").unwrap();
612 session.set_body_path(Sex::Male, relative);
613 assert!(session.is_dirty());
614 session.save().unwrap();
615 assert!(!session.is_dirty());
616 assert_eq!(
617 fs::read(root.join("assets/body/extra.glb")).unwrap(),
618 b"extra-body"
619 );
620 let _ = fs::remove_dir_all(&outside);
621 let _ = fs::remove_dir_all(&root);
622 }
623
624 #[test]
625 fn navigation_and_equipment_crud_roundtrip() {
626 let root = scratch();
627 let mut session = Session::new();
628 session.show_body(Sex::Male);
629 assert_eq!(session.screen, Screen::Start);
630 session.install_new_project(&root).unwrap();
631 session.show_body(Sex::Female);
632 assert_eq!(session.screen, Screen::Body(Sex::Female));
633 session.show_equipment_list();
634 assert_eq!(session.screen, Screen::EquipmentList);
635 let index = session.add_equipment();
636 assert_eq!(index, 0);
637 session.set_equipment_model(0, "assets/equipment/zwaard.glb".to_string());
638 session.save().unwrap();
639 let text = fs::read_to_string(root.join("presets/nieuw-1.json")).unwrap();
640 assert!(text.contains("zwaard.glb"));
641 let mut again = Session::new();
642 let opened = crate::project::open_project(&manifest_in(&root)).unwrap();
643 again.install_loaded(opened);
644 assert_eq!(
645 again.equipment[0].model.as_deref(),
646 Some("assets/equipment/zwaard.glb")
647 );
648 again.delete_equipment(0);
649 again.save().unwrap();
650 assert!(again
651 .opened
652 .as_ref()
653 .unwrap()
654 .document
655 .equipment_presets
656 .is_empty());
657 // Old immutable record stays recoverable if an earlier save is restored.
658 assert!(root.join("presets/nieuw-1.json").exists());
659 let _ = fs::remove_dir_all(&root);
660 }
661
662 #[test]
663 fn failed_save_preserves_previous_equipment_and_manifest_bytes() {
664 let root = scratch();
665 let mut session = Session::new();
666 session.install_new_project(&root).unwrap();
667 session.add_equipment();
668 session.save().unwrap();
669 let manifest = fs::read(manifest_in(&root)).unwrap();
670 let relative = session.opened.as_ref().unwrap().document.equipment_presets[0].clone();
671 let preset = fs::read(root.join(&relative)).unwrap();
672 session.equipment[0].slug = "changed".into();
673 // Missing body fails after immutable equipment snapshots are written.
674 session.set_body_path(Sex::Male, "assets/body/missing.glb".into());
675 assert!(session.save().is_err());
676 assert_eq!(fs::read(manifest_in(&root)).unwrap(), manifest);
677 assert_eq!(fs::read(root.join(&relative)).unwrap(), preset);
678 assert!(session.is_dirty());
679 let _ = fs::remove_dir_all(root);
680 }
681
682 #[test]
683 fn dirty_confirm_save_discard_and_cancel_keep_bytes() {
684 let root = scratch();
685 let mut session = Session::new();
686 session.install_new_project(&root).unwrap();
687 session.set_name("Gewijzigd".to_string());
688 assert!(session.is_dirty());
689 assert!(!session.gate(Intent::Quit));
690 assert_eq!(session.resolve(Choice::Cancel), AfterChoice::Stay);
691 assert_eq!(session.opened.as_ref().unwrap().document.name, "Gewijzigd");
692 assert!(!session.gate(Intent::PickLoad));
693 assert_eq!(
694 session.resolve(Choice::Discard),
695 AfterChoice::Go(Intent::PickLoad)
696 );
697 assert_eq!(session.opened.as_ref().unwrap().document.name, "Gewijzigd");
698 assert!(session.is_dirty()); // canceled file picker must not discard the old project
699 session.set_name("Bewaard".to_string());
700 assert!(!session.gate(Intent::PickNew));
701 assert_eq!(
702 session.resolve(Choice::Save),
703 AfterChoice::SaveThen(Intent::PickNew)
704 );
705 session.save().unwrap();
706 assert_eq!(session.opened.as_ref().unwrap().document.name, "Bewaard");
707 let opened = crate::project::open_project(&manifest_in(&root)).unwrap();
708 assert_eq!(opened.document.name, "Bewaard");
709 let _ = fs::remove_dir_all(&root);
710 }
711}