spine-rigc 0.16.0 → 0.18.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 +67 -6
- package/cli.ts +368 -19
- package/docs/AUTHORING.md +570 -99
- package/docs/FACE.md +256 -0
- package/docs/MOTION.md +52 -0
- package/package.json +2 -1
- package/skills/motion/SKILL.md +1 -1
- package/src/compile.ts +770 -41
- package/src/deformgen.ts +125 -18
- package/src/deformmeasure.ts +1469 -138
- package/src/depth.ts +610 -0
- package/src/mesh.ts +104 -1
- package/src/rig.ts +124 -0
- package/src/transform.ts +24 -0
- package/src/types.ts +66 -3
- package/src/validate.ts +464 -8
- package/tools/editor_roundtrip.ts +557 -0
package/src/compile.ts
CHANGED
|
@@ -17,9 +17,22 @@
|
|
|
17
17
|
* Determinism is a contract, not a habit: `validate` re-runs this and compares
|
|
18
18
|
* the two emits byte for byte (assertion A18).
|
|
19
19
|
*/
|
|
20
|
-
import { basename, dirname, relative, resolve } from 'node:path';
|
|
20
|
+
import { basename, dirname, join, relative, resolve } from 'node:path';
|
|
21
21
|
import { existsSync, readFileSync } from 'node:fs';
|
|
22
22
|
import { readPngInfo } from './png.ts';
|
|
23
|
+
import {
|
|
24
|
+
DEPTH_TONE_IDENTITY,
|
|
25
|
+
DepthError,
|
|
26
|
+
checkTone,
|
|
27
|
+
checkZScale,
|
|
28
|
+
depthDigest,
|
|
29
|
+
sampleLevel,
|
|
30
|
+
toneLevel,
|
|
31
|
+
turnCeiling,
|
|
32
|
+
type DepthMap,
|
|
33
|
+
type DepthNear,
|
|
34
|
+
type DepthTone,
|
|
35
|
+
} from './depth.ts';
|
|
23
36
|
import { CompileError, NotImplementedError } from './errors.ts';
|
|
24
37
|
import { parseJsonWithPosition } from './json-position.ts';
|
|
25
38
|
import { parseMotionSpec } from './motion.ts';
|
|
@@ -42,9 +55,12 @@ import {
|
|
|
42
55
|
type RigRegionAttachment,
|
|
43
56
|
type RigSpec,
|
|
44
57
|
type RigVertexGeometry,
|
|
58
|
+
type RigDepthMap,
|
|
59
|
+
type RigSoftRegion,
|
|
45
60
|
} from './rig.ts';
|
|
46
61
|
import {
|
|
47
62
|
buildContourMesh,
|
|
63
|
+
buildGridMesh,
|
|
48
64
|
buildRibbonMesh,
|
|
49
65
|
buildRingMesh,
|
|
50
66
|
checkHullOrder,
|
|
@@ -57,6 +73,7 @@ import {
|
|
|
57
73
|
type MeshBoneRef,
|
|
58
74
|
type MeshFitReport,
|
|
59
75
|
type MeshGeometry,
|
|
76
|
+
type MeshVertexWeight,
|
|
60
77
|
} from './mesh.ts';
|
|
61
78
|
import { Plate, readPlate } from '../tools/plate.ts';
|
|
62
79
|
import {
|
|
@@ -76,6 +93,7 @@ import {
|
|
|
76
93
|
normaliseDegrees,
|
|
77
94
|
screenToSpineDegrees,
|
|
78
95
|
toBoneLocal,
|
|
96
|
+
toBoneLocalVector,
|
|
79
97
|
toWorld,
|
|
80
98
|
TransformError,
|
|
81
99
|
type BoneTransform,
|
|
@@ -1246,6 +1264,9 @@ export function compile(opts: CompileOptions): CompileResult {
|
|
|
1246
1264
|
for (const skinName of skinNames) tableFor(skinName);
|
|
1247
1265
|
const meshBones = new Set<string>();
|
|
1248
1266
|
const meshes: CompileResult['meshes'] = [];
|
|
1267
|
+
// `skin/slot/attachment` -> per-vertex z, for the attachments that named a
|
|
1268
|
+
// depth map. Read at deform-key time; never emitted. See `AttachmentContext`.
|
|
1269
|
+
const attachmentDepths = new Map<string, number[]>();
|
|
1249
1270
|
|
|
1250
1271
|
for (const rigSlot of rig.slots) {
|
|
1251
1272
|
const part = partBySlot.get(rigSlot.name);
|
|
@@ -1335,6 +1356,9 @@ export function compile(opts: CompileOptions): CompileResult {
|
|
|
1335
1356
|
meshes,
|
|
1336
1357
|
slotName: rigSlot.name,
|
|
1337
1358
|
anchorBone: rigSlot.bone,
|
|
1359
|
+
skinName,
|
|
1360
|
+
imagesDir,
|
|
1361
|
+
depths: attachmentDepths,
|
|
1338
1362
|
slotNames: new Set(rig.slots.map((s) => s.name)),
|
|
1339
1363
|
});
|
|
1340
1364
|
}
|
|
@@ -1424,6 +1448,7 @@ export function compile(opts: CompileOptions): CompileResult {
|
|
|
1424
1448
|
slotNames: new Set(rig.slots.map((s) => s.name)),
|
|
1425
1449
|
pathSlots,
|
|
1426
1450
|
animationNames: new Set(Object.keys(motion.animations ?? {})),
|
|
1451
|
+
animationDurations: new Map(Object.entries(motion.animations ?? {}).map(([name, anim]) => [name, anim.duration])),
|
|
1427
1452
|
};
|
|
1428
1453
|
// Read as the raw records they are on disk: `buildRigConstraint` checks every
|
|
1429
1454
|
// field itself, because a spec that came off a file has whatever the author
|
|
@@ -1665,7 +1690,10 @@ export function compile(opts: CompileOptions): CompileResult {
|
|
|
1665
1690
|
motion,
|
|
1666
1691
|
animName,
|
|
1667
1692
|
anim.duration,
|
|
1668
|
-
|
|
1693
|
+
{
|
|
1694
|
+
...deformGeometryOf(attachment, at, bones, transforms),
|
|
1695
|
+
depth: attachmentDepths.get(`${skinName}/${track.slot}/${track.attachment}`) ?? null,
|
|
1696
|
+
},
|
|
1669
1697
|
deformTransforms,
|
|
1670
1698
|
);
|
|
1671
1699
|
for (const key of keys) compiledDuration = Math.max(compiledDuration, key.time as number);
|
|
@@ -1930,6 +1958,25 @@ interface AttachmentContext {
|
|
|
1930
1958
|
meshes: CompileResult['meshes'];
|
|
1931
1959
|
slotName: string;
|
|
1932
1960
|
anchorBone: string;
|
|
1961
|
+
/** Which skin this attachment is being built for — half of a `depths` key. */
|
|
1962
|
+
skinName: string;
|
|
1963
|
+
/**
|
|
1964
|
+
* Where a file the spec names is resolved from. A depth map is read from here
|
|
1965
|
+
* and never packed, so it is the one input path that does not go through
|
|
1966
|
+
* `images`.
|
|
1967
|
+
*/
|
|
1968
|
+
imagesDir: string;
|
|
1969
|
+
/**
|
|
1970
|
+
* Per-vertex `z` for the attachments that named a depth map, keyed by
|
|
1971
|
+
* `skin/slot/attachment`.
|
|
1972
|
+
*
|
|
1973
|
+
* ⭐ It rides beside the emitted attachment rather than on it. A `z` written
|
|
1974
|
+
* into the mesh would be a field the Spine format has no room for — the
|
|
1975
|
+
* parser drops what it does not know, so it would survive exactly until the
|
|
1976
|
+
* round trip and then vanish — and the whole point of this table is to be
|
|
1977
|
+
* read by `evaluateDeformTransform` at key time and never emitted at all.
|
|
1978
|
+
*/
|
|
1979
|
+
depths: Map<string, number[]>;
|
|
1933
1980
|
/** Every slot the rig declares — a clipping attachment's `end` resolves here. */
|
|
1934
1981
|
slotNames: Set<string>;
|
|
1935
1982
|
}
|
|
@@ -2580,6 +2627,7 @@ function buildGeneratedMesh(
|
|
|
2580
2627
|
ctx: AttachmentContext,
|
|
2581
2628
|
): SpineMeshAttachment {
|
|
2582
2629
|
if (generator.kind === 'contour') return buildContourAttachment(att, generator, placeholder, where, ctx);
|
|
2630
|
+
if (generator.kind === 'grid') return buildGridAttachment(att, generator, placeholder, where, ctx);
|
|
2583
2631
|
const controls = generator.kind === 'ring' ? generator.controls : generator.chain;
|
|
2584
2632
|
const refFor = (name: string): MeshBoneRef => {
|
|
2585
2633
|
const index = ctx.bones.findIndex((b) => b.name === name);
|
|
@@ -2638,6 +2686,433 @@ function buildGeneratedMesh(
|
|
|
2638
2686
|
return out;
|
|
2639
2687
|
}
|
|
2640
2688
|
|
|
2689
|
+
/**
|
|
2690
|
+
* Read a depth map, check it against the part it claims to describe, and put a
|
|
2691
|
+
* `z` on every vertex.
|
|
2692
|
+
*
|
|
2693
|
+
* ## The three refusals, and why each one is a refusal
|
|
2694
|
+
*
|
|
2695
|
+
* **A size that is not the part's.** The map is sampled in part-local pixels,
|
|
2696
|
+
* so a sheet of a different size is a different coordinate system; sampling it
|
|
2697
|
+
* anyway would read the right shape from the wrong place and produce a turn
|
|
2698
|
+
* that is subtly, plausibly wrong everywhere.
|
|
2699
|
+
*
|
|
2700
|
+
* **A colour image.** A depth map is one channel. Silently taking red from an
|
|
2701
|
+
* RGB file works right up until somebody hands over a normal map or a tinted
|
|
2702
|
+
* preview, and then the part turns by whatever the red channel happened to say.
|
|
2703
|
+
*
|
|
2704
|
+
* **A vertex the map does not cover.** This is the one that is easy to miss and
|
|
2705
|
+
* ruins the result. A `contour` mesh's vertices are ALL on the silhouette, and
|
|
2706
|
+
* the outline is pushed `margin` pixels OUTSIDE it — while a depth sheet is
|
|
2707
|
+
* usually cut to the art's own alpha. Sample naively and every vertex reads the
|
|
2708
|
+
* sheet's transparent background, the whole rim takes the background depth, and
|
|
2709
|
+
* the silhouette folds away from the turn. Nothing about that is detectable
|
|
2710
|
+
* downstream: the arithmetic is correct and the numbers are plausible. So a
|
|
2711
|
+
* sheet that carries an alpha channel is held to it, and the fix — dilate the
|
|
2712
|
+
* sheet past the mesh margin — is named in the message.
|
|
2713
|
+
*
|
|
2714
|
+
* A sheet with no alpha channel covers its whole grid by construction and the
|
|
2715
|
+
* third check has nothing to test; what the background level means is then the
|
|
2716
|
+
* author's statement, and `range` in the report is where it shows up.
|
|
2717
|
+
*/
|
|
2718
|
+
function sampleMeshDepth(
|
|
2719
|
+
spec: NonNullable<Extract<NonNullable<RigMeshAttachment['generator']>, { kind: 'contour' }>['depth']>,
|
|
2720
|
+
kind: 'contour' | 'grid',
|
|
2721
|
+
points: ReadonlyArray<readonly [number, number]>,
|
|
2722
|
+
/** The mesh's own triangle list, for the turn ceiling. */
|
|
2723
|
+
triangles: ReadonlyArray<number>,
|
|
2724
|
+
/**
|
|
2725
|
+
* A part-local pixel to the BIND space this mesh's vertices are emitted in —
|
|
2726
|
+
* the same composition `encodeWeightedVertices` applies, handed in rather than
|
|
2727
|
+
* rebuilt so the two cannot diverge.
|
|
2728
|
+
*
|
|
2729
|
+
* 🚨 The ceiling has to be taken in bind space and in no other. A deform
|
|
2730
|
+
* offset is authored there, so a rotated slot bone MIXES the two axes: a
|
|
2731
|
+
* ceiling measured in part-aligned pixels would report the yaw limit of a
|
|
2732
|
+
* turn nobody is taking. It is also scale-sensitive — a ratio of an area to
|
|
2733
|
+
* an area with a depth substituted into it — so part pixels have to reach the
|
|
2734
|
+
* units `z` is already in before it is taken.
|
|
2735
|
+
*/
|
|
2736
|
+
toBind: (px: number, py: number) => readonly [number, number],
|
|
2737
|
+
partWidth: number,
|
|
2738
|
+
partHeight: number,
|
|
2739
|
+
where: string,
|
|
2740
|
+
ctx: AttachmentContext,
|
|
2741
|
+
): {
|
|
2742
|
+
z: number[];
|
|
2743
|
+
/** The tone-curved 0..1 nearness per vertex, which `bind` thresholds. */
|
|
2744
|
+
nearness: number[];
|
|
2745
|
+
summary: NonNullable<CompileResult['meshes'][number]['depth']>;
|
|
2746
|
+
} {
|
|
2747
|
+
if (typeof spec.image !== 'string' || spec.image.length === 0) {
|
|
2748
|
+
throw new CompileError(`${where}: the depth block has no "image"; it is the sheet to sample, relative to the rig's images directory`);
|
|
2749
|
+
}
|
|
2750
|
+
if (spec.near !== 'white' && spec.near !== 'black') {
|
|
2751
|
+
throw new CompileError(
|
|
2752
|
+
`${where}: the depth block says "near": ${JSON.stringify(spec.near)}; it is "white" or "black". Both ` +
|
|
2753
|
+
'conventions are in use, and a sheet read with the wrong one turns the part inside out with every gate green.',
|
|
2754
|
+
);
|
|
2755
|
+
}
|
|
2756
|
+
const tone: DepthTone = {
|
|
2757
|
+
gamma: spec.gamma ?? DEPTH_TONE_IDENTITY.gamma,
|
|
2758
|
+
contrast: spec.contrast ?? DEPTH_TONE_IDENTITY.contrast,
|
|
2759
|
+
bias: spec.bias ?? DEPTH_TONE_IDENTITY.bias,
|
|
2760
|
+
};
|
|
2761
|
+
try {
|
|
2762
|
+
checkTone(tone, where);
|
|
2763
|
+
checkZScale(spec.zScale, where);
|
|
2764
|
+
} catch (err) {
|
|
2765
|
+
if (err instanceof DepthError) throw new CompileError(err.message);
|
|
2766
|
+
throw err;
|
|
2767
|
+
}
|
|
2768
|
+
|
|
2769
|
+
const { map, cover, opaqueEverywhere } = readGreySheet(spec.image, 'depth', partWidth, partHeight, where, ctx);
|
|
2770
|
+
|
|
2771
|
+
// Every texel a bilinear tap touches has to be covered, not just the nearest
|
|
2772
|
+
// one: a vertex half a pixel outside the sheet blends real depth with the
|
|
2773
|
+
// background and lands somewhere neither states.
|
|
2774
|
+
const uncovered: number[] = [];
|
|
2775
|
+
if (!opaqueEverywhere) {
|
|
2776
|
+
const cx = (i: number): number => (i < 0 ? 0 : i > partWidth - 1 ? partWidth - 1 : i);
|
|
2777
|
+
const cy = (j: number): number => (j < 0 ? 0 : j > partHeight - 1 ? partHeight - 1 : j);
|
|
2778
|
+
for (let v = 0; v < points.length; v++) {
|
|
2779
|
+
const x0 = Math.floor(points[v][0] - 0.5);
|
|
2780
|
+
const y0 = Math.floor(points[v][1] - 0.5);
|
|
2781
|
+
let covered = true;
|
|
2782
|
+
for (const [dx, dy] of [[0, 0], [1, 0], [0, 1], [1, 1]] as const) {
|
|
2783
|
+
if (cover[cy(y0 + dy) * partWidth + cx(x0 + dx)] !== 255) covered = false;
|
|
2784
|
+
}
|
|
2785
|
+
if (!covered) uncovered.push(v);
|
|
2786
|
+
}
|
|
2787
|
+
}
|
|
2788
|
+
if (uncovered.length > 0) {
|
|
2789
|
+
const first = uncovered[0];
|
|
2790
|
+
// The two topologies run out of sheet for different reasons, and a message
|
|
2791
|
+
// that explains the wrong one sends the author to the wrong fix.
|
|
2792
|
+
const why =
|
|
2793
|
+
kind === 'contour'
|
|
2794
|
+
? 'A contour mesh puts every vertex ON the silhouette and pushes it out by the margin, so a sheet cut to ' +
|
|
2795
|
+
'the art stops short of every one of them. Dilate the sheet past the mesh margin, or lower the margin.'
|
|
2796
|
+
: 'A grid spans the whole part window, corners included, while a sheet is usually cut to the art — so the ' +
|
|
2797
|
+
'lattice reaches past it wherever the art does not fill the window. Dilate the sheet to the window, or ' +
|
|
2798
|
+
'state "us"/"vs" that keep the lattice inside the art.';
|
|
2799
|
+
throw new CompileError(
|
|
2800
|
+
`${where}: the depth map "${spec.image}" does not cover ${uncovered.length} of the mesh's ${points.length} ` +
|
|
2801
|
+
`vertices — the first is vertex ${first} at (${points[first][0]}, ${points[first][1]}). ${why} ` +
|
|
2802
|
+
'Sampling anyway would give those vertices the sheet\'s background depth and fold them away from the turn.',
|
|
2803
|
+
);
|
|
2804
|
+
}
|
|
2805
|
+
|
|
2806
|
+
const z: number[] = [];
|
|
2807
|
+
const nearness: number[] = [];
|
|
2808
|
+
let lo = Infinity;
|
|
2809
|
+
let hi = -Infinity;
|
|
2810
|
+
for (const [x, y] of points) {
|
|
2811
|
+
const n = toneLevel(sampleLevel(map, x, y), spec.near as DepthNear, tone);
|
|
2812
|
+
const d = n * spec.zScale;
|
|
2813
|
+
nearness.push(n);
|
|
2814
|
+
z.push(d);
|
|
2815
|
+
if (d < lo) lo = d;
|
|
2816
|
+
if (d > hi) hi = d;
|
|
2817
|
+
}
|
|
2818
|
+
return {
|
|
2819
|
+
z,
|
|
2820
|
+
nearness,
|
|
2821
|
+
summary: {
|
|
2822
|
+
image: spec.image,
|
|
2823
|
+
digest: depthDigest(map),
|
|
2824
|
+
near: spec.near,
|
|
2825
|
+
zScale: spec.zScale,
|
|
2826
|
+
tone,
|
|
2827
|
+
range: [r6(lo), r6(hi)],
|
|
2828
|
+
ceiling: turnCeiling(
|
|
2829
|
+
points.map(([px, py]) => toBind(px, py)),
|
|
2830
|
+
z,
|
|
2831
|
+
triangles,
|
|
2832
|
+
),
|
|
2833
|
+
},
|
|
2834
|
+
};
|
|
2835
|
+
}
|
|
2836
|
+
|
|
2837
|
+
/** One bone of the rig as `encodeWeightedVertices` wants it. */
|
|
2838
|
+
function meshBoneRef(name: string, where: string, ctx: AttachmentContext): MeshBoneRef {
|
|
2839
|
+
const transform = ctx.transforms.get(name);
|
|
2840
|
+
if (!transform) throw new CompileError(`${where}: bone "${name}" has no setup transform`);
|
|
2841
|
+
const index = ctx.bones.findIndex((b) => b.name === name);
|
|
2842
|
+
if (index < 0) throw new CompileError(`${where}: bone "${name}" is not in the rig's bone list`);
|
|
2843
|
+
return { index, toBind: (wx, wy) => toBoneLocal(transform, wx, wy) };
|
|
2844
|
+
}
|
|
2845
|
+
|
|
2846
|
+
/**
|
|
2847
|
+
* Read a one-channel sheet in a part's own pixel grid.
|
|
2848
|
+
*
|
|
2849
|
+
* Two callers want the same three refusals — the depth map and the soft-region
|
|
2850
|
+
* mask — and both are a greyscale image that has to line up with the art. What
|
|
2851
|
+
* they do with the levels differs; that a colour file or a mismatched size is a
|
|
2852
|
+
* refusal by name does not.
|
|
2853
|
+
*
|
|
2854
|
+
* `purpose` only spells the messages. It is not a mode: the checks are the same
|
|
2855
|
+
* either way, and a reader has to be told which of their inputs is at fault.
|
|
2856
|
+
*/
|
|
2857
|
+
function readGreySheet(
|
|
2858
|
+
image: string,
|
|
2859
|
+
purpose: 'depth' | 'soft',
|
|
2860
|
+
partWidth: number,
|
|
2861
|
+
partHeight: number,
|
|
2862
|
+
where: string,
|
|
2863
|
+
ctx: AttachmentContext,
|
|
2864
|
+
): { map: DepthMap; cover: Uint8Array; opaqueEverywhere: boolean } {
|
|
2865
|
+
const noun = purpose === 'depth' ? 'depth map' : 'soft mask';
|
|
2866
|
+
if (typeof image !== 'string' || image.length === 0) {
|
|
2867
|
+
throw new CompileError(`${where}: the "${purpose}" block has no image to read`);
|
|
2868
|
+
}
|
|
2869
|
+
const path = join(ctx.imagesDir, image);
|
|
2870
|
+
if (!existsSync(path)) {
|
|
2871
|
+
throw new CompileError(`${where}: the ${noun} "${image}" is not at ${relative(process.cwd(), path)}`);
|
|
2872
|
+
}
|
|
2873
|
+
const sheet = readPlate(path);
|
|
2874
|
+
if (sheet.width !== partWidth || sheet.height !== partHeight) {
|
|
2875
|
+
throw new CompileError(
|
|
2876
|
+
`${where}: the ${noun} "${image}" is ${sheet.width}x${sheet.height} and the part is ` +
|
|
2877
|
+
`${partWidth}x${partHeight}. It is sampled in the part's own pixel grid, so the two are the same size — ` +
|
|
2878
|
+
'resample the sheet, or point at the one that was made for this part.',
|
|
2879
|
+
);
|
|
2880
|
+
}
|
|
2881
|
+
// One channel, proved rather than assumed.
|
|
2882
|
+
const level = new Uint8Array(partWidth * partHeight);
|
|
2883
|
+
const cover = new Uint8Array(partWidth * partHeight);
|
|
2884
|
+
let opaqueEverywhere = true;
|
|
2885
|
+
for (let i = 0; i < level.length; i++) {
|
|
2886
|
+
const r = sheet.data[i * 4];
|
|
2887
|
+
const g = sheet.data[i * 4 + 1];
|
|
2888
|
+
const b = sheet.data[i * 4 + 2];
|
|
2889
|
+
if (r !== g || g !== b) {
|
|
2890
|
+
throw new CompileError(
|
|
2891
|
+
`${where}: the ${noun} "${image}" is not greyscale — pixel (${i % partWidth}, ${Math.floor(i / partWidth)}) ` +
|
|
2892
|
+
`is rgb(${r}, ${g}, ${b}). It is one channel; reading red out of a colour file would use whatever that ` +
|
|
2893
|
+
'channel happened to hold.',
|
|
2894
|
+
);
|
|
2895
|
+
}
|
|
2896
|
+
level[i] = r;
|
|
2897
|
+
const a = sheet.data[i * 4 + 3];
|
|
2898
|
+
cover[i] = a;
|
|
2899
|
+
if (a !== 255) opaqueEverywhere = false;
|
|
2900
|
+
}
|
|
2901
|
+
return { map: { width: partWidth, height: partHeight, level }, cover, opaqueEverywhere };
|
|
2902
|
+
}
|
|
2903
|
+
|
|
2904
|
+
/**
|
|
2905
|
+
* Read a soft-region mask and turn it into mesh weights on a second bone.
|
|
2906
|
+
*
|
|
2907
|
+
* ⭐ The level IS the weight — black still, white carried — sampled at each
|
|
2908
|
+
* vertex with the same bilinear the depth map uses, so a painted falloff
|
|
2909
|
+
* reaches the mesh as a painted falloff. The remainder always stays on the slot
|
|
2910
|
+
* bone, so every vertex closes at 1 by construction rather than by A20 catching
|
|
2911
|
+
* it later.
|
|
2912
|
+
*
|
|
2913
|
+
* 🚨 This was a DEPTH THRESHOLD for one day and that was wrong: softness and
|
|
2914
|
+
* prominence are different properties, the most prominent thing on a face is
|
|
2915
|
+
* the nose, and a nose does not wobble. See `RigSoftRegion` for the whole of
|
|
2916
|
+
* that correction.
|
|
2917
|
+
*/
|
|
2918
|
+
function softRegionWeights(
|
|
2919
|
+
spec: RigSoftRegion,
|
|
2920
|
+
points: ReadonlyArray<readonly [number, number]>,
|
|
2921
|
+
partWidth: number,
|
|
2922
|
+
partHeight: number,
|
|
2923
|
+
where: string,
|
|
2924
|
+
ctx: AttachmentContext,
|
|
2925
|
+
): { weights: MeshVertexWeight[][]; bone: string; mask: string; digest: string; carried: number; ramped: number } {
|
|
2926
|
+
const { bone } = spec;
|
|
2927
|
+
if (typeof bone !== 'string' || bone.length === 0) {
|
|
2928
|
+
throw new CompileError(`${where}: the "soft" block has no "bone"; it is the bone the soft region is carried by`);
|
|
2929
|
+
}
|
|
2930
|
+
if (!ctx.bones.some((b) => b.name === bone)) {
|
|
2931
|
+
throw new CompileError(
|
|
2932
|
+
`${where}: "soft" names bone "${bone}", which this rig does not declare. rigc binds a region to a bone that ` +
|
|
2933
|
+
'already exists rather than creating one — a bone a physics constraint has to target is part of the ' +
|
|
2934
|
+
'skeleton, not a side effect of a mesh.',
|
|
2935
|
+
);
|
|
2936
|
+
}
|
|
2937
|
+
if (bone === ctx.anchorBone) {
|
|
2938
|
+
throw new CompileError(
|
|
2939
|
+
`${where}: "soft" names "${bone}", which is this slot's own bone. Carrying the region with the bone the rest ` +
|
|
2940
|
+
'of the mesh is already pinned to moves nothing — a soft region needs a bone that can move independently, ' +
|
|
2941
|
+
'which is what a physics constraint is put on.',
|
|
2942
|
+
);
|
|
2943
|
+
}
|
|
2944
|
+
const { map: sheet } = readGreySheet(spec.mask, 'soft', partWidth, partHeight, where, ctx);
|
|
2945
|
+
let carried = 0;
|
|
2946
|
+
let ramped = 0;
|
|
2947
|
+
const weights = points.map(([x, y]) => {
|
|
2948
|
+
const w = sampleLevel(sheet, x, y) / 255;
|
|
2949
|
+
if (w >= 1) carried++;
|
|
2950
|
+
else if (w > 0) ramped++;
|
|
2951
|
+
if (w <= 0) return [{ bone: 'anchor', weight: 1 }] as MeshVertexWeight[];
|
|
2952
|
+
if (w >= 1) return [{ bone: 'control', control: 0, weight: 1 }] as MeshVertexWeight[];
|
|
2953
|
+
return [
|
|
2954
|
+
{ bone: 'anchor', weight: 1 - w },
|
|
2955
|
+
{ bone: 'control', control: 0, weight: w },
|
|
2956
|
+
] as MeshVertexWeight[];
|
|
2957
|
+
});
|
|
2958
|
+
if (carried === 0 && ramped === 0) {
|
|
2959
|
+
throw new CompileError(
|
|
2960
|
+
`${where}: the soft mask "${spec.mask}" carries no vertex of this mesh — every one of its ${points.length} ` +
|
|
2961
|
+
'vertices samples black. A region that moves nothing is a physics constraint with nothing on the end of it; ' +
|
|
2962
|
+
'check that the mask is painted where the mesh actually is, and that it is white where the art is soft.',
|
|
2963
|
+
);
|
|
2964
|
+
}
|
|
2965
|
+
return { weights, bone, mask: spec.mask, digest: depthDigest(sheet), carried, ramped };
|
|
2966
|
+
}
|
|
2967
|
+
|
|
2968
|
+
/**
|
|
2969
|
+
* Build a `grid` mesh: a lattice over the part window, at stated positions.
|
|
2970
|
+
*
|
|
2971
|
+
* It shares `contour`'s placement and weighting exactly — the window is the
|
|
2972
|
+
* PNG's own size, the mesh is centred on its slot bone, and every vertex is
|
|
2973
|
+
* pinned to that bone at weight 1 — and differs only in where the vertices go.
|
|
2974
|
+
* The two are kept as separate functions rather than one with a branch because
|
|
2975
|
+
* what they measure is different: a contour asks the art where its edge is and
|
|
2976
|
+
* reports how well it covered it, and a grid asks nothing of the art at all.
|
|
2977
|
+
*
|
|
2978
|
+
* ⭐ Why this exists at all: the lattice was being written BY HAND. The worked
|
|
2979
|
+
* portrait shipped 25 vertex pairs, 32 triangles and a perimeter numbered in
|
|
2980
|
+
* the one order the loader accepts — all of it a person's arithmetic, on the
|
|
2981
|
+
* one topology whose correctness is least visible. A hand-numbered hull that
|
|
2982
|
+
* lists an interior vertex first loads, draws and deforms wrong.
|
|
2983
|
+
*/
|
|
2984
|
+
function buildGridAttachment(
|
|
2985
|
+
att: RigMeshAttachment,
|
|
2986
|
+
generator: Extract<NonNullable<RigMeshAttachment['generator']>, { kind: 'grid' }>,
|
|
2987
|
+
placeholder: string,
|
|
2988
|
+
where: string,
|
|
2989
|
+
ctx: AttachmentContext,
|
|
2990
|
+
): SpineMeshAttachment {
|
|
2991
|
+
if (att.image === undefined) {
|
|
2992
|
+
throw new CompileError(
|
|
2993
|
+
`${where}: a "grid" generator lays its lattice over the part's own window, so the attachment needs an ` +
|
|
2994
|
+
'"image" — there is nothing else here that says how big that window is',
|
|
2995
|
+
);
|
|
2996
|
+
}
|
|
2997
|
+
const stated = generator.us !== undefined || generator.vs !== undefined;
|
|
2998
|
+
const even = generator.cols !== undefined || generator.rows !== undefined;
|
|
2999
|
+
if (stated && even) {
|
|
3000
|
+
throw new CompileError(
|
|
3001
|
+
`${where}: the "grid" generator states both positions ("us"/"vs") and a count ("cols"/"rows"). They are two ` +
|
|
3002
|
+
'answers to where the columns go, and a spec carrying both leaves a reader unable to say which one built ' +
|
|
3003
|
+
'the mesh. Drop one — positions are the more precise of the two.',
|
|
3004
|
+
);
|
|
3005
|
+
}
|
|
3006
|
+
if (!stated && !even) {
|
|
3007
|
+
throw new CompileError(
|
|
3008
|
+
`${where}: the "grid" generator says neither where its columns go ("us"/"vs", fractions of the window) nor ` +
|
|
3009
|
+
'how many there are ("cols"/"rows"). A lattice is not a default.',
|
|
3010
|
+
);
|
|
3011
|
+
}
|
|
3012
|
+
const spread = (n: unknown, name: string): number[] => {
|
|
3013
|
+
if (typeof n !== 'number' || !Number.isInteger(n) || n < 2) {
|
|
3014
|
+
throw new CompileError(`${where}: "${name}" is ${JSON.stringify(n)}; it is a whole number of at least 2`);
|
|
3015
|
+
}
|
|
3016
|
+
// An even division spans the whole window. A lattice that should sit inside
|
|
3017
|
+
// it states its own positions — which is what `us`/`vs` are for.
|
|
3018
|
+
return Array.from({ length: n }, (_, i) => i / (n - 1));
|
|
3019
|
+
};
|
|
3020
|
+
const us = stated ? generator.us : spread(generator.cols, 'cols');
|
|
3021
|
+
const vs = stated ? generator.vs : spread(generator.rows, 'rows');
|
|
3022
|
+
if (us === undefined || vs === undefined) {
|
|
3023
|
+
throw new CompileError(
|
|
3024
|
+
`${where}: the "grid" generator states ${us === undefined ? '"vs"' : '"us"'} and not ` +
|
|
3025
|
+
`${us === undefined ? '"us"' : '"vs"'}; a lattice needs both axes`,
|
|
3026
|
+
);
|
|
3027
|
+
}
|
|
3028
|
+
|
|
3029
|
+
const region = basename(att.image, '.png');
|
|
3030
|
+
const img = ctx.images.find((im) => im.region === region);
|
|
3031
|
+
if (!img) throw new CompileError(`${where}: no compiled image for "${att.image}"`);
|
|
3032
|
+
const plate = partPlate(img);
|
|
3033
|
+
|
|
3034
|
+
let geometry;
|
|
3035
|
+
try {
|
|
3036
|
+
geometry = buildGridMesh({ size: [plate.width, plate.height], us, vs });
|
|
3037
|
+
} catch (err) {
|
|
3038
|
+
if (err instanceof MeshError) throw new CompileError(`${where}: ${err.message}`);
|
|
3039
|
+
throw err;
|
|
3040
|
+
}
|
|
3041
|
+
|
|
3042
|
+
const w = img.width;
|
|
3043
|
+
const h = img.height;
|
|
3044
|
+
const toArt = w / plate.width;
|
|
3045
|
+
if (att.width !== undefined && att.width !== w) {
|
|
3046
|
+
throw new CompileError(`${where}: the spec says width ${att.width} and "${att.image}" measures ${w}`);
|
|
3047
|
+
}
|
|
3048
|
+
if (att.height !== undefined && att.height !== h) {
|
|
3049
|
+
throw new CompileError(`${where}: the spec says height ${att.height} and "${att.image}" measures ${h}`);
|
|
3050
|
+
}
|
|
3051
|
+
const anchor = ctx.transforms.get(ctx.anchorBone);
|
|
3052
|
+
if (!anchor) throw new CompileError(`${where}: slot bone "${ctx.anchorBone}" has no setup transform`);
|
|
3053
|
+
const index = ctx.bones.findIndex((b) => b.name === ctx.anchorBone);
|
|
3054
|
+
if (index < 0) throw new CompileError(`${where}: slot bone "${ctx.anchorBone}" is not in the rig's bone list`);
|
|
3055
|
+
// Sampled BEFORE the encode, because `bind` rewrites the weights the encode
|
|
3056
|
+
// then writes out.
|
|
3057
|
+
const depth =
|
|
3058
|
+
generator.depth === undefined
|
|
3059
|
+
? undefined
|
|
3060
|
+
:
|
|
3061
|
+
sampleMeshDepth(
|
|
3062
|
+
generator.depth,
|
|
3063
|
+
'grid',
|
|
3064
|
+
geometry.points,
|
|
3065
|
+
geometry.triangles,
|
|
3066
|
+
(px, py) => toBoneLocal(anchor, anchor.worldX + px * toArt - w / 2, anchor.worldY + h / 2 - py * toArt),
|
|
3067
|
+
plate.width,
|
|
3068
|
+
plate.height,
|
|
3069
|
+
where,
|
|
3070
|
+
ctx,
|
|
3071
|
+
);
|
|
3072
|
+
const bound =
|
|
3073
|
+
generator.soft === undefined
|
|
3074
|
+
? undefined
|
|
3075
|
+
: softRegionWeights(generator.soft, geometry.points, plate.width, plate.height, where, ctx);
|
|
3076
|
+
if (bound) geometry = { ...geometry, weights: bound.weights };
|
|
3077
|
+
const vertices = encodeWeightedVertices(
|
|
3078
|
+
geometry,
|
|
3079
|
+
(px, py) => [r6(anchor.worldX + px * toArt - w / 2), r6(anchor.worldY + h / 2 - py * toArt)],
|
|
3080
|
+
{
|
|
3081
|
+
anchor: { index, toBind: (wx, wy) => toBoneLocal(anchor, wx, wy) },
|
|
3082
|
+
controls: bound ? [meshBoneRef(bound.bone, where, ctx)] : [],
|
|
3083
|
+
},
|
|
3084
|
+
);
|
|
3085
|
+
ctx.meshBones.add(ctx.anchorBone);
|
|
3086
|
+
if (bound) ctx.meshBones.add(bound.bone);
|
|
3087
|
+
if (depth !== undefined) ctx.depths.set(`${ctx.skinName}/${ctx.slotName}/${placeholder}`, depth.z);
|
|
3088
|
+
ctx.meshes.push({
|
|
3089
|
+
slot: ctx.slotName,
|
|
3090
|
+
kind: 'grid',
|
|
3091
|
+
attachments: [placeholder],
|
|
3092
|
+
vertices: geometry.uvs.length / 2,
|
|
3093
|
+
triangles: geometry.triangles.length / 3,
|
|
3094
|
+
bones: bound ? [ctx.anchorBone, bound.bone] : [ctx.anchorBone],
|
|
3095
|
+
depth: depth?.summary,
|
|
3096
|
+
soft:
|
|
3097
|
+
bound === undefined
|
|
3098
|
+
? undefined
|
|
3099
|
+
: { mask: bound.mask, digest: bound.digest, bone: bound.bone, carried: bound.carried, ramped: bound.ramped },
|
|
3100
|
+
});
|
|
3101
|
+
const out: SpineMeshAttachment = {
|
|
3102
|
+
type: 'mesh',
|
|
3103
|
+
uvs: geometry.uvs,
|
|
3104
|
+
triangles: geometry.triangles,
|
|
3105
|
+
vertices,
|
|
3106
|
+
...generatedHullAndEdges(geometry, where),
|
|
3107
|
+
width: r6(w),
|
|
3108
|
+
height: r6(h),
|
|
3109
|
+
};
|
|
3110
|
+
if (att.path !== undefined) out.path = att.path;
|
|
3111
|
+
else if (region !== placeholder) out.path = region;
|
|
3112
|
+
if (att.color !== undefined) out.color = att.color;
|
|
3113
|
+
return out;
|
|
3114
|
+
}
|
|
3115
|
+
|
|
2641
3116
|
/** Defaults for the `contour` generator's optional parameters, stated once. */
|
|
2642
3117
|
const CONTOUR_DEFAULTS = { margin: 1, maxVertices: 64, alpha: 1 } as const;
|
|
2643
3118
|
|
|
@@ -2731,6 +3206,25 @@ function buildContourAttachment(
|
|
|
2731
3206
|
{ anchor: { index, toBind: (wx, wy) => toBoneLocal(anchor, wx, wy) }, controls: [] },
|
|
2732
3207
|
);
|
|
2733
3208
|
ctx.meshBones.add(ctx.anchorBone);
|
|
3209
|
+
// The depth map is sampled on the TRACED grid — the plate's — because that is
|
|
3210
|
+
// the grid the vertices are in. `toArt` scales positions into world units
|
|
3211
|
+
// afterwards and does not touch `z`, which carries its own stated scale.
|
|
3212
|
+
const depth =
|
|
3213
|
+
generator.depth === undefined
|
|
3214
|
+
? undefined
|
|
3215
|
+
:
|
|
3216
|
+
sampleMeshDepth(
|
|
3217
|
+
generator.depth,
|
|
3218
|
+
'contour',
|
|
3219
|
+
geometry.points,
|
|
3220
|
+
geometry.triangles,
|
|
3221
|
+
(px, py) => toBoneLocal(anchor, anchor.worldX + px * toArt - w / 2, anchor.worldY + h / 2 - py * toArt),
|
|
3222
|
+
plate.width,
|
|
3223
|
+
plate.height,
|
|
3224
|
+
where,
|
|
3225
|
+
ctx,
|
|
3226
|
+
);
|
|
3227
|
+
if (depth !== undefined) ctx.depths.set(`${ctx.skinName}/${ctx.slotName}/${placeholder}`, depth.z);
|
|
2734
3228
|
ctx.meshes.push({
|
|
2735
3229
|
slot: ctx.slotName,
|
|
2736
3230
|
kind: 'contour',
|
|
@@ -2741,6 +3235,7 @@ function buildContourAttachment(
|
|
|
2741
3235
|
coverage: geometry.contour?.coverage,
|
|
2742
3236
|
overshoot: geometry.contour?.overshoot,
|
|
2743
3237
|
holePixels: geometry.contour?.holePixels,
|
|
3238
|
+
depth: depth?.summary,
|
|
2744
3239
|
});
|
|
2745
3240
|
const out: SpineMeshAttachment = {
|
|
2746
3241
|
type: 'mesh',
|
|
@@ -2778,6 +3273,12 @@ interface ConstraintContext {
|
|
|
2778
3273
|
pathSlots: Map<string, string[]>;
|
|
2779
3274
|
/** Animations the motion spec declares — a slider names one of them. */
|
|
2780
3275
|
animationNames: Set<string>;
|
|
3276
|
+
/**
|
|
3277
|
+
* The duration each of those declares, which is what turns a slider's
|
|
3278
|
+
* `from`/`to`/`scale` into the range of driving values that can reach a frame.
|
|
3279
|
+
* Only the slider's 360° refusal reads it.
|
|
3280
|
+
*/
|
|
3281
|
+
animationDurations: Map<string, number>;
|
|
2781
3282
|
}
|
|
2782
3283
|
|
|
2783
3284
|
/**
|
|
@@ -2960,6 +3461,57 @@ function buildRigConstraint(spec: RigConstraintInput, ctx: ConstraintContext): S
|
|
|
2960
3461
|
// time = to + (value - from) * 0, so the slider holds one frame forever.
|
|
2961
3462
|
throw new CompileError(`${where}: scale is 0, so the bone's property cannot move the slider's time at all`);
|
|
2962
3463
|
}
|
|
3464
|
+
// ⚠️ `local: false` reads the bone's WORLD rotation, and `FromRotate.value`
|
|
3465
|
+
// (`TransformConstraintData.js`) ends
|
|
3466
|
+
//
|
|
3467
|
+
// let value = Math.atan2(source.c / sy, source.a / sx) * radDeg + …;
|
|
3468
|
+
// if (value < 0) value += 360;
|
|
3469
|
+
//
|
|
3470
|
+
// so a driving value below 0° is one the runtime never produces: it arrives
|
|
3471
|
+
// as `value + 360` instead, and `time = to + (value - from) * scale` then
|
|
3472
|
+
// lands far outside the animation. A yaw axis with its neutral at 0° — the
|
|
3473
|
+
// natural way to author a face — therefore has its entire negative half
|
|
3474
|
+
// pinned to one frame, and nothing anywhere says so (issue #402).
|
|
3475
|
+
//
|
|
3476
|
+
// ⭐ Refused here rather than documented, and rather than left to the gate,
|
|
3477
|
+
// for three reasons. The failure is invisible AND total, so a note in a
|
|
3478
|
+
// guide is read after the loss rather than before it. The fix is one field
|
|
3479
|
+
// in the rig spec, which is the file this message can name — an artifact
|
|
3480
|
+
// -side assertion would have to name the emitted `local`/`offset` fields the
|
|
3481
|
+
// author never typed. And the arithmetic that proves it needs the
|
|
3482
|
+
// animation's declared DURATION, which is a motion-spec fact the compiler
|
|
3483
|
+
// has in front of it: the range of driving values that can reach a frame is
|
|
3484
|
+
// exactly `[to, to + duration]` mapped back through `scale`.
|
|
3485
|
+
//
|
|
3486
|
+
// The refusal is deliberately narrow: `rotate` only, because `FromRotate` is
|
|
3487
|
+
// the one property whose reader wraps, and only when the range actually
|
|
3488
|
+
// crosses (or sits below) 0°, because a dial authored at 340°..20° is how
|
|
3489
|
+
// you write this axis under `local: false` and it works.
|
|
3490
|
+
if (property === 'rotate' && spec.local !== true) {
|
|
3491
|
+
const fromValue = spec.from === undefined ? 0 : needNumber(spec.from, 'from');
|
|
3492
|
+
const toTime = spec.to === undefined ? 0 : needNumber(spec.to, 'to');
|
|
3493
|
+
const perUnit = spec.scale === undefined ? 1 : needNumber(spec.scale, 'scale');
|
|
3494
|
+
const duration = ctx.animationDurations.get(animation) ?? 0;
|
|
3495
|
+
/** The driving value that maps to the animation's first frame, and to its last. */
|
|
3496
|
+
const atStart = fromValue - toTime / perUnit;
|
|
3497
|
+
const atEnd = fromValue + (duration - toTime) / perUnit;
|
|
3498
|
+
const lowest = Math.min(atStart, atEnd);
|
|
3499
|
+
const highest = Math.max(atStart, atEnd);
|
|
3500
|
+
if (lowest < -1e-6) {
|
|
3501
|
+
const readAs = lowest + 360;
|
|
3502
|
+
const lands = toTime + (readAs - fromValue) * perUnit;
|
|
3503
|
+
throw new CompileError(
|
|
3504
|
+
`${where}: drives off bone "${String(spec.bone)}" rotate with "local": false, and the driving values ` +
|
|
3505
|
+
`that reach animation "${animation}" (0s..${duration}s) run from ${lowest.toFixed(3)}° to ${highest.toFixed(3)}°. ` +
|
|
3506
|
+
'A world rotation is read through `FromRotate.value`, which ends `if (value < 0) value += 360`, so the bone ' +
|
|
3507
|
+
`at ${lowest.toFixed(3)}° is read as ${readAs.toFixed(3)}° and maps to time ${lands.toFixed(3)}s — outside ` +
|
|
3508
|
+
`the animation's ${duration}s. With "loop": false that is \`Math.max(0, time)\` holding the last frame; with ` +
|
|
3509
|
+
'"loop": true it wraps to some other frame. Either way the whole part of the range below 0° is dead and ' +
|
|
3510
|
+
'nothing at runtime reports it. Add `"local": true` to read the bone\'s own rotation signed and unwrapped — ' +
|
|
3511
|
+
'that is the form a face axis wants — or move the range so it does not cross 0°.',
|
|
3512
|
+
);
|
|
3513
|
+
}
|
|
3514
|
+
}
|
|
2963
3515
|
for (const field of timeSide) {
|
|
2964
3516
|
if (spec[field] !== undefined) {
|
|
2965
3517
|
throw new CompileError(
|
|
@@ -3054,6 +3606,8 @@ function buildRigInfo(
|
|
|
3054
3606
|
}
|
|
3055
3607
|
const meshKinds: RigInfo['meshKinds'] = {};
|
|
3056
3608
|
for (const mesh of meshes) meshKinds[mesh.slot] = mesh.kind;
|
|
3609
|
+
const meshSoftBones: RigInfo['meshSoftBones'] = {};
|
|
3610
|
+
for (const mesh of meshes) if (mesh.soft !== undefined) meshSoftBones[mesh.slot] = mesh.soft.bone;
|
|
3057
3611
|
// A fold exemption on a slot that carries no mesh cannot exempt anything —
|
|
3058
3612
|
// A39 reads triangles, and only a mesh has them. `parseRigSpec` already
|
|
3059
3613
|
// refused a name that is not a SLOT; this is the second half, and it needs
|
|
@@ -3090,6 +3644,7 @@ function buildRigInfo(
|
|
|
3090
3644
|
detached: (rig.invariants?.detached ?? []).map((d) => [d.bone, d.notUnder] as [string, string]),
|
|
3091
3645
|
slotOrder: rig.slots.length ? rig.slots.map((s) => s.name) : null,
|
|
3092
3646
|
meshKinds,
|
|
3647
|
+
meshSoftBones,
|
|
3093
3648
|
deformMayFold,
|
|
3094
3649
|
meshSlotBudget: rig.invariants?.meshSlots ?? null,
|
|
3095
3650
|
meshTriangleBudget: rig.invariants?.meshTriangles ?? null,
|
|
@@ -3744,19 +4299,51 @@ interface DeformGeometry {
|
|
|
3744
4299
|
/** Bone influences per vertex, in vertex order. Null on an unweighted attachment. */
|
|
3745
4300
|
boneCounts: number[] | null;
|
|
3746
4301
|
/**
|
|
3747
|
-
* Setup `x, y` per vertex
|
|
3748
|
-
*
|
|
4302
|
+
* Setup `x, y` per vertex, for a `transform` key to evaluate a model over
|
|
4303
|
+
* (issue #294).
|
|
4304
|
+
*
|
|
4305
|
+
* ⚠️ **Two spaces, and `influenceBones` is which.** Where every vertex of
|
|
4306
|
+
* the attachment occupies exactly one pair of one bone's bind space — an
|
|
4307
|
+
* unweighted run, or a weighted one whose every vertex is a single influence
|
|
4308
|
+
* of a single bone — this is that space, the model's output IS the deform
|
|
4309
|
+
* array, and `influenceBones` is null. Where it is not, this is the
|
|
4310
|
+
* vertex's setup **world** position, `Σ wᵢ · Mᵢ · bindᵢ`, which is defined for
|
|
4311
|
+
* any weighting; the model's output is then a world displacement that the
|
|
4312
|
+
* caller pushes into each influence through `influenceBones`.
|
|
4313
|
+
*
|
|
4314
|
+
* That seam is deliberate and it is the reason the field is documented rather
|
|
4315
|
+
* than named: keeping the first case in its own space is what makes the
|
|
4316
|
+
* change to the second byte-for-byte invisible to every rig that already
|
|
4317
|
+
* compiles (issue #389's own gate). It does mean a `radius` or an `about` is
|
|
4318
|
+
* read in world units the moment a second bone touches any vertex, so the
|
|
4319
|
+
* report says which space it evaluated in whenever it is not the first one.
|
|
3749
4320
|
*
|
|
3750
|
-
* Null
|
|
3751
|
-
*
|
|
3752
|
-
* is a weighted sum of a pair in each bone's own bind space; or a run bound to
|
|
3753
|
-
* several bones, where one closed form would be evaluated across several
|
|
3754
|
-
* unrelated coordinate systems. Both are the `fromVertex` refusal's reasoning
|
|
3755
|
-
* applied to a whole run — rigc will not guess a space.
|
|
4321
|
+
* Null only in the one case that has no identity behind it: weights that do
|
|
4322
|
+
* not close at 1. `setupWhy` then says so.
|
|
3756
4323
|
*/
|
|
3757
4324
|
setup: number[] | null;
|
|
4325
|
+
/**
|
|
4326
|
+
* The setup world transform of the bone behind each influence, in the deform
|
|
4327
|
+
* array's **own order** — one entry per pair — or null when `setup` is
|
|
4328
|
+
* already the space the array is in.
|
|
4329
|
+
*
|
|
4330
|
+
* Non-null is the multi-influence path: a vertex's world displacement `D`
|
|
4331
|
+
* becomes `Mᵢ⁻¹ · D` in every one of its influences (`toBoneLocalVector`), so
|
|
4332
|
+
* the runtime's `Σ wᵢ · Mᵢ · Mᵢ⁻¹ · D` collapses to `D · Σ wᵢ = D` at setup,
|
|
4333
|
+
* and to the same blend that carries the vertex once a bone moves.
|
|
4334
|
+
*/
|
|
4335
|
+
influenceBones: BoneTransform[] | null;
|
|
3758
4336
|
/** Why `setup` is null, phrased for the refusal. Null when it is not. */
|
|
3759
4337
|
setupWhy: string | null;
|
|
4338
|
+
/**
|
|
4339
|
+
* Per-vertex `z`, when this attachment's generator named a depth map.
|
|
4340
|
+
*
|
|
4341
|
+
* Not measured from the emitted attachment like everything else here — there
|
|
4342
|
+
* is nowhere in the format to measure it FROM, which is the whole reason the
|
|
4343
|
+
* sampler keeps it beside the skeleton instead of in it. Filled in by the
|
|
4344
|
+
* caller, which is where the skin, slot and attachment names live.
|
|
4345
|
+
*/
|
|
4346
|
+
depth: readonly number[] | null;
|
|
3760
4347
|
}
|
|
3761
4348
|
|
|
3762
4349
|
/**
|
|
@@ -3766,7 +4353,14 @@ interface DeformGeometry {
|
|
|
3766
4353
|
* all, so `attachment.vertices.length` throws inside the parser — one of the very
|
|
3767
4354
|
* few places this format fails loudly, and it fails in the consumer's process.
|
|
3768
4355
|
*/
|
|
3769
|
-
function deformGeometryOf(
|
|
4356
|
+
function deformGeometryOf(
|
|
4357
|
+
att: SpineAttachment,
|
|
4358
|
+
where: string,
|
|
4359
|
+
/** The emitted bone array, which a weight run's `boneIndex` indexes into. */
|
|
4360
|
+
rigBones: SpineBone[],
|
|
4361
|
+
/** Their setup world transforms, by name. */
|
|
4362
|
+
transforms: Map<string, BoneTransform>,
|
|
4363
|
+
): DeformGeometry {
|
|
3770
4364
|
const type = (att as { type?: string }).type ?? 'region';
|
|
3771
4365
|
let worldVerticesLength: number;
|
|
3772
4366
|
if (type === 'mesh') {
|
|
@@ -3800,7 +4394,11 @@ function deformGeometryOf(att: SpineAttachment, where: string): DeformGeometry {
|
|
|
3800
4394
|
// An unweighted attachment IS its own space: the array is one `x, y` per
|
|
3801
4395
|
// vertex in the slot bone's space, which is the space the offsets are in.
|
|
3802
4396
|
setup: vertices.slice(),
|
|
4397
|
+
influenceBones: null,
|
|
3803
4398
|
setupWhy: null,
|
|
4399
|
+
// Filled in by the caller; `deformGeometryOf` reads the emitted
|
|
4400
|
+
// attachment, and the depth is deliberately not in it.
|
|
4401
|
+
depth: null,
|
|
3804
4402
|
};
|
|
3805
4403
|
}
|
|
3806
4404
|
// Walk the weight run for the per-vertex influence counts. The run's own shape
|
|
@@ -3809,6 +4407,8 @@ function deformGeometryOf(att: SpineAttachment, where: string): DeformGeometry {
|
|
|
3809
4407
|
const boneCounts: number[] = [];
|
|
3810
4408
|
const bindSpace: number[] = [];
|
|
3811
4409
|
const bones = new Set<number>();
|
|
4410
|
+
/** `boneIndex, bindX, bindY, weight` per influence, in deform-array order. */
|
|
4411
|
+
const influenceRun: number[] = [];
|
|
3812
4412
|
for (let i = 0; i < vertices.length; ) {
|
|
3813
4413
|
const n = vertices[i++];
|
|
3814
4414
|
if (!Number.isInteger(n) || n < 1) {
|
|
@@ -3818,10 +4418,12 @@ function deformGeometryOf(att: SpineAttachment, where: string): DeformGeometry {
|
|
|
3818
4418
|
bones.add(vertices[i]);
|
|
3819
4419
|
bindSpace.push(vertices[i + 1], vertices[i + 2]);
|
|
3820
4420
|
}
|
|
4421
|
+
const from = i;
|
|
3821
4422
|
i += n * 4;
|
|
3822
4423
|
if (i > vertices.length) {
|
|
3823
4424
|
throw new CompileError(`${where}: the attachment's weighted vertex run is truncated at vertex ${boneCounts.length}`);
|
|
3824
4425
|
}
|
|
4426
|
+
for (let k = from; k < i; k++) influenceRun.push(vertices[k]);
|
|
3825
4427
|
boneCounts.push(n);
|
|
3826
4428
|
}
|
|
3827
4429
|
// ⚠️ The influence count is the SUM of the per-vertex counts, not a division
|
|
@@ -3834,22 +4436,65 @@ function deformGeometryOf(att: SpineAttachment, where: string): DeformGeometry {
|
|
|
3834
4436
|
// LOADED attachment, which is where the `/3*2` in the parser comes from.
|
|
3835
4437
|
let influences = 0;
|
|
3836
4438
|
for (const n of boneCounts) influences += n;
|
|
3837
|
-
const
|
|
3838
|
-
|
|
3839
|
-
|
|
3840
|
-
|
|
3841
|
-
|
|
3842
|
-
|
|
3843
|
-
|
|
3844
|
-
|
|
3845
|
-
|
|
4439
|
+
const common = { weighted, deformLength: influences * 2, vertexCount: boneCounts.length, boneCounts, depth: null };
|
|
4440
|
+
// -- one bind space, unchanged -------------------------------------------
|
|
4441
|
+
//
|
|
4442
|
+
// Every vertex a single influence of a single bone: the run IS that bone's
|
|
4443
|
+
// bind space, a model evaluated over it lands straight in the deform array,
|
|
4444
|
+
// and not one byte of this path moved for issue #389. Keeping it separate is
|
|
4445
|
+
// what makes that provable — the alternative, folding it into the world path
|
|
4446
|
+
// below, would re-evaluate every existing `transform` key at world
|
|
4447
|
+
// coordinates and emit a different file for a spec nobody edited.
|
|
4448
|
+
if (boneCounts.every((n) => n === 1) && bones.size === 1) {
|
|
4449
|
+
return { ...common, setup: bindSpace, influenceBones: null, setupWhy: null };
|
|
4450
|
+
}
|
|
4451
|
+
// -- several influences, evaluated in world (issue #389) -------------------
|
|
4452
|
+
//
|
|
4453
|
+
// The refusal that used to stand here was right about the ARRAY and wrong
|
|
4454
|
+
// about the MODEL. There is no single space the array lives in, true; but the
|
|
4455
|
+
// vertex has one setup world position whatever its weighting —
|
|
4456
|
+
// `Σ wᵢ · Mᵢ · bindᵢ` — and a world displacement `D` written into influence
|
|
4457
|
+
// `i` as `Mᵢ⁻¹ · D` moves it by `D · Σ wᵢ`. So the model is evaluated once, in
|
|
4458
|
+
// world, and pushed into each bind space rather than guessed at in one of
|
|
4459
|
+
// them.
|
|
4460
|
+
//
|
|
4461
|
+
// ⭐ The one case with no identity behind it is a vertex whose weights do not
|
|
4462
|
+
// close at 1, because that is the `Σ wᵢ` the arithmetic cancels against.
|
|
4463
|
+
// `A20_MESH_WEIGHTS_COHERENT` is the assertion that requires it and 1e-3 is
|
|
4464
|
+
// the tolerance it uses, quoted here rather than chosen again.
|
|
4465
|
+
const setupWorld: number[] = [];
|
|
4466
|
+
const influenceBones: BoneTransform[] = [];
|
|
4467
|
+
let openWeights: string | null = null;
|
|
4468
|
+
for (let v = 0, k = 0; v < boneCounts.length; v++) {
|
|
4469
|
+
let wx = 0;
|
|
4470
|
+
let wy = 0;
|
|
4471
|
+
let sum = 0;
|
|
4472
|
+
for (let n = 0; n < boneCounts[v]; n++, k++) {
|
|
4473
|
+
const index = influenceRun[4 * k];
|
|
4474
|
+
const bone = rigBones[index];
|
|
4475
|
+
const m = bone === undefined ? undefined : transforms.get(bone.name);
|
|
4476
|
+
if (!m) throw new CompileError(`${where}: vertex ${v} binds bone index ${index}, which is not in the bone list`);
|
|
4477
|
+
const weight = influenceRun[4 * k + 3];
|
|
4478
|
+
const [x, y] = toWorld(m, influenceRun[4 * k + 1], influenceRun[4 * k + 2]);
|
|
4479
|
+
wx += x * weight;
|
|
4480
|
+
wy += y * weight;
|
|
4481
|
+
sum += weight;
|
|
4482
|
+
influenceBones.push(m);
|
|
4483
|
+
}
|
|
4484
|
+
setupWorld.push(wx, wy);
|
|
4485
|
+
if (openWeights === null && Math.abs(sum - 1) > 1e-3) {
|
|
4486
|
+
openWeights =
|
|
4487
|
+
`vertex ${v}'s ${boneCounts[v]} weights sum to ${sum.toFixed(4)} rather than 1. A stated model becomes a deform ` +
|
|
4488
|
+
'run by writing each vertex\'s world displacement D into every influence as Mᵢ⁻¹·D, which moves the vertex by ' +
|
|
4489
|
+
'D·Σwᵢ — so a sum that is not 1 has no displacement that lands it where the model says. ' +
|
|
4490
|
+
'A20_MESH_WEIGHTS_COHERENT is the assertion that requires that sum, at the same 1e-3';
|
|
4491
|
+
}
|
|
4492
|
+
}
|
|
3846
4493
|
return {
|
|
3847
|
-
|
|
3848
|
-
|
|
3849
|
-
|
|
3850
|
-
|
|
3851
|
-
setup: setupWhy === null ? bindSpace : null,
|
|
3852
|
-
setupWhy,
|
|
4494
|
+
...common,
|
|
4495
|
+
setup: openWeights === null ? setupWorld : null,
|
|
4496
|
+
influenceBones: openWeights === null ? influenceBones : null,
|
|
4497
|
+
setupWhy: openWeights,
|
|
3853
4498
|
};
|
|
3854
4499
|
}
|
|
3855
4500
|
|
|
@@ -3885,6 +4530,15 @@ function deformGeometryOf(att: SpineAttachment, where: string): DeformGeometry {
|
|
|
3885
4530
|
* bone happens to share one world matrix. So that case is refused by name and
|
|
3886
4531
|
* `offset` stays available for an author who really is writing bind-space
|
|
3887
4532
|
* offsets per influence.
|
|
4533
|
+
*
|
|
4534
|
+
* ⭐ **A `transform` key is not in that bind, and since issue #389 it is not
|
|
4535
|
+
* refused for it.** The difference is where the numbers come from: `fromVertex`
|
|
4536
|
+
* hands rigc a pair the AUTHOR wrote, in a space only the author knows, and
|
|
4537
|
+
* rigc will not guess which. A model states a displacement rigc evaluates
|
|
4538
|
+
* itself, so it can put that displacement in world and push it into each
|
|
4539
|
+
* influence through that bone's own inverse — `Σ wᵢ · Mᵢ · Mᵢ⁻¹ · D = D`, with
|
|
4540
|
+
* nothing guessed. What survives is the one case the identity does not cover: a
|
|
4541
|
+
* vertex whose weights do not close at 1.
|
|
3888
4542
|
*/
|
|
3889
4543
|
function compileDeformTrack(
|
|
3890
4544
|
track: MotionDeformTrack,
|
|
@@ -3916,9 +4570,9 @@ function compileDeformTrack(
|
|
|
3916
4570
|
// `vertices` and would otherwise be read as the format's "back to the setup
|
|
3917
4571
|
// pose". The three refusals are the bounds the issue drew: a key states one
|
|
3918
4572
|
// or the other, never both; a model covers every vertex, so a start index
|
|
3919
|
-
// has nothing to mean; and a model
|
|
3920
|
-
//
|
|
3921
|
-
// not
|
|
4573
|
+
// has nothing to mean; and a model has to land each vertex somewhere
|
|
4574
|
+
// definite, which the arithmetic does for any weighting that closes at 1 and
|
|
4575
|
+
// for no weighting that does not (issue #389).
|
|
3922
4576
|
if (key.transform !== undefined) {
|
|
3923
4577
|
if (key.vertices !== undefined && key.vertices !== null) {
|
|
3924
4578
|
throw new CompileError(
|
|
@@ -3937,24 +4591,63 @@ function compileDeformTrack(
|
|
|
3937
4591
|
}
|
|
3938
4592
|
if (geometry.setup === null) {
|
|
3939
4593
|
throw new CompileError(
|
|
3940
|
-
`${where} (t=${key.t}): a "transform"
|
|
3941
|
-
`
|
|
3942
|
-
'
|
|
4594
|
+
`${where} (t=${key.t}): a "transform" states where every vertex of this attachment goes, and this one has a ` +
|
|
4595
|
+
`vertex the arithmetic cannot place — ${geometry.setupWhy}. Fix the weights, or write the bind-space pairs ` +
|
|
4596
|
+
'yourself and start the run with "offset".',
|
|
4597
|
+
);
|
|
4598
|
+
}
|
|
4599
|
+
const report = evaluateDeformTransform(key.transform, geometry.setup, r6, `${where} (t=${key.t})`, geometry.depth);
|
|
4600
|
+
if (report.offsets.length !== geometry.vertexCount * 2) {
|
|
4601
|
+
// Unreachable while `setup` is one pair per vertex, which is the whole
|
|
4602
|
+
// reason both are derived from the same walk. Stated rather than
|
|
4603
|
+
// assumed: a silent mismatch here is the overrun A35 exists for.
|
|
4604
|
+
throw new CompileError(
|
|
4605
|
+
`${where} (t=${key.t}): the transform produced ${report.offsets.length} numbers for ` +
|
|
4606
|
+
`${geometry.vertexCount} vertices`,
|
|
3943
4607
|
);
|
|
3944
4608
|
}
|
|
3945
|
-
|
|
3946
|
-
|
|
3947
|
-
|
|
3948
|
-
|
|
3949
|
-
|
|
3950
|
-
|
|
4609
|
+
// -- one displacement per vertex -> one pair per influence (issue #389) -
|
|
4610
|
+
//
|
|
4611
|
+
// On a single-space attachment the two are the same array and this is a
|
|
4612
|
+
// no-op by construction. On a multi-influence one the model's output is a
|
|
4613
|
+
// WORLD displacement, and it reaches the deform array through each
|
|
4614
|
+
// influencing bone's own inverse: `Σ wᵢ · Mᵢ · Mᵢ⁻¹ · D = D`. The
|
|
4615
|
+
// displacement it expands is the ROUNDED one the report prints, so a
|
|
4616
|
+
// reader can reproduce every emitted pair from the audit rather than from
|
|
4617
|
+
// a second evaluation.
|
|
4618
|
+
const run = expandDeformToInfluences(report.offsets, geometry);
|
|
4619
|
+
if (run.length !== geometry.deformLength) {
|
|
3951
4620
|
throw new CompileError(
|
|
3952
|
-
`${where} (t=${key.t}): the transform
|
|
4621
|
+
`${where} (t=${key.t}): the transform expanded to ${run.length} numbers and this attachment's deform ` +
|
|
3953
4622
|
`array is ${geometry.deformLength} long`,
|
|
3954
4623
|
);
|
|
3955
4624
|
}
|
|
3956
|
-
generated.push({
|
|
3957
|
-
|
|
4625
|
+
generated.push({
|
|
4626
|
+
animation: animName,
|
|
4627
|
+
skin,
|
|
4628
|
+
slot: track.slot,
|
|
4629
|
+
attachment: track.attachment,
|
|
4630
|
+
time,
|
|
4631
|
+
...report,
|
|
4632
|
+
// ⚠️ Which space the model was read in is not a detail: `radius`,
|
|
4633
|
+
// `about`, `from` and `to` all mean something different in world
|
|
4634
|
+
// coordinates, and a reader comparing two keys on two attachments has no
|
|
4635
|
+
// other way to know they were not evaluated alike. Said only on the path
|
|
4636
|
+
// where it is not the attachment's own space, so every existing report
|
|
4637
|
+
// prints exactly the lines it printed before.
|
|
4638
|
+
...(geometry.influenceBones === null
|
|
4639
|
+
? {}
|
|
4640
|
+
: {
|
|
4641
|
+
expanded: run,
|
|
4642
|
+
derived: [
|
|
4643
|
+
...report.derived,
|
|
4644
|
+
`evaluated at setup WORLD positions, because ${geometry.vertexCount} vertices carry ` +
|
|
4645
|
+
`${geometry.deformLength / 2} influences; each displacement D is written into every influence as ` +
|
|
4646
|
+
'Mᵢ⁻¹·D, so the runtime composes Σwᵢ·Mᵢ·Mᵢ⁻¹·D = D',
|
|
4647
|
+
],
|
|
4648
|
+
}),
|
|
4649
|
+
});
|
|
4650
|
+
out.push({ time, vertices: run });
|
|
3958
4651
|
continue;
|
|
3959
4652
|
}
|
|
3960
4653
|
if (key.offset !== undefined && key.fromVertex !== undefined) {
|
|
@@ -4017,6 +4710,42 @@ function compileDeformTrack(
|
|
|
4017
4710
|
return out;
|
|
4018
4711
|
}
|
|
4019
4712
|
|
|
4713
|
+
/**
|
|
4714
|
+
* A stated model's per-vertex displacement, as the deform array holds it.
|
|
4715
|
+
*
|
|
4716
|
+
* Two encodings once more, and the caller does not choose between them —
|
|
4717
|
+
* `influenceBones` does, because it is null exactly when `setup` was already the
|
|
4718
|
+
* array's own space:
|
|
4719
|
+
*
|
|
4720
|
+
* one space — the displacements ARE the array, returned untouched. This is
|
|
4721
|
+
* every `transform` key that compiled before issue #389, and
|
|
4722
|
+
* returning the same array object is what makes that visibly
|
|
4723
|
+
* true rather than arithmetically true.
|
|
4724
|
+
* several — `D` is in world, and influence `i` of a vertex gets
|
|
4725
|
+
* `Mᵢ⁻¹ · D` (rotation and scale, no translation), which the
|
|
4726
|
+
* runtime sums back to `D · Σ wᵢ = D`.
|
|
4727
|
+
*
|
|
4728
|
+
* ⚠️ `toBoneLocalVector` and not `toBoneLocal`: a displacement has no origin to
|
|
4729
|
+
* subtract, and subtracting one would move the vertex by the bone's own
|
|
4730
|
+
* position. The coordinate contract lives in `src/transform.ts` and this reads
|
|
4731
|
+
* it rather than restating it.
|
|
4732
|
+
*/
|
|
4733
|
+
function expandDeformToInfluences(displacements: number[], geometry: DeformGeometry): number[] {
|
|
4734
|
+
const perInfluence = geometry.influenceBones;
|
|
4735
|
+
if (perInfluence === null) return displacements;
|
|
4736
|
+
const counts = geometry.boneCounts ?? [];
|
|
4737
|
+
const out: number[] = [];
|
|
4738
|
+
for (let v = 0, k = 0; v < counts.length; v++) {
|
|
4739
|
+
const dx = displacements[2 * v];
|
|
4740
|
+
const dy = displacements[2 * v + 1];
|
|
4741
|
+
for (let n = 0; n < counts[v]; n++, k++) {
|
|
4742
|
+
const [bx, by] = toBoneLocalVector(perInfluence[k], dx, dy);
|
|
4743
|
+
out.push(r6(bx), r6(by));
|
|
4744
|
+
}
|
|
4745
|
+
}
|
|
4746
|
+
return out;
|
|
4747
|
+
}
|
|
4748
|
+
|
|
4020
4749
|
/**
|
|
4021
4750
|
* Where in the deform array this key's run begins.
|
|
4022
4751
|
*
|