spine-rigc 2.55.0 → 2.56.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/docs/AUTHORING.md +446 -6
- package/package.json +1 -1
- package/src/anglerange.ts +190 -0
- package/src/cli/core_commands.ts +166 -13
- package/src/cli/shared.ts +43 -8
- package/src/compile.ts +45 -6
- package/src/core/entry.ts +6 -1
- package/src/deformgen.ts +333 -2
- package/src/motion.ts +20 -8
- package/src/render_shared.ts +30 -0
- package/src/rig.ts +95 -3
- package/src/stop.ts +472 -0
- package/src/transform.ts +20 -0
- package/src/viewportpin.ts +168 -0
- package/src/whirl.ts +580 -0
package/src/compile.ts
CHANGED
|
@@ -120,7 +120,7 @@ import {
|
|
|
120
120
|
type PhysicsPoseRule,
|
|
121
121
|
type SlotColorChannel,
|
|
122
122
|
} from './timelines.ts';
|
|
123
|
-
import { evaluateDeformTransform } from './deformgen.ts';
|
|
123
|
+
import { evaluateDeformTransform, type DeformSoftMask } from './deformgen.ts';
|
|
124
124
|
import { evaluateTrackDerive, TRACK_DERIVE_PROJECTIONS, type TrackDeriveMember } from './trackgen.ts';
|
|
125
125
|
import {
|
|
126
126
|
computeExactFrameTransforms,
|
|
@@ -2875,6 +2875,9 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
|
|
|
2875
2875
|
// `skin/slot/attachment` -> per-vertex z, for the attachments that named a
|
|
2876
2876
|
// depth map. Read at deform-key time; never emitted. See `AttachmentContext`.
|
|
2877
2877
|
const attachmentDepths = new Map<string, number[]>();
|
|
2878
|
+
// The same for a `soft` mask's per-vertex level, which a deform envelope's
|
|
2879
|
+
// `mask` reads (issue #1413). Never emitted either.
|
|
2880
|
+
const attachmentSoftMasks = new Map<string, DeformSoftMask>();
|
|
2878
2881
|
|
|
2879
2882
|
for (const rigSlot of rig.slots) {
|
|
2880
2883
|
const part = partBySlot.get(rigSlot.name);
|
|
@@ -3042,6 +3045,7 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
|
|
|
3042
3045
|
skinName,
|
|
3043
3046
|
imagesDir,
|
|
3044
3047
|
depths: attachmentDepths,
|
|
3048
|
+
softMasks: attachmentSoftMasks,
|
|
3045
3049
|
slotNames: new Set(rig.slots.map((s) => s.name)),
|
|
3046
3050
|
atlasRegions: atlasIn === null ? null : atlasIn.byName,
|
|
3047
3051
|
links: pendingLinks,
|
|
@@ -3201,6 +3205,17 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
|
|
|
3201
3205
|
if (!boneNames.has(spec.bone)) {
|
|
3202
3206
|
throw new CompileError(`physics constraint "${name}" targets unknown bone "${spec.bone}"`);
|
|
3203
3207
|
}
|
|
3208
|
+
// A stop's copy and clamps sit right after the rig constraint that swings
|
|
3209
|
+
// each link (`src/stop.ts`); this table is emitted after every rig
|
|
3210
|
+
// constraint, so a swing here would run after the clamp meant to hold it.
|
|
3211
|
+
const stop = (rig.stops ?? []).find((s) => s.links.includes(spec.bone));
|
|
3212
|
+
if (stop !== undefined) {
|
|
3213
|
+
throw new CompileError(
|
|
3214
|
+
`physics constraint "${name}" drives "${spec.bone}", a link stop "${stop.chain}" holds, from the physics table — ` +
|
|
3215
|
+
"the table is emitted after every rig constraint, past the stop's copy and clamp, which would read the link " +
|
|
3216
|
+
'before this swings it; declare the constraint in the rig spec\'s "constraints"',
|
|
3217
|
+
);
|
|
3218
|
+
}
|
|
3204
3219
|
// Every component and parameter the table states is held, 0 and the
|
|
3205
3220
|
// parser's default included; which of them Spine leaves out is the
|
|
3206
3221
|
// emitter's (`emitConstraint`). `components` is the report's list of the
|
|
@@ -3544,6 +3559,7 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
|
|
|
3544
3559
|
{
|
|
3545
3560
|
...deformGeometryOf(attachment, at, transforms),
|
|
3546
3561
|
depth: attachmentDepths.get(`${skinName}/${track.slot}/${track.attachment}`) ?? null,
|
|
3562
|
+
soft: attachmentSoftMasks.get(`${skinName}/${track.slot}/${track.attachment}`) ?? null,
|
|
3547
3563
|
},
|
|
3548
3564
|
deformTransforms,
|
|
3549
3565
|
);
|
|
@@ -4174,6 +4190,12 @@ interface AttachmentContext {
|
|
|
4174
4190
|
* read by `evaluateDeformTransform` at key time and never emitted at all.
|
|
4175
4191
|
*/
|
|
4176
4192
|
depths: Map<string, number[]>;
|
|
4193
|
+
/**
|
|
4194
|
+
* A `soft` mask's file and its sampled level per vertex, keyed like `depths`
|
|
4195
|
+
* — what a deform envelope's `mask` reads at key time (issue #1413). Never
|
|
4196
|
+
* emitted, for `depths`' reason.
|
|
4197
|
+
*/
|
|
4198
|
+
softMasks: Map<string, DeformSoftMask>;
|
|
4177
4199
|
/** Every slot the rig declares — a clipping attachment's `end` resolves here. */
|
|
4178
4200
|
slotNames: Set<string>;
|
|
4179
4201
|
/**
|
|
@@ -6292,7 +6314,7 @@ function softRegionWeights(
|
|
|
6292
6314
|
grid: SheetGrid,
|
|
6293
6315
|
where: string,
|
|
6294
6316
|
ctx: AttachmentContext,
|
|
6295
|
-
): { weights: MeshVertexWeight[][]; bone: string; mask: string; digest: string; carried: number; ramped: number } {
|
|
6317
|
+
): { weights: MeshVertexWeight[][]; levels: number[]; bone: string; mask: string; digest: string; carried: number; ramped: number } {
|
|
6296
6318
|
const { bone } = spec;
|
|
6297
6319
|
if (typeof bone !== 'string' || bone.length === 0) {
|
|
6298
6320
|
throw new CompileError(`${where}: the "soft" block has no "bone"; it is the bone the soft region is carried by`);
|
|
@@ -6316,8 +6338,10 @@ function softRegionWeights(
|
|
|
6316
6338
|
const [toSheetX, toSheetY] = sheetRatio(grid);
|
|
6317
6339
|
let carried = 0;
|
|
6318
6340
|
let ramped = 0;
|
|
6341
|
+
const levels: number[] = [];
|
|
6319
6342
|
const weights = points.map(([x, y]) => {
|
|
6320
6343
|
const w = sampleLevel(sheet, x * toSheetX, y * toSheetY) / 255;
|
|
6344
|
+
levels.push(w);
|
|
6321
6345
|
if (w >= 1) carried++;
|
|
6322
6346
|
else if (w > 0) ramped++;
|
|
6323
6347
|
if (w <= 0) return [{ bone: 'anchor', weight: 1 }] as MeshVertexWeight[];
|
|
@@ -6334,7 +6358,7 @@ function softRegionWeights(
|
|
|
6334
6358
|
'check that the mask is painted where the mesh actually is, and that it is white where the art is soft.',
|
|
6335
6359
|
);
|
|
6336
6360
|
}
|
|
6337
|
-
return { weights, bone, mask: spec.mask, digest: depthDigest(sheet), carried, ramped };
|
|
6361
|
+
return { weights, levels, bone, mask: spec.mask, digest: depthDigest(sheet), carried, ramped };
|
|
6338
6362
|
}
|
|
6339
6363
|
|
|
6340
6364
|
/**
|
|
@@ -6461,6 +6485,7 @@ function buildGridAttachment(
|
|
|
6461
6485
|
ctx.meshBones.add(ctx.anchorBone);
|
|
6462
6486
|
if (bound) ctx.meshBones.add(bound.bone);
|
|
6463
6487
|
if (depth !== undefined) ctx.depths.set(`${ctx.skinName}/${ctx.slotName}/${placeholder}`, depth.z);
|
|
6488
|
+
if (bound) ctx.softMasks.set(`${ctx.skinName}/${ctx.slotName}/${placeholder}`, { mask: bound.mask, levels: bound.levels });
|
|
6464
6489
|
ctx.meshes.push({
|
|
6465
6490
|
slot: ctx.slotName,
|
|
6466
6491
|
kind: 'grid',
|
|
@@ -8520,6 +8545,12 @@ export interface DeformGeometry {
|
|
|
8520
8545
|
* caller, which is where the skin, slot and attachment names live.
|
|
8521
8546
|
*/
|
|
8522
8547
|
depth: readonly number[] | null;
|
|
8548
|
+
/**
|
|
8549
|
+
* The attachment's `soft` mask and its level per vertex, when its generator
|
|
8550
|
+
* named one — what a deform envelope's `mask` reads (issue #1413). Filled in
|
|
8551
|
+
* by the caller, as `depth` is, and for the same reason.
|
|
8552
|
+
*/
|
|
8553
|
+
soft: DeformSoftMask | null;
|
|
8523
8554
|
}
|
|
8524
8555
|
|
|
8525
8556
|
/**
|
|
@@ -8596,8 +8627,9 @@ export function deformGeometryOf(
|
|
|
8596
8627
|
influenceBones: null,
|
|
8597
8628
|
setupWhy: null,
|
|
8598
8629
|
// Filled in by the caller; `deformGeometryOf` reads the attachment's
|
|
8599
|
-
// record, and the depth is deliberately not in it.
|
|
8630
|
+
// record, and the depth is deliberately not in it — nor the soft mask.
|
|
8600
8631
|
depth: null,
|
|
8632
|
+
soft: null,
|
|
8601
8633
|
};
|
|
8602
8634
|
}
|
|
8603
8635
|
// Walk the bindings for the per-vertex influence counts. Their shape is
|
|
@@ -8631,7 +8663,7 @@ export function deformGeometryOf(
|
|
|
8631
8663
|
// LOADED attachment, which is where the `/3*2` in the parser comes from.
|
|
8632
8664
|
let influences = 0;
|
|
8633
8665
|
for (const n of boneCounts) influences += n;
|
|
8634
|
-
const common = { weighted, deformLength: influences * 2, vertexCount: boneCounts.length, boneCounts, depth: null };
|
|
8666
|
+
const common = { weighted, deformLength: influences * 2, vertexCount: boneCounts.length, boneCounts, depth: null, soft: null };
|
|
8635
8667
|
// -- one bind space, unchanged -------------------------------------------
|
|
8636
8668
|
//
|
|
8637
8669
|
// Every vertex a single influence of a single bone: the run IS that bone's
|
|
@@ -8874,7 +8906,14 @@ function compileDeformTrack(
|
|
|
8874
8906
|
'yourself and start the run with "offset".',
|
|
8875
8907
|
);
|
|
8876
8908
|
}
|
|
8877
|
-
const report = evaluateDeformTransform(
|
|
8909
|
+
const report = evaluateDeformTransform(
|
|
8910
|
+
key.transform,
|
|
8911
|
+
geometry.setup,
|
|
8912
|
+
onModelGrid,
|
|
8913
|
+
`${where} (t=${key.t})`,
|
|
8914
|
+
geometry.depth,
|
|
8915
|
+
geometry.soft,
|
|
8916
|
+
);
|
|
8878
8917
|
if (report.offsets.length !== geometry.vertexCount * 2) {
|
|
8879
8918
|
// Unreachable while `setup` is one pair per vertex, which is the whole
|
|
8880
8919
|
// reason both are derived from the same walk. Stated rather than
|
package/src/core/entry.ts
CHANGED
|
@@ -20,7 +20,11 @@
|
|
|
20
20
|
* `openTracks`, issue #1336; `physicsClockStep` and `PHYSICS_CLOCK_LEAD`,
|
|
21
21
|
* the clock a live track's `clock` option takes, issue #1396;
|
|
22
22
|
* `PhysicsEnvironment`, what a live track's `setEnvironment` takes, issue
|
|
23
|
-
* #1416
|
|
23
|
+
* #1416; `bezierPolyline` and `BEZIER_PIECES`, the polyline the poser draws
|
|
24
|
+
* a Bézier segment as, so that a fitter holds its tolerance to the poser's
|
|
25
|
+
* own evaluation rather than to a mirror of it, issue #1417 — re-exported,
|
|
26
|
+
* never copied, because a copy is the drift the request exists to remove;
|
|
27
|
+
* `CA12` holds the identity).
|
|
24
28
|
* The list is `scripts/install_smoke.ts`'s `OBSERVED_SYMBOLS` rows for
|
|
25
29
|
* `./core`, and a symbol added here and not there is not promised.
|
|
26
30
|
*/
|
|
@@ -39,4 +43,5 @@ export type { DrawnRegion, UvPage, UvRegion, UvSource } from './uvs.ts';
|
|
|
39
43
|
export { clipThrough, clipShapeOf, convexWhy, REGION_UVS, REGION_TRIANGLES } from './clipping.ts';
|
|
40
44
|
export type { ClipShape, ClipResult } from './clipping.ts';
|
|
41
45
|
export { eventsFired } from './events.ts';
|
|
46
|
+
export { bezierPolyline, BEZIER_PIECES } from './animation.ts';
|
|
42
47
|
export type { ModelPage, ModelPageRegion } from '../model.ts';
|
package/src/deformgen.ts
CHANGED
|
@@ -57,6 +57,7 @@
|
|
|
57
57
|
|
|
58
58
|
import { CompileError } from './errors.ts';
|
|
59
59
|
import { hypot2 } from './numeric.ts';
|
|
60
|
+
import { RIG_INFLUENCE_SHAPES } from './rig.ts';
|
|
60
61
|
|
|
61
62
|
/** Which coordinate a bend or a wave reads, and which one it displaces. */
|
|
62
63
|
export type DeformAxis = 'x' | 'y';
|
|
@@ -120,6 +121,8 @@ export interface DeformTurn {
|
|
|
120
121
|
degrees: number;
|
|
121
122
|
/** Where the axis crosses the driving coordinate. Default 0. */
|
|
122
123
|
about?: number;
|
|
124
|
+
/** Scales each vertex's offset by a weight in [0, 1] — see `DeformEnvelope`. */
|
|
125
|
+
envelope?: DeformEnvelope;
|
|
123
126
|
}
|
|
124
127
|
|
|
125
128
|
/**
|
|
@@ -143,6 +146,8 @@ export interface DeformAffine {
|
|
|
143
146
|
scale: [number, number];
|
|
144
147
|
/** The fixed point, in the attachment's own units. Default `[0, 0]`. */
|
|
145
148
|
about?: [number, number];
|
|
149
|
+
/** Scales each vertex's offset by a weight in [0, 1] — see `DeformEnvelope`. */
|
|
150
|
+
envelope?: DeformEnvelope;
|
|
146
151
|
}
|
|
147
152
|
|
|
148
153
|
/**
|
|
@@ -170,6 +175,8 @@ export interface DeformWave {
|
|
|
170
175
|
along: DeformAxis;
|
|
171
176
|
/** The coordinate it displaces. */
|
|
172
177
|
axis: DeformAxis;
|
|
178
|
+
/** Scales each vertex's offset by a weight in [0, 1] — see `DeformEnvelope`. */
|
|
179
|
+
envelope?: DeformEnvelope;
|
|
173
180
|
}
|
|
174
181
|
|
|
175
182
|
/**
|
|
@@ -196,10 +203,96 @@ export interface DeformBend {
|
|
|
196
203
|
along: DeformAxis;
|
|
197
204
|
/** The coordinate it displaces. */
|
|
198
205
|
axis: DeformAxis;
|
|
206
|
+
/** Scales each vertex's offset by a weight in [0, 1] — see `DeformEnvelope`. */
|
|
207
|
+
envelope?: DeformEnvelope;
|
|
199
208
|
}
|
|
200
209
|
|
|
201
210
|
export type DeformTransform = DeformTurn | DeformAffine | DeformWave | DeformBend;
|
|
202
211
|
|
|
212
|
+
/**
|
|
213
|
+
* The two curves an envelope rises along: the names `influence.shape` gives its
|
|
214
|
+
* pull (issue #1415), and the same two curves read from the other end — a pull
|
|
215
|
+
* falls from 1 to 0, a weight rises from 0 to 1, and each is `1 − the other`
|
|
216
|
+
* at the same `u`. One vocabulary, so `"smooth"` means one curve in this tree.
|
|
217
|
+
*/
|
|
218
|
+
export const DEFORM_ENVELOPE_SHAPES = RIG_INFLUENCE_SHAPES;
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* **Which vertices a transform moves, and how much** (issue #1413) — a weight
|
|
222
|
+
* in [0, 1] per vertex that each vertex's offset is multiplied by before it is
|
|
223
|
+
* written, so "only the hem ripples" is a model rather than a table.
|
|
224
|
+
*
|
|
225
|
+
* Two forms, and a key states exactly one:
|
|
226
|
+
*
|
|
227
|
+
* - **a ramp** — `axis`, `from`, `to`, `shape`. A vertex's position is read
|
|
228
|
+
* along `axis` (`p = axis · position`), `u = (p − from) / (to − from)` is
|
|
229
|
+
* clamped to [0, 1], and the weight is `u` (`"linear"`) or `u²(3 − 2u)`
|
|
230
|
+
* (`"smooth"`). So the weight is 0 at `from` and everywhere before it, 1 at
|
|
231
|
+
* `to` and everywhere past it.
|
|
232
|
+
* - **a mask** — `mask`, the file name of this attachment's own `soft` mask
|
|
233
|
+
* (AUTHORING §3.4.6). The weight is the level that mask was sampled to at the
|
|
234
|
+
* vertex when the mesh was built, black 0 and white 1 — the same number the
|
|
235
|
+
* soft region's binding carries.
|
|
236
|
+
*
|
|
237
|
+
* ⭐ **The ramp is read in the space the transform is evaluated in**, and that
|
|
238
|
+
* is the only space it could be read in: the weight multiplies the offset the
|
|
239
|
+
* model produced at a vertex, so the position that picks the weight has to be
|
|
240
|
+
* the position the model read. That is the attachment's own space while every
|
|
241
|
+
* vertex is one pair of one bone, and setup WORLD past that (§4.11.1) — so
|
|
242
|
+
* `from` and `to` change units exactly when `radius`, `about` and a bend's
|
|
243
|
+
* `from` do, and the report's `derived` line says which.
|
|
244
|
+
*
|
|
245
|
+
* 🔒 **It is baked, not played.** The weighted offsets are the deform table the
|
|
246
|
+
* file carries, so the runtime is asked to do nothing it did not already do and
|
|
247
|
+
* every poser sees the same numbers. Every number here is a field the spec
|
|
248
|
+
* states; none has a default.
|
|
249
|
+
*/
|
|
250
|
+
export interface DeformEnvelope {
|
|
251
|
+
/**
|
|
252
|
+
* The unit direction a vertex's position is read along — `[0, 1]` reads `y`,
|
|
253
|
+
* `[1, 0]` reads `x`, and a diagonal reads across both. Its length is 1 on
|
|
254
|
+
* the model's own 1e-6 grid, or the key is refused: a longer one would scale
|
|
255
|
+
* the positions `from` and `to` are compared with.
|
|
256
|
+
*/
|
|
257
|
+
axis?: [number, number];
|
|
258
|
+
/** The position along `axis` where the weight is 0 — and before which it stays 0. */
|
|
259
|
+
from?: number;
|
|
260
|
+
/** The position along `axis` where the weight is 1 — and past which it stays 1. Differs from `from`. */
|
|
261
|
+
to?: number;
|
|
262
|
+
/** How the weight rises between them: `"linear"` is `u`, `"smooth"` is `u²(3 − 2u)`, flat at both ends. */
|
|
263
|
+
shape?: (typeof DEFORM_ENVELOPE_SHAPES)[number];
|
|
264
|
+
/** The file name of this attachment's own `soft` mask; the weight is its sampled level. Never beside the ramp's fields. */
|
|
265
|
+
mask?: string;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* A `soft` mask as the mesh builder sampled it: the file the generator named
|
|
270
|
+
* and its level at each vertex, `0..1`, in the emitted vertex order — what a
|
|
271
|
+
* `mask` envelope reads. Kept beside the attachment rather than in it, as a
|
|
272
|
+
* depth map's `z` is, because the format has nowhere to carry it.
|
|
273
|
+
*/
|
|
274
|
+
export interface DeformSoftMask {
|
|
275
|
+
mask: string;
|
|
276
|
+
levels: readonly number[];
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** What an envelope did, for `explain` to print beside the model. */
|
|
280
|
+
export interface DeformEnvelopeReport {
|
|
281
|
+
/** The envelope as the spec states it. */
|
|
282
|
+
stated: string;
|
|
283
|
+
/** How a vertex's weight is computed, written out. */
|
|
284
|
+
formula: string;
|
|
285
|
+
/**
|
|
286
|
+
* Each vertex's weight, on the model's 1e-6 grid — the number its offset was
|
|
287
|
+
* multiplied by. Every emitted offset is `round(weight × the un-enveloped
|
|
288
|
+
* offset)`, so a reader reproduces the run from this and the model.
|
|
289
|
+
*/
|
|
290
|
+
weights: number[];
|
|
291
|
+
/** How many vertices took weight 0, and how many weight 1. */
|
|
292
|
+
zero: number;
|
|
293
|
+
full: number;
|
|
294
|
+
}
|
|
295
|
+
|
|
203
296
|
/** The kinds this module evaluates, in the order the docs list them. */
|
|
204
297
|
export const DEFORM_TRANSFORM_KINDS = ['yaw', 'pitch', 'affine', 'wave', 'bend'] as const;
|
|
205
298
|
|
|
@@ -233,8 +326,13 @@ export interface DeformTransformReport {
|
|
|
233
326
|
* multi-influence one it is one WORLD displacement per vertex, and the array
|
|
234
327
|
* the caller wrote from it is `expanded` on the compile result — the report
|
|
235
328
|
* still quotes what was emitted rather than re-evaluating anything.
|
|
329
|
+
*
|
|
330
|
+
* Under an envelope these are the WEIGHTED displacements — what was emitted
|
|
331
|
+
* — and `envelope` carries the weights.
|
|
236
332
|
*/
|
|
237
333
|
offsets: number[];
|
|
334
|
+
/** Present only on a key that states an `envelope` (issue #1413). */
|
|
335
|
+
envelope?: DeformEnvelopeReport;
|
|
238
336
|
}
|
|
239
337
|
|
|
240
338
|
/** The compiler's `onModelGrid` (1e-6, then float32), passed in so the quantiser stays in one place. */
|
|
@@ -271,6 +369,12 @@ export function evaluateDeformTransform(
|
|
|
271
369
|
* name rather than silently falling back to a cylinder.
|
|
272
370
|
*/
|
|
273
371
|
depth: readonly number[] | null = null,
|
|
372
|
+
/**
|
|
373
|
+
* The attachment's `soft` mask as its builder sampled it, or null when its
|
|
374
|
+
* generator named none — what makes a `mask` envelope that names nothing
|
|
375
|
+
* refusable by name (issue #1413).
|
|
376
|
+
*/
|
|
377
|
+
soft: DeformSoftMask | null = null,
|
|
274
378
|
): DeformTransformReport {
|
|
275
379
|
if (transform === null || typeof transform !== 'object' || Array.isArray(transform)) {
|
|
276
380
|
throw new CompileError(`${where}: "transform" is ${JSON.stringify(transform)}; it is an object with a "kind"`);
|
|
@@ -284,6 +388,10 @@ export function evaluateDeformTransform(
|
|
|
284
388
|
}
|
|
285
389
|
const count = setup.length / 2;
|
|
286
390
|
const offsets = new Array<number>(setup.length);
|
|
391
|
+
// An envelope scales each vertex by its own weight, so a proof that holds of
|
|
392
|
+
// the model — an affine's positive determinant, a power-1 bend's shear —
|
|
393
|
+
// says nothing about the enveloped key, and the derived line must not claim it.
|
|
394
|
+
const envelope = (transform as { envelope?: unknown }).envelope;
|
|
287
395
|
let derived: string[];
|
|
288
396
|
let stated: string;
|
|
289
397
|
let formula: string;
|
|
@@ -440,7 +548,13 @@ export function evaluateDeformTransform(
|
|
|
440
548
|
'the key holds still';
|
|
441
549
|
stated = `scale=[${scale[0]}, ${scale[1]}]${a.about === undefined ? '' : ` about=[${about[0]}, ${about[1]}]`}`;
|
|
442
550
|
formula = 'dx = (sx − 1)·(x − ax), dy = (sy − 1)·(y − ay)';
|
|
443
|
-
derived = [
|
|
551
|
+
derived = [
|
|
552
|
+
`sx − 1 = ${round(scale[0] - 1)}`,
|
|
553
|
+
`sy − 1 = ${round(scale[1] - 1)}`,
|
|
554
|
+
envelope === undefined
|
|
555
|
+
? `det = sx·sy = ${round(det)} > 0, so no triangle can reverse`
|
|
556
|
+
: `det = sx·sy = ${round(det)} > 0 un-enveloped; the envelope weighs each vertex alone, so the keyed map is not affine and A39 measures it`,
|
|
557
|
+
];
|
|
444
558
|
break;
|
|
445
559
|
}
|
|
446
560
|
case 'wave': {
|
|
@@ -527,8 +641,10 @@ export function evaluateDeformTransform(
|
|
|
527
641
|
formula = `d${axis === 0 ? 'x' : 'y'} = amount · u^${power}, u = (${an} − from) / (to − from)`;
|
|
528
642
|
derived = [
|
|
529
643
|
`span = to − from = ${round(span)}`,
|
|
530
|
-
power === 1
|
|
644
|
+
power === 1 && envelope === undefined
|
|
531
645
|
? 'power 1 is an affine shear (det = 1), so no triangle can reverse'
|
|
646
|
+
: power === 1
|
|
647
|
+
? 'power 1 is an affine shear (det = 1) un-enveloped; the envelope weighs each vertex alone, so the keyed map is not affine and A39 measures it'
|
|
532
648
|
: `power ${power} is not affine — the gradient at u is ${power}·amount·u^${power - 1}/span, so it is 0 at "from"`,
|
|
533
649
|
];
|
|
534
650
|
break;
|
|
@@ -567,6 +683,49 @@ export function evaluateDeformTransform(
|
|
|
567
683
|
);
|
|
568
684
|
}
|
|
569
685
|
|
|
686
|
+
// -- the envelope (issue #1413) --------------------------------------------
|
|
687
|
+
//
|
|
688
|
+
// Applied to the run the model emits un-enveloped — the ROUNDED offsets, the
|
|
689
|
+
// ones a key without the envelope writes — and rounded again, so every
|
|
690
|
+
// emitted offset is `round(weight × that offset)` and a reader reproduces it
|
|
691
|
+
// from the report's weights and the model. Applied after the refusal above,
|
|
692
|
+
// so a model the geometry sampled to nothing keeps that refusal's diagnosis,
|
|
693
|
+
// and before the two below, which are the envelope's own.
|
|
694
|
+
let envelopeReport: DeformEnvelopeReport | undefined;
|
|
695
|
+
if (envelope !== undefined) {
|
|
696
|
+
const weighed = evaluateDeformEnvelope(envelope, setup, soft, round, where);
|
|
697
|
+
const plain = offsets.slice();
|
|
698
|
+
for (let v = 0; v < count; v++) {
|
|
699
|
+
offsets[2 * v] = round(weighed.weights[v] * plain[2 * v]);
|
|
700
|
+
offsets[2 * v + 1] = round(weighed.weights[v] * plain[2 * v + 1]);
|
|
701
|
+
}
|
|
702
|
+
envelopeReport = weighed;
|
|
703
|
+
const moving = plain.some((d) => d !== 0);
|
|
704
|
+
// ⭐ The same principle as the refusal above, one step on: every field is
|
|
705
|
+
// legal and the envelope still makes the key mean something other than it
|
|
706
|
+
// says. Weighing every moving vertex 0 states a deformation and emits the
|
|
707
|
+
// identity; leaving every emitted number where it was is a field that
|
|
708
|
+
// changes nothing — a reader's false lead about which model produced the
|
|
709
|
+
// run. A key whose model states the identity is exempt from both: the
|
|
710
|
+
// envelope multiplies the zeros it already said.
|
|
711
|
+
if (count > 0 && !identity && moving && offsets.every((d) => d === 0)) {
|
|
712
|
+
throw new CompileError(
|
|
713
|
+
`${where}: transform ${kind} states ${stated} and moves ${plain.filter((d, i) => i % 2 === 0 && (d !== 0 || plain[i + 1] !== 0)).length} ` +
|
|
714
|
+
`of ${count} vertices, and envelope ${weighed.stated} weighs every one of them 0 — so the key states a ` +
|
|
715
|
+
`deformation and emits the identity. ${weighed.reach}`,
|
|
716
|
+
);
|
|
717
|
+
}
|
|
718
|
+
if (count > 0 && !identity && moving && offsets.every((d, i) => d === plain[i])) {
|
|
719
|
+
throw new CompileError(
|
|
720
|
+
`${where}: transform ${kind} states envelope ${weighed.stated}, and it changes no emitted number: every vertex ` +
|
|
721
|
+
`the model moves takes weight 1 (${weighed.full} of ${count} vertices at 1, ${weighed.zero} at 0), so the run ` +
|
|
722
|
+
`is the un-enveloped one and the envelope is a field a reader would credit for an output it did not shape. ` +
|
|
723
|
+
`${weighed.reach}`,
|
|
724
|
+
);
|
|
725
|
+
}
|
|
726
|
+
stated = `${stated} envelope(${weighed.stated})`;
|
|
727
|
+
}
|
|
728
|
+
|
|
570
729
|
let maxOffset = 0;
|
|
571
730
|
let maxOffsetVertex = 0;
|
|
572
731
|
for (let v = 0; v < count; v++) {
|
|
@@ -585,9 +744,181 @@ export function evaluateDeformTransform(
|
|
|
585
744
|
maxOffset: round(maxOffset),
|
|
586
745
|
maxOffsetVertex,
|
|
587
746
|
offsets,
|
|
747
|
+
...(envelopeReport === undefined
|
|
748
|
+
? {}
|
|
749
|
+
: {
|
|
750
|
+
envelope: {
|
|
751
|
+
stated: envelopeReport.stated,
|
|
752
|
+
formula: envelopeReport.formula,
|
|
753
|
+
weights: envelopeReport.weights,
|
|
754
|
+
zero: envelopeReport.zero,
|
|
755
|
+
full: envelopeReport.full,
|
|
756
|
+
},
|
|
757
|
+
}),
|
|
588
758
|
};
|
|
589
759
|
}
|
|
590
760
|
|
|
761
|
+
/** The fields of the two envelope forms, in the order a refusal names them. */
|
|
762
|
+
const ENVELOPE_RAMP_FIELDS = ['axis', 'from', 'to', 'shape'] as const;
|
|
763
|
+
|
|
764
|
+
/**
|
|
765
|
+
* Each vertex's envelope weight (issue #1413), refused by field name wherever
|
|
766
|
+
* the envelope cannot mean what it says.
|
|
767
|
+
*
|
|
768
|
+
* `setup` is the array the model was evaluated over, so a ramp reads the same
|
|
769
|
+
* positions in the same space the model did; `soft` is the attachment's
|
|
770
|
+
* sampled mask, or null. Returns the weights on the model's grid with the
|
|
771
|
+
* audit `explain` prints and the reach the two envelope refusals in
|
|
772
|
+
* `evaluateDeformTransform` quote.
|
|
773
|
+
*/
|
|
774
|
+
export function evaluateDeformEnvelope(
|
|
775
|
+
envelope: unknown,
|
|
776
|
+
setup: readonly number[],
|
|
777
|
+
soft: DeformSoftMask | null,
|
|
778
|
+
round: Rounder,
|
|
779
|
+
where: string,
|
|
780
|
+
): DeformEnvelopeReport & { reach: string } {
|
|
781
|
+
if (envelope === null || typeof envelope !== 'object' || Array.isArray(envelope)) {
|
|
782
|
+
throw new CompileError(
|
|
783
|
+
`${where}: transform field "envelope" is ${JSON.stringify(envelope)}; it is an object — a ramp ` +
|
|
784
|
+
'`{ "axis", "from", "to", "shape" }` or a mask `{ "mask" }`',
|
|
785
|
+
);
|
|
786
|
+
}
|
|
787
|
+
const e = envelope as DeformEnvelope;
|
|
788
|
+
const count = setup.length / 2;
|
|
789
|
+
const rampStated = ENVELOPE_RAMP_FIELDS.filter((f) => e[f] !== undefined);
|
|
790
|
+
if (e.mask !== undefined && rampStated.length > 0) {
|
|
791
|
+
throw new CompileError(
|
|
792
|
+
`${where}: transform field "envelope" states "mask" ${JSON.stringify(e.mask)} beside the ramp's ` +
|
|
793
|
+
`${rampStated.map((f) => `"${f}"`).join(', ')}. They are two answers to one question — what weight each vertex ` +
|
|
794
|
+
'takes — and a key carrying both leaves a reader unable to say which one the output came from. Drop one.',
|
|
795
|
+
);
|
|
796
|
+
}
|
|
797
|
+
if (e.mask === undefined && rampStated.length === 0) {
|
|
798
|
+
throw new CompileError(
|
|
799
|
+
`${where}: transform field "envelope" is ${JSON.stringify(envelope)}, which states no weight at all. It is a ramp ` +
|
|
800
|
+
'`{ "axis", "from", "to", "shape" }` or a mask `{ "mask" }`; a key that weighs every vertex 1 carries no envelope.',
|
|
801
|
+
);
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
let weights: number[];
|
|
805
|
+
let stated: string;
|
|
806
|
+
let formula: string;
|
|
807
|
+
let reach: string;
|
|
808
|
+
if (e.mask !== undefined) {
|
|
809
|
+
const mask = e.mask;
|
|
810
|
+
if (soft === null) {
|
|
811
|
+
throw new CompileError(
|
|
812
|
+
`${where}: transform field "envelope.mask" is ${JSON.stringify(mask)}, and this attachment has no "soft" block, ` +
|
|
813
|
+
'so the mask names nothing. An envelope\'s mask is the attachment\'s own soft mask (AUTHORING §3.4.6), the ' +
|
|
814
|
+
'one its generator sampled at every vertex when the mesh was built; give the generator a "soft" block, or ' +
|
|
815
|
+
'state a ramp instead.',
|
|
816
|
+
);
|
|
817
|
+
}
|
|
818
|
+
if (soft.mask !== mask) {
|
|
819
|
+
throw new CompileError(
|
|
820
|
+
`${where}: transform field "envelope.mask" is ${JSON.stringify(mask)}, and this attachment's soft mask is ` +
|
|
821
|
+
`${JSON.stringify(soft.mask)}, so the mask names nothing it was built with. An envelope reads the mask the ` +
|
|
822
|
+
'generator sampled at every vertex, and only that one.',
|
|
823
|
+
);
|
|
824
|
+
}
|
|
825
|
+
if (soft.levels.length !== count) {
|
|
826
|
+
// Unreachable while the sampler walks the same vertex list the mesh
|
|
827
|
+
// emitted, as for a depth map; stated because a silent mismatch would
|
|
828
|
+
// weigh one vertex by another's level.
|
|
829
|
+
throw new CompileError(`${where}: the soft mask "${mask}" has ${soft.levels.length} sampled levels for ${count} vertices`);
|
|
830
|
+
}
|
|
831
|
+
weights = soft.levels.map((level) => round(level));
|
|
832
|
+
stated = `mask=${mask}`;
|
|
833
|
+
formula = 'w = the soft mask\'s level at the vertex, black 0 and white 1';
|
|
834
|
+
const carried = weights.filter((w) => w > 0).length;
|
|
835
|
+
reach = `The mask "${mask}" weighs ${carried} of ${count} vertices above 0.`;
|
|
836
|
+
} else {
|
|
837
|
+
const axis = pair(e.axis, 'envelope.axis', where);
|
|
838
|
+
const from = num(e.from, 'envelope.from', where);
|
|
839
|
+
const to = num(e.to, 'envelope.to', where);
|
|
840
|
+
const shape = DEFORM_ENVELOPE_SHAPES.find((name) => name === e.shape);
|
|
841
|
+
if (shape === undefined) {
|
|
842
|
+
throw new CompileError(
|
|
843
|
+
`${where}: transform field "envelope.shape" is ${JSON.stringify(e.shape)}; it is one of ` +
|
|
844
|
+
`${DEFORM_ENVELOPE_SHAPES.map((name) => JSON.stringify(name)).join(', ')} — how the weight rises from 0 at ` +
|
|
845
|
+
'"from" to 1 at "to": "linear" is u, "smooth" is u²(3 − 2u)',
|
|
846
|
+
);
|
|
847
|
+
}
|
|
848
|
+
const length = hypot2(axis[0], axis[1]);
|
|
849
|
+
// The model's own resolution decides "unit", rather than a tolerance chosen
|
|
850
|
+
// here: the diagonal written to seven places, `[0.7071068, 0.7071068]`, is
|
|
851
|
+
// 1.0000000266 long and is the unit direction it says, and a length the
|
|
852
|
+
// 1e-6 grid tells apart from 1 would scale every position the ramp
|
|
853
|
+
// compares with `from` and `to`.
|
|
854
|
+
if (round(length - 1) !== 0) {
|
|
855
|
+
throw new CompileError(
|
|
856
|
+
`${where}: transform field "envelope.axis" is [${axis[0]}, ${axis[1]}], whose length is ${round(length)}; it is a ` +
|
|
857
|
+
'unit direction, length 1 on the model\'s 1e-6 grid. A longer or shorter one would scale every position the ' +
|
|
858
|
+
'ramp compares with "from" and "to" — divide it by its length, and state "from" and "to" along the unit one.',
|
|
859
|
+
);
|
|
860
|
+
}
|
|
861
|
+
if (from === to) {
|
|
862
|
+
throw new CompileError(
|
|
863
|
+
`${where}: transform fields "envelope.from" and "envelope.to" are both ${from}; they are where the weight is 0 ` +
|
|
864
|
+
'and where it is 1, so they differ — a ramp of no length is a step, and a step is the edge a model exists not to leave.',
|
|
865
|
+
);
|
|
866
|
+
}
|
|
867
|
+
const span = to - from;
|
|
868
|
+
let lo = Infinity;
|
|
869
|
+
let hi = -Infinity;
|
|
870
|
+
weights = new Array<number>(count);
|
|
871
|
+
for (let v = 0; v < count; v++) {
|
|
872
|
+
const p = axis[0] * setup[2 * v] + axis[1] * setup[2 * v + 1];
|
|
873
|
+
if (p < lo) lo = p;
|
|
874
|
+
if (p > hi) hi = p;
|
|
875
|
+
const u = Math.min(1, Math.max(0, (p - from) / span));
|
|
876
|
+
weights[v] = round(shape === 'linear' ? u : u * u * (3 - 2 * u));
|
|
877
|
+
}
|
|
878
|
+
stated = `axis=[${axis[0]}, ${axis[1]}] from=${from} to=${to} shape=${shape}`;
|
|
879
|
+
formula =
|
|
880
|
+
`w = ${shape === 'linear' ? 'u' : 'u²(3 − 2u)'}, u = clamp((axis · position − from) / (to − from), 0, 1)`;
|
|
881
|
+
reach =
|
|
882
|
+
count === 0
|
|
883
|
+
? 'The attachment has no vertices.'
|
|
884
|
+
: `Along [${axis[0]}, ${axis[1]}] this attachment's vertices sit at ${round(lo)} to ${round(hi)}, and the ramp ` +
|
|
885
|
+
`rises from ${from} (weight 0) to ${to} (weight 1) — move "from" and "to" onto the part.`;
|
|
886
|
+
}
|
|
887
|
+
checkEnvelopeWeights(weights, e.mask === undefined ? 'envelope.shape' : 'envelope.mask', where);
|
|
888
|
+
return {
|
|
889
|
+
stated,
|
|
890
|
+
formula,
|
|
891
|
+
weights,
|
|
892
|
+
zero: weights.filter((w) => w === 0).length,
|
|
893
|
+
full: weights.filter((w) => w === 1).length,
|
|
894
|
+
reach,
|
|
895
|
+
};
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/**
|
|
899
|
+
* Refuse a weight outside [0, 1], naming the field that produced it, the vertex
|
|
900
|
+
* and the value (issue #1413, Bar 2).
|
|
901
|
+
*
|
|
902
|
+
* ⚠️ Nothing a spec states can reach this, and that is stated rather than left
|
|
903
|
+
* to be inferred: a ramp clamps `u` to [0, 1] and both shapes map [0, 1] onto
|
|
904
|
+
* itself, and a mask's level is a sample of a greyscale sheet. It stands
|
|
905
|
+
* because the multiplication is the whole of the envelope's effect and a
|
|
906
|
+
* weight of 1.2 would push a vertex past the model — the shape of defect a
|
|
907
|
+
* later shape or a later mask reader would bring in silently. Exported so the
|
|
908
|
+
* selftest can hand it the weight no envelope produces and see it refuse.
|
|
909
|
+
*/
|
|
910
|
+
export function checkEnvelopeWeights(weights: readonly number[], field: string, where: string): void {
|
|
911
|
+
for (let v = 0; v < weights.length; v++) {
|
|
912
|
+
const w = weights[v];
|
|
913
|
+
if (!(w >= 0 && w <= 1)) {
|
|
914
|
+
throw new CompileError(
|
|
915
|
+
`${where}: transform field "${field}" weighs vertex ${v} at ${w}; an envelope's weight is in [0, 1] — 0 leaves ` +
|
|
916
|
+
'the vertex where it is and 1 moves it the whole way the model says, and nothing past either end is a weight.',
|
|
917
|
+
);
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
|
|
591
922
|
/** One required finite number, refused by field name rather than compiled as a NaN. */
|
|
592
923
|
function num(value: unknown, field: string, where: string): number {
|
|
593
924
|
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
package/src/motion.ts
CHANGED
|
@@ -147,10 +147,11 @@ export const MOTION_KEYS = {
|
|
|
147
147
|
MotionDrawOrderOffset: ['slot', 'offset'],
|
|
148
148
|
MotionEventKey: ['t', 'name', 'int', 'float', 'string', 'volume', 'balance'],
|
|
149
149
|
TrackDeriveTurn: ['kind', 'degrees', 'depth', 'carried', 'about'],
|
|
150
|
-
DeformTurn: ['kind', 'radius', 'depth', 'degrees', 'about'],
|
|
151
|
-
DeformAffine: ['kind', 'scale', 'about'],
|
|
152
|
-
DeformWave: ['kind', 'amplitude', 'wavelength', 'phase', 'along', 'axis'],
|
|
153
|
-
DeformBend: ['kind', 'amount', 'from', 'to', 'power', 'along', 'axis'],
|
|
150
|
+
DeformTurn: ['kind', 'radius', 'depth', 'degrees', 'about', 'envelope'],
|
|
151
|
+
DeformAffine: ['kind', 'scale', 'about', 'envelope'],
|
|
152
|
+
DeformWave: ['kind', 'amplitude', 'wavelength', 'phase', 'along', 'axis', 'envelope'],
|
|
153
|
+
DeformBend: ['kind', 'amount', 'from', 'to', 'power', 'along', 'axis', 'envelope'],
|
|
154
|
+
DeformEnvelope: ['axis', 'from', 'to', 'shape', 'mask'],
|
|
154
155
|
} as const satisfies Record<string, readonly string[]>;
|
|
155
156
|
|
|
156
157
|
/**
|
|
@@ -220,10 +221,16 @@ export const MOTION_TYPES = {
|
|
|
220
221
|
t: 'number', name: 'string', int: 'number', float: 'number', string: 'string', volume: 'number', balance: 'number',
|
|
221
222
|
},
|
|
222
223
|
TrackDeriveTurn: { kind: 'enum', degrees: 'number', depth: 'mixed', carried: 'number', about: 'number' },
|
|
223
|
-
DeformTurn: { kind: 'enum', radius: 'number', depth: 'boolean', degrees: 'number', about: 'number' },
|
|
224
|
-
DeformAffine: { kind: 'enum', scale: 'number[]', about: 'number[]' },
|
|
225
|
-
DeformWave: {
|
|
226
|
-
|
|
224
|
+
DeformTurn: { kind: 'enum', radius: 'number', depth: 'boolean', degrees: 'number', about: 'number', envelope: 'object' },
|
|
225
|
+
DeformAffine: { kind: 'enum', scale: 'number[]', about: 'number[]', envelope: 'object' },
|
|
226
|
+
DeformWave: {
|
|
227
|
+
kind: 'enum', amplitude: 'number', wavelength: 'number', phase: 'number', along: 'enum', axis: 'enum', envelope: 'object',
|
|
228
|
+
},
|
|
229
|
+
DeformBend: {
|
|
230
|
+
kind: 'enum', amount: 'number', from: 'number', to: 'number', power: 'number', along: 'enum', axis: 'enum',
|
|
231
|
+
envelope: 'object',
|
|
232
|
+
},
|
|
233
|
+
DeformEnvelope: { axis: 'number[]', from: 'number', to: 'number', shape: 'enum', mask: 'string' },
|
|
227
234
|
} as const satisfies MotionTypeTable;
|
|
228
235
|
|
|
229
236
|
/**
|
|
@@ -242,6 +249,7 @@ export const MOTION_ENUMS = {
|
|
|
242
249
|
DeformAffine: { kind: { owner: 'evaluateDeformTransform' } },
|
|
243
250
|
DeformWave: { kind: { owner: 'evaluateDeformTransform' }, along: { owner: 'evaluateDeformTransform' }, axis: { owner: 'evaluateDeformTransform' } },
|
|
244
251
|
DeformBend: { kind: { owner: 'evaluateDeformTransform' }, along: { owner: 'evaluateDeformTransform' }, axis: { owner: 'evaluateDeformTransform' } },
|
|
252
|
+
DeformEnvelope: { shape: { owner: 'evaluateDeformEnvelope' } },
|
|
245
253
|
} as const satisfies SpecEnumTable<typeof MOTION_TYPES>;
|
|
246
254
|
|
|
247
255
|
/** A deform key's `transform` kinds, and the shape each one's keys come from. */
|
|
@@ -587,6 +595,10 @@ function parseDeform(raw: unknown, where: string, at: string): void {
|
|
|
587
595
|
if (isObj(parsed.transform)) {
|
|
588
596
|
const shape = DEFORM_TRANSFORM_SHAPE[String(parsed.transform.kind)];
|
|
589
597
|
if (shape !== undefined) known(parsed.transform, shape, where, `${at}.transform`);
|
|
598
|
+
// The envelope is one shape under all five kinds (issue #1413); which
|
|
599
|
+
// of its two forms it states, and whether the fields agree, is
|
|
600
|
+
// `evaluateDeformEnvelope`'s to say.
|
|
601
|
+
known(parsed.transform.envelope, 'DeformEnvelope', where, `${at}.transform.envelope`);
|
|
590
602
|
}
|
|
591
603
|
}
|
|
592
604
|
}
|