BlueGit

golden-age paperdoll-renderer

wip: preserve tested equipment attachment and binary preset checkpoint

timfa Grok Bot · pushed by timfa

Authored 2026-11-06 · 7 days ago

Hash
f4002be8efc3cdb6953aff8d80df461cd999bd32
Parent
60e63750
15 files changed +1876 −41
examples/equipment_fixture.rs added +64 −0
@@ -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.
2use paperdoll_renderer::{
3 editor::EditorApp,
4 equipment, preview,
5 session::{Session, Sex},
6};
7use std::path::PathBuf;
8fn 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}
src/dialogs.rs +12 −2
@@ -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(_) => dialog
131 DialogPurpose::EquipmentModel(_) => dialog
130 .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(_, _) => 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 .pick_file(),
142 .pick_file(),
133 }
143 }
134}
144}
src/editor.rs +311 −27
@@ -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
34impl EditorApp {
35impl 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) = 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 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 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 return;
254 return;
210 };
255 };
211 match adopt_file(&root, path, "assets/equipment") {
256 let mut preset = item
212 Ok(relative) => {
257 .preset
213 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
354impl eframe::App for EditorApp {
421impl 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 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 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 = self
663 ui.label("Naam");
893 .session
664 ui.text_edit_singleline(&mut item.slug);
894 .equipment
665 });
895 .get(index)
666 let model = item
896 .and_then(|item| item.preset.as_ref());
667 .model
897 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
934fn 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#[cfg(test)]
997#[cfg(test)]
714mod tests {
998mod tests {
715 use super::*;
999 use super::*;
src/equipment.rs added +773 −0
@@ -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 animated
3//! joint world transform, never a skin matrix or inverse bind a second time.
4
5use std::collections::BTreeSet;
6use std::io::{Cursor, Read, Write};
7use std::path::Path;
8
9use crate::preview::{
10 animated_joint_world, identity, joint_paths, mul, Mat4, PreviewCharacter, PreviewControls,
11};
12use crate::project::portable_relative;
13use serde::{Deserialize, Serialize};
14
15pub const MAGIC: &[u8; 8] = b"PPEQ\r\n\x1a\n";
16pub const VERSION: u32 = 1;
17pub const MAX_FILE: usize = 32 * 1024 * 1024;
18const MAX_META: usize = 4 * 1024 * 1024;
19const MAX_ASSET: usize = 8 * 1024 * 1024;
20const MAX_ASSETS: usize = 32;
21const MAX_VERTICES: usize = 80_000;
22
23#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
24#[serde(deny_unknown_fields)]
25pub 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)]
33pub 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)]
44pub struct RigSignature {
45 pub paths: Vec<String>,
46 pub rest_world: Vec<Mat4>,
47}
48impl 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)]
61pub 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)]
73pub 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}
81impl 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
297pub 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
306pub 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.
319pub 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)]
358pub struct Texture {
359 pub content_hash: u64,
360 pub width: u32,
361 pub height: u32,
362 pub rgba: Vec<u8>,
363}
364fn 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
406pub 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)]
428pub 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)]
437pub struct TextureCache {
438 entries: std::collections::VecDeque<(Vec<u8>, std::sync::Arc<Texture>)>,
439 bytes: usize,
440}
441impl 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
462pub 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
471pub 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.
517pub 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
700pub 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
724fn 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}
744fn 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}
src/lib.rs +1 −0
@@ -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
6pub mod dialogs;
6pub mod dialogs;
7pub mod editor;
7pub mod editor;
8pub mod equipment;
8pub mod file_assoc;
9pub mod file_assoc;
9pub mod foundation;
10pub mod foundation;
10pub mod icon;
11pub mod icon;
src/preview.rs +37 −0
@@ -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.
283pub 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
304pub 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
282pub fn joint_world_translation(
319pub fn joint_world_translation(
283 character: &PreviewCharacter,
320 character: &PreviewCharacter,
284 clip_name: &str,
321 clip_name: &str,
src/preview_gl.rs +247 −9
@@ -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 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 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
255impl 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
201impl GpuCache {
319impl 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;
367layout(location = 1) in vec3 a_normal;
586layout(location = 1) in vec3 a_normal;
368layout(location = 2) in vec4 a_joints;
587layout(location = 2) in vec4 a_joints;
369layout(location = 3) in vec4 a_weights;
588layout(location = 3) in vec4 a_weights;
589layout(location = 4) in vec2 a_uv;
590uniform mat4 u_model;
591out vec2 v_uv;
592out vec3 v_world;
370uniform mat4 u_view_proj;
593uniform mat4 u_view_proj;
371uniform mat4 u_bones[128];
594uniform mat4 u_bones[128];
372out vec3 v_normal;
595out 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() {
388const FRAGMENT: &str = r#"#version 330
612const FRAGMENT: &str = r#"#version 330
389in vec3 v_normal;
613in vec3 v_normal;
390uniform vec4 u_color;
614uniform vec4 u_color;
615uniform sampler2D u_albedo;
616uniform sampler2D u_shiny;
617uniform bool u_albedo_enabled;
618uniform bool u_shiny_enabled;
619uniform float u_specular;
620uniform vec3 u_eye;
621in vec2 v_uv;
622in vec3 v_world;
391out vec4 frag;
623out vec4 frag;
392void main() {
624void 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"#;
src/session.rs +63 −3
@@ -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 @@
7use std::fs;
7use std::fs;
8use std::path::{Path, PathBuf};
8use std::path::{Path, PathBuf};
9
9
10use crate::equipment::Preset;
10use serde::{Deserialize, Serialize};
11use serde::{Deserialize, Serialize};
11
12
12use crate::project::{
13use 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)]
69pub struct EquipmentItem {
70pub 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)]
76pub struct Session {
79pub 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: 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 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());
tests/equipment_presets.rs added +348 −0
@@ -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 @@
1use paperdoll_renderer::equipment::{self, Preset, RigSignature, MAGIC};
2use paperdoll_renderer::preview::{self, PreviewControls};
3use std::fs;
4use std::path::PathBuf;
5
6fn fixture(name: &str) -> PathBuf {
7 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
8 .join("tests/fixtures/equipment")
9 .join(name)
10}
11fn 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}
19fn 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}
37fn 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]
48fn 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]
80fn 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]
112fn 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]
122fn 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]
146fn 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.
170fn 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]
187fn 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]
209fn 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]
228fn 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]
246fn 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]
275fn 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]
325fn 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}
tests/fixtures/equipment/LICENSE added +1 −0
@@ -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 @@
1CC0-1.0. These original generated equipment fixture meshes, materials and textures are dedicated to the public domain.
tests/fixtures/equipment/checker.png added Binary file +0 −0

The contents of this binary file are not shown.

tests/fixtures/equipment/shiny.png added Binary file +0 −0

The contents of this binary file are not shown.

tests/fixtures/equipment/sword.mtl added +4 −0
@@ -0,0 +1,4 @@
1 newmtl checker
2 Kd 1 1 1
3 d 1
4 map_Kd checker.png
@@ -0,0 +1,4 @@
1newmtl checker
2Kd 1 1 1
3d 1
4map_Kd checker.png
tests/fixtures/equipment/sword.obj added +15 −0
@@ -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, metres
2mtllib sword.mtl
3o blade
4v -0.055 0.00 -0.10
5v 0.055 0.00 -0.10
6v 0.055 0.58 -0.10
7v -0.055 0.58 -0.10
8vt 0 0
9vt 1 0
10vt 1 1
11vt 0 1
12vn 0 0 -1
13usemtl checker
14f 1/1/1 3/3/1 2/2/1
15f 1/1/1 4/4/1 3/3/1
tests/fixtures/equipment/sword.ppe added Binary file +0 −0

The contents of this binary file are not shown.