merge: live skinned 3D preview
Non-fast-forward merge of feature/live-3d-preview into main. Co-authored-by: tim <tim@timfalken.com>
@@ -1808,6 +1808,8 @@ name = "paperdoll-renderer"
1808
1808
version = "0.1.0"
1809
1809
dependencies = [
1810
1810
"eframe",
1811
"egui_glow",
1812
"glow 0.14.2",
1811
1813
"gltf",
1812
1814
"libloading",
1813
1815
"serde",
@@ -1808,6 +1808,8 @@ name = "paperdoll-renderer"1808
version = "0.1.0"1808
version = "0.1.0"1809
dependencies = [1809
dependencies = [1810
"eframe",1810
"eframe",1811
"egui_glow",1812
"glow 0.14.2",1811
"gltf",1813
"gltf",1812
"libloading",1814
"libloading",1813
"serde",1815
"serde",@@ -8,6 +8,8 @@ description = "Foundation shell for the Paperdoll Renderer. Import and rendering
8
8
9
9
[dependencies]
10
10
eframe = { version = "0.29.1", default-features = false, features = ["default_fonts", "glow", "x11"] }
11
egui_glow = "0.29.1"
12
glow = "0.14.2"
11
13
gltf = { version = "1.4.1", default-features = false, features = ["import", "names", "utils"] }
12
14
libloading = "0.8"
13
15
serde = { version = "1", features = ["derive"] }
@@ -8,6 +8,8 @@ description = "Foundation shell for the Paperdoll Renderer. Import and rendering8
8
9
[dependencies]9
[dependencies]10
eframe = { version = "0.29.1", default-features = false, features = ["default_fonts", "glow", "x11"] }10
eframe = { version = "0.29.1", default-features = false, features = ["default_fonts", "glow", "x11"] }11
egui_glow = "0.29.1"12
glow = "0.14.2"11
gltf = { version = "1.4.1", default-features = false, features = ["import", "names", "utils"] }13
gltf = { version = "1.4.1", default-features = false, features = ["import", "names", "utils"] }12
libloading = "0.8"14
libloading = "0.8"13
serde = { version = "1", features = ["derive"] }15
serde = { version = "1", features = ["derive"] }@@ -1,51 +1,40 @@
1
1
# Paperdoll Renderer
2
2
3
Native Rust-schil voor Tim Falkens Paperdoll Renderer. Het venster bewaart een projectmap met één `character.ppd` en leest glTF/GLB en FBX (mesh, materialen, textures, skeleton en animatieclips) plus OBJ+MTL als statische equipment. FBX gaat via ufbx, meegecompileerd in de binary, niet via een losse libassimp. Er is geen 3D-beeld en geen paperdoll-render. Het ontwerp, inclusief de letterlijke Nederlandse specificatie en de formaatmatrix voor OBJ+MTL+textures, FBX en glTF/GLB, staat in [`design.md`](design.md).
3
Desktopprogramma voor een paperdoll-project. Je bewaart een projectmap, leest modellen uit, en bekijkt een glTF-lichaam als bewegend 3D-model.
4
4
5
## Eisen
6
7
- Rust 1.96.0 (vastgezet in `rust-toolchain.toml`; hetzelfde image als de publieke posprint-pipeline, `rust:1.96-bookworm`)
8
- Een X11-display voor het venster
9
- Voor compileren: `pkg-config`, `libxcb1-dev`, `libx11-dev`, `libxkbcommon-dev`, `libxkbcommon-x11-dev`, `libgl1-mesa-dev`
10
- Voor het venster zelf: `libxkbcommon-x11-0`, `libxcb1`, `libx11-6`, `libgl1`. Die laadt eframe pas bij het openen. Ontbreken ze, dan stopt het programma met een melding in plaats van een panic.
11
12
## Lokaal
5
Rust 1.96.0 staat in `rust-toolchain.toml`. Voor het venster: `pkg-config`, `libxcb1-dev`, `libx11-dev`, `libxkbcommon-dev`, `libxkbcommon-x11-dev` en `libgl1-mesa-dev`.
13
6
14
7
```bash
15
cargo test --locked
16
cargo run -- --status
17
8
cargo run
9
cargo run -- --preview-fixture
10
cargo run -- --status
11
cargo test --locked
18
12
```
19
13
20
`--status` print de capability-lijst en opent geen venster. Een modelpad op de opdrachtregel wordt geweigerd. In het venster vullen Nieuw, Open en Opslaan een projectmap. Kies daarna bestanden in die map; de opgeslagen paden blijven relatief.
21
22
## Testlichamen
23
24
`tests/fixtures/bodies/` bevat twee procedurele GLB-bestanden, man en vrouw, elk met een skeleton en de clips `walk` en `attack`. Ze zijn CC0, duidelijk als testasset gelabeld, en geen definitieve karakters. Opnieuw maken:
25
26
```bash
27
python3 scripts/generate_test_bodies.py
28
```
29
30
`cargo test --locked` controleert of de bestanden leesbaar zijn, het skeleton en beide clips bevatten, en nog gelijk zijn aan de generator. Het venster laadt ze niet.
14
`--preview-fixture` opent de preview met de testlichamen uit `tests/fixtures/bodies/`.
31
15
32
## Wat deze build niet doet
16
## Project
33
17
34
OBJ+MTL+textures, FBX, glTF/GLB, skeletanimatie, de 3D-attachmenteditor, paperdoll-render en de 2D-preview zijn niet gebouwd. Bestanden onder `tests/fixtures/`, als die er later bijkomen, worden door dit programma niet geladen.
18
**Nieuw** maakt een map met `character.ppd`, `assets/body/`, `assets/equipment/`, `presets/`, `cache/` en `renders/`. **Open** leest een `.ppd`. **Opslaan** schrijft het bestand via een tijdelijk buurbestand.
35
19
36
## BlueGit
20
In het bestand staan de projectnaam, de paden van het mannelijke en vrouwelijke model, de paden van equipment-presets, de uitvoermap, en breedte, hoogte en fps. Paden zijn relatief aan de projectmap. Een leeg geslachtsslot blijft leeg. **Lees man** en **Lees vrouw** tonen meshes, materialen, textures, joints en clips van glTF/GLB, FBX of OBJ+MTL.
37
21
38
De pipeline staat in `blue-ci-cd.yml`. Tests draaien op merge requests en op pushes naar `master`, `main` en `CI_DEFAULT_BRANCH`. Op een tag-push, na geslaagde tests: AppImage, Ubuntu-pakket, Arch-pakket, Windows-zip, daarna een controle, daarna het release-script.
22
## 3D-preview
39
23
40
Dat script publiceert niet. De publieke BlueGit-routes kunnen artifacts niet aan een bestaande tag of release hangen. `POST /api/v1/repos/{owner}/{repo}/releases` maakt de tag zelf (HTTP 409 als die er al is). `replace: true` is op een tag-pipeline HTTP 403. `scripts/ci/release.sh` doet geen van beide en stopt met exit 3 (`release-blocked: no-attach-endpoint`). De volgorde blijft een tag-push. De ontbrekende serverroute staat in `design.md`. Er is geen tag en geen release gestart.
24
Het middelste vlak tekent een geskind glTF/GLB-lichaam.
41
25
42
`timfa/legacy-cycles` heeft alleen publieke releases; het pipelinebestand was niet te lezen. De gebruikte YAML komt uit de BlueGit-CI-specificatie en uit de publieke pipelines van `timfa/posprint`, `timfa/minecart-trains` en `timfa/click-tween`.
26
- **Laad testlichamen** zet de man en de vrouw uit de fixture in de view.
27
- **3D van project** gebruikt de `.glb`- of `.gltf`-paden van man en vrouw.
28
- **Man** en **Vrouw** wisselen het lichaam. **walk** en **attack** wisselen de clip. **Pauze** / **Speel** zet de animatie stil of laat haar lopen.
29
- Sleep in het vlak om te draaien. Scroll om te zoomen.
30
- **Yaw −** en **Yaw +** stappen langs twaalf hoeken van 30°. **pitch -30**, **pitch 0** en **pitch 30** zetten de drie hoogtes. Yaw 0 kijkt op de voorkant; de testlichamen wijzen naar −Z.
43
31
44
Pakketten lokaal, zonder publiceren:
32
## Tests
45
33
46
34
```bash
47
bash scripts/package/deb.sh
48
bash scripts/package/appimage.sh
35
cargo fmt --check
36
cargo clippy --workspace --all-targets --locked -- -D warnings
37
cargo test --workspace --locked
49
38
```
50
39
51
Op deze machine zijn het `.deb`-pakket en de AppImage gebouwd. `paperdoll-renderer --status` via de AppImage geeft dezelfde status als de gewone binary. Het Arch-pakket heeft `makepkg` nodig en de Windows-zip de MinGW-crosscompiler; die twee builds zijn hier niet gedraaid.
40
De testlichamen komen uit `scripts/generate_test_bodies.py`. Het FBX-bestand van de man komt uit `scripts/convert_test_body_to_fbx.py` met Blender.
@@ -1,51 +1,40 @@1
# Paperdoll Renderer1
# Paperdoll Renderer2
2
3
Native Rust-schil voor Tim Falkens Paperdoll Renderer. Het venster bewaart een projectmap met één `character.ppd` en leest glTF/GLB en FBX (mesh, materialen, textures, skeleton en animatieclips) plus OBJ+MTL als statische equipment. FBX gaat via ufbx, meegecompileerd in de binary, niet via een losse libassimp. Er is geen 3D-beeld en geen paperdoll-render. Het ontwerp, inclusief de letterlijke Nederlandse specificatie en de formaatmatrix voor OBJ+MTL+textures, FBX en glTF/GLB, staat in [`design.md`](design.md).3
Desktopprogramma voor een paperdoll-project. Je bewaart een projectmap, leest modellen uit, en bekijkt een glTF-lichaam als bewegend 3D-model.4
4
5
## Eisen5
Rust 1.96.0 staat in `rust-toolchain.toml`. Voor het venster: `pkg-config`, `libxcb1-dev`, `libx11-dev`, `libxkbcommon-dev`, `libxkbcommon-x11-dev` en `libgl1-mesa-dev`.6
7
- Rust 1.96.0 (vastgezet in `rust-toolchain.toml`; hetzelfde image als de publieke posprint-pipeline, `rust:1.96-bookworm`)8
- Een X11-display voor het venster9
- Voor compileren: `pkg-config`, `libxcb1-dev`, `libx11-dev`, `libxkbcommon-dev`, `libxkbcommon-x11-dev`, `libgl1-mesa-dev`10
- Voor het venster zelf: `libxkbcommon-x11-0`, `libxcb1`, `libx11-6`, `libgl1`. Die laadt eframe pas bij het openen. Ontbreken ze, dan stopt het programma met een melding in plaats van een panic.11
12
## Lokaal13
6
14
```bash7
```bash15
cargo test --locked16
cargo run -- --status17
cargo run8
cargo run9
cargo run -- --preview-fixture10
cargo run -- --status11
cargo test --locked18
```12
```19
13
20
`--status` print de capability-lijst en opent geen venster. Een modelpad op de opdrachtregel wordt geweigerd. In het venster vullen Nieuw, Open en Opslaan een projectmap. Kies daarna bestanden in die map; de opgeslagen paden blijven relatief.14
`--preview-fixture` opent de preview met de testlichamen uit `tests/fixtures/bodies/`.21
22
## Testlichamen23
24
`tests/fixtures/bodies/` bevat twee procedurele GLB-bestanden, man en vrouw, elk met een skeleton en de clips `walk` en `attack`. Ze zijn CC0, duidelijk als testasset gelabeld, en geen definitieve karakters. Opnieuw maken:25
26
```bash27
python3 scripts/generate_test_bodies.py28
```29
30
`cargo test --locked` controleert of de bestanden leesbaar zijn, het skeleton en beide clips bevatten, en nog gelijk zijn aan de generator. Het venster laadt ze niet.31
15
32
## Wat deze build niet doet16
## Project33
17
34
OBJ+MTL+textures, FBX, glTF/GLB, skeletanimatie, de 3D-attachmenteditor, paperdoll-render en de 2D-preview zijn niet gebouwd. Bestanden onder `tests/fixtures/`, als die er later bijkomen, worden door dit programma niet geladen.18
**Nieuw** maakt een map met `character.ppd`, `assets/body/`, `assets/equipment/`, `presets/`, `cache/` en `renders/`. **Open** leest een `.ppd`. **Opslaan** schrijft het bestand via een tijdelijk buurbestand.35
19
36
## BlueGit20
In het bestand staan de projectnaam, de paden van het mannelijke en vrouwelijke model, de paden van equipment-presets, de uitvoermap, en breedte, hoogte en fps. Paden zijn relatief aan de projectmap. Een leeg geslachtsslot blijft leeg. **Lees man** en **Lees vrouw** tonen meshes, materialen, textures, joints en clips van glTF/GLB, FBX of OBJ+MTL.37
21
38
De pipeline staat in `blue-ci-cd.yml`. Tests draaien op merge requests en op pushes naar `master`, `main` en `CI_DEFAULT_BRANCH`. Op een tag-push, na geslaagde tests: AppImage, Ubuntu-pakket, Arch-pakket, Windows-zip, daarna een controle, daarna het release-script.22
## 3D-preview39
23
40
Dat script publiceert niet. De publieke BlueGit-routes kunnen artifacts niet aan een bestaande tag of release hangen. `POST /api/v1/repos/{owner}/{repo}/releases` maakt de tag zelf (HTTP 409 als die er al is). `replace: true` is op een tag-pipeline HTTP 403. `scripts/ci/release.sh` doet geen van beide en stopt met exit 3 (`release-blocked: no-attach-endpoint`). De volgorde blijft een tag-push. De ontbrekende serverroute staat in `design.md`. Er is geen tag en geen release gestart.24
Het middelste vlak tekent een geskind glTF/GLB-lichaam.41
25
42
`timfa/legacy-cycles` heeft alleen publieke releases; het pipelinebestand was niet te lezen. De gebruikte YAML komt uit de BlueGit-CI-specificatie en uit de publieke pipelines van `timfa/posprint`, `timfa/minecart-trains` en `timfa/click-tween`.26
- **Laad testlichamen** zet de man en de vrouw uit de fixture in de view.27
- **3D van project** gebruikt de `.glb`- of `.gltf`-paden van man en vrouw.28
- **Man** en **Vrouw** wisselen het lichaam. **walk** en **attack** wisselen de clip. **Pauze** / **Speel** zet de animatie stil of laat haar lopen.29
- Sleep in het vlak om te draaien. Scroll om te zoomen.30
- **Yaw −** en **Yaw +** stappen langs twaalf hoeken van 30°. **pitch -30**, **pitch 0** en **pitch 30** zetten de drie hoogtes. Yaw 0 kijkt op de voorkant; de testlichamen wijzen naar −Z.43
31
44
Pakketten lokaal, zonder publiceren:32
## Tests45
33
46
```bash34
```bash47
bash scripts/package/deb.sh35
cargo fmt --check48
bash scripts/package/appimage.sh36
cargo clippy --workspace --all-targets --locked -- -D warnings37
cargo test --workspace --locked49
```38
```50
39
51
Op deze machine zijn het `.deb`-pakket en de AppImage gebouwd. `paperdoll-renderer --status` via de AppImage geeft dezelfde status als de gewone binary. Het Arch-pakket heeft `makepkg` nodig en de Windows-zip de MinGW-crosscompiler; die twee builds zijn hier niet gedraaid.40
De testlichamen komen uit `scripts/generate_test_bodies.py`. Het FBX-bestand van de man komt uit `scripts/convert_test_body_to_fbx.py` met Blender.@@ -141,7 +141,7 @@ Equipment pass hides the body from **all color, depth, normal and specular write
141
141
142
142
UI selects sex, target skeleton/bone, imported equipment mesh, XYZ translation and XYZ Euler rotation in degrees. **Proposal:** persisted rotation is a quaternion with UI Euler values/order documented; test round-trip and avoid cumulative rotations each frame. Attachment transform is `body_world * animated_bone_world * attachment_local`; clarify inverse-bind usage in importer and test with nonidentity parents. Scale is initially asset import normalization, not an unrequested body-part editor feature.
143
143
144
Live 3D preview displays body and selected equipment, allows free orbit/zoom and optional animation selection for placement checks. It is separate from the discrete 2D preview. Display a bone tree and highlight unresolved attachments. A preset supports multiple meshes attached to multiple bones, sex flag, stable bone paths, original/import transforms and embedded geometry/material/texture payloads.
144
Live 3D preview displays body and selected equipment, allows free orbit/zoom and optional animation selection for placement checks. It is separate from the discrete 2D preview. The current viewport draws the body glTF only; equipment attachment and the orthographic export camera are later work, recorded here rather than in the README. Display a bone tree and highlight unresolved attachments. A preset supports multiple meshes attached to multiple bones, sex flag, stable bone paths, original/import transforms and embedded geometry/material/texture payloads.
145
145
146
146
Binary preset **proposal:** magic header, schema version, bounded metadata length and asset count, checksummed entries and compressed payload chunks. Choose a documented serialization/container implementation in the preset feature; do not dump Rust memory layouts. Include rig fingerprint and reject mismatched skeletons with an actionable remapping prompt. Preserve original or normalized embedded asset representation plus license/provenance, protect against decompression bombs and traversal, save atomically. Filename extension for presets remains a proposal; .ppd is reserved for project manifests.
147
147
@@ -157,6 +157,10 @@ Depth compositor selects the nearest valid surface per pixel from body and chose
157
157
158
158
Each feature starts on its own `feature/...` branch from current master. Implement its tests and acceptance criteria, run local checks, commit, then `git merge --no-ff` into master and push. No initial-phase MRs. Keep branch/feature and merge SHAs in the coordinator status file, report completion before the next feature. Setup/design may use `feature/project-design`; establish a baseline master commit first if required to obtain a genuine two-parent merge. Never fabricate a merge on an unborn branch.
159
159
160
After every merge, `README.md` describes only the current behavior: what the program can do and how to use it. It does not list absent features, a roadmap, or negative claims. A screenshot belongs in the README only when it shows how to use the program. This convention applies to every later feature. Design limits stay in this file.
161
162
Live 3D preview, as built: the center of the eframe window draws the skinned glTF/GLB mesh with a perspective orbit camera. Drag orbits, scroll zooms, and buttons snap to pitches -30/0/+30 and the twelve yaw steps of 30 degrees. Yaw 0 places the camera on -Z because the test bodies face -Z. Walk and attack are sampled on the CPU and uploaded as joint matrices. The preview light is a placement shade. It is not the orthographic paperdoll export, and it does not draw OBJ or FBX.
163
160
164
CI filename requested: `blue-ci-cd.yml`. **Unverified infrastructure detail:** do not invent BlueGit YAML syntax. Inspect documented BlueGit runner schema and narrow existing `timfa/posprint` and `legacy-cycles` pipeline/release examples before writing configuration. Respect runner/image selection, artifact retention and release API semantics. No GitLab mirror is requested for this new project.
161
165
162
166
Tests run on MR events (future-proof despite no current MRs), master pushes AND tags. Quality gates: `cargo fmt --check`, `cargo check --workspace --all-targets --locked`, `cargo clippy --workspace --all-targets --locked -- -D warnings`, `cargo test --workspace --locked`; feature combinations should be covered explicitly when introduced. Pin toolchain/components and package tooling. Core tests require no GPU; separate GPU integration tests may use a supported software adapter, with explicit results rather than silent skip-as-pass.
@@ -141,7 +141,7 @@ Equipment pass hides the body from **all color, depth, normal and specular write141
141
142
UI selects sex, target skeleton/bone, imported equipment mesh, XYZ translation and XYZ Euler rotation in degrees. **Proposal:** persisted rotation is a quaternion with UI Euler values/order documented; test round-trip and avoid cumulative rotations each frame. Attachment transform is `body_world * animated_bone_world * attachment_local`; clarify inverse-bind usage in importer and test with nonidentity parents. Scale is initially asset import normalization, not an unrequested body-part editor feature.142
UI selects sex, target skeleton/bone, imported equipment mesh, XYZ translation and XYZ Euler rotation in degrees. **Proposal:** persisted rotation is a quaternion with UI Euler values/order documented; test round-trip and avoid cumulative rotations each frame. Attachment transform is `body_world * animated_bone_world * attachment_local`; clarify inverse-bind usage in importer and test with nonidentity parents. Scale is initially asset import normalization, not an unrequested body-part editor feature.143
143
144
Live 3D preview displays body and selected equipment, allows free orbit/zoom and optional animation selection for placement checks. It is separate from the discrete 2D preview. Display a bone tree and highlight unresolved attachments. A preset supports multiple meshes attached to multiple bones, sex flag, stable bone paths, original/import transforms and embedded geometry/material/texture payloads.144
Live 3D preview displays body and selected equipment, allows free orbit/zoom and optional animation selection for placement checks. It is separate from the discrete 2D preview. The current viewport draws the body glTF only; equipment attachment and the orthographic export camera are later work, recorded here rather than in the README. Display a bone tree and highlight unresolved attachments. A preset supports multiple meshes attached to multiple bones, sex flag, stable bone paths, original/import transforms and embedded geometry/material/texture payloads.145
145
146
Binary preset **proposal:** magic header, schema version, bounded metadata length and asset count, checksummed entries and compressed payload chunks. Choose a documented serialization/container implementation in the preset feature; do not dump Rust memory layouts. Include rig fingerprint and reject mismatched skeletons with an actionable remapping prompt. Preserve original or normalized embedded asset representation plus license/provenance, protect against decompression bombs and traversal, save atomically. Filename extension for presets remains a proposal; .ppd is reserved for project manifests.146
Binary preset **proposal:** magic header, schema version, bounded metadata length and asset count, checksummed entries and compressed payload chunks. Choose a documented serialization/container implementation in the preset feature; do not dump Rust memory layouts. Include rig fingerprint and reject mismatched skeletons with an actionable remapping prompt. Preserve original or normalized embedded asset representation plus license/provenance, protect against decompression bombs and traversal, save atomically. Filename extension for presets remains a proposal; .ppd is reserved for project manifests.147
147
@@ -157,6 +157,10 @@ Depth compositor selects the nearest valid surface per pixel from body and chose157
157
158
Each feature starts on its own `feature/...` branch from current master. Implement its tests and acceptance criteria, run local checks, commit, then `git merge --no-ff` into master and push. No initial-phase MRs. Keep branch/feature and merge SHAs in the coordinator status file, report completion before the next feature. Setup/design may use `feature/project-design`; establish a baseline master commit first if required to obtain a genuine two-parent merge. Never fabricate a merge on an unborn branch.158
Each feature starts on its own `feature/...` branch from current master. Implement its tests and acceptance criteria, run local checks, commit, then `git merge --no-ff` into master and push. No initial-phase MRs. Keep branch/feature and merge SHAs in the coordinator status file, report completion before the next feature. Setup/design may use `feature/project-design`; establish a baseline master commit first if required to obtain a genuine two-parent merge. Never fabricate a merge on an unborn branch.159
159
160
After every merge, `README.md` describes only the current behavior: what the program can do and how to use it. It does not list absent features, a roadmap, or negative claims. A screenshot belongs in the README only when it shows how to use the program. This convention applies to every later feature. Design limits stay in this file.161
162
Live 3D preview, as built: the center of the eframe window draws the skinned glTF/GLB mesh with a perspective orbit camera. Drag orbits, scroll zooms, and buttons snap to pitches -30/0/+30 and the twelve yaw steps of 30 degrees. Yaw 0 places the camera on -Z because the test bodies face -Z. Walk and attack are sampled on the CPU and uploaded as joint matrices. The preview light is a placement shade. It is not the orthographic paperdoll export, and it does not draw OBJ or FBX.163
160
CI filename requested: `blue-ci-cd.yml`. **Unverified infrastructure detail:** do not invent BlueGit YAML syntax. Inspect documented BlueGit runner schema and narrow existing `timfa/posprint` and `legacy-cycles` pipeline/release examples before writing configuration. Respect runner/image selection, artifact retention and release API semantics. No GitLab mirror is requested for this new project.164
CI filename requested: `blue-ci-cd.yml`. **Unverified infrastructure detail:** do not invent BlueGit YAML syntax. Inspect documented BlueGit runner schema and narrow existing `timfa/posprint` and `legacy-cycles` pipeline/release examples before writing configuration. Respect runner/image selection, artifact retention and release API semantics. No GitLab mirror is requested for this new project.161
165
162
Tests run on MR events (future-proof despite no current MRs), master pushes AND tags. Quality gates: `cargo fmt --check`, `cargo check --workspace --all-targets --locked`, `cargo clippy --workspace --all-targets --locked -- -D warnings`, `cargo test --workspace --locked`; feature combinations should be covered explicitly when introduced. Pin toolchain/components and package tooling. Core tests require no GPU; separate GPU integration tests may use a supported software adapter, with explicit results rather than silent skip-as-pass.166
Tests run on MR events (future-proof despite no current MRs), master pushes AND tags. Quality gates: `cargo fmt --check`, `cargo check --workspace --all-targets --locked`, `cargo clippy --workspace --all-targets --locked -- -D warnings`, `cargo test --workspace --locked`; feature combinations should be covered explicitly when introduced. Pin toolchain/components and package tooling. Core tests require no GPU; separate GPU integration tests may use a supported software adapter, with explicit results rather than silent skip-as-pass.@@ -5,6 +5,8 @@ use std::path::PathBuf;
5
5
use eframe::egui;
6
6
7
7
use crate::import::{import_project_model, ImportedModel, ModelFormat};
8
use crate::preview::{load_gltf_file, PreviewCharacter, PreviewControls, PreviewSex};
9
use crate::preview_gl::Viewport;
8
10
use crate::project::{
9
11
assemble, create_project, list_dir, open_project, save_project, OpenedProject, ProjectDraft,
10
12
ProjectError, FORMAT_VERSION, MANIFEST_NAME,
@@ -26,6 +28,10 @@ pub struct EditorApp {
26
28
browser: String,
27
29
male_import: Option<Result<ImportedModel, String>>,
28
30
female_import: Option<Result<ImportedModel, String>>,
31
preview_controls: PreviewControls,
32
preview_male: Option<PreviewCharacter>,
33
preview_female: Option<PreviewCharacter>,
34
viewport: Viewport,
29
35
notice: String,
30
36
notice_ok: bool,
31
37
}
@@ -48,6 +54,10 @@ impl EditorApp {
48
54
browser: String::new(),
49
55
male_import: None,
50
56
female_import: None,
57
preview_controls: PreviewControls::default(),
58
preview_male: None,
59
preview_female: None,
60
viewport: Viewport::new(),
51
61
notice: "Maak een projectmap of open een bestaand character.ppd-bestand.".to_string(),
52
62
notice_ok: true,
53
63
}
@@ -74,6 +84,7 @@ impl EditorApp {
74
84
if !female.trim().is_empty() {
75
85
self.inspect(false);
76
86
}
87
self.refresh_preview_from_project();
77
88
if male.trim().is_empty() && female.trim().is_empty() {
78
89
if opened.disk_issues.is_empty() {
79
90
self.ok(notice);
@@ -83,6 +94,37 @@ impl EditorApp {
83
94
}
84
95
}
85
96
97
pub fn load_fixture_bodies(&mut self) {
98
let root =
99
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/bodies");
100
match load_gltf_file(&root.join("test-body-male.glb")) {
101
Ok(character) => self.preview_male = Some(character),
102
Err(error) => self.fail_text(format!("testman: {error}")),
103
}
104
match load_gltf_file(&root.join("test-body-female.glb")) {
105
Ok(character) => self.preview_female = Some(character),
106
Err(error) => self.fail_text(format!("testvrouw: {error}")),
107
}
108
if self.preview_male.is_some() {
109
self.preview_controls.sex = PreviewSex::Male;
110
self.preview_controls.select_clip("walk");
111
self.ok("Testlichamen staan in de 3D-preview.");
112
}
113
}
114
115
fn refresh_preview_from_project(&mut self) {
116
let Some(root) = self.opened.as_ref().and_then(|opened| {
117
opened
118
.manifest_path
119
.parent()
120
.map(std::path::Path::to_path_buf)
121
}) else {
122
return;
123
};
124
self.preview_male = load_preview_path(&root, &self.male);
125
self.preview_female = load_preview_path(&root, &self.female);
126
}
127
86
128
fn ok(&mut self, message: &str) {
87
129
self.notice = message.to_string();
88
130
self.notice_ok = true;
@@ -292,16 +334,40 @@ impl eframe::App for EditorApp {
292
334
};
293
335
ui.colored_label(color, &self.notice);
294
336
});
337
egui::SidePanel::left("form")
338
.resizable(true)
339
.default_width(380.0)
340
.show(ctx, |ui| {
341
egui::ScrollArea::vertical().show(ui, |ui| self.show_form(ui));
342
});
295
343
egui::SidePanel::right("browser")
296
.min_width(240.0)
344
.min_width(220.0)
297
345
.show(ctx, |ui| {
298
346
self.show_browser(ui);
299
347
});
300
348
egui::CentralPanel::default().show(ctx, |ui| {
301
egui::ScrollArea::vertical().show(ui, |ui| {
302
self.show_form(ui);
303
});
349
self.show_preview(ui);
350
});
351
}
352
}
353
354
impl EditorApp {
355
fn show_preview(&mut self, ui: &mut egui::Ui) {
356
ui.horizontal_wrapped(|ui| {
357
if ui.button("Laad testlichamen").clicked() {
358
self.load_fixture_bodies();
359
}
360
if ui.button("3D van project").clicked() {
361
self.refresh_preview_from_project();
362
self.ok("De 3D-preview gebruikt de .glb- of .gltf-paden van man en vrouw.");
363
}
304
364
});
365
let character = match self.preview_controls.sex {
366
PreviewSex::Male => self.preview_male.as_ref(),
367
PreviewSex::Female => self.preview_female.as_ref(),
368
};
369
self.viewport
370
.show(ui, &mut self.preview_controls, character);
305
371
}
306
372
}
307
373
@@ -309,7 +375,7 @@ impl EditorApp {
309
375
fn show_form(&mut self, ui: &mut egui::Ui) {
310
376
ui.heading("Paperdoll Renderer");
311
377
ui.label(
312
"Projectmap met één .ppd-bestand. Lees man of Lees vrouw toont glTF, FBX of OBJ: mesh, skeleton en clips. Er is geen 3D-beeld.",
378
"Links het projectbestand. In het midden draait de live 3D-preview van een glTF/GLB. Lees man of Lees vrouw toont het importoverzicht.",
313
379
);
314
380
ui.add_space(8.0);
315
381
ui.horizontal(|ui| {
@@ -544,6 +610,15 @@ fn show_import(ui: &mut egui::Ui, title: &str, report: Option<&Result<ImportedMo
544
610
});
545
611
}
546
612
613
fn load_preview_path(root: &std::path::Path, relative: &str) -> Option<PreviewCharacter> {
614
let relative = relative.trim();
615
let lower = relative.to_ascii_lowercase();
616
if !(lower.ends_with(".glb") || lower.ends_with(".gltf")) {
617
return None;
618
}
619
load_gltf_file(&root.join(relative)).ok()
620
}
621
547
622
fn parent_relative(relative: &str) -> String {
548
623
match relative.rfind('/') {
549
624
Some(index) => relative[..index].to_string(),
@@ -5,6 +5,8 @@ use std::path::PathBuf;5
use eframe::egui;5
use eframe::egui;6
6
7
use crate::import::{import_project_model, ImportedModel, ModelFormat};7
use crate::import::{import_project_model, ImportedModel, ModelFormat};8
use crate::preview::{load_gltf_file, PreviewCharacter, PreviewControls, PreviewSex};9
use crate::preview_gl::Viewport;8
use crate::project::{10
use crate::project::{9
assemble, create_project, list_dir, open_project, save_project, OpenedProject, ProjectDraft,11
assemble, create_project, list_dir, open_project, save_project, OpenedProject, ProjectDraft,10
ProjectError, FORMAT_VERSION, MANIFEST_NAME,12
ProjectError, FORMAT_VERSION, MANIFEST_NAME,@@ -26,6 +28,10 @@ pub struct EditorApp {26
browser: String,28
browser: String,27
male_import: Option<Result<ImportedModel, String>>,29
male_import: Option<Result<ImportedModel, String>>,28
female_import: Option<Result<ImportedModel, String>>,30
female_import: Option<Result<ImportedModel, String>>,31
preview_controls: PreviewControls,32
preview_male: Option<PreviewCharacter>,33
preview_female: Option<PreviewCharacter>,34
viewport: Viewport,29
notice: String,35
notice: String,30
notice_ok: bool,36
notice_ok: bool,31
}37
}@@ -48,6 +54,10 @@ impl EditorApp {48
browser: String::new(),54
browser: String::new(),49
male_import: None,55
male_import: None,50
female_import: None,56
female_import: None,57
preview_controls: PreviewControls::default(),58
preview_male: None,59
preview_female: None,60
viewport: Viewport::new(),51
notice: "Maak een projectmap of open een bestaand character.ppd-bestand.".to_string(),61
notice: "Maak een projectmap of open een bestaand character.ppd-bestand.".to_string(),52
notice_ok: true,62
notice_ok: true,53
}63
}@@ -74,6 +84,7 @@ impl EditorApp {74
if !female.trim().is_empty() {84
if !female.trim().is_empty() {75
self.inspect(false);85
self.inspect(false);76
}86
}87
self.refresh_preview_from_project();77
if male.trim().is_empty() && female.trim().is_empty() {88
if male.trim().is_empty() && female.trim().is_empty() {78
if opened.disk_issues.is_empty() {89
if opened.disk_issues.is_empty() {79
self.ok(notice);90
self.ok(notice);@@ -83,6 +94,37 @@ impl EditorApp {83
}94
}84
}95
}85
96
97
pub fn load_fixture_bodies(&mut self) {98
let root =99
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/bodies");100
match load_gltf_file(&root.join("test-body-male.glb")) {101
Ok(character) => self.preview_male = Some(character),102
Err(error) => self.fail_text(format!("testman: {error}")),103
}104
match load_gltf_file(&root.join("test-body-female.glb")) {105
Ok(character) => self.preview_female = Some(character),106
Err(error) => self.fail_text(format!("testvrouw: {error}")),107
}108
if self.preview_male.is_some() {109
self.preview_controls.sex = PreviewSex::Male;110
self.preview_controls.select_clip("walk");111
self.ok("Testlichamen staan in de 3D-preview.");112
}113
}114
115
fn refresh_preview_from_project(&mut self) {116
let Some(root) = self.opened.as_ref().and_then(|opened| {117
opened118
.manifest_path119
.parent()120
.map(std::path::Path::to_path_buf)121
}) else {122
return;123
};124
self.preview_male = load_preview_path(&root, &self.male);125
self.preview_female = load_preview_path(&root, &self.female);126
}127
86
fn ok(&mut self, message: &str) {128
fn ok(&mut self, message: &str) {87
self.notice = message.to_string();129
self.notice = message.to_string();88
self.notice_ok = true;130
self.notice_ok = true;@@ -292,16 +334,40 @@ impl eframe::App for EditorApp {292
};334
};293
ui.colored_label(color, &self.notice);335
ui.colored_label(color, &self.notice);294
});336
});337
egui::SidePanel::left("form")338
.resizable(true)339
.default_width(380.0)340
.show(ctx, |ui| {341
egui::ScrollArea::vertical().show(ui, |ui| self.show_form(ui));342
});295
egui::SidePanel::right("browser")343
egui::SidePanel::right("browser")296
.min_width(240.0)344
.min_width(220.0)297
.show(ctx, |ui| {345
.show(ctx, |ui| {298
self.show_browser(ui);346
self.show_browser(ui);299
});347
});300
egui::CentralPanel::default().show(ctx, |ui| {348
egui::CentralPanel::default().show(ctx, |ui| {301
egui::ScrollArea::vertical().show(ui, |ui| {349
self.show_preview(ui);302
self.show_form(ui);350
});303
});351
}352
}353
354
impl EditorApp {355
fn show_preview(&mut self, ui: &mut egui::Ui) {356
ui.horizontal_wrapped(|ui| {357
if ui.button("Laad testlichamen").clicked() {358
self.load_fixture_bodies();359
}360
if ui.button("3D van project").clicked() {361
self.refresh_preview_from_project();362
self.ok("De 3D-preview gebruikt de .glb- of .gltf-paden van man en vrouw.");363
}304
});364
});365
let character = match self.preview_controls.sex {366
PreviewSex::Male => self.preview_male.as_ref(),367
PreviewSex::Female => self.preview_female.as_ref(),368
};369
self.viewport370
.show(ui, &mut self.preview_controls, character);305
}371
}306
}372
}307
373
@@ -309,7 +375,7 @@ impl EditorApp {309
fn show_form(&mut self, ui: &mut egui::Ui) {375
fn show_form(&mut self, ui: &mut egui::Ui) {310
ui.heading("Paperdoll Renderer");376
ui.heading("Paperdoll Renderer");311
ui.label(377
ui.label(312
"Projectmap met één .ppd-bestand. Lees man of Lees vrouw toont glTF, FBX of OBJ: mesh, skeleton en clips. Er is geen 3D-beeld.",378
"Links het projectbestand. In het midden draait de live 3D-preview van een glTF/GLB. Lees man of Lees vrouw toont het importoverzicht.",313
);379
);314
ui.add_space(8.0);380
ui.add_space(8.0);315
ui.horizontal(|ui| {381
ui.horizontal(|ui| {@@ -544,6 +610,15 @@ fn show_import(ui: &mut egui::Ui, title: &str, report: Option<&Result<ImportedMo544
});610
});545
}611
}546
612
613
fn load_preview_path(root: &std::path::Path, relative: &str) -> Option<PreviewCharacter> {614
let relative = relative.trim();615
let lower = relative.to_ascii_lowercase();616
if !(lower.ends_with(".glb") || lower.ends_with(".gltf")) {617
return None;618
}619
load_gltf_file(&root.join(relative)).ok()620
}621
547
fn parent_relative(relative: &str) -> String {622
fn parent_relative(relative: &str) -> String {548
match relative.rfind('/') {623
match relative.rfind('/') {549
Some(index) => relative[..index].to_string(),624
Some(index) => relative[..index].to_string(),@@ -29,6 +29,7 @@ pub enum CliRequest {
29
29
OpenWindow,
30
30
PrintStatus,
31
31
PrintHelp,
32
PreviewFixtures,
32
33
}
33
34
34
35
/// Rejected command line. A model path is rejected on purpose: accepting one
@@ -58,11 +59,11 @@ pub fn availability(capability: Capability) -> Availability {
58
59
Capability::ProjectShell
59
60
| Capability::ObjMtlTextures
60
61
| Capability::GltfGlb
61
| Capability::Fbx => Availability::Present,
62
Capability::SkeletalAnimation
63
| Capability::AttachmentEditor
64
| Capability::PaperdollRender
65
| Capability::Preview2d => Availability::NotBuilt,
62
| Capability::Fbx
63
| Capability::SkeletalAnimation => Availability::Present,
64
Capability::AttachmentEditor | Capability::PaperdollRender | Capability::Preview2d => {
65
Availability::NotBuilt
66
}
66
67
}
67
68
}
68
69
@@ -107,14 +108,14 @@ pub fn dutch_state(availability: Availability) -> &'static str {
107
108
}
108
109
109
110
pub fn dutch_summary() -> &'static str {
110
"Deze build leest glTF/GLB en FBX uit tot meshes, materialen, textures, joints en animatieclips, en OBJ+MTL als statische mesh. Het afspelen van skeletanimatie, de attachment-editor, paperdoll-render en de 2D-preview zijn niet gebouwd."
111
"Deze build leest glTF/GLB, FBX en OBJ+MTL, en toont een live geskinde 3D-preview van glTF/GLB met walk en attack."
111
112
}
112
113
113
114
/// Stable status text shared by `--status` and the window.
114
115
pub fn report() -> String {
115
116
let mut lines = Vec::with_capacity(2 + CAPABILITIES.len());
116
117
lines.push("name: Paperdoll Renderer".to_string());
117
lines.push("phase: fbx-import".to_string());
118
lines.push("phase: live-3d-preview".to_string());
118
119
for capability in CAPABILITIES {
119
120
lines.push(format!(
120
121
"{}: {}",
@@ -165,7 +166,9 @@ Gebruik:
165
166
paperdoll-renderer --status print de status en sluit
166
167
paperdoll-renderer --help toon deze tekst
167
168
168
Een modelpad op de opdrachtregel wordt niet geaccepteerd. glTF/GLB, FBX en OBJ+MTL worden in het venster gelezen. Render is niet gebouwd."
169
paperdoll-renderer --preview-fixture open de 3D-preview met de testlichamen
170
171
Een los modelpad op de opdrachtregel opent geen bestand. Gebruik --preview-fixture of een .ppd-project."
169
172
}
170
173
171
174
pub fn parse_cli<'a, I>(args: I) -> Result<CliRequest, CliError>
@@ -176,6 +179,11 @@ where
176
179
for arg in args {
177
180
match arg {
178
181
"--help" | "-h" => request = CliRequest::PrintHelp,
182
"--preview-fixture" => {
183
if request != CliRequest::PrintHelp {
184
request = CliRequest::PreviewFixtures;
185
}
186
}
179
187
"--status" => {
180
188
if request != CliRequest::PrintHelp {
181
189
request = CliRequest::PrintStatus;
@@ -205,8 +213,11 @@ mod tests {
205
213
Availability::Present
206
214
);
207
215
assert_eq!(availability(Capability::Fbx), Availability::Present);
216
assert_eq!(
217
availability(Capability::SkeletalAnimation),
218
Availability::Present
219
);
208
220
for capability in [
209
Capability::SkeletalAnimation,
210
221
Capability::AttachmentEditor,
211
222
Capability::PaperdollRender,
212
223
Capability::Preview2d,
@@ -222,19 +233,20 @@ mod tests {
222
233
#[test]
223
234
fn status_report_matches_the_import_build() {
224
235
let report = report();
225
assert!(report.starts_with("name: Paperdoll Renderer\nphase: fbx-import\n"));
226
for key in ["project_shell", "obj_mtl_textures", "gltf_glb", "fbx"] {
236
assert!(report.starts_with("name: Paperdoll Renderer\nphase: live-3d-preview\n"));
237
for key in [
238
"project_shell",
239
"obj_mtl_textures",
240
"gltf_glb",
241
"fbx",
242
"skeletal_animation",
243
] {
227
244
assert!(
228
245
report.contains(&format!("{key}: present")),
229
246
"missing present {key} in {report}"
230
247
);
231
248
}
232
for key in [
233
"skeletal_animation",
234
"attachment_editor",
235
"paperdoll_render",
236
"preview_2d",
237
] {
249
for key in ["attachment_editor", "paperdoll_render", "preview_2d"] {
238
250
assert!(
239
251
report.contains(&format!("{key}: not_built")),
240
252
"missing {key} in {report}"
@@ -245,12 +257,9 @@ mod tests {
245
257
);
246
258
}
247
259
let summary = dutch_summary();
248
assert!(summary.contains("niet gebouwd"));
249
assert!(summary.contains("OBJ+MTL"));
260
assert!(summary.contains("3D-preview"));
250
261
assert!(summary.contains("glTF/GLB"));
251
assert!(summary.contains("FBX"));
252
let lower = summary.to_lowercase();
253
assert!(!lower.contains("render werkt"));
262
assert!(summary.contains("walk"));
254
263
}
255
264
256
265
#[test]
@@ -269,7 +278,11 @@ mod tests {
269
278
parse_cli(["--status", "--render"]),
270
279
Err(CliError::UnknownArgument("--render".to_string()))
271
280
);
272
assert!(help_text().contains("niet gebouwd"));
281
assert!(help_text().contains("--preview-fixture"));
282
assert_eq!(
283
parse_cli(["--preview-fixture"]),
284
Ok(CliRequest::PreviewFixtures)
285
);
273
286
}
274
287
275
288
#[test]
@@ -29,6 +29,7 @@ pub enum CliRequest {29
OpenWindow,29
OpenWindow,30
PrintStatus,30
PrintStatus,31
PrintHelp,31
PrintHelp,32
PreviewFixtures,32
}33
}33
34
34
/// Rejected command line. A model path is rejected on purpose: accepting one35
/// Rejected command line. A model path is rejected on purpose: accepting one@@ -58,11 +59,11 @@ pub fn availability(capability: Capability) -> Availability {58
Capability::ProjectShell59
Capability::ProjectShell59
| Capability::ObjMtlTextures60
| Capability::ObjMtlTextures60
| Capability::GltfGlb61
| Capability::GltfGlb61
| Capability::Fbx => Availability::Present,62
| Capability::Fbx62
Capability::SkeletalAnimation63
| Capability::SkeletalAnimation => Availability::Present,63
| Capability::AttachmentEditor64
Capability::AttachmentEditor | Capability::PaperdollRender | Capability::Preview2d => {64
| Capability::PaperdollRender65
Availability::NotBuilt65
| Capability::Preview2d => Availability::NotBuilt,66
}66
}67
}67
}68
}68
69
@@ -107,14 +108,14 @@ pub fn dutch_state(availability: Availability) -> &'static str {107
}108
}108
109
109
pub fn dutch_summary() -> &'static str {110
pub fn dutch_summary() -> &'static str {110
"Deze build leest glTF/GLB en FBX uit tot meshes, materialen, textures, joints en animatieclips, en OBJ+MTL als statische mesh. Het afspelen van skeletanimatie, de attachment-editor, paperdoll-render en de 2D-preview zijn niet gebouwd."111
"Deze build leest glTF/GLB, FBX en OBJ+MTL, en toont een live geskinde 3D-preview van glTF/GLB met walk en attack."111
}112
}112
113
113
/// Stable status text shared by `--status` and the window.114
/// Stable status text shared by `--status` and the window.114
pub fn report() -> String {115
pub fn report() -> String {115
let mut lines = Vec::with_capacity(2 + CAPABILITIES.len());116
let mut lines = Vec::with_capacity(2 + CAPABILITIES.len());116
lines.push("name: Paperdoll Renderer".to_string());117
lines.push("name: Paperdoll Renderer".to_string());117
lines.push("phase: fbx-import".to_string());118
lines.push("phase: live-3d-preview".to_string());118
for capability in CAPABILITIES {119
for capability in CAPABILITIES {119
lines.push(format!(120
lines.push(format!(120
"{}: {}",121
"{}: {}",@@ -165,7 +166,9 @@ Gebruik:165
paperdoll-renderer --status print de status en sluit166
paperdoll-renderer --status print de status en sluit166
paperdoll-renderer --help toon deze tekst167
paperdoll-renderer --help toon deze tekst167
168
168
Een modelpad op de opdrachtregel wordt niet geaccepteerd. glTF/GLB, FBX en OBJ+MTL worden in het venster gelezen. Render is niet gebouwd."169
paperdoll-renderer --preview-fixture open de 3D-preview met de testlichamen170
171
Een los modelpad op de opdrachtregel opent geen bestand. Gebruik --preview-fixture of een .ppd-project."169
}172
}170
173
171
pub fn parse_cli<'a, I>(args: I) -> Result<CliRequest, CliError>174
pub fn parse_cli<'a, I>(args: I) -> Result<CliRequest, CliError>@@ -176,6 +179,11 @@ where176
for arg in args {179
for arg in args {177
match arg {180
match arg {178
"--help" | "-h" => request = CliRequest::PrintHelp,181
"--help" | "-h" => request = CliRequest::PrintHelp,182
"--preview-fixture" => {183
if request != CliRequest::PrintHelp {184
request = CliRequest::PreviewFixtures;185
}186
}179
"--status" => {187
"--status" => {180
if request != CliRequest::PrintHelp {188
if request != CliRequest::PrintHelp {181
request = CliRequest::PrintStatus;189
request = CliRequest::PrintStatus;@@ -205,8 +213,11 @@ mod tests {205
Availability::Present213
Availability::Present206
);214
);207
assert_eq!(availability(Capability::Fbx), Availability::Present);215
assert_eq!(availability(Capability::Fbx), Availability::Present);216
assert_eq!(217
availability(Capability::SkeletalAnimation),218
Availability::Present219
);208
for capability in [220
for capability in [209
Capability::SkeletalAnimation,210
Capability::AttachmentEditor,221
Capability::AttachmentEditor,211
Capability::PaperdollRender,222
Capability::PaperdollRender,212
Capability::Preview2d,223
Capability::Preview2d,@@ -222,19 +233,20 @@ mod tests {222
#[test]233
#[test]223
fn status_report_matches_the_import_build() {234
fn status_report_matches_the_import_build() {224
let report = report();235
let report = report();225
assert!(report.starts_with("name: Paperdoll Renderer\nphase: fbx-import\n"));236
assert!(report.starts_with("name: Paperdoll Renderer\nphase: live-3d-preview\n"));226
for key in ["project_shell", "obj_mtl_textures", "gltf_glb", "fbx"] {237
for key in [238
"project_shell",239
"obj_mtl_textures",240
"gltf_glb",241
"fbx",242
"skeletal_animation",243
] {227
assert!(244
assert!(228
report.contains(&format!("{key}: present")),245
report.contains(&format!("{key}: present")),229
"missing present {key} in {report}"246
"missing present {key} in {report}"230
);247
);231
}248
}232
for key in [249
for key in ["attachment_editor", "paperdoll_render", "preview_2d"] {233
"skeletal_animation",234
"attachment_editor",235
"paperdoll_render",236
"preview_2d",237
] {238
assert!(250
assert!(239
report.contains(&format!("{key}: not_built")),251
report.contains(&format!("{key}: not_built")),240
"missing {key} in {report}"252
"missing {key} in {report}"@@ -245,12 +257,9 @@ mod tests {245
);257
);246
}258
}247
let summary = dutch_summary();259
let summary = dutch_summary();248
assert!(summary.contains("niet gebouwd"));260
assert!(summary.contains("3D-preview"));249
assert!(summary.contains("OBJ+MTL"));250
assert!(summary.contains("glTF/GLB"));261
assert!(summary.contains("glTF/GLB"));251
assert!(summary.contains("FBX"));262
assert!(summary.contains("walk"));252
let lower = summary.to_lowercase();253
assert!(!lower.contains("render werkt"));254
}263
}255
264
256
#[test]265
#[test]@@ -269,7 +278,11 @@ mod tests {269
parse_cli(["--status", "--render"]),278
parse_cli(["--status", "--render"]),270
Err(CliError::UnknownArgument("--render".to_string()))279
Err(CliError::UnknownArgument("--render".to_string()))271
);280
);272
assert!(help_text().contains("niet gebouwd"));281
assert!(help_text().contains("--preview-fixture"));282
assert_eq!(283
parse_cli(["--preview-fixture"]),284
Ok(CliRequest::PreviewFixtures)285
);273
}286
}274
287
275
#[test]288
#[test]@@ -6,4 +6,6 @@
6
6
pub mod editor;
7
7
pub mod foundation;
8
8
pub mod import;
9
pub mod preview;
10
pub mod preview_gl;
9
11
pub mod project;
@@ -6,4 +6,6 @@6
pub mod editor;6
pub mod editor;7
pub mod foundation;7
pub mod foundation;8
pub mod import;8
pub mod import;9
pub mod preview;10
pub mod preview_gl;9
pub mod project;11
pub mod project;@@ -1,7 +1,6 @@
1
1
//! Desktop shell for Paperdoll Renderer project files.
2
2
//!
3
//! The window creates and saves a `.ppd` project directory. It does not import
4
//! models and it does not render paperdolls.
3
//! The window edits a `.ppd` project and shows a live skinned glTF preview.
5
4
6
5
use std::process::ExitCode;
7
6
@@ -20,27 +19,30 @@ fn main() -> ExitCode {
20
19
println!("{}", report());
21
20
ExitCode::SUCCESS
22
21
}
23
Ok(CliRequest::OpenWindow) => {
24
if let Some(message) = window_preflight() {
25
eprintln!("paperdoll-renderer: {message}");
26
return ExitCode::from(1);
27
}
28
match run_window() {
29
Ok(()) => ExitCode::SUCCESS,
30
Err(err) => {
31
eprintln!("paperdoll-renderer: het projectvenster openen is mislukt: {err}");
32
ExitCode::from(1)
33
}
34
}
35
}
22
Ok(CliRequest::PreviewFixtures) => open_window(true),
23
Ok(CliRequest::OpenWindow) => open_window(false),
36
24
Err(CliError::UnknownArgument(arg)) => {
37
25
eprintln!("paperdoll-renderer: onbekend argument '{arg}'.");
38
eprintln!("Import en render zijn niet gebouwd. Gebruik --help.");
26
eprintln!("Gebruik --help.");
39
27
ExitCode::from(2)
40
28
}
41
29
}
42
30
}
43
31
32
fn open_window(fixtures: bool) -> ExitCode {
33
if let Some(message) = window_preflight() {
34
eprintln!("paperdoll-renderer: {message}");
35
return ExitCode::from(1);
36
}
37
match run_window(fixtures) {
38
Ok(()) => ExitCode::SUCCESS,
39
Err(err) => {
40
eprintln!("paperdoll-renderer: het venster openen is mislukt: {err}");
41
ExitCode::from(1)
42
}
43
}
44
}
45
44
46
fn window_preflight() -> Option<String> {
45
47
#[cfg(target_os = "linux")]
46
48
{
@@ -59,17 +61,23 @@ fn window_preflight() -> Option<String> {
59
61
}
60
62
}
61
63
62
fn run_window() -> eframe::Result<()> {
64
fn run_window(fixtures: bool) -> eframe::Result<()> {
63
65
let options = eframe::NativeOptions {
64
66
viewport: eframe::egui::ViewportBuilder::default()
65
67
.with_title("Paperdoll Renderer")
66
.with_inner_size([980.0, 680.0])
67
.with_min_inner_size([720.0, 480.0]),
68
.with_inner_size([1180.0, 760.0])
69
.with_min_inner_size([900.0, 560.0]),
68
70
..Default::default()
69
71
};
70
72
eframe::run_native(
71
73
"Paperdoll Renderer",
72
74
options,
73
Box::new(|_creation| Ok(Box::<EditorApp>::default())),
75
Box::new(move |_creation| {
76
let mut app = EditorApp::default();
77
if fixtures {
78
app.load_fixture_bodies();
79
}
80
Ok(Box::new(app))
81
}),
74
82
)
75
83
}
@@ -1,7 +1,6 @@1
//! Desktop shell for Paperdoll Renderer project files.1
//! Desktop shell for Paperdoll Renderer project files.2
//!2
//!3
//! The window creates and saves a `.ppd` project directory. It does not import3
//! The window edits a `.ppd` project and shows a live skinned glTF preview.4
//! models and it does not render paperdolls.5
4
6
use std::process::ExitCode;5
use std::process::ExitCode;7
6
@@ -20,27 +19,30 @@ fn main() -> ExitCode {20
println!("{}", report());19
println!("{}", report());21
ExitCode::SUCCESS20
ExitCode::SUCCESS22
}21
}23
Ok(CliRequest::OpenWindow) => {22
Ok(CliRequest::PreviewFixtures) => open_window(true),24
if let Some(message) = window_preflight() {23
Ok(CliRequest::OpenWindow) => open_window(false),25
eprintln!("paperdoll-renderer: {message}");26
return ExitCode::from(1);27
}28
match run_window() {29
Ok(()) => ExitCode::SUCCESS,30
Err(err) => {31
eprintln!("paperdoll-renderer: het projectvenster openen is mislukt: {err}");32
ExitCode::from(1)33
}34
}35
}36
Err(CliError::UnknownArgument(arg)) => {24
Err(CliError::UnknownArgument(arg)) => {37
eprintln!("paperdoll-renderer: onbekend argument '{arg}'.");25
eprintln!("paperdoll-renderer: onbekend argument '{arg}'.");38
eprintln!("Import en render zijn niet gebouwd. Gebruik --help.");26
eprintln!("Gebruik --help.");39
ExitCode::from(2)27
ExitCode::from(2)40
}28
}41
}29
}42
}30
}43
31
32
fn open_window(fixtures: bool) -> ExitCode {33
if let Some(message) = window_preflight() {34
eprintln!("paperdoll-renderer: {message}");35
return ExitCode::from(1);36
}37
match run_window(fixtures) {38
Ok(()) => ExitCode::SUCCESS,39
Err(err) => {40
eprintln!("paperdoll-renderer: het venster openen is mislukt: {err}");41
ExitCode::from(1)42
}43
}44
}45
44
fn window_preflight() -> Option<String> {46
fn window_preflight() -> Option<String> {45
#[cfg(target_os = "linux")]47
#[cfg(target_os = "linux")]46
{48
{@@ -59,17 +61,23 @@ fn window_preflight() -> Option<String> {59
}61
}60
}62
}61
63
62
fn run_window() -> eframe::Result<()> {64
fn run_window(fixtures: bool) -> eframe::Result<()> {63
let options = eframe::NativeOptions {65
let options = eframe::NativeOptions {64
viewport: eframe::egui::ViewportBuilder::default()66
viewport: eframe::egui::ViewportBuilder::default()65
.with_title("Paperdoll Renderer")67
.with_title("Paperdoll Renderer")66
.with_inner_size([980.0, 680.0])68
.with_inner_size([1180.0, 760.0])67
.with_min_inner_size([720.0, 480.0]),69
.with_min_inner_size([900.0, 560.0]),68
..Default::default()70
..Default::default()69
};71
};70
eframe::run_native(72
eframe::run_native(71
"Paperdoll Renderer",73
"Paperdoll Renderer",72
options,74
options,73
Box::new(|_creation| Ok(Box::<EditorApp>::default())),75
Box::new(move |_creation| {76
let mut app = EditorApp::default();77
if fixtures {78
app.load_fixture_bodies();79
}80
Ok(Box::new(app))81
}),74
)82
)75
}83
}@@ -0,0 +1,767 @@
1
//! CPU skeleton sampling and orbit camera for the live glTF preview.
2
//!
3
//! This is not the paperdoll export. The export camera is orthographic and
4
//! still out of scope. The interactive camera can snap to the same three
5
//! pitches and twelve yaws. The character forward axis of the test bodies is -Z,
6
//! so yaw 0 places the camera on -Z, looking at the body.
7
8
use std::path::Path;
9
10
pub const PAPERDOLL_PITCHES_DEG: [f32; 3] = [-30.0, 0.0, 30.0];
11
pub const PAPERDOLL_YAW_COUNT: usize = 12;
12
pub const MAX_JOINTS: usize = 128;
13
14
pub type Mat4 = [f32; 16];
15
16
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17
pub enum PreviewSex {
18
Male,
19
Female,
20
}
21
22
#[derive(Debug, Clone)]
23
pub struct PreviewControls {
24
pub sex: PreviewSex,
25
pub clip_name: String,
26
pub time: f32,
27
pub playing: bool,
28
pub yaw_deg: f32,
29
pub pitch_deg: f32,
30
pub distance: f32,
31
}
32
33
impl Default for PreviewControls {
34
fn default() -> Self {
35
Self {
36
sex: PreviewSex::Male,
37
clip_name: "walk".to_string(),
38
time: 0.0,
39
playing: true,
40
yaw_deg: 0.0,
41
pitch_deg: 0.0,
42
distance: 3.4,
43
}
44
}
45
}
46
47
impl PreviewControls {
48
pub fn drag(&mut self, dx_px: f32, dy_px: f32) {
49
self.yaw_deg = wrap_deg(self.yaw_deg - dx_px * 0.45);
50
self.pitch_deg = (self.pitch_deg + dy_px * 0.35).clamp(-80.0, 80.0);
51
}
52
53
pub fn zoom(&mut self, scroll_y: f32) {
54
let factor = (-scroll_y * 0.0015).exp();
55
self.distance = (self.distance * factor).clamp(0.8, 12.0);
56
}
57
58
pub fn snap_yaw_index(&mut self, index: i32) {
59
let wrapped = index.rem_euclid(PAPERDOLL_YAW_COUNT as i32) as f32;
60
self.yaw_deg = wrapped * 30.0;
61
}
62
63
pub fn snap_pitch_row(&mut self, row: i32) {
64
let row = row.clamp(0, 2) as usize;
65
self.pitch_deg = PAPERDOLL_PITCHES_DEG[row];
66
}
67
68
pub fn advance(&mut self, dt: f32, duration: f32) {
69
if self.playing && duration > 0.0 {
70
self.time = wrap_time(self.time + dt, duration);
71
}
72
}
73
74
pub fn select_clip(&mut self, name: &str) {
75
if self.clip_name != name {
76
self.clip_name = name.to_string();
77
self.time = 0.0;
78
}
79
}
80
}
81
82
#[derive(Debug, Clone)]
83
pub struct PreviewCharacter {
84
pub nodes: Vec<SceneNode>,
85
pub joint_nodes: Vec<usize>,
86
pub joint_names: Vec<String>,
87
pub inverse_binds: Vec<Mat4>,
88
pub positions: Vec<[f32; 3]>,
89
pub normals: Vec<[f32; 3]>,
90
pub joints: Vec<[u16; 4]>,
91
pub weights: Vec<[f32; 4]>,
92
pub indices: Vec<u32>,
93
pub base_color: [f32; 4],
94
pub clips: Vec<PreviewClip>,
95
}
96
97
#[derive(Debug, Clone)]
98
pub struct SceneNode {
99
pub name: String,
100
pub parent: Option<usize>,
101
pub translation: [f32; 3],
102
pub rotation: [f32; 4],
103
pub scale: [f32; 3],
104
}
105
106
#[derive(Debug, Clone)]
107
pub struct PreviewClip {
108
pub name: String,
109
pub duration: f32,
110
pub channels: Vec<AnimChannel>,
111
}
112
113
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114
pub enum ChannelProperty {
115
Translation,
116
Rotation,
117
Scale,
118
}
119
120
#[derive(Debug, Clone)]
121
pub struct AnimChannel {
122
pub node: usize,
123
pub property: ChannelProperty,
124
pub times: Vec<f32>,
125
pub values: Vec<f32>,
126
}
127
128
pub fn load_gltf_file(path: &Path) -> Result<PreviewCharacter, String> {
129
let (document, buffers, _images) = gltf::import(path).map_err(|err| err.to_string())?;
130
let mut nodes = Vec::new();
131
let mut parents = vec![None; document.nodes().len()];
132
for node in document.nodes() {
133
for child in node.children() {
134
parents[child.index()] = Some(node.index());
135
}
136
}
137
for node in document.nodes() {
138
let (translation, rotation, scale) = node.transform().decomposed();
139
nodes.push(SceneNode {
140
name: node.name().unwrap_or("").to_string(),
141
parent: parents[node.index()],
142
translation,
143
rotation,
144
scale,
145
});
146
}
147
let skin = document
148
.skins()
149
.next()
150
.ok_or_else(|| "het glTF-bestand heeft geen skeleton".to_string())?;
151
if skin.joints().len() > MAX_JOINTS {
152
return Err(format!(
153
"het skeleton heeft {} joints; de preview stopt bij {MAX_JOINTS}",
154
skin.joints().len()
155
));
156
}
157
let joint_nodes: Vec<usize> = skin.joints().map(|joint| joint.index()).collect();
158
let joint_names: Vec<String> = joint_nodes
159
.iter()
160
.map(|index| nodes[*index].name.clone())
161
.collect();
162
let reader = skin.reader(|buffer| Some(buffers[buffer.index()].as_ref()));
163
let inverse_binds = reader
164
.read_inverse_bind_matrices()
165
.map(|iter| iter.map(columns_to_mat).collect())
166
.unwrap_or_else(|| vec![identity(); joint_nodes.len()]);
167
if inverse_binds.len() != joint_nodes.len() {
168
return Err("inverse-bindmatrices en joints verschillen".to_string());
169
}
170
let mut positions = Vec::new();
171
let mut normals = Vec::new();
172
let mut joints = Vec::new();
173
let mut weights = Vec::new();
174
let mut indices = Vec::new();
175
let mut base_color = [0.7, 0.7, 0.75, 1.0];
176
if let Some(mesh) = document.meshes().next() {
177
for primitive in mesh.primitives() {
178
let reader = primitive.reader(|buffer| Some(buffers[buffer.index()].as_ref()));
179
let prim_positions: Vec<[f32; 3]> = reader
180
.read_positions()
181
.map(|iter| iter.collect())
182
.unwrap_or_default();
183
let prim_normals: Vec<[f32; 3]> = reader
184
.read_normals()
185
.map(|iter| iter.collect())
186
.unwrap_or_else(|| vec![[0.0, 1.0, 0.0]; prim_positions.len()]);
187
let prim_joints: Vec<[u16; 4]> = reader
188
.read_joints(0)
189
.map(|iter| iter.into_u16().collect())
190
.unwrap_or_else(|| vec![[0, 0, 0, 0]; prim_positions.len()]);
191
let prim_weights: Vec<[f32; 4]> = reader
192
.read_weights(0)
193
.map(|iter| iter.into_f32().collect())
194
.unwrap_or_else(|| vec![[1.0, 0.0, 0.0, 0.0]; prim_positions.len()]);
195
let base = positions.len() as u32;
196
if let Some(read_indices) = reader.read_indices() {
197
indices.extend(read_indices.into_u32().map(|index| index + base));
198
} else {
199
indices.extend(base..base + prim_positions.len() as u32);
200
}
201
positions.extend(prim_positions);
202
normals.extend(prim_normals);
203
joints.extend(prim_joints);
204
weights.extend(prim_weights);
205
base_color = primitive
206
.material()
207
.pbr_metallic_roughness()
208
.base_color_factor();
209
}
210
}
211
if positions.is_empty() || indices.len() < 3 {
212
return Err("het glTF-bestand heeft geen tekenbare driehoeken".to_string());
213
}
214
let mut clips = Vec::new();
215
for animation in document.animations() {
216
let mut channels = Vec::new();
217
let mut duration = 0.0_f32;
218
for channel in animation.channels() {
219
let reader = channel.reader(|buffer| Some(buffers[buffer.index()].as_ref()));
220
let times: Vec<f32> = reader
221
.read_inputs()
222
.map(|iter| iter.collect())
223
.unwrap_or_default();
224
if let Some(last) = times.last() {
225
duration = duration.max(*last);
226
}
227
let Some(outputs) = reader.read_outputs() else {
228
continue;
229
};
230
let (property, values) = match outputs {
231
gltf::animation::util::ReadOutputs::Translations(values) => {
232
(ChannelProperty::Translation, values.flatten().collect())
233
}
234
gltf::animation::util::ReadOutputs::Scales(values) => {
235
(ChannelProperty::Scale, values.flatten().collect())
236
}
237
gltf::animation::util::ReadOutputs::Rotations(values) => (
238
ChannelProperty::Rotation,
239
values.into_f32().flatten().collect(),
240
),
241
gltf::animation::util::ReadOutputs::MorphTargetWeights(_) => continue,
242
};
243
channels.push(AnimChannel {
244
node: channel.target().node().index(),
245
property,
246
times,
247
values,
248
});
249
}
250
clips.push(PreviewClip {
251
name: animation.name().unwrap_or("naamloos").to_string(),
252
duration,
253
channels,
254
});
255
}
256
Ok(PreviewCharacter {
257
nodes,
258
joint_nodes,
259
joint_names,
260
inverse_binds,
261
positions,
262
normals,
263
joints,
264
weights,
265
indices,
266
base_color,
267
clips,
268
})
269
}
270
271
pub fn sample_skin_matrices(character: &PreviewCharacter, clip_name: &str, time: f32) -> Vec<Mat4> {
272
let locals = sample_local_matrices(character, clip_name, time);
273
let worlds = world_matrices(&character.nodes, &locals);
274
character
275
.joint_nodes
276
.iter()
277
.enumerate()
278
.map(|(joint, node)| mul(worlds[*node], character.inverse_binds[joint]))
279
.collect()
280
}
281
282
pub fn joint_world_translation(
283
character: &PreviewCharacter,
284
clip_name: &str,
285
time: f32,
286
joint_name: &str,
287
) -> Option<[f32; 3]> {
288
let joint = character
289
.joint_names
290
.iter()
291
.position(|name| name == joint_name)?;
292
let node = character.joint_nodes[joint];
293
let locals = sample_local_matrices(character, clip_name, time);
294
let worlds = world_matrices(&character.nodes, &locals);
295
Some([worlds[node][12], worlds[node][13], worlds[node][14]])
296
}
297
298
pub fn clip_duration(character: &PreviewCharacter, clip_name: &str) -> f32 {
299
character
300
.clips
301
.iter()
302
.find(|clip| {
303
clip.name == clip_name || clip.name.to_lowercase().contains(&clip_name.to_lowercase())
304
})
305
.map(|clip| clip.duration)
306
.unwrap_or(0.0)
307
}
308
309
pub fn eye_offset(yaw_deg: f32, pitch_deg: f32, distance: f32) -> [f32; 3] {
310
let yaw = yaw_deg.to_radians();
311
let pitch = pitch_deg.to_radians();
312
[
313
-distance * pitch.cos() * yaw.sin(),
314
distance * pitch.sin(),
315
-distance * pitch.cos() * yaw.cos(),
316
]
317
}
318
319
pub fn paperdoll_yaws_deg() -> [f32; PAPERDOLL_YAW_COUNT] {
320
let mut yaws = [0.0; PAPERDOLL_YAW_COUNT];
321
for (index, yaw) in yaws.iter_mut().enumerate() {
322
*yaw = index as f32 * 30.0;
323
}
324
yaws
325
}
326
327
fn sample_local_matrices(character: &PreviewCharacter, clip_name: &str, time: f32) -> Vec<Mat4> {
328
let mut translation: Vec<[f32; 3]> = character
329
.nodes
330
.iter()
331
.map(|node| node.translation)
332
.collect();
333
let mut rotation: Vec<[f32; 4]> = character.nodes.iter().map(|node| node.rotation).collect();
334
let mut scale: Vec<[f32; 3]> = character.nodes.iter().map(|node| node.scale).collect();
335
if let Some(clip) = character.clips.iter().find(|clip| {
336
clip.name == clip_name || clip.name.to_lowercase().contains(&clip_name.to_lowercase())
337
}) {
338
let time = wrap_time(time, clip.duration);
339
for channel in &clip.channels {
340
if channel.node >= character.nodes.len() || channel.times.is_empty() {
341
continue;
342
}
343
let (left, right, blend) = key_blend(&channel.times, time);
344
match channel.property {
345
ChannelProperty::Translation => {
346
translation[channel.node] = lerp3(
347
key3(&channel.values, left),
348
key3(&channel.values, right),
349
blend,
350
);
351
}
352
ChannelProperty::Scale => {
353
scale[channel.node] = lerp3(
354
key3(&channel.values, left),
355
key3(&channel.values, right),
356
blend,
357
);
358
}
359
ChannelProperty::Rotation => {
360
rotation[channel.node] = slerp(
361
key4(&channel.values, left),
362
key4(&channel.values, right),
363
blend,
364
);
365
}
366
}
367
}
368
}
369
translation
370
.into_iter()
371
.zip(rotation)
372
.zip(scale)
373
.map(|((translation, rotation), scale)| trs(translation, rotation, scale))
374
.collect()
375
}
376
377
fn world_matrices(nodes: &[SceneNode], locals: &[Mat4]) -> Vec<Mat4> {
378
fn world(
379
index: usize,
380
nodes: &[SceneNode],
381
locals: &[Mat4],
382
worlds: &mut [Option<Mat4>],
383
depth: usize,
384
) -> Mat4 {
385
if let Some(matrix) = worlds[index] {
386
return matrix;
387
}
388
if depth > nodes.len() {
389
return locals[index];
390
}
391
let matrix = match nodes[index].parent {
392
Some(parent) => mul(
393
world(parent, nodes, locals, worlds, depth + 1),
394
locals[index],
395
),
396
None => locals[index],
397
};
398
worlds[index] = Some(matrix);
399
matrix
400
}
401
let mut cached = vec![None; nodes.len()];
402
(0..nodes.len())
403
.map(|index| world(index, nodes, locals, &mut cached, 0))
404
.collect()
405
}
406
407
fn key_blend(times: &[f32], time: f32) -> (usize, usize, f32) {
408
if times.len() == 1 || time <= times[0] {
409
return (0, 0, 0.0);
410
}
411
if time >= times[times.len() - 1] {
412
let last = times.len() - 1;
413
return (last, last, 0.0);
414
}
415
let mut index = 0;
416
while index + 1 < times.len() && times[index + 1] < time {
417
index += 1;
418
}
419
let span = times[index + 1] - times[index];
420
let blend = if span.abs() < 1e-6 {
421
0.0
422
} else {
423
(time - times[index]) / span
424
};
425
(index, index + 1, blend)
426
}
427
428
fn key3(values: &[f32], index: usize) -> [f32; 3] {
429
let start = index * 3;
430
[
431
values.get(start).copied().unwrap_or(0.0),
432
values.get(start + 1).copied().unwrap_or(0.0),
433
values.get(start + 2).copied().unwrap_or(0.0),
434
]
435
}
436
437
fn key4(values: &[f32], index: usize) -> [f32; 4] {
438
let start = index * 4;
439
[
440
values.get(start).copied().unwrap_or(0.0),
441
values.get(start + 1).copied().unwrap_or(0.0),
442
values.get(start + 2).copied().unwrap_or(0.0),
443
values.get(start + 3).copied().unwrap_or(1.0),
444
]
445
}
446
447
fn lerp3(a: [f32; 3], b: [f32; 3], t: f32) -> [f32; 3] {
448
[
449
a[0] + (b[0] - a[0]) * t,
450
a[1] + (b[1] - a[1]) * t,
451
a[2] + (b[2] - a[2]) * t,
452
]
453
}
454
455
fn slerp(a: [f32; 4], mut b: [f32; 4], t: f32) -> [f32; 4] {
456
let mut dot = a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3];
457
if dot < 0.0 {
458
dot = -dot;
459
b = [-b[0], -b[1], -b[2], -b[3]];
460
}
461
if dot > 0.9995 {
462
return normalize4([
463
a[0] + (b[0] - a[0]) * t,
464
a[1] + (b[1] - a[1]) * t,
465
a[2] + (b[2] - a[2]) * t,
466
a[3] + (b[3] - a[3]) * t,
467
]);
468
}
469
let theta = dot.clamp(-1.0, 1.0).acos();
470
let sin_theta = theta.sin();
471
let w1 = ((1.0 - t) * theta).sin() / sin_theta;
472
let w2 = (t * theta).sin() / sin_theta;
473
[
474
a[0] * w1 + b[0] * w2,
475
a[1] * w1 + b[1] * w2,
476
a[2] * w1 + b[2] * w2,
477
a[3] * w1 + b[3] * w2,
478
]
479
}
480
481
fn normalize4(value: [f32; 4]) -> [f32; 4] {
482
let length =
483
(value[0] * value[0] + value[1] * value[1] + value[2] * value[2] + value[3] * value[3])
484
.sqrt();
485
if length < 1e-8 {
486
[0.0, 0.0, 0.0, 1.0]
487
} else {
488
[
489
value[0] / length,
490
value[1] / length,
491
value[2] / length,
492
value[3] / length,
493
]
494
}
495
}
496
497
fn trs(translation: [f32; 3], rotation: [f32; 4], scale: [f32; 3]) -> Mat4 {
498
let mut matrix = quat_to_mat(normalize4(rotation));
499
matrix[0] *= scale[0];
500
matrix[1] *= scale[0];
501
matrix[2] *= scale[0];
502
matrix[4] *= scale[1];
503
matrix[5] *= scale[1];
504
matrix[6] *= scale[1];
505
matrix[8] *= scale[2];
506
matrix[9] *= scale[2];
507
matrix[10] *= scale[2];
508
matrix[12] = translation[0];
509
matrix[13] = translation[1];
510
matrix[14] = translation[2];
511
matrix
512
}
513
514
fn quat_to_mat(q: [f32; 4]) -> Mat4 {
515
let (x, y, z, w) = (q[0], q[1], q[2], q[3]);
516
let xx = x * x;
517
let yy = y * y;
518
let zz = z * z;
519
let xy = x * y;
520
let xz = x * z;
521
let yz = y * z;
522
let wx = w * x;
523
let wy = w * y;
524
let wz = w * z;
525
[
526
1.0 - 2.0 * (yy + zz),
527
2.0 * (xy + wz),
528
2.0 * (xz - wy),
529
0.0,
530
2.0 * (xy - wz),
531
1.0 - 2.0 * (xx + zz),
532
2.0 * (yz + wx),
533
0.0,
534
2.0 * (xz + wy),
535
2.0 * (yz - wx),
536
1.0 - 2.0 * (xx + yy),
537
0.0,
538
0.0,
539
0.0,
540
0.0,
541
1.0,
542
]
543
}
544
545
pub fn identity() -> Mat4 {
546
[
547
1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
548
]
549
}
550
551
pub fn mul(left: Mat4, right: Mat4) -> Mat4 {
552
let mut out = [0.0; 16];
553
for column in 0..4 {
554
for row in 0..4 {
555
out[column * 4 + row] = left[row] * right[column * 4]
556
+ left[4 + row] * right[column * 4 + 1]
557
+ left[8 + row] * right[column * 4 + 2]
558
+ left[12 + row] * right[column * 4 + 3];
559
}
560
}
561
out
562
}
563
564
fn columns_to_mat(columns: [[f32; 4]; 4]) -> Mat4 {
565
let mut matrix = [0.0; 16];
566
for column in 0..4 {
567
for row in 0..4 {
568
matrix[column * 4 + row] = columns[column][row];
569
}
570
}
571
matrix
572
}
573
574
pub fn wrap_time(time: f32, duration: f32) -> f32 {
575
if duration <= 1e-5 {
576
return 0.0;
577
}
578
let mut wrapped = time % duration;
579
if wrapped < 0.0 {
580
wrapped += duration;
581
}
582
wrapped
583
}
584
585
fn wrap_deg(mut degrees: f32) -> f32 {
586
degrees %= 360.0;
587
if degrees < 0.0 {
588
degrees += 360.0;
589
}
590
degrees
591
}
592
593
pub fn view_matrix(eye: [f32; 3], target: [f32; 3]) -> Mat4 {
594
let forward = normalize3([target[0] - eye[0], target[1] - eye[1], target[2] - eye[2]]);
595
let up = [0.0, 1.0, 0.0];
596
let mut side = normalize3(cross(forward, up));
597
if side[0].abs() + side[1].abs() + side[2].abs() < 1e-4 {
598
side = [1.0, 0.0, 0.0];
599
}
600
let up = cross(side, forward);
601
[
602
side[0],
603
up[0],
604
-forward[0],
605
0.0,
606
side[1],
607
up[1],
608
-forward[1],
609
0.0,
610
side[2],
611
up[2],
612
-forward[2],
613
0.0,
614
-dot(side, eye),
615
-dot(up, eye),
616
dot(forward, eye),
617
1.0,
618
]
619
}
620
621
pub fn perspective(fov_y_deg: f32, aspect: f32, near: f32, far: f32) -> Mat4 {
622
let f = 1.0 / (fov_y_deg.to_radians() * 0.5).tan();
623
let aspect = if aspect.abs() < 1e-4 { 1.0 } else { aspect };
624
let nf = 1.0 / (near - far);
625
[
626
f / aspect,
627
0.0,
628
0.0,
629
0.0,
630
0.0,
631
f,
632
0.0,
633
0.0,
634
0.0,
635
0.0,
636
(far + near) * nf,
637
-1.0,
638
0.0,
639
0.0,
640
2.0 * far * near * nf,
641
0.0,
642
]
643
}
644
645
fn cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
646
[
647
a[1] * b[2] - a[2] * b[1],
648
a[2] * b[0] - a[0] * b[2],
649
a[0] * b[1] - a[1] * b[0],
650
]
651
}
652
653
fn dot(a: [f32; 3], b: [f32; 3]) -> f32 {
654
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
655
}
656
657
fn normalize3(value: [f32; 3]) -> [f32; 3] {
658
let length = dot(value, value).sqrt();
659
if length < 1e-8 {
660
[0.0, 0.0, 1.0]
661
} else {
662
[value[0] / length, value[1] / length, value[2] / length]
663
}
664
}
665
666
#[cfg(test)]
667
mod tests {
668
use super::*;
669
670
fn male() -> PreviewCharacter {
671
load_gltf_file(
672
&Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/bodies/test-body-male.glb"),
673
)
674
.unwrap()
675
}
676
677
fn distance(a: [f32; 3], b: [f32; 3]) -> f32 {
678
let d = [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
679
dot(d, d).sqrt()
680
}
681
682
#[test]
683
fn walk_and_attack_move_the_matching_bones() {
684
let character = male();
685
assert!(character.clips.iter().any(|clip| clip.name == "walk"));
686
assert!(character.clips.iter().any(|clip| clip.name == "attack"));
687
assert_eq!(character.joint_names.len(), 19);
688
assert!(character.indices.len() >= 3);
689
assert!(character.positions.len() >= 19 * 8);
690
let leg_a = joint_world_translation(&character, "walk", 0.0, "LeftLowerLeg").unwrap();
691
let leg_b = joint_world_translation(&character, "walk", 0.25, "LeftLowerLeg").unwrap();
692
assert!(
693
distance(leg_a, leg_b) > 0.05,
694
"walk should move the lower leg, delta {}",
695
distance(leg_a, leg_b)
696
);
697
let arm_a = joint_world_translation(&character, "attack", 0.0, "RightHand").unwrap();
698
let arm_b = joint_world_translation(&character, "attack", 0.35, "RightHand").unwrap();
699
assert!(
700
distance(arm_a, arm_b) > 0.05,
701
"attack should move the right hand, delta {}",
702
distance(arm_a, arm_b)
703
);
704
let matrices = sample_skin_matrices(&character, "walk", 0.2);
705
assert_eq!(matrices.len(), 19);
706
assert!(matrices.iter().flatten().all(|value| value.is_finite()));
707
}
708
709
#[test]
710
fn female_fixture_has_the_same_clip_names() {
711
let character = load_gltf_file(
712
&Path::new(env!("CARGO_MANIFEST_DIR"))
713
.join("tests/fixtures/bodies/test-body-female.glb"),
714
)
715
.unwrap();
716
assert!(character.clips.iter().any(|clip| clip.name == "walk"));
717
assert!(character.clips.iter().any(|clip| clip.name == "attack"));
718
let a = joint_world_translation(&character, "walk", 0.0, "RightLowerLeg").unwrap();
719
let b = joint_world_translation(&character, "walk", 0.5, "RightLowerLeg").unwrap();
720
assert!(distance(a, b) > 0.05);
721
}
722
723
#[test]
724
fn yaw_zero_is_in_front_and_the_twelve_yaws_differ() {
725
let front = eye_offset(0.0, 0.0, 4.0);
726
assert!(front[2] < -3.5, "yaw 0 camera belongs on -Z, got {front:?}");
727
assert!(front[0].abs() < 0.05);
728
let mut seen = Vec::new();
729
for yaw in paperdoll_yaws_deg() {
730
let eye = eye_offset(yaw, 0.0, 4.0);
731
assert!(seen
732
.iter()
733
.all(|previous: &[f32; 3]| distance(*previous, eye) > 0.5));
734
seen.push(eye);
735
}
736
assert_eq!(seen.len(), 12);
737
let low = eye_offset(0.0, -30.0, 4.0);
738
let high = eye_offset(0.0, 30.0, 4.0);
739
assert!(low[1] < 0.0 && high[1] > 0.0);
740
assert_eq!(PAPERDOLL_PITCHES_DEG.len(), 3);
741
}
742
743
#[test]
744
fn controls_snap_wrap_and_keep_a_clip_change() {
745
let mut controls = PreviewControls::default();
746
controls.drag(-100.0, 40.0);
747
assert!(controls.yaw_deg > 40.0);
748
assert!(controls.pitch_deg > 10.0);
749
controls.zoom(400.0);
750
assert!(controls.distance < 3.4);
751
controls.snap_yaw_index(-1);
752
assert!((controls.yaw_deg - 330.0).abs() < 0.01);
753
controls.snap_pitch_row(99);
754
assert!((controls.pitch_deg - 30.0).abs() < 0.01);
755
controls.snap_pitch_row(-4);
756
assert!((controls.pitch_deg + 30.0).abs() < 0.01);
757
controls.time = 0.4;
758
controls.select_clip("attack");
759
assert_eq!(controls.clip_name, "attack");
760
assert_eq!(controls.time, 0.0);
761
controls.time = 1.2;
762
controls.advance(0.0, 1.0);
763
controls.playing = true;
764
controls.advance(0.3, 1.0);
765
assert!((controls.time - 0.5).abs() < 0.02 || (wrap_time(1.5, 1.0) - 0.5).abs() < 0.02);
766
}
767
}
@@ -0,0 +1,767 @@1
//! CPU skeleton sampling and orbit camera for the live glTF preview.2
//!3
//! This is not the paperdoll export. The export camera is orthographic and4
//! still out of scope. The interactive camera can snap to the same three5
//! pitches and twelve yaws. The character forward axis of the test bodies is -Z,6
//! so yaw 0 places the camera on -Z, looking at the body.7
8
use std::path::Path;9
10
pub const PAPERDOLL_PITCHES_DEG: [f32; 3] = [-30.0, 0.0, 30.0];11
pub const PAPERDOLL_YAW_COUNT: usize = 12;12
pub const MAX_JOINTS: usize = 128;13
14
pub type Mat4 = [f32; 16];15
16
#[derive(Debug, Clone, Copy, PartialEq, Eq)]17
pub enum PreviewSex {18
Male,19
Female,20
}21
22
#[derive(Debug, Clone)]23
pub struct PreviewControls {24
pub sex: PreviewSex,25
pub clip_name: String,26
pub time: f32,27
pub playing: bool,28
pub yaw_deg: f32,29
pub pitch_deg: f32,30
pub distance: f32,31
}32
33
impl Default for PreviewControls {34
fn default() -> Self {35
Self {36
sex: PreviewSex::Male,37
clip_name: "walk".to_string(),38
time: 0.0,39
playing: true,40
yaw_deg: 0.0,41
pitch_deg: 0.0,42
distance: 3.4,43
}44
}45
}46
47
impl PreviewControls {48
pub fn drag(&mut self, dx_px: f32, dy_px: f32) {49
self.yaw_deg = wrap_deg(self.yaw_deg - dx_px * 0.45);50
self.pitch_deg = (self.pitch_deg + dy_px * 0.35).clamp(-80.0, 80.0);51
}52
53
pub fn zoom(&mut self, scroll_y: f32) {54
let factor = (-scroll_y * 0.0015).exp();55
self.distance = (self.distance * factor).clamp(0.8, 12.0);56
}57
58
pub fn snap_yaw_index(&mut self, index: i32) {59
let wrapped = index.rem_euclid(PAPERDOLL_YAW_COUNT as i32) as f32;60
self.yaw_deg = wrapped * 30.0;61
}62
63
pub fn snap_pitch_row(&mut self, row: i32) {64
let row = row.clamp(0, 2) as usize;65
self.pitch_deg = PAPERDOLL_PITCHES_DEG[row];66
}67
68
pub fn advance(&mut self, dt: f32, duration: f32) {69
if self.playing && duration > 0.0 {70
self.time = wrap_time(self.time + dt, duration);71
}72
}73
74
pub fn select_clip(&mut self, name: &str) {75
if self.clip_name != name {76
self.clip_name = name.to_string();77
self.time = 0.0;78
}79
}80
}81
82
#[derive(Debug, Clone)]83
pub struct PreviewCharacter {84
pub nodes: Vec<SceneNode>,85
pub joint_nodes: Vec<usize>,86
pub joint_names: Vec<String>,87
pub inverse_binds: Vec<Mat4>,88
pub positions: Vec<[f32; 3]>,89
pub normals: Vec<[f32; 3]>,90
pub joints: Vec<[u16; 4]>,91
pub weights: Vec<[f32; 4]>,92
pub indices: Vec<u32>,93
pub base_color: [f32; 4],94
pub clips: Vec<PreviewClip>,95
}96
97
#[derive(Debug, Clone)]98
pub struct SceneNode {99
pub name: String,100
pub parent: Option<usize>,101
pub translation: [f32; 3],102
pub rotation: [f32; 4],103
pub scale: [f32; 3],104
}105
106
#[derive(Debug, Clone)]107
pub struct PreviewClip {108
pub name: String,109
pub duration: f32,110
pub channels: Vec<AnimChannel>,111
}112
113
#[derive(Debug, Clone, Copy, PartialEq, Eq)]114
pub enum ChannelProperty {115
Translation,116
Rotation,117
Scale,118
}119
120
#[derive(Debug, Clone)]121
pub struct AnimChannel {122
pub node: usize,123
pub property: ChannelProperty,124
pub times: Vec<f32>,125
pub values: Vec<f32>,126
}127
128
pub fn load_gltf_file(path: &Path) -> Result<PreviewCharacter, String> {129
let (document, buffers, _images) = gltf::import(path).map_err(|err| err.to_string())?;130
let mut nodes = Vec::new();131
let mut parents = vec![None; document.nodes().len()];132
for node in document.nodes() {133
for child in node.children() {134
parents[child.index()] = Some(node.index());135
}136
}137
for node in document.nodes() {138
let (translation, rotation, scale) = node.transform().decomposed();139
nodes.push(SceneNode {140
name: node.name().unwrap_or("").to_string(),141
parent: parents[node.index()],142
translation,143
rotation,144
scale,145
});146
}147
let skin = document148
.skins()149
.next()150
.ok_or_else(|| "het glTF-bestand heeft geen skeleton".to_string())?;151
if skin.joints().len() > MAX_JOINTS {152
return Err(format!(153
"het skeleton heeft {} joints; de preview stopt bij {MAX_JOINTS}",154
skin.joints().len()155
));156
}157
let joint_nodes: Vec<usize> = skin.joints().map(|joint| joint.index()).collect();158
let joint_names: Vec<String> = joint_nodes159
.iter()160
.map(|index| nodes[*index].name.clone())161
.collect();162
let reader = skin.reader(|buffer| Some(buffers[buffer.index()].as_ref()));163
let inverse_binds = reader164
.read_inverse_bind_matrices()165
.map(|iter| iter.map(columns_to_mat).collect())166
.unwrap_or_else(|| vec![identity(); joint_nodes.len()]);167
if inverse_binds.len() != joint_nodes.len() {168
return Err("inverse-bindmatrices en joints verschillen".to_string());169
}170
let mut positions = Vec::new();171
let mut normals = Vec::new();172
let mut joints = Vec::new();173
let mut weights = Vec::new();174
let mut indices = Vec::new();175
let mut base_color = [0.7, 0.7, 0.75, 1.0];176
if let Some(mesh) = document.meshes().next() {177
for primitive in mesh.primitives() {178
let reader = primitive.reader(|buffer| Some(buffers[buffer.index()].as_ref()));179
let prim_positions: Vec<[f32; 3]> = reader180
.read_positions()181
.map(|iter| iter.collect())182
.unwrap_or_default();183
let prim_normals: Vec<[f32; 3]> = reader184
.read_normals()185
.map(|iter| iter.collect())186
.unwrap_or_else(|| vec![[0.0, 1.0, 0.0]; prim_positions.len()]);187
let prim_joints: Vec<[u16; 4]> = reader188
.read_joints(0)189
.map(|iter| iter.into_u16().collect())190
.unwrap_or_else(|| vec![[0, 0, 0, 0]; prim_positions.len()]);191
let prim_weights: Vec<[f32; 4]> = reader192
.read_weights(0)193
.map(|iter| iter.into_f32().collect())194
.unwrap_or_else(|| vec![[1.0, 0.0, 0.0, 0.0]; prim_positions.len()]);195
let base = positions.len() as u32;196
if let Some(read_indices) = reader.read_indices() {197
indices.extend(read_indices.into_u32().map(|index| index + base));198
} else {199
indices.extend(base..base + prim_positions.len() as u32);200
}201
positions.extend(prim_positions);202
normals.extend(prim_normals);203
joints.extend(prim_joints);204
weights.extend(prim_weights);205
base_color = primitive206
.material()207
.pbr_metallic_roughness()208
.base_color_factor();209
}210
}211
if positions.is_empty() || indices.len() < 3 {212
return Err("het glTF-bestand heeft geen tekenbare driehoeken".to_string());213
}214
let mut clips = Vec::new();215
for animation in document.animations() {216
let mut channels = Vec::new();217
let mut duration = 0.0_f32;218
for channel in animation.channels() {219
let reader = channel.reader(|buffer| Some(buffers[buffer.index()].as_ref()));220
let times: Vec<f32> = reader221
.read_inputs()222
.map(|iter| iter.collect())223
.unwrap_or_default();224
if let Some(last) = times.last() {225
duration = duration.max(*last);226
}227
let Some(outputs) = reader.read_outputs() else {228
continue;229
};230
let (property, values) = match outputs {231
gltf::animation::util::ReadOutputs::Translations(values) => {232
(ChannelProperty::Translation, values.flatten().collect())233
}234
gltf::animation::util::ReadOutputs::Scales(values) => {235
(ChannelProperty::Scale, values.flatten().collect())236
}237
gltf::animation::util::ReadOutputs::Rotations(values) => (238
ChannelProperty::Rotation,239
values.into_f32().flatten().collect(),240
),241
gltf::animation::util::ReadOutputs::MorphTargetWeights(_) => continue,242
};243
channels.push(AnimChannel {244
node: channel.target().node().index(),245
property,246
times,247
values,248
});249
}250
clips.push(PreviewClip {251
name: animation.name().unwrap_or("naamloos").to_string(),252
duration,253
channels,254
});255
}256
Ok(PreviewCharacter {257
nodes,258
joint_nodes,259
joint_names,260
inverse_binds,261
positions,262
normals,263
joints,264
weights,265
indices,266
base_color,267
clips,268
})269
}270
271
pub fn sample_skin_matrices(character: &PreviewCharacter, clip_name: &str, time: f32) -> Vec<Mat4> {272
let locals = sample_local_matrices(character, clip_name, time);273
let worlds = world_matrices(&character.nodes, &locals);274
character275
.joint_nodes276
.iter()277
.enumerate()278
.map(|(joint, node)| mul(worlds[*node], character.inverse_binds[joint]))279
.collect()280
}281
282
pub fn joint_world_translation(283
character: &PreviewCharacter,284
clip_name: &str,285
time: f32,286
joint_name: &str,287
) -> Option<[f32; 3]> {288
let joint = character289
.joint_names290
.iter()291
.position(|name| name == joint_name)?;292
let node = character.joint_nodes[joint];293
let locals = sample_local_matrices(character, clip_name, time);294
let worlds = world_matrices(&character.nodes, &locals);295
Some([worlds[node][12], worlds[node][13], worlds[node][14]])296
}297
298
pub fn clip_duration(character: &PreviewCharacter, clip_name: &str) -> f32 {299
character300
.clips301
.iter()302
.find(|clip| {303
clip.name == clip_name || clip.name.to_lowercase().contains(&clip_name.to_lowercase())304
})305
.map(|clip| clip.duration)306
.unwrap_or(0.0)307
}308
309
pub fn eye_offset(yaw_deg: f32, pitch_deg: f32, distance: f32) -> [f32; 3] {310
let yaw = yaw_deg.to_radians();311
let pitch = pitch_deg.to_radians();312
[313
-distance * pitch.cos() * yaw.sin(),314
distance * pitch.sin(),315
-distance * pitch.cos() * yaw.cos(),316
]317
}318
319
pub fn paperdoll_yaws_deg() -> [f32; PAPERDOLL_YAW_COUNT] {320
let mut yaws = [0.0; PAPERDOLL_YAW_COUNT];321
for (index, yaw) in yaws.iter_mut().enumerate() {322
*yaw = index as f32 * 30.0;323
}324
yaws325
}326
327
fn sample_local_matrices(character: &PreviewCharacter, clip_name: &str, time: f32) -> Vec<Mat4> {328
let mut translation: Vec<[f32; 3]> = character329
.nodes330
.iter()331
.map(|node| node.translation)332
.collect();333
let mut rotation: Vec<[f32; 4]> = character.nodes.iter().map(|node| node.rotation).collect();334
let mut scale: Vec<[f32; 3]> = character.nodes.iter().map(|node| node.scale).collect();335
if let Some(clip) = character.clips.iter().find(|clip| {336
clip.name == clip_name || clip.name.to_lowercase().contains(&clip_name.to_lowercase())337
}) {338
let time = wrap_time(time, clip.duration);339
for channel in &clip.channels {340
if channel.node >= character.nodes.len() || channel.times.is_empty() {341
continue;342
}343
let (left, right, blend) = key_blend(&channel.times, time);344
match channel.property {345
ChannelProperty::Translation => {346
translation[channel.node] = lerp3(347
key3(&channel.values, left),348
key3(&channel.values, right),349
blend,350
);351
}352
ChannelProperty::Scale => {353
scale[channel.node] = lerp3(354
key3(&channel.values, left),355
key3(&channel.values, right),356
blend,357
);358
}359
ChannelProperty::Rotation => {360
rotation[channel.node] = slerp(361
key4(&channel.values, left),362
key4(&channel.values, right),363
blend,364
);365
}366
}367
}368
}369
translation370
.into_iter()371
.zip(rotation)372
.zip(scale)373
.map(|((translation, rotation), scale)| trs(translation, rotation, scale))374
.collect()375
}376
377
fn world_matrices(nodes: &[SceneNode], locals: &[Mat4]) -> Vec<Mat4> {378
fn world(379
index: usize,380
nodes: &[SceneNode],381
locals: &[Mat4],382
worlds: &mut [Option<Mat4>],383
depth: usize,384
) -> Mat4 {385
if let Some(matrix) = worlds[index] {386
return matrix;387
}388
if depth > nodes.len() {389
return locals[index];390
}391
let matrix = match nodes[index].parent {392
Some(parent) => mul(393
world(parent, nodes, locals, worlds, depth + 1),394
locals[index],395
),396
None => locals[index],397
};398
worlds[index] = Some(matrix);399
matrix400
}401
let mut cached = vec![None; nodes.len()];402
(0..nodes.len())403
.map(|index| world(index, nodes, locals, &mut cached, 0))404
.collect()405
}406
407
fn key_blend(times: &[f32], time: f32) -> (usize, usize, f32) {408
if times.len() == 1 || time <= times[0] {409
return (0, 0, 0.0);410
}411
if time >= times[times.len() - 1] {412
let last = times.len() - 1;413
return (last, last, 0.0);414
}415
let mut index = 0;416
while index + 1 < times.len() && times[index + 1] < time {417
index += 1;418
}419
let span = times[index + 1] - times[index];420
let blend = if span.abs() < 1e-6 {421
0.0422
} else {423
(time - times[index]) / span424
};425
(index, index + 1, blend)426
}427
428
fn key3(values: &[f32], index: usize) -> [f32; 3] {429
let start = index * 3;430
[431
values.get(start).copied().unwrap_or(0.0),432
values.get(start + 1).copied().unwrap_or(0.0),433
values.get(start + 2).copied().unwrap_or(0.0),434
]435
}436
437
fn key4(values: &[f32], index: usize) -> [f32; 4] {438
let start = index * 4;439
[440
values.get(start).copied().unwrap_or(0.0),441
values.get(start + 1).copied().unwrap_or(0.0),442
values.get(start + 2).copied().unwrap_or(0.0),443
values.get(start + 3).copied().unwrap_or(1.0),444
]445
}446
447
fn lerp3(a: [f32; 3], b: [f32; 3], t: f32) -> [f32; 3] {448
[449
a[0] + (b[0] - a[0]) * t,450
a[1] + (b[1] - a[1]) * t,451
a[2] + (b[2] - a[2]) * t,452
]453
}454
455
fn slerp(a: [f32; 4], mut b: [f32; 4], t: f32) -> [f32; 4] {456
let mut dot = a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3];457
if dot < 0.0 {458
dot = -dot;459
b = [-b[0], -b[1], -b[2], -b[3]];460
}461
if dot > 0.9995 {462
return normalize4([463
a[0] + (b[0] - a[0]) * t,464
a[1] + (b[1] - a[1]) * t,465
a[2] + (b[2] - a[2]) * t,466
a[3] + (b[3] - a[3]) * t,467
]);468
}469
let theta = dot.clamp(-1.0, 1.0).acos();470
let sin_theta = theta.sin();471
let w1 = ((1.0 - t) * theta).sin() / sin_theta;472
let w2 = (t * theta).sin() / sin_theta;473
[474
a[0] * w1 + b[0] * w2,475
a[1] * w1 + b[1] * w2,476
a[2] * w1 + b[2] * w2,477
a[3] * w1 + b[3] * w2,478
]479
}480
481
fn normalize4(value: [f32; 4]) -> [f32; 4] {482
let length =483
(value[0] * value[0] + value[1] * value[1] + value[2] * value[2] + value[3] * value[3])484
.sqrt();485
if length < 1e-8 {486
[0.0, 0.0, 0.0, 1.0]487
} else {488
[489
value[0] / length,490
value[1] / length,491
value[2] / length,492
value[3] / length,493
]494
}495
}496
497
fn trs(translation: [f32; 3], rotation: [f32; 4], scale: [f32; 3]) -> Mat4 {498
let mut matrix = quat_to_mat(normalize4(rotation));499
matrix[0] *= scale[0];500
matrix[1] *= scale[0];501
matrix[2] *= scale[0];502
matrix[4] *= scale[1];503
matrix[5] *= scale[1];504
matrix[6] *= scale[1];505
matrix[8] *= scale[2];506
matrix[9] *= scale[2];507
matrix[10] *= scale[2];508
matrix[12] = translation[0];509
matrix[13] = translation[1];510
matrix[14] = translation[2];511
matrix512
}513
514
fn quat_to_mat(q: [f32; 4]) -> Mat4 {515
let (x, y, z, w) = (q[0], q[1], q[2], q[3]);516
let xx = x * x;517
let yy = y * y;518
let zz = z * z;519
let xy = x * y;520
let xz = x * z;521
let yz = y * z;522
let wx = w * x;523
let wy = w * y;524
let wz = w * z;525
[526
1.0 - 2.0 * (yy + zz),527
2.0 * (xy + wz),528
2.0 * (xz - wy),529
0.0,530
2.0 * (xy - wz),531
1.0 - 2.0 * (xx + zz),532
2.0 * (yz + wx),533
0.0,534
2.0 * (xz + wy),535
2.0 * (yz - wx),536
1.0 - 2.0 * (xx + yy),537
0.0,538
0.0,539
0.0,540
0.0,541
1.0,542
]543
}544
545
pub fn identity() -> Mat4 {546
[547
1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,548
]549
}550
551
pub fn mul(left: Mat4, right: Mat4) -> Mat4 {552
let mut out = [0.0; 16];553
for column in 0..4 {554
for row in 0..4 {555
out[column * 4 + row] = left[row] * right[column * 4]556
+ left[4 + row] * right[column * 4 + 1]557
+ left[8 + row] * right[column * 4 + 2]558
+ left[12 + row] * right[column * 4 + 3];559
}560
}561
out562
}563
564
fn columns_to_mat(columns: [[f32; 4]; 4]) -> Mat4 {565
let mut matrix = [0.0; 16];566
for column in 0..4 {567
for row in 0..4 {568
matrix[column * 4 + row] = columns[column][row];569
}570
}571
matrix572
}573
574
pub fn wrap_time(time: f32, duration: f32) -> f32 {575
if duration <= 1e-5 {576
return 0.0;577
}578
let mut wrapped = time % duration;579
if wrapped < 0.0 {580
wrapped += duration;581
}582
wrapped583
}584
585
fn wrap_deg(mut degrees: f32) -> f32 {586
degrees %= 360.0;587
if degrees < 0.0 {588
degrees += 360.0;589
}590
degrees591
}592
593
pub fn view_matrix(eye: [f32; 3], target: [f32; 3]) -> Mat4 {594
let forward = normalize3([target[0] - eye[0], target[1] - eye[1], target[2] - eye[2]]);595
let up = [0.0, 1.0, 0.0];596
let mut side = normalize3(cross(forward, up));597
if side[0].abs() + side[1].abs() + side[2].abs() < 1e-4 {598
side = [1.0, 0.0, 0.0];599
}600
let up = cross(side, forward);601
[602
side[0],603
up[0],604
-forward[0],605
0.0,606
side[1],607
up[1],608
-forward[1],609
0.0,610
side[2],611
up[2],612
-forward[2],613
0.0,614
-dot(side, eye),615
-dot(up, eye),616
dot(forward, eye),617
1.0,618
]619
}620
621
pub fn perspective(fov_y_deg: f32, aspect: f32, near: f32, far: f32) -> Mat4 {622
let f = 1.0 / (fov_y_deg.to_radians() * 0.5).tan();623
let aspect = if aspect.abs() < 1e-4 { 1.0 } else { aspect };624
let nf = 1.0 / (near - far);625
[626
f / aspect,627
0.0,628
0.0,629
0.0,630
0.0,631
f,632
0.0,633
0.0,634
0.0,635
0.0,636
(far + near) * nf,637
-1.0,638
0.0,639
0.0,640
2.0 * far * near * nf,641
0.0,642
]643
}644
645
fn cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {646
[647
a[1] * b[2] - a[2] * b[1],648
a[2] * b[0] - a[0] * b[2],649
a[0] * b[1] - a[1] * b[0],650
]651
}652
653
fn dot(a: [f32; 3], b: [f32; 3]) -> f32 {654
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]655
}656
657
fn normalize3(value: [f32; 3]) -> [f32; 3] {658
let length = dot(value, value).sqrt();659
if length < 1e-8 {660
[0.0, 0.0, 1.0]661
} else {662
[value[0] / length, value[1] / length, value[2] / length]663
}664
}665
666
#[cfg(test)]667
mod tests {668
use super::*;669
670
fn male() -> PreviewCharacter {671
load_gltf_file(672
&Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/bodies/test-body-male.glb"),673
)674
.unwrap()675
}676
677
fn distance(a: [f32; 3], b: [f32; 3]) -> f32 {678
let d = [a[0] - b[0], a[1] - b[1], a[2] - b[2]];679
dot(d, d).sqrt()680
}681
682
#[test]683
fn walk_and_attack_move_the_matching_bones() {684
let character = male();685
assert!(character.clips.iter().any(|clip| clip.name == "walk"));686
assert!(character.clips.iter().any(|clip| clip.name == "attack"));687
assert_eq!(character.joint_names.len(), 19);688
assert!(character.indices.len() >= 3);689
assert!(character.positions.len() >= 19 * 8);690
let leg_a = joint_world_translation(&character, "walk", 0.0, "LeftLowerLeg").unwrap();691
let leg_b = joint_world_translation(&character, "walk", 0.25, "LeftLowerLeg").unwrap();692
assert!(693
distance(leg_a, leg_b) > 0.05,694
"walk should move the lower leg, delta {}",695
distance(leg_a, leg_b)696
);697
let arm_a = joint_world_translation(&character, "attack", 0.0, "RightHand").unwrap();698
let arm_b = joint_world_translation(&character, "attack", 0.35, "RightHand").unwrap();699
assert!(700
distance(arm_a, arm_b) > 0.05,701
"attack should move the right hand, delta {}",702
distance(arm_a, arm_b)703
);704
let matrices = sample_skin_matrices(&character, "walk", 0.2);705
assert_eq!(matrices.len(), 19);706
assert!(matrices.iter().flatten().all(|value| value.is_finite()));707
}708
709
#[test]710
fn female_fixture_has_the_same_clip_names() {711
let character = load_gltf_file(712
&Path::new(env!("CARGO_MANIFEST_DIR"))713
.join("tests/fixtures/bodies/test-body-female.glb"),714
)715
.unwrap();716
assert!(character.clips.iter().any(|clip| clip.name == "walk"));717
assert!(character.clips.iter().any(|clip| clip.name == "attack"));718
let a = joint_world_translation(&character, "walk", 0.0, "RightLowerLeg").unwrap();719
let b = joint_world_translation(&character, "walk", 0.5, "RightLowerLeg").unwrap();720
assert!(distance(a, b) > 0.05);721
}722
723
#[test]724
fn yaw_zero_is_in_front_and_the_twelve_yaws_differ() {725
let front = eye_offset(0.0, 0.0, 4.0);726
assert!(front[2] < -3.5, "yaw 0 camera belongs on -Z, got {front:?}");727
assert!(front[0].abs() < 0.05);728
let mut seen = Vec::new();729
for yaw in paperdoll_yaws_deg() {730
let eye = eye_offset(yaw, 0.0, 4.0);731
assert!(seen732
.iter()733
.all(|previous: &[f32; 3]| distance(*previous, eye) > 0.5));734
seen.push(eye);735
}736
assert_eq!(seen.len(), 12);737
let low = eye_offset(0.0, -30.0, 4.0);738
let high = eye_offset(0.0, 30.0, 4.0);739
assert!(low[1] < 0.0 && high[1] > 0.0);740
assert_eq!(PAPERDOLL_PITCHES_DEG.len(), 3);741
}742
743
#[test]744
fn controls_snap_wrap_and_keep_a_clip_change() {745
let mut controls = PreviewControls::default();746
controls.drag(-100.0, 40.0);747
assert!(controls.yaw_deg > 40.0);748
assert!(controls.pitch_deg > 10.0);749
controls.zoom(400.0);750
assert!(controls.distance < 3.4);751
controls.snap_yaw_index(-1);752
assert!((controls.yaw_deg - 330.0).abs() < 0.01);753
controls.snap_pitch_row(99);754
assert!((controls.pitch_deg - 30.0).abs() < 0.01);755
controls.snap_pitch_row(-4);756
assert!((controls.pitch_deg + 30.0).abs() < 0.01);757
controls.time = 0.4;758
controls.select_clip("attack");759
assert_eq!(controls.clip_name, "attack");760
assert_eq!(controls.time, 0.0);761
controls.time = 1.2;762
controls.advance(0.0, 1.0);763
controls.playing = true;764
controls.advance(0.3, 1.0);765
assert!((controls.time - 0.5).abs() < 0.02 || (wrap_time(1.5, 1.0) - 0.5).abs() < 0.02);766
}767
}@@ -0,0 +1,386 @@
1
//! OpenGL viewport for one skinned glTF body.
2
//!
3
//! The shaded mesh is a placement preview. It is not the unlit paperdoll
4
//! diffuse/depth/normal export.
5
6
use std::sync::{Arc, Mutex};
7
8
use eframe::egui;
9
use egui_glow::CallbackFn;
10
use glow::HasContext;
11
12
use crate::preview::{
13
clip_duration, eye_offset, mul, perspective, sample_skin_matrices, view_matrix,
14
PreviewCharacter, PreviewControls, PAPERDOLL_PITCHES_DEG, PAPERDOLL_YAW_COUNT,
15
};
16
17
const MAX_BONES: usize = crate::preview::MAX_JOINTS;
18
19
pub struct Viewport {
20
cache: Arc<Mutex<GpuCache>>,
21
}
22
23
impl Default for Viewport {
24
fn default() -> Self {
25
Self::new()
26
}
27
}
28
29
impl Viewport {
30
pub fn new() -> Self {
31
Self {
32
cache: Arc::new(Mutex::new(GpuCache::default())),
33
}
34
}
35
36
pub fn show(
37
&self,
38
ui: &mut egui::Ui,
39
controls: &mut PreviewControls,
40
character: Option<&PreviewCharacter>,
41
) {
42
ui.horizontal_wrapped(|ui| {
43
if ui
44
.selectable_label(controls.sex == crate::preview::PreviewSex::Male, "Man")
45
.clicked()
46
{
47
controls.sex = crate::preview::PreviewSex::Male;
48
}
49
if ui
50
.selectable_label(controls.sex == crate::preview::PreviewSex::Female, "Vrouw")
51
.clicked()
52
{
53
controls.sex = crate::preview::PreviewSex::Female;
54
}
55
if ui.button("walk").clicked() {
56
controls.select_clip("walk");
57
}
58
if ui.button("attack").clicked() {
59
controls.select_clip("attack");
60
}
61
if ui
62
.selectable_label(
63
controls.playing,
64
if controls.playing { "Pauze" } else { "Speel" },
65
)
66
.clicked()
67
{
68
controls.playing = !controls.playing;
69
}
70
if ui.button("Yaw −").clicked() {
71
controls.snap_yaw_index((controls.yaw_deg / 30.0).round() as i32 - 1);
72
}
73
if ui.button("Yaw +").clicked() {
74
controls.snap_yaw_index((controls.yaw_deg / 30.0).round() as i32 + 1);
75
}
76
for (index, pitch) in PAPERDOLL_PITCHES_DEG.iter().enumerate() {
77
if ui.button(format!("pitch {pitch:.0}")).clicked() {
78
controls.snap_pitch_row(index as i32);
79
}
80
}
81
ui.label(format!(
82
"yaw {:.0}° / {} pitch {:.0}°",
83
controls.yaw_deg, PAPERDOLL_YAW_COUNT, controls.pitch_deg
84
));
85
});
86
ui.label(
87
"Sleep om te draaien, scroll om te zoomen. Yaw 0 kijkt op de voorkant (−Z). De knoppen zetten de drie pitches en de twaalf yaws.",
88
);
89
let available = ui.available_rect_before_wrap();
90
let rect = egui::Rect::from_min_size(
91
available.min,
92
egui::vec2(available.width().max(240.0), available.height().max(240.0)),
93
);
94
let response = ui.allocate_rect(rect, egui::Sense::click_and_drag());
95
if response.dragged() {
96
controls.drag(response.drag_delta().x, response.drag_delta().y);
97
}
98
let scroll = ui.input(|input| input.smooth_scroll_delta.y);
99
if response.hovered() && scroll.abs() > 0.0 {
100
controls.zoom(scroll);
101
}
102
let Some(character) = character else {
103
ui.painter()
104
.rect_filled(rect, 4.0, egui::Color32::from_rgb(18, 22, 28));
105
ui.painter().text(
106
rect.center(),
107
egui::Align2::CENTER_CENTER,
108
"Geen glTF geladen. Open een project met een .glb of start met --preview-fixture.",
109
egui::FontId::proportional(14.0),
110
egui::Color32::from_rgb(210, 210, 210),
111
);
112
return;
113
};
114
let duration = clip_duration(character, &controls.clip_name);
115
let dt = ui.input(|input| input.stable_dt);
116
controls.advance(dt, duration);
117
ui.ctx().request_repaint();
118
let bones = sample_skin_matrices(character, &controls.clip_name, controls.time);
119
let aspect = rect.width() / rect.height().max(1.0);
120
let target = [0.0, 0.9, 0.0];
121
let offset = eye_offset(controls.yaw_deg, controls.pitch_deg, controls.distance);
122
let eye = [
123
target[0] + offset[0],
124
target[1] + offset[1],
125
target[2] + offset[2],
126
];
127
let view_proj = mul(
128
perspective(38.0, aspect, 0.05, 40.0),
129
view_matrix(eye, target),
130
);
131
{
132
let mut cache = self.cache.lock().expect("preview gpu lock");
133
cache.pending = Some(PendingFrame {
134
generation: character_generation(character),
135
positions: character.positions.clone(),
136
normals: character.normals.clone(),
137
joints: character.joints.clone(),
138
weights: character.weights.clone(),
139
indices: character.indices.clone(),
140
bones,
141
view_proj,
142
color: character.base_color,
143
});
144
}
145
let cache = Arc::clone(&self.cache);
146
let callback = CallbackFn::new(move |info, painter| {
147
if let Ok(mut cache) = cache.lock() {
148
unsafe { cache.paint(painter.gl(), &info) };
149
}
150
});
151
ui.painter().add(egui::PaintCallback {
152
rect,
153
callback: Arc::new(callback),
154
});
155
}
156
}
157
158
fn character_generation(character: &PreviewCharacter) -> u64 {
159
character.positions.len() as u64
160
^ ((character.indices.len() as u64) << 16)
161
^ ((character.base_color[0].to_bits() as u64) << 32)
162
}
163
164
#[derive(Clone)]
165
struct PendingFrame {
166
generation: u64,
167
positions: Vec<[f32; 3]>,
168
normals: Vec<[f32; 3]>,
169
joints: Vec<[u16; 4]>,
170
weights: Vec<[f32; 4]>,
171
indices: Vec<u32>,
172
bones: Vec<crate::preview::Mat4>,
173
view_proj: crate::preview::Mat4,
174
color: [f32; 4],
175
}
176
177
#[derive(Default)]
178
struct GpuCache {
179
program: Option<glow::Program>,
180
vao: Option<glow::VertexArray>,
181
vbo: Option<glow::Buffer>,
182
ebo: Option<glow::Buffer>,
183
uploaded: u64,
184
index_count: i32,
185
pending: Option<PendingFrame>,
186
error: Option<String>,
187
}
188
189
impl GpuCache {
190
unsafe fn paint(&mut self, gl: &glow::Context, info: &egui::PaintCallbackInfo) {
191
let Some(frame) = self.pending.clone() else {
192
return;
193
};
194
if let Err(error) = self.ensure_program(gl) {
195
self.error = Some(error);
196
return;
197
}
198
if self.uploaded != frame.generation {
199
if let Err(error) = self.upload(gl, &frame) {
200
self.error = Some(error);
201
return;
202
}
203
}
204
let Some(program) = self.program else {
205
return;
206
};
207
let viewport = info.viewport_in_pixels();
208
if viewport.width_px <= 0 || viewport.height_px <= 0 {
209
return;
210
}
211
gl.enable(glow::SCISSOR_TEST);
212
gl.scissor(
213
viewport.left_px,
214
viewport.from_bottom_px,
215
viewport.width_px,
216
viewport.height_px,
217
);
218
gl.viewport(
219
viewport.left_px,
220
viewport.from_bottom_px,
221
viewport.width_px,
222
viewport.height_px,
223
);
224
gl.enable(glow::DEPTH_TEST);
225
gl.depth_func(glow::LESS);
226
gl.disable(glow::BLEND);
227
gl.clear_color(0.08, 0.09, 0.12, 1.0);
228
gl.clear(glow::COLOR_BUFFER_BIT | glow::DEPTH_BUFFER_BIT);
229
gl.use_program(Some(program));
230
let view = gl.get_uniform_location(program, "u_view_proj");
231
gl.uniform_matrix_4_f32_slice(view.as_ref(), false, &frame.view_proj);
232
let color = gl.get_uniform_location(program, "u_color");
233
gl.uniform_4_f32(
234
color.as_ref(),
235
frame.color[0],
236
frame.color[1],
237
frame.color[2],
238
frame.color[3],
239
);
240
let mut bones = vec![crate::preview::identity(); MAX_BONES];
241
for (index, bone) in frame.bones.iter().take(MAX_BONES).enumerate() {
242
bones[index] = *bone;
243
}
244
let flat: Vec<f32> = bones.iter().flat_map(|bone| *bone).collect();
245
let bones_loc = gl.get_uniform_location(program, "u_bones");
246
gl.uniform_matrix_4_f32_slice(bones_loc.as_ref(), false, &flat);
247
gl.bind_vertex_array(self.vao);
248
gl.draw_elements(glow::TRIANGLES, self.index_count, glow::UNSIGNED_INT, 0);
249
gl.bind_vertex_array(None);
250
gl.disable(glow::DEPTH_TEST);
251
gl.disable(glow::SCISSOR_TEST);
252
gl.enable(glow::BLEND);
253
}
254
255
unsafe fn ensure_program(&mut self, gl: &glow::Context) -> Result<(), String> {
256
if self.program.is_some() {
257
return Ok(());
258
}
259
let program = link(gl, VERTEX, FRAGMENT)?;
260
let vao = gl.create_vertex_array().map_err(|err| err.to_string())?;
261
let vbo = gl.create_buffer().map_err(|err| err.to_string())?;
262
let ebo = gl.create_buffer().map_err(|err| err.to_string())?;
263
self.program = Some(program);
264
self.vao = Some(vao);
265
self.vbo = Some(vbo);
266
self.ebo = Some(ebo);
267
Ok(())
268
}
269
270
unsafe fn upload(&mut self, gl: &glow::Context, frame: &PendingFrame) -> Result<(), String> {
271
let mut interleaved = Vec::with_capacity(frame.positions.len() * 14);
272
for index in 0..frame.positions.len() {
273
let position = frame.positions[index];
274
let normal = frame.normals.get(index).copied().unwrap_or([0.0, 1.0, 0.0]);
275
let joint = frame.joints.get(index).copied().unwrap_or([0, 0, 0, 0]);
276
let weight = frame
277
.weights
278
.get(index)
279
.copied()
280
.unwrap_or([1.0, 0.0, 0.0, 0.0]);
281
interleaved.extend_from_slice(&position);
282
interleaved.extend_from_slice(&normal);
283
interleaved.extend([
284
joint[0] as f32,
285
joint[1] as f32,
286
joint[2] as f32,
287
joint[3] as f32,
288
]);
289
interleaved.extend_from_slice(&weight);
290
}
291
gl.bind_vertex_array(self.vao);
292
gl.bind_buffer(glow::ARRAY_BUFFER, self.vbo);
293
gl.buffer_data_u8_slice(
294
glow::ARRAY_BUFFER,
295
bytemuck_f32(&interleaved),
296
glow::DYNAMIC_DRAW,
297
);
298
gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, self.ebo);
299
gl.buffer_data_u8_slice(
300
glow::ELEMENT_ARRAY_BUFFER,
301
bytemuck_u32(&frame.indices),
302
glow::DYNAMIC_DRAW,
303
);
304
let stride = 14 * 4;
305
enable_float(gl, 0, 3, stride, 0);
306
enable_float(gl, 1, 3, stride, 3 * 4);
307
enable_float(gl, 2, 4, stride, 6 * 4);
308
enable_float(gl, 3, 4, stride, 10 * 4);
309
self.uploaded = frame.generation;
310
self.index_count = frame.indices.len() as i32;
311
Ok(())
312
}
313
}
314
315
unsafe fn enable_float(gl: &glow::Context, index: u32, size: i32, stride: i32, offset: i32) {
316
gl.enable_vertex_attrib_array(index);
317
gl.vertex_attrib_pointer_f32(index, size, glow::FLOAT, false, stride, offset);
318
}
319
320
fn bytemuck_f32(values: &[f32]) -> &[u8] {
321
unsafe { std::slice::from_raw_parts(values.as_ptr().cast(), std::mem::size_of_val(values)) }
322
}
323
324
fn bytemuck_u32(values: &[u32]) -> &[u8] {
325
unsafe { std::slice::from_raw_parts(values.as_ptr().cast(), std::mem::size_of_val(values)) }
326
}
327
328
unsafe fn link(gl: &glow::Context, vertex: &str, fragment: &str) -> Result<glow::Program, String> {
329
let program = gl.create_program().map_err(|err| err.to_string())?;
330
let vs = compile(gl, glow::VERTEX_SHADER, vertex)?;
331
let fs = compile(gl, glow::FRAGMENT_SHADER, fragment)?;
332
gl.attach_shader(program, vs);
333
gl.attach_shader(program, fs);
334
gl.link_program(program);
335
if !gl.get_program_link_status(program) {
336
return Err(gl.get_program_info_log(program));
337
}
338
gl.delete_shader(vs);
339
gl.delete_shader(fs);
340
Ok(program)
341
}
342
343
unsafe fn compile(gl: &glow::Context, stage: u32, source: &str) -> Result<glow::Shader, String> {
344
let shader = gl.create_shader(stage).map_err(|err| err.to_string())?;
345
gl.shader_source(shader, source);
346
gl.compile_shader(shader);
347
if !gl.get_shader_compile_status(shader) {
348
return Err(gl.get_shader_info_log(shader));
349
}
350
Ok(shader)
351
}
352
353
const VERTEX: &str = r#"#version 330
354
layout(location = 0) in vec3 a_pos;
355
layout(location = 1) in vec3 a_normal;
356
layout(location = 2) in vec4 a_joints;
357
layout(location = 3) in vec4 a_weights;
358
uniform mat4 u_view_proj;
359
uniform mat4 u_bones[128];
360
out vec3 v_normal;
361
void main() {
362
int j0 = int(a_joints.x);
363
int j1 = int(a_joints.y);
364
int j2 = int(a_joints.z);
365
int j3 = int(a_joints.w);
366
mat4 skin = a_weights.x * u_bones[j0]
367
+ a_weights.y * u_bones[j1]
368
+ a_weights.z * u_bones[j2]
369
+ a_weights.w * u_bones[j3];
370
vec4 world = skin * vec4(a_pos, 1.0);
371
v_normal = mat3(skin) * a_normal;
372
gl_Position = u_view_proj * world;
373
}
374
"#;
375
376
const FRAGMENT: &str = r#"#version 330
377
in vec3 v_normal;
378
uniform vec4 u_color;
379
out vec4 frag;
380
void main() {
381
vec3 n = normalize(v_normal);
382
vec3 light = normalize(vec3(0.35, 0.85, 0.4));
383
float shade = 0.28 + 0.72 * max(dot(n, light), 0.0);
384
frag = vec4(u_color.rgb * shade, 1.0);
385
}
386
"#;
@@ -0,0 +1,386 @@1
//! OpenGL viewport for one skinned glTF body.2
//!3
//! The shaded mesh is a placement preview. It is not the unlit paperdoll4
//! diffuse/depth/normal export.5
6
use std::sync::{Arc, Mutex};7
8
use eframe::egui;9
use egui_glow::CallbackFn;10
use glow::HasContext;11
12
use crate::preview::{13
clip_duration, eye_offset, mul, perspective, sample_skin_matrices, view_matrix,14
PreviewCharacter, PreviewControls, PAPERDOLL_PITCHES_DEG, PAPERDOLL_YAW_COUNT,15
};16
17
const MAX_BONES: usize = crate::preview::MAX_JOINTS;18
19
pub struct Viewport {20
cache: Arc<Mutex<GpuCache>>,21
}22
23
impl Default for Viewport {24
fn default() -> Self {25
Self::new()26
}27
}28
29
impl Viewport {30
pub fn new() -> Self {31
Self {32
cache: Arc::new(Mutex::new(GpuCache::default())),33
}34
}35
36
pub fn show(37
&self,38
ui: &mut egui::Ui,39
controls: &mut PreviewControls,40
character: Option<&PreviewCharacter>,41
) {42
ui.horizontal_wrapped(|ui| {43
if ui44
.selectable_label(controls.sex == crate::preview::PreviewSex::Male, "Man")45
.clicked()46
{47
controls.sex = crate::preview::PreviewSex::Male;48
}49
if ui50
.selectable_label(controls.sex == crate::preview::PreviewSex::Female, "Vrouw")51
.clicked()52
{53
controls.sex = crate::preview::PreviewSex::Female;54
}55
if ui.button("walk").clicked() {56
controls.select_clip("walk");57
}58
if ui.button("attack").clicked() {59
controls.select_clip("attack");60
}61
if ui62
.selectable_label(63
controls.playing,64
if controls.playing { "Pauze" } else { "Speel" },65
)66
.clicked()67
{68
controls.playing = !controls.playing;69
}70
if ui.button("Yaw −").clicked() {71
controls.snap_yaw_index((controls.yaw_deg / 30.0).round() as i32 - 1);72
}73
if ui.button("Yaw +").clicked() {74
controls.snap_yaw_index((controls.yaw_deg / 30.0).round() as i32 + 1);75
}76
for (index, pitch) in PAPERDOLL_PITCHES_DEG.iter().enumerate() {77
if ui.button(format!("pitch {pitch:.0}")).clicked() {78
controls.snap_pitch_row(index as i32);79
}80
}81
ui.label(format!(82
"yaw {:.0}° / {} pitch {:.0}°",83
controls.yaw_deg, PAPERDOLL_YAW_COUNT, controls.pitch_deg84
));85
});86
ui.label(87
"Sleep om te draaien, scroll om te zoomen. Yaw 0 kijkt op de voorkant (−Z). De knoppen zetten de drie pitches en de twaalf yaws.",88
);89
let available = ui.available_rect_before_wrap();90
let rect = egui::Rect::from_min_size(91
available.min,92
egui::vec2(available.width().max(240.0), available.height().max(240.0)),93
);94
let response = ui.allocate_rect(rect, egui::Sense::click_and_drag());95
if response.dragged() {96
controls.drag(response.drag_delta().x, response.drag_delta().y);97
}98
let scroll = ui.input(|input| input.smooth_scroll_delta.y);99
if response.hovered() && scroll.abs() > 0.0 {100
controls.zoom(scroll);101
}102
let Some(character) = character else {103
ui.painter()104
.rect_filled(rect, 4.0, egui::Color32::from_rgb(18, 22, 28));105
ui.painter().text(106
rect.center(),107
egui::Align2::CENTER_CENTER,108
"Geen glTF geladen. Open een project met een .glb of start met --preview-fixture.",109
egui::FontId::proportional(14.0),110
egui::Color32::from_rgb(210, 210, 210),111
);112
return;113
};114
let duration = clip_duration(character, &controls.clip_name);115
let dt = ui.input(|input| input.stable_dt);116
controls.advance(dt, duration);117
ui.ctx().request_repaint();118
let bones = sample_skin_matrices(character, &controls.clip_name, controls.time);119
let aspect = rect.width() / rect.height().max(1.0);120
let target = [0.0, 0.9, 0.0];121
let offset = eye_offset(controls.yaw_deg, controls.pitch_deg, controls.distance);122
let eye = [123
target[0] + offset[0],124
target[1] + offset[1],125
target[2] + offset[2],126
];127
let view_proj = mul(128
perspective(38.0, aspect, 0.05, 40.0),129
view_matrix(eye, target),130
);131
{132
let mut cache = self.cache.lock().expect("preview gpu lock");133
cache.pending = Some(PendingFrame {134
generation: character_generation(character),135
positions: character.positions.clone(),136
normals: character.normals.clone(),137
joints: character.joints.clone(),138
weights: character.weights.clone(),139
indices: character.indices.clone(),140
bones,141
view_proj,142
color: character.base_color,143
});144
}145
let cache = Arc::clone(&self.cache);146
let callback = CallbackFn::new(move |info, painter| {147
if let Ok(mut cache) = cache.lock() {148
unsafe { cache.paint(painter.gl(), &info) };149
}150
});151
ui.painter().add(egui::PaintCallback {152
rect,153
callback: Arc::new(callback),154
});155
}156
}157
158
fn character_generation(character: &PreviewCharacter) -> u64 {159
character.positions.len() as u64160
^ ((character.indices.len() as u64) << 16)161
^ ((character.base_color[0].to_bits() as u64) << 32)162
}163
164
#[derive(Clone)]165
struct PendingFrame {166
generation: u64,167
positions: Vec<[f32; 3]>,168
normals: Vec<[f32; 3]>,169
joints: Vec<[u16; 4]>,170
weights: Vec<[f32; 4]>,171
indices: Vec<u32>,172
bones: Vec<crate::preview::Mat4>,173
view_proj: crate::preview::Mat4,174
color: [f32; 4],175
}176
177
#[derive(Default)]178
struct GpuCache {179
program: Option<glow::Program>,180
vao: Option<glow::VertexArray>,181
vbo: Option<glow::Buffer>,182
ebo: Option<glow::Buffer>,183
uploaded: u64,184
index_count: i32,185
pending: Option<PendingFrame>,186
error: Option<String>,187
}188
189
impl GpuCache {190
unsafe fn paint(&mut self, gl: &glow::Context, info: &egui::PaintCallbackInfo) {191
let Some(frame) = self.pending.clone() else {192
return;193
};194
if let Err(error) = self.ensure_program(gl) {195
self.error = Some(error);196
return;197
}198
if self.uploaded != frame.generation {199
if let Err(error) = self.upload(gl, &frame) {200
self.error = Some(error);201
return;202
}203
}204
let Some(program) = self.program else {205
return;206
};207
let viewport = info.viewport_in_pixels();208
if viewport.width_px <= 0 || viewport.height_px <= 0 {209
return;210
}211
gl.enable(glow::SCISSOR_TEST);212
gl.scissor(213
viewport.left_px,214
viewport.from_bottom_px,215
viewport.width_px,216
viewport.height_px,217
);218
gl.viewport(219
viewport.left_px,220
viewport.from_bottom_px,221
viewport.width_px,222
viewport.height_px,223
);224
gl.enable(glow::DEPTH_TEST);225
gl.depth_func(glow::LESS);226
gl.disable(glow::BLEND);227
gl.clear_color(0.08, 0.09, 0.12, 1.0);228
gl.clear(glow::COLOR_BUFFER_BIT | glow::DEPTH_BUFFER_BIT);229
gl.use_program(Some(program));230
let view = gl.get_uniform_location(program, "u_view_proj");231
gl.uniform_matrix_4_f32_slice(view.as_ref(), false, &frame.view_proj);232
let color = gl.get_uniform_location(program, "u_color");233
gl.uniform_4_f32(234
color.as_ref(),235
frame.color[0],236
frame.color[1],237
frame.color[2],238
frame.color[3],239
);240
let mut bones = vec![crate::preview::identity(); MAX_BONES];241
for (index, bone) in frame.bones.iter().take(MAX_BONES).enumerate() {242
bones[index] = *bone;243
}244
let flat: Vec<f32> = bones.iter().flat_map(|bone| *bone).collect();245
let bones_loc = gl.get_uniform_location(program, "u_bones");246
gl.uniform_matrix_4_f32_slice(bones_loc.as_ref(), false, &flat);247
gl.bind_vertex_array(self.vao);248
gl.draw_elements(glow::TRIANGLES, self.index_count, glow::UNSIGNED_INT, 0);249
gl.bind_vertex_array(None);250
gl.disable(glow::DEPTH_TEST);251
gl.disable(glow::SCISSOR_TEST);252
gl.enable(glow::BLEND);253
}254
255
unsafe fn ensure_program(&mut self, gl: &glow::Context) -> Result<(), String> {256
if self.program.is_some() {257
return Ok(());258
}259
let program = link(gl, VERTEX, FRAGMENT)?;260
let vao = gl.create_vertex_array().map_err(|err| err.to_string())?;261
let vbo = gl.create_buffer().map_err(|err| err.to_string())?;262
let ebo = gl.create_buffer().map_err(|err| err.to_string())?;263
self.program = Some(program);264
self.vao = Some(vao);265
self.vbo = Some(vbo);266
self.ebo = Some(ebo);267
Ok(())268
}269
270
unsafe fn upload(&mut self, gl: &glow::Context, frame: &PendingFrame) -> Result<(), String> {271
let mut interleaved = Vec::with_capacity(frame.positions.len() * 14);272
for index in 0..frame.positions.len() {273
let position = frame.positions[index];274
let normal = frame.normals.get(index).copied().unwrap_or([0.0, 1.0, 0.0]);275
let joint = frame.joints.get(index).copied().unwrap_or([0, 0, 0, 0]);276
let weight = frame277
.weights278
.get(index)279
.copied()280
.unwrap_or([1.0, 0.0, 0.0, 0.0]);281
interleaved.extend_from_slice(&position);282
interleaved.extend_from_slice(&normal);283
interleaved.extend([284
joint[0] as f32,285
joint[1] as f32,286
joint[2] as f32,287
joint[3] as f32,288
]);289
interleaved.extend_from_slice(&weight);290
}291
gl.bind_vertex_array(self.vao);292
gl.bind_buffer(glow::ARRAY_BUFFER, self.vbo);293
gl.buffer_data_u8_slice(294
glow::ARRAY_BUFFER,295
bytemuck_f32(&interleaved),296
glow::DYNAMIC_DRAW,297
);298
gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, self.ebo);299
gl.buffer_data_u8_slice(300
glow::ELEMENT_ARRAY_BUFFER,301
bytemuck_u32(&frame.indices),302
glow::DYNAMIC_DRAW,303
);304
let stride = 14 * 4;305
enable_float(gl, 0, 3, stride, 0);306
enable_float(gl, 1, 3, stride, 3 * 4);307
enable_float(gl, 2, 4, stride, 6 * 4);308
enable_float(gl, 3, 4, stride, 10 * 4);309
self.uploaded = frame.generation;310
self.index_count = frame.indices.len() as i32;311
Ok(())312
}313
}314
315
unsafe fn enable_float(gl: &glow::Context, index: u32, size: i32, stride: i32, offset: i32) {316
gl.enable_vertex_attrib_array(index);317
gl.vertex_attrib_pointer_f32(index, size, glow::FLOAT, false, stride, offset);318
}319
320
fn bytemuck_f32(values: &[f32]) -> &[u8] {321
unsafe { std::slice::from_raw_parts(values.as_ptr().cast(), std::mem::size_of_val(values)) }322
}323
324
fn bytemuck_u32(values: &[u32]) -> &[u8] {325
unsafe { std::slice::from_raw_parts(values.as_ptr().cast(), std::mem::size_of_val(values)) }326
}327
328
unsafe fn link(gl: &glow::Context, vertex: &str, fragment: &str) -> Result<glow::Program, String> {329
let program = gl.create_program().map_err(|err| err.to_string())?;330
let vs = compile(gl, glow::VERTEX_SHADER, vertex)?;331
let fs = compile(gl, glow::FRAGMENT_SHADER, fragment)?;332
gl.attach_shader(program, vs);333
gl.attach_shader(program, fs);334
gl.link_program(program);335
if !gl.get_program_link_status(program) {336
return Err(gl.get_program_info_log(program));337
}338
gl.delete_shader(vs);339
gl.delete_shader(fs);340
Ok(program)341
}342
343
unsafe fn compile(gl: &glow::Context, stage: u32, source: &str) -> Result<glow::Shader, String> {344
let shader = gl.create_shader(stage).map_err(|err| err.to_string())?;345
gl.shader_source(shader, source);346
gl.compile_shader(shader);347
if !gl.get_shader_compile_status(shader) {348
return Err(gl.get_shader_info_log(shader));349
}350
Ok(shader)351
}352
353
const VERTEX: &str = r#"#version 330354
layout(location = 0) in vec3 a_pos;355
layout(location = 1) in vec3 a_normal;356
layout(location = 2) in vec4 a_joints;357
layout(location = 3) in vec4 a_weights;358
uniform mat4 u_view_proj;359
uniform mat4 u_bones[128];360
out vec3 v_normal;361
void main() {362
int j0 = int(a_joints.x);363
int j1 = int(a_joints.y);364
int j2 = int(a_joints.z);365
int j3 = int(a_joints.w);366
mat4 skin = a_weights.x * u_bones[j0]367
+ a_weights.y * u_bones[j1]368
+ a_weights.z * u_bones[j2]369
+ a_weights.w * u_bones[j3];370
vec4 world = skin * vec4(a_pos, 1.0);371
v_normal = mat3(skin) * a_normal;372
gl_Position = u_view_proj * world;373
}374
"#;375
376
const FRAGMENT: &str = r#"#version 330377
in vec3 v_normal;378
uniform vec4 u_color;379
out vec4 frag;380
void main() {381
vec3 n = normalize(v_normal);382
vec3 light = normalize(vec3(0.35, 0.85, 0.4));383
float shade = 0.28 + 0.72 * max(dot(n, light), 0.0);384
frag = vec4(u_color.rgb * shade, 1.0);385
}386
"#;