Merge feature/neck-camera-pivot: rest-pose neck orbit pivot
Orbit pivot at neck height is independent of vertical image framing.
@@ -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** selecteert21
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.@@ -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/`, `presets94
94
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.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
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.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 tempor104
104
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.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
**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 algorithm129
## 8. Map contracts and rendering algorithm130
130
@@ -215,7 +215,7 @@ These requirements are durable even where the implementation is staged. They sup215
- 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
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
## 12. Incremental feature plan and acceptance gates220
## 12. Incremental feature plan and acceptance gates221
221
@@ -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 --locked3
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
encoder22
.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
out40
}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 = creation59
.gl60
.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 old98
.diffuse99
.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
b121
};122
let neck0b = unsafe {123
let mut r = production::GpuRenderer::default();124
let b = r.render(gl, &neck0)?;125
r.destroy(gl);126
b127
};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 = outcome214
.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,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.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 = self27
.framing28
.pivot_y_milli29
.unwrap_or(self.framing.center_y_milli) as f3230
/ 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
token46
.strip_prefix("neck")47
.is_some_and(|suffix| suffix.chars().all(|c| c.is_ascii_digit()))48
};49
lower50
.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
false66
}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 = body70
.joint_names71
.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 = candidates80
.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 stable126
/// 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 = names163
.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
..framing310
};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-4328
|| (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_milli360
);361
}362
}@@ -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 = self510
.session511
.opened512
.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 = diagnostic528
.as_ref()529
.map(|resolved| {530
resolved531
.framing532
.pivot_y_milli533
.unwrap_or(resolved.framing.center_y_milli) as f32534
/ 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 ui541
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 ui564
.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
&& ui575
.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])> {@@ -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
6
pub mod camera;6
pub mod dialogs;7
pub mod dialogs;7
pub mod editor;8
pub mod editor;8
pub mod equipment;9
pub mod equipment;@@ -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.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
pub fn animated_joint_world(417
pub fn animated_joint_world(397
character: &PreviewCharacter,418
character: &PreviewCharacter,398
clip: &str,419
clip: &str,@@ -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 = framing71
.filter(|(c, r)| c.validate().is_ok() && r[0] > 0 && r[1] > 0)72
.map(|(c, r)| {73
(74
character75
.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 !character82
if !character@@ -159,7 +168,7 @@ impl Viewport {159
let target = [168
let target = [160
0.0,169
0.0,161
framing170
framing162
.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
];@@ -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
}214
pub fn camera(framing: CameraFraming, yaw: f32, pitch: f32, aspect: f32) -> (Mat4, Mat4) {214
pub 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
}226
fn point(m: &Mat4, p: [f32; 3]) -> [f32; 3] {216
fn 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 == 01219
if job.target_width == 01228
|| job.target_height == 01220
|| job.target_height == 01229
|| job.target_width > MAX_HIGHRES / SCALE1221
|| 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);@@ -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 and3
//! Version 1 stores paths and render settings. It does not import models and4
//! it does not render paperdolls. Unknown JSON fields are rejected so a save4
//! it does not render paperdolls. Unknown JSON fields are rejected so a save5
//! 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
use std::fs::{self, File};7
use std::fs::{self, File};8
use std::io::Write;8
use std::io::Write;@@ -12,7 +12,7 @@ use std::time::{SystemTime, UNIX_EPOCH};12
use serde::{Deserialize, Serialize};12
use serde::{Deserialize, Serialize};13
use serde_json::Value;13
use serde_json::Value;14
14
15
pub const FORMAT_VERSION: u32 = 2;15
pub const FORMAT_VERSION: u32 = 3;16
pub const MANIFEST_NAME: &str = "character.ppd";16
pub const MANIFEST_NAME: &str = "character.ppd";17
pub const MAX_PROJECT_PRESETS: usize = 64;17
pub const MAX_PROJECT_PRESETS: usize = 64;18
pub const MAX_MANIFEST_BYTES: usize = 4 * 1024 * 1024;18
pub const MAX_MANIFEST_BYTES: usize = 4 * 1024 * 1024;@@ -34,20 +34,26 @@ const PROJECT_DIRS: [&str; 5] = [34
pub struct CameraFraming {34
pub 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
}38
impl Default for CameraFraming {41
impl 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
}46
impl CameraFraming {50
impl 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() > 1000052
if !(100..=10000).contains(&self.height_milli)53
|| self.center_y_milli.unsigned_abs() > 1000054
|| 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: 2825
supported: 3820
}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
}