BlueGit

golden-age paperdoll-renderer

feat: show a live skinned glTF preview

Sample walk and attack on the test bodies and draw the mesh in the
project window. Orbit, zoom, and the three pitches and twelve yaws
control the camera. The README now describes only the current program.

Co-authored-by: tim <tim@timfalken.com>

timfa Cursor Agent · pushed by timfa

Authored 2026-11-06 · 7 days ago

Hash
228e617aad386ce7f83f31a0f3873e7701e85cb1
Parent
3614baaf
10 files changed +1329 −81
Cargo.lock +2 −0
@@ -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"
1808version = "0.1.0"
1808version = "0.1.0"
1809dependencies = [
1809dependencies = [
1810 "eframe",
1810 "eframe",
1811 "egui_glow",
1812 "glow 0.14.2",
1811 "gltf",
1813 "gltf",
1812 "libloading",
1814 "libloading",
1813 "serde",
1815 "serde",
Cargo.toml +2 −0
@@ -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 rendering
8
8
9[dependencies]
9[dependencies]
10eframe = { version = "0.29.1", default-features = false, features = ["default_fonts", "glow", "x11"] }
10eframe = { version = "0.29.1", default-features = false, features = ["default_fonts", "glow", "x11"] }
11egui_glow = "0.29.1"
12glow = "0.14.2"
11gltf = { version = "1.4.1", default-features = false, features = ["import", "names", "utils"] }
13gltf = { version = "1.4.1", default-features = false, features = ["import", "names", "utils"] }
12libloading = "0.8"
14libloading = "0.8"
13serde = { version = "1", features = ["derive"] }
15serde = { version = "1", features = ["derive"] }
README.md +21 −32
@@ -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 Renderer
1# Paperdoll Renderer
2
2
3Native 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).
3Desktopprogramma voor een paperdoll-project. Je bewaart een projectmap, leest modellen uit, en bekijkt een glTF-lichaam als bewegend 3D-model.
4
4
5## Eisen
5Rust 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 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
13
6
14```bash
7```bash
15cargo test --locked
16cargo run -- --status
17cargo run
8cargo run
9cargo run -- --preview-fixture
10cargo run -- --status
11cargo 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.
14`--preview-fixture` opent de preview met de testlichamen uit `tests/fixtures/bodies/`.
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
27python3 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.
31
15
32## Wat deze build niet doet
16## Project
33
17
34OBJ+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
20In 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
38De 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
40Dat 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.
24Het 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
44Pakketten lokaal, zonder publiceren:
32## Tests
45
33
46```bash
34```bash
47bash scripts/package/deb.sh
35cargo fmt --check
48bash scripts/package/appimage.sh
36cargo clippy --workspace --all-targets --locked -- -D warnings
37cargo test --workspace --locked
49```
38```
50
39
51Op 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.
40De 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.
design.md +5 −1
@@ -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 write
141
141
142UI 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.
142UI 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
144Live 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.
144Live 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
146Binary 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.
146Binary 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
158Each 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.
158Each 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
160After 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
162Live 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
160CI 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.
164CI 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
162Tests 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.
166Tests 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.
src/editor.rs +80 −5
@@ -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;
5use eframe::egui;
5use eframe::egui;
6
6
7use crate::import::{import_project_model, ImportedModel, ModelFormat};
7use crate::import::{import_project_model, ImportedModel, ModelFormat};
8use crate::preview::{load_gltf_file, PreviewCharacter, PreviewControls, PreviewSex};
9use crate::preview_gl::Viewport;
8use crate::project::{
10use 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 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 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
354impl 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 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<ImportedMo
544 });
610 });
545}
611}
546
612
613fn 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
547fn parent_relative(relative: &str) -> String {
622fn 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(),
src/foundation.rs +36 −23
@@ -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 one
35/// Rejected command line. A model path is rejected on purpose: accepting one
@@ -58,11 +59,11 @@ pub fn availability(capability: Capability) -> Availability {
58 Capability::ProjectShell
59 Capability::ProjectShell
59 | Capability::ObjMtlTextures
60 | Capability::ObjMtlTextures
60 | Capability::GltfGlb
61 | Capability::GltfGlb
61 | Capability::Fbx => Availability::Present,
62 | Capability::Fbx
62 Capability::SkeletalAnimation
63 | Capability::SkeletalAnimation => Availability::Present,
63 | Capability::AttachmentEditor
64 Capability::AttachmentEditor | Capability::PaperdollRender | Capability::Preview2d => {
64 | Capability::PaperdollRender
65 Availability::NotBuilt
65 | 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
109pub fn dutch_summary() -> &'static str {
110pub 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.
114pub fn report() -> String {
115pub 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 sluit
166 paperdoll-renderer --status print de status en sluit
166 paperdoll-renderer --help toon deze tekst
167 paperdoll-renderer --help toon deze tekst
167
168
168Een 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
171Een los modelpad op de opdrachtregel opent geen bestand. Gebruik --preview-fixture of een .ppd-project."
169}
172}
170
173
171pub fn parse_cli<'a, I>(args: I) -> Result<CliRequest, CliError>
174pub fn parse_cli<'a, I>(args: I) -> Result<CliRequest, CliError>
@@ -176,6 +179,11 @@ where
176 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::Present
213 Availability::Present
206 );
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::Present
219 );
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]
src/lib.rs +2 −0
@@ -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 @@
6pub mod editor;
6pub mod editor;
7pub mod foundation;
7pub mod foundation;
8pub mod import;
8pub mod import;
9pub mod preview;
10pub mod preview_gl;
9pub mod project;
11pub mod project;
src/main.rs +28 −20
@@ -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 import
3//! The window edits a `.ppd` project and shows a live skinned glTF preview.
4//! models and it does not render paperdolls.
5
4
6use std::process::ExitCode;
5use std::process::ExitCode;
7
6
@@ -20,27 +19,30 @@ fn main() -> ExitCode {
20 println!("{}", report());
19 println!("{}", report());
21 ExitCode::SUCCESS
20 ExitCode::SUCCESS
22 }
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
32fn 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
44fn window_preflight() -> Option<String> {
46fn 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
62fn run_window() -> eframe::Result<()> {
64fn 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}
src/preview.rs added +767 −0
@@ -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 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
8use std::path::Path;
9
10pub const PAPERDOLL_PITCHES_DEG: [f32; 3] = [-30.0, 0.0, 30.0];
11pub const PAPERDOLL_YAW_COUNT: usize = 12;
12pub const MAX_JOINTS: usize = 128;
13
14pub type Mat4 = [f32; 16];
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum PreviewSex {
18 Male,
19 Female,
20}
21
22#[derive(Debug, Clone)]
23pub 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
33impl 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
47impl 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)]
83pub 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)]
98pub 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)]
107pub struct PreviewClip {
108 pub name: String,
109 pub duration: f32,
110 pub channels: Vec<AnimChannel>,
111}
112
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114pub enum ChannelProperty {
115 Translation,
116 Rotation,
117 Scale,
118}
119
120#[derive(Debug, Clone)]
121pub struct AnimChannel {
122 pub node: usize,
123 pub property: ChannelProperty,
124 pub times: Vec<f32>,
125 pub values: Vec<f32>,
126}
127
128pub 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
271pub 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
282pub 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
298pub 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
309pub 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
319pub 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
327fn 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
377fn 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
407fn 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
428fn 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
437fn 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
447fn 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
455fn 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
481fn 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
497fn 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
514fn 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
545pub 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
551pub 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
564fn 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
574pub 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
585fn wrap_deg(mut degrees: f32) -> f32 {
586 degrees %= 360.0;
587 if degrees < 0.0 {
588 degrees += 360.0;
589 }
590 degrees
591}
592
593pub 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
621pub 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
645fn 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
653fn dot(a: [f32; 3], b: [f32; 3]) -> f32 {
654 a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
655}
656
657fn 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)]
667mod 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}
src/preview_gl.rs added +386 −0
@@ -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 paperdoll
4//! diffuse/depth/normal export.
5
6use std::sync::{Arc, Mutex};
7
8use eframe::egui;
9use egui_glow::CallbackFn;
10use glow::HasContext;
11
12use 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
17const MAX_BONES: usize = crate::preview::MAX_JOINTS;
18
19pub struct Viewport {
20 cache: Arc<Mutex<GpuCache>>,
21}
22
23impl Default for Viewport {
24 fn default() -> Self {
25 Self::new()
26 }
27}
28
29impl 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
158fn 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)]
165struct 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)]
178struct 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
189impl 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
315unsafe 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
320fn bytemuck_f32(values: &[f32]) -> &[u8] {
321 unsafe { std::slice::from_raw_parts(values.as_ptr().cast(), std::mem::size_of_val(values)) }
322}
323
324fn bytemuck_u32(values: &[u32]) -> &[u8] {
325 unsafe { std::slice::from_raw_parts(values.as_ptr().cast(), std::mem::size_of_val(values)) }
326}
327
328unsafe 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
343unsafe 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
353const VERTEX: &str = r#"#version 330
354layout(location = 0) in vec3 a_pos;
355layout(location = 1) in vec3 a_normal;
356layout(location = 2) in vec4 a_joints;
357layout(location = 3) in vec4 a_weights;
358uniform mat4 u_view_proj;
359uniform mat4 u_bones[128];
360out vec3 v_normal;
361void 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
376const FRAGMENT: &str = r#"#version 330
377in vec3 v_normal;
378uniform vec4 u_color;
379out vec4 frag;
380void 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"#;