spine-rigc 2.54.0 → 2.56.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/docs/AUTHORING.md +590 -30
- package/package.json +1 -1
- package/src/anglerange.ts +190 -0
- package/src/assertions/bodies/a23.ts +80 -5
- package/src/cli/core_commands.ts +236 -14
- package/src/cli/shared.ts +53 -8
- package/src/compile.ts +45 -6
- package/src/core/entry.ts +6 -1
- package/src/deformgen.ts +333 -2
- package/src/motion.ts +20 -8
- package/src/passthrough.ts +575 -0
- package/src/render_shared.ts +30 -0
- package/src/rig.ts +95 -3
- package/src/stop.ts +472 -0
- package/src/timelines.ts +75 -29
- package/src/transform.ts +20 -0
- package/src/types.ts +4 -3
- package/src/viewportpin.ts +168 -0
- package/src/whirl.ts +580 -0
package/src/timelines.ts
CHANGED
|
@@ -476,7 +476,7 @@ export interface PhysicsPoseRule {
|
|
|
476
476
|
* the value, and the row's `why` — the key's sentence — opens with the arms a
|
|
477
477
|
* key can still take. Until this field existed `src/types.ts` said of all four
|
|
478
478
|
* rows that "the bounds are the runtime's, not a policy", which is true of two
|
|
479
|
-
* of the
|
|
479
|
+
* of the nine ways out.
|
|
480
480
|
*/
|
|
481
481
|
basis: readonly PhysicsBoundBasis[];
|
|
482
482
|
/** The bound in words, for a message: what the value has to be. */
|
|
@@ -517,7 +517,7 @@ const STRENGTH_OUTSIDE: readonly PhysicsOutsideArm[] = [
|
|
|
517
517
|
];
|
|
518
518
|
|
|
519
519
|
/**
|
|
520
|
-
* The
|
|
520
|
+
* The nine ways out of the four bounds, each with its basis (issue #798). They
|
|
521
521
|
* are named constants rather than literals inside the rows because each row's
|
|
522
522
|
* `why` quotes its own — the key's sentence and `A23`'s setup sentence are then
|
|
523
523
|
* one text about one number, as `STRENGTH_OUTSIDE` already made them for two.
|
|
@@ -531,7 +531,8 @@ const STRENGTH_OUTSIDE: readonly PhysicsOutsideArm[] = [
|
|
|
531
531
|
* exactly where the rig with no constraint does, on every step; `mix` −0.5 is
|
|
532
532
|
* finite and moves the bone by exactly −1× what +0.5 moves it by; `damping` 2 is
|
|
533
533
|
* finite over the walk (Infinity only at step 1,065); `damping` −0.5 at 45 fps is
|
|
534
|
-
* NaN at step 2. `
|
|
534
|
+
* NaN at step 2. The ninth, `damping` 1 (issue #1422), is finite on every step
|
|
535
|
+
* and its velocity never decays. `T113` re-takes all nine on every run.
|
|
535
536
|
*/
|
|
536
537
|
const MIX_BELOW_0: PhysicsBoundBasis = {
|
|
537
538
|
kind: 'behavioural',
|
|
@@ -584,6 +585,33 @@ const DAMPING_ABOVE_1: PhysicsBoundBasis = {
|
|
|
584
585
|
witness: { value: 2 },
|
|
585
586
|
does: 'above 1 every velocity grows on every step and the offset diverges',
|
|
586
587
|
};
|
|
588
|
+
/**
|
|
589
|
+
* `damping` at exactly 1 (issue #1422) — inside the bound from #794 until then.
|
|
590
|
+
*
|
|
591
|
+
* 🔑 The decay `damping ** (60 * step)` is 1 at every rate, so the step map
|
|
592
|
+
* `physicsStepStability` derives has determinant 1, and for every `k` under the
|
|
593
|
+
* bound its two eigenvalues are a complex pair of modulus √1 = 1: ON the unit
|
|
594
|
+
* circle, where every other damping puts them inside it. Nothing an excitation
|
|
595
|
+
* puts into the offset ever decays, so the rig is marginal at best — and the
|
|
596
|
+
* excitation is not benign. [measured on issue #1422, spine-core and rigc's core
|
|
597
|
+
* identical bit for bit, x drive, inertia 1, fps 30, strength 100, the runtime's
|
|
598
|
+
* default world, 600 s at 60 Hz] one such link held its peak offset at 40.3 for
|
|
599
|
+
* the whole walk; two identical ones on one chain grew it LINEARLY, 6.03e3 at
|
|
600
|
+
* 60 s to 6.09e4 at 600 s; three ran past 10⁶ at 316 s. The parent's undamped
|
|
601
|
+
* oscillation reaches the child through inertia at exactly the child's own
|
|
602
|
+
* frequency. Detuning the second link's strength by 1 % held it flat, which is
|
|
603
|
+
* why the refusal is of the value and not of a pair: equal maps are the edge,
|
|
604
|
+
* and no authored number states how close is too close.
|
|
605
|
+
*/
|
|
606
|
+
const DAMPING_AT_1: PhysicsBoundBasis = {
|
|
607
|
+
kind: 'behavioural',
|
|
608
|
+
when: (v) => v === 1,
|
|
609
|
+
witness: { value: 1 },
|
|
610
|
+
does:
|
|
611
|
+
'at 1 the per-step decay is 1 at every rate, so no velocity ever decays: the step\'s two eigenvalues lie ON the unit ' +
|
|
612
|
+
'circle (modulus √1 = 1) rather than inside it, so nothing a push puts in (wind, gravity, a key, the bone moving) ever ' +
|
|
613
|
+
'dies out, and two such constraints on one chain stepping alike resonate and grow without limit',
|
|
614
|
+
};
|
|
587
615
|
const DAMPING_BELOW_0: PhysicsBoundBasis = {
|
|
588
616
|
kind: 'arithmetic',
|
|
589
617
|
when: (v) => v < 0,
|
|
@@ -626,8 +654,8 @@ const DAMPING_BELOW_0: PhysicsBoundBasis = {
|
|
|
626
654
|
*
|
|
627
655
|
* 🔎 Each row's `basis` says, per way out, whether the runtime's arithmetic
|
|
628
656
|
* fails there or the value runs and rigc refuses what it does (issue #798). Of
|
|
629
|
-
* the
|
|
630
|
-
* and
|
|
657
|
+
* the nine ways out, two are arithmetic — `mass` at 0 and `damping` below 0 —
|
|
658
|
+
* and seven are rigc's call.
|
|
631
659
|
*/
|
|
632
660
|
export const PHYSICS_POSE_RULES: PhysicsPoseRule[] = [
|
|
633
661
|
{
|
|
@@ -697,7 +725,7 @@ export const PHYSICS_POSE_RULES: PhysicsPoseRule[] = [
|
|
|
697
725
|
timeline: 'damping',
|
|
698
726
|
field: 'damping',
|
|
699
727
|
toPose: (v) => v,
|
|
700
|
-
poseOk: (v) => v >= 0 && v
|
|
728
|
+
poseOk: (v) => v >= 0 && v < 1,
|
|
701
729
|
keyOk: null,
|
|
702
730
|
inertAtSetup: false,
|
|
703
731
|
outside: null,
|
|
@@ -705,22 +733,27 @@ export const PHYSICS_POSE_RULES: PhysicsPoseRule[] = [
|
|
|
705
733
|
// above 1 is a run-away, finite until the velocity overflows (step 1,065
|
|
706
734
|
// for a setup of 2 at 60 fps), so it is behavioural by the line
|
|
707
735
|
// `PhysicsBoundBasis` draws — the card that asked for the bases (#798)
|
|
708
|
-
// counted it arithmetic, and the walk says otherwise.
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
//
|
|
714
|
-
//
|
|
715
|
-
//
|
|
716
|
-
//
|
|
717
|
-
//
|
|
718
|
-
//
|
|
719
|
-
//
|
|
736
|
+
// counted it arithmetic, and the walk says otherwise. 1 itself is
|
|
737
|
+
// behavioural too: finite on every step, and never decaying.
|
|
738
|
+
basis: [DAMPING_ABOVE_1, DAMPING_AT_1, DAMPING_BELOW_0],
|
|
739
|
+
states: 'inside [0, 1)',
|
|
740
|
+
statesKeyed: 'inside [0, 1)',
|
|
741
|
+
// 🔑 The interval is HALF-OPEN (issue #1422): 0 is in and 1 is out. `0 ** x`
|
|
742
|
+
// is 0 for every positive exponent, so 0 follows the bone with no overshoot
|
|
743
|
+
// at every `fps` — a rig somebody can mean. 1 was inside from #794 to #1422,
|
|
744
|
+
// on the argument that `1 ** x` is 1 and finite at every rate, a jiggle held
|
|
745
|
+
// rather than a fault. Finite it is — [measured] through spine-core on the
|
|
746
|
+
// generated physics fixture, 120 steps from `Physics.reset` at 60, 45 and
|
|
747
|
+
// 30 fps, every pose value finite and the velocity never 0 — and that is why
|
|
748
|
+
// its arm is behavioural. What the argument missed is that a step whose
|
|
749
|
+
// decay is 1 has its eigenvalues on the unit circle, so nothing that excites
|
|
750
|
+
// it ever dies out, and a chain of such links resonates: `DAMPING_AT_1` has
|
|
751
|
+
// the measurement. ⚠️ 1 is also 4.2's own parser default for an omitted
|
|
720
752
|
// `damping` (`SkeletonJson.js:242` in 4.2.120,
|
|
721
753
|
// `getValue(constraintMap, "damping", 1)`; 4.3.13 reads 0.85 at `:308`), so a
|
|
722
|
-
// rig migrated from 4.2 that keys "the default" writes 1 and
|
|
723
|
-
//
|
|
754
|
+
// rig migrated from 4.2 that keys "the default" writes 1 and is refused; at
|
|
755
|
+
// #1422 none of the public recipes, the gallery, the twelve example exports,
|
|
756
|
+
// the trailer rig or the parts recipes states it, at rest or keyed.
|
|
724
757
|
//
|
|
725
758
|
// ⚠️ The frame-rate half is why the bound cannot be checked by playing a rig
|
|
726
759
|
// at 60 fps (issue #748): `step` is `1 / fps`, the constraint's own rate, so
|
|
@@ -734,9 +767,9 @@ export const PHYSICS_POSE_RULES: PhysicsPoseRule[] = [
|
|
|
734
767
|
"every rate — finite on every step until the velocity overflows, so refusing it is rigc's call rather than the " +
|
|
735
768
|
"runtime's. Below 0 the result depends on `fps`: where `60 / fps` is a " +
|
|
736
769
|
'whole number a negative base stays finite — at 60 fps it is the velocity\'s sign flipped each step, which can ' +
|
|
737
|
-
`look like a jiggle settling — and ${DAMPING_BELOW_0.expression} (\`(-0.5) ** (60 / 45)\`), so a rig tried only at 60 fps never shows the failure.
|
|
738
|
-
|
|
739
|
-
'
|
|
770
|
+
`look like a jiggle settling — and ${DAMPING_BELOW_0.expression} (\`(-0.5) ** (60 / 45)\`), so a rig tried only at 60 fps never shows the failure. ` +
|
|
771
|
+
`And ${DAMPING_AT_1.does} — finite on every step as well, so that refusal is rigc's call too. 0 is inside: ` +
|
|
772
|
+
'at 0 every velocity is zeroed on every step, so the offset follows the bone with no overshoot',
|
|
740
773
|
},
|
|
741
774
|
];
|
|
742
775
|
|
|
@@ -746,11 +779,11 @@ export interface PhysicsStepStability {
|
|
|
746
779
|
readonly k: number;
|
|
747
780
|
/** The per-step velocity decay, `damping ** (60 * step)` — the runtime's own expression. */
|
|
748
781
|
readonly d: number;
|
|
749
|
-
/** `2 · (1 + d)`: the step is stable exactly when `k` is below it. */
|
|
782
|
+
/** `2 · (1 + d)`: for `d` below 1 the step is stable exactly when `k` is below it. */
|
|
750
783
|
readonly bound: number;
|
|
751
784
|
/** The largest magnitude among the step map's two eigenvalues — what one step multiplies an undisturbed offset by, at most. */
|
|
752
785
|
readonly growth: number;
|
|
753
|
-
/** `k < bound`, strictly. */
|
|
786
|
+
/** `k < bound` and `d < 1`, both strictly. */
|
|
754
787
|
readonly stable: boolean;
|
|
755
788
|
}
|
|
756
789
|
|
|
@@ -766,9 +799,22 @@ export interface PhysicsStepStability {
|
|
|
766
799
|
* additive — wind and gravity set how far the offset goes, never whether it
|
|
767
800
|
* comes back — so with `u = v·step` the step is the linear map
|
|
768
801
|
* `[offset; u] ← [[1 − k, 1], [−k·d, d]]·[offset; u]`, `k = strength·m·step`:
|
|
769
|
-
* trace `1 − k + d`, determinant `d`. For `d` in `[0, 1
|
|
770
|
-
*
|
|
771
|
-
*
|
|
802
|
+
* trace `1 − k + d`, determinant `d`. For `d` in `[0, 1)` both eigenvalues lie
|
|
803
|
+
* inside the unit circle exactly when `0 < k < 2·(1 + d)`. The lower end is the
|
|
804
|
+
* strength row's; this is the upper.
|
|
805
|
+
*
|
|
806
|
+
* ⚠️ **Not at `d = 1`** — this comment said `[0, 1]` until issue #1422, and it
|
|
807
|
+
* was false at that end. The determinant is `d`, so at `d = 1` the product of the
|
|
808
|
+
* two eigenvalues is 1, and for every `0 < k < 4` they are a complex pair of
|
|
809
|
+
* modulus `√d` = 1: on the circle, not inside it. One such link never decays
|
|
810
|
+
* (marginal), and two on one chain stepping alike resonate and grow without
|
|
811
|
+
* limit — measured on #1422, and the reason the damping row now ends below 1
|
|
812
|
+
* (`DAMPING_AT_1`). `d` is 1 for a `damping` of 1 at every rate, and — in the
|
|
813
|
+
* runtime's own doubles — for a `damping` a hair under 1 at a high enough rate:
|
|
814
|
+
* [measured] the largest double below 1 gives `d` exactly 1 from fps 121 on,
|
|
815
|
+
* while the largest float32 below 1, which is all a key can hold, needs about
|
|
816
|
+
* 6.4·10¹⁰. The runtime then multiplies every velocity by exactly 1, so `stable`
|
|
817
|
+
* requires `d < 1` as well, and A23 names that case by the decay it reached.
|
|
772
818
|
*
|
|
773
819
|
* ⚠️ **Strict.** At `k = 2·(1 + d)` one eigenvalue is exactly −1: the offset
|
|
774
820
|
* flips sign every step and never settles, and at `d = 1` the two coincide and
|
|
@@ -798,7 +844,7 @@ export function physicsStepStability(strength: number, massInverse: number, damp
|
|
|
798
844
|
const trace = 1 - k + d;
|
|
799
845
|
const disc = trace * trace - 4 * d;
|
|
800
846
|
const growth = disc >= 0 ? Math.max(Math.abs(trace + Math.sqrt(disc)), Math.abs(trace - Math.sqrt(disc))) / 2 : Math.sqrt(d);
|
|
801
|
-
return { k, d, bound, growth, stable: k < bound };
|
|
847
|
+
return { k, d, bound, growth, stable: k < bound && d < 1 };
|
|
802
848
|
}
|
|
803
849
|
|
|
804
850
|
/**
|
package/src/transform.ts
CHANGED
|
@@ -287,6 +287,26 @@ export function toWorld(m: BoneTransform, localX: number, localY: number): [numb
|
|
|
287
287
|
return [m.a * localX + m.b * localY + m.worldX, m.c * localX + m.d * localY + m.worldY];
|
|
288
288
|
}
|
|
289
289
|
|
|
290
|
+
/**
|
|
291
|
+
* The direction of a displacement, in degrees CCW from +x, y up, in (-180, 180]
|
|
292
|
+
* — radians to degrees by the exact frame's division (`EXACT_FRAME_ARITHMETIC`),
|
|
293
|
+
* so an angle read here off `computeExactFrameTransforms` and that frame's own
|
|
294
|
+
* `worldRotation` are one arithmetic. Not rounded, for `toWorld`'s reason.
|
|
295
|
+
*/
|
|
296
|
+
export function directionDegrees(dx: number, dy: number): number {
|
|
297
|
+
return EXACT_FRAME_ARITHMETIC.degreesOf(Math.atan2(dy, dx));
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* A world displacement's direction in a bone's local space — the rotation a
|
|
302
|
+
* child of that bone states to point along it. `toBoneLocalVector`, then
|
|
303
|
+
* `directionDegrees`; a stop's frame (`src/stop.ts`) is aimed with it.
|
|
304
|
+
*/
|
|
305
|
+
export function localDirectionDegrees(m: BoneTransform, worldDX: number, worldDY: number): number {
|
|
306
|
+
const [lx, ly] = toBoneLocalVector(m, worldDX, worldDY);
|
|
307
|
+
return directionDegrees(lx, ly);
|
|
308
|
+
}
|
|
309
|
+
|
|
290
310
|
/**
|
|
291
311
|
* Normalise degrees into (-180, 180], which is how an editor shows a rotation.
|
|
292
312
|
*
|
package/src/types.ts
CHANGED
|
@@ -352,14 +352,15 @@ export type BoneProperty =
|
|
|
352
352
|
* export; `PHYSICS_TRACKS` in `compile.ts` carries the argument.
|
|
353
353
|
*
|
|
354
354
|
* 🔒 **Four of them have a compile-time range** (issue #610): `mass` must be
|
|
355
|
-
* `> 0`, `damping` inside the
|
|
356
|
-
* more. The bounds are `PHYSICS_POSE_RULES` in `src/timelines.ts`, and each row's
|
|
355
|
+
* `> 0`, `damping` inside the half-open `[0, 1)` (issue #1422; closed from #794),
|
|
356
|
+
* and `mix` and `strength` `0` or more. The bounds are `PHYSICS_POSE_RULES` in `src/timelines.ts`, and each row's
|
|
357
357
|
* `basis` says, per way out, whose they are (issue #798). Two are the
|
|
358
358
|
* runtime's arithmetic: a `mass` of 0 is an infinite `massInverse` and NaN from
|
|
359
359
|
* the first step, and a `damping` below 0 is a negative base under a fractional
|
|
360
|
-
* exponent at any `fps` where `60 / fps` is not whole. The other
|
|
360
|
+
* exponent at any `fps` where `60 / fps` is not whole. The other seven are rigc's
|
|
361
361
|
* call — the rig runs, finitely, and runs wrongly: `mass` below 0 and
|
|
362
362
|
* `strength` below 0 run away, `damping` above 1 diverges until it overflows,
|
|
363
|
+
* `damping` at 1 never decays and resonates on a chain,
|
|
363
364
|
* `mix` below 0 is the jiggle inverted, and a setup `mix` or `strength` of 0 is
|
|
364
365
|
* a constraint muted or pulled back by nothing. `inertia`, `wind`, `gravity`
|
|
365
366
|
* and the top of `mix` are bounded nowhere, because the runtime documents
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A render's box given rather than fitted — `rigc render --viewport` and
|
|
3
|
+
* `rigc render --viewport-from` (issue #1414) — and what the frames leave out of it.
|
|
4
|
+
*
|
|
5
|
+
* ⭐ **Why a box is given.** `render` fits its box to the rig it draws, so two
|
|
6
|
+
* candidates — a rig before and after a polish — land on two grids (377×480 against
|
|
7
|
+
* 385×480 on the run that asked for this), and a comparison of their pixels needs a
|
|
8
|
+
* transform written by hand. A given box puts both on one grid: the same world box
|
|
9
|
+
* and the same pixel size, so a world point lands on the same pixel in both.
|
|
10
|
+
*
|
|
11
|
+
* Two ways to give it, and only two:
|
|
12
|
+
*
|
|
13
|
+
* - `--viewport <x,y,w,h>` — a world box, y up, `x,y` its bottom-left corner: the
|
|
14
|
+
* spelling `check --viewport` and `inspect --viewport` already take, and the
|
|
15
|
+
* numbers `frames.json`'s `viewport` records. Its pixel size follows from `--max`
|
|
16
|
+
* as a fitted box's does.
|
|
17
|
+
* - `--viewport-from <frames.json | render root>` — an earlier render's grid exactly:
|
|
18
|
+
* its box, its scale and its pixel size as that `frames.json` records them, so the
|
|
19
|
+
* second render is on the first one's pixels to the bit. `--max` is refused beside
|
|
20
|
+
* it, because the file already fixes the pixel size.
|
|
21
|
+
*
|
|
22
|
+
* ⚠️ **Nothing is cropped silently.** A given box can leave part of the rig out,
|
|
23
|
+
* which a fitted box — measured over the rig's own poses at 60 fps and padded —
|
|
24
|
+
* is built not to. Each frame is the box — what lies past it is not
|
|
25
|
+
* drawn — so every slot whose drawn geometry reaches past it is named, by set and
|
|
26
|
+
* frame, with how far and on which side, and `frames.json` records the same list
|
|
27
|
+
* under `pinned` (`outsideViewport`).
|
|
28
|
+
*
|
|
29
|
+
* Pure: no clock, no randomness, no runtime.
|
|
30
|
+
*/
|
|
31
|
+
import { indexRuns } from './folds.ts';
|
|
32
|
+
import { FRAMES_SPEC, type Frame, type PinnedViewport, type Viewport, unionBounds, viewportOfSize } from './render_shared.ts';
|
|
33
|
+
|
|
34
|
+
/** A given box that cannot be used — said by name. */
|
|
35
|
+
export class ViewportError extends Error {}
|
|
36
|
+
|
|
37
|
+
/** `--viewport`'s value: four numbers, `x,y,w,h`, the world box, y up — refused by name otherwise. Shared by `render`, `check` and `inspect`. */
|
|
38
|
+
export function readViewportBox(raw: string): { x: number; y: number; width: number; height: number } {
|
|
39
|
+
const parts = raw.split(',').map((s) => Number(s.trim()));
|
|
40
|
+
if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n))) {
|
|
41
|
+
throw new ViewportError('--viewport takes four numbers: <x>,<y>,<width>,<height> — the world box, y up');
|
|
42
|
+
}
|
|
43
|
+
if (parts[2] <= 0 || parts[3] <= 0) throw new ViewportError('--viewport width and height must be positive');
|
|
44
|
+
return { x: parts[0], y: parts[1], width: parts[2], height: parts[3] };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The grid a `frames.json` records — `value` is the parsed file, `where` its path —
|
|
49
|
+
* as the viewport a second render draws on: the box, the scale and the pixel size
|
|
50
|
+
* exactly as written (`viewportOfSize`, the reading `check` takes of the same
|
|
51
|
+
* block). Refused naming the field and its value where the file is not a frame
|
|
52
|
+
* set's sidecar or its `viewport` is not a grid.
|
|
53
|
+
*/
|
|
54
|
+
export function viewportOfFramesFile(value: unknown, where: string): Viewport {
|
|
55
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new ViewportError(`--viewport-from ${where}: the file is not a JSON object`);
|
|
56
|
+
const file = value as Record<string, unknown>;
|
|
57
|
+
if (file.spec !== FRAMES_SPEC) {
|
|
58
|
+
throw new ViewportError(`--viewport-from ${where}: spec is ${JSON.stringify(file.spec)}, and the grid is read off a frame set's ${FRAMES_SPEC} sidecar — the frames.json \`render\` writes`);
|
|
59
|
+
}
|
|
60
|
+
const v = file.viewport;
|
|
61
|
+
if (typeof v !== 'object' || v === null || Array.isArray(v)) throw new ViewportError(`--viewport-from ${where}: no viewport object`);
|
|
62
|
+
const box = v as Record<string, unknown>;
|
|
63
|
+
const field = (name: string, rule: 'finite' | 'positive' | 'pixels'): number => {
|
|
64
|
+
const n = box[name];
|
|
65
|
+
const ok =
|
|
66
|
+
typeof n === 'number' &&
|
|
67
|
+
Number.isFinite(n) &&
|
|
68
|
+
(rule === 'finite' || n > 0) &&
|
|
69
|
+
(rule !== 'pixels' || Number.isInteger(n));
|
|
70
|
+
if (!ok) {
|
|
71
|
+
const need = rule === 'finite' ? 'a finite number' : rule === 'positive' ? 'a number above 0' : 'a whole number of pixels above 0';
|
|
72
|
+
throw new ViewportError(`--viewport-from ${where}: viewport.${name} is ${JSON.stringify(n) ?? 'absent'}; it must be ${need}`);
|
|
73
|
+
}
|
|
74
|
+
return n as number;
|
|
75
|
+
};
|
|
76
|
+
return viewportOfSize(
|
|
77
|
+
field('x', 'finite'),
|
|
78
|
+
field('y', 'finite'),
|
|
79
|
+
field('width', 'positive'),
|
|
80
|
+
field('height', 'positive'),
|
|
81
|
+
field('scale', 'positive'),
|
|
82
|
+
field('pixelWidth', 'pixels'),
|
|
83
|
+
field('pixelHeight', 'pixels'),
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The four sides of a box, world terms, y up, in the order a line names them. */
|
|
88
|
+
export const SIDES = ['left', 'right', 'bottom', 'top'] as const;
|
|
89
|
+
export type Side = (typeof SIDES)[number];
|
|
90
|
+
|
|
91
|
+
/** One slot that reaches past the box on one set: the frames, how far at most (world units) and on which sides. */
|
|
92
|
+
export interface OutsideReading {
|
|
93
|
+
set: string;
|
|
94
|
+
slot: string;
|
|
95
|
+
frames: number[];
|
|
96
|
+
/** The farthest any of its vertices reaches past the box, on any frame and side, world units. */
|
|
97
|
+
most: number;
|
|
98
|
+
sides: Side[];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* A wrong reading the suite plants to see its control go red (issue #1414):
|
|
103
|
+
* `ownBox` measures each set against the box its own frames fill rather than the
|
|
104
|
+
* given one — a reading that ignores the viewport.
|
|
105
|
+
*/
|
|
106
|
+
export interface OutsidePlant {
|
|
107
|
+
ownBox?: true;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Every slot whose drawn geometry reaches past `viewport` — a piece's posed
|
|
112
|
+
* vertex strictly outside the box, as the framing measures a piece — per set in
|
|
113
|
+
* `sets`' order, the slots in the order they first reach past it. Read off the
|
|
114
|
+
* frames as drawn: a slot `--slot`/`--hide` leaves out draws no piece and is not
|
|
115
|
+
* read, and a clip's cut is what is read.
|
|
116
|
+
*/
|
|
117
|
+
export function outsideViewport(sets: ReadonlyMap<string, readonly Frame[]>, viewport: Viewport, plant: OutsidePlant = {}): OutsideReading[] {
|
|
118
|
+
const out: OutsideReading[] = [];
|
|
119
|
+
for (const [set, frames] of sets) {
|
|
120
|
+
const box = plant.ownBox === true ? unionBounds([[...frames]]) : viewport;
|
|
121
|
+
const bySlot = new Map<string, { frames: Set<number>; most: number; sides: Set<Side> }>();
|
|
122
|
+
for (const frame of frames) {
|
|
123
|
+
for (const piece of frame.pieces) {
|
|
124
|
+
for (let i = 0; i + 1 < piece.world.length; i += 2) {
|
|
125
|
+
const x = piece.world[i];
|
|
126
|
+
const y = piece.world[i + 1];
|
|
127
|
+
const past: Array<[Side, number]> = [
|
|
128
|
+
['left', box.minX - x],
|
|
129
|
+
['right', x - box.maxX],
|
|
130
|
+
['bottom', box.minY - y],
|
|
131
|
+
['top', y - box.maxY],
|
|
132
|
+
];
|
|
133
|
+
for (const [side, by] of past) {
|
|
134
|
+
if (!(by > 0)) continue;
|
|
135
|
+
let slot = bySlot.get(piece.slot);
|
|
136
|
+
if (slot === undefined) {
|
|
137
|
+
slot = { frames: new Set(), most: 0, sides: new Set() };
|
|
138
|
+
bySlot.set(piece.slot, slot);
|
|
139
|
+
}
|
|
140
|
+
slot.frames.add(frame.index);
|
|
141
|
+
slot.most = Math.max(slot.most, by);
|
|
142
|
+
slot.sides.add(side);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
for (const [slot, read] of bySlot) {
|
|
148
|
+
out.push({ set, slot, frames: [...read.frames].sort((a, b) => a - b), most: read.most, sides: SIDES.filter((s) => read.sides.has(s)) });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** `frames.json`'s `pinned` record for a given box and its reading. */
|
|
155
|
+
export function pinnedRecord(by: PinnedViewport['by'], outside: readonly OutsideReading[]): PinnedViewport {
|
|
156
|
+
return { by, outside: outside.map(({ set, slot, frames }) => ({ set, slot, frames })) };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The printed line for one slot past the box: the set, the slot, the frames, how
|
|
161
|
+
* far at most and on which sides — and what happens to what lies past it.
|
|
162
|
+
*/
|
|
163
|
+
export function outsideLine(r: OutsideReading, fps: number): string {
|
|
164
|
+
return (
|
|
165
|
+
`${JSON.stringify(r.set)}: slot ${JSON.stringify(r.slot)} reaches past the box on ${r.frames.length} frame(s) [${indexRuns(r.frames)}] at ${fps} fps, ` +
|
|
166
|
+
`by up to ${r.most.toFixed(2)} units (${r.sides.join(', ')}) — what lies past the box is not drawn: each frame is the box`
|
|
167
|
+
);
|
|
168
|
+
}
|