BlueGit

golden-age paperdoll-renderer

feat: rest-pose neck-height orbit pivot independent of image center

Separate CameraFraming.pivot_y_milli (schema v3) from center_y_milli.
Auto-detect unique neck joints, allow manual override/clear, share
camera::resolve/matrices across live fixed frame, equipment and
production; record pivot_source in render.json v2.

timfa Grok Bot · pushed by timfa

Authored 2026-11-07 · 7 days ago

Hash
eb3667d9478c04cf669dd45eb26e7302ec2c456e
Parent
c70e7a0e
10 files changed +725 −33
README.md +1 −1
@@ -21,7 +21,7 @@ De eerste projectweergave toont de bodies. **Kies man / Kies vrouw** selecteert
21 21
22 22 **Open man / Open vrouw** opent de body. Een geskind glTF/GLB-model is in de live preview zichtbaar:
23 23
24 - Sleep om te orbiten. **Vast renderkader tonen** toont het gele orthografische exportkader; stel per man/vrouw **Vaste renderhoogte** en **Centrum Y** in en sla op. Body en equipment gebruiken bij export altijd ditzelfde kader. Uitvinken geeft vrije perspectief-preview met scrollzoom; die zoom verandert de export niet. Een echte 24-bits depthbuffer houdt voorste oppervlakken vooraan.
24 - Sleep om te orbiten. **Vast renderkader tonen** toont het gele orthografische exportkader; stel per man/vrouw **Vaste renderhoogte**, **Centrum Y** (beeldcentrum) en **Pivot Y** (orbitpunt) in en sla op. Zonder handmatige pivot gebruikt de editor de rustpose-nekhoogte (of een fallback met diagnose); **Auto nek** wist een handmatige override. Body en equipment delen exact dezelfde camera. Uitvinken geeft vrije perspectief-preview met scrollzoom; die zoom verandert de export niet. Een echte 24-bits depthbuffer houdt voorste oppervlakken vooraan.
25 25 - Kies een animatieclip en gebruik **Pauze / Speel**.
26 26 - **Yaw − / Yaw +** volgen twaalf hoeken van 30°; **pitch -30 / 0 / 30** kiezen de hoogte. Yaw 0 kijkt naar de voorkant van de testlichamen (−Z).
27 27 - **Render alle hoeken** rendert alle animaties × alle frames op project-fps × 36 hoeken. De preview wordt direct vervangen door iedere voltooide pass: highres → detail → target/diffuse → depth → normal → specular. Alle passes behouden hun aspect met gecentreerde letterboxing en nearest-neighbor weergave. Voortgang telt echte passes; **Annuleer render** behoudt geldige gedeeltelijke uitvoer. **Live 3D** keert terug naar het model; een volgende voltooide renderpass kan de preview weer vervangen.
@@ -21,7 +21,7 @@ De eerste projectweergave toont de bodies. **Kies man / Kies vrouw** selecteert
21
21
22**Open man / Open vrouw** opent de body. Een geskind glTF/GLB-model is in de live preview zichtbaar:
22**Open man / Open vrouw** opent de body. Een geskind glTF/GLB-model is in de live preview zichtbaar:
23
23
24- Sleep om te orbiten. **Vast renderkader tonen** toont het gele orthografische exportkader; stel per man/vrouw **Vaste renderhoogte** en **Centrum Y** in en sla op. Body en equipment gebruiken bij export altijd ditzelfde kader. Uitvinken geeft vrije perspectief-preview met scrollzoom; die zoom verandert de export niet. Een echte 24-bits depthbuffer houdt voorste oppervlakken vooraan.
24- Sleep om te orbiten. **Vast renderkader tonen** toont het gele orthografische exportkader; stel per man/vrouw **Vaste renderhoogte**, **Centrum Y** (beeldcentrum) en **Pivot Y** (orbitpunt) in en sla op. Zonder handmatige pivot gebruikt de editor de rustpose-nekhoogte (of een fallback met diagnose); **Auto nek** wist een handmatige override. Body en equipment delen exact dezelfde camera. Uitvinken geeft vrije perspectief-preview met scrollzoom; die zoom verandert de export niet. Een echte 24-bits depthbuffer houdt voorste oppervlakken vooraan.
25- Kies een animatieclip en gebruik **Pauze / Speel**.
25- Kies een animatieclip en gebruik **Pauze / Speel**.
26- **Yaw − / Yaw +** volgen twaalf hoeken van 30°; **pitch -30 / 0 / 30** kiezen de hoogte. Yaw 0 kijkt naar de voorkant van de testlichamen (−Z).
26- **Yaw − / Yaw +** volgen twaalf hoeken van 30°; **pitch -30 / 0 / 30** kiezen de hoogte. Yaw 0 kijkt naar de voorkant van de testlichamen (−Z).
27- **Render alle hoeken** rendert alle animaties × alle frames op project-fps × 36 hoeken. De preview wordt direct vervangen door iedere voltooide pass: highres → detail → target/diffuse → depth → normal → specular. Alle passes behouden hun aspect met gecentreerde letterboxing en nearest-neighbor weergave. Voortgang telt echte passes; **Annuleer render** behoudt geldige gedeeltelijke uitvoer. **Live 3D** keert terug naar het model; een volgende voltooide renderpass kan de preview weer vervangen.
27- **Render alle hoeken** rendert alle animaties × alle frames op project-fps × 36 hoeken. De preview wordt direct vervangen door iedere voltooide pass: highres → detail → target/diffuse → depth → normal → specular. Alle passes behouden hun aspect met gecentreerde letterboxing en nearest-neighbor weergave. Voortgang telt echte passes; **Annuleer render** behoudt geldige gedeeltelijke uitvoer. **Live 3D** keert terug naar het model; een volgende voltooide renderpass kan de preview weer vervangen.
design.md +6 −6
@@ -94,9 +94,9 @@ Suggested layout: `character.ppd`, `assets/body/`, `assets/equipment/`, `presets
94 94
95 95 Validate finite numeric values, dimensions > 0, bounded dimensions and memory estimate, positive FPS, valid clip references, existing model/preset paths and output directories distinct from sources. A missing optional sex slot is a valid project, but cannot render that slot. No absolute-path leakage into exported manifests unless user explicitly chooses external references.
96 96
97 ### Implemented format_version 2 (version 1 migration)
97 ### Implemented format_version 3 (versions 1/2 migration)
98 98
99 `character.ppd` is a strict UTF-8 JSON manifest. Fields remain `format_version`, `id`, `name`, nullable `male_model`/`female_model`, `equipment_presets`, `output_directory`, and `render` (`target_width`, `target_height`, `fps`). Version 2 adds `render.framing`, an array **[male, female]** of `{height_milli, center_y_milli}`: integer world-unit millimetres, default height 2600 and center Y 1000. Heights 100…10000 and centers −10000…10000 are validated. Each sex has its own fixed camera, shared by every body/equipment export. Opening version 1 migrates in memory with default framing; the next save writes version 2. Other versions and unknown fields are rejected, not silently discarded. This does not add source-axis/import-transform overrides or animation inventory overrides.
99 `character.ppd` is a strict UTF-8 JSON manifest. Fields remain `format_version`, `id`, `name`, nullable `male_model`/`female_model`, `equipment_presets`, `output_directory`, and `render` (`target_width`, `target_height`, `fps`, `framing`). Version 2 introduced `render.framing`, an array **[male, female]** of `{height_milli, center_y_milli}`: integer world-unit millimetres, default height 2600 and center Y 1000. Version 3 adds optional `pivot_y_milli` on each framing entry (`None` = auto rest-pose neck, `Some` = manual override). Heights 100…10000 and center/pivot −10000…10000 are validated. Each sex has its own fixed camera, shared by every body/equipment export. Opening version 1 or 2 migrates in memory (missing pivot stays auto); the next save writes version 3. Version 4+ and unknown fields are rejected, not silently discarded. This does not add source-axis/import-transform overrides or animation inventory overrides.
100 100
101 101 Paths use `/` and stay inside the project directory. Save writes/fsyncs a temporary sibling and atomically replaces the manifest; failure preserves the prior manifest. Binary equipment snapshots remain separate. Per-sex framing is part of the normal unsaved-change/save/discard gate, not transient orbit state.
102 102
@@ -104,9 +104,9 @@ Paths use `/` and stay inside the project directory. Save writes/fsyncs a tempor
104 104
105 105 Required views: pitches -30, 0, +30 degrees; yaw 0, 30, 60, ..., 330 degrees. **Proposal:** yaw zero means the standardized character front faces the camera, world +Y is up, and positive yaw is defined by a documented right-hand convention. Distinguish camera elevation from model rotation: use orbiting orthographic camera around fixed character origin. Golden orientation fixture must show labelled front/back/left/right to remove ambiguity before production export.
106 106
107 **Implemented fixed framing:** orthographic projection, +Y world up, yaw 0 camera on −Z looking toward the character; positive yaw follows the existing orbit convention. Orbit target is `(0, center_y_milli/1000, 0)`, camera distance **10**, vertical world extent `height_milli/1000`, horizontal extent vertical × target aspect. Near **0.05**, far **40** are shared absolutely across all layers, frames and views. No per-frame/per-equipment auto-framing or depth normalization. Source root motion is retained as imported (no extraction). User adjusts height/center in the body view to allow all animation/equipment extents; this is manual, not a promised automatic animation envelope.
107 **Implemented fixed framing (Tim t952u):** orthographic projection, +Y world up, yaw 0 camera on −Z looking toward the character; positive yaw follows the existing orbit convention. The **orbit pivot** is independent of the vertical image center: target is `(0, pivot_y/1000, 0)` with camera distance **10**. `pivot_y_milli = None` resolves the unique rest-pose neck joint (name tokens such as `Neck`, `mixamorigNeck`, `Bip001 Neck`, `CC_Base_Neck`); missing/ambiguous necks fall back to `center_y_milli` with a UI diagnostic; an explicit `Some` value is a manual override. Vertical world extent is `height_milli/1000`, horizontal extent vertical × target aspect. Orthographic Y translation `(pivot − center) / half_height` keeps zero-pitch image framing (feet / image center) stable when the pivot is raised to the neck. Near **0.05**, far **40** are shared absolutely across all layers, frames and views. Body, equipment layered preview, fixed live frame and production `camera()` all share `camera::resolve` + `camera::matrices`. No per-frame/per-equipment auto-framing or depth normalization. Source root motion is retained as imported (no extraction). User adjusts height/center/pivot in the body view; automatic animation envelopes are not promised.
108 108
109 **Vast renderkader tonen** switches live placement to this same orthographic camera and target-aspect, centered yellow frame. Drag still changes yaw/pitch; orbit-scroll zoom is ignored while the fixed frame is shown. Uncheck it for the independent perspective orbit/scroll camera. Neither free orbit zoom nor live selected clip/time changes a production job. Render snapshots the current project settings at job start; later edits do not change that in-flight batch. **Opslaan** persists male and female fixed framing in `.ppd`.
109 **Vast renderkader tonen** switches live placement to this same orthographic camera and target-aspect, centered yellow frame. Drag still changes yaw/pitch; orbit-scroll zoom is ignored while the fixed frame is shown. Uncheck it for the independent perspective orbit/scroll camera. Neither free orbit zoom nor live selected clip/time changes a production job. Render snapshots the current project settings at job start (including resolved pivot and `pivot_source`); later edits do not change that in-flight batch. **Opslaan** persists male and female fixed framing (including optional manual pivot) in `.ppd`.
110 110
111 111 **Implemented batch nesting:** source clip order → sampled frame → pitch **−30,0,+30** → yaw **0…330 step30** → six completed passes. Samples are `t=frame/FPS`, `0 <= frame < max(1, ceil(duration*FPS))`. To avoid exporter float noise creating a near-duplicate terminal frame, the product snaps to its nearest integer when within `4*f32::EPSILON*max(1,product)` before ceiling; a regression covers an exported 1-second endpoint one ULP above 1. Otherwise the full fractional duration is sampled; a zero-duration clip has one static sample; no clips produces one bind-pose clip. Duplicate display names get indexed tokens and internally unique sampling names, not accidental reuse of the first clip. All clips/frames/views are rendered regardless of current live selection. FPS is sampling, not playback rate.
112 112
@@ -124,7 +124,7 @@ renders/run-<unix-nanoseconds>-<pid>/<male|female>/<body|equipment-slug>/
124 124
125 125 `output_directory` may change the `renders/` root. Safe lowercase clip slugs replace non-ASCII/non-alphanumerics with `-`, truncate to 48 characters, and prefix source index (so duplicate names cannot collide). Equipment slugs use the existing strict preset validator; `body` is reserved. Frame index is zero-based. Map tokens are **only `diffuse`, `depth`, `normal`, `specular`**, for body AND equipment, all exactly target width/height. **Highres and detail are preview-only intermediates: never PNG exports.** Each run is a fresh directory, never replaces any previous run or named image. Canonical parent checks reject traversal/outside symlinks; sibling temporary file + fsync + no-clobber hardlink publication prevents a partial PNG appearing as a valid final file. Filesystems must support hardlinks; unsupported filesystems fail explicitly. Existing or foreign temporary files are never deleted on creation failure.
126 126
127 `render.json` (output metadata schema 1, separate from `.ppd` schema 2) records sex/set, target, FPS, highres scale, framing, common near/far, conventions, source clip names/tokens/durations/frame counts, six-pass order, four saved maps and actual counts. Frame timestamps follow the recorded sampling equation. `complete.json` is published ONLY after all PNGs complete; cancellation/failure best-effort writes `cancelled.json`/`failed.json` and leaves valid partial files. `render.json` intentionally remains an incomplete-start record; consumers must require `complete.json`. Source-file checksums, per-image checksums, resumable batches and automatic cleanup are future work, not implemented manifest fields.
127 `render.json` (output metadata schema 2, separate from `.ppd` schema 3) records sex/set, target, FPS, highres scale, framing (with resolved `pivot_y_milli`), `pivot_source` (`manual` / `neck` / `fallback`), common near/far, conventions, source clip names/tokens/durations/frame counts, six-pass order, four saved maps and actual counts. Frame timestamps follow the recorded sampling equation. `complete.json` is published ONLY after all PNGs complete; cancellation/failure best-effort writes `cancelled.json`/`failed.json` and leaves valid partial files. `render.json` intentionally remains an incomplete-start record; consumers must require `complete.json`. Source-file checksums, per-image checksums, resumable batches and automatic cleanup are future work, not implemented manifest fields.
128 128
129 129 ## 8. Map contracts and rendering algorithm
130 130
@@ -215,7 +215,7 @@ These requirements are durable even where the implementation is staged. They sup
215 215 - Tim’s PNG is the authoritative app/window/executable/package icon; the original is retained and derived sizes are tested. After every merge the README describes only current abilities and their usage. Add screenshots only when they clarify usage. Merged feature branches may be removed after ancestry verification; never remove master or active work.
216 216 - During a body render, a single preview area is replaced immediately after each completed pass: high-resolution image, internal detail map, target-resolution image enlarged with **nearest-neighbor**, preserving aspect ratio with one uniform scale, centered in the preview box with letterboxing (never independently stretch X/Y; applies to every pass/map), then each produced output map, then the next sampled pose/view. Show progress across actual completed work. Do not add sleeps, playback delays or wait for UI acknowledgment; fast passes may be visually skipped. Never count an unimplemented map as rendered, invent a map image or substitute a 3D viewport for exported maps.
217 217
218 Current implementation checkpoint: native project/pickers, equipment authoring/presets, shared-depth animated placement preview and fixed per-sex framing are present. **Render alle hoeken** now produces every source clip × sampled frame × 36 views with the six real pass previews and four target maps described above, streaming, progress and cancellation. The former two-pass shaded color still is no longer the Render action. The older CPU still helper remains regression-tested but is not the production path. The 2D relighting/compositor, rim effects, resume and FBX drawing remain unimplemented.
218 Current implementation checkpoint: native project/pickers, equipment authoring/presets, shared-depth animated placement preview, fixed per-sex framing and rest-pose neck-height orbit pivot are present. **Render alle hoeken** now produces every source clip × sampled frame × 36 views with the six real pass previews and four target maps described above, streaming, progress and cancellation. The former two-pass shaded color still is no longer the Render action. The older CPU still helper remains regression-tested but is not the production path. The 2D relighting/compositor, rim effects, resume and FBX drawing remain unimplemented.
219 219
220 220 ## 12. Incremental feature plan and acceptance gates
221 221
@@ -94,9 +94,9 @@ Suggested layout: `character.ppd`, `assets/body/`, `assets/equipment/`, `presets
94
94
95Validate finite numeric values, dimensions > 0, bounded dimensions and memory estimate, positive FPS, valid clip references, existing model/preset paths and output directories distinct from sources. A missing optional sex slot is a valid project, but cannot render that slot. No absolute-path leakage into exported manifests unless user explicitly chooses external references.
95Validate finite numeric values, dimensions > 0, bounded dimensions and memory estimate, positive FPS, valid clip references, existing model/preset paths and output directories distinct from sources. A missing optional sex slot is a valid project, but cannot render that slot. No absolute-path leakage into exported manifests unless user explicitly chooses external references.
96
96
97### Implemented format_version 2 (version 1 migration)
97### Implemented format_version 3 (versions 1/2 migration)
98
98
99`character.ppd` is a strict UTF-8 JSON manifest. Fields remain `format_version`, `id`, `name`, nullable `male_model`/`female_model`, `equipment_presets`, `output_directory`, and `render` (`target_width`, `target_height`, `fps`). Version 2 adds `render.framing`, an array **[male, female]** of `{height_milli, center_y_milli}`: integer world-unit millimetres, default height 2600 and center Y 1000. Heights 100…10000 and centers −10000…10000 are validated. Each sex has its own fixed camera, shared by every body/equipment export. Opening version 1 migrates in memory with default framing; the next save writes version 2. Other versions and unknown fields are rejected, not silently discarded. This does not add source-axis/import-transform overrides or animation inventory overrides.
99`character.ppd` is a strict UTF-8 JSON manifest. Fields remain `format_version`, `id`, `name`, nullable `male_model`/`female_model`, `equipment_presets`, `output_directory`, and `render` (`target_width`, `target_height`, `fps`, `framing`). Version 2 introduced `render.framing`, an array **[male, female]** of `{height_milli, center_y_milli}`: integer world-unit millimetres, default height 2600 and center Y 1000. Version 3 adds optional `pivot_y_milli` on each framing entry (`None` = auto rest-pose neck, `Some` = manual override). Heights 100…10000 and center/pivot −10000…10000 are validated. Each sex has its own fixed camera, shared by every body/equipment export. Opening version 1 or 2 migrates in memory (missing pivot stays auto); the next save writes version 3. Version 4+ and unknown fields are rejected, not silently discarded. This does not add source-axis/import-transform overrides or animation inventory overrides.
100
100
101Paths use `/` and stay inside the project directory. Save writes/fsyncs a temporary sibling and atomically replaces the manifest; failure preserves the prior manifest. Binary equipment snapshots remain separate. Per-sex framing is part of the normal unsaved-change/save/discard gate, not transient orbit state.
101Paths use `/` and stay inside the project directory. Save writes/fsyncs a temporary sibling and atomically replaces the manifest; failure preserves the prior manifest. Binary equipment snapshots remain separate. Per-sex framing is part of the normal unsaved-change/save/discard gate, not transient orbit state.
102
102
@@ -104,9 +104,9 @@ Paths use `/` and stay inside the project directory. Save writes/fsyncs a tempor
104
104
105Required views: pitches -30, 0, +30 degrees; yaw 0, 30, 60, ..., 330 degrees. **Proposal:** yaw zero means the standardized character front faces the camera, world +Y is up, and positive yaw is defined by a documented right-hand convention. Distinguish camera elevation from model rotation: use orbiting orthographic camera around fixed character origin. Golden orientation fixture must show labelled front/back/left/right to remove ambiguity before production export.
105Required views: pitches -30, 0, +30 degrees; yaw 0, 30, 60, ..., 330 degrees. **Proposal:** yaw zero means the standardized character front faces the camera, world +Y is up, and positive yaw is defined by a documented right-hand convention. Distinguish camera elevation from model rotation: use orbiting orthographic camera around fixed character origin. Golden orientation fixture must show labelled front/back/left/right to remove ambiguity before production export.
106
106
107**Implemented fixed framing:** orthographic projection, +Y world up, yaw 0 camera on −Z looking toward the character; positive yaw follows the existing orbit convention. Orbit target is `(0, center_y_milli/1000, 0)`, camera distance **10**, vertical world extent `height_milli/1000`, horizontal extent vertical × target aspect. Near **0.05**, far **40** are shared absolutely across all layers, frames and views. No per-frame/per-equipment auto-framing or depth normalization. Source root motion is retained as imported (no extraction). User adjusts height/center in the body view to allow all animation/equipment extents; this is manual, not a promised automatic animation envelope.
107**Implemented fixed framing (Tim t952u):** orthographic projection, +Y world up, yaw 0 camera on −Z looking toward the character; positive yaw follows the existing orbit convention. The **orbit pivot** is independent of the vertical image center: target is `(0, pivot_y/1000, 0)` with camera distance **10**. `pivot_y_milli = None` resolves the unique rest-pose neck joint (name tokens such as `Neck`, `mixamorigNeck`, `Bip001 Neck`, `CC_Base_Neck`); missing/ambiguous necks fall back to `center_y_milli` with a UI diagnostic; an explicit `Some` value is a manual override. Vertical world extent is `height_milli/1000`, horizontal extent vertical × target aspect. Orthographic Y translation `(pivot − center) / half_height` keeps zero-pitch image framing (feet / image center) stable when the pivot is raised to the neck. Near **0.05**, far **40** are shared absolutely across all layers, frames and views. Body, equipment layered preview, fixed live frame and production `camera()` all share `camera::resolve` + `camera::matrices`. No per-frame/per-equipment auto-framing or depth normalization. Source root motion is retained as imported (no extraction). User adjusts height/center/pivot in the body view; automatic animation envelopes are not promised.
108
108
109**Vast renderkader tonen** switches live placement to this same orthographic camera and target-aspect, centered yellow frame. Drag still changes yaw/pitch; orbit-scroll zoom is ignored while the fixed frame is shown. Uncheck it for the independent perspective orbit/scroll camera. Neither free orbit zoom nor live selected clip/time changes a production job. Render snapshots the current project settings at job start; later edits do not change that in-flight batch. **Opslaan** persists male and female fixed framing in `.ppd`.
109**Vast renderkader tonen** switches live placement to this same orthographic camera and target-aspect, centered yellow frame. Drag still changes yaw/pitch; orbit-scroll zoom is ignored while the fixed frame is shown. Uncheck it for the independent perspective orbit/scroll camera. Neither free orbit zoom nor live selected clip/time changes a production job. Render snapshots the current project settings at job start (including resolved pivot and `pivot_source`); later edits do not change that in-flight batch. **Opslaan** persists male and female fixed framing (including optional manual pivot) in `.ppd`.
110
110
111**Implemented batch nesting:** source clip order → sampled frame → pitch **−30,0,+30** → yaw **0…330 step30** → six completed passes. Samples are `t=frame/FPS`, `0 <= frame < max(1, ceil(duration*FPS))`. To avoid exporter float noise creating a near-duplicate terminal frame, the product snaps to its nearest integer when within `4*f32::EPSILON*max(1,product)` before ceiling; a regression covers an exported 1-second endpoint one ULP above 1. Otherwise the full fractional duration is sampled; a zero-duration clip has one static sample; no clips produces one bind-pose clip. Duplicate display names get indexed tokens and internally unique sampling names, not accidental reuse of the first clip. All clips/frames/views are rendered regardless of current live selection. FPS is sampling, not playback rate.
111**Implemented batch nesting:** source clip order → sampled frame → pitch **−30,0,+30** → yaw **0…330 step30** → six completed passes. Samples are `t=frame/FPS`, `0 <= frame < max(1, ceil(duration*FPS))`. To avoid exporter float noise creating a near-duplicate terminal frame, the product snaps to its nearest integer when within `4*f32::EPSILON*max(1,product)` before ceiling; a regression covers an exported 1-second endpoint one ULP above 1. Otherwise the full fractional duration is sampled; a zero-duration clip has one static sample; no clips produces one bind-pose clip. Duplicate display names get indexed tokens and internally unique sampling names, not accidental reuse of the first clip. All clips/frames/views are rendered regardless of current live selection. FPS is sampling, not playback rate.
112
112
@@ -124,7 +124,7 @@ renders/run-<unix-nanoseconds>-<pid>/<male|female>/<body|equipment-slug>/
124
124
125`output_directory` may change the `renders/` root. Safe lowercase clip slugs replace non-ASCII/non-alphanumerics with `-`, truncate to 48 characters, and prefix source index (so duplicate names cannot collide). Equipment slugs use the existing strict preset validator; `body` is reserved. Frame index is zero-based. Map tokens are **only `diffuse`, `depth`, `normal`, `specular`**, for body AND equipment, all exactly target width/height. **Highres and detail are preview-only intermediates: never PNG exports.** Each run is a fresh directory, never replaces any previous run or named image. Canonical parent checks reject traversal/outside symlinks; sibling temporary file + fsync + no-clobber hardlink publication prevents a partial PNG appearing as a valid final file. Filesystems must support hardlinks; unsupported filesystems fail explicitly. Existing or foreign temporary files are never deleted on creation failure.
125`output_directory` may change the `renders/` root. Safe lowercase clip slugs replace non-ASCII/non-alphanumerics with `-`, truncate to 48 characters, and prefix source index (so duplicate names cannot collide). Equipment slugs use the existing strict preset validator; `body` is reserved. Frame index is zero-based. Map tokens are **only `diffuse`, `depth`, `normal`, `specular`**, for body AND equipment, all exactly target width/height. **Highres and detail are preview-only intermediates: never PNG exports.** Each run is a fresh directory, never replaces any previous run or named image. Canonical parent checks reject traversal/outside symlinks; sibling temporary file + fsync + no-clobber hardlink publication prevents a partial PNG appearing as a valid final file. Filesystems must support hardlinks; unsupported filesystems fail explicitly. Existing or foreign temporary files are never deleted on creation failure.
126
126
127`render.json` (output metadata schema 1, separate from `.ppd` schema 2) records sex/set, target, FPS, highres scale, framing, common near/far, conventions, source clip names/tokens/durations/frame counts, six-pass order, four saved maps and actual counts. Frame timestamps follow the recorded sampling equation. `complete.json` is published ONLY after all PNGs complete; cancellation/failure best-effort writes `cancelled.json`/`failed.json` and leaves valid partial files. `render.json` intentionally remains an incomplete-start record; consumers must require `complete.json`. Source-file checksums, per-image checksums, resumable batches and automatic cleanup are future work, not implemented manifest fields.
127`render.json` (output metadata schema 2, separate from `.ppd` schema 3) records sex/set, target, FPS, highres scale, framing (with resolved `pivot_y_milli`), `pivot_source` (`manual` / `neck` / `fallback`), common near/far, conventions, source clip names/tokens/durations/frame counts, six-pass order, four saved maps and actual counts. Frame timestamps follow the recorded sampling equation. `complete.json` is published ONLY after all PNGs complete; cancellation/failure best-effort writes `cancelled.json`/`failed.json` and leaves valid partial files. `render.json` intentionally remains an incomplete-start record; consumers must require `complete.json`. Source-file checksums, per-image checksums, resumable batches and automatic cleanup are future work, not implemented manifest fields.
128
128
129## 8. Map contracts and rendering algorithm
129## 8. Map contracts and rendering algorithm
130
130
@@ -215,7 +215,7 @@ These requirements are durable even where the implementation is staged. They sup
215- Tim’s PNG is the authoritative app/window/executable/package icon; the original is retained and derived sizes are tested. After every merge the README describes only current abilities and their usage. Add screenshots only when they clarify usage. Merged feature branches may be removed after ancestry verification; never remove master or active work.
215- Tim’s PNG is the authoritative app/window/executable/package icon; the original is retained and derived sizes are tested. After every merge the README describes only current abilities and their usage. Add screenshots only when they clarify usage. Merged feature branches may be removed after ancestry verification; never remove master or active work.
216- During a body render, a single preview area is replaced immediately after each completed pass: high-resolution image, internal detail map, target-resolution image enlarged with **nearest-neighbor**, preserving aspect ratio with one uniform scale, centered in the preview box with letterboxing (never independently stretch X/Y; applies to every pass/map), then each produced output map, then the next sampled pose/view. Show progress across actual completed work. Do not add sleeps, playback delays or wait for UI acknowledgment; fast passes may be visually skipped. Never count an unimplemented map as rendered, invent a map image or substitute a 3D viewport for exported maps.
216- During a body render, a single preview area is replaced immediately after each completed pass: high-resolution image, internal detail map, target-resolution image enlarged with **nearest-neighbor**, preserving aspect ratio with one uniform scale, centered in the preview box with letterboxing (never independently stretch X/Y; applies to every pass/map), then each produced output map, then the next sampled pose/view. Show progress across actual completed work. Do not add sleeps, playback delays or wait for UI acknowledgment; fast passes may be visually skipped. Never count an unimplemented map as rendered, invent a map image or substitute a 3D viewport for exported maps.
217
217
218Current implementation checkpoint: native project/pickers, equipment authoring/presets, shared-depth animated placement preview and fixed per-sex framing are present. **Render alle hoeken** now produces every source clip × sampled frame × 36 views with the six real pass previews and four target maps described above, streaming, progress and cancellation. The former two-pass shaded color still is no longer the Render action. The older CPU still helper remains regression-tested but is not the production path. The 2D relighting/compositor, rim effects, resume and FBX drawing remain unimplemented.
218Current implementation checkpoint: native project/pickers, equipment authoring/presets, shared-depth animated placement preview, fixed per-sex framing and rest-pose neck-height orbit pivot are present. **Render alle hoeken** now produces every source clip × sampled frame × 36 views with the six real pass previews and four target maps described above, streaming, progress and cancellation. The former two-pass shaded color still is no longer the Render action. The older CPU still helper remains regression-tested but is not the production path. The 2D relighting/compositor, rim effects, resume and FBX drawing remain unimplemented.
219
219
220## 12. Incremental feature plan and acceptance gates
220## 12. Incremental feature plan and acceptance gates
221
221
examples/neck_pivot_probe.rs added +222 −0
@@ -0,0 +1,222 @@
1 //! Real Mesa/Xvfb proof that neck pivot differs from center-only orbit at pitched yaw.
2 //! Usage: OUTDIR=... xvfb-run -a cargo run --example neck_pivot_probe --locked
3 use paperdoll_renderer::{camera, preview, production, project::CameraFraming};
4 use std::{
5 path::{Path, PathBuf},
6 sync::{Arc, Mutex},
7 };
8
9 struct Quit;
10 impl eframe::App for Quit {
11 fn update(&mut self, ctx: &eframe::egui::Context, _: &mut eframe::Frame) {
12 ctx.send_viewport_cmd(eframe::egui::ViewportCommand::Close);
13 }
14 }
15
16 fn write_rgba(path: &Path, width: u32, height: u32, rgba: &[u8]) -> Result<(), String> {
17 let file = std::fs::File::create(path).map_err(|e| e.to_string())?;
18 let mut encoder = png::Encoder::new(file, width, height);
19 encoder.set_color(png::ColorType::Rgba);
20 encoder.set_depth(png::BitDepth::Eight);
21 encoder
22 .write_header()
23 .map_err(|e| e.to_string())?
24 .write_image_data(rgba)
25 .map_err(|e| e.to_string())
26 }
27
28 fn downsample(src: &[u8], sw: u32, sh: u32, dw: u32, dh: u32) -> Vec<u8> {
29 let mut out = vec![0u8; (dw * dh * 4) as usize];
30 for y in 0..dh {
31 for x in 0..dw {
32 let sx = x * sw / dw;
33 let sy = y * sh / dh;
34 let si = ((sy * sw + sx) * 4) as usize;
35 let di = ((y * dw + x) * 4) as usize;
36 out[di..di + 4].copy_from_slice(&src[si..si + 4]);
37 }
38 }
39 out
40 }
41
42 fn main() -> Result<(), Box<dyn std::error::Error>> {
43 let out = PathBuf::from(
44 std::env::var("OUTDIR").unwrap_or_else(|_| "/workspace/paperdoll-neck-artifacts".into()),
45 );
46 std::fs::create_dir_all(&out)?;
47 let outcome = Arc::new(Mutex::new(None));
48 let result = outcome.clone();
49 let options = eframe::NativeOptions {
50 viewport: eframe::egui::ViewportBuilder::default().with_inner_size([64., 64.]),
51 ..paperdoll_renderer::preview_gl::native_options()
52 };
53 eframe::run_native(
54 "Paperdoll neck pivot probe",
55 options,
56 Box::new(move |creation| {
57 let test = (|| -> Result<serde_json::Value, String> {
58 let gl = creation
59 .gl
60 .as_ref()
61 .ok_or_else(|| "geen Glow-context".to_string())?;
62 let body = preview::load_gltf_file(
63 &Path::new(env!("CARGO_MANIFEST_DIR"))
64 .join("tests/fixtures/bodies/test-body-male.glb"),
65 )?;
66 let resolved = camera::resolve(&body, CameraFraming::default());
67 let neck_y = resolved.framing.pivot_y_milli.ok_or("nekpivot ontbreekt")?;
68 if neck_y == CameraFraming::default().center_y_milli {
69 return Err("nekpivot mag niet gelijk zijn aan default centrum".into());
70 }
71 let mut renderer = production::GpuRenderer::default();
72 let mut cache = paperdoll_renderer::equipment::TextureCache::default();
73 let pitch = 30.;
74 let yaw = 90.;
75 let mut job = production::RenderJob {
76 character: body,
77 sex: "male".into(),
78 preset: None,
79 target_width: 64,
80 target_height: 96,
81 fps: 12,
82 framing: CameraFraming {
83 height_milli: 2600,
84 center_y_milli: 1000,
85 pivot_y_milli: Some(1000),
86 },
87 output: out.clone(),
88 };
89 let old_scene = production::scene(&job, "walk", 0.0, pitch, yaw, &mut cache)?;
90 let old = unsafe { renderer.render(gl, &old_scene) }?;
91 job.framing.pivot_y_milli = None;
92 let neck_scene = production::scene(&job, "walk", 0.0, pitch, yaw, &mut cache)?;
93 let neck = unsafe { renderer.render(gl, &neck_scene) }?;
94 unsafe { renderer.destroy(gl) };
95 assert_eq!((old.width, old.height), (neck.width, neck.height));
96 let mut changed = 0usize;
97 for (a, b) in old
98 .diffuse
99 .chunks_exact(4)
100 .zip(neck.diffuse.chunks_exact(4))
101 {
102 if a != b {
103 changed += 1;
104 }
105 }
106 if changed < 200 {
107 return Err(format!(
108 "te weinig pixelverschil tussen centrum-pivot en nekpivot: {changed}"
109 ));
110 }
111 // Zero-pitch image center / feet framing must stay close after raising pivot.
112 job.framing.pivot_y_milli = Some(1000);
113 let old0 = production::scene(&job, "walk", 0.0, 0., 0., &mut cache)?;
114 job.framing.pivot_y_milli = None;
115 let neck0 = production::scene(&job, "walk", 0.0, 0., 0., &mut cache)?;
116 let old0b = unsafe {
117 let mut r = production::GpuRenderer::default();
118 let b = r.render(gl, &old0)?;
119 r.destroy(gl);
120 b
121 };
122 let neck0b = unsafe {
123 let mut r = production::GpuRenderer::default();
124 let b = r.render(gl, &neck0)?;
125 r.destroy(gl);
126 b
127 };
128 let mid = ((old0b.height / 2) * old0b.width + old0b.width / 2) as usize;
129 let feet = ((old0b.height * 9 / 10) * old0b.width + old0b.width / 2) as usize;
130 let mid_delta = old0b.diffuse[mid * 4..mid * 4 + 4]
131 .iter()
132 .zip(&neck0b.diffuse[mid * 4..mid * 4 + 4])
133 .map(|(a, b)| a.abs_diff(*b) as u32)
134 .sum::<u32>();
135 let feet_delta = old0b.diffuse[feet * 4..feet * 4 + 4]
136 .iter()
137 .zip(&neck0b.diffuse[feet * 4..feet * 4 + 4])
138 .map(|(a, b)| a.abs_diff(*b) as u32)
139 .sum::<u32>();
140 write_rgba(
141 &out.join("center-pivot-p30-y90.png"),
142 old.width,
143 old.height,
144 &old.diffuse,
145 )?;
146 write_rgba(
147 &out.join("neck-pivot-p30-y90.png"),
148 neck.width,
149 neck.height,
150 &neck.diffuse,
151 )?;
152 let contact_w = 256;
153 let contact_h = 192;
154 let left = downsample(
155 &old.diffuse,
156 old.width,
157 old.height,
158 contact_w / 2,
159 contact_h,
160 );
161 let right = downsample(
162 &neck.diffuse,
163 neck.width,
164 neck.height,
165 contact_w / 2,
166 contact_h,
167 );
168 let mut contact = vec![0u8; (contact_w * contact_h * 4) as usize];
169 for y in 0..contact_h {
170 for x in 0..contact_w / 2 {
171 let si = ((y * (contact_w / 2) + x) * 4) as usize;
172 let li = ((y * contact_w + x) * 4) as usize;
173 let ri = ((y * contact_w + contact_w / 2 + x) * 4) as usize;
174 contact[li..li + 4].copy_from_slice(&left[si..si + 4]);
175 contact[ri..ri + 4].copy_from_slice(&right[si..si + 4]);
176 }
177 }
178 write_rgba(
179 &out.join("neck-vs-center-contact.png"),
180 contact_w,
181 contact_h,
182 &contact,
183 )?;
184 let report = serde_json::json!({
185 "success": true,
186 "pitch": pitch,
187 "yaw": yaw,
188 "center_y_milli": 1000,
189 "neck_y_milli": neck_y,
190 "pivot_source": resolved.source,
191 "highres": [old.width, old.height],
192 "changed_pixels_pitch30": changed,
193 "zero_pitch_mid_channel_delta": mid_delta,
194 "zero_pitch_feet_channel_delta": feet_delta,
195 "artifacts": [
196 "center-pivot-p30-y90.png",
197 "neck-pivot-p30-y90.png",
198 "neck-vs-center-contact.png"
199 ]
200 });
201 std::fs::write(
202 out.join("neck-pivot-probe.json"),
203 serde_json::to_vec_pretty(&report).unwrap(),
204 )
205 .map_err(|e| e.to_string())?;
206 println!("NECK_PIVOT_PROBE {report}");
207 Ok(report)
208 })();
209 *result.lock().unwrap() = Some(test);
210 Ok(Box::new(Quit))
211 }),
212 )?;
213 let value = outcome
214 .lock()
215 .unwrap()
216 .take()
217 .ok_or("GPU-probe is niet uitgevoerd")??;
218 if value["success"] != true {
219 return Err("probe failed".into());
220 }
221 Ok(())
222 }
@@ -0,0 +1,222 @@
1//! Real Mesa/Xvfb proof that neck pivot differs from center-only orbit at pitched yaw.
2//! Usage: OUTDIR=... xvfb-run -a cargo run --example neck_pivot_probe --locked
3use paperdoll_renderer::{camera, preview, production, project::CameraFraming};
4use std::{
5 path::{Path, PathBuf},
6 sync::{Arc, Mutex},
7};
8
9struct Quit;
10impl eframe::App for Quit {
11 fn update(&mut self, ctx: &eframe::egui::Context, _: &mut eframe::Frame) {
12 ctx.send_viewport_cmd(eframe::egui::ViewportCommand::Close);
13 }
14}
15
16fn write_rgba(path: &Path, width: u32, height: u32, rgba: &[u8]) -> Result<(), String> {
17 let file = std::fs::File::create(path).map_err(|e| e.to_string())?;
18 let mut encoder = png::Encoder::new(file, width, height);
19 encoder.set_color(png::ColorType::Rgba);
20 encoder.set_depth(png::BitDepth::Eight);
21 encoder
22 .write_header()
23 .map_err(|e| e.to_string())?
24 .write_image_data(rgba)
25 .map_err(|e| e.to_string())
26}
27
28fn downsample(src: &[u8], sw: u32, sh: u32, dw: u32, dh: u32) -> Vec<u8> {
29 let mut out = vec![0u8; (dw * dh * 4) as usize];
30 for y in 0..dh {
31 for x in 0..dw {
32 let sx = x * sw / dw;
33 let sy = y * sh / dh;
34 let si = ((sy * sw + sx) * 4) as usize;
35 let di = ((y * dw + x) * 4) as usize;
36 out[di..di + 4].copy_from_slice(&src[si..si + 4]);
37 }
38 }
39 out
40}
41
42fn main() -> Result<(), Box<dyn std::error::Error>> {
43 let out = PathBuf::from(
44 std::env::var("OUTDIR").unwrap_or_else(|_| "/workspace/paperdoll-neck-artifacts".into()),
45 );
46 std::fs::create_dir_all(&out)?;
47 let outcome = Arc::new(Mutex::new(None));
48 let result = outcome.clone();
49 let options = eframe::NativeOptions {
50 viewport: eframe::egui::ViewportBuilder::default().with_inner_size([64., 64.]),
51 ..paperdoll_renderer::preview_gl::native_options()
52 };
53 eframe::run_native(
54 "Paperdoll neck pivot probe",
55 options,
56 Box::new(move |creation| {
57 let test = (|| -> Result<serde_json::Value, String> {
58 let gl = creation
59 .gl
60 .as_ref()
61 .ok_or_else(|| "geen Glow-context".to_string())?;
62 let body = preview::load_gltf_file(
63 &Path::new(env!("CARGO_MANIFEST_DIR"))
64 .join("tests/fixtures/bodies/test-body-male.glb"),
65 )?;
66 let resolved = camera::resolve(&body, CameraFraming::default());
67 let neck_y = resolved.framing.pivot_y_milli.ok_or("nekpivot ontbreekt")?;
68 if neck_y == CameraFraming::default().center_y_milli {
69 return Err("nekpivot mag niet gelijk zijn aan default centrum".into());
70 }
71 let mut renderer = production::GpuRenderer::default();
72 let mut cache = paperdoll_renderer::equipment::TextureCache::default();
73 let pitch = 30.;
74 let yaw = 90.;
75 let mut job = production::RenderJob {
76 character: body,
77 sex: "male".into(),
78 preset: None,
79 target_width: 64,
80 target_height: 96,
81 fps: 12,
82 framing: CameraFraming {
83 height_milli: 2600,
84 center_y_milli: 1000,
85 pivot_y_milli: Some(1000),
86 },
87 output: out.clone(),
88 };
89 let old_scene = production::scene(&job, "walk", 0.0, pitch, yaw, &mut cache)?;
90 let old = unsafe { renderer.render(gl, &old_scene) }?;
91 job.framing.pivot_y_milli = None;
92 let neck_scene = production::scene(&job, "walk", 0.0, pitch, yaw, &mut cache)?;
93 let neck = unsafe { renderer.render(gl, &neck_scene) }?;
94 unsafe { renderer.destroy(gl) };
95 assert_eq!((old.width, old.height), (neck.width, neck.height));
96 let mut changed = 0usize;
97 for (a, b) in old
98 .diffuse
99 .chunks_exact(4)
100 .zip(neck.diffuse.chunks_exact(4))
101 {
102 if a != b {
103 changed += 1;
104 }
105 }
106 if changed < 200 {
107 return Err(format!(
108 "te weinig pixelverschil tussen centrum-pivot en nekpivot: {changed}"
109 ));
110 }
111 // Zero-pitch image center / feet framing must stay close after raising pivot.
112 job.framing.pivot_y_milli = Some(1000);
113 let old0 = production::scene(&job, "walk", 0.0, 0., 0., &mut cache)?;
114 job.framing.pivot_y_milli = None;
115 let neck0 = production::scene(&job, "walk", 0.0, 0., 0., &mut cache)?;
116 let old0b = unsafe {
117 let mut r = production::GpuRenderer::default();
118 let b = r.render(gl, &old0)?;
119 r.destroy(gl);
120 b
121 };
122 let neck0b = unsafe {
123 let mut r = production::GpuRenderer::default();
124 let b = r.render(gl, &neck0)?;
125 r.destroy(gl);
126 b
127 };
128 let mid = ((old0b.height / 2) * old0b.width + old0b.width / 2) as usize;
129 let feet = ((old0b.height * 9 / 10) * old0b.width + old0b.width / 2) as usize;
130 let mid_delta = old0b.diffuse[mid * 4..mid * 4 + 4]
131 .iter()
132 .zip(&neck0b.diffuse[mid * 4..mid * 4 + 4])
133 .map(|(a, b)| a.abs_diff(*b) as u32)
134 .sum::<u32>();
135 let feet_delta = old0b.diffuse[feet * 4..feet * 4 + 4]
136 .iter()
137 .zip(&neck0b.diffuse[feet * 4..feet * 4 + 4])
138 .map(|(a, b)| a.abs_diff(*b) as u32)
139 .sum::<u32>();
140 write_rgba(
141 &out.join("center-pivot-p30-y90.png"),
142 old.width,
143 old.height,
144 &old.diffuse,
145 )?;
146 write_rgba(
147 &out.join("neck-pivot-p30-y90.png"),
148 neck.width,
149 neck.height,
150 &neck.diffuse,
151 )?;
152 let contact_w = 256;
153 let contact_h = 192;
154 let left = downsample(
155 &old.diffuse,
156 old.width,
157 old.height,
158 contact_w / 2,
159 contact_h,
160 );
161 let right = downsample(
162 &neck.diffuse,
163 neck.width,
164 neck.height,
165 contact_w / 2,
166 contact_h,
167 );
168 let mut contact = vec![0u8; (contact_w * contact_h * 4) as usize];
169 for y in 0..contact_h {
170 for x in 0..contact_w / 2 {
171 let si = ((y * (contact_w / 2) + x) * 4) as usize;
172 let li = ((y * contact_w + x) * 4) as usize;
173 let ri = ((y * contact_w + contact_w / 2 + x) * 4) as usize;
174 contact[li..li + 4].copy_from_slice(&left[si..si + 4]);
175 contact[ri..ri + 4].copy_from_slice(&right[si..si + 4]);
176 }
177 }
178 write_rgba(
179 &out.join("neck-vs-center-contact.png"),
180 contact_w,
181 contact_h,
182 &contact,
183 )?;
184 let report = serde_json::json!({
185 "success": true,
186 "pitch": pitch,
187 "yaw": yaw,
188 "center_y_milli": 1000,
189 "neck_y_milli": neck_y,
190 "pivot_source": resolved.source,
191 "highres": [old.width, old.height],
192 "changed_pixels_pitch30": changed,
193 "zero_pitch_mid_channel_delta": mid_delta,
194 "zero_pitch_feet_channel_delta": feet_delta,
195 "artifacts": [
196 "center-pivot-p30-y90.png",
197 "neck-pivot-p30-y90.png",
198 "neck-vs-center-contact.png"
199 ]
200 });
201 std::fs::write(
202 out.join("neck-pivot-probe.json"),
203 serde_json::to_vec_pretty(&report).unwrap(),
204 )
205 .map_err(|e| e.to_string())?;
206 println!("NECK_PIVOT_PROBE {report}");
207 Ok(report)
208 })();
209 *result.lock().unwrap() = Some(test);
210 Ok(Box::new(Quit))
211 }),
212 )?;
213 let value = outcome
214 .lock()
215 .unwrap()
216 .take()
217 .ok_or("GPU-probe is niet uitgevoerd")??;
218 if value["success"] != true {
219 return Err("probe failed".into());
220 }
221 Ok(())
222}
src/camera.rs added +362 −0
@@ -0,0 +1,362 @@
1 //! Fixed render orbit pivot is independent of the orthographic image center.
2 use crate::{
3 preview::{self, Mat4, PreviewCharacter},
4 project::CameraFraming,
5 };
6 use serde::Serialize;
7 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
8 #[serde(tag = "kind", rename_all = "snake_case")]
9 pub enum PivotSource {
10 Manual,
11 Neck {
12 joint_index: usize,
13 joint_name: String,
14 },
15 Fallback {
16 reason: String,
17 },
18 }
19 #[derive(Debug, Clone, PartialEq, Eq)]
20 pub struct Resolved {
21 pub framing: CameraFraming,
22 pub source: PivotSource,
23 }
24 impl Resolved {
25 pub fn description(&self) -> String {
26 let y = self
27 .framing
28 .pivot_y_milli
29 .unwrap_or(self.framing.center_y_milli) as f32
30 / 1000.;
31 match &self.source {
32 PivotSource::Manual => format!("Vaste pivot Y {y:.3} m"),
33 PivotSource::Neck { joint_name, .. } => {
34 format!("Nekpivot {joint_name}: Y {y:.3} m (rustpose)")
35 }
36 PivotSource::Fallback { reason } => {
37 format!("{reason}; centrum Y {y:.3} m als fallback — stel de pivot handmatig in")
38 }
39 }
40 }
41 }
42 fn neck_name(name: &str) -> bool {
43 let lower = name.to_ascii_lowercase();
44 let is_neck = |token: &str| {
45 token
46 .strip_prefix("neck")
47 .is_some_and(|suffix| suffix.chars().all(|c| c.is_ascii_digit()))
48 };
49 lower
50 .split(|c: char| !c.is_ascii_alphanumeric())
51 .any(is_neck)
52 || ["mixamorig", "bip001", "bip01", "ccbase"]
53 .iter()
54 .any(|prefix| lower.strip_prefix(prefix).is_some_and(is_neck))
55 }
56 fn ancestor(body: &PreviewCharacter, parent: usize, child: usize) -> bool {
57 let mut current = Some(child);
58 for _ in 0..body.nodes.len() {
59 let Some(node) = current else { return false };
60 if node == parent {
61 return true;
62 }
63 current = body.nodes.get(node).and_then(|n| n.parent);
64 }
65 false
66 }
67 /// Select the base of one neck chain, not a similarly named unrelated skeleton.
68 pub fn neck_height(body: &PreviewCharacter) -> Result<(usize, i32), String> {
69 let candidates = body
70 .joint_names
71 .iter()
72 .enumerate()
73 .filter(|(_, n)| neck_name(n))
74 .map(|(j, _)| j)
75 .collect::<Vec<_>>();
76 if candidates.is_empty() {
77 return Err("Geen nekbot gevonden".into());
78 }
79 let roots = candidates
80 .iter()
81 .copied()
82 .filter(|j| {
83 candidates.iter().all(|other| {
84 match (body.joint_nodes.get(*j), body.joint_nodes.get(*other)) {
85 (Some(a), Some(b)) => ancestor(body, *a, *b),
86 _ => false,
87 }
88 })
89 })
90 .collect::<Vec<_>>();
91 if roots.len() != 1 {
92 return Err("Meerdere losstaande/ongeldige nekketens".into());
93 }
94 let joint = roots[0];
95 let world = preview::rest_joint_world(body, joint).ok_or("Ongeldige rustpose van nekbot")?;
96 let height = (world[13] * 1000.).round();
97 if !(-10000.0..=10000.0).contains(&height) {
98 return Err("Nekhoogte buiten -10…10 m".into());
99 }
100 Ok((joint, height as i32))
101 }
102 pub fn resolve(body: &PreviewCharacter, mut framing: CameraFraming) -> Resolved {
103 if framing.pivot_y_milli.is_some() {
104 return Resolved {
105 framing,
106 source: PivotSource::Manual,
107 };
108 }
109 let source = match neck_height(body) {
110 Ok((joint, y)) => {
111 framing.pivot_y_milli = Some(y);
112 PivotSource::Neck {
113 joint_index: joint,
114 joint_name: body.joint_names[joint].clone(),
115 }
116 }
117 Err(reason) => {
118 framing.pivot_y_milli = Some(framing.center_y_milli);
119 PivotSource::Fallback { reason }
120 }
121 };
122 Resolved { framing, source }
123 }
124 /// Translation around the neck is separate from the image's vertical framing.
125 /// Keeping the orthographic offset constant makes the orbit pivot pixel stable
126 /// for every yaw/pitch without cropping the feet merely by raising the pivot.
127 pub fn matrices(framing: CameraFraming, yaw: f32, pitch: f32, aspect: f32) -> (Mat4, Mat4) {
128 let pivot = framing.pivot_y_milli.unwrap_or(framing.center_y_milli) as f32 / 1000.;
129 let center = framing.center_y_milli as f32 / 1000.;
130 let target = [0., pivot, 0.];
131 let d = preview::eye_offset(yaw, pitch, 10.);
132 let eye = [d[0], pivot + d[1], d[2]];
133 let half = framing.height_milli as f32 / 2000.;
134 let mut p = preview::identity();
135 p[0] = 1. / (half * aspect);
136 p[5] = 1. / half;
137 p[13] = (pivot - center) / half;
138 p[10] = -2. / (crate::production::FAR - crate::production::NEAR);
139 p[14] = -(crate::production::FAR + crate::production::NEAR)
140 / (crate::production::FAR - crate::production::NEAR);
141 (preview::view_matrix(eye, target), p)
142 }
143
144 #[cfg(test)]
145 mod tests {
146 use super::*;
147 use crate::preview::{self, PreviewCharacter, SceneNode};
148
149 fn node(name: &str, parent: Option<usize>, y: f32) -> SceneNode {
150 SceneNode {
151 name: name.into(),
152 parent,
153 translation: [0., y, 0.],
154 rotation: [0., 0., 0., 1.],
155 scale: [1., 1., 1.],
156 }
157 }
158
159 fn rig(names: &[&str], parents: &[Option<usize>], ys: &[f32]) -> PreviewCharacter {
160 assert_eq!(names.len(), parents.len());
161 assert_eq!(names.len(), ys.len());
162 let nodes = names
163 .iter()
164 .zip(parents)
165 .zip(ys)
166 .map(|((name, parent), y)| node(name, *parent, *y))
167 .collect();
168 PreviewCharacter {
169 nodes,
170 joint_nodes: (0..names.len()).collect(),
171 joint_names: names.iter().map(|n| (*n).into()).collect(),
172 inverse_binds: vec![preview::identity(); names.len()],
173 positions: Vec::new(),
174 normals: Vec::new(),
175 source_normals_complete: true,
176 joints: Vec::new(),
177 weights: Vec::new(),
178 indices: Vec::new(),
179 base_color: [1., 1., 1., 1.],
180 colors: Vec::new(),
181 uvs: Vec::new(),
182 surfaces: Vec::new(),
183 clips: Vec::new(),
184 }
185 }
186
187 fn project(view: Mat4, projection: Mat4, world: [f32; 3]) -> [f32; 3] {
188 let eye = [
189 view[0] * world[0] + view[4] * world[1] + view[8] * world[2] + view[12],
190 view[1] * world[0] + view[5] * world[1] + view[9] * world[2] + view[13],
191 view[2] * world[0] + view[6] * world[1] + view[10] * world[2] + view[14],
192 ];
193 [
194 projection[0] * eye[0] + projection[12],
195 projection[5] * eye[1] + projection[13],
196 projection[10] * eye[2] + projection[14],
197 ]
198 }
199
200 #[test]
201 fn neck_name_variants_and_missing_ambiguous() {
202 let cases = [
203 ("Neck", 1.5),
204 ("mixamorigNeck", 1.6),
205 ("Bip001 Neck", 1.7),
206 ("Bip01 Neck", 1.55),
207 ("CC_Base_Neck", 1.45),
208 ("neck2", 1.42),
209 ];
210 for (name, y) in cases {
211 let body = rig(&["Hips", name], &[None, Some(0)], &[1.0, y - 1.0]);
212 let (joint, milli) = neck_height(&body).unwrap();
213 assert_eq!(body.joint_names[joint], name);
214 assert_eq!(milli, (y * 1000.).round() as i32);
215 }
216 assert!(neck_height(&rig(&["Hips", "Head"], &[None, Some(0)], &[1., 0.5])).is_err());
217 let ambiguous = rig(
218 &["Hips", "Neck", "Prop Neck"],
219 &[None, Some(0), None],
220 &[1., 0.5, 2.],
221 );
222 assert!(neck_height(&ambiguous).is_err());
223 let chain = rig(
224 &["Hips", "Neck", "Neck1"],
225 &[None, Some(0), Some(1)],
226 &[1., 0.5, 0.1],
227 );
228 let (joint, _) = neck_height(&chain).unwrap();
229 assert_eq!(chain.joint_names[joint], "Neck");
230 }
231
232 #[test]
233 fn resolve_manual_override_wins_and_fallback_uses_center() {
234 let body = rig(&["Hips", "Neck"], &[None, Some(0)], &[1.0, 0.5]);
235 let auto = resolve(
236 &body,
237 CameraFraming {
238 height_milli: 2600,
239 center_y_milli: 1000,
240 pivot_y_milli: None,
241 },
242 );
243 assert!(matches!(auto.source, PivotSource::Neck { .. }));
244 assert_eq!(auto.framing.pivot_y_milli, Some(1500));
245 let manual = resolve(
246 &body,
247 CameraFraming {
248 height_milli: 2600,
249 center_y_milli: 1000,
250 pivot_y_milli: Some(1800),
251 },
252 );
253 assert_eq!(manual.source, PivotSource::Manual);
254 assert_eq!(manual.framing.pivot_y_milli, Some(1800));
255 let missing = resolve(
256 &rig(&["Hips"], &[None], &[1.0]),
257 CameraFraming {
258 height_milli: 2600,
259 center_y_milli: 900,
260 pivot_y_milli: None,
261 },
262 );
263 assert!(matches!(missing.source, PivotSource::Fallback { .. }));
264 assert_eq!(missing.framing.pivot_y_milli, Some(900));
265 }
266
267 #[test]
268 fn zero_pitch_keeps_image_center_when_pivot_differs() {
269 let framing = CameraFraming {
270 height_milli: 2600,
271 center_y_milli: 1000,
272 pivot_y_milli: Some(1600),
273 };
274 for yaw in [0., 90., 180., 270.] {
275 let (view, projection) = matrices(framing, yaw, 0., 1.);
276 let center = project(view, projection, [0., 1., 0.]);
277 assert!(
278 center[0].abs() < 1e-4 && center[1].abs() < 1e-4,
279 "yaw {yaw}: center NDC {center:?}"
280 );
281 let feet = project(view, projection, [0., 0., 0.]);
282 assert!(
283 (feet[1] + 1000. / 1300.).abs() < 1e-4,
284 "yaw {yaw}: feet NDC y {}",
285 feet[1]
286 );
287 }
288 }
289
290 #[test]
291 fn all_36_angles_share_pivot_and_neck_projects_stably() {
292 let framing = CameraFraming {
293 height_milli: 3000,
294 center_y_milli: 1100,
295 pivot_y_milli: Some(1500),
296 };
297 let mut pivot_ndcs = Vec::new();
298 for pitch in [-30., 0., 30.] {
299 for yaw in (0..12).map(|i| i as f32 * 30.) {
300 let (view, projection) = matrices(framing, yaw, pitch, 64. / 96.);
301 let pivot = project(view, projection, [0., 1.5, 0.]);
302 assert!(
303 pivot[0].abs() < 1e-4,
304 "pivot NDC x drifted at yaw {yaw} pitch {pitch}: {pivot:?}"
305 );
306 pivot_ndcs.push(pivot[1]);
307 let old = CameraFraming {
308 pivot_y_milli: Some(framing.center_y_milli),
309 ..framing
310 };
311 let (old_view, old_projection) = matrices(old, yaw, pitch, 64. / 96.);
312 assert_ne!(
313 view, old_view,
314 "eye must move with pivot at yaw {yaw} pitch {pitch}"
315 );
316 let neck_new = project(view, projection, [0., 1.5, 0.]);
317 let neck_old = project(old_view, old_projection, [0., 1.5, 0.]);
318 if pitch.abs() > 1. {
319 // Ortho Y-compensation cancels at pitch 0; pitched residual is δ/half*(1-cos θ).
320 assert!(
321 (neck_new[1] - neck_old[1]).abs() > 0.02,
322 "pitched neck residual too small at pitch {pitch}: {} vs {}",
323 neck_new[1],
324 neck_old[1]
325 );
326 assert!(
327 (neck_new[2] - neck_old[2]).abs() > 1e-4
328 || (view[13] - old_view[13]).abs() > 1e-4,
329 "view translation/depth must change with pivot"
330 );
331 } else {
332 assert!((neck_new[1] - neck_old[1]).abs() < 1e-4);
333 }
334 }
335 }
336 assert_eq!(pivot_ndcs.len(), 36);
337 let first = pivot_ndcs[0];
338 assert!(pivot_ndcs.iter().all(|y| (y - first).abs() < 1e-4));
339 }
340
341 #[test]
342 fn male_fixture_neck_is_detected() {
343 let body = preview::load_gltf_file(
344 &std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
345 .join("tests/fixtures/bodies/test-body-male.glb"),
346 )
347 .unwrap();
348 let resolved = resolve(&body, CameraFraming::default());
349 assert!(matches!(
350 resolved.source,
351 PivotSource::Neck {
352 joint_name: ref name,
353 ..
354 } if name == "Neck"
355 ));
356 assert_eq!(resolved.framing.pivot_y_milli, Some(1460));
357 assert_ne!(
358 resolved.framing.pivot_y_milli.unwrap(),
359 resolved.framing.center_y_milli
360 );
361 }
362 }
@@ -0,0 +1,362 @@
1//! Fixed render orbit pivot is independent of the orthographic image center.
2use crate::{
3 preview::{self, Mat4, PreviewCharacter},
4 project::CameraFraming,
5};
6use serde::Serialize;
7#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
8#[serde(tag = "kind", rename_all = "snake_case")]
9pub enum PivotSource {
10 Manual,
11 Neck {
12 joint_index: usize,
13 joint_name: String,
14 },
15 Fallback {
16 reason: String,
17 },
18}
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct Resolved {
21 pub framing: CameraFraming,
22 pub source: PivotSource,
23}
24impl Resolved {
25 pub fn description(&self) -> String {
26 let y = self
27 .framing
28 .pivot_y_milli
29 .unwrap_or(self.framing.center_y_milli) as f32
30 / 1000.;
31 match &self.source {
32 PivotSource::Manual => format!("Vaste pivot Y {y:.3} m"),
33 PivotSource::Neck { joint_name, .. } => {
34 format!("Nekpivot {joint_name}: Y {y:.3} m (rustpose)")
35 }
36 PivotSource::Fallback { reason } => {
37 format!("{reason}; centrum Y {y:.3} m als fallback — stel de pivot handmatig in")
38 }
39 }
40 }
41}
42fn neck_name(name: &str) -> bool {
43 let lower = name.to_ascii_lowercase();
44 let is_neck = |token: &str| {
45 token
46 .strip_prefix("neck")
47 .is_some_and(|suffix| suffix.chars().all(|c| c.is_ascii_digit()))
48 };
49 lower
50 .split(|c: char| !c.is_ascii_alphanumeric())
51 .any(is_neck)
52 || ["mixamorig", "bip001", "bip01", "ccbase"]
53 .iter()
54 .any(|prefix| lower.strip_prefix(prefix).is_some_and(is_neck))
55}
56fn ancestor(body: &PreviewCharacter, parent: usize, child: usize) -> bool {
57 let mut current = Some(child);
58 for _ in 0..body.nodes.len() {
59 let Some(node) = current else { return false };
60 if node == parent {
61 return true;
62 }
63 current = body.nodes.get(node).and_then(|n| n.parent);
64 }
65 false
66}
67/// Select the base of one neck chain, not a similarly named unrelated skeleton.
68pub fn neck_height(body: &PreviewCharacter) -> Result<(usize, i32), String> {
69 let candidates = body
70 .joint_names
71 .iter()
72 .enumerate()
73 .filter(|(_, n)| neck_name(n))
74 .map(|(j, _)| j)
75 .collect::<Vec<_>>();
76 if candidates.is_empty() {
77 return Err("Geen nekbot gevonden".into());
78 }
79 let roots = candidates
80 .iter()
81 .copied()
82 .filter(|j| {
83 candidates.iter().all(|other| {
84 match (body.joint_nodes.get(*j), body.joint_nodes.get(*other)) {
85 (Some(a), Some(b)) => ancestor(body, *a, *b),
86 _ => false,
87 }
88 })
89 })
90 .collect::<Vec<_>>();
91 if roots.len() != 1 {
92 return Err("Meerdere losstaande/ongeldige nekketens".into());
93 }
94 let joint = roots[0];
95 let world = preview::rest_joint_world(body, joint).ok_or("Ongeldige rustpose van nekbot")?;
96 let height = (world[13] * 1000.).round();
97 if !(-10000.0..=10000.0).contains(&height) {
98 return Err("Nekhoogte buiten -10…10 m".into());
99 }
100 Ok((joint, height as i32))
101}
102pub fn resolve(body: &PreviewCharacter, mut framing: CameraFraming) -> Resolved {
103 if framing.pivot_y_milli.is_some() {
104 return Resolved {
105 framing,
106 source: PivotSource::Manual,
107 };
108 }
109 let source = match neck_height(body) {
110 Ok((joint, y)) => {
111 framing.pivot_y_milli = Some(y);
112 PivotSource::Neck {
113 joint_index: joint,
114 joint_name: body.joint_names[joint].clone(),
115 }
116 }
117 Err(reason) => {
118 framing.pivot_y_milli = Some(framing.center_y_milli);
119 PivotSource::Fallback { reason }
120 }
121 };
122 Resolved { framing, source }
123}
124/// Translation around the neck is separate from the image's vertical framing.
125/// Keeping the orthographic offset constant makes the orbit pivot pixel stable
126/// for every yaw/pitch without cropping the feet merely by raising the pivot.
127pub fn matrices(framing: CameraFraming, yaw: f32, pitch: f32, aspect: f32) -> (Mat4, Mat4) {
128 let pivot = framing.pivot_y_milli.unwrap_or(framing.center_y_milli) as f32 / 1000.;
129 let center = framing.center_y_milli as f32 / 1000.;
130 let target = [0., pivot, 0.];
131 let d = preview::eye_offset(yaw, pitch, 10.);
132 let eye = [d[0], pivot + d[1], d[2]];
133 let half = framing.height_milli as f32 / 2000.;
134 let mut p = preview::identity();
135 p[0] = 1. / (half * aspect);
136 p[5] = 1. / half;
137 p[13] = (pivot - center) / half;
138 p[10] = -2. / (crate::production::FAR - crate::production::NEAR);
139 p[14] = -(crate::production::FAR + crate::production::NEAR)
140 / (crate::production::FAR - crate::production::NEAR);
141 (preview::view_matrix(eye, target), p)
142}
143
144#[cfg(test)]
145mod tests {
146 use super::*;
147 use crate::preview::{self, PreviewCharacter, SceneNode};
148
149 fn node(name: &str, parent: Option<usize>, y: f32) -> SceneNode {
150 SceneNode {
151 name: name.into(),
152 parent,
153 translation: [0., y, 0.],
154 rotation: [0., 0., 0., 1.],
155 scale: [1., 1., 1.],
156 }
157 }
158
159 fn rig(names: &[&str], parents: &[Option<usize>], ys: &[f32]) -> PreviewCharacter {
160 assert_eq!(names.len(), parents.len());
161 assert_eq!(names.len(), ys.len());
162 let nodes = names
163 .iter()
164 .zip(parents)
165 .zip(ys)
166 .map(|((name, parent), y)| node(name, *parent, *y))
167 .collect();
168 PreviewCharacter {
169 nodes,
170 joint_nodes: (0..names.len()).collect(),
171 joint_names: names.iter().map(|n| (*n).into()).collect(),
172 inverse_binds: vec![preview::identity(); names.len()],
173 positions: Vec::new(),
174 normals: Vec::new(),
175 source_normals_complete: true,
176 joints: Vec::new(),
177 weights: Vec::new(),
178 indices: Vec::new(),
179 base_color: [1., 1., 1., 1.],
180 colors: Vec::new(),
181 uvs: Vec::new(),
182 surfaces: Vec::new(),
183 clips: Vec::new(),
184 }
185 }
186
187 fn project(view: Mat4, projection: Mat4, world: [f32; 3]) -> [f32; 3] {
188 let eye = [
189 view[0] * world[0] + view[4] * world[1] + view[8] * world[2] + view[12],
190 view[1] * world[0] + view[5] * world[1] + view[9] * world[2] + view[13],
191 view[2] * world[0] + view[6] * world[1] + view[10] * world[2] + view[14],
192 ];
193 [
194 projection[0] * eye[0] + projection[12],
195 projection[5] * eye[1] + projection[13],
196 projection[10] * eye[2] + projection[14],
197 ]
198 }
199
200 #[test]
201 fn neck_name_variants_and_missing_ambiguous() {
202 let cases = [
203 ("Neck", 1.5),
204 ("mixamorigNeck", 1.6),
205 ("Bip001 Neck", 1.7),
206 ("Bip01 Neck", 1.55),
207 ("CC_Base_Neck", 1.45),
208 ("neck2", 1.42),
209 ];
210 for (name, y) in cases {
211 let body = rig(&["Hips", name], &[None, Some(0)], &[1.0, y - 1.0]);
212 let (joint, milli) = neck_height(&body).unwrap();
213 assert_eq!(body.joint_names[joint], name);
214 assert_eq!(milli, (y * 1000.).round() as i32);
215 }
216 assert!(neck_height(&rig(&["Hips", "Head"], &[None, Some(0)], &[1., 0.5])).is_err());
217 let ambiguous = rig(
218 &["Hips", "Neck", "Prop Neck"],
219 &[None, Some(0), None],
220 &[1., 0.5, 2.],
221 );
222 assert!(neck_height(&ambiguous).is_err());
223 let chain = rig(
224 &["Hips", "Neck", "Neck1"],
225 &[None, Some(0), Some(1)],
226 &[1., 0.5, 0.1],
227 );
228 let (joint, _) = neck_height(&chain).unwrap();
229 assert_eq!(chain.joint_names[joint], "Neck");
230 }
231
232 #[test]
233 fn resolve_manual_override_wins_and_fallback_uses_center() {
234 let body = rig(&["Hips", "Neck"], &[None, Some(0)], &[1.0, 0.5]);
235 let auto = resolve(
236 &body,
237 CameraFraming {
238 height_milli: 2600,
239 center_y_milli: 1000,
240 pivot_y_milli: None,
241 },
242 );
243 assert!(matches!(auto.source, PivotSource::Neck { .. }));
244 assert_eq!(auto.framing.pivot_y_milli, Some(1500));
245 let manual = resolve(
246 &body,
247 CameraFraming {
248 height_milli: 2600,
249 center_y_milli: 1000,
250 pivot_y_milli: Some(1800),
251 },
252 );
253 assert_eq!(manual.source, PivotSource::Manual);
254 assert_eq!(manual.framing.pivot_y_milli, Some(1800));
255 let missing = resolve(
256 &rig(&["Hips"], &[None], &[1.0]),
257 CameraFraming {
258 height_milli: 2600,
259 center_y_milli: 900,
260 pivot_y_milli: None,
261 },
262 );
263 assert!(matches!(missing.source, PivotSource::Fallback { .. }));
264 assert_eq!(missing.framing.pivot_y_milli, Some(900));
265 }
266
267 #[test]
268 fn zero_pitch_keeps_image_center_when_pivot_differs() {
269 let framing = CameraFraming {
270 height_milli: 2600,
271 center_y_milli: 1000,
272 pivot_y_milli: Some(1600),
273 };
274 for yaw in [0., 90., 180., 270.] {
275 let (view, projection) = matrices(framing, yaw, 0., 1.);
276 let center = project(view, projection, [0., 1., 0.]);
277 assert!(
278 center[0].abs() < 1e-4 && center[1].abs() < 1e-4,
279 "yaw {yaw}: center NDC {center:?}"
280 );
281 let feet = project(view, projection, [0., 0., 0.]);
282 assert!(
283 (feet[1] + 1000. / 1300.).abs() < 1e-4,
284 "yaw {yaw}: feet NDC y {}",
285 feet[1]
286 );
287 }
288 }
289
290 #[test]
291 fn all_36_angles_share_pivot_and_neck_projects_stably() {
292 let framing = CameraFraming {
293 height_milli: 3000,
294 center_y_milli: 1100,
295 pivot_y_milli: Some(1500),
296 };
297 let mut pivot_ndcs = Vec::new();
298 for pitch in [-30., 0., 30.] {
299 for yaw in (0..12).map(|i| i as f32 * 30.) {
300 let (view, projection) = matrices(framing, yaw, pitch, 64. / 96.);
301 let pivot = project(view, projection, [0., 1.5, 0.]);
302 assert!(
303 pivot[0].abs() < 1e-4,
304 "pivot NDC x drifted at yaw {yaw} pitch {pitch}: {pivot:?}"
305 );
306 pivot_ndcs.push(pivot[1]);
307 let old = CameraFraming {
308 pivot_y_milli: Some(framing.center_y_milli),
309 ..framing
310 };
311 let (old_view, old_projection) = matrices(old, yaw, pitch, 64. / 96.);
312 assert_ne!(
313 view, old_view,
314 "eye must move with pivot at yaw {yaw} pitch {pitch}"
315 );
316 let neck_new = project(view, projection, [0., 1.5, 0.]);
317 let neck_old = project(old_view, old_projection, [0., 1.5, 0.]);
318 if pitch.abs() > 1. {
319 // Ortho Y-compensation cancels at pitch 0; pitched residual is δ/half*(1-cos θ).
320 assert!(
321 (neck_new[1] - neck_old[1]).abs() > 0.02,
322 "pitched neck residual too small at pitch {pitch}: {} vs {}",
323 neck_new[1],
324 neck_old[1]
325 );
326 assert!(
327 (neck_new[2] - neck_old[2]).abs() > 1e-4
328 || (view[13] - old_view[13]).abs() > 1e-4,
329 "view translation/depth must change with pivot"
330 );
331 } else {
332 assert!((neck_new[1] - neck_old[1]).abs() < 1e-4);
333 }
334 }
335 }
336 assert_eq!(pivot_ndcs.len(), 36);
337 let first = pivot_ndcs[0];
338 assert!(pivot_ndcs.iter().all(|y| (y - first).abs() < 1e-4));
339 }
340
341 #[test]
342 fn male_fixture_neck_is_detected() {
343 let body = preview::load_gltf_file(
344 &std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
345 .join("tests/fixtures/bodies/test-body-male.glb"),
346 )
347 .unwrap();
348 let resolved = resolve(&body, CameraFraming::default());
349 assert!(matches!(
350 resolved.source,
351 PivotSource::Neck {
352 joint_name: ref name,
353 ..
354 } if name == "Neck"
355 ));
356 assert_eq!(resolved.framing.pivot_y_milli, Some(1460));
357 assert_ne!(
358 resolved.framing.pivot_y_milli.unwrap(),
359 resolved.framing.center_y_milli
360 );
361 }
362}
src/editor.rs +51 −0
@@ -505,10 +505,37 @@ impl EditorApp {
505 505 }
506 506 }
507 507 fn framing_ui(&mut self, ui: &mut egui::Ui, sex: usize) {
508 let diagnostic = {
509 let framing = self
510 .session
511 .opened
512 .as_ref()
513 .map(|opened| opened.document.render.framing[sex]);
514 let body = match sex {
515 0 => self.preview_male.as_ref(),
516 _ => self.preview_female.as_ref(),
517 };
518 match (body, framing) {
519 (Some(body), Some(framing)) => Some(crate::camera::resolve(body, framing)),
520 _ => None,
521 }
522 };
508 523 if let Some(opened) = &mut self.session.opened {
509 524 let camera = &mut opened.document.render.framing[sex];
510 525 let mut height = camera.height_milli as f32 / 1000.;
511 526 let mut center = camera.center_y_milli as f32 / 1000.;
527 let mut pivot = diagnostic
528 .as_ref()
529 .map(|resolved| {
530 resolved
531 .framing
532 .pivot_y_milli
533 .unwrap_or(resolved.framing.center_y_milli) as f32
534 / 1000.
535 })
536 .unwrap_or_else(|| {
537 camera.pivot_y_milli.unwrap_or(camera.center_y_milli) as f32 / 1000.
538 });
512 539 ui.horizontal_wrapped(|ui| {
513 540 ui.label("Vaste renderhoogte (m)");
514 541 if ui
@@ -532,8 +559,32 @@ impl EditorApp {
532 559 {
533 560 camera.center_y_milli = (center * 1000.).round() as i32;
534 561 }
562 ui.label("Pivot Y (m)");
563 if ui
564 .add(
565 egui::DragValue::new(&mut pivot)
566 .speed(0.01)
567 .range(-10.0..=10.0),
568 )
569 .changed()
570 {
571 camera.pivot_y_milli = Some((pivot * 1000.).round() as i32);
572 }
573 if camera.pivot_y_milli.is_some()
574 && ui
575 .button("Auto nek")
576 .on_hover_text("Wis handmatige pivot; gebruik rustpose-nekhoogte")
577 .clicked()
578 {
579 camera.pivot_y_milli = None;
580 }
535 581 ui.checkbox(&mut self.show_render_frame, "Vast renderkader tonen");
536 582 });
583 if let Some(resolved) = &diagnostic {
584 ui.label(resolved.description());
585 } else {
586 ui.label("Laad een glTF/GLB-lichaam om de nekpivot te bepalen.");
587 }
537 588 }
538 589 }
539 590 fn fixed_camera(&self) -> Option<(crate::project::CameraFraming, [u32; 2])> {
@@ -505,10 +505,37 @@ impl EditorApp {
505 }
505 }
506 }
506 }
507 fn framing_ui(&mut self, ui: &mut egui::Ui, sex: usize) {
507 fn framing_ui(&mut self, ui: &mut egui::Ui, sex: usize) {
508 let diagnostic = {
509 let framing = self
510 .session
511 .opened
512 .as_ref()
513 .map(|opened| opened.document.render.framing[sex]);
514 let body = match sex {
515 0 => self.preview_male.as_ref(),
516 _ => self.preview_female.as_ref(),
517 };
518 match (body, framing) {
519 (Some(body), Some(framing)) => Some(crate::camera::resolve(body, framing)),
520 _ => None,
521 }
522 };
508 if let Some(opened) = &mut self.session.opened {
523 if let Some(opened) = &mut self.session.opened {
509 let camera = &mut opened.document.render.framing[sex];
524 let camera = &mut opened.document.render.framing[sex];
510 let mut height = camera.height_milli as f32 / 1000.;
525 let mut height = camera.height_milli as f32 / 1000.;
511 let mut center = camera.center_y_milli as f32 / 1000.;
526 let mut center = camera.center_y_milli as f32 / 1000.;
527 let mut pivot = diagnostic
528 .as_ref()
529 .map(|resolved| {
530 resolved
531 .framing
532 .pivot_y_milli
533 .unwrap_or(resolved.framing.center_y_milli) as f32
534 / 1000.
535 })
536 .unwrap_or_else(|| {
537 camera.pivot_y_milli.unwrap_or(camera.center_y_milli) as f32 / 1000.
538 });
512 ui.horizontal_wrapped(|ui| {
539 ui.horizontal_wrapped(|ui| {
513 ui.label("Vaste renderhoogte (m)");
540 ui.label("Vaste renderhoogte (m)");
514 if ui
541 if ui
@@ -532,8 +559,32 @@ impl EditorApp {
532 {
559 {
533 camera.center_y_milli = (center * 1000.).round() as i32;
560 camera.center_y_milli = (center * 1000.).round() as i32;
534 }
561 }
562 ui.label("Pivot Y (m)");
563 if ui
564 .add(
565 egui::DragValue::new(&mut pivot)
566 .speed(0.01)
567 .range(-10.0..=10.0),
568 )
569 .changed()
570 {
571 camera.pivot_y_milli = Some((pivot * 1000.).round() as i32);
572 }
573 if camera.pivot_y_milli.is_some()
574 && ui
575 .button("Auto nek")
576 .on_hover_text("Wis handmatige pivot; gebruik rustpose-nekhoogte")
577 .clicked()
578 {
579 camera.pivot_y_milli = None;
580 }
535 ui.checkbox(&mut self.show_render_frame, "Vast renderkader tonen");
581 ui.checkbox(&mut self.show_render_frame, "Vast renderkader tonen");
536 });
582 });
583 if let Some(resolved) = &diagnostic {
584 ui.label(resolved.description());
585 } else {
586 ui.label("Laad een glTF/GLB-lichaam om de nekpivot te bepalen.");
587 }
537 }
588 }
538 }
589 }
539 fn fixed_camera(&self) -> Option<(crate::project::CameraFraming, [u32; 2])> {
590 fn fixed_camera(&self) -> Option<(crate::project::CameraFraming, [u32; 2])> {
src/lib.rs +1 −0
@@ -3,6 +3,7 @@
3 3 //! The shell stores a `.ppd` project, reads glTF/GLB, FBX and OBJ+MTL,
4 4 //! draws skinned body/equipment previews, and streams production target maps.
5 5
6 pub mod camera;
6 7 pub mod dialogs;
7 8 pub mod editor;
8 9 pub mod equipment;
@@ -3,6 +3,7 @@
3//! The shell stores a `.ppd` project, reads glTF/GLB, FBX and OBJ+MTL,
3//! The shell stores a `.ppd` project, reads glTF/GLB, FBX and OBJ+MTL,
4//! draws skinned body/equipment previews, and streams production target maps.
4//! draws skinned body/equipment previews, and streams production target maps.
5
5
6pub mod camera;
6pub mod dialogs;
7pub mod dialogs;
7pub mod editor;
8pub mod editor;
8pub mod equipment;
9pub mod equipment;
src/preview.rs +21 −0
@@ -393,6 +393,27 @@ pub fn joint_paths(character: &PreviewCharacter) -> Vec<String> {
393 393 .collect()
394 394 }
395 395
396 /// Rest-world joint transform, never selected by a possibly empty clip name.
397 /// Bounded parent walk rejects malformed cycles/indices instead of recursing.
398 pub fn rest_joint_world(character: &PreviewCharacter, joint: usize) -> Option<Mat4> {
399 let mut node = Some(*character.joint_nodes.get(joint)?);
400 let mut chain = Vec::new();
401 while let Some(i) = node {
402 if chain.len() >= character.nodes.len() {
403 return None;
404 }
405 let n = character.nodes.get(i)?;
406 chain.push(i);
407 node = n.parent;
408 }
409 let mut world = identity();
410 for i in chain.into_iter().rev() {
411 let n = &character.nodes[i];
412 world = mul(world, trs(n.translation, n.rotation, n.scale));
413 }
414 world.iter().all(|n| n.is_finite()).then_some(world)
415 }
416
396 417 pub fn animated_joint_world(
397 418 character: &PreviewCharacter,
398 419 clip: &str,
@@ -393,6 +393,27 @@ pub fn joint_paths(character: &PreviewCharacter) -> Vec<String> {
393 .collect()
393 .collect()
394}
394}
395
395
396/// Rest-world joint transform, never selected by a possibly empty clip name.
397/// Bounded parent walk rejects malformed cycles/indices instead of recursing.
398pub fn rest_joint_world(character: &PreviewCharacter, joint: usize) -> Option<Mat4> {
399 let mut node = Some(*character.joint_nodes.get(joint)?);
400 let mut chain = Vec::new();
401 while let Some(i) = node {
402 if chain.len() >= character.nodes.len() {
403 return None;
404 }
405 let n = character.nodes.get(i)?;
406 chain.push(i);
407 node = n.parent;
408 }
409 let mut world = identity();
410 for i in chain.into_iter().rev() {
411 let n = &character.nodes[i];
412 world = mul(world, trs(n.translation, n.rotation, n.scale));
413 }
414 world.iter().all(|n| n.is_finite()).then_some(world)
415}
416
396pub fn animated_joint_world(
417pub fn animated_joint_world(
397 character: &PreviewCharacter,
418 character: &PreviewCharacter,
398 clip: &str,
419 clip: &str,
src/preview_gl.rs +11 −2
@@ -67,7 +67,16 @@ impl Viewport {
67 67 presets: &[&crate::equipment::Preset],
68 68 framing: Option<(crate::project::CameraFraming, [u32; 2])>,
69 69 ) {
70 let framing = framing.filter(|(c, r)| c.validate().is_ok() && r[0] > 0 && r[1] > 0);
70 let framing = framing
71 .filter(|(c, r)| c.validate().is_ok() && r[0] > 0 && r[1] > 0)
72 .map(|(c, r)| {
73 (
74 character
75 .map(|body| crate::camera::resolve(body, c).framing)
76 .unwrap_or(c),
77 r,
78 )
79 });
71 80 ui.horizontal_wrapped(|ui| {
72 81 if let Some(character) = character {
73 82 if !character
@@ -159,7 +168,7 @@ impl Viewport {
159 168 let target = [
160 169 0.0,
161 170 framing
162 .map(|(c, _)| c.center_y_milli as f32 / 1000.)
171 .map(|(c, _)| c.pivot_y_milli.unwrap_or(c.center_y_milli) as f32 / 1000.)
163 172 .unwrap_or(0.9),
164 173 0.0,
165 174 ];
@@ -67,7 +67,16 @@ impl Viewport {
67 presets: &[&crate::equipment::Preset],
67 presets: &[&crate::equipment::Preset],
68 framing: Option<(crate::project::CameraFraming, [u32; 2])>,
68 framing: Option<(crate::project::CameraFraming, [u32; 2])>,
69 ) {
69 ) {
70 let framing = framing.filter(|(c, r)| c.validate().is_ok() && r[0] > 0 && r[1] > 0);
70 let framing = framing
71 .filter(|(c, r)| c.validate().is_ok() && r[0] > 0 && r[1] > 0)
72 .map(|(c, r)| {
73 (
74 character
75 .map(|body| crate::camera::resolve(body, c).framing)
76 .unwrap_or(c),
77 r,
78 )
79 });
71 ui.horizontal_wrapped(|ui| {
80 ui.horizontal_wrapped(|ui| {
72 if let Some(character) = character {
81 if let Some(character) = character {
73 if !character
82 if !character
@@ -159,7 +168,7 @@ impl Viewport {
159 let target = [
168 let target = [
160 0.0,
169 0.0,
161 framing
170 framing
162 .map(|(c, _)| c.center_y_milli as f32 / 1000.)
171 .map(|(c, _)| c.pivot_y_milli.unwrap_or(c.center_y_milli) as f32 / 1000.)
163 .unwrap_or(0.9),
172 .unwrap_or(0.9),
164 0.0,
173 0.0,
165 ];
174 ];
src/production.rs +23 −14
@@ -211,18 +211,8 @@ pub fn output_path(
211 211 .join(format!("f{frame:06}"))
212 212 .join(format!("p{pitch:+03}_y{yaw:03}_{map}.png")))
213 213 }
214 pub fn camera(framing: CameraFraming, yaw: f32, pitch: f32, aspect: f32) -> (Mat4, Mat4) {
215 let target = [0., framing.center_y_milli as f32 / 1000., 0.];
216 let d = preview::eye_offset(yaw, pitch, 10.);
217 let eye = [d[0], target[1] + d[1], d[2]];
218 let half = framing.height_milli as f32 / 2000.;
219 let mut p = preview::identity();
220 p[0] = 1. / (half * aspect);
221 p[5] = 1. / half;
222 p[10] = -2. / (FAR - NEAR);
223 p[14] = -(FAR + NEAR) / (FAR - NEAR);
224 (preview::view_matrix(eye, target), p)
225 }
214 pub use crate::camera::matrices as camera;
215
226 216 fn point(m: &Mat4, p: [f32; 3]) -> [f32; 3] {
227 217 std::array::from_fn(|i| m[i] * p[0] + m[i + 4] * p[1] + m[i + 8] * p[2] + m[i + 12])
228 218 }
@@ -360,7 +350,7 @@ pub fn scene(
360 350 }
361 351 }
362 352 let (view, projection) = camera(
363 job.framing,
353 crate::camera::resolve(body, job.framing).framing,
364 354 yaw,
365 355 pitch,
366 356 job.target_width as f32 / job.target_height as f32,
@@ -1224,6 +1214,8 @@ fn execute(
1224 1214 emit: &mut dyn FnMut(RenderEvent),
1225 1215 ) -> Result<(), String> {
1226 1216 job.framing.validate()?;
1217 let pivot = crate::camera::resolve(&job.character, job.framing);
1218 job.framing = pivot.framing;
1227 1219 if job.target_width == 0
1228 1220 || job.target_height == 0
1229 1221 || job.target_width > MAX_HIGHRES / SCALE
@@ -1267,7 +1259,7 @@ fn execute(
1267 1259 write_json(
1268 1260 &root,
1269 1261 "render.json",
1270 serde_json::json!({"format_version":1,"status":"incomplete","sex":job.sex,"set":set,"target":[job.target_width,job.target_height],"fps":job.fps,"highres_scale":SCALE,"framing":job.framing,"near":NEAR,"far":FAR,"normal_space":"view (+Y up), PNG rows top-down","camera_distance":10,"yaw_zero":"camera on -Z","pitches":[-30,0,30],"yaw_step":30,"sampling":"clip -> frame (t=frame/fps) -> pitch -> yaw -> pass; ceil(duration*fps) snapped within 4*f32_epsilon*max(1,samples) of an integer to omit floating terminal duplicates; root motion retained","alpha":"straight, opaque/cutout, coverage resolve","diffuse":"unlit sRGB albedo × vertex color, linear-light detail resolve","depth":"16-bit gray+alpha; distance=near+(sample/65535)*(far-near)","specular":"linear scalar; body defaults zero","pass_order":PASSES,"saved_maps":["diffuse","depth","normal","specular"],"clips":plan.clips.iter().enumerate().map(|(i,c)|serde_json::json!({"name":c.source_name,"token":c.token,"frames":c.frames,"duration":job.character.clips.get(i).map(|c|c.duration).unwrap_or(0.)})).collect::<Vec<_>>(),"images":plan.images,"passes":plan.passes,"png_files":plan.files}),
1262 serde_json::json!({"format_version":2,"status":"incomplete","sex":job.sex,"set":set,"target":[job.target_width,job.target_height],"fps":job.fps,"highres_scale":SCALE,"framing":job.framing,"pivot_source":pivot.source,"near":NEAR,"far":FAR,"normal_space":"view (+Y up), PNG rows top-down","camera_distance":10,"yaw_zero":"camera on -Z","pitches":[-30,0,30],"yaw_step":30,"sampling":"clip -> frame (t=frame/fps) -> pitch -> yaw -> pass; ceil(duration*fps) snapped within 4*f32_epsilon*max(1,samples) of an integer to omit floating terminal duplicates; root motion retained","alpha":"straight, opaque/cutout, coverage resolve","diffuse":"unlit sRGB albedo × vertex color, linear-light detail resolve","depth":"16-bit gray+alpha; distance=near+(sample/65535)*(far-near)","specular":"linear scalar; body defaults zero","pass_order":PASSES,"saved_maps":["diffuse","depth","normal","specular"],"clips":plan.clips.iter().enumerate().map(|(i,c)|serde_json::json!({"name":c.source_name,"token":c.token,"frames":c.frames,"duration":job.character.clips.get(i).map(|c|c.duration).unwrap_or(0.)})).collect::<Vec<_>>(),"images":plan.images,"passes":plan.passes,"png_files":plan.files}),
1271 1263 )?;
1272 1264 // Internal unique names select every source clip, even duplicate display names.
1273 1265 for (i, c) in job.character.clips.iter_mut().enumerate() {
@@ -1803,6 +1795,7 @@ mod tests {
1803 1795 framing: CameraFraming {
1804 1796 height_milli: 3100,
1805 1797 center_y_milli: 1200,
1798 pivot_y_milli: Some(1200),
1806 1799 },
1807 1800 output: temp.0.clone(),
1808 1801 };
@@ -1981,6 +1974,22 @@ mod tests {
1981 1974 assert_eq!(run.join("complete.json").exists(), !stop);
1982 1975 assert_eq!(run.join("cancelled.json").exists(), stop);
1983 1976 assert_eq!(result.is_ok(), !stop);
1977 let meta: serde_json::Value =
1978 serde_json::from_str(&std::fs::read_to_string(run.join("render.json")).unwrap())
1979 .unwrap();
1980 assert_eq!(meta["format_version"], 2);
1981 assert_eq!(meta["framing"]["pivot_y_milli"], 1460);
1982 assert_eq!(meta["pivot_source"]["kind"], "neck");
1983 assert_eq!(meta["pivot_source"]["joint_name"], "Neck");
1984 assert_eq!(
1985 meta["saved_maps"]
1986 .as_array()
1987 .unwrap()
1988 .iter()
1989 .map(|v| v.as_str().unwrap())
1990 .collect::<Vec<_>>(),
1991 ["diffuse", "depth", "normal", "specular"]
1992 );
1984 1993 let frame = run.join("male/body/clip000-bind/f000000");
1985 1994 assert!(frame.join("p-30_y000_diffuse.png").exists());
1986 1995 assert_eq!(frame.join("p-30_y000_depth.png").exists(), !stop);
@@ -211,18 +211,8 @@ pub fn output_path(
211 .join(format!("f{frame:06}"))
211 .join(format!("f{frame:06}"))
212 .join(format!("p{pitch:+03}_y{yaw:03}_{map}.png")))
212 .join(format!("p{pitch:+03}_y{yaw:03}_{map}.png")))
213}
213}
214pub fn camera(framing: CameraFraming, yaw: f32, pitch: f32, aspect: f32) -> (Mat4, Mat4) {
214pub use crate::camera::matrices as camera;
215 let target = [0., framing.center_y_milli as f32 / 1000., 0.];
215
216 let d = preview::eye_offset(yaw, pitch, 10.);
217 let eye = [d[0], target[1] + d[1], d[2]];
218 let half = framing.height_milli as f32 / 2000.;
219 let mut p = preview::identity();
220 p[0] = 1. / (half * aspect);
221 p[5] = 1. / half;
222 p[10] = -2. / (FAR - NEAR);
223 p[14] = -(FAR + NEAR) / (FAR - NEAR);
224 (preview::view_matrix(eye, target), p)
225}
226fn point(m: &Mat4, p: [f32; 3]) -> [f32; 3] {
216fn point(m: &Mat4, p: [f32; 3]) -> [f32; 3] {
227 std::array::from_fn(|i| m[i] * p[0] + m[i + 4] * p[1] + m[i + 8] * p[2] + m[i + 12])
217 std::array::from_fn(|i| m[i] * p[0] + m[i + 4] * p[1] + m[i + 8] * p[2] + m[i + 12])
228}
218}
@@ -360,7 +350,7 @@ pub fn scene(
360 }
350 }
361 }
351 }
362 let (view, projection) = camera(
352 let (view, projection) = camera(
363 job.framing,
353 crate::camera::resolve(body, job.framing).framing,
364 yaw,
354 yaw,
365 pitch,
355 pitch,
366 job.target_width as f32 / job.target_height as f32,
356 job.target_width as f32 / job.target_height as f32,
@@ -1224,6 +1214,8 @@ fn execute(
1224 emit: &mut dyn FnMut(RenderEvent),
1214 emit: &mut dyn FnMut(RenderEvent),
1225) -> Result<(), String> {
1215) -> Result<(), String> {
1226 job.framing.validate()?;
1216 job.framing.validate()?;
1217 let pivot = crate::camera::resolve(&job.character, job.framing);
1218 job.framing = pivot.framing;
1227 if job.target_width == 0
1219 if job.target_width == 0
1228 || job.target_height == 0
1220 || job.target_height == 0
1229 || job.target_width > MAX_HIGHRES / SCALE
1221 || job.target_width > MAX_HIGHRES / SCALE
@@ -1267,7 +1259,7 @@ fn execute(
1267 write_json(
1259 write_json(
1268 &root,
1260 &root,
1269 "render.json",
1261 "render.json",
1270 serde_json::json!({"format_version":1,"status":"incomplete","sex":job.sex,"set":set,"target":[job.target_width,job.target_height],"fps":job.fps,"highres_scale":SCALE,"framing":job.framing,"near":NEAR,"far":FAR,"normal_space":"view (+Y up), PNG rows top-down","camera_distance":10,"yaw_zero":"camera on -Z","pitches":[-30,0,30],"yaw_step":30,"sampling":"clip -> frame (t=frame/fps) -> pitch -> yaw -> pass; ceil(duration*fps) snapped within 4*f32_epsilon*max(1,samples) of an integer to omit floating terminal duplicates; root motion retained","alpha":"straight, opaque/cutout, coverage resolve","diffuse":"unlit sRGB albedo × vertex color, linear-light detail resolve","depth":"16-bit gray+alpha; distance=near+(sample/65535)*(far-near)","specular":"linear scalar; body defaults zero","pass_order":PASSES,"saved_maps":["diffuse","depth","normal","specular"],"clips":plan.clips.iter().enumerate().map(|(i,c)|serde_json::json!({"name":c.source_name,"token":c.token,"frames":c.frames,"duration":job.character.clips.get(i).map(|c|c.duration).unwrap_or(0.)})).collect::<Vec<_>>(),"images":plan.images,"passes":plan.passes,"png_files":plan.files}),
1262 serde_json::json!({"format_version":2,"status":"incomplete","sex":job.sex,"set":set,"target":[job.target_width,job.target_height],"fps":job.fps,"highres_scale":SCALE,"framing":job.framing,"pivot_source":pivot.source,"near":NEAR,"far":FAR,"normal_space":"view (+Y up), PNG rows top-down","camera_distance":10,"yaw_zero":"camera on -Z","pitches":[-30,0,30],"yaw_step":30,"sampling":"clip -> frame (t=frame/fps) -> pitch -> yaw -> pass; ceil(duration*fps) snapped within 4*f32_epsilon*max(1,samples) of an integer to omit floating terminal duplicates; root motion retained","alpha":"straight, opaque/cutout, coverage resolve","diffuse":"unlit sRGB albedo × vertex color, linear-light detail resolve","depth":"16-bit gray+alpha; distance=near+(sample/65535)*(far-near)","specular":"linear scalar; body defaults zero","pass_order":PASSES,"saved_maps":["diffuse","depth","normal","specular"],"clips":plan.clips.iter().enumerate().map(|(i,c)|serde_json::json!({"name":c.source_name,"token":c.token,"frames":c.frames,"duration":job.character.clips.get(i).map(|c|c.duration).unwrap_or(0.)})).collect::<Vec<_>>(),"images":plan.images,"passes":plan.passes,"png_files":plan.files}),
1271 )?;
1263 )?;
1272 // Internal unique names select every source clip, even duplicate display names.
1264 // Internal unique names select every source clip, even duplicate display names.
1273 for (i, c) in job.character.clips.iter_mut().enumerate() {
1265 for (i, c) in job.character.clips.iter_mut().enumerate() {
@@ -1803,6 +1795,7 @@ mod tests {
1803 framing: CameraFraming {
1795 framing: CameraFraming {
1804 height_milli: 3100,
1796 height_milli: 3100,
1805 center_y_milli: 1200,
1797 center_y_milli: 1200,
1798 pivot_y_milli: Some(1200),
1806 },
1799 },
1807 output: temp.0.clone(),
1800 output: temp.0.clone(),
1808 };
1801 };
@@ -1981,6 +1974,22 @@ mod tests {
1981 assert_eq!(run.join("complete.json").exists(), !stop);
1974 assert_eq!(run.join("complete.json").exists(), !stop);
1982 assert_eq!(run.join("cancelled.json").exists(), stop);
1975 assert_eq!(run.join("cancelled.json").exists(), stop);
1983 assert_eq!(result.is_ok(), !stop);
1976 assert_eq!(result.is_ok(), !stop);
1977 let meta: serde_json::Value =
1978 serde_json::from_str(&std::fs::read_to_string(run.join("render.json")).unwrap())
1979 .unwrap();
1980 assert_eq!(meta["format_version"], 2);
1981 assert_eq!(meta["framing"]["pivot_y_milli"], 1460);
1982 assert_eq!(meta["pivot_source"]["kind"], "neck");
1983 assert_eq!(meta["pivot_source"]["joint_name"], "Neck");
1984 assert_eq!(
1985 meta["saved_maps"]
1986 .as_array()
1987 .unwrap()
1988 .iter()
1989 .map(|v| v.as_str().unwrap())
1990 .collect::<Vec<_>>(),
1991 ["diffuse", "depth", "normal", "specular"]
1992 );
1984 let frame = run.join("male/body/clip000-bind/f000000");
1993 let frame = run.join("male/body/clip000-bind/f000000");
1985 assert!(frame.join("p-30_y000_diffuse.png").exists());
1994 assert!(frame.join("p-30_y000_diffuse.png").exists());
1986 assert_eq!(frame.join("p-30_y000_depth.png").exists(), !stop);
1995 assert_eq!(frame.join("p-30_y000_depth.png").exists(), !stop);
src/project.rs +27 −10
@@ -2,7 +2,7 @@
2 2 //!
3 3 //! Version 1 stores paths and render settings. It does not import models and
4 4 //! it does not render paperdolls. Unknown JSON fields are rejected so a save
5 //! cannot drop them. Schema 1 migrates to 2; unsupported `format_version` values are rejected first.
5 //! cannot drop them. Schemas 1/2 migrate to 3; unsupported `format_version` values are rejected first.
6 6
7 7 use std::fs::{self, File};
8 8 use std::io::Write;
@@ -12,7 +12,7 @@ use std::time::{SystemTime, UNIX_EPOCH};
12 12 use serde::{Deserialize, Serialize};
13 13 use serde_json::Value;
14 14
15 pub const FORMAT_VERSION: u32 = 2;
15 pub const FORMAT_VERSION: u32 = 3;
16 16 pub const MANIFEST_NAME: &str = "character.ppd";
17 17 pub const MAX_PROJECT_PRESETS: usize = 64;
18 18 pub const MAX_MANIFEST_BYTES: usize = 4 * 1024 * 1024;
@@ -34,20 +34,26 @@ const PROJECT_DIRS: [&str; 5] = [
34 34 pub struct CameraFraming {
35 35 pub height_milli: u32,
36 36 pub center_y_milli: i32,
37 /// None resolves the rest-pose neck height, independently of image framing.
38 #[serde(default)]
39 pub pivot_y_milli: Option<i32>,
37 40 }
38 41 impl Default for CameraFraming {
39 42 fn default() -> Self {
40 43 Self {
41 44 height_milli: 2600,
42 45 center_y_milli: 1000,
46 pivot_y_milli: None,
43 47 }
44 48 }
45 49 }
46 50 impl CameraFraming {
47 51 pub fn validate(self) -> Result<(), String> {
48 if !(100..=10000).contains(&self.height_milli) || self.center_y_milli.unsigned_abs() > 10000
52 if !(100..=10000).contains(&self.height_milli)
53 || self.center_y_milli.unsigned_abs() > 10000
54 || self.pivot_y_milli.is_some_and(|p| p.unsigned_abs() > 10000)
49 55 {
50 Err("renderkader: hoogte 0.1–10 m en centrum -10–10 m".into())
56 Err("renderkader: hoogte 0.1–10 m, centrum/pivot -10–10 m".into())
51 57 } else {
52 58 Ok(())
53 59 }
@@ -329,7 +335,7 @@ pub fn parse_manifest(text: &str) -> Result<ProjectFile, ProjectError> {
329 335 "format_version moet een geheel getal zijn".to_string(),
330 336 ));
331 337 };
332 if version != 1 && version != u64::from(FORMAT_VERSION) {
338 if !(1..=u64::from(FORMAT_VERSION)).contains(&version) {
333 339 return Err(ProjectError::UnsupportedVersion {
334 340 found: version,
335 341 supported: FORMAT_VERSION,
@@ -810,16 +816,16 @@ mod tests {
810 816
811 817 #[test]
812 818 fn newer_version_and_unknown_field_are_recoverable_parse_errors() {
813 let newer = r#"{"format_version":3,"id":"a","name":"A","male_model":null,"female_model":null,"equipment_presets":[],"output_directory":"renders","render":{"target_width":128,"target_height":128,"fps":12},"extra":true}"#;
819 let newer = r#"{"format_version":4,"id":"a","name":"A","male_model":null,"female_model":null,"equipment_presets":[],"output_directory":"renders","render":{"target_width":128,"target_height":128,"fps":12},"extra":true}"#;
814 820 let error = parse_manifest(newer).unwrap_err();
815 821 assert!(matches!(
816 822 error,
817 823 ProjectError::UnsupportedVersion {
818 found: 3,
819 supported: 2
824 found: 4,
825 supported: 3
820 826 }
821 827 ));
822 assert!(error.to_string().contains("format_version 3"));
828 assert!(error.to_string().contains("format_version 4"));
823 829 let unknown = r#"{"format_version":1,"id":"a","name":"A","male_model":null,"female_model":null,"equipment_presets":[],"output_directory":"renders","render":{"target_width":128,"target_height":128,"fps":12},"extra":true}"#;
824 830 let error = parse_manifest(unknown).unwrap_err();
825 831 assert!(matches!(error, ProjectError::UnknownField(_)));
@@ -950,10 +956,12 @@ mod framing_tests {
950 956 CameraFraming {
951 957 height_milli: 3210,
952 958 center_y_milli: 1100,
959 pivot_y_milli: Some(1700),
953 960 },
954 961 CameraFraming {
955 962 height_milli: 2890,
956 963 center_y_milli: 987,
964 pivot_y_milli: Some(1650),
957 965 },
958 966 ];
959 967 let root = std::env::temp_dir().join(format!(
@@ -985,12 +993,21 @@ mod framing_tests {
985 993 value["format_version"] = serde_json::json!(1);
986 994 value["render"].as_object_mut().unwrap().remove("framing");
987 995 let migrated = parse_manifest(&value.to_string()).unwrap();
988 assert_eq!(migrated.format_version, 2);
996 assert_eq!(migrated.format_version, 3);
989 997 assert_eq!(migrated.render.framing, [CameraFraming::default(); 2]);
990 998 value["render"]["framing"] = serde_json::json!([{"height_milli":2600,"center_y_milli":-2147483648},{"height_milli":2600,"center_y_milli":1000}]);
991 999 assert!(parse_manifest(&value.to_string())
992 1000 .unwrap()
993 1001 .validate_shape()
994 1002 .is_err());
1003 value["format_version"] = serde_json::json!(2);
1004 value["render"]["framing"] = serde_json::json!([
1005 {"height_milli":2600,"center_y_milli":1000},
1006 {"height_milli":2600,"center_y_milli":1000}
1007 ]);
1008 let migrated_v2 = parse_manifest(&value.to_string()).unwrap();
1009 assert_eq!(migrated_v2.format_version, 3);
1010 assert_eq!(migrated_v2.render.framing[0].pivot_y_milli, None);
1011 assert_eq!(migrated_v2.render.framing[1].pivot_y_milli, None);
995 1012 }
996 1013 }
@@ -2,7 +2,7 @@
2//!
2//!
3//! Version 1 stores paths and render settings. It does not import models and
3//! Version 1 stores paths and render settings. It does not import models and
4//! it does not render paperdolls. Unknown JSON fields are rejected so a save
4//! it does not render paperdolls. Unknown JSON fields are rejected so a save
5//! cannot drop them. Schema 1 migrates to 2; unsupported `format_version` values are rejected first.
5//! cannot drop them. Schemas 1/2 migrate to 3; unsupported `format_version` values are rejected first.
6
6
7use std::fs::{self, File};
7use std::fs::{self, File};
8use std::io::Write;
8use std::io::Write;
@@ -12,7 +12,7 @@ use std::time::{SystemTime, UNIX_EPOCH};
12use serde::{Deserialize, Serialize};
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
13use serde_json::Value;
14
14
15pub const FORMAT_VERSION: u32 = 2;
15pub const FORMAT_VERSION: u32 = 3;
16pub const MANIFEST_NAME: &str = "character.ppd";
16pub const MANIFEST_NAME: &str = "character.ppd";
17pub const MAX_PROJECT_PRESETS: usize = 64;
17pub const MAX_PROJECT_PRESETS: usize = 64;
18pub const MAX_MANIFEST_BYTES: usize = 4 * 1024 * 1024;
18pub const MAX_MANIFEST_BYTES: usize = 4 * 1024 * 1024;
@@ -34,20 +34,26 @@ const PROJECT_DIRS: [&str; 5] = [
34pub struct CameraFraming {
34pub struct CameraFraming {
35 pub height_milli: u32,
35 pub height_milli: u32,
36 pub center_y_milli: i32,
36 pub center_y_milli: i32,
37 /// None resolves the rest-pose neck height, independently of image framing.
38 #[serde(default)]
39 pub pivot_y_milli: Option<i32>,
37}
40}
38impl Default for CameraFraming {
41impl Default for CameraFraming {
39 fn default() -> Self {
42 fn default() -> Self {
40 Self {
43 Self {
41 height_milli: 2600,
44 height_milli: 2600,
42 center_y_milli: 1000,
45 center_y_milli: 1000,
46 pivot_y_milli: None,
43 }
47 }
44 }
48 }
45}
49}
46impl CameraFraming {
50impl CameraFraming {
47 pub fn validate(self) -> Result<(), String> {
51 pub fn validate(self) -> Result<(), String> {
48 if !(100..=10000).contains(&self.height_milli) || self.center_y_milli.unsigned_abs() > 10000
52 if !(100..=10000).contains(&self.height_milli)
53 || self.center_y_milli.unsigned_abs() > 10000
54 || self.pivot_y_milli.is_some_and(|p| p.unsigned_abs() > 10000)
49 {
55 {
50 Err("renderkader: hoogte 0.1–10 m en centrum -10–10 m".into())
56 Err("renderkader: hoogte 0.1–10 m, centrum/pivot -10–10 m".into())
51 } else {
57 } else {
52 Ok(())
58 Ok(())
53 }
59 }
@@ -329,7 +335,7 @@ pub fn parse_manifest(text: &str) -> Result<ProjectFile, ProjectError> {
329 "format_version moet een geheel getal zijn".to_string(),
335 "format_version moet een geheel getal zijn".to_string(),
330 ));
336 ));
331 };
337 };
332 if version != 1 && version != u64::from(FORMAT_VERSION) {
338 if !(1..=u64::from(FORMAT_VERSION)).contains(&version) {
333 return Err(ProjectError::UnsupportedVersion {
339 return Err(ProjectError::UnsupportedVersion {
334 found: version,
340 found: version,
335 supported: FORMAT_VERSION,
341 supported: FORMAT_VERSION,
@@ -810,16 +816,16 @@ mod tests {
810
816
811 #[test]
817 #[test]
812 fn newer_version_and_unknown_field_are_recoverable_parse_errors() {
818 fn newer_version_and_unknown_field_are_recoverable_parse_errors() {
813 let newer = r#"{"format_version":3,"id":"a","name":"A","male_model":null,"female_model":null,"equipment_presets":[],"output_directory":"renders","render":{"target_width":128,"target_height":128,"fps":12},"extra":true}"#;
819 let newer = r#"{"format_version":4,"id":"a","name":"A","male_model":null,"female_model":null,"equipment_presets":[],"output_directory":"renders","render":{"target_width":128,"target_height":128,"fps":12},"extra":true}"#;
814 let error = parse_manifest(newer).unwrap_err();
820 let error = parse_manifest(newer).unwrap_err();
815 assert!(matches!(
821 assert!(matches!(
816 error,
822 error,
817 ProjectError::UnsupportedVersion {
823 ProjectError::UnsupportedVersion {
818 found: 3,
824 found: 4,
819 supported: 2
825 supported: 3
820 }
826 }
821 ));
827 ));
822 assert!(error.to_string().contains("format_version 3"));
828 assert!(error.to_string().contains("format_version 4"));
823 let unknown = r#"{"format_version":1,"id":"a","name":"A","male_model":null,"female_model":null,"equipment_presets":[],"output_directory":"renders","render":{"target_width":128,"target_height":128,"fps":12},"extra":true}"#;
829 let unknown = r#"{"format_version":1,"id":"a","name":"A","male_model":null,"female_model":null,"equipment_presets":[],"output_directory":"renders","render":{"target_width":128,"target_height":128,"fps":12},"extra":true}"#;
824 let error = parse_manifest(unknown).unwrap_err();
830 let error = parse_manifest(unknown).unwrap_err();
825 assert!(matches!(error, ProjectError::UnknownField(_)));
831 assert!(matches!(error, ProjectError::UnknownField(_)));
@@ -950,10 +956,12 @@ mod framing_tests {
950 CameraFraming {
956 CameraFraming {
951 height_milli: 3210,
957 height_milli: 3210,
952 center_y_milli: 1100,
958 center_y_milli: 1100,
959 pivot_y_milli: Some(1700),
953 },
960 },
954 CameraFraming {
961 CameraFraming {
955 height_milli: 2890,
962 height_milli: 2890,
956 center_y_milli: 987,
963 center_y_milli: 987,
964 pivot_y_milli: Some(1650),
957 },
965 },
958 ];
966 ];
959 let root = std::env::temp_dir().join(format!(
967 let root = std::env::temp_dir().join(format!(
@@ -985,12 +993,21 @@ mod framing_tests {
985 value["format_version"] = serde_json::json!(1);
993 value["format_version"] = serde_json::json!(1);
986 value["render"].as_object_mut().unwrap().remove("framing");
994 value["render"].as_object_mut().unwrap().remove("framing");
987 let migrated = parse_manifest(&value.to_string()).unwrap();
995 let migrated = parse_manifest(&value.to_string()).unwrap();
988 assert_eq!(migrated.format_version, 2);
996 assert_eq!(migrated.format_version, 3);
989 assert_eq!(migrated.render.framing, [CameraFraming::default(); 2]);
997 assert_eq!(migrated.render.framing, [CameraFraming::default(); 2]);
990 value["render"]["framing"] = serde_json::json!([{"height_milli":2600,"center_y_milli":-2147483648},{"height_milli":2600,"center_y_milli":1000}]);
998 value["render"]["framing"] = serde_json::json!([{"height_milli":2600,"center_y_milli":-2147483648},{"height_milli":2600,"center_y_milli":1000}]);
991 assert!(parse_manifest(&value.to_string())
999 assert!(parse_manifest(&value.to_string())
992 .unwrap()
1000 .unwrap()
993 .validate_shape()
1001 .validate_shape()
994 .is_err());
1002 .is_err());
1003 value["format_version"] = serde_json::json!(2);
1004 value["render"]["framing"] = serde_json::json!([
1005 {"height_milli":2600,"center_y_milli":1000},
1006 {"height_milli":2600,"center_y_milli":1000}
1007 ]);
1008 let migrated_v2 = parse_manifest(&value.to_string()).unwrap();
1009 assert_eq!(migrated_v2.format_version, 3);
1010 assert_eq!(migrated_v2.render.framing[0].pivot_y_milli, None);
1011 assert_eq!(migrated_v2.render.framing[1].pivot_y_milli, None);
995 }
1012 }
996}
1013}