BlueGit

golden-age paperdoll-renderer

Merge feature/render-all: Render alles (9h)

timfa Grok Bot · pushed by timfa

Authored 2026-11-07 · 7 days ago

Hash
fa5dd5cdd62dff4811ed9c802983538c96cb15b1
2 files changed +346 −49
README.md +4 −0
@@ -87,6 +87,10 @@ Bij sluiten of **Ander project / Nieuw project** met wijzigingen kies je **Opsla
87 87
88 88 ## Productie-output
89 89
90 ### Render alles
91
92 **Render alles** in het Body-scherm rendert het hele project in één wachtrij: eerst het mannelijke en vrouwelijke lichaam (als ingesteld), daarna ieder equipment-preset voor elk geslacht waarvoor het geldt. Elk item krijgt de volledige export (alle clips × frames × 60 hoeken, vier maps, atlassen + `.ppda`) naar zijn eigen `renders/<geslacht>-<settype>-atlas/`. De voortgang toont *item k/n* plus de gewone passtelling; **Annuleer render** stopt de wachtrij (het lopende item blijft hervatbaar, de rest staat als *niet gestart*). Heeft een item een passende onderbroken run, dan wordt die hervat in plaats van opnieuw begonnen. Items die niet kunnen (geen lichaam, equipment niet geconfigureerd of zonder onderdelen, ongeldige instellingen) worden met reden overgeslagen en de wachtrij gaat door. Na afloop staat een samenvatting met wat gerenderd, overgeslagen, mislukt of niet gestart is.
93
90 94 De bodyknop rendert alleen het lichaam; **Render equipment alle hoeken** in de equipment-editor rendert het geselecteerde preset, geanimeerd op het gekozen lichaam maar **zonder body-occlusie of bodypixels**. De vaste camera van dat geslacht blijft gelijk, ook als je in de live preview vrij hebt ingezoomd.
91 95
92 96 Per beeld worden **alleen vier target-res PNGs** opgeslagen: `diffuse`, `normal`, `depth`, `specular`. Diffuse is **onbelichte albedo** (basiskleur/texture × vertexkleur, alpha behouden), nooit ingebakken lighting/schaduw/glans. Depth is lineaire 16-bit grayscale+alpha binnen een vast, smal dieptebereik per geslacht rond het orbitpunt: `near/far = 10 m ∓ R`, met R = halve renderhoogte + |pivot Y − centrum Y| (minimaal 0,5 m); bij 2,6 m hoogte en nekpivot 1,46 m dus ca. 8,24–11,76 m. Alle clips, frames, hoeken en equipment van dat geslacht delen hetzelfde bereik; afstand = near + (grijs/65535)·(far − near). Geometrie buiten het bereik wordt geklemd (niet weggeknipt) en gemeld in de voortgang en in `complete.json` (`depth_clamped`); normal is view-space RGB+alpha; specular is lineaire sterkte (body nul, equipment inclusief ingestelde shinymap). Highres en detail zijn uitsluitend zichtbare hulpstappen en worden niet opgeslagen.
@@ -87,6 +87,10 @@ Bij sluiten of **Ander project / Nieuw project** met wijzigingen kies je **Opsla
87
87
88## Productie-output
88## Productie-output
89
89
90### Render alles
91
92**Render alles** in het Body-scherm rendert het hele project in één wachtrij: eerst het mannelijke en vrouwelijke lichaam (als ingesteld), daarna ieder equipment-preset voor elk geslacht waarvoor het geldt. Elk item krijgt de volledige export (alle clips × frames × 60 hoeken, vier maps, atlassen + `.ppda`) naar zijn eigen `renders/<geslacht>-<settype>-atlas/`. De voortgang toont *item k/n* plus de gewone passtelling; **Annuleer render** stopt de wachtrij (het lopende item blijft hervatbaar, de rest staat als *niet gestart*). Heeft een item een passende onderbroken run, dan wordt die hervat in plaats van opnieuw begonnen. Items die niet kunnen (geen lichaam, equipment niet geconfigureerd of zonder onderdelen, ongeldige instellingen) worden met reden overgeslagen en de wachtrij gaat door. Na afloop staat een samenvatting met wat gerenderd, overgeslagen, mislukt of niet gestart is.
93
90De bodyknop rendert alleen het lichaam; **Render equipment alle hoeken** in de equipment-editor rendert het geselecteerde preset, geanimeerd op het gekozen lichaam maar **zonder body-occlusie of bodypixels**. De vaste camera van dat geslacht blijft gelijk, ook als je in de live preview vrij hebt ingezoomd.
94De bodyknop rendert alleen het lichaam; **Render equipment alle hoeken** in de equipment-editor rendert het geselecteerde preset, geanimeerd op het gekozen lichaam maar **zonder body-occlusie of bodypixels**. De vaste camera van dat geslacht blijft gelijk, ook als je in de live preview vrij hebt ingezoomd.
91
95
92Per beeld worden **alleen vier target-res PNGs** opgeslagen: `diffuse`, `normal`, `depth`, `specular`. Diffuse is **onbelichte albedo** (basiskleur/texture × vertexkleur, alpha behouden), nooit ingebakken lighting/schaduw/glans. Depth is lineaire 16-bit grayscale+alpha binnen een vast, smal dieptebereik per geslacht rond het orbitpunt: `near/far = 10 m ∓ R`, met R = halve renderhoogte + |pivot Y − centrum Y| (minimaal 0,5 m); bij 2,6 m hoogte en nekpivot 1,46 m dus ca. 8,24–11,76 m. Alle clips, frames, hoeken en equipment van dat geslacht delen hetzelfde bereik; afstand = near + (grijs/65535)·(far − near). Geometrie buiten het bereik wordt geklemd (niet weggeknipt) en gemeld in de voortgang en in `complete.json` (`depth_clamped`); normal is view-space RGB+alpha; specular is lineaire sterkte (body nul, equipment inclusief ingestelde shinymap). Highres en detail zijn uitsluitend zichtbare hulpstappen en worden niet opgeslagen.
96Per beeld worden **alleen vier target-res PNGs** opgeslagen: `diffuse`, `normal`, `depth`, `specular`. Diffuse is **onbelichte albedo** (basiskleur/texture × vertexkleur, alpha behouden), nooit ingebakken lighting/schaduw/glans. Depth is lineaire 16-bit grayscale+alpha binnen een vast, smal dieptebereik per geslacht rond het orbitpunt: `near/far = 10 m ∓ R`, met R = halve renderhoogte + |pivot Y − centrum Y| (minimaal 0,5 m); bij 2,6 m hoogte en nekpivot 1,46 m dus ca. 8,24–11,76 m. Alle clips, frames, hoeken en equipment van dat geslacht delen hetzelfde bereik; afstand = near + (grijs/65535)·(far − near). Geometrie buiten het bereik wordt geklemd (niet weggeknipt) en gemeld in de voortgang en in `complete.json` (`depth_clamped`); normal is view-space RGB+alpha; specular is lineaire sterkte (body nul, equipment inclusief ingestelde shinymap). Highres en detail zijn uitsluitend zichtbare hulpstappen en worden niet opgeslagen.
src/editor.rs +342 −49
@@ -53,6 +53,51 @@ pub struct EditorApp {
53 53 resume_candidates: Vec<crate::resume::ResumeCandidate>,
54 54 resume_scanned: bool,
55 55 resume_scan_error: Option<String>,
56 render_all: Option<RenderAll>,
57 render_all_summary: Option<RenderAllSummary>,
58 }
59
60 /// One queued item of **Render alles**: a body (0 = male, 1 = female) with
61 /// optional equipment item index.
62 #[derive(Debug, Clone, PartialEq, Eq)]
63 pub struct RenderAllItem {
64 pub sex: usize,
65 pub equipment: Option<usize>,
66 pub label: String,
67 }
68
69 /// What **Render alles** did, per item.
70 #[derive(Debug, Clone, Default, PartialEq, Eq)]
71 pub struct RenderAllSummary {
72 pub rendered: Vec<String>,
73 pub skipped: Vec<(String, String)>,
74 pub failed: Vec<(String, String)>,
75 pub not_started: Vec<String>,
76 pub cancelled: bool,
77 }
78
79 impl RenderAllSummary {
80 pub fn text(&self) -> String {
81 let mut out = format!(
82 "Render alles{}: {} gerenderd, {} overgeslagen, {} mislukt",
83 if self.cancelled { " (geannuleerd)" } else { "" },
84 self.rendered.len(),
85 self.skipped.len(),
86 self.failed.len()
87 );
88 if !self.not_started.is_empty() {
89 out += &format!(", {} niet gestart", self.not_started.len());
90 }
91 out
92 }
93 }
94
95 struct RenderAll {
96 queue: std::collections::VecDeque<RenderAllItem>,
97 total: usize,
98 started: usize,
99 current: Option<String>,
100 summary: RenderAllSummary,
56 101 }
57 102
58 103 /// Decoded atlases for the 2D preview selection (body first).
@@ -96,6 +141,8 @@ impl EditorApp {
96 141 resume_candidates: Vec::new(),
97 142 resume_scanned: false,
98 143 resume_scan_error: None,
144 render_all: None,
145 render_all_summary: None,
99 146 }
100 147 }
101 148
@@ -421,63 +468,62 @@ impl EditorApp {
421 468 }
422 469 /// The job the Render action would start now (current project, body, equipment).
423 470 fn build_job(&mut self) -> Option<RenderJob> {
424 let opened = self.session.opened.as_ref()?;
425 if let Err(error) = opened.document.validate_shape() {
426 self.session.fail(error.to_string());
427 return None;
428 }
429 let Some(character) = self.character().cloned() else {
430 self.session
431 .fail("dit lichaam heeft geen tekenbaar glTF/GLB-, FBX- of OBJ-model");
432 return None;
433 };
434 let root = self.session.project_root()?;
435 if let Err(error) =
436 crate::project::prepare_output_dir(&root, &opened.document.output_directory)
437 {
438 self.session.fail(error.to_string());
439 return None;
440 }
441 471 let sex = usize::from(self.controls.sex == PreviewSex::Female);
442 let preset = match self.session.screen {
443 Screen::Equipment(i) => self.session.equipment.get(i).and_then(|i| i.preset.clone()),
472 let equipment = match self.session.screen {
473 Screen::Equipment(i) => Some(i),
444 474 _ => None,
445 475 };
446 if matches!(self.session.screen, Screen::Equipment(_)) && preset.is_none() {
447 self.session.fail("configureer eerst het equipment");
448 return None;
476 match self.job_for(sex, equipment) {
477 Ok(job) => Some(job),
478 Err(error) => {
479 self.session.fail(error);
480 None
481 }
449 482 }
450 let body_rel = match self.controls.sex {
451 PreviewSex::Male => opened.document.male_model.clone(),
452 PreviewSex::Female => opened.document.female_model.clone(),
453 };
454 let Some(body_rel) = body_rel else {
455 self.session.fail("dit lichaam heeft geen modelpad");
456 return None;
483 }
484 /// Render job for one body (sex 0 = male, 1 = female) and optional equipment item.
485 fn job_for(&self, sex: usize, equipment: Option<usize>) -> Result<RenderJob, String> {
486 let opened = self.session.opened.as_ref().ok_or("geen project geopend")?;
487 opened
488 .document
489 .validate_shape()
490 .map_err(|e| e.to_string())?;
491 let character = match sex {
492 0 => self.preview_male.as_ref(),
493 _ => self.preview_female.as_ref(),
494 }
495 .cloned()
496 .ok_or("dit lichaam heeft geen tekenbaar glTF/GLB-, FBX- of OBJ-model")?;
497 let root = self.session.project_root().ok_or("geen projectmap")?;
498 crate::project::prepare_output_dir(&root, &opened.document.output_directory)
499 .map_err(|e| e.to_string())?;
500 let preset = match equipment {
501 Some(i) => Some(
502 self.session
503 .equipment
504 .get(i)
505 .and_then(|i| i.preset.clone())
506 .ok_or("configureer eerst het equipment")?,
507 ),
508 None => None,
457 509 };
458 if let Err(error) = crate::project::portable_relative(&body_rel) {
459 self.session.fail(error);
460 return None;
510 let body_rel = match sex {
511 0 => opened.document.male_model.clone(),
512 _ => opened.document.female_model.clone(),
461 513 }
514 .ok_or("dit lichaam heeft geen modelpad")?;
515 crate::project::portable_relative(&body_rel)?;
462 516 let body_path = root.join(&body_rel);
463 let body_sha = match crate::digest::sha256_file(&body_path, 32 * 1024 * 1024) {
464 Ok(hash) => hash,
465 Err(error) => {
466 self.session.fail(format!("body-fingerprint: {error}"));
467 return None;
468 }
469 };
517 let body_sha = crate::digest::sha256_file(&body_path, 32 * 1024 * 1024)
518 .map_err(|e| format!("body-fingerprint: {e}"))?;
470 519 let preset_sha256 = match &preset {
471 520 None => None,
472 Some(p) => match p.encode() {
473 Ok(bytes) => Some(crate::digest::sha256_hex(&bytes)),
474 Err(error) => {
475 self.session.fail(format!("equipment-fingerprint: {error}"));
476 return None;
477 }
478 },
521 Some(p) => Some(crate::digest::sha256_hex(
522 &p.encode()
523 .map_err(|e| format!("equipment-fingerprint: {e}"))?,
524 )),
479 525 };
480 Some(RenderJob {
526 Ok(RenderJob {
481 527 character,
482 528 sex: if sex == 0 { "male" } else { "female" }.into(),
483 529 preset,
@@ -495,6 +541,184 @@ impl EditorApp {
495 541 preset_sha256,
496 542 })
497 543 }
544 /// Every body (male, female) and then every equipment item per applicable
545 /// sex, plus items skipped before rendering (with the reason).
546 pub fn render_all_plan(&self) -> (Vec<RenderAllItem>, Vec<(String, String)>) {
547 let mut items = Vec::new();
548 let mut skipped = Vec::new();
549 let names = ["man", "vrouw"];
550 for (sex, name) in names.iter().enumerate() {
551 let label = format!("{name} · body");
552 if self
553 .session
554 .body_path(if sex == 0 { Sex::Male } else { Sex::Female })
555 .is_none()
556 {
557 skipped.push((label, "geen lichaam ingesteld".into()));
558 } else {
559 items.push(RenderAllItem {
560 sex,
561 equipment: None,
562 label,
563 });
564 }
565 }
566 for (index, item) in self.session.equipment.iter().enumerate() {
567 let Some(preset) = &item.preset else {
568 skipped.push((
569 format!("{} · equipment", item.slug),
570 item.error
571 .clone()
572 .unwrap_or_else(|| "equipment is niet geconfigureerd".into()),
573 ));
574 continue;
575 };
576 for (sex, name) in names.iter().enumerate() {
577 let label = format!("{name} · {}", item.slug);
578 if !preset.applies[sex] {
579 continue; // not applicable to this sex by design
580 }
581 if self
582 .session
583 .body_path(if sex == 0 { Sex::Male } else { Sex::Female })
584 .is_none()
585 {
586 skipped.push((label, "geen lichaam ingesteld voor dit geslacht".into()));
587 } else {
588 items.push(RenderAllItem {
589 sex,
590 equipment: Some(index),
591 label,
592 });
593 }
594 }
595 }
596 (items, skipped)
597 }
598 /// **Render alles**: queue every body and equipment export.
599 pub fn start_render_all(&mut self) {
600 if self.render_ui.running || self.render_all.is_some() {
601 return;
602 }
603 let (items, skipped) = self.render_all_plan();
604 self.render_all_summary = None;
605 self.render_all = Some(RenderAll {
606 total: items.len(),
607 queue: items.into(),
608 started: 0,
609 current: None,
610 summary: RenderAllSummary {
611 skipped,
612 ..Default::default()
613 },
614 });
615 self.render_all_next();
616 }
617 pub fn render_all_running(&self) -> bool {
618 self.render_all.is_some()
619 }
620 pub fn render_all_summary(&self) -> Option<&RenderAllSummary> {
621 self.render_all_summary.as_ref()
622 }
623 /// Start the next renderable queue item; items that cannot be built or fail
624 /// the render contract are skipped with their diagnostic. A matching
625 /// interrupted run of the item is resumed instead of starting over.
626 fn render_all_next(&mut self) {
627 loop {
628 let Some(state) = self.render_all.as_mut() else {
629 return;
630 };
631 let Some(item) = state.queue.pop_front() else {
632 let mut done = self.render_all.take().unwrap().summary;
633 done.cancelled = false;
634 self.finish_render_all(done);
635 return;
636 };
637 state.started += 1;
638 let job = self.job_for(item.sex, item.equipment).and_then(|job| {
639 let output = job.output.clone();
640 let contract = crate::production::contract(job.clone())?;
641 let resume = crate::resume::candidates(&output, &contract.render_doc)
642 .ok()
643 .and_then(|c| c.into_iter().find(|c| c.blocked.is_none()))
644 .map(|c| c.dir);
645 Ok((job, resume))
646 });
647 let state = self.render_all.as_mut().unwrap();
648 match job {
649 Err(reason) => state.summary.skipped.push((item.label, reason)),
650 Ok((job, resume)) => {
651 state.current = Some(item.label);
652 let worker = match resume {
653 Some(dir) => crate::render_job::spawn_resume(job, dir, None),
654 None => spawn(job, None),
655 };
656 self.render_worker = Some(worker);
657 self.begin_worker();
658 return;
659 }
660 }
661 }
662 }
663 /// Record the outcome of the current item and continue (or stop on cancel).
664 fn render_all_item_done(&mut self) {
665 let Some(state) = self.render_all.as_mut() else {
666 return;
667 };
668 let label = state.current.take().unwrap_or_default();
669 if self.render_ui.saved.is_some() {
670 state.summary.rendered.push(label);
671 } else if let Some(error) = &self.render_ui.error {
672 state.summary.failed.push((label, error.clone()));
673 } else {
674 // Cancelled: stop the queue, the item stays resumable.
675 let state = self.render_all.take().unwrap();
676 let mut summary = state.summary;
677 summary
678 .failed
679 .push((label, "geannuleerd (hervatbaar)".into()));
680 summary.cancelled = true;
681 summary
682 .not_started
683 .extend(state.queue.into_iter().map(|i| i.label));
684 self.finish_render_all(summary);
685 return;
686 }
687 self.render_all_next();
688 }
689 fn finish_render_all(&mut self, summary: RenderAllSummary) {
690 self.session.notice = summary.text();
691 self.session.notice_ok = summary.failed.is_empty() && !summary.cancelled;
692 self.render_all_summary = Some(summary);
693 }
694 fn render_all_ui(&mut self, ui: &mut egui::Ui) {
695 if let Some(state) = &self.render_all {
696 ui.label(format!(
697 "Render alles: item {}/{} · {}",
698 state.started,
699 state.total,
700 state.current.as_deref().unwrap_or("")
701 ));
702 }
703 if let Some(summary) = &self.render_all_summary {
704 ui.label(summary.text());
705 for label in &summary.rendered {
706 ui.label(format!("✔ {label}"));
707 }
708 for (label, reason) in &summary.skipped {
709 ui.weak(format!("overgeslagen: {label} — {reason}"));
710 }
711 for (label, reason) in &summary.failed {
712 ui.colored_label(
713 egui::Color32::LIGHT_RED,
714 format!("mislukt: {label} — {reason}"),
715 );
716 }
717 for label in &summary.not_started {
718 ui.weak(format!("niet gestart: {label}"));
719 }
720 }
721 }
498 722 fn start_render(&mut self) {
499 723 if self.render_ui.running {
500 724 return;
@@ -638,7 +862,9 @@ impl EditorApp {
638 862 }
639 863 if terminal {
640 864 self.render_worker = None;
641 if self.render_ui.saved.is_none() && self.render_ui.error.is_none() {
865 if self.render_all.is_some() {
866 self.render_all_item_done();
867 } else if self.render_ui.saved.is_none() && self.render_ui.error.is_none() {
642 868 // Cancelled: offer the run for resumption right away.
643 869 self.scan_resumable_runs();
644 870 }
@@ -647,6 +873,7 @@ impl EditorApp {
647 873 "De renderworker stopte zonder eindresultaat.".into(),
648 874 ));
649 875 self.render_worker = None;
876 self.render_all_item_done();
650 877 }
651 878 }
652 879 fn cancel_render(&mut self) {
@@ -916,10 +1143,27 @@ impl EditorApp {
916 1143 });
917 1144 self.apply_render_fields();
918 1145 ui.add_space(8.0);
1146 ui.horizontal_wrapped(|ui| {
1147 if ui
1148 .add_enabled(
1149 !self.render_ui.running && self.render_all.is_none(),
1150 egui::Button::new("Render alles"),
1151 )
1152 .on_hover_text(
1153 "Rendert alle lichamen en daarna al het equipment per geldig geslacht, elk naar renders/<geslacht>-<settype>-atlas/.",
1154 )
1155 .clicked()
1156 {
1157 self.start_render_all();
1158 }
1159 });
1160 self.render_all_ui(ui);
1161 self.render_progress_ui(ui);
1162 ui.add_space(8.0);
919 1163 if ui.button("2D-preview").clicked() {
920 1164 self.open_preview2d();
921 1165 }
922 ui.label("Opent de gerenderde 2D-compositor over voltooide exportsets in renders/.");
1166 ui.label("Opent de gerenderde 2D-compositor over de resultaatmappen in renders/.");
923 1167 });
924 1168 }
925 1169
@@ -2123,6 +2367,55 @@ mod tests {
2123 2367 let _ = std::fs::remove_dir_all(root.parent().unwrap());
2124 2368 }
2125 2369
2370 fn pump(app: &mut EditorApp) {
2371 for _ in 0..20000 {
2372 if !app.render_all_running() {
2373 return;
2374 }
2375 app.service_gpu(None);
2376 app.drain_render();
2377 std::thread::sleep(std::time::Duration::from_millis(1));
2378 }
2379 panic!("Render alles liep niet af");
2380 }
2381
2382 #[test]
2383 fn render_all_queues_bodies_and_equipment_with_skip_diagnostics() {
2384 let root = root();
2385 let opened = crate::session::create_seeded_project(&root).unwrap();
2386 let mut app = EditorApp::default();
2387 app.open_path(opened.manifest_path.clone());
2388 let index = app.session.add_equipment();
2389 app.session.equipment[index].slug = "leeg".into();
2390 let (items, skipped) = app.render_all_plan();
2391 let keys: Vec<_> = items.iter().map(|i| (i.sex, i.equipment)).collect();
2392 assert_eq!(
2393 keys,
2394 vec![(0, None), (1, None), (0, Some(index)), (1, Some(index))]
2395 );
2396 assert!(skipped.is_empty());
2397 // Without a GL context every body render fails; empty equipment is
2398 // skipped by the render contract; the queue still continues.
2399 app.start_render_all();
2400 assert!(app.render_all_running());
2401 pump(&mut app);
2402 let summary = app.render_all_summary().unwrap().clone();
2403 assert_eq!(summary.failed.len(), 2, "{summary:?}");
2404 assert!(summary.failed[0].1.contains("Glow"), "{summary:?}");
2405 assert_eq!(summary.skipped.len(), 2, "{summary:?}");
2406 assert!(summary.skipped[0].0.contains("leeg"), "{summary:?}");
2407 assert!(!summary.cancelled);
2408 assert!(app.session.notice.contains("2 overgeslagen, 2 mislukt"));
2409 // Cancel stops the queue; the rest is listed as not started.
2410 app.start_render_all();
2411 app.cancel_render();
2412 pump(&mut app);
2413 let summary = app.render_all_summary().unwrap().clone();
2414 assert!(summary.cancelled, "{summary:?}");
2415 assert_eq!(summary.not_started.len(), 3, "{summary:?}");
2416 let _ = std::fs::remove_dir_all(root);
2417 }
2418
2126 2419 #[test]
2127 2420 fn renderer_disconnect_is_reported() {
2128 2421 let mut app = EditorApp::default();
@@ -53,6 +53,51 @@ pub struct EditorApp {
53 resume_candidates: Vec<crate::resume::ResumeCandidate>,
53 resume_candidates: Vec<crate::resume::ResumeCandidate>,
54 resume_scanned: bool,
54 resume_scanned: bool,
55 resume_scan_error: Option<String>,
55 resume_scan_error: Option<String>,
56 render_all: Option<RenderAll>,
57 render_all_summary: Option<RenderAllSummary>,
58}
59
60/// One queued item of **Render alles**: a body (0 = male, 1 = female) with
61/// optional equipment item index.
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct RenderAllItem {
64 pub sex: usize,
65 pub equipment: Option<usize>,
66 pub label: String,
67}
68
69/// What **Render alles** did, per item.
70#[derive(Debug, Clone, Default, PartialEq, Eq)]
71pub struct RenderAllSummary {
72 pub rendered: Vec<String>,
73 pub skipped: Vec<(String, String)>,
74 pub failed: Vec<(String, String)>,
75 pub not_started: Vec<String>,
76 pub cancelled: bool,
77}
78
79impl RenderAllSummary {
80 pub fn text(&self) -> String {
81 let mut out = format!(
82 "Render alles{}: {} gerenderd, {} overgeslagen, {} mislukt",
83 if self.cancelled { " (geannuleerd)" } else { "" },
84 self.rendered.len(),
85 self.skipped.len(),
86 self.failed.len()
87 );
88 if !self.not_started.is_empty() {
89 out += &format!(", {} niet gestart", self.not_started.len());
90 }
91 out
92 }
93}
94
95struct RenderAll {
96 queue: std::collections::VecDeque<RenderAllItem>,
97 total: usize,
98 started: usize,
99 current: Option<String>,
100 summary: RenderAllSummary,
56}
101}
57
102
58/// Decoded atlases for the 2D preview selection (body first).
103/// Decoded atlases for the 2D preview selection (body first).
@@ -96,6 +141,8 @@ impl EditorApp {
96 resume_candidates: Vec::new(),
141 resume_candidates: Vec::new(),
97 resume_scanned: false,
142 resume_scanned: false,
98 resume_scan_error: None,
143 resume_scan_error: None,
144 render_all: None,
145 render_all_summary: None,
99 }
146 }
100 }
147 }
101
148
@@ -421,63 +468,62 @@ impl EditorApp {
421 }
468 }
422 /// The job the Render action would start now (current project, body, equipment).
469 /// The job the Render action would start now (current project, body, equipment).
423 fn build_job(&mut self) -> Option<RenderJob> {
470 fn build_job(&mut self) -> Option<RenderJob> {
424 let opened = self.session.opened.as_ref()?;
425 if let Err(error) = opened.document.validate_shape() {
426 self.session.fail(error.to_string());
427 return None;
428 }
429 let Some(character) = self.character().cloned() else {
430 self.session
431 .fail("dit lichaam heeft geen tekenbaar glTF/GLB-, FBX- of OBJ-model");
432 return None;
433 };
434 let root = self.session.project_root()?;
435 if let Err(error) =
436 crate::project::prepare_output_dir(&root, &opened.document.output_directory)
437 {
438 self.session.fail(error.to_string());
439 return None;
440 }
441 let sex = usize::from(self.controls.sex == PreviewSex::Female);
471 let sex = usize::from(self.controls.sex == PreviewSex::Female);
442 let preset = match self.session.screen {
472 let equipment = match self.session.screen {
443 Screen::Equipment(i) => self.session.equipment.get(i).and_then(|i| i.preset.clone()),
473 Screen::Equipment(i) => Some(i),
444 _ => None,
474 _ => None,
445 };
475 };
446 if matches!(self.session.screen, Screen::Equipment(_)) && preset.is_none() {
476 match self.job_for(sex, equipment) {
447 self.session.fail("configureer eerst het equipment");
477 Ok(job) => Some(job),
448 return None;
478 Err(error) => {
479 self.session.fail(error);
480 None
481 }
449 }
482 }
450 let body_rel = match self.controls.sex {
483 }
451 PreviewSex::Male => opened.document.male_model.clone(),
484 /// Render job for one body (sex 0 = male, 1 = female) and optional equipment item.
452 PreviewSex::Female => opened.document.female_model.clone(),
485 fn job_for(&self, sex: usize, equipment: Option<usize>) -> Result<RenderJob, String> {
453 };
486 let opened = self.session.opened.as_ref().ok_or("geen project geopend")?;
454 let Some(body_rel) = body_rel else {
487 opened
455 self.session.fail("dit lichaam heeft geen modelpad");
488 .document
456 return None;
489 .validate_shape()
490 .map_err(|e| e.to_string())?;
491 let character = match sex {
492 0 => self.preview_male.as_ref(),
493 _ => self.preview_female.as_ref(),
494 }
495 .cloned()
496 .ok_or("dit lichaam heeft geen tekenbaar glTF/GLB-, FBX- of OBJ-model")?;
497 let root = self.session.project_root().ok_or("geen projectmap")?;
498 crate::project::prepare_output_dir(&root, &opened.document.output_directory)
499 .map_err(|e| e.to_string())?;
500 let preset = match equipment {
501 Some(i) => Some(
502 self.session
503 .equipment
504 .get(i)
505 .and_then(|i| i.preset.clone())
506 .ok_or("configureer eerst het equipment")?,
507 ),
508 None => None,
457 };
509 };
458 if let Err(error) = crate::project::portable_relative(&body_rel) {
510 let body_rel = match sex {
459 self.session.fail(error);
511 0 => opened.document.male_model.clone(),
460 return None;
512 _ => opened.document.female_model.clone(),
461 }
513 }
514 .ok_or("dit lichaam heeft geen modelpad")?;
515 crate::project::portable_relative(&body_rel)?;
462 let body_path = root.join(&body_rel);
516 let body_path = root.join(&body_rel);
463 let body_sha = match crate::digest::sha256_file(&body_path, 32 * 1024 * 1024) {
517 let body_sha = crate::digest::sha256_file(&body_path, 32 * 1024 * 1024)
464 Ok(hash) => hash,
518 .map_err(|e| format!("body-fingerprint: {e}"))?;
465 Err(error) => {
466 self.session.fail(format!("body-fingerprint: {error}"));
467 return None;
468 }
469 };
470 let preset_sha256 = match &preset {
519 let preset_sha256 = match &preset {
471 None => None,
520 None => None,
472 Some(p) => match p.encode() {
521 Some(p) => Some(crate::digest::sha256_hex(
473 Ok(bytes) => Some(crate::digest::sha256_hex(&bytes)),
522 &p.encode()
474 Err(error) => {
523 .map_err(|e| format!("equipment-fingerprint: {e}"))?,
475 self.session.fail(format!("equipment-fingerprint: {error}"));
524 )),
476 return None;
477 }
478 },
479 };
525 };
480 Some(RenderJob {
526 Ok(RenderJob {
481 character,
527 character,
482 sex: if sex == 0 { "male" } else { "female" }.into(),
528 sex: if sex == 0 { "male" } else { "female" }.into(),
483 preset,
529 preset,
@@ -495,6 +541,184 @@ impl EditorApp {
495 preset_sha256,
541 preset_sha256,
496 })
542 })
497 }
543 }
544 /// Every body (male, female) and then every equipment item per applicable
545 /// sex, plus items skipped before rendering (with the reason).
546 pub fn render_all_plan(&self) -> (Vec<RenderAllItem>, Vec<(String, String)>) {
547 let mut items = Vec::new();
548 let mut skipped = Vec::new();
549 let names = ["man", "vrouw"];
550 for (sex, name) in names.iter().enumerate() {
551 let label = format!("{name} · body");
552 if self
553 .session
554 .body_path(if sex == 0 { Sex::Male } else { Sex::Female })
555 .is_none()
556 {
557 skipped.push((label, "geen lichaam ingesteld".into()));
558 } else {
559 items.push(RenderAllItem {
560 sex,
561 equipment: None,
562 label,
563 });
564 }
565 }
566 for (index, item) in self.session.equipment.iter().enumerate() {
567 let Some(preset) = &item.preset else {
568 skipped.push((
569 format!("{} · equipment", item.slug),
570 item.error
571 .clone()
572 .unwrap_or_else(|| "equipment is niet geconfigureerd".into()),
573 ));
574 continue;
575 };
576 for (sex, name) in names.iter().enumerate() {
577 let label = format!("{name} · {}", item.slug);
578 if !preset.applies[sex] {
579 continue; // not applicable to this sex by design
580 }
581 if self
582 .session
583 .body_path(if sex == 0 { Sex::Male } else { Sex::Female })
584 .is_none()
585 {
586 skipped.push((label, "geen lichaam ingesteld voor dit geslacht".into()));
587 } else {
588 items.push(RenderAllItem {
589 sex,
590 equipment: Some(index),
591 label,
592 });
593 }
594 }
595 }
596 (items, skipped)
597 }
598 /// **Render alles**: queue every body and equipment export.
599 pub fn start_render_all(&mut self) {
600 if self.render_ui.running || self.render_all.is_some() {
601 return;
602 }
603 let (items, skipped) = self.render_all_plan();
604 self.render_all_summary = None;
605 self.render_all = Some(RenderAll {
606 total: items.len(),
607 queue: items.into(),
608 started: 0,
609 current: None,
610 summary: RenderAllSummary {
611 skipped,
612 ..Default::default()
613 },
614 });
615 self.render_all_next();
616 }
617 pub fn render_all_running(&self) -> bool {
618 self.render_all.is_some()
619 }
620 pub fn render_all_summary(&self) -> Option<&RenderAllSummary> {
621 self.render_all_summary.as_ref()
622 }
623 /// Start the next renderable queue item; items that cannot be built or fail
624 /// the render contract are skipped with their diagnostic. A matching
625 /// interrupted run of the item is resumed instead of starting over.
626 fn render_all_next(&mut self) {
627 loop {
628 let Some(state) = self.render_all.as_mut() else {
629 return;
630 };
631 let Some(item) = state.queue.pop_front() else {
632 let mut done = self.render_all.take().unwrap().summary;
633 done.cancelled = false;
634 self.finish_render_all(done);
635 return;
636 };
637 state.started += 1;
638 let job = self.job_for(item.sex, item.equipment).and_then(|job| {
639 let output = job.output.clone();
640 let contract = crate::production::contract(job.clone())?;
641 let resume = crate::resume::candidates(&output, &contract.render_doc)
642 .ok()
643 .and_then(|c| c.into_iter().find(|c| c.blocked.is_none()))
644 .map(|c| c.dir);
645 Ok((job, resume))
646 });
647 let state = self.render_all.as_mut().unwrap();
648 match job {
649 Err(reason) => state.summary.skipped.push((item.label, reason)),
650 Ok((job, resume)) => {
651 state.current = Some(item.label);
652 let worker = match resume {
653 Some(dir) => crate::render_job::spawn_resume(job, dir, None),
654 None => spawn(job, None),
655 };
656 self.render_worker = Some(worker);
657 self.begin_worker();
658 return;
659 }
660 }
661 }
662 }
663 /// Record the outcome of the current item and continue (or stop on cancel).
664 fn render_all_item_done(&mut self) {
665 let Some(state) = self.render_all.as_mut() else {
666 return;
667 };
668 let label = state.current.take().unwrap_or_default();
669 if self.render_ui.saved.is_some() {
670 state.summary.rendered.push(label);
671 } else if let Some(error) = &self.render_ui.error {
672 state.summary.failed.push((label, error.clone()));
673 } else {
674 // Cancelled: stop the queue, the item stays resumable.
675 let state = self.render_all.take().unwrap();
676 let mut summary = state.summary;
677 summary
678 .failed
679 .push((label, "geannuleerd (hervatbaar)".into()));
680 summary.cancelled = true;
681 summary
682 .not_started
683 .extend(state.queue.into_iter().map(|i| i.label));
684 self.finish_render_all(summary);
685 return;
686 }
687 self.render_all_next();
688 }
689 fn finish_render_all(&mut self, summary: RenderAllSummary) {
690 self.session.notice = summary.text();
691 self.session.notice_ok = summary.failed.is_empty() && !summary.cancelled;
692 self.render_all_summary = Some(summary);
693 }
694 fn render_all_ui(&mut self, ui: &mut egui::Ui) {
695 if let Some(state) = &self.render_all {
696 ui.label(format!(
697 "Render alles: item {}/{} · {}",
698 state.started,
699 state.total,
700 state.current.as_deref().unwrap_or("")
701 ));
702 }
703 if let Some(summary) = &self.render_all_summary {
704 ui.label(summary.text());
705 for label in &summary.rendered {
706 ui.label(format!("✔ {label}"));
707 }
708 for (label, reason) in &summary.skipped {
709 ui.weak(format!("overgeslagen: {label} — {reason}"));
710 }
711 for (label, reason) in &summary.failed {
712 ui.colored_label(
713 egui::Color32::LIGHT_RED,
714 format!("mislukt: {label} — {reason}"),
715 );
716 }
717 for label in &summary.not_started {
718 ui.weak(format!("niet gestart: {label}"));
719 }
720 }
721 }
498 fn start_render(&mut self) {
722 fn start_render(&mut self) {
499 if self.render_ui.running {
723 if self.render_ui.running {
500 return;
724 return;
@@ -638,7 +862,9 @@ impl EditorApp {
638 }
862 }
639 if terminal {
863 if terminal {
640 self.render_worker = None;
864 self.render_worker = None;
641 if self.render_ui.saved.is_none() && self.render_ui.error.is_none() {
865 if self.render_all.is_some() {
866 self.render_all_item_done();
867 } else if self.render_ui.saved.is_none() && self.render_ui.error.is_none() {
642 // Cancelled: offer the run for resumption right away.
868 // Cancelled: offer the run for resumption right away.
643 self.scan_resumable_runs();
869 self.scan_resumable_runs();
644 }
870 }
@@ -647,6 +873,7 @@ impl EditorApp {
647 "De renderworker stopte zonder eindresultaat.".into(),
873 "De renderworker stopte zonder eindresultaat.".into(),
648 ));
874 ));
649 self.render_worker = None;
875 self.render_worker = None;
876 self.render_all_item_done();
650 }
877 }
651 }
878 }
652 fn cancel_render(&mut self) {
879 fn cancel_render(&mut self) {
@@ -916,10 +1143,27 @@ impl EditorApp {
916 });
1143 });
917 self.apply_render_fields();
1144 self.apply_render_fields();
918 ui.add_space(8.0);
1145 ui.add_space(8.0);
1146 ui.horizontal_wrapped(|ui| {
1147 if ui
1148 .add_enabled(
1149 !self.render_ui.running && self.render_all.is_none(),
1150 egui::Button::new("Render alles"),
1151 )
1152 .on_hover_text(
1153 "Rendert alle lichamen en daarna al het equipment per geldig geslacht, elk naar renders/<geslacht>-<settype>-atlas/.",
1154 )
1155 .clicked()
1156 {
1157 self.start_render_all();
1158 }
1159 });
1160 self.render_all_ui(ui);
1161 self.render_progress_ui(ui);
1162 ui.add_space(8.0);
919 if ui.button("2D-preview").clicked() {
1163 if ui.button("2D-preview").clicked() {
920 self.open_preview2d();
1164 self.open_preview2d();
921 }
1165 }
922 ui.label("Opent de gerenderde 2D-compositor over voltooide exportsets in renders/.");
1166 ui.label("Opent de gerenderde 2D-compositor over de resultaatmappen in renders/.");
923 });
1167 });
924 }
1168 }
925
1169
@@ -2123,6 +2367,55 @@ mod tests {
2123 let _ = std::fs::remove_dir_all(root.parent().unwrap());
2367 let _ = std::fs::remove_dir_all(root.parent().unwrap());
2124 }
2368 }
2125
2369
2370 fn pump(app: &mut EditorApp) {
2371 for _ in 0..20000 {
2372 if !app.render_all_running() {
2373 return;
2374 }
2375 app.service_gpu(None);
2376 app.drain_render();
2377 std::thread::sleep(std::time::Duration::from_millis(1));
2378 }
2379 panic!("Render alles liep niet af");
2380 }
2381
2382 #[test]
2383 fn render_all_queues_bodies_and_equipment_with_skip_diagnostics() {
2384 let root = root();
2385 let opened = crate::session::create_seeded_project(&root).unwrap();
2386 let mut app = EditorApp::default();
2387 app.open_path(opened.manifest_path.clone());
2388 let index = app.session.add_equipment();
2389 app.session.equipment[index].slug = "leeg".into();
2390 let (items, skipped) = app.render_all_plan();
2391 let keys: Vec<_> = items.iter().map(|i| (i.sex, i.equipment)).collect();
2392 assert_eq!(
2393 keys,
2394 vec![(0, None), (1, None), (0, Some(index)), (1, Some(index))]
2395 );
2396 assert!(skipped.is_empty());
2397 // Without a GL context every body render fails; empty equipment is
2398 // skipped by the render contract; the queue still continues.
2399 app.start_render_all();
2400 assert!(app.render_all_running());
2401 pump(&mut app);
2402 let summary = app.render_all_summary().unwrap().clone();
2403 assert_eq!(summary.failed.len(), 2, "{summary:?}");
2404 assert!(summary.failed[0].1.contains("Glow"), "{summary:?}");
2405 assert_eq!(summary.skipped.len(), 2, "{summary:?}");
2406 assert!(summary.skipped[0].0.contains("leeg"), "{summary:?}");
2407 assert!(!summary.cancelled);
2408 assert!(app.session.notice.contains("2 overgeslagen, 2 mislukt"));
2409 // Cancel stops the queue; the rest is listed as not started.
2410 app.start_render_all();
2411 app.cancel_render();
2412 pump(&mut app);
2413 let summary = app.render_all_summary().unwrap().clone();
2414 assert!(summary.cancelled, "{summary:?}");
2415 assert_eq!(summary.not_started.len(), 3, "{summary:?}");
2416 let _ = std::fs::remove_dir_all(root);
2417 }
2418
2126 #[test]
2419 #[test]
2127 fn renderer_disconnect_is_reported() {
2420 fn renderer_disconnect_is_reported() {
2128 let mut app = EditorApp::default();
2421 let mut app = EditorApp::default();