wip: preserve tested equipment attachment and binary preset checkpoint
@@ -0,0 +1,64 @@
1
//! Prepare a self-contained example project and show its equipment editor.
2
use paperdoll_renderer::{
3
editor::EditorApp,
4
equipment, preview,
5
session::{Session, Sex},
6
};
7
use std::path::PathBuf;
8
fn main() -> Result<(), Box<dyn std::error::Error>> {
9
let args: Vec<String> = std::env::args().skip(1).collect();
10
let prepare_only = args.iter().any(|x| x == "--prepare-only");
11
let root = args
12
.iter()
13
.find(|x| !x.starts_with('-'))
14
.map(PathBuf::from)
15
.unwrap_or_else(|| {
16
std::env::temp_dir().join(format!(
17
"paperdoll-equipment-demo-{}",
18
std::time::SystemTime::now()
19
.duration_since(std::time::UNIX_EPOCH)
20
.unwrap()
21
.as_nanos()
22
))
23
});
24
let mut session = Session::new();
25
session.install_new_project(&root)?;
26
let fixtures = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/equipment");
27
let mut preset = equipment::Preset::new("sword".into());
28
preset.provenance = "CC0-1.0 generated fixture".into();
29
equipment::import_obj(&mut preset, &fixtures.join("sword.obj"))?;
30
equipment::import_shiny(&mut preset, 0, &fixtures.join("shiny.png"))?;
31
for (sex, name) in [(0, "test-body-male.glb"), (1, "test-body-female.glb")] {
32
let body = preview::load_gltf_file(&root.join("assets/body").join(name))?;
33
let joint = body
34
.joint_names
35
.iter()
36
.position(|n| n == "RightHand")
37
.ok_or("missing RightHand")?;
38
preset.bind(sex, &body, 0, joint)?;
39
}
40
preset.attachments[0].rotation_deg = [0., 0., -15.];
41
let index = session.add_equipment();
42
session.equipment[index].slug = "sword".into();
43
session.equipment[index].preset = Some(preset);
44
session.save()?;
45
println!("{}", root.join("character.ppd").display());
46
if prepare_only {
47
return Ok(());
48
}
49
let options = eframe::NativeOptions {
50
viewport: eframe::egui::ViewportBuilder::default().with_inner_size([1100., 720.]),
51
..Default::default()
52
};
53
eframe::run_native(
54
"Paperdoll Equipment",
55
options,
56
Box::new(move |_| {
57
let mut app = EditorApp::default();
58
app.open_path(root.join("character.ppd"));
59
app.open_equipment_configuration(0, Sex::Male);
60
Ok(Box::new(app))
61
}),
62
)?;
63
Ok(())
64
}
@@ -0,0 +1,64 @@1
//! Prepare a self-contained example project and show its equipment editor.2
use paperdoll_renderer::{3
editor::EditorApp,4
equipment, preview,5
session::{Session, Sex},6
};7
use std::path::PathBuf;8
fn main() -> Result<(), Box<dyn std::error::Error>> {9
let args: Vec<String> = std::env::args().skip(1).collect();10
let prepare_only = args.iter().any(|x| x == "--prepare-only");11
let root = args12
.iter()13
.find(|x| !x.starts_with('-'))14
.map(PathBuf::from)15
.unwrap_or_else(|| {16
std::env::temp_dir().join(format!(17
"paperdoll-equipment-demo-{}",18
std::time::SystemTime::now()19
.duration_since(std::time::UNIX_EPOCH)20
.unwrap()21
.as_nanos()22
))23
});24
let mut session = Session::new();25
session.install_new_project(&root)?;26
let fixtures = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/equipment");27
let mut preset = equipment::Preset::new("sword".into());28
preset.provenance = "CC0-1.0 generated fixture".into();29
equipment::import_obj(&mut preset, &fixtures.join("sword.obj"))?;30
equipment::import_shiny(&mut preset, 0, &fixtures.join("shiny.png"))?;31
for (sex, name) in [(0, "test-body-male.glb"), (1, "test-body-female.glb")] {32
let body = preview::load_gltf_file(&root.join("assets/body").join(name))?;33
let joint = body34
.joint_names35
.iter()36
.position(|n| n == "RightHand")37
.ok_or("missing RightHand")?;38
preset.bind(sex, &body, 0, joint)?;39
}40
preset.attachments[0].rotation_deg = [0., 0., -15.];41
let index = session.add_equipment();42
session.equipment[index].slug = "sword".into();43
session.equipment[index].preset = Some(preset);44
session.save()?;45
println!("{}", root.join("character.ppd").display());46
if prepare_only {47
return Ok(());48
}49
let options = eframe::NativeOptions {50
viewport: eframe::egui::ViewportBuilder::default().with_inner_size([1100., 720.]),51
..Default::default()52
};53
eframe::run_native(54
"Paperdoll Equipment",55
options,56
Box::new(move |_| {57
let mut app = EditorApp::default();58
app.open_path(root.join("character.ppd"));59
app.open_equipment_configuration(0, Sex::Male);60
Ok(Box::new(app))61
}),62
)?;63
Ok(())64
}@@ -12,6 +12,8 @@ pub enum DialogPurpose {
12
12
LoadProject,
13
13
Body(crate::session::Sex),
14
14
EquipmentModel(usize),
15
EquipmentShiny(usize, usize),
16
ImportEquipment,
15
17
}
16
18
17
19
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -127,8 +129,16 @@ fn native_pick(purpose: &DialogPurpose) -> Option<PathBuf> {
127
129
.add_filter("Model", &["glb", "gltf", "fbx", "obj"])
128
130
.pick_file(),
129
131
DialogPurpose::EquipmentModel(_) => dialog
130
.set_title("Equipmentmodel")
131
.add_filter("Model", &["glb", "gltf", "fbx", "obj"])
132
.set_title("Rigide equipment toevoegen")
133
.add_filter("Wavefront OBJ", &["obj"])
134
.pick_file(),
135
DialogPurpose::EquipmentShiny(_, _) => dialog
136
.set_title("Shinymap")
137
.add_filter("PNG", &["png"])
138
.pick_file(),
139
DialogPurpose::ImportEquipment => dialog
140
.set_title("Equipment-preset importeren")
141
.add_filter("Paperdoll equipment", &["ppe"])
132
142
.pick_file(),
133
143
}
134
144
}
@@ -12,6 +12,8 @@ pub enum DialogPurpose {12
LoadProject,12
LoadProject,13
Body(crate::session::Sex),13
Body(crate::session::Sex),14
EquipmentModel(usize),14
EquipmentModel(usize),15
EquipmentShiny(usize, usize),16
ImportEquipment,15
}17
}16
18
17
#[derive(Debug, Clone, PartialEq, Eq)]19
#[derive(Debug, Clone, PartialEq, Eq)]@@ -127,8 +129,16 @@ fn native_pick(purpose: &DialogPurpose) -> Option<PathBuf> {127
.add_filter("Model", &["glb", "gltf", "fbx", "obj"])129
.add_filter("Model", &["glb", "gltf", "fbx", "obj"])128
.pick_file(),130
.pick_file(),129
DialogPurpose::EquipmentModel(_) => dialog131
DialogPurpose::EquipmentModel(_) => dialog130
.set_title("Equipmentmodel")132
.set_title("Rigide equipment toevoegen")131
.add_filter("Model", &["glb", "gltf", "fbx", "obj"])133
.add_filter("Wavefront OBJ", &["obj"])134
.pick_file(),135
DialogPurpose::EquipmentShiny(_, _) => dialog136
.set_title("Shinymap")137
.add_filter("PNG", &["png"])138
.pick_file(),139
DialogPurpose::ImportEquipment => dialog140
.set_title("Equipment-preset importeren")141
.add_filter("Paperdoll equipment", &["ppe"])132
.pick_file(),142
.pick_file(),133
}143
}134
}144
}@@ -29,6 +29,7 @@ pub struct EditorApp {
29
29
fps_text: String,
30
30
allow_close: bool,
31
31
dialog_pending: bool,
32
equipment_part: usize,
32
33
}
33
34
34
35
impl EditorApp {
@@ -49,6 +50,7 @@ impl EditorApp {
49
50
fps_text: "12".to_string(),
50
51
allow_close: false,
51
52
dialog_pending: false,
53
equipment_part: 0,
52
54
}
53
55
}
54
56
@@ -179,6 +181,39 @@ impl EditorApp {
179
181
},
180
182
DialogPurpose::Body(sex) => self.take_model(sex, &path),
181
183
DialogPurpose::EquipmentModel(index) => self.take_equipment(index, &path),
184
DialogPurpose::EquipmentShiny(index, part) => {
185
if let Some(preset) = self
186
.session
187
.equipment
188
.get_mut(index)
189
.and_then(|i| i.preset.as_mut())
190
{
191
if let Err(error) = crate::equipment::import_shiny(preset, part, &path) {
192
self.session.fail(error);
193
}
194
}
195
}
196
DialogPurpose::ImportEquipment => match crate::equipment::Preset::read(&path) {
197
Ok(mut preset) => {
198
let base = preset.name.clone();
199
let mut n = 2;
200
while self.session.equipment.iter().any(|i| i.slug == preset.name) {
201
preset.name = format!("{base}-{n}");
202
n += 1;
203
}
204
let index = self.session.equipment.len();
205
self.session.equipment.push(crate::session::EquipmentItem {
206
slug: preset.name.clone(),
207
model: preset.attachments.first().map(|p| p.label.clone()),
208
preset: Some(preset),
209
legacy_path: None,
210
error: None,
211
});
212
self.equipment_part = 0;
213
self.session.show_equipment(index);
214
}
215
Err(error) => self.session.fail(error),
216
},
182
217
}
183
218
}
184
219
}
@@ -205,19 +240,51 @@ impl EditorApp {
205
240
}
206
241
207
242
fn take_equipment(&mut self, index: usize, path: &std::path::Path) {
208
let Some(root) = self.session.project_root() else {
243
let sex = if self.session.active_body == Some(Sex::Female) {
244
1
245
} else {
246
0
247
};
248
let body = if sex == 0 {
249
self.preview_male.as_ref()
250
} else {
251
self.preview_female.as_ref()
252
};
253
let Some(item) = self.session.equipment.get_mut(index) else {
209
254
return;
210
255
};
211
match adopt_file(&root, path, "assets/equipment") {
212
Ok(relative) => {
213
self.session.set_equipment_model(index, relative.clone());
214
self.session.notice = format!("{relative} hoort bij deze equipment.");
256
let mut preset = item
257
.preset
258
.clone()
259
.unwrap_or_else(|| crate::equipment::Preset::new(item.slug.clone()));
260
preset.name = item.slug.clone();
261
match crate::equipment::import_obj(&mut preset, path) {
262
Ok(part) => {
263
if let Some(body) = body {
264
if !body.joint_nodes.is_empty() {
265
let _ = preset.bind(sex, body, part, 0);
266
}
267
}
268
item.model = Some(preset.attachments[part].label.clone());
269
item.legacy_path = None;
270
item.error = None;
271
item.preset = Some(preset);
272
self.equipment_part = part;
273
self.session.notice =
274
"Onderdeel inclusief materiaal en textures ingesloten. Kies het doelbot."
275
.into();
215
276
self.session.notice_ok = true;
216
277
}
217
278
Err(error) => self.session.fail(error),
218
279
}
219
280
}
220
281
282
pub fn open_equipment_configuration(&mut self, index: usize, sex: Sex) {
283
self.session.active_body = Some(sex);
284
self.session.show_equipment(index);
285
self.equipment_part = 0;
286
}
287
221
288
fn begin(&mut self, intent: Intent) {
222
289
if self.session.gate(intent) {
223
290
self.run_intent(intent);
@@ -352,6 +419,11 @@ impl Default for EditorApp {
352
419
}
353
420
354
421
impl eframe::App for EditorApp {
422
fn on_exit(&mut self, gl: Option<&glow::Context>) {
423
if let Some(gl) = gl {
424
self.viewport.destroy(gl);
425
}
426
}
355
427
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
356
428
let shell = self.session.shell_size();
357
429
if self.applied_shell != Some(shell) {
@@ -582,7 +654,7 @@ impl EditorApp {
582
654
PreviewSex::Female => self.preview_female.as_ref(),
583
655
};
584
656
if character.is_some() {
585
self.viewport.show(ui, &mut self.controls, character);
657
self.viewport.show(ui, &mut self.controls, character, &[]);
586
658
} else {
587
659
ui.label("Open een glTF/GLB-lichaam voor de live 3D-weergave.");
588
660
}
@@ -637,8 +709,12 @@ impl EditorApp {
637
709
}
638
710
}
639
711
if ui.button("Nieuw").clicked() {
712
self.equipment_part = 0;
640
713
self.session.add_equipment();
641
714
}
715
if ui.button("Preset importeren").clicked() {
716
self.dialogs.start(DialogPurpose::ImportEquipment);
717
}
642
718
if let Some(index) = open {
643
719
self.session.show_equipment(index);
644
720
}
@@ -650,31 +726,176 @@ impl EditorApp {
650
726
let Screen::Equipment(index) = self.session.screen else {
651
727
return;
652
728
};
729
let mut sex = if self.session.active_body == Some(Sex::Female) {
730
1
731
} else {
732
0
733
};
734
egui::SidePanel::left("equipment-config")
735
.default_width(330.0)
736
.resizable(true)
737
.show(ctx, |ui| {
738
if self.session.confirm.is_some() || self.dialog_pending {
739
ui.disable();
740
}
741
egui::ScrollArea::vertical().show(ui, |ui| {
742
ui.heading("Equipment-configuratie");
743
if ui.button("Terug naar equipment").clicked() {
744
self.session.show_equipment_list();
745
}
746
ui.horizontal(|ui| {
747
ui.selectable_value(&mut sex, 0, "Man");
748
ui.selectable_value(&mut sex, 1, "Vrouw");
749
});
750
self.session.active_body = Some(if sex == 0 { Sex::Male } else { Sex::Female });
751
let Some(item) = self.session.equipment.get_mut(index) else {
752
return;
753
};
754
ui.label("Naam (kleine letters/cijfers/-/_)");
755
ui.text_edit_singleline(&mut item.slug);
756
if !crate::equipment::valid_name(&item.slug) {
757
ui.colored_label(
758
egui::Color32::LIGHT_RED,
759
"Ongeldige naam; 'body' is gereserveerd.",
760
);
761
}
762
if let Some(error) = &item.error {
763
ui.colored_label(egui::Color32::LIGHT_RED, error);
764
}
765
if ui.button("Onderdeel toevoegen (OBJ)").clicked() {
766
self.dialogs.start(DialogPurpose::EquipmentModel(index));
767
}
768
if let Some(preset) = &mut item.preset {
769
ui.horizontal(|ui| {
770
ui.label("Geldig voor:");
771
ui.checkbox(&mut preset.applies[0], "Man");
772
ui.checkbox(&mut preset.applies[1], "Vrouw");
773
});
774
if !preset.applies[0] && !preset.applies[1] {
775
ui.colored_label(
776
egui::Color32::LIGHT_RED,
777
"Kies minstens één geslacht.",
778
);
779
}
780
if !preset.attachments.is_empty() {
781
self.equipment_part =
782
self.equipment_part.min(preset.attachments.len() - 1);
783
egui::ComboBox::from_id_salt("equipment-part")
784
.selected_text(&preset.attachments[self.equipment_part].label)
785
.show_ui(ui, |ui| {
786
for (part, item) in preset.attachments.iter().enumerate() {
787
ui.selectable_value(
788
&mut self.equipment_part,
789
part,
790
&item.label,
791
);
792
}
793
});
794
let body = if sex == 0 {
795
self.preview_male.as_ref()
796
} else {
797
self.preview_female.as_ref()
798
};
799
let attachment = &mut preset.attachments[self.equipment_part];
800
ui.label("Positie relatief aan bot (XYZ)");
801
ui.horizontal(|ui| {
802
for value in &mut attachment.translation {
803
ui.add(
804
egui::DragValue::new(value)
805
.speed(0.005)
806
.range(-1000.0..=1000.0),
807
);
808
}
809
});
810
ui.label("Rotatie in graden XYZ (Rz·Ry·Rx)");
811
ui.horizontal(|ui| {
812
for value in &mut attachment.rotation_deg {
813
ui.add(
814
egui::DragValue::new(value)
815
.speed(0.5)
816
.range(-3600.0..=3600.0),
817
);
818
}
819
});
820
ui.add(
821
egui::Slider::new(&mut attachment.specular, 0.0..=1.0)
822
.text("Glanssterkte"),
823
);
824
let shiny = attachment
825
.shiny
826
.and_then(|i| preset.assets.get(i))
827
.map(|a| a.path.clone());
828
ui.label(shiny.unwrap_or_else(|| "Glanssterkte zonder texture".into()));
829
if ui.button("Kies shinymap (PNG)").clicked() {
830
self.dialogs.start(DialogPurpose::EquipmentShiny(
831
index,
832
self.equipment_part,
833
));
834
}
835
if ui.button("Wis shinymap").clicked() {
836
attachment.shiny = None;
837
}
838
let selected = attachment.bones[sex].clone();
839
ui.label(format!(
840
"Bot: {}",
841
selected.as_deref().unwrap_or("nog niet gekoppeld")
842
));
843
if let Some(body) = body {
844
if preset.rigs[sex].as_ref()
845
!= Some(&crate::equipment::RigSignature::of(body))
846
{
847
ui.colored_label(
848
egui::Color32::LIGHT_RED,
849
"Rigbinding ontbreekt of wijkt af; selecteer een bot.",
850
);
851
}
852
ui.collapsing("Skeleton / botten", |ui| {
853
if let Some(joint) = bone_tree(ui, body, selected.as_deref()) {
854
if let Err(error) =
855
preset.bind(sex, body, self.equipment_part, joint)
856
{
857
ui.colored_label(egui::Color32::LIGHT_RED, error);
858
}
859
}
860
});
861
} else {
862
ui.colored_label(
863
egui::Color32::LIGHT_RED,
864
"Open een geskind glTF/GLB-lichaam voor deze rigbinding.",
865
);
866
}
867
if ui.button("Verwijder onderdeel").clicked() {
868
preset.attachments.remove(self.equipment_part);
869
self.equipment_part = self.equipment_part.saturating_sub(1);
870
}
871
}
872
}
873
if ui.button("Verwijder equipmentdefinitie").clicked() {
874
self.session.delete_equipment(index);
875
}
876
});
877
});
878
self.controls.sex = if sex == 0 {
879
PreviewSex::Male
880
} else {
881
PreviewSex::Female
882
};
653
883
egui::CentralPanel::default().show(ctx, |ui| {
654
884
if self.session.confirm.is_some() || self.dialog_pending {
655
885
ui.disable();
656
886
}
657
ui.heading("Equipment");
658
if ui.button("Terug").clicked() {
659
self.session.show_equipment_list();
660
}
661
if let Some(item) = self.session.equipment.get_mut(index) {
662
ui.horizontal(|ui| {
663
ui.label("Naam");
664
ui.text_edit_singleline(&mut item.slug);
665
});
666
let model = item
667
.model
668
.clone()
669
.unwrap_or_else(|| "geen model".to_string());
670
ui.label(model);
671
}
672
if ui.button("Kies model").clicked() {
673
self.dialogs.start(DialogPurpose::EquipmentModel(index));
674
}
675
if ui.button("Verwijder").clicked() {
676
self.session.delete_equipment(index);
677
}
887
let body = if sex == 0 {
888
self.preview_male.as_ref()
889
} else {
890
self.preview_female.as_ref()
891
};
892
let preset = self
893
.session
894
.equipment
895
.get(index)
896
.and_then(|item| item.preset.as_ref());
897
let presets: Vec<_> = preset.into_iter().collect();
898
self.viewport.show(ui, &mut self.controls, body, &presets);
678
899
});
679
900
}
680
901
@@ -710,6 +931,69 @@ impl EditorApp {
710
931
}
711
932
}
712
933
934
fn bone_tree(ui: &mut egui::Ui, body: &PreviewCharacter, selected: Option<&str>) -> Option<usize> {
935
fn node(
936
ui: &mut egui::Ui,
937
body: &PreviewCharacter,
938
paths: &[String],
939
index: usize,
940
selected: Option<&str>,
941
choice: &mut Option<usize>,
942
depth: usize,
943
) {
944
if depth > 64 {
945
return;
946
}
947
let joint = body.joint_nodes.iter().position(|i| *i == index);
948
let children: Vec<_> = body
949
.nodes
950
.iter()
951
.enumerate()
952
.filter(|(_, n)| n.parent == Some(index))
953
.map(|(i, _)| i)
954
.collect();
955
let label = if body.nodes[index].name.is_empty() {
956
format!("node-{index}")
957
} else {
958
body.nodes[index].name.clone()
959
};
960
let select = |ui: &mut egui::Ui, choice: &mut Option<usize>| {
961
if let Some(joint) = joint {
962
if ui
963
.selectable_label(
964
selected == Some(paths[joint].as_str()),
965
format!("Koppel {label}"),
966
)
967
.clicked()
968
{
969
*choice = Some(joint);
970
}
971
}
972
};
973
if children.is_empty() {
974
select(ui, choice);
975
} else {
976
egui::CollapsingHeader::new(&label)
977
.id_salt(index)
978
.default_open(depth < 2)
979
.show(ui, |ui| {
980
select(ui, choice);
981
for child in children {
982
node(ui, body, paths, child, selected, choice, depth + 1);
983
}
984
});
985
}
986
}
987
let mut choice = None;
988
let paths = crate::preview::joint_paths(body);
989
for (index, item) in body.nodes.iter().enumerate() {
990
if item.parent.is_none() {
991
node(ui, body, &paths, index, selected, &mut choice, 0);
992
}
993
}
994
choice
995
}
996
713
997
#[cfg(test)]
714
998
mod tests {
715
999
use super::*;
@@ -29,6 +29,7 @@ pub struct EditorApp {29
fps_text: String,29
fps_text: String,30
allow_close: bool,30
allow_close: bool,31
dialog_pending: bool,31
dialog_pending: bool,32
equipment_part: usize,32
}33
}33
34
34
impl EditorApp {35
impl EditorApp {@@ -49,6 +50,7 @@ impl EditorApp {49
fps_text: "12".to_string(),50
fps_text: "12".to_string(),50
allow_close: false,51
allow_close: false,51
dialog_pending: false,52
dialog_pending: false,53
equipment_part: 0,52
}54
}53
}55
}54
56
@@ -179,6 +181,39 @@ impl EditorApp {179
},181
},180
DialogPurpose::Body(sex) => self.take_model(sex, &path),182
DialogPurpose::Body(sex) => self.take_model(sex, &path),181
DialogPurpose::EquipmentModel(index) => self.take_equipment(index, &path),183
DialogPurpose::EquipmentModel(index) => self.take_equipment(index, &path),184
DialogPurpose::EquipmentShiny(index, part) => {185
if let Some(preset) = self186
.session187
.equipment188
.get_mut(index)189
.and_then(|i| i.preset.as_mut())190
{191
if let Err(error) = crate::equipment::import_shiny(preset, part, &path) {192
self.session.fail(error);193
}194
}195
}196
DialogPurpose::ImportEquipment => match crate::equipment::Preset::read(&path) {197
Ok(mut preset) => {198
let base = preset.name.clone();199
let mut n = 2;200
while self.session.equipment.iter().any(|i| i.slug == preset.name) {201
preset.name = format!("{base}-{n}");202
n += 1;203
}204
let index = self.session.equipment.len();205
self.session.equipment.push(crate::session::EquipmentItem {206
slug: preset.name.clone(),207
model: preset.attachments.first().map(|p| p.label.clone()),208
preset: Some(preset),209
legacy_path: None,210
error: None,211
});212
self.equipment_part = 0;213
self.session.show_equipment(index);214
}215
Err(error) => self.session.fail(error),216
},182
}217
}183
}218
}184
}219
}@@ -205,19 +240,51 @@ impl EditorApp {205
}240
}206
241
207
fn take_equipment(&mut self, index: usize, path: &std::path::Path) {242
fn take_equipment(&mut self, index: usize, path: &std::path::Path) {208
let Some(root) = self.session.project_root() else {243
let sex = if self.session.active_body == Some(Sex::Female) {244
1245
} else {246
0247
};248
let body = if sex == 0 {249
self.preview_male.as_ref()250
} else {251
self.preview_female.as_ref()252
};253
let Some(item) = self.session.equipment.get_mut(index) else {209
return;254
return;210
};255
};211
match adopt_file(&root, path, "assets/equipment") {256
let mut preset = item212
Ok(relative) => {257
.preset213
self.session.set_equipment_model(index, relative.clone());258
.clone()214
self.session.notice = format!("{relative} hoort bij deze equipment.");259
.unwrap_or_else(|| crate::equipment::Preset::new(item.slug.clone()));260
preset.name = item.slug.clone();261
match crate::equipment::import_obj(&mut preset, path) {262
Ok(part) => {263
if let Some(body) = body {264
if !body.joint_nodes.is_empty() {265
let _ = preset.bind(sex, body, part, 0);266
}267
}268
item.model = Some(preset.attachments[part].label.clone());269
item.legacy_path = None;270
item.error = None;271
item.preset = Some(preset);272
self.equipment_part = part;273
self.session.notice =274
"Onderdeel inclusief materiaal en textures ingesloten. Kies het doelbot."275
.into();215
self.session.notice_ok = true;276
self.session.notice_ok = true;216
}277
}217
Err(error) => self.session.fail(error),278
Err(error) => self.session.fail(error),218
}279
}219
}280
}220
281
282
pub fn open_equipment_configuration(&mut self, index: usize, sex: Sex) {283
self.session.active_body = Some(sex);284
self.session.show_equipment(index);285
self.equipment_part = 0;286
}287
221
fn begin(&mut self, intent: Intent) {288
fn begin(&mut self, intent: Intent) {222
if self.session.gate(intent) {289
if self.session.gate(intent) {223
self.run_intent(intent);290
self.run_intent(intent);@@ -352,6 +419,11 @@ impl Default for EditorApp {352
}419
}353
420
354
impl eframe::App for EditorApp {421
impl eframe::App for EditorApp {422
fn on_exit(&mut self, gl: Option<&glow::Context>) {423
if let Some(gl) = gl {424
self.viewport.destroy(gl);425
}426
}355
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {427
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {356
let shell = self.session.shell_size();428
let shell = self.session.shell_size();357
if self.applied_shell != Some(shell) {429
if self.applied_shell != Some(shell) {@@ -582,7 +654,7 @@ impl EditorApp {582
PreviewSex::Female => self.preview_female.as_ref(),654
PreviewSex::Female => self.preview_female.as_ref(),583
};655
};584
if character.is_some() {656
if character.is_some() {585
self.viewport.show(ui, &mut self.controls, character);657
self.viewport.show(ui, &mut self.controls, character, &[]);586
} else {658
} else {587
ui.label("Open een glTF/GLB-lichaam voor de live 3D-weergave.");659
ui.label("Open een glTF/GLB-lichaam voor de live 3D-weergave.");588
}660
}@@ -637,8 +709,12 @@ impl EditorApp {637
}709
}638
}710
}639
if ui.button("Nieuw").clicked() {711
if ui.button("Nieuw").clicked() {712
self.equipment_part = 0;640
self.session.add_equipment();713
self.session.add_equipment();641
}714
}715
if ui.button("Preset importeren").clicked() {716
self.dialogs.start(DialogPurpose::ImportEquipment);717
}642
if let Some(index) = open {718
if let Some(index) = open {643
self.session.show_equipment(index);719
self.session.show_equipment(index);644
}720
}@@ -650,31 +726,176 @@ impl EditorApp {650
let Screen::Equipment(index) = self.session.screen else {726
let Screen::Equipment(index) = self.session.screen else {651
return;727
return;652
};728
};729
let mut sex = if self.session.active_body == Some(Sex::Female) {730
1731
} else {732
0733
};734
egui::SidePanel::left("equipment-config")735
.default_width(330.0)736
.resizable(true)737
.show(ctx, |ui| {738
if self.session.confirm.is_some() || self.dialog_pending {739
ui.disable();740
}741
egui::ScrollArea::vertical().show(ui, |ui| {742
ui.heading("Equipment-configuratie");743
if ui.button("Terug naar equipment").clicked() {744
self.session.show_equipment_list();745
}746
ui.horizontal(|ui| {747
ui.selectable_value(&mut sex, 0, "Man");748
ui.selectable_value(&mut sex, 1, "Vrouw");749
});750
self.session.active_body = Some(if sex == 0 { Sex::Male } else { Sex::Female });751
let Some(item) = self.session.equipment.get_mut(index) else {752
return;753
};754
ui.label("Naam (kleine letters/cijfers/-/_)");755
ui.text_edit_singleline(&mut item.slug);756
if !crate::equipment::valid_name(&item.slug) {757
ui.colored_label(758
egui::Color32::LIGHT_RED,759
"Ongeldige naam; 'body' is gereserveerd.",760
);761
}762
if let Some(error) = &item.error {763
ui.colored_label(egui::Color32::LIGHT_RED, error);764
}765
if ui.button("Onderdeel toevoegen (OBJ)").clicked() {766
self.dialogs.start(DialogPurpose::EquipmentModel(index));767
}768
if let Some(preset) = &mut item.preset {769
ui.horizontal(|ui| {770
ui.label("Geldig voor:");771
ui.checkbox(&mut preset.applies[0], "Man");772
ui.checkbox(&mut preset.applies[1], "Vrouw");773
});774
if !preset.applies[0] && !preset.applies[1] {775
ui.colored_label(776
egui::Color32::LIGHT_RED,777
"Kies minstens één geslacht.",778
);779
}780
if !preset.attachments.is_empty() {781
self.equipment_part =782
self.equipment_part.min(preset.attachments.len() - 1);783
egui::ComboBox::from_id_salt("equipment-part")784
.selected_text(&preset.attachments[self.equipment_part].label)785
.show_ui(ui, |ui| {786
for (part, item) in preset.attachments.iter().enumerate() {787
ui.selectable_value(788
&mut self.equipment_part,789
part,790
&item.label,791
);792
}793
});794
let body = if sex == 0 {795
self.preview_male.as_ref()796
} else {797
self.preview_female.as_ref()798
};799
let attachment = &mut preset.attachments[self.equipment_part];800
ui.label("Positie relatief aan bot (XYZ)");801
ui.horizontal(|ui| {802
for value in &mut attachment.translation {803
ui.add(804
egui::DragValue::new(value)805
.speed(0.005)806
.range(-1000.0..=1000.0),807
);808
}809
});810
ui.label("Rotatie in graden XYZ (Rz·Ry·Rx)");811
ui.horizontal(|ui| {812
for value in &mut attachment.rotation_deg {813
ui.add(814
egui::DragValue::new(value)815
.speed(0.5)816
.range(-3600.0..=3600.0),817
);818
}819
});820
ui.add(821
egui::Slider::new(&mut attachment.specular, 0.0..=1.0)822
.text("Glanssterkte"),823
);824
let shiny = attachment825
.shiny826
.and_then(|i| preset.assets.get(i))827
.map(|a| a.path.clone());828
ui.label(shiny.unwrap_or_else(|| "Glanssterkte zonder texture".into()));829
if ui.button("Kies shinymap (PNG)").clicked() {830
self.dialogs.start(DialogPurpose::EquipmentShiny(831
index,832
self.equipment_part,833
));834
}835
if ui.button("Wis shinymap").clicked() {836
attachment.shiny = None;837
}838
let selected = attachment.bones[sex].clone();839
ui.label(format!(840
"Bot: {}",841
selected.as_deref().unwrap_or("nog niet gekoppeld")842
));843
if let Some(body) = body {844
if preset.rigs[sex].as_ref()845
!= Some(&crate::equipment::RigSignature::of(body))846
{847
ui.colored_label(848
egui::Color32::LIGHT_RED,849
"Rigbinding ontbreekt of wijkt af; selecteer een bot.",850
);851
}852
ui.collapsing("Skeleton / botten", |ui| {853
if let Some(joint) = bone_tree(ui, body, selected.as_deref()) {854
if let Err(error) =855
preset.bind(sex, body, self.equipment_part, joint)856
{857
ui.colored_label(egui::Color32::LIGHT_RED, error);858
}859
}860
});861
} else {862
ui.colored_label(863
egui::Color32::LIGHT_RED,864
"Open een geskind glTF/GLB-lichaam voor deze rigbinding.",865
);866
}867
if ui.button("Verwijder onderdeel").clicked() {868
preset.attachments.remove(self.equipment_part);869
self.equipment_part = self.equipment_part.saturating_sub(1);870
}871
}872
}873
if ui.button("Verwijder equipmentdefinitie").clicked() {874
self.session.delete_equipment(index);875
}876
});877
});878
self.controls.sex = if sex == 0 {879
PreviewSex::Male880
} else {881
PreviewSex::Female882
};653
egui::CentralPanel::default().show(ctx, |ui| {883
egui::CentralPanel::default().show(ctx, |ui| {654
if self.session.confirm.is_some() || self.dialog_pending {884
if self.session.confirm.is_some() || self.dialog_pending {655
ui.disable();885
ui.disable();656
}886
}657
ui.heading("Equipment");887
let body = if sex == 0 {658
if ui.button("Terug").clicked() {888
self.preview_male.as_ref()659
self.session.show_equipment_list();889
} else {660
}890
self.preview_female.as_ref()661
if let Some(item) = self.session.equipment.get_mut(index) {891
};662
ui.horizontal(|ui| {892
let preset = self663
ui.label("Naam");893
.session664
ui.text_edit_singleline(&mut item.slug);894
.equipment665
});895
.get(index)666
let model = item896
.and_then(|item| item.preset.as_ref());667
.model897
let presets: Vec<_> = preset.into_iter().collect();668
.clone()898
self.viewport.show(ui, &mut self.controls, body, &presets);669
.unwrap_or_else(|| "geen model".to_string());670
ui.label(model);671
}672
if ui.button("Kies model").clicked() {673
self.dialogs.start(DialogPurpose::EquipmentModel(index));674
}675
if ui.button("Verwijder").clicked() {676
self.session.delete_equipment(index);677
}678
});899
});679
}900
}680
901
@@ -710,6 +931,69 @@ impl EditorApp {710
}931
}711
}932
}712
933
934
fn bone_tree(ui: &mut egui::Ui, body: &PreviewCharacter, selected: Option<&str>) -> Option<usize> {935
fn node(936
ui: &mut egui::Ui,937
body: &PreviewCharacter,938
paths: &[String],939
index: usize,940
selected: Option<&str>,941
choice: &mut Option<usize>,942
depth: usize,943
) {944
if depth > 64 {945
return;946
}947
let joint = body.joint_nodes.iter().position(|i| *i == index);948
let children: Vec<_> = body949
.nodes950
.iter()951
.enumerate()952
.filter(|(_, n)| n.parent == Some(index))953
.map(|(i, _)| i)954
.collect();955
let label = if body.nodes[index].name.is_empty() {956
format!("node-{index}")957
} else {958
body.nodes[index].name.clone()959
};960
let select = |ui: &mut egui::Ui, choice: &mut Option<usize>| {961
if let Some(joint) = joint {962
if ui963
.selectable_label(964
selected == Some(paths[joint].as_str()),965
format!("Koppel {label}"),966
)967
.clicked()968
{969
*choice = Some(joint);970
}971
}972
};973
if children.is_empty() {974
select(ui, choice);975
} else {976
egui::CollapsingHeader::new(&label)977
.id_salt(index)978
.default_open(depth < 2)979
.show(ui, |ui| {980
select(ui, choice);981
for child in children {982
node(ui, body, paths, child, selected, choice, depth + 1);983
}984
});985
}986
}987
let mut choice = None;988
let paths = crate::preview::joint_paths(body);989
for (index, item) in body.nodes.iter().enumerate() {990
if item.parent.is_none() {991
node(ui, body, &paths, index, selected, &mut choice, 0);992
}993
}994
choice995
}996
713
#[cfg(test)]997
#[cfg(test)]714
mod tests {998
mod tests {715
use super::*;999
use super::*;@@ -0,0 +1,773 @@
1
//! Rigid equipment, portable embedded assets and a bounded binary container.
2
//! Local rotation uses Rz * Ry * Rx (degrees); attachment uses the animated
3
//! joint world transform, never a skin matrix or inverse bind a second time.
4
5
use std::collections::BTreeSet;
6
use std::io::{Cursor, Read, Write};
7
use std::path::Path;
8
9
use crate::preview::{
10
animated_joint_world, identity, joint_paths, mul, Mat4, PreviewCharacter, PreviewControls,
11
};
12
use crate::project::portable_relative;
13
use serde::{Deserialize, Serialize};
14
15
pub const MAGIC: &[u8; 8] = b"PPEQ\r\n\x1a\n";
16
pub const VERSION: u32 = 1;
17
pub const MAX_FILE: usize = 32 * 1024 * 1024;
18
const MAX_META: usize = 4 * 1024 * 1024;
19
const MAX_ASSET: usize = 8 * 1024 * 1024;
20
const MAX_ASSETS: usize = 32;
21
const MAX_VERTICES: usize = 80_000;
22
23
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
24
#[serde(deny_unknown_fields)]
25
pub struct EmbeddedAsset {
26
pub path: String,
27
#[serde(skip)]
28
pub bytes: Vec<u8>,
29
}
30
31
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
32
#[serde(deny_unknown_fields)]
33
pub struct Mesh {
34
pub positions: Vec<[f32; 3]>,
35
pub normals: Vec<[f32; 3]>,
36
pub uvs: Vec<[f32; 2]>,
37
pub indices: Vec<u32>,
38
pub color: [f32; 4],
39
pub albedo: Option<usize>,
40
}
41
42
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
43
#[serde(deny_unknown_fields)]
44
pub struct RigSignature {
45
pub paths: Vec<String>,
46
pub rest_world: Vec<Mat4>,
47
}
48
impl RigSignature {
49
pub fn of(body: &PreviewCharacter) -> Self {
50
Self {
51
paths: joint_paths(body),
52
rest_world: (0..body.joint_nodes.len())
53
.map(|j| animated_joint_world(body, "", 0.0, j).unwrap_or_else(identity))
54
.collect(),
55
}
56
}
57
}
58
59
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
60
#[serde(deny_unknown_fields)]
61
pub struct Attachment {
62
pub label: String,
63
pub bones: [Option<String>; 2],
64
pub translation: [f32; 3],
65
pub rotation_deg: [f32; 3],
66
pub meshes: Vec<Mesh>,
67
pub shiny: Option<usize>,
68
pub specular: f32,
69
}
70
71
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
72
#[serde(deny_unknown_fields)]
73
pub struct Preset {
74
pub name: String,
75
pub provenance: String,
76
pub applies: [bool; 2],
77
pub rigs: [Option<RigSignature>; 2],
78
pub attachments: Vec<Attachment>,
79
pub assets: Vec<EmbeddedAsset>,
80
}
81
impl Preset {
82
pub fn new(name: String) -> Self {
83
Self {
84
name,
85
provenance: String::new(),
86
applies: [true, true],
87
rigs: [None, None],
88
attachments: Vec::new(),
89
assets: Vec::new(),
90
}
91
}
92
pub fn bind(
93
&mut self,
94
sex: usize,
95
body: &PreviewCharacter,
96
attachment: usize,
97
joint: usize,
98
) -> Result<(), String> {
99
if sex > 1 {
100
return Err("ongeldig geslacht".into());
101
}
102
let paths = joint_paths(body);
103
let path = paths.get(joint).ok_or("dit bot bestaat niet")?.clone();
104
let item = self
105
.attachments
106
.get_mut(attachment)
107
.ok_or("dit onderdeel bestaat niet")?;
108
item.bones[sex] = Some(path);
109
self.rigs[sex] = Some(RigSignature::of(body));
110
Ok(())
111
}
112
pub fn validate(&self) -> Result<(), String> {
113
if !valid_name(&self.name) {
114
return Err(
115
"equipmentnaam: kleine letters/cijfers/-/_, geen spaties, en niet 'body'".into(),
116
);
117
}
118
if self.provenance.len() > 4096 || self.provenance.contains('\0') {
119
return Err("herkomst/licentie is te lang of bevat NUL".into());
120
}
121
if !self.applies.iter().any(|x| *x) {
122
return Err("kies minstens één geslacht".into());
123
}
124
if self.attachments.len() > 32 || self.assets.len() > MAX_ASSETS {
125
return Err("te veel onderdelen of bestanden".into());
126
}
127
let mut paths = BTreeSet::new();
128
let mut total = 0usize;
129
for asset in &self.assets {
130
portable_relative(&asset.path)?;
131
if asset.path.len() > 240 || asset.bytes.len() > MAX_ASSET || !paths.insert(&asset.path)
132
{
133
return Err("ongeldige, dubbele of te grote asset".into());
134
}
135
total = total
136
.checked_add(asset.bytes.len())
137
.ok_or("te veel assetdata")?;
138
}
139
if total > MAX_FILE {
140
return Err("te veel assetdata".into());
141
}
142
let mut texture_indices = BTreeSet::new();
143
let mut vertices = 0;
144
for rig in self.rigs.iter().flatten() {
145
if rig.paths.len() > 128
146
|| rig.paths.len() != rig.rest_world.len()
147
|| rig.paths.iter().any(|p| p.len() > 4096)
148
|| rig.rest_world.iter().flatten().any(|x| !x.is_finite())
149
{
150
return Err("ongeldige rigbinding".into());
151
}
152
}
153
for attachment in &self.attachments {
154
for sex in 0..2 {
155
if let Some(path) = &attachment.bones[sex] {
156
if !self.rigs[sex]
157
.as_ref()
158
.map(|rig| rig.paths.contains(path))
159
.unwrap_or(false)
160
{
161
return Err("attachment verwijst naar onbekend bot".into());
162
}
163
}
164
}
165
if attachment.label.len() > 128
166
|| attachment.bones.iter().flatten().any(|p| p.len() > 4096)
167
|| attachment
168
.translation
169
.iter()
170
.chain(attachment.rotation_deg.iter())
171
.any(|x| !x.is_finite() || x.abs() > 100_000.0)
172
|| !attachment.specular.is_finite()
173
|| !(0.0..=1.0).contains(&attachment.specular)
174
|| attachment.meshes.len() > 64
175
{
176
return Err("ongeldige attachment/transformatie".into());
177
}
178
if let Some(index) = attachment.shiny {
179
texture_indices.insert(index);
180
}
181
for mesh in &attachment.meshes {
182
vertices += mesh.positions.len();
183
if vertices > MAX_VERTICES
184
|| mesh.indices.len() > MAX_VERTICES * 6
185
|| mesh.indices.len() < 3
186
|| mesh.indices.len() % 3 != 0
187
|| mesh.positions.len() != mesh.normals.len()
188
|| mesh.positions.len() != mesh.uvs.len()
189
|| mesh.positions.is_empty()
190
|| mesh
191
.indices
192
.iter()
193
.any(|i| *i as usize >= mesh.positions.len())
194
|| mesh
195
.positions
196
.iter()
197
.flatten()
198
.chain(mesh.normals.iter().flatten())
199
.chain(mesh.uvs.iter().flatten())
200
.chain(mesh.color.iter())
201
.any(|x| !x.is_finite() || x.abs() > 1_000_000.0)
202
{
203
return Err("ongeldige of te grote equipmentmesh".into());
204
}
205
if let Some(index) = mesh.albedo {
206
texture_indices.insert(index);
207
}
208
}
209
}
210
let mut decoded = 0usize;
211
for index in texture_indices {
212
decoded += self.texture(index)?.rgba.len();
213
if decoded > 64 * 1024 * 1024 {
214
return Err("textures samen groter dan 64 MiB".into());
215
}
216
}
217
Ok(())
218
}
219
pub fn texture(&self, index: usize) -> Result<Texture, String> {
220
let asset = self
221
.assets
222
.get(index)
223
.ok_or("textureverwijzing ontbreekt")?;
224
decode_png(&asset.bytes)
225
}
226
pub fn encode(&self) -> Result<Vec<u8>, String> {
227
self.validate()?;
228
let metadata = serde_json::to_vec(self).map_err(|e| e.to_string())?;
229
if metadata.len() > MAX_META {
230
return Err("presetmetadata is te groot".into());
231
}
232
let mut payload = metadata;
233
let meta_len = payload.len();
234
for asset in &self.assets {
235
payload.extend_from_slice(&(asset.bytes.len() as u32).to_le_bytes());
236
payload.extend_from_slice(&asset.bytes);
237
}
238
if payload.len() + 20 > MAX_FILE {
239
return Err("preset is te groot".into());
240
}
241
let mut out = MAGIC.to_vec();
242
out.extend_from_slice(&VERSION.to_le_bytes());
243
out.extend_from_slice(&(meta_len as u32).to_le_bytes());
244
out.extend_from_slice(&checksum(&payload).to_le_bytes());
245
out.extend(payload);
246
Ok(out)
247
}
248
pub fn decode(bytes: &[u8]) -> Result<Self, String> {
249
if bytes.len() < 20 || bytes.len() > MAX_FILE || &bytes[..8] != MAGIC {
250
return Err("ongeldig equipmentbestand of bestand te groot".into());
251
}
252
let number = |offset| u32::from_le_bytes(bytes[offset..offset + 4].try_into().unwrap());
253
if number(8) != VERSION {
254
return Err("onbekende equipmentversie".into());
255
}
256
let meta_len = number(12) as usize;
257
if meta_len > MAX_META
258
|| meta_len > bytes.len() - 20
259
|| checksum(&bytes[20..]) != number(16)
260
{
261
return Err("beschadigde equipmentdata".into());
262
}
263
let mut preset: Self =
264
serde_json::from_slice(&bytes[20..20 + meta_len]).map_err(|e| e.to_string())?;
265
if preset.assets.len() > MAX_ASSETS {
266
return Err("te veel assets".into());
267
}
268
let mut cursor = 20 + meta_len;
269
for asset in &mut preset.assets {
270
if cursor + 4 > bytes.len() {
271
return Err("afgebroken asset".into());
272
}
273
let len = number(cursor) as usize;
274
cursor += 4;
275
if len > MAX_ASSET || len > bytes.len() - cursor {
276
return Err("ongeldige assetlengte".into());
277
}
278
asset.bytes = bytes[cursor..cursor + len].to_vec();
279
cursor += len;
280
}
281
if cursor != bytes.len() {
282
return Err("onverwachte extra equipmentdata".into());
283
}
284
preset.validate()?;
285
Ok(preset)
286
}
287
pub fn read(path: &Path) -> Result<Self, String> {
288
let file = std::fs::File::open(path).map_err(|e| e.to_string())?;
289
let mut bytes = Vec::new();
290
file.take((MAX_FILE + 1) as u64)
291
.read_to_end(&mut bytes)
292
.map_err(|e| e.to_string())?;
293
Self::decode(&bytes)
294
}
295
}
296
297
pub fn valid_name(name: &str) -> bool {
298
!name.is_empty()
299
&& name.len() <= 64
300
&& name != "body"
301
&& name
302
.bytes()
303
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'-' || c == b'_')
304
}
305
306
pub fn checksum(bytes: &[u8]) -> u32 {
307
let mut crc = !0u32;
308
for byte in bytes {
309
crc ^= u32::from(*byte);
310
for _ in 0..8 {
311
crc = (crc >> 1) ^ (0xedb88320u32 & (0u32.wrapping_sub(crc & 1)));
312
}
313
}
314
!crc
315
}
316
317
/// Immutable snapshot publish: a hard link atomically creates the destination
318
/// without overwriting an older saved preset. Failure leaves old data intact.
319
pub fn write_snapshot(path: &Path, bytes: &[u8]) -> Result<(), String> {
320
if bytes.len() > MAX_FILE {
321
return Err("snapshot te groot".into());
322
}
323
if path.exists() {
324
let mut current = Vec::new();
325
std::fs::File::open(path)
326
.map_err(|e| e.to_string())?
327
.take((MAX_FILE + 1) as u64)
328
.read_to_end(&mut current)
329
.map_err(|e| e.to_string())?;
330
return if current == bytes {
331
Ok(())
332
} else {
333
Err("snapshotnaam bestaat met andere inhoud".into())
334
};
335
}
336
let stamp = std::time::SystemTime::now()
337
.duration_since(std::time::UNIX_EPOCH)
338
.unwrap_or_default()
339
.as_nanos();
340
let temp = path.with_extension(format!("ppe.part-{}-{stamp}", std::process::id()));
341
let result = (|| {
342
let mut file = std::fs::OpenOptions::new()
343
.write(true)
344
.create_new(true)
345
.open(&temp)
346
.map_err(|e| e.to_string())?;
347
file.write_all(bytes)
348
.and_then(|_| file.sync_all())
349
.map_err(|e| e.to_string())?;
350
std::fs::hard_link(&temp, path).map_err(|e| e.to_string())?;
351
Ok(())
352
})();
353
let _ = std::fs::remove_file(temp);
354
result
355
}
356
357
#[derive(Debug, Clone, PartialEq)]
358
pub struct Texture {
359
pub content_hash: u64,
360
pub width: u32,
361
pub height: u32,
362
pub rgba: Vec<u8>,
363
}
364
fn decode_png(bytes: &[u8]) -> Result<Texture, String> {
365
let mut decoder = png::Decoder::new(Cursor::new(bytes));
366
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
367
let mut reader = decoder.read_info().map_err(|e| e.to_string())?;
368
let info = reader.info();
369
if info.width == 0 || info.height == 0 || info.width > 2048 || info.height > 2048 {
370
return Err("texture is groter dan 2048×2048".into());
371
}
372
let size = reader
373
.output_buffer_size()
374
.ok_or("ongeldige texturegrootte")?;
375
if size > 2048 * 2048 * 8 {
376
return Err("texture is te groot".into());
377
}
378
let mut data = vec![0; size];
379
let info = reader.next_frame(&mut data).map_err(|e| e.to_string())?;
380
let channels = info.color_type.samples();
381
let mut rgba = Vec::with_capacity(info.width as usize * info.height as usize * 4);
382
for pixel in data[..info.buffer_size()].chunks_exact(channels) {
383
match info.color_type {
384
png::ColorType::Grayscale => {
385
rgba.extend_from_slice(&[pixel[0], pixel[0], pixel[0], 255])
386
}
387
png::ColorType::GrayscaleAlpha => {
388
rgba.extend_from_slice(&[pixel[0], pixel[0], pixel[0], pixel[1]])
389
}
390
png::ColorType::Rgb => rgba.extend_from_slice(&[pixel[0], pixel[1], pixel[2], 255]),
391
png::ColorType::Rgba => rgba.extend_from_slice(pixel),
392
_ => return Err("onbekende texturecodering".into()),
393
}
394
}
395
use std::hash::{Hash, Hasher};
396
let mut hash = std::collections::hash_map::DefaultHasher::new();
397
rgba.hash(&mut hash);
398
Ok(Texture {
399
content_hash: hash.finish(),
400
width: info.width,
401
height: info.height,
402
rgba,
403
})
404
}
405
406
pub fn local_matrix(translation: [f32; 3], degrees: [f32; 3]) -> Mat4 {
407
let [x, y, z] = degrees.map(f32::to_radians);
408
let (sx, cx) = x.sin_cos();
409
let (sy, cy) = y.sin_cos();
410
let (sz, cz) = z.sin_cos();
411
let rx = [
412
1., 0., 0., 0., 0., cx, sx, 0., 0., -sx, cx, 0., 0., 0., 0., 1.,
413
];
414
let ry = [
415
cy, 0., -sy, 0., 0., 1., 0., 0., sy, 0., cy, 0., 0., 0., 0., 1.,
416
];
417
let rz = [
418
cz, sz, 0., 0., -sz, cz, 0., 0., 0., 0., 1., 0., 0., 0., 0., 1.,
419
];
420
let mut matrix = mul(rz, mul(ry, rx));
421
matrix[12] = translation[0];
422
matrix[13] = translation[1];
423
matrix[14] = translation[2];
424
matrix
425
}
426
427
#[derive(Debug, Clone)]
428
pub struct Layer {
429
pub mesh: Mesh,
430
pub model: Mat4,
431
pub albedo: Option<std::sync::Arc<Texture>>,
432
pub shiny: Option<std::sync::Arc<Texture>>,
433
pub specular: f32,
434
}
435
436
#[derive(Default)]
437
pub struct TextureCache {
438
entries: std::collections::VecDeque<(Vec<u8>, std::sync::Arc<Texture>)>,
439
bytes: usize,
440
}
441
impl TextureCache {
442
fn get(&mut self, preset: &Preset, index: usize) -> Result<std::sync::Arc<Texture>, String> {
443
let bytes = &preset.assets.get(index).ok_or("texture ontbreekt")?.bytes;
444
if let Some((_, texture)) = self.entries.iter().find(|(source, _)| source == bytes) {
445
return Ok(texture.clone());
446
}
447
let texture = std::sync::Arc::new(decode_png(bytes)?);
448
let cost = bytes.len() + texture.rgba.len();
449
while self.bytes + cost > 128 * 1024 * 1024 {
450
if let Some((source, image)) = self.entries.pop_front() {
451
self.bytes -= source.len() + image.rgba.len();
452
} else {
453
break;
454
}
455
}
456
self.bytes += cost;
457
self.entries.push_back((bytes.clone(), texture.clone()));
458
Ok(texture)
459
}
460
}
461
462
pub fn layers(
463
preset: &Preset,
464
sex: usize,
465
body: &PreviewCharacter,
466
controls: &PreviewControls,
467
) -> Result<Vec<Layer>, String> {
468
layers_cached(preset, sex, body, controls, &mut TextureCache::default())
469
}
470
471
pub fn layers_cached(
472
preset: &Preset,
473
sex: usize,
474
body: &PreviewCharacter,
475
controls: &PreviewControls,
476
cache: &mut TextureCache,
477
) -> Result<Vec<Layer>, String> {
478
if sex > 1 {
479
return Err("ongeldig geslacht".into());
480
}
481
if !preset.applies[sex] {
482
return Ok(Vec::new());
483
}
484
let signature = RigSignature::of(body);
485
if preset.rigs[sex].as_ref() != Some(&signature) {
486
return Err("rig wijkt af: kies de botten opnieuw voor dit lichaam".into());
487
}
488
let paths = joint_paths(body);
489
let mut layers = Vec::new();
490
for item in &preset.attachments {
491
let path = item.bones[sex]
492
.as_deref()
493
.ok_or("kies een bot voor dit onderdeel en geslacht")?;
494
let joint = paths
495
.iter()
496
.position(|p| p == path)
497
.ok_or("het gekoppelde bot ontbreekt")?;
498
let world = animated_joint_world(body, &controls.clip_name, controls.time, joint)
499
.ok_or("het gekoppelde bot ontbreekt")?;
500
let model = mul(world, local_matrix(item.translation, item.rotation_deg));
501
let shiny = item.shiny.map(|i| cache.get(preset, i)).transpose()?;
502
for mesh in &item.meshes {
503
layers.push(Layer {
504
mesh: mesh.clone(),
505
model,
506
albedo: mesh.albedo.map(|i| cache.get(preset, i)).transpose()?,
507
shiny: shiny.clone(),
508
specular: item.specular,
509
});
510
}
511
}
512
Ok(layers)
513
}
514
515
/// Import an OBJ and its declared MTL/PNG dependencies into the preset itself.
516
/// No extraction directories, absolute references or parent traversal allowed.
517
pub fn import_obj(preset: &mut Preset, source: &Path) -> Result<usize, String> {
518
if source
519
.extension()
520
.and_then(|x| x.to_str())
521
.map(|x| !x.eq_ignore_ascii_case("obj"))
522
.unwrap_or(true)
523
{
524
return Err("kies een rigide OBJ-model met MTL/PNG-bijbestanden".into());
525
}
526
let root = source
527
.parent()
528
.ok_or("modelmap ontbreekt")?
529
.canonicalize()
530
.map_err(|e| e.to_string())?;
531
let name = source
532
.file_name()
533
.and_then(|x| x.to_str())
534
.ok_or("ongeldige modelnaam")?;
535
let mut candidate = preset.clone();
536
if candidate.attachments.len() >= 32 {
537
return Err("te veel onderdelen".into());
538
}
539
let prefix = format!("part-{}", candidate.attachments.len());
540
let obj_bytes = read_asset(&root, name)?;
541
let obj_text = std::str::from_utf8(&obj_bytes).map_err(|e| e.to_string())?;
542
let mut dependencies = BTreeSet::new();
543
dependencies.insert(name.to_string());
544
for line in obj_text.lines() {
545
if let Some(rest) = line.trim().strip_prefix("mtllib ") {
546
let mtl = rest.trim();
547
dependencies.insert(mtl.to_string());
548
let bytes = read_asset(&root, mtl)?;
549
let text = std::str::from_utf8(&bytes).map_err(|e| e.to_string())?;
550
for line in text.lines() {
551
let line = line.trim();
552
if line.starts_with("map_")
553
|| line.starts_with("bump ")
554
|| line.starts_with("disp ")
555
{
556
let (_, path) = line
557
.split_once(char::is_whitespace)
558
.ok_or("ongeldige textureverwijzing")?;
559
let relative = Path::new(mtl)
560
.parent()
561
.unwrap_or(Path::new(""))
562
.join(path.trim());
563
let relative = relative
564
.to_str()
565
.ok_or("ongeldig texturepad")?
566
.replace('\\', "/");
567
portable_relative(&relative)?;
568
dependencies.insert(relative);
569
}
570
}
571
}
572
}
573
if dependencies.len() + candidate.assets.len() > MAX_ASSETS {
574
return Err("te veel bijbestanden".into());
575
}
576
let mut asset_map = std::collections::BTreeMap::new();
577
let mut total_bytes: usize = candidate.assets.iter().map(|a| a.bytes.len()).sum();
578
for relative in dependencies {
579
let bytes = read_asset(&root, &relative)?;
580
total_bytes += bytes.len();
581
if total_bytes > MAX_FILE {
582
return Err("assets samen te groot".into());
583
}
584
let index = candidate.assets.len();
585
candidate.assets.push(EmbeddedAsset {
586
path: format!("{prefix}/{relative}"),
587
bytes,
588
});
589
asset_map.insert(relative, index);
590
}
591
// A checked callback prevents the parser from reading undeclared/unsafe files.
592
let mut input = std::io::BufReader::new(Cursor::new(obj_bytes));
593
let options = tobj::LoadOptions {
594
triangulate: true,
595
single_index: true,
596
..Default::default()
597
};
598
let texture_paths = std::cell::RefCell::new(std::collections::BTreeMap::new());
599
let (models, materials) = tobj::load_obj_buf(&mut input, &options, |path| {
600
let relative = path.to_string_lossy().replace('\\', "/");
601
let index = asset_map
602
.get(&relative)
603
.ok_or(tobj::LoadError::OpenFileFailed)?;
604
let mut reader = std::io::BufReader::new(Cursor::new(&candidate.assets[*index].bytes));
605
let (materials, map) = tobj::load_mtl_buf(&mut reader)?;
606
for material in &materials {
607
if let Some(texture) = &material.diffuse_texture {
608
let relative = path
609
.parent()
610
.unwrap_or(Path::new(""))
611
.join(texture)
612
.to_string_lossy()
613
.replace('\\', "/");
614
texture_paths
615
.borrow_mut()
616
.insert(material.name.clone(), relative);
617
}
618
}
619
Ok((materials, map))
620
})
621
.map_err(|e| e.to_string())?;
622
let materials = materials.map_err(|e| e.to_string())?;
623
let texture_paths = texture_paths.into_inner();
624
let mut meshes = Vec::new();
625
let mut vertices: usize = candidate
626
.attachments
627
.iter()
628
.flat_map(|a| &a.meshes)
629
.map(|m| m.positions.len())
630
.sum();
631
for model in models {
632
let source = model.mesh;
633
vertices += source.positions.len() / 3;
634
if vertices > MAX_VERTICES || source.indices.len() > MAX_VERTICES * 6 {
635
return Err("equipmentmesh is te groot".into());
636
}
637
let positions: Vec<[f32; 3]> = source
638
.positions
639
.chunks_exact(3)
640
.map(|x| [x[0], x[1], x[2]])
641
.collect();
642
let mut normals: Vec<[f32; 3]> = source
643
.normals
644
.chunks_exact(3)
645
.map(|x| [x[0], x[1], x[2]])
646
.collect();
647
if normals.len() != positions.len() {
648
normals = compute_normals(&positions, &source.indices)?;
649
}
650
let mut uvs: Vec<[f32; 2]> = source
651
.texcoords
652
.chunks_exact(2)
653
.map(|x| [x[0], x[1]])
654
.collect();
655
if uvs.len() != positions.len() {
656
uvs = vec![[0.; 2]; positions.len()];
657
}
658
let material = source.material_id.and_then(|id| materials.get(id));
659
let color = material.and_then(|m| m.diffuse).unwrap_or([0.7, 0.7, 0.7]);
660
let albedo = material
661
.and_then(|m| texture_paths.get(&m.name))
662
.map(|path| {
663
asset_map
664
.get(path)
665
.copied()
666
.ok_or("ontbrekende diffuse texture")
667
})
668
.transpose()?;
669
meshes.push(Mesh {
670
positions,
671
normals,
672
uvs,
673
indices: source.indices,
674
color: [
675
color[0],
676
color[1],
677
color[2],
678
material.and_then(|m| m.dissolve).unwrap_or(1.),
679
],
680
albedo,
681
});
682
}
683
if meshes.is_empty() {
684
return Err("het OBJ-bestand heeft geen meshes".into());
685
}
686
candidate.attachments.push(Attachment {
687
label: name.to_string(),
688
bones: [None, None],
689
translation: [0.; 3],
690
rotation_deg: [0.; 3],
691
meshes,
692
shiny: None,
693
specular: 0.25,
694
});
695
candidate.validate()?;
696
*preset = candidate;
697
Ok(preset.attachments.len() - 1)
698
}
699
700
pub fn import_shiny(preset: &mut Preset, attachment: usize, source: &Path) -> Result<(), String> {
701
let root = source.parent().ok_or("texturemap ontbreekt")?;
702
let name = source
703
.file_name()
704
.and_then(|x| x.to_str())
705
.ok_or("ongeldige bestandsnaam")?;
706
let bytes = read_asset(root, name)?;
707
decode_png(&bytes)?;
708
let mut candidate = preset.clone();
709
let index = candidate.assets.len();
710
candidate.assets.push(EmbeddedAsset {
711
path: format!("shiny-{index}/{name}"),
712
bytes,
713
});
714
candidate
715
.attachments
716
.get_mut(attachment)
717
.ok_or("onderdeel ontbreekt")?
718
.shiny = Some(index);
719
candidate.validate()?;
720
*preset = candidate;
721
Ok(())
722
}
723
724
fn read_asset(root: &Path, relative: &str) -> Result<Vec<u8>, String> {
725
portable_relative(relative)?;
726
let root = root.canonicalize().map_err(|e| e.to_string())?;
727
let path = root
728
.join(relative)
729
.canonicalize()
730
.map_err(|e| e.to_string())?;
731
if !path.starts_with(&root) {
732
return Err("asset verlaat de modelmap".into());
733
}
734
let file = std::fs::File::open(path).map_err(|e| e.to_string())?;
735
let mut bytes = Vec::new();
736
file.take((MAX_ASSET + 1) as u64)
737
.read_to_end(&mut bytes)
738
.map_err(|e| e.to_string())?;
739
if bytes.len() > MAX_ASSET {
740
return Err("asset is te groot".into());
741
}
742
Ok(bytes)
743
}
744
fn compute_normals(positions: &[[f32; 3]], indices: &[u32]) -> Result<Vec<[f32; 3]>, String> {
745
let mut normals = vec![[0.; 3]; positions.len()];
746
for t in indices.chunks_exact(3) {
747
let [a, b, c] = [t[0] as usize, t[1] as usize, t[2] as usize];
748
if [a, b, c].iter().any(|i| *i >= positions.len()) {
749
return Err("ongeldige OBJ-index".into());
750
}
751
let ab = std::array::from_fn::<_, 3, _>(|i| positions[b][i] - positions[a][i]);
752
let ac = std::array::from_fn::<_, 3, _>(|i| positions[c][i] - positions[a][i]);
753
let n = [
754
ab[1] * ac[2] - ab[2] * ac[1],
755
ab[2] * ac[0] - ab[0] * ac[2],
756
ab[0] * ac[1] - ab[1] * ac[0],
757
];
758
for vertex in [a, b, c] {
759
for (i, value) in n.iter().enumerate() {
760
normals[vertex][i] += value;
761
}
762
}
763
}
764
for normal in &mut normals {
765
let length = normal.iter().map(|v| v * v).sum::<f32>().sqrt();
766
if length > 1e-10 {
767
for v in normal {
768
*v /= length
769
}
770
}
771
}
772
Ok(normals)
773
}
@@ -0,0 +1,773 @@1
//! Rigid equipment, portable embedded assets and a bounded binary container.2
//! Local rotation uses Rz * Ry * Rx (degrees); attachment uses the animated3
//! joint world transform, never a skin matrix or inverse bind a second time.4
5
use std::collections::BTreeSet;6
use std::io::{Cursor, Read, Write};7
use std::path::Path;8
9
use crate::preview::{10
animated_joint_world, identity, joint_paths, mul, Mat4, PreviewCharacter, PreviewControls,11
};12
use crate::project::portable_relative;13
use serde::{Deserialize, Serialize};14
15
pub const MAGIC: &[u8; 8] = b"PPEQ\r\n\x1a\n";16
pub const VERSION: u32 = 1;17
pub const MAX_FILE: usize = 32 * 1024 * 1024;18
const MAX_META: usize = 4 * 1024 * 1024;19
const MAX_ASSET: usize = 8 * 1024 * 1024;20
const MAX_ASSETS: usize = 32;21
const MAX_VERTICES: usize = 80_000;22
23
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]24
#[serde(deny_unknown_fields)]25
pub struct EmbeddedAsset {26
pub path: String,27
#[serde(skip)]28
pub bytes: Vec<u8>,29
}30
31
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]32
#[serde(deny_unknown_fields)]33
pub struct Mesh {34
pub positions: Vec<[f32; 3]>,35
pub normals: Vec<[f32; 3]>,36
pub uvs: Vec<[f32; 2]>,37
pub indices: Vec<u32>,38
pub color: [f32; 4],39
pub albedo: Option<usize>,40
}41
42
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]43
#[serde(deny_unknown_fields)]44
pub struct RigSignature {45
pub paths: Vec<String>,46
pub rest_world: Vec<Mat4>,47
}48
impl RigSignature {49
pub fn of(body: &PreviewCharacter) -> Self {50
Self {51
paths: joint_paths(body),52
rest_world: (0..body.joint_nodes.len())53
.map(|j| animated_joint_world(body, "", 0.0, j).unwrap_or_else(identity))54
.collect(),55
}56
}57
}58
59
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]60
#[serde(deny_unknown_fields)]61
pub struct Attachment {62
pub label: String,63
pub bones: [Option<String>; 2],64
pub translation: [f32; 3],65
pub rotation_deg: [f32; 3],66
pub meshes: Vec<Mesh>,67
pub shiny: Option<usize>,68
pub specular: f32,69
}70
71
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]72
#[serde(deny_unknown_fields)]73
pub struct Preset {74
pub name: String,75
pub provenance: String,76
pub applies: [bool; 2],77
pub rigs: [Option<RigSignature>; 2],78
pub attachments: Vec<Attachment>,79
pub assets: Vec<EmbeddedAsset>,80
}81
impl Preset {82
pub fn new(name: String) -> Self {83
Self {84
name,85
provenance: String::new(),86
applies: [true, true],87
rigs: [None, None],88
attachments: Vec::new(),89
assets: Vec::new(),90
}91
}92
pub fn bind(93
&mut self,94
sex: usize,95
body: &PreviewCharacter,96
attachment: usize,97
joint: usize,98
) -> Result<(), String> {99
if sex > 1 {100
return Err("ongeldig geslacht".into());101
}102
let paths = joint_paths(body);103
let path = paths.get(joint).ok_or("dit bot bestaat niet")?.clone();104
let item = self105
.attachments106
.get_mut(attachment)107
.ok_or("dit onderdeel bestaat niet")?;108
item.bones[sex] = Some(path);109
self.rigs[sex] = Some(RigSignature::of(body));110
Ok(())111
}112
pub fn validate(&self) -> Result<(), String> {113
if !valid_name(&self.name) {114
return Err(115
"equipmentnaam: kleine letters/cijfers/-/_, geen spaties, en niet 'body'".into(),116
);117
}118
if self.provenance.len() > 4096 || self.provenance.contains('\0') {119
return Err("herkomst/licentie is te lang of bevat NUL".into());120
}121
if !self.applies.iter().any(|x| *x) {122
return Err("kies minstens één geslacht".into());123
}124
if self.attachments.len() > 32 || self.assets.len() > MAX_ASSETS {125
return Err("te veel onderdelen of bestanden".into());126
}127
let mut paths = BTreeSet::new();128
let mut total = 0usize;129
for asset in &self.assets {130
portable_relative(&asset.path)?;131
if asset.path.len() > 240 || asset.bytes.len() > MAX_ASSET || !paths.insert(&asset.path)132
{133
return Err("ongeldige, dubbele of te grote asset".into());134
}135
total = total136
.checked_add(asset.bytes.len())137
.ok_or("te veel assetdata")?;138
}139
if total > MAX_FILE {140
return Err("te veel assetdata".into());141
}142
let mut texture_indices = BTreeSet::new();143
let mut vertices = 0;144
for rig in self.rigs.iter().flatten() {145
if rig.paths.len() > 128146
|| rig.paths.len() != rig.rest_world.len()147
|| rig.paths.iter().any(|p| p.len() > 4096)148
|| rig.rest_world.iter().flatten().any(|x| !x.is_finite())149
{150
return Err("ongeldige rigbinding".into());151
}152
}153
for attachment in &self.attachments {154
for sex in 0..2 {155
if let Some(path) = &attachment.bones[sex] {156
if !self.rigs[sex]157
.as_ref()158
.map(|rig| rig.paths.contains(path))159
.unwrap_or(false)160
{161
return Err("attachment verwijst naar onbekend bot".into());162
}163
}164
}165
if attachment.label.len() > 128166
|| attachment.bones.iter().flatten().any(|p| p.len() > 4096)167
|| attachment168
.translation169
.iter()170
.chain(attachment.rotation_deg.iter())171
.any(|x| !x.is_finite() || x.abs() > 100_000.0)172
|| !attachment.specular.is_finite()173
|| !(0.0..=1.0).contains(&attachment.specular)174
|| attachment.meshes.len() > 64175
{176
return Err("ongeldige attachment/transformatie".into());177
}178
if let Some(index) = attachment.shiny {179
texture_indices.insert(index);180
}181
for mesh in &attachment.meshes {182
vertices += mesh.positions.len();183
if vertices > MAX_VERTICES184
|| mesh.indices.len() > MAX_VERTICES * 6185
|| mesh.indices.len() < 3186
|| mesh.indices.len() % 3 != 0187
|| mesh.positions.len() != mesh.normals.len()188
|| mesh.positions.len() != mesh.uvs.len()189
|| mesh.positions.is_empty()190
|| mesh191
.indices192
.iter()193
.any(|i| *i as usize >= mesh.positions.len())194
|| mesh195
.positions196
.iter()197
.flatten()198
.chain(mesh.normals.iter().flatten())199
.chain(mesh.uvs.iter().flatten())200
.chain(mesh.color.iter())201
.any(|x| !x.is_finite() || x.abs() > 1_000_000.0)202
{203
return Err("ongeldige of te grote equipmentmesh".into());204
}205
if let Some(index) = mesh.albedo {206
texture_indices.insert(index);207
}208
}209
}210
let mut decoded = 0usize;211
for index in texture_indices {212
decoded += self.texture(index)?.rgba.len();213
if decoded > 64 * 1024 * 1024 {214
return Err("textures samen groter dan 64 MiB".into());215
}216
}217
Ok(())218
}219
pub fn texture(&self, index: usize) -> Result<Texture, String> {220
let asset = self221
.assets222
.get(index)223
.ok_or("textureverwijzing ontbreekt")?;224
decode_png(&asset.bytes)225
}226
pub fn encode(&self) -> Result<Vec<u8>, String> {227
self.validate()?;228
let metadata = serde_json::to_vec(self).map_err(|e| e.to_string())?;229
if metadata.len() > MAX_META {230
return Err("presetmetadata is te groot".into());231
}232
let mut payload = metadata;233
let meta_len = payload.len();234
for asset in &self.assets {235
payload.extend_from_slice(&(asset.bytes.len() as u32).to_le_bytes());236
payload.extend_from_slice(&asset.bytes);237
}238
if payload.len() + 20 > MAX_FILE {239
return Err("preset is te groot".into());240
}241
let mut out = MAGIC.to_vec();242
out.extend_from_slice(&VERSION.to_le_bytes());243
out.extend_from_slice(&(meta_len as u32).to_le_bytes());244
out.extend_from_slice(&checksum(&payload).to_le_bytes());245
out.extend(payload);246
Ok(out)247
}248
pub fn decode(bytes: &[u8]) -> Result<Self, String> {249
if bytes.len() < 20 || bytes.len() > MAX_FILE || &bytes[..8] != MAGIC {250
return Err("ongeldig equipmentbestand of bestand te groot".into());251
}252
let number = |offset| u32::from_le_bytes(bytes[offset..offset + 4].try_into().unwrap());253
if number(8) != VERSION {254
return Err("onbekende equipmentversie".into());255
}256
let meta_len = number(12) as usize;257
if meta_len > MAX_META258
|| meta_len > bytes.len() - 20259
|| checksum(&bytes[20..]) != number(16)260
{261
return Err("beschadigde equipmentdata".into());262
}263
let mut preset: Self =264
serde_json::from_slice(&bytes[20..20 + meta_len]).map_err(|e| e.to_string())?;265
if preset.assets.len() > MAX_ASSETS {266
return Err("te veel assets".into());267
}268
let mut cursor = 20 + meta_len;269
for asset in &mut preset.assets {270
if cursor + 4 > bytes.len() {271
return Err("afgebroken asset".into());272
}273
let len = number(cursor) as usize;274
cursor += 4;275
if len > MAX_ASSET || len > bytes.len() - cursor {276
return Err("ongeldige assetlengte".into());277
}278
asset.bytes = bytes[cursor..cursor + len].to_vec();279
cursor += len;280
}281
if cursor != bytes.len() {282
return Err("onverwachte extra equipmentdata".into());283
}284
preset.validate()?;285
Ok(preset)286
}287
pub fn read(path: &Path) -> Result<Self, String> {288
let file = std::fs::File::open(path).map_err(|e| e.to_string())?;289
let mut bytes = Vec::new();290
file.take((MAX_FILE + 1) as u64)291
.read_to_end(&mut bytes)292
.map_err(|e| e.to_string())?;293
Self::decode(&bytes)294
}295
}296
297
pub fn valid_name(name: &str) -> bool {298
!name.is_empty()299
&& name.len() <= 64300
&& name != "body"301
&& name302
.bytes()303
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'-' || c == b'_')304
}305
306
pub fn checksum(bytes: &[u8]) -> u32 {307
let mut crc = !0u32;308
for byte in bytes {309
crc ^= u32::from(*byte);310
for _ in 0..8 {311
crc = (crc >> 1) ^ (0xedb88320u32 & (0u32.wrapping_sub(crc & 1)));312
}313
}314
!crc315
}316
317
/// Immutable snapshot publish: a hard link atomically creates the destination318
/// without overwriting an older saved preset. Failure leaves old data intact.319
pub fn write_snapshot(path: &Path, bytes: &[u8]) -> Result<(), String> {320
if bytes.len() > MAX_FILE {321
return Err("snapshot te groot".into());322
}323
if path.exists() {324
let mut current = Vec::new();325
std::fs::File::open(path)326
.map_err(|e| e.to_string())?327
.take((MAX_FILE + 1) as u64)328
.read_to_end(&mut current)329
.map_err(|e| e.to_string())?;330
return if current == bytes {331
Ok(())332
} else {333
Err("snapshotnaam bestaat met andere inhoud".into())334
};335
}336
let stamp = std::time::SystemTime::now()337
.duration_since(std::time::UNIX_EPOCH)338
.unwrap_or_default()339
.as_nanos();340
let temp = path.with_extension(format!("ppe.part-{}-{stamp}", std::process::id()));341
let result = (|| {342
let mut file = std::fs::OpenOptions::new()343
.write(true)344
.create_new(true)345
.open(&temp)346
.map_err(|e| e.to_string())?;347
file.write_all(bytes)348
.and_then(|_| file.sync_all())349
.map_err(|e| e.to_string())?;350
std::fs::hard_link(&temp, path).map_err(|e| e.to_string())?;351
Ok(())352
})();353
let _ = std::fs::remove_file(temp);354
result355
}356
357
#[derive(Debug, Clone, PartialEq)]358
pub struct Texture {359
pub content_hash: u64,360
pub width: u32,361
pub height: u32,362
pub rgba: Vec<u8>,363
}364
fn decode_png(bytes: &[u8]) -> Result<Texture, String> {365
let mut decoder = png::Decoder::new(Cursor::new(bytes));366
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);367
let mut reader = decoder.read_info().map_err(|e| e.to_string())?;368
let info = reader.info();369
if info.width == 0 || info.height == 0 || info.width > 2048 || info.height > 2048 {370
return Err("texture is groter dan 2048×2048".into());371
}372
let size = reader373
.output_buffer_size()374
.ok_or("ongeldige texturegrootte")?;375
if size > 2048 * 2048 * 8 {376
return Err("texture is te groot".into());377
}378
let mut data = vec![0; size];379
let info = reader.next_frame(&mut data).map_err(|e| e.to_string())?;380
let channels = info.color_type.samples();381
let mut rgba = Vec::with_capacity(info.width as usize * info.height as usize * 4);382
for pixel in data[..info.buffer_size()].chunks_exact(channels) {383
match info.color_type {384
png::ColorType::Grayscale => {385
rgba.extend_from_slice(&[pixel[0], pixel[0], pixel[0], 255])386
}387
png::ColorType::GrayscaleAlpha => {388
rgba.extend_from_slice(&[pixel[0], pixel[0], pixel[0], pixel[1]])389
}390
png::ColorType::Rgb => rgba.extend_from_slice(&[pixel[0], pixel[1], pixel[2], 255]),391
png::ColorType::Rgba => rgba.extend_from_slice(pixel),392
_ => return Err("onbekende texturecodering".into()),393
}394
}395
use std::hash::{Hash, Hasher};396
let mut hash = std::collections::hash_map::DefaultHasher::new();397
rgba.hash(&mut hash);398
Ok(Texture {399
content_hash: hash.finish(),400
width: info.width,401
height: info.height,402
rgba,403
})404
}405
406
pub fn local_matrix(translation: [f32; 3], degrees: [f32; 3]) -> Mat4 {407
let [x, y, z] = degrees.map(f32::to_radians);408
let (sx, cx) = x.sin_cos();409
let (sy, cy) = y.sin_cos();410
let (sz, cz) = z.sin_cos();411
let rx = [412
1., 0., 0., 0., 0., cx, sx, 0., 0., -sx, cx, 0., 0., 0., 0., 1.,413
];414
let ry = [415
cy, 0., -sy, 0., 0., 1., 0., 0., sy, 0., cy, 0., 0., 0., 0., 1.,416
];417
let rz = [418
cz, sz, 0., 0., -sz, cz, 0., 0., 0., 0., 1., 0., 0., 0., 0., 1.,419
];420
let mut matrix = mul(rz, mul(ry, rx));421
matrix[12] = translation[0];422
matrix[13] = translation[1];423
matrix[14] = translation[2];424
matrix425
}426
427
#[derive(Debug, Clone)]428
pub struct Layer {429
pub mesh: Mesh,430
pub model: Mat4,431
pub albedo: Option<std::sync::Arc<Texture>>,432
pub shiny: Option<std::sync::Arc<Texture>>,433
pub specular: f32,434
}435
436
#[derive(Default)]437
pub struct TextureCache {438
entries: std::collections::VecDeque<(Vec<u8>, std::sync::Arc<Texture>)>,439
bytes: usize,440
}441
impl TextureCache {442
fn get(&mut self, preset: &Preset, index: usize) -> Result<std::sync::Arc<Texture>, String> {443
let bytes = &preset.assets.get(index).ok_or("texture ontbreekt")?.bytes;444
if let Some((_, texture)) = self.entries.iter().find(|(source, _)| source == bytes) {445
return Ok(texture.clone());446
}447
let texture = std::sync::Arc::new(decode_png(bytes)?);448
let cost = bytes.len() + texture.rgba.len();449
while self.bytes + cost > 128 * 1024 * 1024 {450
if let Some((source, image)) = self.entries.pop_front() {451
self.bytes -= source.len() + image.rgba.len();452
} else {453
break;454
}455
}456
self.bytes += cost;457
self.entries.push_back((bytes.clone(), texture.clone()));458
Ok(texture)459
}460
}461
462
pub fn layers(463
preset: &Preset,464
sex: usize,465
body: &PreviewCharacter,466
controls: &PreviewControls,467
) -> Result<Vec<Layer>, String> {468
layers_cached(preset, sex, body, controls, &mut TextureCache::default())469
}470
471
pub fn layers_cached(472
preset: &Preset,473
sex: usize,474
body: &PreviewCharacter,475
controls: &PreviewControls,476
cache: &mut TextureCache,477
) -> Result<Vec<Layer>, String> {478
if sex > 1 {479
return Err("ongeldig geslacht".into());480
}481
if !preset.applies[sex] {482
return Ok(Vec::new());483
}484
let signature = RigSignature::of(body);485
if preset.rigs[sex].as_ref() != Some(&signature) {486
return Err("rig wijkt af: kies de botten opnieuw voor dit lichaam".into());487
}488
let paths = joint_paths(body);489
let mut layers = Vec::new();490
for item in &preset.attachments {491
let path = item.bones[sex]492
.as_deref()493
.ok_or("kies een bot voor dit onderdeel en geslacht")?;494
let joint = paths495
.iter()496
.position(|p| p == path)497
.ok_or("het gekoppelde bot ontbreekt")?;498
let world = animated_joint_world(body, &controls.clip_name, controls.time, joint)499
.ok_or("het gekoppelde bot ontbreekt")?;500
let model = mul(world, local_matrix(item.translation, item.rotation_deg));501
let shiny = item.shiny.map(|i| cache.get(preset, i)).transpose()?;502
for mesh in &item.meshes {503
layers.push(Layer {504
mesh: mesh.clone(),505
model,506
albedo: mesh.albedo.map(|i| cache.get(preset, i)).transpose()?,507
shiny: shiny.clone(),508
specular: item.specular,509
});510
}511
}512
Ok(layers)513
}514
515
/// Import an OBJ and its declared MTL/PNG dependencies into the preset itself.516
/// No extraction directories, absolute references or parent traversal allowed.517
pub fn import_obj(preset: &mut Preset, source: &Path) -> Result<usize, String> {518
if source519
.extension()520
.and_then(|x| x.to_str())521
.map(|x| !x.eq_ignore_ascii_case("obj"))522
.unwrap_or(true)523
{524
return Err("kies een rigide OBJ-model met MTL/PNG-bijbestanden".into());525
}526
let root = source527
.parent()528
.ok_or("modelmap ontbreekt")?529
.canonicalize()530
.map_err(|e| e.to_string())?;531
let name = source532
.file_name()533
.and_then(|x| x.to_str())534
.ok_or("ongeldige modelnaam")?;535
let mut candidate = preset.clone();536
if candidate.attachments.len() >= 32 {537
return Err("te veel onderdelen".into());538
}539
let prefix = format!("part-{}", candidate.attachments.len());540
let obj_bytes = read_asset(&root, name)?;541
let obj_text = std::str::from_utf8(&obj_bytes).map_err(|e| e.to_string())?;542
let mut dependencies = BTreeSet::new();543
dependencies.insert(name.to_string());544
for line in obj_text.lines() {545
if let Some(rest) = line.trim().strip_prefix("mtllib ") {546
let mtl = rest.trim();547
dependencies.insert(mtl.to_string());548
let bytes = read_asset(&root, mtl)?;549
let text = std::str::from_utf8(&bytes).map_err(|e| e.to_string())?;550
for line in text.lines() {551
let line = line.trim();552
if line.starts_with("map_")553
|| line.starts_with("bump ")554
|| line.starts_with("disp ")555
{556
let (_, path) = line557
.split_once(char::is_whitespace)558
.ok_or("ongeldige textureverwijzing")?;559
let relative = Path::new(mtl)560
.parent()561
.unwrap_or(Path::new(""))562
.join(path.trim());563
let relative = relative564
.to_str()565
.ok_or("ongeldig texturepad")?566
.replace('\\', "/");567
portable_relative(&relative)?;568
dependencies.insert(relative);569
}570
}571
}572
}573
if dependencies.len() + candidate.assets.len() > MAX_ASSETS {574
return Err("te veel bijbestanden".into());575
}576
let mut asset_map = std::collections::BTreeMap::new();577
let mut total_bytes: usize = candidate.assets.iter().map(|a| a.bytes.len()).sum();578
for relative in dependencies {579
let bytes = read_asset(&root, &relative)?;580
total_bytes += bytes.len();581
if total_bytes > MAX_FILE {582
return Err("assets samen te groot".into());583
}584
let index = candidate.assets.len();585
candidate.assets.push(EmbeddedAsset {586
path: format!("{prefix}/{relative}"),587
bytes,588
});589
asset_map.insert(relative, index);590
}591
// A checked callback prevents the parser from reading undeclared/unsafe files.592
let mut input = std::io::BufReader::new(Cursor::new(obj_bytes));593
let options = tobj::LoadOptions {594
triangulate: true,595
single_index: true,596
..Default::default()597
};598
let texture_paths = std::cell::RefCell::new(std::collections::BTreeMap::new());599
let (models, materials) = tobj::load_obj_buf(&mut input, &options, |path| {600
let relative = path.to_string_lossy().replace('\\', "/");601
let index = asset_map602
.get(&relative)603
.ok_or(tobj::LoadError::OpenFileFailed)?;604
let mut reader = std::io::BufReader::new(Cursor::new(&candidate.assets[*index].bytes));605
let (materials, map) = tobj::load_mtl_buf(&mut reader)?;606
for material in &materials {607
if let Some(texture) = &material.diffuse_texture {608
let relative = path609
.parent()610
.unwrap_or(Path::new(""))611
.join(texture)612
.to_string_lossy()613
.replace('\\', "/");614
texture_paths615
.borrow_mut()616
.insert(material.name.clone(), relative);617
}618
}619
Ok((materials, map))620
})621
.map_err(|e| e.to_string())?;622
let materials = materials.map_err(|e| e.to_string())?;623
let texture_paths = texture_paths.into_inner();624
let mut meshes = Vec::new();625
let mut vertices: usize = candidate626
.attachments627
.iter()628
.flat_map(|a| &a.meshes)629
.map(|m| m.positions.len())630
.sum();631
for model in models {632
let source = model.mesh;633
vertices += source.positions.len() / 3;634
if vertices > MAX_VERTICES || source.indices.len() > MAX_VERTICES * 6 {635
return Err("equipmentmesh is te groot".into());636
}637
let positions: Vec<[f32; 3]> = source638
.positions639
.chunks_exact(3)640
.map(|x| [x[0], x[1], x[2]])641
.collect();642
let mut normals: Vec<[f32; 3]> = source643
.normals644
.chunks_exact(3)645
.map(|x| [x[0], x[1], x[2]])646
.collect();647
if normals.len() != positions.len() {648
normals = compute_normals(&positions, &source.indices)?;649
}650
let mut uvs: Vec<[f32; 2]> = source651
.texcoords652
.chunks_exact(2)653
.map(|x| [x[0], x[1]])654
.collect();655
if uvs.len() != positions.len() {656
uvs = vec![[0.; 2]; positions.len()];657
}658
let material = source.material_id.and_then(|id| materials.get(id));659
let color = material.and_then(|m| m.diffuse).unwrap_or([0.7, 0.7, 0.7]);660
let albedo = material661
.and_then(|m| texture_paths.get(&m.name))662
.map(|path| {663
asset_map664
.get(path)665
.copied()666
.ok_or("ontbrekende diffuse texture")667
})668
.transpose()?;669
meshes.push(Mesh {670
positions,671
normals,672
uvs,673
indices: source.indices,674
color: [675
color[0],676
color[1],677
color[2],678
material.and_then(|m| m.dissolve).unwrap_or(1.),679
],680
albedo,681
});682
}683
if meshes.is_empty() {684
return Err("het OBJ-bestand heeft geen meshes".into());685
}686
candidate.attachments.push(Attachment {687
label: name.to_string(),688
bones: [None, None],689
translation: [0.; 3],690
rotation_deg: [0.; 3],691
meshes,692
shiny: None,693
specular: 0.25,694
});695
candidate.validate()?;696
*preset = candidate;697
Ok(preset.attachments.len() - 1)698
}699
700
pub fn import_shiny(preset: &mut Preset, attachment: usize, source: &Path) -> Result<(), String> {701
let root = source.parent().ok_or("texturemap ontbreekt")?;702
let name = source703
.file_name()704
.and_then(|x| x.to_str())705
.ok_or("ongeldige bestandsnaam")?;706
let bytes = read_asset(root, name)?;707
decode_png(&bytes)?;708
let mut candidate = preset.clone();709
let index = candidate.assets.len();710
candidate.assets.push(EmbeddedAsset {711
path: format!("shiny-{index}/{name}"),712
bytes,713
});714
candidate715
.attachments716
.get_mut(attachment)717
.ok_or("onderdeel ontbreekt")?718
.shiny = Some(index);719
candidate.validate()?;720
*preset = candidate;721
Ok(())722
}723
724
fn read_asset(root: &Path, relative: &str) -> Result<Vec<u8>, String> {725
portable_relative(relative)?;726
let root = root.canonicalize().map_err(|e| e.to_string())?;727
let path = root728
.join(relative)729
.canonicalize()730
.map_err(|e| e.to_string())?;731
if !path.starts_with(&root) {732
return Err("asset verlaat de modelmap".into());733
}734
let file = std::fs::File::open(path).map_err(|e| e.to_string())?;735
let mut bytes = Vec::new();736
file.take((MAX_ASSET + 1) as u64)737
.read_to_end(&mut bytes)738
.map_err(|e| e.to_string())?;739
if bytes.len() > MAX_ASSET {740
return Err("asset is te groot".into());741
}742
Ok(bytes)743
}744
fn compute_normals(positions: &[[f32; 3]], indices: &[u32]) -> Result<Vec<[f32; 3]>, String> {745
let mut normals = vec![[0.; 3]; positions.len()];746
for t in indices.chunks_exact(3) {747
let [a, b, c] = [t[0] as usize, t[1] as usize, t[2] as usize];748
if [a, b, c].iter().any(|i| *i >= positions.len()) {749
return Err("ongeldige OBJ-index".into());750
}751
let ab = std::array::from_fn::<_, 3, _>(|i| positions[b][i] - positions[a][i]);752
let ac = std::array::from_fn::<_, 3, _>(|i| positions[c][i] - positions[a][i]);753
let n = [754
ab[1] * ac[2] - ab[2] * ac[1],755
ab[2] * ac[0] - ab[0] * ac[2],756
ab[0] * ac[1] - ab[1] * ac[0],757
];758
for vertex in [a, b, c] {759
for (i, value) in n.iter().enumerate() {760
normals[vertex][i] += value;761
}762
}763
}764
for normal in &mut normals {765
let length = normal.iter().map(|v| v * v).sum::<f32>().sqrt();766
if length > 1e-10 {767
for v in normal {768
*v /= length769
}770
}771
}772
Ok(normals)773
}@@ -5,6 +5,7 @@
5
5
6
6
pub mod dialogs;
7
7
pub mod editor;
8
pub mod equipment;
8
9
pub mod file_assoc;
9
10
pub mod foundation;
10
11
pub mod icon;
@@ -5,6 +5,7 @@5
5
6
pub mod dialogs;6
pub mod dialogs;7
pub mod editor;7
pub mod editor;8
pub mod equipment;8
pub mod file_assoc;9
pub mod file_assoc;9
pub mod foundation;10
pub mod foundation;10
pub mod icon;11
pub mod icon;@@ -279,6 +279,43 @@ pub fn sample_skin_matrices(character: &PreviewCharacter, clip_name: &str, time:
279
279
.collect()
280
280
}
281
281
282
/// Stable hierarchy path includes node indices to disambiguate duplicate names.
283
pub fn joint_paths(character: &PreviewCharacter) -> Vec<String> {
284
character
285
.joint_nodes
286
.iter()
287
.map(|&node| {
288
let mut names = Vec::new();
289
let mut cursor = Some(node);
290
while let Some(index) = cursor {
291
let item = &character.nodes[index];
292
names.push(format!("{}[{}]", item.name, index));
293
cursor = item.parent;
294
if names.len() > character.nodes.len() {
295
break;
296
}
297
}
298
names.reverse();
299
names.join("/")
300
})
301
.collect()
302
}
303
304
pub fn animated_joint_world(
305
character: &PreviewCharacter,
306
clip: &str,
307
time: f32,
308
joint: usize,
309
) -> Option<Mat4> {
310
let locals = sample_local_matrices(character, clip, time);
311
let worlds = world_matrices(&character.nodes, &locals);
312
character
313
.joint_nodes
314
.get(joint)
315
.and_then(|node| worlds.get(*node))
316
.copied()
317
}
318
282
319
pub fn joint_world_translation(
283
320
character: &PreviewCharacter,
284
321
clip_name: &str,
@@ -279,6 +279,43 @@ pub fn sample_skin_matrices(character: &PreviewCharacter, clip_name: &str, time:279
.collect()279
.collect()280
}280
}281
281
282
/// Stable hierarchy path includes node indices to disambiguate duplicate names.283
pub fn joint_paths(character: &PreviewCharacter) -> Vec<String> {284
character285
.joint_nodes286
.iter()287
.map(|&node| {288
let mut names = Vec::new();289
let mut cursor = Some(node);290
while let Some(index) = cursor {291
let item = &character.nodes[index];292
names.push(format!("{}[{}]", item.name, index));293
cursor = item.parent;294
if names.len() > character.nodes.len() {295
break;296
}297
}298
names.reverse();299
names.join("/")300
})301
.collect()302
}303
304
pub fn animated_joint_world(305
character: &PreviewCharacter,306
clip: &str,307
time: f32,308
joint: usize,309
) -> Option<Mat4> {310
let locals = sample_local_matrices(character, clip, time);311
let worlds = world_matrices(&character.nodes, &locals);312
character313
.joint_nodes314
.get(joint)315
.and_then(|node| worlds.get(*node))316
.copied()317
}318
282
pub fn joint_world_translation(319
pub fn joint_world_translation(283
character: &PreviewCharacter,320
character: &PreviewCharacter,284
clip_name: &str,321
clip_name: &str,@@ -33,11 +33,18 @@ impl Viewport {
33
33
}
34
34
}
35
35
36
pub fn destroy(&self, gl: &glow::Context) {
37
if let Ok(mut cache) = self.cache.lock() {
38
unsafe { cache.destroy(gl) }
39
}
40
}
41
36
42
pub fn show(
37
43
&self,
38
44
ui: &mut egui::Ui,
39
45
controls: &mut PreviewControls,
40
46
character: Option<&PreviewCharacter>,
47
presets: &[&crate::equipment::Preset],
41
48
) {
42
49
ui.horizontal_wrapped(|ui| {
43
50
if let Some(character) = character {
@@ -129,8 +136,33 @@ impl Viewport {
129
136
perspective(38.0, aspect, 0.05, 40.0),
130
137
view_matrix(eye, target),
131
138
);
139
let mut cache = self.cache.lock().expect("preview gpu lock");
140
let mut diagnostics = Vec::new();
141
let mut equipment = Vec::new();
142
let sex = if controls.sex == crate::preview::PreviewSex::Male {
143
0
144
} else {
145
1
146
};
147
for preset in presets {
148
match crate::equipment::layers_cached(
149
preset,
150
sex,
151
character,
152
controls,
153
&mut cache.texture_cache,
154
) {
155
Ok(layers) => {
156
for layer in layers {
157
equipment.push(PendingFrame::from_layer(layer, view_proj, eye));
158
}
159
}
160
Err(error) => {
161
diagnostics.push(error);
162
}
163
}
164
}
132
165
{
133
let mut cache = self.cache.lock().expect("preview gpu lock");
134
166
cache.pending = Some(PendingFrame {
135
167
generation: character_generation(character),
136
168
positions: character.positions.clone(),
@@ -141,18 +173,45 @@ impl Viewport {
141
173
bones,
142
174
view_proj,
143
175
color: character.base_color,
176
model: crate::preview::identity(),
177
albedo: None,
178
shiny: None,
179
specular: 0.0,
180
eye,
181
equipment,
182
uvs: Vec::new(),
144
183
});
145
184
}
185
drop(cache);
146
186
let cache = Arc::clone(&self.cache);
147
187
let callback = CallbackFn::new(move |info, painter| {
148
188
if let Ok(mut cache) = cache.lock() {
149
unsafe { cache.paint(painter.gl(), &info) };
189
unsafe { cache.paint(painter.gl(), &info, true) };
150
190
}
151
191
});
152
192
ui.painter().add(egui::PaintCallback {
153
193
rect,
154
194
callback: Arc::new(callback),
155
195
});
196
if let Ok(cache) = self.cache.lock() {
197
if let Some(error) = &cache.error {
198
diagnostics.push(error.clone());
199
}
200
for layer in &cache.layers {
201
if let Some(error) = &layer.error {
202
diagnostics.push(error.clone());
203
}
204
}
205
}
206
if !diagnostics.is_empty() {
207
ui.painter().text(
208
rect.left_top() + egui::vec2(12., 12.),
209
egui::Align2::LEFT_TOP,
210
diagnostics.join("\n"),
211
egui::FontId::proportional(14.),
212
egui::Color32::LIGHT_RED,
213
);
214
}
156
215
}
157
216
}
158
217
@@ -184,6 +243,62 @@ struct PendingFrame {
184
243
bones: Vec<crate::preview::Mat4>,
185
244
view_proj: crate::preview::Mat4,
186
245
color: [f32; 4],
246
model: crate::preview::Mat4,
247
eye: [f32; 3],
248
albedo: Option<std::sync::Arc<crate::equipment::Texture>>,
249
shiny: Option<std::sync::Arc<crate::equipment::Texture>>,
250
specular: f32,
251
equipment: Vec<PendingFrame>,
252
uvs: Vec<[f32; 2]>,
253
}
254
255
impl PendingFrame {
256
fn from_layer(
257
layer: crate::equipment::Layer,
258
view_proj: crate::preview::Mat4,
259
eye: [f32; 3],
260
) -> Self {
261
use std::hash::{Hash, Hasher};
262
let mut hash = std::collections::hash_map::DefaultHasher::new();
263
for v in &layer.mesh.positions {
264
v.map(f32::to_bits).hash(&mut hash);
265
}
266
for v in &layer.mesh.normals {
267
v.map(f32::to_bits).hash(&mut hash);
268
}
269
for v in &layer.mesh.uvs {
270
v.map(f32::to_bits).hash(&mut hash);
271
}
272
layer.mesh.indices.hash(&mut hash);
273
if let Some(t) = &layer.albedo {
274
t.content_hash.hash(&mut hash);
275
t.width.hash(&mut hash);
276
t.height.hash(&mut hash);
277
}
278
if let Some(t) = &layer.shiny {
279
t.content_hash.hash(&mut hash);
280
t.width.hash(&mut hash);
281
t.height.hash(&mut hash);
282
}
283
Self {
284
generation: hash.finish(),
285
positions: layer.mesh.positions,
286
normals: layer.mesh.normals,
287
indices: layer.mesh.indices,
288
uvs: layer.mesh.uvs,
289
color: layer.mesh.color,
290
joints: Vec::new(),
291
weights: Vec::new(),
292
bones: Vec::new(),
293
model: layer.model,
294
view_proj,
295
eye,
296
albedo: layer.albedo,
297
shiny: layer.shiny,
298
specular: layer.specular,
299
equipment: Vec::new(),
300
}
301
}
187
302
}
188
303
189
304
#[derive(Default)]
@@ -196,10 +311,13 @@ struct GpuCache {
196
311
index_count: i32,
197
312
pending: Option<PendingFrame>,
198
313
error: Option<String>,
314
textures: [Option<glow::Texture>; 2],
315
layers: Vec<GpuCache>,
316
texture_cache: crate::equipment::TextureCache,
199
317
}
200
318
201
319
impl GpuCache {
202
unsafe fn paint(&mut self, gl: &glow::Context, info: &egui::PaintCallbackInfo) {
320
unsafe fn paint(&mut self, gl: &glow::Context, info: &egui::PaintCallbackInfo, clear: bool) {
203
321
let Some(frame) = self.pending.clone() else {
204
322
return;
205
323
};
@@ -237,10 +355,40 @@ impl GpuCache {
237
355
gl.depth_func(glow::LESS);
238
356
gl.disable(glow::BLEND);
239
357
gl.clear_color(0.08, 0.09, 0.12, 1.0);
240
gl.clear(glow::COLOR_BUFFER_BIT | glow::DEPTH_BUFFER_BIT);
358
if clear {
359
gl.clear(glow::COLOR_BUFFER_BIT | glow::DEPTH_BUFFER_BIT);
360
}
241
361
gl.use_program(Some(program));
242
362
let view = gl.get_uniform_location(program, "u_view_proj");
243
363
gl.uniform_matrix_4_f32_slice(view.as_ref(), false, &frame.view_proj);
364
gl.uniform_matrix_4_f32_slice(
365
gl.get_uniform_location(program, "u_model").as_ref(),
366
false,
367
&frame.model,
368
);
369
gl.uniform_3_f32(
370
gl.get_uniform_location(program, "u_eye").as_ref(),
371
frame.eye[0],
372
frame.eye[1],
373
frame.eye[2],
374
);
375
gl.uniform_1_f32(
376
gl.get_uniform_location(program, "u_specular").as_ref(),
377
frame.specular,
378
);
379
for (unit, name, present) in [
380
(0, "u_albedo", frame.albedo.is_some()),
381
(1, "u_shiny", frame.shiny.is_some()),
382
] {
383
gl.active_texture(glow::TEXTURE0 + unit as u32);
384
gl.bind_texture(glow::TEXTURE_2D, self.textures[unit]);
385
gl.uniform_1_i32(gl.get_uniform_location(program, name).as_ref(), unit as i32);
386
gl.uniform_1_i32(
387
gl.get_uniform_location(program, &format!("{name}_enabled"))
388
.as_ref(),
389
i32::from(present),
390
);
391
}
244
392
let color = gl.get_uniform_location(program, "u_color");
245
393
gl.uniform_4_f32(
246
394
color.as_ref(),
@@ -259,11 +407,46 @@ impl GpuCache {
259
407
gl.bind_vertex_array(self.vao);
260
408
gl.draw_elements(glow::TRIANGLES, self.index_count, glow::UNSIGNED_INT, 0);
261
409
gl.bind_vertex_array(None);
410
while self.layers.len() > frame.equipment.len() {
411
if let Some(mut layer) = self.layers.pop() {
412
layer.destroy(gl);
413
}
414
}
415
while self.layers.len() < frame.equipment.len() {
416
self.layers.push(GpuCache::default());
417
}
418
for (cache, frame) in self.layers.iter_mut().zip(frame.equipment) {
419
cache.pending = Some(frame);
420
cache.paint(gl, info, false);
421
}
422
gl.active_texture(glow::TEXTURE0);
262
423
gl.disable(glow::DEPTH_TEST);
263
424
gl.disable(glow::SCISSOR_TEST);
264
425
gl.enable(glow::BLEND);
265
426
}
266
427
428
unsafe fn destroy(&mut self, gl: &glow::Context) {
429
for layer in &mut self.layers {
430
layer.destroy(gl);
431
}
432
for texture in &mut self.textures {
433
if let Some(t) = texture.take() {
434
gl.delete_texture(t)
435
}
436
}
437
if let Some(v) = self.vao.take() {
438
gl.delete_vertex_array(v)
439
}
440
if let Some(v) = self.vbo.take() {
441
gl.delete_buffer(v)
442
}
443
if let Some(v) = self.ebo.take() {
444
gl.delete_buffer(v)
445
}
446
if let Some(p) = self.program.take() {
447
gl.delete_program(p)
448
}
449
}
267
450
unsafe fn ensure_program(&mut self, gl: &glow::Context) -> Result<(), String> {
268
451
if self.program.is_some() {
269
452
return Ok(());
@@ -280,7 +463,7 @@ impl GpuCache {
280
463
}
281
464
282
465
unsafe fn upload(&mut self, gl: &glow::Context, frame: &PendingFrame) -> Result<(), String> {
283
let mut interleaved = Vec::with_capacity(frame.positions.len() * 14);
466
let mut interleaved = Vec::with_capacity(frame.positions.len() * 16);
284
467
for index in 0..frame.positions.len() {
285
468
let position = frame.positions[index];
286
469
let normal = frame.normals.get(index).copied().unwrap_or([0.0, 1.0, 0.0]);
@@ -299,6 +482,7 @@ impl GpuCache {
299
482
joint[3] as f32,
300
483
]);
301
484
interleaved.extend_from_slice(&weight);
485
interleaved.extend_from_slice(&frame.uvs.get(index).copied().unwrap_or([0.; 2]));
302
486
}
303
487
gl.bind_vertex_array(self.vao);
304
488
gl.bind_buffer(glow::ARRAY_BUFFER, self.vbo);
@@ -313,11 +497,46 @@ impl GpuCache {
313
497
bytemuck_u32(&frame.indices),
314
498
glow::DYNAMIC_DRAW,
315
499
);
316
let stride = 14 * 4;
500
let stride = 16 * 4;
317
501
enable_float(gl, 0, 3, stride, 0);
318
502
enable_float(gl, 1, 3, stride, 3 * 4);
319
503
enable_float(gl, 2, 4, stride, 6 * 4);
320
504
enable_float(gl, 3, 4, stride, 10 * 4);
505
enable_float(gl, 4, 2, stride, 14 * 4);
506
for (unit, texture) in [&frame.albedo, &frame.shiny].iter().enumerate() {
507
if let Some(old) = self.textures[unit].take() {
508
gl.delete_texture(old);
509
}
510
if let Some(texture) = texture {
511
let id = gl.create_texture().map_err(|e| e.to_string())?;
512
self.textures[unit] = Some(id);
513
gl.active_texture(glow::TEXTURE0 + unit as u32);
514
gl.bind_texture(glow::TEXTURE_2D, Some(id));
515
gl.tex_parameter_i32(
516
glow::TEXTURE_2D,
517
glow::TEXTURE_MIN_FILTER,
518
glow::NEAREST as i32,
519
);
520
gl.tex_parameter_i32(
521
glow::TEXTURE_2D,
522
glow::TEXTURE_MAG_FILTER,
523
glow::NEAREST as i32,
524
);
525
gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_S, glow::REPEAT as i32);
526
gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_T, glow::REPEAT as i32);
527
gl.tex_image_2d(
528
glow::TEXTURE_2D,
529
0,
530
glow::RGBA8 as i32,
531
texture.width as i32,
532
texture.height as i32,
533
0,
534
glow::RGBA,
535
glow::UNSIGNED_BYTE,
536
Some(&texture.rgba),
537
);
538
}
539
}
321
540
self.uploaded = frame.generation;
322
541
self.index_count = frame.indices.len() as i32;
323
542
Ok(())
@@ -367,6 +586,10 @@ layout(location = 0) in vec3 a_pos;
367
586
layout(location = 1) in vec3 a_normal;
368
587
layout(location = 2) in vec4 a_joints;
369
588
layout(location = 3) in vec4 a_weights;
589
layout(location = 4) in vec2 a_uv;
590
uniform mat4 u_model;
591
out vec2 v_uv;
592
out vec3 v_world;
370
593
uniform mat4 u_view_proj;
371
594
uniform mat4 u_bones[128];
372
595
out vec3 v_normal;
@@ -379,8 +602,9 @@ void main() {
379
602
+ a_weights.y * u_bones[j1]
380
603
+ a_weights.z * u_bones[j2]
381
604
+ a_weights.w * u_bones[j3];
382
vec4 world = skin * vec4(a_pos, 1.0);
383
v_normal = mat3(skin) * a_normal;
605
vec4 world = u_model * skin * vec4(a_pos, 1.0);
606
v_uv = vec2(a_uv.x,1.0-a_uv.y); v_world=world.xyz;
607
v_normal = transpose(inverse(mat3(u_model * skin))) * a_normal;
384
608
gl_Position = u_view_proj * world;
385
609
}
386
610
"#;
@@ -388,11 +612,25 @@ void main() {
388
612
const FRAGMENT: &str = r#"#version 330
389
613
in vec3 v_normal;
390
614
uniform vec4 u_color;
615
uniform sampler2D u_albedo;
616
uniform sampler2D u_shiny;
617
uniform bool u_albedo_enabled;
618
uniform bool u_shiny_enabled;
619
uniform float u_specular;
620
uniform vec3 u_eye;
621
in vec2 v_uv;
622
in vec3 v_world;
391
623
out vec4 frag;
392
624
void main() {
393
625
vec3 n = normalize(v_normal);
394
626
vec3 light = normalize(vec3(0.35, 0.85, 0.4));
395
627
float shade = 0.28 + 0.72 * max(dot(n, light), 0.0);
396
frag = vec4(u_color.rgb * shade, 1.0);
628
vec4 color=u_color;
629
if(u_albedo_enabled) color*=texture(u_albedo,v_uv);
630
if(color.a<0.5) discard;
631
float strength=u_specular;
632
if(u_shiny_enabled) strength*=dot(texture(u_shiny,v_uv).rgb,vec3(0.2126,0.7152,0.0722));
633
float highlight=pow(max(dot(reflect(-light,n),normalize(u_eye-v_world)),0.0),32.0)*strength;
634
frag = vec4(clamp(color.rgb * shade + highlight,0.0,1.0), 1.0);
397
635
}
398
636
"#;
@@ -33,11 +33,18 @@ impl Viewport {33
}33
}34
}34
}35
35
36
pub fn destroy(&self, gl: &glow::Context) {37
if let Ok(mut cache) = self.cache.lock() {38
unsafe { cache.destroy(gl) }39
}40
}41
36
pub fn show(42
pub fn show(37
&self,43
&self,38
ui: &mut egui::Ui,44
ui: &mut egui::Ui,39
controls: &mut PreviewControls,45
controls: &mut PreviewControls,40
character: Option<&PreviewCharacter>,46
character: Option<&PreviewCharacter>,47
presets: &[&crate::equipment::Preset],41
) {48
) {42
ui.horizontal_wrapped(|ui| {49
ui.horizontal_wrapped(|ui| {43
if let Some(character) = character {50
if let Some(character) = character {@@ -129,8 +136,33 @@ impl Viewport {129
perspective(38.0, aspect, 0.05, 40.0),136
perspective(38.0, aspect, 0.05, 40.0),130
view_matrix(eye, target),137
view_matrix(eye, target),131
);138
);139
let mut cache = self.cache.lock().expect("preview gpu lock");140
let mut diagnostics = Vec::new();141
let mut equipment = Vec::new();142
let sex = if controls.sex == crate::preview::PreviewSex::Male {143
0144
} else {145
1146
};147
for preset in presets {148
match crate::equipment::layers_cached(149
preset,150
sex,151
character,152
controls,153
&mut cache.texture_cache,154
) {155
Ok(layers) => {156
for layer in layers {157
equipment.push(PendingFrame::from_layer(layer, view_proj, eye));158
}159
}160
Err(error) => {161
diagnostics.push(error);162
}163
}164
}132
{165
{133
let mut cache = self.cache.lock().expect("preview gpu lock");134
cache.pending = Some(PendingFrame {166
cache.pending = Some(PendingFrame {135
generation: character_generation(character),167
generation: character_generation(character),136
positions: character.positions.clone(),168
positions: character.positions.clone(),@@ -141,18 +173,45 @@ impl Viewport {141
bones,173
bones,142
view_proj,174
view_proj,143
color: character.base_color,175
color: character.base_color,176
model: crate::preview::identity(),177
albedo: None,178
shiny: None,179
specular: 0.0,180
eye,181
equipment,182
uvs: Vec::new(),144
});183
});145
}184
}185
drop(cache);146
let cache = Arc::clone(&self.cache);186
let cache = Arc::clone(&self.cache);147
let callback = CallbackFn::new(move |info, painter| {187
let callback = CallbackFn::new(move |info, painter| {148
if let Ok(mut cache) = cache.lock() {188
if let Ok(mut cache) = cache.lock() {149
unsafe { cache.paint(painter.gl(), &info) };189
unsafe { cache.paint(painter.gl(), &info, true) };150
}190
}151
});191
});152
ui.painter().add(egui::PaintCallback {192
ui.painter().add(egui::PaintCallback {153
rect,193
rect,154
callback: Arc::new(callback),194
callback: Arc::new(callback),155
});195
});196
if let Ok(cache) = self.cache.lock() {197
if let Some(error) = &cache.error {198
diagnostics.push(error.clone());199
}200
for layer in &cache.layers {201
if let Some(error) = &layer.error {202
diagnostics.push(error.clone());203
}204
}205
}206
if !diagnostics.is_empty() {207
ui.painter().text(208
rect.left_top() + egui::vec2(12., 12.),209
egui::Align2::LEFT_TOP,210
diagnostics.join("\n"),211
egui::FontId::proportional(14.),212
egui::Color32::LIGHT_RED,213
);214
}156
}215
}157
}216
}158
217
@@ -184,6 +243,62 @@ struct PendingFrame {184
bones: Vec<crate::preview::Mat4>,243
bones: Vec<crate::preview::Mat4>,185
view_proj: crate::preview::Mat4,244
view_proj: crate::preview::Mat4,186
color: [f32; 4],245
color: [f32; 4],246
model: crate::preview::Mat4,247
eye: [f32; 3],248
albedo: Option<std::sync::Arc<crate::equipment::Texture>>,249
shiny: Option<std::sync::Arc<crate::equipment::Texture>>,250
specular: f32,251
equipment: Vec<PendingFrame>,252
uvs: Vec<[f32; 2]>,253
}254
255
impl PendingFrame {256
fn from_layer(257
layer: crate::equipment::Layer,258
view_proj: crate::preview::Mat4,259
eye: [f32; 3],260
) -> Self {261
use std::hash::{Hash, Hasher};262
let mut hash = std::collections::hash_map::DefaultHasher::new();263
for v in &layer.mesh.positions {264
v.map(f32::to_bits).hash(&mut hash);265
}266
for v in &layer.mesh.normals {267
v.map(f32::to_bits).hash(&mut hash);268
}269
for v in &layer.mesh.uvs {270
v.map(f32::to_bits).hash(&mut hash);271
}272
layer.mesh.indices.hash(&mut hash);273
if let Some(t) = &layer.albedo {274
t.content_hash.hash(&mut hash);275
t.width.hash(&mut hash);276
t.height.hash(&mut hash);277
}278
if let Some(t) = &layer.shiny {279
t.content_hash.hash(&mut hash);280
t.width.hash(&mut hash);281
t.height.hash(&mut hash);282
}283
Self {284
generation: hash.finish(),285
positions: layer.mesh.positions,286
normals: layer.mesh.normals,287
indices: layer.mesh.indices,288
uvs: layer.mesh.uvs,289
color: layer.mesh.color,290
joints: Vec::new(),291
weights: Vec::new(),292
bones: Vec::new(),293
model: layer.model,294
view_proj,295
eye,296
albedo: layer.albedo,297
shiny: layer.shiny,298
specular: layer.specular,299
equipment: Vec::new(),300
}301
}187
}302
}188
303
189
#[derive(Default)]304
#[derive(Default)]@@ -196,10 +311,13 @@ struct GpuCache {196
index_count: i32,311
index_count: i32,197
pending: Option<PendingFrame>,312
pending: Option<PendingFrame>,198
error: Option<String>,313
error: Option<String>,314
textures: [Option<glow::Texture>; 2],315
layers: Vec<GpuCache>,316
texture_cache: crate::equipment::TextureCache,199
}317
}200
318
201
impl GpuCache {319
impl GpuCache {202
unsafe fn paint(&mut self, gl: &glow::Context, info: &egui::PaintCallbackInfo) {320
unsafe fn paint(&mut self, gl: &glow::Context, info: &egui::PaintCallbackInfo, clear: bool) {203
let Some(frame) = self.pending.clone() else {321
let Some(frame) = self.pending.clone() else {204
return;322
return;205
};323
};@@ -237,10 +355,40 @@ impl GpuCache {237
gl.depth_func(glow::LESS);355
gl.depth_func(glow::LESS);238
gl.disable(glow::BLEND);356
gl.disable(glow::BLEND);239
gl.clear_color(0.08, 0.09, 0.12, 1.0);357
gl.clear_color(0.08, 0.09, 0.12, 1.0);240
gl.clear(glow::COLOR_BUFFER_BIT | glow::DEPTH_BUFFER_BIT);358
if clear {359
gl.clear(glow::COLOR_BUFFER_BIT | glow::DEPTH_BUFFER_BIT);360
}241
gl.use_program(Some(program));361
gl.use_program(Some(program));242
let view = gl.get_uniform_location(program, "u_view_proj");362
let view = gl.get_uniform_location(program, "u_view_proj");243
gl.uniform_matrix_4_f32_slice(view.as_ref(), false, &frame.view_proj);363
gl.uniform_matrix_4_f32_slice(view.as_ref(), false, &frame.view_proj);364
gl.uniform_matrix_4_f32_slice(365
gl.get_uniform_location(program, "u_model").as_ref(),366
false,367
&frame.model,368
);369
gl.uniform_3_f32(370
gl.get_uniform_location(program, "u_eye").as_ref(),371
frame.eye[0],372
frame.eye[1],373
frame.eye[2],374
);375
gl.uniform_1_f32(376
gl.get_uniform_location(program, "u_specular").as_ref(),377
frame.specular,378
);379
for (unit, name, present) in [380
(0, "u_albedo", frame.albedo.is_some()),381
(1, "u_shiny", frame.shiny.is_some()),382
] {383
gl.active_texture(glow::TEXTURE0 + unit as u32);384
gl.bind_texture(glow::TEXTURE_2D, self.textures[unit]);385
gl.uniform_1_i32(gl.get_uniform_location(program, name).as_ref(), unit as i32);386
gl.uniform_1_i32(387
gl.get_uniform_location(program, &format!("{name}_enabled"))388
.as_ref(),389
i32::from(present),390
);391
}244
let color = gl.get_uniform_location(program, "u_color");392
let color = gl.get_uniform_location(program, "u_color");245
gl.uniform_4_f32(393
gl.uniform_4_f32(246
color.as_ref(),394
color.as_ref(),@@ -259,11 +407,46 @@ impl GpuCache {259
gl.bind_vertex_array(self.vao);407
gl.bind_vertex_array(self.vao);260
gl.draw_elements(glow::TRIANGLES, self.index_count, glow::UNSIGNED_INT, 0);408
gl.draw_elements(glow::TRIANGLES, self.index_count, glow::UNSIGNED_INT, 0);261
gl.bind_vertex_array(None);409
gl.bind_vertex_array(None);410
while self.layers.len() > frame.equipment.len() {411
if let Some(mut layer) = self.layers.pop() {412
layer.destroy(gl);413
}414
}415
while self.layers.len() < frame.equipment.len() {416
self.layers.push(GpuCache::default());417
}418
for (cache, frame) in self.layers.iter_mut().zip(frame.equipment) {419
cache.pending = Some(frame);420
cache.paint(gl, info, false);421
}422
gl.active_texture(glow::TEXTURE0);262
gl.disable(glow::DEPTH_TEST);423
gl.disable(glow::DEPTH_TEST);263
gl.disable(glow::SCISSOR_TEST);424
gl.disable(glow::SCISSOR_TEST);264
gl.enable(glow::BLEND);425
gl.enable(glow::BLEND);265
}426
}266
427
428
unsafe fn destroy(&mut self, gl: &glow::Context) {429
for layer in &mut self.layers {430
layer.destroy(gl);431
}432
for texture in &mut self.textures {433
if let Some(t) = texture.take() {434
gl.delete_texture(t)435
}436
}437
if let Some(v) = self.vao.take() {438
gl.delete_vertex_array(v)439
}440
if let Some(v) = self.vbo.take() {441
gl.delete_buffer(v)442
}443
if let Some(v) = self.ebo.take() {444
gl.delete_buffer(v)445
}446
if let Some(p) = self.program.take() {447
gl.delete_program(p)448
}449
}267
unsafe fn ensure_program(&mut self, gl: &glow::Context) -> Result<(), String> {450
unsafe fn ensure_program(&mut self, gl: &glow::Context) -> Result<(), String> {268
if self.program.is_some() {451
if self.program.is_some() {269
return Ok(());452
return Ok(());@@ -280,7 +463,7 @@ impl GpuCache {280
}463
}281
464
282
unsafe fn upload(&mut self, gl: &glow::Context, frame: &PendingFrame) -> Result<(), String> {465
unsafe fn upload(&mut self, gl: &glow::Context, frame: &PendingFrame) -> Result<(), String> {283
let mut interleaved = Vec::with_capacity(frame.positions.len() * 14);466
let mut interleaved = Vec::with_capacity(frame.positions.len() * 16);284
for index in 0..frame.positions.len() {467
for index in 0..frame.positions.len() {285
let position = frame.positions[index];468
let position = frame.positions[index];286
let normal = frame.normals.get(index).copied().unwrap_or([0.0, 1.0, 0.0]);469
let normal = frame.normals.get(index).copied().unwrap_or([0.0, 1.0, 0.0]);@@ -299,6 +482,7 @@ impl GpuCache {299
joint[3] as f32,482
joint[3] as f32,300
]);483
]);301
interleaved.extend_from_slice(&weight);484
interleaved.extend_from_slice(&weight);485
interleaved.extend_from_slice(&frame.uvs.get(index).copied().unwrap_or([0.; 2]));302
}486
}303
gl.bind_vertex_array(self.vao);487
gl.bind_vertex_array(self.vao);304
gl.bind_buffer(glow::ARRAY_BUFFER, self.vbo);488
gl.bind_buffer(glow::ARRAY_BUFFER, self.vbo);@@ -313,11 +497,46 @@ impl GpuCache {313
bytemuck_u32(&frame.indices),497
bytemuck_u32(&frame.indices),314
glow::DYNAMIC_DRAW,498
glow::DYNAMIC_DRAW,315
);499
);316
let stride = 14 * 4;500
let stride = 16 * 4;317
enable_float(gl, 0, 3, stride, 0);501
enable_float(gl, 0, 3, stride, 0);318
enable_float(gl, 1, 3, stride, 3 * 4);502
enable_float(gl, 1, 3, stride, 3 * 4);319
enable_float(gl, 2, 4, stride, 6 * 4);503
enable_float(gl, 2, 4, stride, 6 * 4);320
enable_float(gl, 3, 4, stride, 10 * 4);504
enable_float(gl, 3, 4, stride, 10 * 4);505
enable_float(gl, 4, 2, stride, 14 * 4);506
for (unit, texture) in [&frame.albedo, &frame.shiny].iter().enumerate() {507
if let Some(old) = self.textures[unit].take() {508
gl.delete_texture(old);509
}510
if let Some(texture) = texture {511
let id = gl.create_texture().map_err(|e| e.to_string())?;512
self.textures[unit] = Some(id);513
gl.active_texture(glow::TEXTURE0 + unit as u32);514
gl.bind_texture(glow::TEXTURE_2D, Some(id));515
gl.tex_parameter_i32(516
glow::TEXTURE_2D,517
glow::TEXTURE_MIN_FILTER,518
glow::NEAREST as i32,519
);520
gl.tex_parameter_i32(521
glow::TEXTURE_2D,522
glow::TEXTURE_MAG_FILTER,523
glow::NEAREST as i32,524
);525
gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_S, glow::REPEAT as i32);526
gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_T, glow::REPEAT as i32);527
gl.tex_image_2d(528
glow::TEXTURE_2D,529
0,530
glow::RGBA8 as i32,531
texture.width as i32,532
texture.height as i32,533
0,534
glow::RGBA,535
glow::UNSIGNED_BYTE,536
Some(&texture.rgba),537
);538
}539
}321
self.uploaded = frame.generation;540
self.uploaded = frame.generation;322
self.index_count = frame.indices.len() as i32;541
self.index_count = frame.indices.len() as i32;323
Ok(())542
Ok(())@@ -367,6 +586,10 @@ layout(location = 0) in vec3 a_pos;367
layout(location = 1) in vec3 a_normal;586
layout(location = 1) in vec3 a_normal;368
layout(location = 2) in vec4 a_joints;587
layout(location = 2) in vec4 a_joints;369
layout(location = 3) in vec4 a_weights;588
layout(location = 3) in vec4 a_weights;589
layout(location = 4) in vec2 a_uv;590
uniform mat4 u_model;591
out vec2 v_uv;592
out vec3 v_world;370
uniform mat4 u_view_proj;593
uniform mat4 u_view_proj;371
uniform mat4 u_bones[128];594
uniform mat4 u_bones[128];372
out vec3 v_normal;595
out vec3 v_normal;@@ -379,8 +602,9 @@ void main() {379
+ a_weights.y * u_bones[j1]602
+ a_weights.y * u_bones[j1]380
+ a_weights.z * u_bones[j2]603
+ a_weights.z * u_bones[j2]381
+ a_weights.w * u_bones[j3];604
+ a_weights.w * u_bones[j3];382
vec4 world = skin * vec4(a_pos, 1.0);605
vec4 world = u_model * skin * vec4(a_pos, 1.0);383
v_normal = mat3(skin) * a_normal;606
v_uv = vec2(a_uv.x,1.0-a_uv.y); v_world=world.xyz;607
v_normal = transpose(inverse(mat3(u_model * skin))) * a_normal;384
gl_Position = u_view_proj * world;608
gl_Position = u_view_proj * world;385
}609
}386
"#;610
"#;@@ -388,11 +612,25 @@ void main() {388
const FRAGMENT: &str = r#"#version 330612
const FRAGMENT: &str = r#"#version 330389
in vec3 v_normal;613
in vec3 v_normal;390
uniform vec4 u_color;614
uniform vec4 u_color;615
uniform sampler2D u_albedo;616
uniform sampler2D u_shiny;617
uniform bool u_albedo_enabled;618
uniform bool u_shiny_enabled;619
uniform float u_specular;620
uniform vec3 u_eye;621
in vec2 v_uv;622
in vec3 v_world;391
out vec4 frag;623
out vec4 frag;392
void main() {624
void main() {393
vec3 n = normalize(v_normal);625
vec3 n = normalize(v_normal);394
vec3 light = normalize(vec3(0.35, 0.85, 0.4));626
vec3 light = normalize(vec3(0.35, 0.85, 0.4));395
float shade = 0.28 + 0.72 * max(dot(n, light), 0.0);627
float shade = 0.28 + 0.72 * max(dot(n, light), 0.0);396
frag = vec4(u_color.rgb * shade, 1.0);628
vec4 color=u_color;629
if(u_albedo_enabled) color*=texture(u_albedo,v_uv);630
if(color.a<0.5) discard;631
float strength=u_specular;632
if(u_shiny_enabled) strength*=dot(texture(u_shiny,v_uv).rgb,vec3(0.2126,0.7152,0.0722));633
float highlight=pow(max(dot(reflect(-light,n),normalize(u_eye-v_world)),0.0),32.0)*strength;634
frag = vec4(clamp(color.rgb * shade + highlight,0.0,1.0), 1.0);397
}635
}398
"#;636
"#;@@ -7,6 +7,7 @@
7
7
use std::fs;
8
8
use std::path::{Path, PathBuf};
9
9
10
use crate::equipment::Preset;
10
11
use serde::{Deserialize, Serialize};
11
12
12
13
use crate::project::{
@@ -65,14 +66,16 @@ struct EquipmentFile {
65
66
model: Option<String>,
66
67
}
67
68
68
#[derive(Debug, Clone, PartialEq, Eq)]
69
#[derive(Debug, Clone, PartialEq)]
69
70
pub struct EquipmentItem {
70
71
pub slug: String,
71
72
pub model: Option<String>,
72
73
pub legacy_path: Option<String>,
74
pub preset: Option<Preset>,
75
pub error: Option<String>,
73
76
}
74
77
75
#[derive(Debug, Clone, PartialEq, Eq)]
78
#[derive(Debug, Clone, PartialEq)]
76
79
pub struct Session {
77
80
pub screen: Screen,
78
81
pub active_body: Option<Sex>,
@@ -216,6 +219,8 @@ impl Session {
216
219
pub fn add_equipment(&mut self) -> usize {
217
220
let slug = next_slug(&self.equipment);
218
221
self.equipment.push(EquipmentItem {
222
preset: Some(Preset::new(slug.clone())),
223
error: None,
219
224
slug,
220
225
model: None,
221
226
legacy_path: None,
@@ -252,6 +257,8 @@ impl Session {
252
257
if let Some(item) = self.equipment.get_mut(index) {
253
258
item.model = Some(relative);
254
259
item.legacy_path = None;
260
item.preset = None;
261
item.error = None;
255
262
}
256
263
}
257
264
@@ -321,6 +328,11 @@ impl Session {
321
328
disk_issues: Vec::new(),
322
329
};
323
330
save_project(&candidate)?;
331
for item in &mut self.equipment {
332
if let Some(preset) = &mut item.preset {
333
preset.name = item.slug.clone();
334
}
335
}
324
336
self.opened = Some(candidate);
325
337
self.note_saved();
326
338
self.ok("Opgeslagen.");
@@ -449,16 +461,42 @@ fn read_equipment(root: &Path, presets: &[String]) -> Vec<EquipmentItem> {
449
461
Ok(path) => path,
450
462
Err(_) => {
451
463
return EquipmentItem {
464
preset: None,
465
error: Some("onveilig presetpad".into()),
452
466
slug: "onveilig".into(),
453
467
model: None,
454
468
legacy_path: Some(path.clone()),
455
469
}
456
470
}
457
471
};
472
if file.extension().and_then(|e| e.to_str()) == Some("ppe") {
473
return match Preset::read(&file) {
474
Ok(preset) => EquipmentItem {
475
slug: preset.name.clone(),
476
model: preset.attachments.first().map(|a| a.label.clone()),
477
preset: Some(preset),
478
error: None,
479
legacy_path: None,
480
},
481
Err(error) => EquipmentItem {
482
slug: file
483
.file_stem()
484
.unwrap_or_default()
485
.to_string_lossy()
486
.into(),
487
model: None,
488
preset: None,
489
error: Some(error),
490
legacy_path: Some(path.clone()),
491
},
492
};
493
}
458
494
if let Ok(text) = fs::read_to_string(&file) {
459
495
if let Ok(parsed) = serde_json::from_str::<EquipmentFile>(&text) {
460
496
if valid_slug(&parsed.name) {
461
497
return EquipmentItem {
498
preset: None,
499
error: None,
462
500
slug: parsed.name,
463
501
model: parsed.model,
464
502
legacy_path: None,
@@ -475,6 +513,8 @@ fn read_equipment(root: &Path, presets: &[String]) -> Vec<EquipmentItem> {
475
513
.unwrap_or("item")
476
514
.to_string();
477
515
EquipmentItem {
516
preset: None,
517
error: Some("legacy preset; kies een OBJ om te configureren".into()),
478
518
slug,
479
519
model: None,
480
520
legacy_path: Some(path.clone()),
@@ -498,11 +538,31 @@ fn write_equipment(root: &Path, items: &[EquipmentItem]) -> Result<Vec<String>,
498
538
if !valid_slug(&item.slug) {
499
539
return Err(format!("equipment '{}' heeft geen geldige naam", item.slug));
500
540
}
501
if let Some(model) = &item.model {
541
if let Some(preset) = &item.preset {
542
let mut preset = preset.clone();
543
preset.name = item.slug.clone();
544
preset.validate()?;
545
} else if let Some(model) = &item.model {
502
546
portable_relative(model)?;
503
547
}
504
548
}
505
549
for item in items {
550
if let Some(preset) = &item.preset {
551
let mut preset = preset.clone();
552
preset.name = item.slug.clone();
553
let bytes = preset.encode()?;
554
let relative = format!(
555
"presets/{}-{:08x}-{}.ppe",
556
item.slug,
557
crate::equipment::checksum(&bytes),
558
bytes.len()
559
);
560
let path = checked_project_path(root, &relative)?;
561
fs::create_dir_all(path.parent().unwrap()).map_err(|e| e.to_string())?;
562
crate::equipment::write_snapshot(&path, &bytes)?;
563
paths.push(relative);
564
continue;
565
}
506
566
if item.model.is_none() {
507
567
if let Some(legacy) = &item.legacy_path {
508
568
paths.push(legacy.clone());
@@ -7,6 +7,7 @@7
use std::fs;7
use std::fs;8
use std::path::{Path, PathBuf};8
use std::path::{Path, PathBuf};9
9
10
use crate::equipment::Preset;10
use serde::{Deserialize, Serialize};11
use serde::{Deserialize, Serialize};11
12
12
use crate::project::{13
use crate::project::{@@ -65,14 +66,16 @@ struct EquipmentFile {65
model: Option<String>,66
model: Option<String>,66
}67
}67
68
68
#[derive(Debug, Clone, PartialEq, Eq)]69
#[derive(Debug, Clone, PartialEq)]69
pub struct EquipmentItem {70
pub struct EquipmentItem {70
pub slug: String,71
pub slug: String,71
pub model: Option<String>,72
pub model: Option<String>,72
pub legacy_path: Option<String>,73
pub legacy_path: Option<String>,74
pub preset: Option<Preset>,75
pub error: Option<String>,73
}76
}74
77
75
#[derive(Debug, Clone, PartialEq, Eq)]78
#[derive(Debug, Clone, PartialEq)]76
pub struct Session {79
pub struct Session {77
pub screen: Screen,80
pub screen: Screen,78
pub active_body: Option<Sex>,81
pub active_body: Option<Sex>,@@ -216,6 +219,8 @@ impl Session {216
pub fn add_equipment(&mut self) -> usize {219
pub fn add_equipment(&mut self) -> usize {217
let slug = next_slug(&self.equipment);220
let slug = next_slug(&self.equipment);218
self.equipment.push(EquipmentItem {221
self.equipment.push(EquipmentItem {222
preset: Some(Preset::new(slug.clone())),223
error: None,219
slug,224
slug,220
model: None,225
model: None,221
legacy_path: None,226
legacy_path: None,@@ -252,6 +257,8 @@ impl Session {252
if let Some(item) = self.equipment.get_mut(index) {257
if let Some(item) = self.equipment.get_mut(index) {253
item.model = Some(relative);258
item.model = Some(relative);254
item.legacy_path = None;259
item.legacy_path = None;260
item.preset = None;261
item.error = None;255
}262
}256
}263
}257
264
@@ -321,6 +328,11 @@ impl Session {321
disk_issues: Vec::new(),328
disk_issues: Vec::new(),322
};329
};323
save_project(&candidate)?;330
save_project(&candidate)?;331
for item in &mut self.equipment {332
if let Some(preset) = &mut item.preset {333
preset.name = item.slug.clone();334
}335
}324
self.opened = Some(candidate);336
self.opened = Some(candidate);325
self.note_saved();337
self.note_saved();326
self.ok("Opgeslagen.");338
self.ok("Opgeslagen.");@@ -449,16 +461,42 @@ fn read_equipment(root: &Path, presets: &[String]) -> Vec<EquipmentItem> {449
Ok(path) => path,461
Ok(path) => path,450
Err(_) => {462
Err(_) => {451
return EquipmentItem {463
return EquipmentItem {464
preset: None,465
error: Some("onveilig presetpad".into()),452
slug: "onveilig".into(),466
slug: "onveilig".into(),453
model: None,467
model: None,454
legacy_path: Some(path.clone()),468
legacy_path: Some(path.clone()),455
}469
}456
}470
}457
};471
};472
if file.extension().and_then(|e| e.to_str()) == Some("ppe") {473
return match Preset::read(&file) {474
Ok(preset) => EquipmentItem {475
slug: preset.name.clone(),476
model: preset.attachments.first().map(|a| a.label.clone()),477
preset: Some(preset),478
error: None,479
legacy_path: None,480
},481
Err(error) => EquipmentItem {482
slug: file483
.file_stem()484
.unwrap_or_default()485
.to_string_lossy()486
.into(),487
model: None,488
preset: None,489
error: Some(error),490
legacy_path: Some(path.clone()),491
},492
};493
}458
if let Ok(text) = fs::read_to_string(&file) {494
if let Ok(text) = fs::read_to_string(&file) {459
if let Ok(parsed) = serde_json::from_str::<EquipmentFile>(&text) {495
if let Ok(parsed) = serde_json::from_str::<EquipmentFile>(&text) {460
if valid_slug(&parsed.name) {496
if valid_slug(&parsed.name) {461
return EquipmentItem {497
return EquipmentItem {498
preset: None,499
error: None,462
slug: parsed.name,500
slug: parsed.name,463
model: parsed.model,501
model: parsed.model,464
legacy_path: None,502
legacy_path: None,@@ -475,6 +513,8 @@ fn read_equipment(root: &Path, presets: &[String]) -> Vec<EquipmentItem> {475
.unwrap_or("item")513
.unwrap_or("item")476
.to_string();514
.to_string();477
EquipmentItem {515
EquipmentItem {516
preset: None,517
error: Some("legacy preset; kies een OBJ om te configureren".into()),478
slug,518
slug,479
model: None,519
model: None,480
legacy_path: Some(path.clone()),520
legacy_path: Some(path.clone()),@@ -498,11 +538,31 @@ fn write_equipment(root: &Path, items: &[EquipmentItem]) -> Result<Vec<String>,498
if !valid_slug(&item.slug) {538
if !valid_slug(&item.slug) {499
return Err(format!("equipment '{}' heeft geen geldige naam", item.slug));539
return Err(format!("equipment '{}' heeft geen geldige naam", item.slug));500
}540
}501
if let Some(model) = &item.model {541
if let Some(preset) = &item.preset {542
let mut preset = preset.clone();543
preset.name = item.slug.clone();544
preset.validate()?;545
} else if let Some(model) = &item.model {502
portable_relative(model)?;546
portable_relative(model)?;503
}547
}504
}548
}505
for item in items {549
for item in items {550
if let Some(preset) = &item.preset {551
let mut preset = preset.clone();552
preset.name = item.slug.clone();553
let bytes = preset.encode()?;554
let relative = format!(555
"presets/{}-{:08x}-{}.ppe",556
item.slug,557
crate::equipment::checksum(&bytes),558
bytes.len()559
);560
let path = checked_project_path(root, &relative)?;561
fs::create_dir_all(path.parent().unwrap()).map_err(|e| e.to_string())?;562
crate::equipment::write_snapshot(&path, &bytes)?;563
paths.push(relative);564
continue;565
}506
if item.model.is_none() {566
if item.model.is_none() {507
if let Some(legacy) = &item.legacy_path {567
if let Some(legacy) = &item.legacy_path {508
paths.push(legacy.clone());568
paths.push(legacy.clone());@@ -0,0 +1,348 @@
1
use paperdoll_renderer::equipment::{self, Preset, RigSignature, MAGIC};
2
use paperdoll_renderer::preview::{self, PreviewControls};
3
use std::fs;
4
use std::path::PathBuf;
5
6
fn fixture(name: &str) -> PathBuf {
7
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
8
.join("tests/fixtures/equipment")
9
.join(name)
10
}
11
fn fixture_body(female: bool) -> preview::PreviewCharacter {
12
preview::load_gltf_file(&PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(if female {
13
"tests/fixtures/bodies/test-body-female.glb"
14
} else {
15
"tests/fixtures/bodies/test-body-male.glb"
16
}))
17
.unwrap()
18
}
19
fn preset() -> Preset {
20
let mut p = Preset::new("sword".into());
21
p.provenance = "CC0-1.0 generated test fixture".into();
22
equipment::import_obj(&mut p, &fixture("sword.obj")).unwrap();
23
equipment::import_shiny(&mut p, 0, &fixture("shiny.png")).unwrap();
24
for sex in 0..2 {
25
let body = fixture_body(sex == 1);
26
let joint = body
27
.joint_names
28
.iter()
29
.position(|n| n == "RightHand")
30
.unwrap();
31
p.bind(sex, &body, 0, joint).unwrap();
32
}
33
p.attachments[0].translation = [0.02, -0.02, -0.03];
34
p.attachments[0].rotation_deg = [5., 0., -15.];
35
p
36
}
37
fn scratch() -> PathBuf {
38
std::env::temp_dir().join(format!(
39
"paperdoll-equipment-{}",
40
std::time::SystemTime::now()
41
.duration_since(std::time::UNIX_EPOCH)
42
.unwrap()
43
.as_nanos()
44
))
45
}
46
47
#[test]
48
fn binary_roundtrip_embeds_model_material_textures_and_both_rigs() {
49
let p = preset();
50
let bytes = p.encode().unwrap();
51
assert_eq!(&bytes[..8], MAGIC);
52
let restored = Preset::decode(&bytes).unwrap();
53
assert_eq!(restored, p);
54
assert_eq!(restored.assets.len(), 4);
55
assert_eq!(
56
restored
57
.texture(restored.attachments[0].meshes[0].albedo.unwrap())
58
.unwrap()
59
.width,
60
2
61
);
62
assert_eq!(restored.attachments[0].meshes[0].indices.len(), 6);
63
assert!(restored
64
.assets
65
.iter()
66
.any(|a| a.bytes == fs::read(fixture("sword.obj")).unwrap()));
67
assert!(restored.rigs.iter().all(Option::is_some));
68
let snapshot = fixture("sword.ppe");
69
if std::env::var_os("PAPERDOLL_WRITE_PRESET").is_some() {
70
fs::write(&snapshot, &bytes).unwrap();
71
}
72
assert_eq!(
73
fs::read(snapshot).unwrap(),
74
bytes,
75
"binary preset fixture changed"
76
);
77
}
78
79
#[test]
80
fn attachment_uses_animated_joint_world_not_inverse_bind_twice() {
81
let p = preset();
82
let body = fixture_body(false);
83
let joint = body
84
.joint_names
85
.iter()
86
.position(|n| n == "RightHand")
87
.unwrap();
88
let mut controls = PreviewControls {
89
time: 0.0,
90
clip_name: "walk".into(),
91
..Default::default()
92
};
93
let first = equipment::layers(&p, 0, &body, &controls).unwrap();
94
let world = preview::animated_joint_world(&body, "walk", 0., joint).unwrap();
95
let expected = preview::mul(
96
world,
97
equipment::local_matrix(p.attachments[0].translation, p.attachments[0].rotation_deg),
98
);
99
assert_eq!(first[0].model, expected);
100
assert_ne!(
101
world,
102
preview::sample_skin_matrices(&body, "walk", 0.)[joint]
103
);
104
controls.time = 0.25;
105
let second = equipment::layers(&p, 0, &body, &controls).unwrap();
106
assert_ne!(first[0].model, second[0].model);
107
let female = equipment::layers(&p, 1, &fixture_body(true), &controls).unwrap();
108
assert!(!female.is_empty());
109
}
110
111
#[test]
112
fn local_euler_order_and_translation_are_not_accumulated() {
113
let m = equipment::local_matrix([1., 2., 3.], [0., 0., 90.]);
114
let point = [m[0] + m[12], m[1] + m[13], m[2] + m[14]];
115
for (a, b) in point.into_iter().zip([1., 3., 3.]) {
116
assert!((a - b).abs() < 1e-5);
117
}
118
assert_eq!(m, equipment::local_matrix([1., 2., 3.], [0., 0., 90.]));
119
}
120
121
#[test]
122
fn incompatible_rig_and_missing_joint_require_explicit_remapping() {
123
let mut p = preset();
124
let mut changed = fixture_body(false);
125
changed.nodes[0].translation[0] += 0.01;
126
assert!(
127
equipment::layers(&p, 0, &changed, &PreviewControls::default())
128
.unwrap_err()
129
.contains("rig")
130
);
131
p.attachments[0].bones[0] = Some("missing".into());
132
assert!(equipment::layers(&p, 0, &fixture_body(false), &PreviewControls::default()).is_err());
133
p.applies[0] = false;
134
assert!(
135
equipment::layers(&p, 0, &changed, &PreviewControls::default())
136
.unwrap()
137
.is_empty()
138
);
139
assert_ne!(
140
RigSignature::of(&fixture_body(false)),
141
RigSignature::of(&fixture_body(true))
142
);
143
}
144
145
#[test]
146
fn corrupt_truncated_unsupported_and_excessive_containers_are_rejected() {
147
let p = preset();
148
let valid = p.encode().unwrap();
149
for n in [0, 7, 19, valid.len() - 1] {
150
assert!(Preset::decode(&valid[..n]).is_err());
151
}
152
let mut damaged = valid.clone();
153
*damaged.last_mut().unwrap() ^= 1;
154
assert!(Preset::decode(&damaged).is_err());
155
let mut damaged = valid.clone();
156
damaged[8..12].copy_from_slice(&2u32.to_le_bytes());
157
assert!(Preset::decode(&damaged).is_err());
158
let mut damaged = valid.clone();
159
damaged[12..16].copy_from_slice(&u32::MAX.to_le_bytes());
160
assert!(Preset::decode(&damaged).is_err());
161
let mut damaged = valid.clone();
162
damaged.push(0);
163
let crc = equipment::checksum(&damaged[20..]);
164
damaged[16..20].copy_from_slice(&crc.to_le_bytes());
165
assert!(Preset::decode(&damaged).is_err());
166
assert_eq!(equipment::checksum(b"123456789"), 0xcbf43926);
167
}
168
169
// Build a malicious but correctly checksummed container without encoder validation.
170
fn untrusted(p: &Preset) -> Vec<u8> {
171
let metadata = serde_json::to_vec(p).unwrap();
172
let n = metadata.len();
173
let mut payload = metadata;
174
for a in &p.assets {
175
payload.extend_from_slice(&(a.bytes.len() as u32).to_le_bytes());
176
payload.extend(&a.bytes)
177
}
178
let mut out = MAGIC.to_vec();
179
out.extend(1u32.to_le_bytes());
180
out.extend((n as u32).to_le_bytes());
181
out.extend(equipment::checksum(&payload).to_le_bytes());
182
out.extend(payload);
183
out
184
}
185
186
#[test]
187
fn traversal_invalid_names_nonfinite_and_invalid_geometry_are_rejected() {
188
for name in ["body", "Hello", "with space", "../escape", ""] {
189
let mut p = preset();
190
p.name = name.into();
191
assert!(p.encode().is_err());
192
assert!(Preset::decode(&untrusted(&p)).is_err());
193
}
194
let mut p = preset();
195
p.assets[0].path = "../escape".into();
196
assert!(Preset::decode(&untrusted(&p)).is_err());
197
let mut p = preset();
198
p.assets[0].path = "/absolute".into();
199
assert!(p.encode().is_err());
200
let mut p = preset();
201
p.attachments[0].translation[0] = f32::NAN;
202
assert!(p.encode().is_err());
203
let mut p = preset();
204
p.attachments[0].meshes[0].indices[0] = u32::MAX;
205
assert!(Preset::decode(&untrusted(&p)).is_err());
206
}
207
208
#[test]
209
fn dependency_escape_and_failed_import_leave_existing_preset_unchanged() {
210
let root = scratch();
211
fs::create_dir_all(&root).unwrap();
212
let path = root.join("bad.obj");
213
fs::write(&path, "mtllib ../outside.mtl\nv 0 0 0\n").unwrap();
214
let mut p = preset();
215
let saved = p.clone();
216
assert!(equipment::import_obj(&mut p, &path).is_err());
217
assert_eq!(saved, p);
218
#[cfg(unix)]
219
{
220
std::os::unix::fs::symlink(fixture("sword.mtl"), root.join("outside.mtl")).unwrap();
221
fs::write(&path, "mtllib outside.mtl\nv 0 0 0\n").unwrap();
222
assert!(equipment::import_obj(&mut p, &path).is_err());
223
}
224
fs::remove_dir_all(root).unwrap();
225
}
226
227
#[test]
228
fn atomic_snapshot_never_overwrites_previous_valid_data() {
229
let root = scratch();
230
fs::create_dir_all(&root).unwrap();
231
let path = root.join("sword.ppe");
232
let bytes = preset().encode().unwrap();
233
equipment::write_snapshot(&path, &bytes).unwrap();
234
equipment::write_snapshot(&path, &bytes).unwrap();
235
assert!(equipment::write_snapshot(&path, b"different").is_err());
236
assert_eq!(fs::read(&path).unwrap(), bytes);
237
assert!(fs::read_dir(&root).unwrap().all(|entry| !entry
238
.unwrap()
239
.file_name()
240
.to_string_lossy()
241
.contains("part-")));
242
fs::remove_dir_all(root).unwrap();
243
}
244
245
#[test]
246
fn binary_project_reload_keeps_payload_after_source_disappears() {
247
let root = scratch();
248
let mut session = paperdoll_renderer::session::Session::new();
249
session.install_new_project(&root).unwrap();
250
let index = session.add_equipment();
251
session.equipment[index].slug = "sword".into();
252
session.equipment[index].preset = Some(preset());
253
session.save().unwrap();
254
let manifest = root.join("character.ppd");
255
let reference = session.opened.as_ref().unwrap().document.equipment_presets[0].clone();
256
assert!(reference.ends_with(".ppe"));
257
let mut again = paperdoll_renderer::session::Session::new();
258
again.install_loaded(paperdoll_renderer::project::open_project(&manifest).unwrap());
259
let loaded = again.equipment[0].preset.as_ref().unwrap();
260
assert_eq!(loaded.name, "sword");
261
assert!(
262
!equipment::layers(loaded, 0, &fixture_body(false), &PreviewControls::default())
263
.unwrap()
264
.is_empty()
265
);
266
let previous = fs::read(&manifest).unwrap();
267
again.equipment[0].preset.as_mut().unwrap().attachments[0].translation[0] = 0.2;
268
again.set_render(0, 128, 12);
269
assert!(again.save().is_err());
270
assert_eq!(fs::read(&manifest).unwrap(), previous);
271
fs::remove_dir_all(root).unwrap();
272
}
273
274
#[test]
275
fn translated_rotated_parent_uses_the_complete_hierarchy_once() {
276
let mut body = fixture_body(false);
277
body.nodes = vec![
278
preview::SceneNode {
279
name: "root".into(),
280
parent: None,
281
translation: [3., 0., 0.],
282
rotation: [
283
0.,
284
0.,
285
std::f32::consts::FRAC_1_SQRT_2,
286
std::f32::consts::FRAC_1_SQRT_2,
287
],
288
scale: [1.; 3],
289
},
290
preview::SceneNode {
291
name: "shoulder".into(),
292
parent: Some(0),
293
translation: [0., 2., 0.],
294
rotation: [0., 0., 0., 1.],
295
scale: [1.; 3],
296
},
297
preview::SceneNode {
298
name: "hand".into(),
299
parent: Some(1),
300
translation: [0., 0., 1.],
301
rotation: [0., 0., 0., 1.],
302
scale: [1.; 3],
303
},
304
];
305
body.joint_nodes = vec![2];
306
body.joint_names = vec!["hand".into()];
307
body.clips.clear();
308
let mut preset = preset();
309
preset.rigs = [None, None];
310
preset.applies = [true, false];
311
preset.attachments[0].bones = [None, None];
312
preset.attachments[0].translation = [0.2, 0.3, 0.4];
313
preset.attachments[0].rotation_deg = [0.; 3];
314
preset.bind(0, &body, 0, 0).unwrap();
315
let layer = equipment::layers(&preset, 0, &body, &PreviewControls::default()).unwrap();
316
for (value, expected) in [layer[0].model[12], layer[0].model[13], layer[0].model[14]]
317
.into_iter()
318
.zip([0.7, 0.2, 1.4])
319
{
320
assert!((value - expected).abs() < 1e-5, "{value} != {expected}");
321
}
322
}
323
324
#[test]
325
fn unsupported_texture_dimensions_and_asset_counts_are_bounded() {
326
let mut p = preset();
327
let mut data = Vec::new();
328
{
329
let mut encoder = png::Encoder::new(&mut data, 2049, 1);
330
encoder.set_color(png::ColorType::Grayscale);
331
encoder.set_depth(png::BitDepth::Eight);
332
let mut writer = encoder.write_header().unwrap();
333
writer.write_image_data(&vec![0; 2049]).unwrap();
334
}
335
let i = p.attachments[0].shiny.unwrap();
336
p.assets[i].bytes = data;
337
assert!(p.encode().is_err());
338
assert!(Preset::decode(&untrusted(&p)).is_err());
339
let mut p = preset();
340
while p.assets.len() < 33 {
341
let n = p.assets.len();
342
p.assets.push(equipment::EmbeddedAsset {
343
path: format!("asset-{n}"),
344
bytes: Vec::new(),
345
});
346
}
347
assert!(Preset::decode(&untrusted(&p)).is_err());
348
}
@@ -0,0 +1,348 @@1
use paperdoll_renderer::equipment::{self, Preset, RigSignature, MAGIC};2
use paperdoll_renderer::preview::{self, PreviewControls};3
use std::fs;4
use std::path::PathBuf;5
6
fn fixture(name: &str) -> PathBuf {7
PathBuf::from(env!("CARGO_MANIFEST_DIR"))8
.join("tests/fixtures/equipment")9
.join(name)10
}11
fn fixture_body(female: bool) -> preview::PreviewCharacter {12
preview::load_gltf_file(&PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(if female {13
"tests/fixtures/bodies/test-body-female.glb"14
} else {15
"tests/fixtures/bodies/test-body-male.glb"16
}))17
.unwrap()18
}19
fn preset() -> Preset {20
let mut p = Preset::new("sword".into());21
p.provenance = "CC0-1.0 generated test fixture".into();22
equipment::import_obj(&mut p, &fixture("sword.obj")).unwrap();23
equipment::import_shiny(&mut p, 0, &fixture("shiny.png")).unwrap();24
for sex in 0..2 {25
let body = fixture_body(sex == 1);26
let joint = body27
.joint_names28
.iter()29
.position(|n| n == "RightHand")30
.unwrap();31
p.bind(sex, &body, 0, joint).unwrap();32
}33
p.attachments[0].translation = [0.02, -0.02, -0.03];34
p.attachments[0].rotation_deg = [5., 0., -15.];35
p36
}37
fn scratch() -> PathBuf {38
std::env::temp_dir().join(format!(39
"paperdoll-equipment-{}",40
std::time::SystemTime::now()41
.duration_since(std::time::UNIX_EPOCH)42
.unwrap()43
.as_nanos()44
))45
}46
47
#[test]48
fn binary_roundtrip_embeds_model_material_textures_and_both_rigs() {49
let p = preset();50
let bytes = p.encode().unwrap();51
assert_eq!(&bytes[..8], MAGIC);52
let restored = Preset::decode(&bytes).unwrap();53
assert_eq!(restored, p);54
assert_eq!(restored.assets.len(), 4);55
assert_eq!(56
restored57
.texture(restored.attachments[0].meshes[0].albedo.unwrap())58
.unwrap()59
.width,60
261
);62
assert_eq!(restored.attachments[0].meshes[0].indices.len(), 6);63
assert!(restored64
.assets65
.iter()66
.any(|a| a.bytes == fs::read(fixture("sword.obj")).unwrap()));67
assert!(restored.rigs.iter().all(Option::is_some));68
let snapshot = fixture("sword.ppe");69
if std::env::var_os("PAPERDOLL_WRITE_PRESET").is_some() {70
fs::write(&snapshot, &bytes).unwrap();71
}72
assert_eq!(73
fs::read(snapshot).unwrap(),74
bytes,75
"binary preset fixture changed"76
);77
}78
79
#[test]80
fn attachment_uses_animated_joint_world_not_inverse_bind_twice() {81
let p = preset();82
let body = fixture_body(false);83
let joint = body84
.joint_names85
.iter()86
.position(|n| n == "RightHand")87
.unwrap();88
let mut controls = PreviewControls {89
time: 0.0,90
clip_name: "walk".into(),91
..Default::default()92
};93
let first = equipment::layers(&p, 0, &body, &controls).unwrap();94
let world = preview::animated_joint_world(&body, "walk", 0., joint).unwrap();95
let expected = preview::mul(96
world,97
equipment::local_matrix(p.attachments[0].translation, p.attachments[0].rotation_deg),98
);99
assert_eq!(first[0].model, expected);100
assert_ne!(101
world,102
preview::sample_skin_matrices(&body, "walk", 0.)[joint]103
);104
controls.time = 0.25;105
let second = equipment::layers(&p, 0, &body, &controls).unwrap();106
assert_ne!(first[0].model, second[0].model);107
let female = equipment::layers(&p, 1, &fixture_body(true), &controls).unwrap();108
assert!(!female.is_empty());109
}110
111
#[test]112
fn local_euler_order_and_translation_are_not_accumulated() {113
let m = equipment::local_matrix([1., 2., 3.], [0., 0., 90.]);114
let point = [m[0] + m[12], m[1] + m[13], m[2] + m[14]];115
for (a, b) in point.into_iter().zip([1., 3., 3.]) {116
assert!((a - b).abs() < 1e-5);117
}118
assert_eq!(m, equipment::local_matrix([1., 2., 3.], [0., 0., 90.]));119
}120
121
#[test]122
fn incompatible_rig_and_missing_joint_require_explicit_remapping() {123
let mut p = preset();124
let mut changed = fixture_body(false);125
changed.nodes[0].translation[0] += 0.01;126
assert!(127
equipment::layers(&p, 0, &changed, &PreviewControls::default())128
.unwrap_err()129
.contains("rig")130
);131
p.attachments[0].bones[0] = Some("missing".into());132
assert!(equipment::layers(&p, 0, &fixture_body(false), &PreviewControls::default()).is_err());133
p.applies[0] = false;134
assert!(135
equipment::layers(&p, 0, &changed, &PreviewControls::default())136
.unwrap()137
.is_empty()138
);139
assert_ne!(140
RigSignature::of(&fixture_body(false)),141
RigSignature::of(&fixture_body(true))142
);143
}144
145
#[test]146
fn corrupt_truncated_unsupported_and_excessive_containers_are_rejected() {147
let p = preset();148
let valid = p.encode().unwrap();149
for n in [0, 7, 19, valid.len() - 1] {150
assert!(Preset::decode(&valid[..n]).is_err());151
}152
let mut damaged = valid.clone();153
*damaged.last_mut().unwrap() ^= 1;154
assert!(Preset::decode(&damaged).is_err());155
let mut damaged = valid.clone();156
damaged[8..12].copy_from_slice(&2u32.to_le_bytes());157
assert!(Preset::decode(&damaged).is_err());158
let mut damaged = valid.clone();159
damaged[12..16].copy_from_slice(&u32::MAX.to_le_bytes());160
assert!(Preset::decode(&damaged).is_err());161
let mut damaged = valid.clone();162
damaged.push(0);163
let crc = equipment::checksum(&damaged[20..]);164
damaged[16..20].copy_from_slice(&crc.to_le_bytes());165
assert!(Preset::decode(&damaged).is_err());166
assert_eq!(equipment::checksum(b"123456789"), 0xcbf43926);167
}168
169
// Build a malicious but correctly checksummed container without encoder validation.170
fn untrusted(p: &Preset) -> Vec<u8> {171
let metadata = serde_json::to_vec(p).unwrap();172
let n = metadata.len();173
let mut payload = metadata;174
for a in &p.assets {175
payload.extend_from_slice(&(a.bytes.len() as u32).to_le_bytes());176
payload.extend(&a.bytes)177
}178
let mut out = MAGIC.to_vec();179
out.extend(1u32.to_le_bytes());180
out.extend((n as u32).to_le_bytes());181
out.extend(equipment::checksum(&payload).to_le_bytes());182
out.extend(payload);183
out184
}185
186
#[test]187
fn traversal_invalid_names_nonfinite_and_invalid_geometry_are_rejected() {188
for name in ["body", "Hello", "with space", "../escape", ""] {189
let mut p = preset();190
p.name = name.into();191
assert!(p.encode().is_err());192
assert!(Preset::decode(&untrusted(&p)).is_err());193
}194
let mut p = preset();195
p.assets[0].path = "../escape".into();196
assert!(Preset::decode(&untrusted(&p)).is_err());197
let mut p = preset();198
p.assets[0].path = "/absolute".into();199
assert!(p.encode().is_err());200
let mut p = preset();201
p.attachments[0].translation[0] = f32::NAN;202
assert!(p.encode().is_err());203
let mut p = preset();204
p.attachments[0].meshes[0].indices[0] = u32::MAX;205
assert!(Preset::decode(&untrusted(&p)).is_err());206
}207
208
#[test]209
fn dependency_escape_and_failed_import_leave_existing_preset_unchanged() {210
let root = scratch();211
fs::create_dir_all(&root).unwrap();212
let path = root.join("bad.obj");213
fs::write(&path, "mtllib ../outside.mtl\nv 0 0 0\n").unwrap();214
let mut p = preset();215
let saved = p.clone();216
assert!(equipment::import_obj(&mut p, &path).is_err());217
assert_eq!(saved, p);218
#[cfg(unix)]219
{220
std::os::unix::fs::symlink(fixture("sword.mtl"), root.join("outside.mtl")).unwrap();221
fs::write(&path, "mtllib outside.mtl\nv 0 0 0\n").unwrap();222
assert!(equipment::import_obj(&mut p, &path).is_err());223
}224
fs::remove_dir_all(root).unwrap();225
}226
227
#[test]228
fn atomic_snapshot_never_overwrites_previous_valid_data() {229
let root = scratch();230
fs::create_dir_all(&root).unwrap();231
let path = root.join("sword.ppe");232
let bytes = preset().encode().unwrap();233
equipment::write_snapshot(&path, &bytes).unwrap();234
equipment::write_snapshot(&path, &bytes).unwrap();235
assert!(equipment::write_snapshot(&path, b"different").is_err());236
assert_eq!(fs::read(&path).unwrap(), bytes);237
assert!(fs::read_dir(&root).unwrap().all(|entry| !entry238
.unwrap()239
.file_name()240
.to_string_lossy()241
.contains("part-")));242
fs::remove_dir_all(root).unwrap();243
}244
245
#[test]246
fn binary_project_reload_keeps_payload_after_source_disappears() {247
let root = scratch();248
let mut session = paperdoll_renderer::session::Session::new();249
session.install_new_project(&root).unwrap();250
let index = session.add_equipment();251
session.equipment[index].slug = "sword".into();252
session.equipment[index].preset = Some(preset());253
session.save().unwrap();254
let manifest = root.join("character.ppd");255
let reference = session.opened.as_ref().unwrap().document.equipment_presets[0].clone();256
assert!(reference.ends_with(".ppe"));257
let mut again = paperdoll_renderer::session::Session::new();258
again.install_loaded(paperdoll_renderer::project::open_project(&manifest).unwrap());259
let loaded = again.equipment[0].preset.as_ref().unwrap();260
assert_eq!(loaded.name, "sword");261
assert!(262
!equipment::layers(loaded, 0, &fixture_body(false), &PreviewControls::default())263
.unwrap()264
.is_empty()265
);266
let previous = fs::read(&manifest).unwrap();267
again.equipment[0].preset.as_mut().unwrap().attachments[0].translation[0] = 0.2;268
again.set_render(0, 128, 12);269
assert!(again.save().is_err());270
assert_eq!(fs::read(&manifest).unwrap(), previous);271
fs::remove_dir_all(root).unwrap();272
}273
274
#[test]275
fn translated_rotated_parent_uses_the_complete_hierarchy_once() {276
let mut body = fixture_body(false);277
body.nodes = vec![278
preview::SceneNode {279
name: "root".into(),280
parent: None,281
translation: [3., 0., 0.],282
rotation: [283
0.,284
0.,285
std::f32::consts::FRAC_1_SQRT_2,286
std::f32::consts::FRAC_1_SQRT_2,287
],288
scale: [1.; 3],289
},290
preview::SceneNode {291
name: "shoulder".into(),292
parent: Some(0),293
translation: [0., 2., 0.],294
rotation: [0., 0., 0., 1.],295
scale: [1.; 3],296
},297
preview::SceneNode {298
name: "hand".into(),299
parent: Some(1),300
translation: [0., 0., 1.],301
rotation: [0., 0., 0., 1.],302
scale: [1.; 3],303
},304
];305
body.joint_nodes = vec![2];306
body.joint_names = vec!["hand".into()];307
body.clips.clear();308
let mut preset = preset();309
preset.rigs = [None, None];310
preset.applies = [true, false];311
preset.attachments[0].bones = [None, None];312
preset.attachments[0].translation = [0.2, 0.3, 0.4];313
preset.attachments[0].rotation_deg = [0.; 3];314
preset.bind(0, &body, 0, 0).unwrap();315
let layer = equipment::layers(&preset, 0, &body, &PreviewControls::default()).unwrap();316
for (value, expected) in [layer[0].model[12], layer[0].model[13], layer[0].model[14]]317
.into_iter()318
.zip([0.7, 0.2, 1.4])319
{320
assert!((value - expected).abs() < 1e-5, "{value} != {expected}");321
}322
}323
324
#[test]325
fn unsupported_texture_dimensions_and_asset_counts_are_bounded() {326
let mut p = preset();327
let mut data = Vec::new();328
{329
let mut encoder = png::Encoder::new(&mut data, 2049, 1);330
encoder.set_color(png::ColorType::Grayscale);331
encoder.set_depth(png::BitDepth::Eight);332
let mut writer = encoder.write_header().unwrap();333
writer.write_image_data(&vec![0; 2049]).unwrap();334
}335
let i = p.attachments[0].shiny.unwrap();336
p.assets[i].bytes = data;337
assert!(p.encode().is_err());338
assert!(Preset::decode(&untrusted(&p)).is_err());339
let mut p = preset();340
while p.assets.len() < 33 {341
let n = p.assets.len();342
p.assets.push(equipment::EmbeddedAsset {343
path: format!("asset-{n}"),344
bytes: Vec::new(),345
});346
}347
assert!(Preset::decode(&untrusted(&p)).is_err());348
}@@ -0,0 +1 @@
1
CC0-1.0. These original generated equipment fixture meshes, materials and textures are dedicated to the public domain.
@@ -0,0 +1 @@1
CC0-1.0. These original generated equipment fixture meshes, materials and textures are dedicated to the public domain.The contents of this binary file are not shown.
The contents of this binary file are not shown.
@@ -0,0 +1,4 @@
1
newmtl checker
2
Kd 1 1 1
3
d 1
4
map_Kd checker.png
@@ -0,0 +1,4 @@1
newmtl checker2
Kd 1 1 13
d 14
map_Kd checker.png@@ -0,0 +1,15 @@
1
# CC0 rigid attachment fixture, metres
2
mtllib sword.mtl
3
o blade
4
v -0.055 0.00 -0.10
5
v 0.055 0.00 -0.10
6
v 0.055 0.58 -0.10
7
v -0.055 0.58 -0.10
8
vt 0 0
9
vt 1 0
10
vt 1 1
11
vt 0 1
12
vn 0 0 -1
13
usemtl checker
14
f 1/1/1 3/3/1 2/2/1
15
f 1/1/1 4/4/1 3/3/1
@@ -0,0 +1,15 @@1
# CC0 rigid attachment fixture, metres2
mtllib sword.mtl3
o blade4
v -0.055 0.00 -0.105
v 0.055 0.00 -0.106
v 0.055 0.58 -0.107
v -0.055 0.58 -0.108
vt 0 09
vt 1 010
vt 1 111
vt 0 112
vn 0 0 -113
usemtl checker14
f 1/1/1 3/3/1 2/2/115
f 1/1/1 4/4/1 3/3/1The contents of this binary file are not shown.