Merge feature/render-all: Render alles (9h)
@@ -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 **Opsla87
87
88
## Productie-output88
## Productie-output89
89
90
### Render alles91
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
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.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
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.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.@@ -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) with61
/// 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
out92
}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
/// 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.session431
.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
None481
}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
opened455
self.session.fail("dit lichaam heeft geen modelpad");488
.document456
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.session503
.equipment504
.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 applicable545
/// 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 self553
.session554
.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.error571
.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 design580
}581
if self582
.session583
.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 fail624
/// the render contract are skipped with their diagnostic. A matching625
/// 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
summary678
.failed679
.push((label, "geannuleerd (hervatbaar)".into()));680
summary.cancelled = true;681
summary682
.not_started683
.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 ui1148
.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 is2398
// 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();