BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / session.rs

session.rs

session.rs 37.4 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 crate::equipment::Preset;
11use serde::{Deserialize, Serialize};
12
13use crate::project::{
14 create_project, portable_relative, save_project, OpenedProject, ProjectError, ProjectFile,
15 MANIFEST_NAME,
16};
17
18const MALE_GLB: &[u8] = include_bytes!("../tests/fixtures/bodies/test-body-male.glb");
19const FEMALE_GLB: &[u8] = include_bytes!("../tests/fixtures/bodies/test-body-female.glb");
20
21pub const MALE_ASSET: &str = "assets/body/test-body-male.glb";
22pub const FEMALE_ASSET: &str = "assets/body/test-body-female.glb";
23
24pub const START_WINDOW: [f32; 2] = [480.0, 280.0];
25pub const PROJECT_WINDOW: [f32; 2] = [1100.0, 720.0];
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum Sex {
29 Male,
30 Female,
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum Screen {
35 Start,
36 Bodies,
37 Body(Sex),
38 EquipmentList,
39 Equipment(usize),
40 Preview2d,
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum Intent {
45 Quit,
46 PickNew,
47 PickLoad,
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum Choice {
52 Save,
53 Discard,
54 Cancel,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub enum AfterChoice {
59 Stay,
60 SaveThen(Intent),
61 Go(Intent),
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65#[serde(deny_unknown_fields)]
66struct EquipmentFile {
67 name: String,
68 model: Option<String>,
69}
70
71#[derive(Debug, Clone, PartialEq)]
72pub struct EquipmentItem {
73 pub slug: String,
74 pub model: Option<String>,
75 pub legacy_path: Option<String>,
76 pub preset: Option<Preset>,
77 pub error: Option<String>,
78}
79
80#[derive(Debug, Clone, PartialEq)]
81pub struct Session {
82 pub screen: Screen,
83 pub active_body: Option<Sex>,
84 pub opened: Option<OpenedProject>,
85 pub saved_document: Option<ProjectFile>,
86 pub equipment: Vec<EquipmentItem>,
87 pub saved_equipment: Vec<EquipmentItem>,
88 pub confirm: Option<Intent>,
89 pub notice: String,
90 pub notice_ok: bool,
91}
92
93impl Session {
94 pub fn new() -> Self {
95 Self {
96 screen: Screen::Start,
97 active_body: None,
98 opened: None,
99 saved_document: None,
100 equipment: Vec::new(),
101 saved_equipment: Vec::new(),
102 confirm: None,
103 notice: "Laad een project of maak een nieuwe projectmap.".to_string(),
104 notice_ok: true,
105 }
106 }
107
108 pub fn shell_size(&self) -> [f32; 2] {
109 match self.screen {
110 Screen::Start => START_WINDOW,
111 _ => PROJECT_WINDOW,
112 }
113 }
114
115 pub fn is_dirty(&self) -> bool {
116 self.opened.as_ref().map(|opened| &opened.document) != self.saved_document.as_ref()
117 || self.equipment != self.saved_equipment
118 }
119
120 pub fn gate(&mut self, intent: Intent) -> bool {
121 if self.is_dirty() {
122 self.confirm = Some(intent);
123 false
124 } else {
125 true
126 }
127 }
128
129 pub fn resolve(&mut self, choice: Choice) -> AfterChoice {
130 let Some(intent) = self.confirm.take() else {
131 return AfterChoice::Stay;
132 };
133 match choice {
134 Choice::Cancel => {
135 self.confirm = None;
136 AfterChoice::Stay
137 }
138 Choice::Discard => AfterChoice::Go(intent),
139 Choice::Save => {
140 self.confirm = Some(intent);
141 AfterChoice::SaveThen(intent)
142 }
143 }
144 }
145
146 pub fn note_saved(&mut self) {
147 if let Some(opened) = &self.opened {
148 self.saved_document = Some(opened.document.clone());
149 }
150 self.saved_equipment = self.equipment.clone();
151 self.confirm = None;
152 }
153
154 pub fn show_bodies(&mut self) {
155 if self.opened.is_some() {
156 self.screen = Screen::Bodies;
157 }
158 }
159
160 pub fn show_body(&mut self, sex: Sex) {
161 if self.opened.is_some() {
162 self.active_body = Some(sex);
163 self.screen = Screen::Body(sex);
164 }
165 }
166
167 pub fn show_equipment_list(&mut self) {
168 if self.opened.is_some() {
169 self.screen = Screen::EquipmentList;
170 }
171 }
172
173 pub fn show_equipment(&mut self, index: usize) {
174 if index < self.equipment.len() {
175 self.screen = Screen::Equipment(index);
176 }
177 }
178
179 pub fn show_preview2d(&mut self) {
180 if self.opened.is_some() {
181 self.screen = Screen::Preview2d;
182 }
183 }
184
185 pub fn back(&mut self) {
186 self.screen = match self.screen {
187 Screen::Body(_) => Screen::Bodies,
188 Screen::EquipmentList => self.active_body.map(Screen::Body).unwrap_or(Screen::Bodies),
189 Screen::Equipment(_) => Screen::EquipmentList,
190 Screen::Preview2d => Screen::Bodies,
191 other => other,
192 };
193 }
194
195 pub fn set_name(&mut self, name: String) {
196 if let Some(opened) = &mut self.opened {
197 opened.document.name = name;
198 }
199 }
200
201 pub fn set_render(&mut self, width: u32, height: u32, fps: u32) {
202 if let Some(opened) = &mut self.opened {
203 opened.document.render.target_width = width;
204 opened.document.render.target_height = height;
205 opened.document.render.fps = fps;
206 }
207 }
208
209 pub fn body_path(&self, sex: Sex) -> Option<&str> {
210 let document = &self.opened.as_ref()?.document;
211 match sex {
212 Sex::Male => document.male_model.as_deref(),
213 Sex::Female => document.female_model.as_deref(),
214 }
215 }
216
217 pub fn set_body_path(&mut self, sex: Sex, relative: String) {
218 let Some(opened) = &mut self.opened else {
219 return;
220 };
221 let slot = match sex {
222 Sex::Male => &mut opened.document.male_model,
223 Sex::Female => &mut opened.document.female_model,
224 };
225 *slot = Some(relative);
226 }
227
228 pub fn add_equipment(&mut self) -> usize {
229 let slug = next_slug(&self.equipment);
230 self.equipment.push(EquipmentItem {
231 preset: Some(Preset::new(slug.clone())),
232 error: None,
233 slug,
234 model: None,
235 legacy_path: None,
236 });
237 let index = self.equipment.len() - 1;
238 self.screen = Screen::Equipment(index);
239 index
240 }
241
242 pub fn ensure_preset_budget(
243 &self,
244 replacement: Option<usize>,
245 candidate: &Preset,
246 ) -> Result<(), String> {
247 if replacement.is_none() && self.equipment.len() >= crate::project::MAX_PROJECT_PRESETS {
248 return Err("maximaal 64 equipmentpresets per project".into());
249 }
250 let mut budget = MAX_PROJECT_PRESET_BYTES
251 .checked_sub(candidate.encoded_len()?)
252 .ok_or("equipmentpresets samen groter dan 64 MiB")?;
253 for (i, item) in self.equipment.iter().enumerate() {
254 if Some(i) != replacement {
255 if let Some(p) = &item.preset {
256 budget = budget
257 .checked_sub(p.encoded_len()?)
258 .ok_or("equipmentpresets samen groter dan 64 MiB")?;
259 }
260 }
261 }
262 Ok(())
263 }
264 pub fn available_equipment_name(&self, base: &str) -> Result<String, String> {
265 if !valid_slug(base) {
266 return Err("ongeldige equipmentnaam".into());
267 }
268 let mut name = base.to_string();
269 let mut n = 2;
270 while self.equipment.iter().any(|i| i.slug == name) {
271 let suffix = format!("-{n}");
272 name = format!("{}{}", &base[..base.len().min(64 - suffix.len())], suffix);
273 n += 1;
274 }
275 Ok(name)
276 }
277
278 pub fn set_equipment_slug(&mut self, index: usize, slug: String) -> Result<(), String> {
279 let slug = slug.trim().to_string();
280 if !valid_slug(&slug) {
281 return Err(
282 "de naam gebruikt kleine letters, cijfers, '-' of '_', en is niet 'body'"
283 .to_string(),
284 );
285 }
286 if self
287 .equipment
288 .iter()
289 .enumerate()
290 .any(|(other, item)| other != index && item.slug == slug)
291 {
292 return Err("die equipmentnaam bestaat al".to_string());
293 }
294 if let Some(item) = self.equipment.get_mut(index) {
295 item.slug = slug;
296 item.legacy_path = None;
297 }
298 Ok(())
299 }
300
301 pub fn set_equipment_model(&mut self, index: usize, relative: String) {
302 if let Some(item) = self.equipment.get_mut(index) {
303 item.model = Some(relative);
304 item.legacy_path = None;
305 item.preset = None;
306 item.error = None;
307 }
308 }
309
310 pub fn delete_equipment(&mut self, index: usize) {
311 if index < self.equipment.len() {
312 self.equipment.remove(index);
313 self.screen = Screen::EquipmentList;
314 }
315 }
316
317 pub fn install_new_project(&mut self, root: &Path) -> Result<(), ProjectError> {
318 let opened = create_seeded_project(root)?;
319 self.install_opened(opened);
320 self.ok("Projectmap aangemaakt, met de testlichamen in assets/body.");
321 Ok(())
322 }
323
324 /// Create `<parent>/<name>/` (validated) and open it as a new project.
325 pub fn install_named_project(&mut self, parent: &Path, name: &str) -> Result<(), String> {
326 let dir = crate::project::new_project_dir(parent, name)?;
327 let opened = create_default_project_named(&dir, name.trim()).map_err(|e| e.to_string())?;
328 // install_loaded also reads the bundled equipment preset(s).
329 self.install_loaded(opened);
330 self.ok("Project aangemaakt, met de standaardlichamen in assets/body/male en female.");
331 Ok(())
332 }
333
334 pub fn install_loaded(&mut self, opened: OpenedProject) {
335 let root = opened
336 .manifest_path
337 .parent()
338 .map(Path::to_path_buf)
339 .unwrap_or_default();
340 let equipment = read_equipment(&root, &opened.document.equipment_presets);
341 self.equipment = equipment.clone();
342 self.saved_equipment = equipment;
343 self.saved_document = Some(opened.document.clone());
344 self.opened = Some(opened);
345 self.screen = Screen::Bodies;
346 self.active_body = None;
347 self.confirm = None;
348 self.ok("Project geopend.");
349 }
350
351 fn install_opened(&mut self, opened: OpenedProject) {
352 self.equipment.clear();
353 self.saved_equipment.clear();
354 self.saved_document = Some(opened.document.clone());
355 self.opened = Some(opened);
356 self.screen = Screen::Bodies;
357 self.active_body = None;
358 self.confirm = None;
359 }
360
361 pub fn save(&mut self) -> Result<(), ProjectError> {
362 let Some(opened) = self.opened.clone() else {
363 return Err(ProjectError::Validation(vec![
364 "er is geen project om op te slaan".to_string(),
365 ]));
366 };
367 let root = opened
368 .manifest_path
369 .parent()
370 .ok_or_else(|| ProjectError::Io {
371 path: opened.manifest_path.display().to_string(),
372 message: "het .ppd-bestand heeft geen projectmap".to_string(),
373 })?;
374 opened.document.validate_shape()?;
375 let presets = write_equipment(root, &self.equipment)
376 .map_err(|message| ProjectError::Validation(vec![message]))?;
377 let mut document = opened.document.clone();
378 document.equipment_presets = presets;
379 document.validate_shape()?;
380 let candidate = OpenedProject {
381 manifest_path: opened.manifest_path.clone(),
382 document,
383 disk_issues: Vec::new(),
384 };
385 save_project(&candidate)?;
386 for item in &mut self.equipment {
387 if let Some(preset) = &mut item.preset {
388 preset.name = item.slug.clone();
389 }
390 }
391 self.opened = Some(candidate);
392 self.note_saved();
393 self.ok("Opgeslagen.");
394 Ok(())
395 }
396
397 pub fn project_root(&self) -> Option<PathBuf> {
398 self.opened
399 .as_ref()?
400 .manifest_path
401 .parent()
402 .map(PathBuf::from)
403 }
404
405 fn ok(&mut self, message: &str) {
406 self.notice = message.to_string();
407 self.notice_ok = true;
408 }
409
410 pub fn fail(&mut self, message: impl Into<String>) {
411 self.notice = message.into();
412 self.notice_ok = false;
413 }
414}
415
416impl Default for Session {
417 fn default() -> Self {
418 Self::new()
419 }
420}
421
422pub fn create_seeded_project(root: &Path) -> Result<OpenedProject, ProjectError> {
423 create_seeded_project_named(root, "Nieuw project")
424}
425
426pub fn create_seeded_project_named(root: &Path, name: &str) -> Result<OpenedProject, ProjectError> {
427 let mut opened = create_project(root, name)?;
428 for (relative, bytes) in [
429 (MALE_ASSET, MALE_GLB),
430 (FEMALE_ASSET, FEMALE_GLB),
431 (
432 "assets/body/test-bodies-LICENSE.txt",
433 include_bytes!("../tests/fixtures/bodies/LICENSE").as_slice(),
434 ),
435 ] {
436 let path = checked_project_path(root, relative)
437 .map_err(|error| ProjectError::Validation(vec![error]))?;
438 if !path.exists() {
439 write_bytes(&path, bytes)?;
440 }
441 }
442 opened.document.male_model = Some(MALE_ASSET.to_string());
443 opened.document.female_model = Some(FEMALE_ASSET.to_string());
444 save_project(&opened)?;
445 Ok(opened)
446}
447
448/// A new project with Tim's bundled starter bodies (Tim t1009u) in
449/// `assets/body/male/` and `female/`, each with its `license.txt`, set as the
450/// male and female body.
451pub fn create_default_project_named(
452 root: &Path,
453 name: &str,
454) -> Result<OpenedProject, ProjectError> {
455 let mut opened = create_project(root, name)?;
456 for (relative, bytes) in crate::default_bodies::FILES {
457 let path = checked_project_path(root, &format!("assets/body/{relative}"))
458 .map_err(|error| ProjectError::Validation(vec![error]))?;
459 if let Some(parent) = path.parent() {
460 fs::create_dir_all(parent).map_err(|e| ProjectError::Io {
461 path: parent.display().to_string(),
462 message: e.to_string(),
463 })?;
464 }
465 if !path.exists() {
466 write_bytes(&path, bytes)?;
467 }
468 }
469 opened.document.male_model = Some(crate::default_bodies::MALE_MODEL.to_string());
470 opened.document.female_model = Some(crate::default_bodies::FEMALE_MODEL.to_string());
471 // Fit animations already applied (t1022u): the one-shot envelope of the
472 // editor button, per sex, for the bundled bodies and project defaults.
473 let render = &opened.document.render;
474 let aspect = render.target_width.max(1) as f32 / render.target_height.max(1) as f32;
475 let fps = render.fps;
476 for (index, model) in [
477 crate::default_bodies::MALE_MODEL,
478 crate::default_bodies::FEMALE_MODEL,
479 ]
480 .into_iter()
481 .enumerate()
482 {
483 let body = crate::preview::load_body_file(&root.join(model)).map_err(|message| {
484 ProjectError::Io {
485 path: model.to_string(),
486 message,
487 }
488 })?;
489 let camera = &mut opened.document.render.framing[index];
490 if let Some((h, c)) =
491 crate::camera::animation_envelope(&body, *camera, aspect, fps, 0.05, 0.)
492 {
493 camera.height_milli = h.clamp(100, 10_000);
494 camera.center_y_milli = c.clamp(-10_000, 10_000);
495 }
496 }
497 // Bundled female hair preset (t1019u), listed in the equipment presets.
498 let path = checked_project_path(root, crate::default_bodies::FEMALE_HAIR_PRESET)
499 .map_err(|error| ProjectError::Validation(vec![error]))?;
500 if !path.exists() {
501 write_bytes(&path, crate::default_bodies::FEMALE_HAIR_PPE)?;
502 }
503 opened
504 .document
505 .equipment_presets
506 .push(crate::default_bodies::FEMALE_HAIR_PRESET.to_string());
507 save_project(&opened)?;
508 Ok(opened)
509}
510
511pub fn adopt_file(root: &Path, source: &Path, folder: &str) -> Result<String, String> {
512 if !source.is_file() {
513 return Err("kies een bestand".to_string());
514 }
515 let name = source
516 .file_name()
517 .and_then(|name| name.to_str())
518 .filter(|name| {
519 !name.is_empty() && !name.contains('/') && !name.contains('\\') && !name.contains('\0')
520 })
521 .ok_or_else(|| "de bestandsnaam is niet bruikbaar".to_string())?;
522 if let (Ok(source_full), Ok(root_full)) = (source.canonicalize(), root.canonicalize()) {
523 if let Ok(inside) = source_full.strip_prefix(&root_full) {
524 let relative = inside.to_string_lossy().replace('\\', "/");
525 portable_relative(&relative)?;
526 return Ok(relative);
527 }
528 }
529 let mut relative = format!("{folder}/{name}");
530 let stem = Path::new(name).file_stem().unwrap().to_string_lossy();
531 let ext = Path::new(name)
532 .extension()
533 .map(|x| format!(".{}", x.to_string_lossy()))
534 .unwrap_or_default();
535 let mut n = 2;
536 while root.join(&relative).exists() {
537 relative = format!("{folder}/{stem}-{n}{ext}");
538 n += 1;
539 }
540 portable_relative(&relative)?;
541 let dest = checked_project_path(root, &relative)?;
542 if let Some(parent) = dest.parent() {
543 fs::create_dir_all(parent).map_err(|err| err.to_string())?;
544 }
545 fs::copy(source, &dest).map_err(|err| err.to_string())?;
546 Ok(relative)
547}
548
549fn checked_project_path(root: &Path, relative: &str) -> Result<PathBuf, String> {
550 portable_relative(relative)?;
551 let root_full = root.canonicalize().map_err(|e| e.to_string())?;
552 let path = root.join(relative);
553 let mut ancestor = path.as_path();
554 while std::fs::symlink_metadata(ancestor).is_err() {
555 ancestor = ancestor.parent().ok_or("ongeldig projectpad")?;
556 }
557 let canonical = ancestor.canonicalize().map_err(|e| e.to_string())?;
558 if !canonical.starts_with(&root_full) {
559 return Err("het bestand ligt buiten de projectmap".into());
560 }
561 Ok(path)
562}
563
564fn write_bytes(path: &Path, bytes: &[u8]) -> Result<(), ProjectError> {
565 if let Some(parent) = path.parent() {
566 fs::create_dir_all(parent).map_err(|err| io_at(path, &err.to_string()))?;
567 }
568 fs::write(path, bytes).map_err(|err| io_at(path, &err.to_string()))
569}
570
571fn io_at(path: &Path, message: &str) -> ProjectError {
572 ProjectError::Io {
573 path: path.display().to_string(),
574 message: message.to_string(),
575 }
576}
577
578pub const MAX_PROJECT_PRESET_BYTES: usize = 64 * 1024 * 1024;
579const MAX_LEGACY_PRESET_BYTES: usize = 64 * 1024;
580
581fn read_limited(path: &Path, limit: usize) -> Result<Vec<u8>, String> {
582 use std::io::Read;
583 if !path.is_file() {
584 return Err("kies een regulier bestand".into());
585 }
586 let mut bytes = Vec::new();
587 fs::File::open(path)
588 .and_then(|file| file.take((limit + 1) as u64).read_to_end(&mut bytes))
589 .map_err(|e| e.to_string())?;
590 if bytes.len() > limit {
591 return Err("presetbestand overschrijdt het bestands-/projectbudget".into());
592 }
593 Ok(bytes)
594}
595fn read_equipment(root: &Path, presets: &[String]) -> Vec<EquipmentItem> {
596 read_equipment_with_budget(root, presets, MAX_PROJECT_PRESET_BYTES)
597}
598fn read_equipment_with_budget(
599 root: &Path,
600 presets: &[String],
601 mut budget: usize,
602) -> Vec<EquipmentItem> {
603 presets
604 .iter()
605 .map(|path| {
606 let failure = |error: String| EquipmentItem {
607 preset: None,
608 error: Some(error),
609 slug: Path::new(path)
610 .file_stem()
611 .unwrap_or_default()
612 .to_string_lossy()
613 .into(),
614 model: None,
615 legacy_path: Some(path.clone()),
616 };
617 let file = match checked_project_path(root, path) {
618 Ok(path) => path,
619 Err(error) => return failure(error),
620 };
621 let binary = file.extension().and_then(|e| e.to_str()) == Some("ppe");
622 let limit = budget.min(if binary {
623 crate::equipment::MAX_FILE
624 } else {
625 MAX_LEGACY_PRESET_BYTES
626 });
627 let bytes = match read_limited(&file, limit) {
628 Ok(bytes) => bytes,
629 Err(error) => return failure(error),
630 };
631 budget -= bytes.len();
632 if binary {
633 return match Preset::decode(&bytes) {
634 Ok(preset) => EquipmentItem {
635 slug: preset.name.clone(),
636 model: preset.attachments.first().map(|a| a.label.clone()),
637 preset: Some(preset),
638 error: None,
639 legacy_path: None,
640 },
641 Err(error) => failure(error),
642 };
643 }
644 if let Ok(parsed) = serde_json::from_slice::<EquipmentFile>(&bytes) {
645 if valid_slug(&parsed.name) {
646 return EquipmentItem {
647 preset: None,
648 error: None,
649 slug: parsed.name,
650 model: parsed.model,
651 legacy_path: None,
652 };
653 }
654 }
655 failure("legacy preset; kies een OBJ- of FBX-model om te configureren".into())
656 })
657 .collect()
658}
659
660fn write_equipment(root: &Path, items: &[EquipmentItem]) -> Result<Vec<String>, String> {
661 if items.len() > crate::project::MAX_PROJECT_PRESETS {
662 return Err("maximaal 64 equipmentpresets per project".into());
663 }
664 let mut budget = MAX_PROJECT_PRESET_BYTES;
665 let mut paths = Vec::new();
666 let mut names = std::collections::BTreeSet::new();
667 // Validate every record before writing. Config files are immutable snapshots:
668 // a failed manifest save leaves its previous equipment bytes intact.
669 for item in items {
670 if !names.insert(&item.slug) {
671 return Err("dubbele equipmentnaam".into());
672 }
673 if let Some(legacy) = item.legacy_path.as_ref().filter(|_| item.model.is_none()) {
674 let path = checked_project_path(root, legacy)?;
675 let length = fs::metadata(path).map(|meta| meta.len()).unwrap_or(0);
676 budget = budget
677 .checked_sub(usize::try_from(length).map_err(|_| "preset te groot")?)
678 .ok_or("equipmentpresets samen groter dan 64 MiB")?;
679 continue;
680 }
681 if !valid_slug(&item.slug) {
682 return Err(format!("equipment '{}' heeft geen geldige naam", item.slug));
683 }
684 if let Some(preset) = &item.preset {
685 let mut preset = preset.clone();
686 preset.name = item.slug.clone();
687 let length = preset.encode()?.len();
688 budget = budget
689 .checked_sub(length)
690 .ok_or("equipmentpresets samen groter dan 64 MiB")?;
691 } else {
692 if let Some(model) = &item.model {
693 portable_relative(model)?;
694 }
695 let text = serde_json::to_vec_pretty(&EquipmentFile {
696 name: item.slug.clone(),
697 model: item.model.clone(),
698 })
699 .map_err(|e| e.to_string())?;
700 if text.len() > MAX_LEGACY_PRESET_BYTES {
701 return Err("legacy preset groter dan 64 KiB".into());
702 }
703 budget = budget
704 .checked_sub(text.len())
705 .ok_or("equipmentpresets samen groter dan 64 MiB")?;
706 }
707 }
708 for item in items {
709 if let Some(preset) = &item.preset {
710 let mut preset = preset.clone();
711 preset.name = item.slug.clone();
712 let bytes = preset.encode()?;
713 let relative = format!(
714 "presets/{}-{:08x}-{}.ppe",
715 item.slug,
716 crate::equipment::checksum(&bytes),
717 bytes.len()
718 );
719 let path = checked_project_path(root, &relative)?;
720 fs::create_dir_all(path.parent().unwrap()).map_err(|e| e.to_string())?;
721 crate::equipment::write_snapshot(&path, &bytes)?;
722 paths.push(relative);
723 continue;
724 }
725 if item.model.is_none() {
726 if let Some(legacy) = &item.legacy_path {
727 paths.push(legacy.clone());
728 continue;
729 }
730 }
731 let file = EquipmentFile {
732 name: item.slug.clone(),
733 model: item.model.clone(),
734 };
735 let text = serde_json::to_string_pretty(&file).map_err(|err| err.to_string())?;
736 let mut number = 1;
737 loop {
738 let relative = format!("presets/{}-{number}.json", item.slug);
739 let path = checked_project_path(root, &relative)?;
740 if let Ok(existing) = read_limited(&path, MAX_LEGACY_PRESET_BYTES) {
741 if existing == text.as_bytes() {
742 paths.push(relative);
743 break;
744 }
745 } else if !path.exists() {
746 fs::create_dir_all(path.parent().unwrap()).map_err(|err| err.to_string())?;
747 use std::io::Write;
748 let mut out = fs::OpenOptions::new()
749 .write(true)
750 .create_new(true)
751 .open(&path)
752 .map_err(|err| err.to_string())?;
753 out.write_all(text.as_bytes())
754 .map_err(|err| err.to_string())?;
755 out.sync_all().map_err(|err| err.to_string())?;
756 paths.push(relative);
757 break;
758 }
759 number += 1;
760 }
761 }
762 Ok(paths)
763}
764
765fn next_slug(items: &[EquipmentItem]) -> String {
766 let mut number = 1u32;
767 loop {
768 let slug = if number == 1 {
769 "nieuw".to_string()
770 } else {
771 format!("nieuw-{number}")
772 };
773 if !items.iter().any(|item| item.slug == slug) {
774 return slug;
775 }
776 number += 1;
777 }
778}
779
780fn valid_slug(slug: &str) -> bool {
781 crate::equipment::valid_name(slug)
782}
783
784pub fn manifest_in(root: &Path) -> PathBuf {
785 root.join(MANIFEST_NAME)
786}
787
788#[cfg(test)]
789mod tests {
790 use super::*;
791 use std::time::{SystemTime, UNIX_EPOCH};
792
793 fn scratch() -> PathBuf {
794 let nanos = SystemTime::now()
795 .duration_since(UNIX_EPOCH)
796 .unwrap()
797 .as_nanos();
798 let path = std::env::temp_dir().join(format!("paperdoll-session-{nanos}"));
799 let _ = fs::remove_dir_all(&path);
800 path
801 }
802
803 #[test]
804 fn editing_budget_and_import_names_are_bounded_before_install() {
805 let mut session = Session::new();
806 let mut p = Preset::new("sword".into());
807 for i in 0..3 {
808 p.assets.push(crate::equipment::EmbeddedAsset {
809 path: format!("raw-{i}"),
810 bytes: vec![0; 8 * 1024 * 1024],
811 });
812 }
813 for _ in 0..2 {
814 let i = session.add_equipment();
815 session.equipment[i].preset = Some(p.clone());
816 }
817 assert!(session
818 .ensure_preset_budget(None, &p)
819 .unwrap_err()
820 .contains("64 MiB"));
821 assert!(session.ensure_preset_budget(Some(0), &p).is_ok());
822 assert_eq!(session.equipment.len(), 2);
823 let base = "a".repeat(64);
824 session.equipment[0].slug = base.clone();
825 let unique = session.available_equipment_name(&base).unwrap();
826 assert_eq!(unique.len(), 64);
827 assert_ne!(unique, base);
828 assert!(crate::equipment::valid_name(&unique));
829 }
830
831 #[test]
832 fn preset_read_budget_preserves_unloaded_references() {
833 let root = scratch();
834 fs::create_dir_all(root.join("presets")).unwrap();
835 let bytes = Preset::new("sword".into()).encode().unwrap();
836 for name in ["one", "two"] {
837 fs::write(root.join(format!("presets/{name}.ppe")), &bytes).unwrap();
838 }
839 let refs = vec!["presets/one.ppe".into(), "presets/two.ppe".into()];
840 let items = read_equipment_with_budget(&root, &refs, bytes.len());
841 assert!(items[0].preset.is_some());
842 assert!(items[1].preset.is_none());
843 assert!(items[1].error.as_ref().unwrap().contains("budget"));
844 assert_eq!(items[1].legacy_path.as_ref(), Some(&refs[1]));
845 fs::write(
846 root.join("presets/old.json"),
847 vec![b' '; MAX_LEGACY_PRESET_BYTES + 1],
848 )
849 .unwrap();
850 assert!(read_equipment(&root, &["presets/old.json".into()])[0]
851 .error
852 .is_some());
853 fs::remove_dir_all(root).unwrap();
854 }
855 #[test]
856 fn too_many_presets_do_not_write_snapshots_or_replace_manifest() {
857 let root = scratch();
858 let mut session = Session::new();
859 session.install_new_project(&root).unwrap();
860 let before = fs::read(root.join(MANIFEST_NAME)).unwrap();
861 for _ in 0..=crate::project::MAX_PROJECT_PRESETS {
862 session.add_equipment();
863 }
864 assert!(session.save().unwrap_err().to_string().contains("64"));
865 assert_eq!(fs::read(root.join(MANIFEST_NAME)).unwrap(), before);
866 assert_eq!(fs::read_dir(root.join("presets")).unwrap().count(), 0);
867 assert!(session.is_dirty());
868 fs::remove_dir_all(root).unwrap();
869 }
870
871 #[test]
872 fn named_new_project_contains_and_loads_the_default_bodies() {
873 let parent = scratch();
874 fs::create_dir_all(&parent).unwrap();
875 let mut session = Session::new();
876 session.install_named_project(&parent, "Starter").unwrap();
877 let root = parent.join("Starter");
878 for (relative, bytes) in crate::default_bodies::FILES {
879 let copied = fs::read(root.join("assets/body").join(relative)).unwrap();
880 assert!(copied == *bytes, "{relative} not byte-exact");
881 }
882 assert!(root.join("assets/body/male/license.txt").is_file());
883 assert!(root.join("assets/body/female/license.txt").is_file());
884 let document = &session.opened.as_ref().unwrap().document;
885 assert_eq!(
886 document.male_model.as_deref(),
887 Some(crate::default_bodies::MALE_MODEL)
888 );
889 assert_eq!(
890 document.female_model.as_deref(),
891 Some(crate::default_bodies::FEMALE_MODEL)
892 );
893 for model in [
894 crate::default_bodies::MALE_MODEL,
895 crate::default_bodies::FEMALE_MODEL,
896 ] {
897 let body = crate::preview::load_body_file(&root.join(model)).unwrap();
898 assert!(
899 !body.joint_names.is_empty() && !body.clips.is_empty(),
900 "{model}"
901 );
902 assert!(
903 body.surfaces.iter().any(|s| s.albedo.is_some()),
904 "{model} textures"
905 );
906 }
907 // The bundled female hair preset is listed, female-only, and at rest
908 // sits on the head of the default female (t1019u).
909 assert_eq!(
910 fs::read(root.join(crate::default_bodies::FEMALE_HAIR_PRESET)).unwrap(),
911 crate::default_bodies::FEMALE_HAIR_PPE
912 );
913 let hair = session
914 .equipment
915 .iter()
916 .find_map(|item| item.preset.as_ref().filter(|p| p.name == "female-hair"))
917 .expect("female-hair preset in the equipment list");
918 assert_eq!(hair.applies, [false, true]);
919 let female =
920 crate::preview::load_body_file(&root.join(crate::default_bodies::FEMALE_MODEL))
921 .unwrap();
922 let controls = crate::preview::PreviewControls {
923 clip_name: String::new(),
924 ..Default::default()
925 };
926 // Tim t1023u defaults for the bundled hair.
927 let part = &hair.attachments[0];
928 assert_eq!(part.translation, [0.0, -0.370, 0.0]);
929 assert_eq!(part.rotation_deg, [0.0, 0.0, 0.0]);
930 assert_eq!(part.specular, 0.62);
931 let layers = crate::equipment::layers(hair, 1, &female, &controls).unwrap();
932 assert_eq!(layers.len(), 1);
933 assert_eq!(layers[0].specular, 0.62);
934 // The saved manifest reopens with the same bodies.
935 let reopened = crate::project::open_project(&root.join("character.ppd")).unwrap();
936 assert_eq!(
937 reopened.document.male_model.as_deref(),
938 Some(crate::default_bodies::MALE_MODEL)
939 );
940 }
941
942 #[test]
943 fn new_project_copies_both_test_bodies_and_can_be_overwritten() {
944 let root = scratch();
945 let mut session = Session::new();
946 session.install_new_project(&root).unwrap();
947 assert_eq!(session.screen, Screen::Bodies);
948 assert!(!session.is_dirty());
949 assert_eq!(fs::read(root.join(MALE_ASSET)).unwrap(), MALE_GLB);
950 assert_eq!(fs::read(root.join(FEMALE_ASSET)).unwrap(), FEMALE_GLB);
951 let outside = root
952 .parent()
953 .unwrap()
954 .join(format!("pd-src-{}", std::process::id()));
955 let _ = fs::remove_dir_all(&outside);
956 fs::create_dir_all(&outside).unwrap();
957 let source = outside.join("test-body-male.glb");
958 fs::write(&source, b"replacement-body").unwrap();
959 let relative = adopt_file(&root, &source, "assets/body").unwrap();
960 assert_ne!(relative, MALE_ASSET);
961 assert_eq!(fs::read(root.join(&relative)).unwrap(), b"replacement-body");
962 assert_eq!(fs::read(root.join(MALE_ASSET)).unwrap(), MALE_GLB);
963 let extra = outside.join("extra.glb");
964 fs::write(&extra, b"extra-body").unwrap();
965 let relative = adopt_file(&root, &extra, "assets/body").unwrap();
966 session.set_body_path(Sex::Male, relative);
967 assert!(session.is_dirty());
968 session.save().unwrap();
969 assert!(!session.is_dirty());
970 assert_eq!(
971 fs::read(root.join("assets/body/extra.glb")).unwrap(),
972 b"extra-body"
973 );
974 let _ = fs::remove_dir_all(&outside);
975 let _ = fs::remove_dir_all(&root);
976 }
977
978 #[test]
979 fn navigation_and_equipment_crud_roundtrip() {
980 let root = scratch();
981 let mut session = Session::new();
982 session.show_body(Sex::Male);
983 assert_eq!(session.screen, Screen::Start);
984 session.install_new_project(&root).unwrap();
985 session.show_body(Sex::Female);
986 assert_eq!(session.screen, Screen::Body(Sex::Female));
987 session.show_equipment_list();
988 assert_eq!(session.screen, Screen::EquipmentList);
989 let index = session.add_equipment();
990 assert_eq!(index, 0);
991 session.set_equipment_model(0, "assets/equipment/zwaard.glb".to_string());
992 session.save().unwrap();
993 let text = fs::read_to_string(root.join("presets/nieuw-1.json")).unwrap();
994 assert!(text.contains("zwaard.glb"));
995 let mut again = Session::new();
996 let opened = crate::project::open_project(&manifest_in(&root)).unwrap();
997 again.install_loaded(opened);
998 assert_eq!(
999 again.equipment[0].model.as_deref(),
1000 Some("assets/equipment/zwaard.glb")
1001 );
1002 again.delete_equipment(0);
1003 again.save().unwrap();
1004 assert!(again
1005 .opened
1006 .as_ref()
1007 .unwrap()
1008 .document
1009 .equipment_presets
1010 .is_empty());
1011 // Old immutable record stays recoverable if an earlier save is restored.
1012 assert!(root.join("presets/nieuw-1.json").exists());
1013 let _ = fs::remove_dir_all(&root);
1014 }
1015
1016 #[test]
1017 fn failed_save_preserves_previous_equipment_and_manifest_bytes() {
1018 let root = scratch();
1019 let mut session = Session::new();
1020 session.install_new_project(&root).unwrap();
1021 session.add_equipment();
1022 session.save().unwrap();
1023 let manifest = fs::read(manifest_in(&root)).unwrap();
1024 let relative = session.opened.as_ref().unwrap().document.equipment_presets[0].clone();
1025 let preset = fs::read(root.join(&relative)).unwrap();
1026 session.equipment[0].slug = "changed".into();
1027 // Missing body fails after immutable equipment snapshots are written.
1028 session.set_body_path(Sex::Male, "assets/body/missing.glb".into());
1029 assert!(session.save().is_err());
1030 assert_eq!(fs::read(manifest_in(&root)).unwrap(), manifest);
1031 assert_eq!(fs::read(root.join(&relative)).unwrap(), preset);
1032 assert!(session.is_dirty());
1033 let _ = fs::remove_dir_all(root);
1034 }
1035
1036 #[test]
1037 fn dirty_confirm_save_discard_and_cancel_keep_bytes() {
1038 let root = scratch();
1039 let mut session = Session::new();
1040 session.install_new_project(&root).unwrap();
1041 session.set_name("Gewijzigd".to_string());
1042 assert!(session.is_dirty());
1043 assert!(!session.gate(Intent::Quit));
1044 assert_eq!(session.resolve(Choice::Cancel), AfterChoice::Stay);
1045 assert_eq!(session.opened.as_ref().unwrap().document.name, "Gewijzigd");
1046 assert!(!session.gate(Intent::PickLoad));
1047 assert_eq!(
1048 session.resolve(Choice::Discard),
1049 AfterChoice::Go(Intent::PickLoad)
1050 );
1051 assert_eq!(session.opened.as_ref().unwrap().document.name, "Gewijzigd");
1052 assert!(session.is_dirty()); // canceled file picker must not discard the old project
1053 session.set_name("Bewaard".to_string());
1054 assert!(!session.gate(Intent::PickNew));
1055 assert_eq!(
1056 session.resolve(Choice::Save),
1057 AfterChoice::SaveThen(Intent::PickNew)
1058 );
1059 session.save().unwrap();
1060 assert_eq!(session.opened.as_ref().unwrap().document.name, "Bewaard");
1061 let opened = crate::project::open_project(&manifest_in(&root)).unwrap();
1062 assert_eq!(opened.document.name, "Bewaard");
1063 let _ = fs::remove_dir_all(&root);
1064 }
1065}