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/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
- deformGeometryOf(attachment, at),
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 **in the space a deform offset lives in**, for a
3748
- * `transform` key to evaluate a model over (issue #294).
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 when there is no single such space, and `setupWhy` then says which of
3751
- * the two reasons it was: a vertex with more than one bone on it, whose offset
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(att: SpineAttachment, where: string): DeformGeometry {
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 multi = boneCounts.findIndex((n) => n !== 1);
3838
- const setupWhy =
3839
- multi !== -1
3840
- ? `vertex ${multi} has ${boneCounts[multi]} bone influences on it, and one x, y pair for such a vertex is not a ` +
3841
- 'thing the deform array can hold: its world offset is the weighted sum of a pair in each bone\'s own bind space'
3842
- : bones.size > 1
3843
- ? `its ${boneCounts.length} vertices are bound to ${bones.size} different bones, so their x, y pairs are in ` +
3844
- 'that many different bind spaces and one closed form evaluated across them would mean nothing'
3845
- : null;
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
- weighted,
3848
- deformLength: influences * 2,
3849
- vertexCount: boneCounts.length,
3850
- boneCounts,
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 needs one coordinate space to be
3920
- // evaluated in, which a multi-bone or multi-space weighted attachment does
3921
- // not have.
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" is evaluated over the attachment's own setup geometry, and this ` +
3941
- `attachment has no single space to evaluate it in — ${geometry.setupWhy}. Key the control bone instead, or ` +
3942
- 'write the bind-space pairs yourself and start the run with "offset".',
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
- const report = evaluateDeformTransform(key.transform, geometry.setup, r6, `${where} (t=${key.t})`);
3946
- if (report.offsets.length !== geometry.deformLength) {
3947
- // Unreachable while `setup` is one pair per vertex and `deformLength` is
3948
- // twice the vertex count, which is the whole reason both are derived
3949
- // from the same walk. Stated rather than assumed: a silent mismatch here
3950
- // is the overrun A35 exists for.
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 produced ${report.offsets.length} numbers and this attachment's deform ` +
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({ animation: animName, skin, slot: track.slot, attachment: track.attachment, time, ...report });
3957
- out.push({ time, vertices: report.offsets });
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
  *