BlueGit

golden-age paperdoll-renderer

Framing: labelled orientation convention test + one-shot all-animation envelope (Omhullende animaties)

timfa Grok Bot · pushed by timfa

Authored 2026-11-07 · 7 days ago

Hash
7bf816dbe154779190547fa9b75833bc8b930745
Parent
921b870c
3 files changed +192 −2
README.md +2 −2
@@ -21,9 +21,9 @@ 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- of FBX-model, of een statisch lichaam (zie hieronder), 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**, **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.
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. **Omhullende animaties** berekent eenmalig een renderhoogte en Centrum Y waarbinnen alle clips, alle frames (op project-fps) en alle 60 hoeken vallen, met 5% marge aan elke kant; de pivot blijft staan. Daarna is het kader weer vast: er is geen automatische zoom per frame, clip of laag. Equipment dat verder uitsteekt dan het lichaam vraagt handmatig extra hoogte. 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 - **Yaw − / Yaw +** volgen twaalf hoeken van 30°; **pitch -60 / -30 / 0 / 30 / 60** kiezen de hoogte. Yaw 0 kijkt naar de voorkant van de testlichamen (−Z).
26 - **Yaw − / Yaw +** volgen twaalf hoeken van 30°; **pitch -60 / -30 / 0 / 30 / 60** kiezen de hoogte. Yaw 0 kijkt naar de voorkant van de testlichamen (−Z). Vaste oriëntatie (+Y omhoog, het lichaam kijkt naar −Z, zijn rechterzijde is +X): yaw 0 = voorkant (zijn rechterhand links in beeld), 90 = linkerzijde, 180 = achterkant, 270 = rechterzijde; positieve pitch kijkt van boven. Een test met gelabelde markers bewaakt deze afspraak.
27 27 - **Render alle hoeken** rendert alle animaties × alle frames op project-fps × 60 hoeken (5 pitches × 12 yaws). Na de losse maps schrijft hij per maptype een atlas plus een `.ppda`-bestand (zie *Atlassen en .ppda*). 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.
28 28 - Stel breedte, hoogte en fps in de Bodies-weergave in. Nieuwe projecten beginnen op 4 fps; een bestaand `.ppd` houdt zijn opgeslagen fps.
29 29 - **Zoek onderbroken runs** (ook in de equipment-editor) toont geannuleerde, mislukte of afgebroken runs van dezelfde body/equipmentset met het aantal vastgelegde PNG's. **Hervat** gaat alleen verder als bronbestanden en instellingen exact gelijk zijn aan die run; anders staat erbij wat er verschilt. Na annuleren verschijnt de run direct in deze lijst. Zie *Hervatten* hieronder.
@@ -21,9 +21,9 @@ De eerste projectweergave toont de bodies. **Kies man / Kies vrouw** selecteert
21
21
22**Open man / Open vrouw** opent de body. Een geskind glTF/GLB- of FBX-model, of een statisch lichaam (zie hieronder), is in de live preview zichtbaar:
22**Open man / Open vrouw** opent de body. Een geskind glTF/GLB- of FBX-model, of een statisch lichaam (zie hieronder), 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**, **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.
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. **Omhullende animaties** berekent eenmalig een renderhoogte en Centrum Y waarbinnen alle clips, alle frames (op project-fps) en alle 60 hoeken vallen, met 5% marge aan elke kant; de pivot blijft staan. Daarna is het kader weer vast: er is geen automatische zoom per frame, clip of laag. Equipment dat verder uitsteekt dan het lichaam vraagt handmatig extra hoogte. 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 -60 / -30 / 0 / 30 / 60** kiezen de hoogte. Yaw 0 kijkt naar de voorkant van de testlichamen (−Z).
26- **Yaw − / Yaw +** volgen twaalf hoeken van 30°; **pitch -60 / -30 / 0 / 30 / 60** kiezen de hoogte. Yaw 0 kijkt naar de voorkant van de testlichamen (−Z). Vaste oriëntatie (+Y omhoog, het lichaam kijkt naar −Z, zijn rechterzijde is +X): yaw 0 = voorkant (zijn rechterhand links in beeld), 90 = linkerzijde, 180 = achterkant, 270 = rechterzijde; positieve pitch kijkt van boven. Een test met gelabelde markers bewaakt deze afspraak.
27- **Render alle hoeken** rendert alle animaties × alle frames op project-fps × 60 hoeken (5 pitches × 12 yaws). Na de losse maps schrijft hij per maptype een atlas plus een `.ppda`-bestand (zie *Atlassen en .ppda*). 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 × 60 hoeken (5 pitches × 12 yaws). Na de losse maps schrijft hij per maptype een atlas plus een `.ppda`-bestand (zie *Atlassen en .ppda*). 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.
28- Stel breedte, hoogte en fps in de Bodies-weergave in. Nieuwe projecten beginnen op 4 fps; een bestaand `.ppd` houdt zijn opgeslagen fps.
28- Stel breedte, hoogte en fps in de Bodies-weergave in. Nieuwe projecten beginnen op 4 fps; een bestaand `.ppd` houdt zijn opgeslagen fps.
29- **Zoek onderbroken runs** (ook in de equipment-editor) toont geannuleerde, mislukte of afgebroken runs van dezelfde body/equipmentset met het aantal vastgelegde PNG's. **Hervat** gaat alleen verder als bronbestanden en instellingen exact gelijk zijn aan die run; anders staat erbij wat er verschilt. Na annuleren verschijnt de run direct in deze lijst. Zie *Hervatten* hieronder.
29- **Zoek onderbroken runs** (ook in de equipment-editor) toont geannuleerde, mislukte of afgebroken runs van dezelfde body/equipmentset met het aantal vastgelegde PNG's. **Hervat** gaat alleen verder als bronbestanden en instellingen exact gelijk zijn aan die run; anders staat erbij wat er verschilt. Na annuleren verschijnt de run direct in deze lijst. Zie *Hervatten* hieronder.
src/camera.rs +166 −0
@@ -170,6 +170,104 @@ pub fn matrices(framing: CameraFraming, yaw: f32, pitch: f32, aspect: f32) -> (M
170 170 (preview::view_matrix(eye, target), p)
171 171 }
172 172
173 /// Labelled orientation convention (§6/§13), verified by
174 /// `orientation_convention_is_labelled`: the character faces −Z, +Y is up,
175 /// its right hand side is +X. Camera eye = `eye_offset(yaw, pitch)`.
176 pub const ORIENTATION_VIEWS: [(f32, &str); 4] = [
177 (0., "voorkant"),
178 (90., "linkerzijde"),
179 (180., "achterkant"),
180 (270., "rechterzijde"),
181 ];
182
183 /// Fixed framing that contains every frame of every clip from every one of
184 /// the 60 production views (one envelope per body, never per frame/layer).
185 /// `margin` is a fraction of the envelope added on every side, `extra_m` an
186 /// absolute allowance (e.g. for equipment). Pivot stays as resolved; only
187 /// height and center Y are returned (milli). None when there is no geometry.
188 pub fn animation_envelope(
189 body: &PreviewCharacter,
190 framing: CameraFraming,
191 aspect: f32,
192 fps: u32,
193 margin: f32,
194 extra_m: f32,
195 ) -> Option<(u32, i32)> {
196 let framing = resolve(body, framing).framing;
197 let pivot = framing.pivot_y_milli.unwrap_or(framing.center_y_milli) as f32 / 1000.;
198 let mut poses: Vec<Vec<[f32; 3]>> = Vec::new();
199 if body.is_static() || body.clips.is_empty() {
200 poses.push(body.positions.clone());
201 } else {
202 let step = 1. / fps.max(1) as f32;
203 for clip in &body.clips {
204 let frames = ((clip.duration / step).ceil() as usize).max(1);
205 for frame in 0..frames {
206 let bones = preview::sample_skin_matrices(body, &clip.name, frame as f32 * step);
207 poses.push(skin_points(body, &bones));
208 }
209 }
210 }
211 let (mut lo, mut hi, mut side) = (f32::INFINITY, f32::NEG_INFINITY, 0f32);
212 for pitch in preview::PAPERDOLL_PITCHES_DEG {
213 for yaw in preview::paperdoll_yaws_deg() {
214 let (view, _) = matrices(framing, yaw, pitch, aspect);
215 for pose in &poses {
216 for p in pose {
217 let x = view[0] * p[0] + view[4] * p[1] + view[8] * p[2] + view[12];
218 let y = view[1] * p[0] + view[5] * p[1] + view[9] * p[2] + view[13];
219 if x.is_finite() && y.is_finite() {
220 lo = lo.min(y);
221 hi = hi.max(y);
222 side = side.max(x.abs());
223 }
224 }
225 }
226 }
227 }
228 if !lo.is_finite() || !hi.is_finite() {
229 return None;
230 }
231 let height = (hi - lo).max(2. * side / aspect.max(1e-6));
232 let height = height * (1. + 2. * margin.max(0.)) + 2. * extra_m.max(0.);
233 let center = pivot + (hi + lo) / 2.;
234 Some((
235 (height * 1000.).ceil() as u32,
236 (center * 1000.).round() as i32,
237 ))
238 }
239
240 fn skin_points(body: &PreviewCharacter, bones: &[Mat4]) -> Vec<[f32; 3]> {
241 body.positions
242 .iter()
243 .enumerate()
244 .map(|(i, p)| {
245 let w = body.weights.get(i).copied().unwrap_or([1., 0., 0., 0.]);
246 let j = body.joints.get(i).copied().unwrap_or([0; 4]);
247 let mut out = [0.; 3];
248 let mut sum = 0.;
249 for k in 0..4 {
250 if w[k] == 0. {
251 continue;
252 }
253 let m = bones
254 .get(j[k] as usize)
255 .copied()
256 .unwrap_or_else(preview::identity);
257 for a in 0..3 {
258 out[a] += w[k] * (m[a] * p[0] + m[4 + a] * p[1] + m[8 + a] * p[2] + m[12 + a]);
259 }
260 sum += w[k];
261 }
262 if sum > 0. {
263 out
264 } else {
265 *p
266 }
267 })
268 .collect()
269 }
270
173 271 /// How depth samples are stored in the depth PNGs (t980u/t981u/t983u/t987u).
174 272 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, serde::Deserialize)]
175 273 #[serde(rename_all = "snake_case")]
@@ -464,6 +562,74 @@ pub fn arm_measure(body: &PreviewCharacter) -> Option<(f32, f32)> {
464 562
465 563 #[cfg(test)]
466 564 mod tests {
565 #[test]
566 fn orientation_convention_is_labelled() {
567 // Labelled markers on a body facing −Z with its right side on +X.
568 let markers = [
569 ("voorkant", [0., 1., -0.3]),
570 ("achterkant", [0., 1., 0.3]),
571 ("linkerzijde", [-0.3, 1., 0.]),
572 ("rechterzijde", [0.3, 1., 0.]),
573 ];
574 let framing = crate::project::CameraFraming::default();
575 for (yaw, label) in ORIENTATION_VIEWS {
576 let (view, _) = matrices(framing, yaw, 0., 1.);
577 // View space looks down −Z: the largest z is nearest the camera.
578 let z = |p: [f32; 3]| view[2] * p[0] + view[6] * p[1] + view[10] * p[2] + view[14];
579 let nearest = markers
580 .iter()
581 .max_by(|a, b| z(a.1).total_cmp(&z(b.1)))
582 .unwrap()
583 .0;
584 assert_eq!(nearest, label, "yaw {yaw}");
585 }
586 // Yaw 0 (front): the character's right (+X) is on the image's left.
587 let (view, _) = matrices(framing, 0., 0., 1.);
588 let x = |p: [f32; 3]| view[0] * p[0] + view[4] * p[1] + view[8] * p[2] + view[12];
589 assert!(x([0.3, 1., 0.]) < 0. && x([-0.3, 1., 0.]) > 0.);
590 // Positive pitch looks down from above: the front marker sits lower.
591 let (view, _) = matrices(framing, 0., 60., 1.);
592 let y = |p: [f32; 3]| view[1] * p[0] + view[5] * p[1] + view[9] * p[2] + view[13];
593 assert!(y([0., 1., -0.3]) < y([0., 1., 0.3]));
594 }
595
596 #[test]
597 fn animation_envelope_contains_every_frame_and_view() {
598 let body = preview::load_gltf_file(
599 &std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
600 .join("tests/fixtures/bodies/test-body-male.glb"),
601 )
602 .unwrap();
603 let start = crate::project::CameraFraming::default();
604 let (h, c) = animation_envelope(&body, start, 0.75, 8, 0., 0.).unwrap();
605 let mut framing = resolve(&body, start).framing;
606 framing.height_milli = h;
607 framing.center_y_milli = c;
608 let (m2, c2) = animation_envelope(&body, framing, 0.75, 8, 0.05, 0.1).unwrap();
609 assert!(m2 as f32 >= h as f32 * 1.1 + 199.0);
610 assert!((c2 - c).abs() <= 1);
611 // Every skinned vertex of every frame lands inside NDC [-1, 1].
612 let mut worst = 0f32;
613 for clip in &body.clips {
614 for f in 0..((clip.duration * 8.).ceil() as usize).max(1) {
615 let bones = preview::sample_skin_matrices(&body, &clip.name, f as f32 / 8.);
616 let pts = skin_points(&body, &bones);
617 for pitch in preview::PAPERDOLL_PITCHES_DEG {
618 for yaw in preview::paperdoll_yaws_deg() {
619 let (v, p) = matrices(framing, yaw, pitch, 0.75);
620 let vp = preview::mul(p, v);
621 for q in &pts {
622 let nx = vp[0] * q[0] + vp[4] * q[1] + vp[8] * q[2] + vp[12];
623 let ny = vp[1] * q[0] + vp[5] * q[1] + vp[9] * q[2] + vp[13];
624 worst = worst.max(nx.abs()).max(ny.abs());
625 }
626 }
627 }
628 }
629 }
630 assert!(worst <= 1.001 && worst > 0.95, "worst NDC {worst}");
631 }
632
467 633 use super::*;
468 634 use crate::preview::{self, PreviewCharacter, SceneNode};
469 635
@@ -170,6 +170,104 @@ pub fn matrices(framing: CameraFraming, yaw: f32, pitch: f32, aspect: f32) -> (M
170 (preview::view_matrix(eye, target), p)
170 (preview::view_matrix(eye, target), p)
171}
171}
172
172
173/// Labelled orientation convention (§6/§13), verified by
174/// `orientation_convention_is_labelled`: the character faces −Z, +Y is up,
175/// its right hand side is +X. Camera eye = `eye_offset(yaw, pitch)`.
176pub const ORIENTATION_VIEWS: [(f32, &str); 4] = [
177 (0., "voorkant"),
178 (90., "linkerzijde"),
179 (180., "achterkant"),
180 (270., "rechterzijde"),
181];
182
183/// Fixed framing that contains every frame of every clip from every one of
184/// the 60 production views (one envelope per body, never per frame/layer).
185/// `margin` is a fraction of the envelope added on every side, `extra_m` an
186/// absolute allowance (e.g. for equipment). Pivot stays as resolved; only
187/// height and center Y are returned (milli). None when there is no geometry.
188pub fn animation_envelope(
189 body: &PreviewCharacter,
190 framing: CameraFraming,
191 aspect: f32,
192 fps: u32,
193 margin: f32,
194 extra_m: f32,
195) -> Option<(u32, i32)> {
196 let framing = resolve(body, framing).framing;
197 let pivot = framing.pivot_y_milli.unwrap_or(framing.center_y_milli) as f32 / 1000.;
198 let mut poses: Vec<Vec<[f32; 3]>> = Vec::new();
199 if body.is_static() || body.clips.is_empty() {
200 poses.push(body.positions.clone());
201 } else {
202 let step = 1. / fps.max(1) as f32;
203 for clip in &body.clips {
204 let frames = ((clip.duration / step).ceil() as usize).max(1);
205 for frame in 0..frames {
206 let bones = preview::sample_skin_matrices(body, &clip.name, frame as f32 * step);
207 poses.push(skin_points(body, &bones));
208 }
209 }
210 }
211 let (mut lo, mut hi, mut side) = (f32::INFINITY, f32::NEG_INFINITY, 0f32);
212 for pitch in preview::PAPERDOLL_PITCHES_DEG {
213 for yaw in preview::paperdoll_yaws_deg() {
214 let (view, _) = matrices(framing, yaw, pitch, aspect);
215 for pose in &poses {
216 for p in pose {
217 let x = view[0] * p[0] + view[4] * p[1] + view[8] * p[2] + view[12];
218 let y = view[1] * p[0] + view[5] * p[1] + view[9] * p[2] + view[13];
219 if x.is_finite() && y.is_finite() {
220 lo = lo.min(y);
221 hi = hi.max(y);
222 side = side.max(x.abs());
223 }
224 }
225 }
226 }
227 }
228 if !lo.is_finite() || !hi.is_finite() {
229 return None;
230 }
231 let height = (hi - lo).max(2. * side / aspect.max(1e-6));
232 let height = height * (1. + 2. * margin.max(0.)) + 2. * extra_m.max(0.);
233 let center = pivot + (hi + lo) / 2.;
234 Some((
235 (height * 1000.).ceil() as u32,
236 (center * 1000.).round() as i32,
237 ))
238}
239
240fn skin_points(body: &PreviewCharacter, bones: &[Mat4]) -> Vec<[f32; 3]> {
241 body.positions
242 .iter()
243 .enumerate()
244 .map(|(i, p)| {
245 let w = body.weights.get(i).copied().unwrap_or([1., 0., 0., 0.]);
246 let j = body.joints.get(i).copied().unwrap_or([0; 4]);
247 let mut out = [0.; 3];
248 let mut sum = 0.;
249 for k in 0..4 {
250 if w[k] == 0. {
251 continue;
252 }
253 let m = bones
254 .get(j[k] as usize)
255 .copied()
256 .unwrap_or_else(preview::identity);
257 for a in 0..3 {
258 out[a] += w[k] * (m[a] * p[0] + m[4 + a] * p[1] + m[8 + a] * p[2] + m[12 + a]);
259 }
260 sum += w[k];
261 }
262 if sum > 0. {
263 out
264 } else {
265 *p
266 }
267 })
268 .collect()
269}
270
173/// How depth samples are stored in the depth PNGs (t980u/t981u/t983u/t987u).
271/// How depth samples are stored in the depth PNGs (t980u/t981u/t983u/t987u).
174#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, serde::Deserialize)]
272#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, serde::Deserialize)]
175#[serde(rename_all = "snake_case")]
273#[serde(rename_all = "snake_case")]
@@ -464,6 +562,74 @@ pub fn arm_measure(body: &PreviewCharacter) -> Option<(f32, f32)> {
464
562
465#[cfg(test)]
563#[cfg(test)]
466mod tests {
564mod tests {
565 #[test]
566 fn orientation_convention_is_labelled() {
567 // Labelled markers on a body facing −Z with its right side on +X.
568 let markers = [
569 ("voorkant", [0., 1., -0.3]),
570 ("achterkant", [0., 1., 0.3]),
571 ("linkerzijde", [-0.3, 1., 0.]),
572 ("rechterzijde", [0.3, 1., 0.]),
573 ];
574 let framing = crate::project::CameraFraming::default();
575 for (yaw, label) in ORIENTATION_VIEWS {
576 let (view, _) = matrices(framing, yaw, 0., 1.);
577 // View space looks down −Z: the largest z is nearest the camera.
578 let z = |p: [f32; 3]| view[2] * p[0] + view[6] * p[1] + view[10] * p[2] + view[14];
579 let nearest = markers
580 .iter()
581 .max_by(|a, b| z(a.1).total_cmp(&z(b.1)))
582 .unwrap()
583 .0;
584 assert_eq!(nearest, label, "yaw {yaw}");
585 }
586 // Yaw 0 (front): the character's right (+X) is on the image's left.
587 let (view, _) = matrices(framing, 0., 0., 1.);
588 let x = |p: [f32; 3]| view[0] * p[0] + view[4] * p[1] + view[8] * p[2] + view[12];
589 assert!(x([0.3, 1., 0.]) < 0. && x([-0.3, 1., 0.]) > 0.);
590 // Positive pitch looks down from above: the front marker sits lower.
591 let (view, _) = matrices(framing, 0., 60., 1.);
592 let y = |p: [f32; 3]| view[1] * p[0] + view[5] * p[1] + view[9] * p[2] + view[13];
593 assert!(y([0., 1., -0.3]) < y([0., 1., 0.3]));
594 }
595
596 #[test]
597 fn animation_envelope_contains_every_frame_and_view() {
598 let body = preview::load_gltf_file(
599 &std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
600 .join("tests/fixtures/bodies/test-body-male.glb"),
601 )
602 .unwrap();
603 let start = crate::project::CameraFraming::default();
604 let (h, c) = animation_envelope(&body, start, 0.75, 8, 0., 0.).unwrap();
605 let mut framing = resolve(&body, start).framing;
606 framing.height_milli = h;
607 framing.center_y_milli = c;
608 let (m2, c2) = animation_envelope(&body, framing, 0.75, 8, 0.05, 0.1).unwrap();
609 assert!(m2 as f32 >= h as f32 * 1.1 + 199.0);
610 assert!((c2 - c).abs() <= 1);
611 // Every skinned vertex of every frame lands inside NDC [-1, 1].
612 let mut worst = 0f32;
613 for clip in &body.clips {
614 for f in 0..((clip.duration * 8.).ceil() as usize).max(1) {
615 let bones = preview::sample_skin_matrices(&body, &clip.name, f as f32 / 8.);
616 let pts = skin_points(&body, &bones);
617 for pitch in preview::PAPERDOLL_PITCHES_DEG {
618 for yaw in preview::paperdoll_yaws_deg() {
619 let (v, p) = matrices(framing, yaw, pitch, 0.75);
620 let vp = preview::mul(p, v);
621 for q in &pts {
622 let nx = vp[0] * q[0] + vp[4] * q[1] + vp[8] * q[2] + vp[12];
623 let ny = vp[1] * q[0] + vp[5] * q[1] + vp[9] * q[2] + vp[13];
624 worst = worst.max(nx.abs()).max(ny.abs());
625 }
626 }
627 }
628 }
629 }
630 assert!(worst <= 1.001 && worst > 0.95, "worst NDC {worst}");
631 }
632
467 use super::*;
633 use super::*;
468 use crate::preview::{self, PreviewCharacter, SceneNode};
634 use crate::preview::{self, PreviewCharacter, SceneNode};
469
635
src/editor.rs +24 −0
@@ -931,6 +931,9 @@ impl EditorApp {
931 931 }
932 932 };
933 933 if let Some(opened) = &mut self.session.opened {
934 let aspect = opened.document.render.target_width.max(1) as f32
935 / opened.document.render.target_height.max(1) as f32;
936 let fps = opened.document.render.fps;
934 937 let camera = &mut opened.document.render.framing[sex];
935 938 let mut height = camera.height_milli as f32 / 1000.;
936 939 let mut center = camera.center_y_milli as f32 / 1000.;
@@ -988,6 +991,27 @@ impl EditorApp {
988 991 {
989 992 camera.pivot_y_milli = None;
990 993 }
994 let body = match sex {
995 0 => self.preview_male.as_ref(),
996 _ => self.preview_female.as_ref(),
997 };
998 if let Some(body) = body {
999 if ui
1000 .button("Omhullende animaties")
1001 .on_hover_text(
1002 "Eenmalig: hoogte en centrum zo dat alle clips, frames en 60 hoeken \
1003 binnen het kader vallen (+5% marge). Daarna weer vast; geen autozoom.",
1004 )
1005 .clicked()
1006 {
1007 if let Some((h, c)) =
1008 crate::camera::animation_envelope(body, *camera, aspect, fps, 0.05, 0.)
1009 {
1010 camera.height_milli = h.clamp(100, 10_000);
1011 camera.center_y_milli = c.clamp(-10_000, 10_000);
1012 }
1013 }
1014 }
991 1015 ui.checkbox(&mut self.show_render_frame, "Vast renderkader tonen");
992 1016 });
993 1017 let auto_range = diagnostic
@@ -931,6 +931,9 @@ impl EditorApp {
931 }
931 }
932 };
932 };
933 if let Some(opened) = &mut self.session.opened {
933 if let Some(opened) = &mut self.session.opened {
934 let aspect = opened.document.render.target_width.max(1) as f32
935 / opened.document.render.target_height.max(1) as f32;
936 let fps = opened.document.render.fps;
934 let camera = &mut opened.document.render.framing[sex];
937 let camera = &mut opened.document.render.framing[sex];
935 let mut height = camera.height_milli as f32 / 1000.;
938 let mut height = camera.height_milli as f32 / 1000.;
936 let mut center = camera.center_y_milli as f32 / 1000.;
939 let mut center = camera.center_y_milli as f32 / 1000.;
@@ -988,6 +991,27 @@ impl EditorApp {
988 {
991 {
989 camera.pivot_y_milli = None;
992 camera.pivot_y_milli = None;
990 }
993 }
994 let body = match sex {
995 0 => self.preview_male.as_ref(),
996 _ => self.preview_female.as_ref(),
997 };
998 if let Some(body) = body {
999 if ui
1000 .button("Omhullende animaties")
1001 .on_hover_text(
1002 "Eenmalig: hoogte en centrum zo dat alle clips, frames en 60 hoeken \
1003 binnen het kader vallen (+5% marge). Daarna weer vast; geen autozoom.",
1004 )
1005 .clicked()
1006 {
1007 if let Some((h, c)) =
1008 crate::camera::animation_envelope(body, *camera, aspect, fps, 0.05, 0.)
1009 {
1010 camera.height_milli = h.clamp(100, 10_000);
1011 camera.center_y_milli = c.clamp(-10_000, 10_000);
1012 }
1013 }
1014 }
991 ui.checkbox(&mut self.show_render_frame, "Vast renderkader tonen");
1015 ui.checkbox(&mut self.show_render_frame, "Vast renderkader tonen");
992 });
1016 });
993 let auto_range = diagnostic
1017 let auto_range = diagnostic