spine-rigc 2.52.0 → 2.54.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 +199 -2
- package/package.json +1 -1
- package/src/assertions/bodies/a23.ts +174 -0
- package/src/check.ts +12 -0
- package/src/cli/core_commands.ts +95 -4
- package/src/cli/shared.ts +15 -1
- package/src/compile.ts +126 -41
- package/src/core/raw.ts +29 -6
- package/src/environment.ts +193 -0
- package/src/mesh.ts +136 -0
- package/src/render.ts +15 -1
- package/src/render_core.ts +10 -6
- package/src/render_shared.ts +37 -2
- package/src/rig.ts +63 -4
- package/src/timelines.ts +61 -0
- package/src/types.ts +14 -0
package/src/cli/core_commands.ts
CHANGED
|
@@ -32,6 +32,7 @@ import { MODEL_DOCUMENT_FILE, modelDocument } from '../model.ts';
|
|
|
32
32
|
import { parseMotionSpec } from '../motion.ts';
|
|
33
33
|
import { type InspectCompare, type InspectFrame, InspectError, inspectCandidate, INSPECT_DEFAULT_FPS, legendText, readBonePatterns, readShow, readTimes, readZoom } from '../inspect.ts';
|
|
34
34
|
import { FOLDS_FILE, foldLine, foldsFileOf, foldsOf, foldsText, setupFaultLine } from '../folds.ts';
|
|
35
|
+
import { environmentCollisions, environmentFrame, EnvironmentError, environmentResponse, readEnvironmentSchedule, type EnvironmentStep } from '../environment.ts';
|
|
35
36
|
import { estimatePose, poseLines, type PoseOptions } from '../pose.ts';
|
|
36
37
|
import { BACKGROUND, contactSheet, type Frame, FRAMES_SIDECAR, FRAMES_SPEC, type FrameSet, type FramesSidecar, framingViewport, GEOMETRY_FILE, geometryFileOf, geometryText, loadCandidate, POSER_NAMES, type PoserName, PROTOCOL_FPS, refuseUnchosen, renderFrame, sampleAll, sampleAnimation, SETUP_POSE_DIR, SHEET_FILE, SHEET_TILE, sidecarViewport, type SkeletonFacts, type SlotSubset, SlotSubsetError, throughPoser } from '../render_shared.ts';
|
|
37
38
|
import { type CompileResult } from '../types.ts';
|
|
@@ -472,6 +473,72 @@ function readPositiveNumber(flags: Record<string, string>, key: string, fallback
|
|
|
472
473
|
return value;
|
|
473
474
|
}
|
|
474
475
|
|
|
476
|
+
/**
|
|
477
|
+
* `--environment <schedule.json>` read (issue #1416, `../environment.ts`), or
|
|
478
|
+
* refused as a usage error before anything is read off the candidate: a path
|
|
479
|
+
* with nothing at it, a file that is not a schedule, and every malformed entry,
|
|
480
|
+
* by its index, its field, the value found and what is required.
|
|
481
|
+
*/
|
|
482
|
+
function readEnvironmentFlag(flags: Record<string, string>): { path: string; steps: EnvironmentStep[] } | undefined {
|
|
483
|
+
const given = flags.environment;
|
|
484
|
+
if (given === undefined) return undefined;
|
|
485
|
+
const path = resolve(given);
|
|
486
|
+
if (!existsSync(path) || !statSync(path).isFile()) throw new UsageError(`--environment ${given}: nothing at ${path} — it takes a schedule file { "spec": "rigc-environment/1", "steps": [...] }`);
|
|
487
|
+
try {
|
|
488
|
+
return { path, steps: readEnvironmentSchedule(readFileSync(path, 'utf8'), given) };
|
|
489
|
+
} catch (err) {
|
|
490
|
+
if (err instanceof EnvironmentError) throw new UsageError(err.message);
|
|
491
|
+
throw err;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* The refusals of a well-formed schedule that this candidate, at this rate,
|
|
497
|
+
* would quietly not draw (issue #1416) — each one a render that would print a
|
|
498
|
+
* plausible frame set showing nothing the schedule asked for:
|
|
499
|
+
*
|
|
500
|
+
* - a skeleton with no animation, whose one frame is the setup pose, where no
|
|
501
|
+
* physics step runs;
|
|
502
|
+
* - a half the schedule changes that no physics constraint answers — none
|
|
503
|
+
* states it non-zero and no animation keys it — so every frame would draw as
|
|
504
|
+
* without the flag;
|
|
505
|
+
* - two steps on one frame of the render's grid, the first never posed;
|
|
506
|
+
* - a step after the last frame of every set drawn.
|
|
507
|
+
*
|
|
508
|
+
* `durations` are the poser's, of the sets this run draws.
|
|
509
|
+
*/
|
|
510
|
+
function environmentRefusal(given: string, steps: readonly EnvironmentStep[], skeletonText: string, fps: number, durations: ReadonlyArray<{ name: string; duration: number }>): string | null {
|
|
511
|
+
if (durations.length === 0) return `--environment ${given}: the skeleton has no animation — its one frame is the setup pose, where no physics step runs, so no environment moves it`;
|
|
512
|
+
const response = environmentResponse(skeletonText);
|
|
513
|
+
if (response.declared.length === 0) return `--environment ${given}: the skeleton declares no physics constraint, so no environment moves anything in it`;
|
|
514
|
+
for (const half of ['wind', 'gravity'] as const) {
|
|
515
|
+
const changing = steps.map((step, i) => (step[half] === undefined ? -1 : i)).filter((i) => i >= 0);
|
|
516
|
+
if (changing.length > 0 && response[half].length === 0) {
|
|
517
|
+
return (
|
|
518
|
+
`--environment ${given}: entry ${changing[0]} changes ${half}, and no physics constraint answers it — [${response.declared.map((n) => JSON.stringify(n)).join(', ')}] each state ${half} 0 and no animation keys it — ` +
|
|
519
|
+
`so every frame would draw as it does without the schedule. A constraint's own ${half} is its response (AUTHORING §4.6)`
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
const collision = environmentCollisions(steps, fps)[0];
|
|
524
|
+
if (collision !== undefined) {
|
|
525
|
+
return (
|
|
526
|
+
`--environment ${given}: entries [${collision.entries.join(', ')}] (t ${collision.entries.map((i) => steps[i].t).join(', ')}) land on one frame at ${fps} fps — frame ${collision.frame}, ` +
|
|
527
|
+
`t=${(collision.frame / fps).toFixed(4)}s — so entry ${collision.entries[0]} would never be posed. Raise --fps, or merge them`
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
const lastFrames = durations.map(({ name, duration }) => ({ name, last: Math.round(duration * fps) }));
|
|
531
|
+
const longest = lastFrames.reduce((a, b) => (b.last > a.last ? b : a));
|
|
532
|
+
const late = steps.findIndex((step) => environmentFrame(step.t, fps) > longest.last);
|
|
533
|
+
if (late >= 0) {
|
|
534
|
+
return (
|
|
535
|
+
`--environment ${given}: entry ${late}'s t ${steps[late].t} falls after the last frame of every set drawn — the longest, "${longest.name}", ends at frame ${longest.last}, ` +
|
|
536
|
+
`t=${(longest.last / fps).toFixed(4)}s at ${fps} fps — so it would change nothing`
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
return null;
|
|
540
|
+
}
|
|
541
|
+
|
|
475
542
|
/**
|
|
476
543
|
* render — the frame series, drawn by the same rasteriser `check` measures with.
|
|
477
544
|
*
|
|
@@ -487,6 +554,8 @@ export function cmdRender(flags: Record<string, string>): void {
|
|
|
487
554
|
const { skeletonPath, atlasPath, atlasText } = resolveDrawable(flags.candidate, flags.atlas);
|
|
488
555
|
const atlasDir = dirname(atlasPath);
|
|
489
556
|
const fps = readPositiveNumber(flags, 'fps', PROTOCOL_FPS, 1);
|
|
557
|
+
// Read before anything is read off the candidate, so a malformed schedule is refused before a line is printed.
|
|
558
|
+
const environment = readEnvironmentFlag(flags);
|
|
490
559
|
const maxSide = readPositiveNumber(flags, 'max', 256, 16);
|
|
491
560
|
const outRoot = resolve(flags.out ?? 'render');
|
|
492
561
|
const geometry = flags.geometry !== undefined;
|
|
@@ -532,8 +601,9 @@ export function cmdRender(flags: Record<string, string>): void {
|
|
|
532
601
|
// core poses — `skeleton.model.json` beside the pair — reads its names, its
|
|
533
602
|
// subset roster and its pages without loading spine-core at all, and with a
|
|
534
603
|
// rigc-compiled/2 document without its atlas either (issue #1020).
|
|
604
|
+
const skeletonText = readSkeletonText(skeletonPath);
|
|
535
605
|
const { choice, facts, pages } = loadCandidate(
|
|
536
|
-
{ skeletonText
|
|
606
|
+
{ skeletonText, atlasText, atlasDir, label: skeletonPath },
|
|
537
607
|
{ skeleton: skeletonPath, atlas: atlasPath },
|
|
538
608
|
posersAsked(flags),
|
|
539
609
|
);
|
|
@@ -546,12 +616,17 @@ export function cmdRender(flags: Record<string, string>): void {
|
|
|
546
616
|
// `src/render.ts` has one of its own. The inferred shape is the render one.
|
|
547
617
|
// The subset rides on the same object and `framingViewport` takes it off, so
|
|
548
618
|
// the frames draw the subset and the box is still the whole rig's.
|
|
619
|
+
// The environment rides on the same object for the same reason (issue #1416): the framing reads it, so the box holds
|
|
620
|
+
// what the frames under it draw.
|
|
549
621
|
const pose =
|
|
550
|
-
skin === undefined && subset === undefined
|
|
622
|
+
skin === undefined && subset === undefined && environment === undefined
|
|
551
623
|
? undefined
|
|
552
|
-
: { ...(skin === undefined ? {} : { skin }), ...subsetFields(subset) };
|
|
624
|
+
: { ...(skin === undefined ? {} : { skin }), ...subsetFields(subset), ...(environment === undefined ? {} : { environment: environment.steps }) };
|
|
553
625
|
if (skin !== undefined) console.log(` .. skin ${skin}`);
|
|
554
626
|
if (subset !== undefined) console.log(` .. ${subset.mode.padEnd(8)} ${subset.names.join(', ')}`);
|
|
627
|
+
if (environment !== undefined && facts.animations.length === 0) {
|
|
628
|
+
throw new UsageError(environmentRefusal(flags.environment, environment.steps, skeletonText, fps, []) ?? '');
|
|
629
|
+
}
|
|
555
630
|
|
|
556
631
|
// Rigc's own core when the candidate is a rigc build, and spine-core
|
|
557
632
|
// otherwise, or where the core refuses the input by name. Never silently:
|
|
@@ -565,6 +640,13 @@ export function cmdRender(flags: Record<string, string>): void {
|
|
|
565
640
|
// re-poses the whole input on spine-core rather than leaving half of a frame
|
|
566
641
|
// set drawn by each.
|
|
567
642
|
const posed = throughPoser(choice, (poser, roster) => {
|
|
643
|
+
// A schedule this candidate would not draw is refused before anything is posed, off the durations of the poser that
|
|
644
|
+
// poses it (issue #1416) — a fallback re-runs this with the other poser's, which the core holds equal.
|
|
645
|
+
if (environment !== undefined) {
|
|
646
|
+
const drawn = poser.animations.filter((a) => only === undefined || a.name === only);
|
|
647
|
+
const refusal = environmentRefusal(flags.environment, environment.steps, skeletonText, fps, drawn);
|
|
648
|
+
if (refusal !== null) throw new UsageError(refusal);
|
|
649
|
+
}
|
|
568
650
|
// `null` is a skeleton that posed no vertex at all. One that posed a vertex
|
|
569
651
|
// it cannot frame — Infinity or NaN — is thrown from the framing as a
|
|
570
652
|
// `GeometryError` naming the number (issue #873), and one whose every vertex
|
|
@@ -598,7 +680,7 @@ export function cmdRender(flags: Record<string, string>): void {
|
|
|
598
680
|
if (geometry) {
|
|
599
681
|
for (const [name, frames] of sampled) {
|
|
600
682
|
const animation = name === SETUP_POSE_DIR && facts.animations.length === 0 ? null : name;
|
|
601
|
-
geometryTexts.set(name, geometryText(geometryFileOf(poser, animation, fps, frames, viewport, skin)));
|
|
683
|
+
geometryTexts.set(name, geometryText(geometryFileOf(poser, animation, fps, frames, viewport, skin, environment?.steps)));
|
|
602
684
|
}
|
|
603
685
|
}
|
|
604
686
|
// The fold reading, off the very attachments the frames were drawn from, against the rest table the geometry
|
|
@@ -616,6 +698,13 @@ export function cmdRender(flags: Record<string, string>): void {
|
|
|
616
698
|
});
|
|
617
699
|
const { viewport, sampled, geometryTexts, foldReadings } = posed.value;
|
|
618
700
|
console.log(` .. poser ${posed.note}`);
|
|
701
|
+
if (environment !== undefined) {
|
|
702
|
+
const response = environmentResponse(skeletonText);
|
|
703
|
+
console.log(
|
|
704
|
+
` .. world ${environment.steps.length} step(s) from ${environment.path} (x right, y down; the runtime's wind [1, 0] and gravity [0, 1] before the first) — ` +
|
|
705
|
+
`wind answered by ${response.wind.join(', ') || 'none'}; gravity by ${response.gravity.join(', ') || 'none'}`,
|
|
706
|
+
);
|
|
707
|
+
}
|
|
619
708
|
console.log(` .. ${viewport.width}x${viewport.height}px at ${fps} fps, ${sampled.size} set(s) -> ${outRoot}`);
|
|
620
709
|
if (!facts.declaresStage) console.log(` .. ${STAGELESS_FRAMING.render}`);
|
|
621
710
|
|
|
@@ -682,6 +771,8 @@ export function cmdRender(flags: Record<string, string>): void {
|
|
|
682
771
|
// Written only when a subset was asked for, for the same reason: a render of
|
|
683
772
|
// every slot says nothing and stays the bytes it always was (issue #835).
|
|
684
773
|
...subsetFields(subset),
|
|
774
|
+
// Written only when a schedule was given (issue #1416): a render without one says nothing and stays the bytes it was.
|
|
775
|
+
...(environment === undefined ? {} : { environment: environment.steps }),
|
|
685
776
|
background: BACKGROUND,
|
|
686
777
|
// The one spelling `geometry.json` repeats, so the two files cannot state
|
|
687
778
|
// two boxes for one grid.
|
package/src/cli/shared.ts
CHANGED
|
@@ -458,6 +458,11 @@ export function meshInfluenceNote(m: CompileResult['meshes'][number]): string {
|
|
|
458
458
|
`\n influence max ${inf.maxBones} bone(s) per vertex, mean ${inf.meanBones.toFixed(2)}, ` +
|
|
459
459
|
`${inf.singleBone} of ${m.vertices} vertices (${single.toFixed(2)}%) on a single bone` +
|
|
460
460
|
(unbound.length ? `; named and bound by no vertex: ${unbound.join(', ')}` : '') +
|
|
461
|
+
(inf.reach === undefined
|
|
462
|
+
? ''
|
|
463
|
+
: `\n reach radius ${inf.reach.radius}px, ${inf.reach.shape}, ` +
|
|
464
|
+
`${inf.reach.across ? 'across entries' : 'nearest entry only'}, max ${inf.reach.max}: ` +
|
|
465
|
+
`${inf.reach.capped} of ${m.vertices} vertices had more links in reach than max (widest ${inf.reach.widest})`) +
|
|
461
466
|
`\n lattice cell ${inf.cell}px, ${inf.cols}x${inf.rows} cells, ${inf.artCells} with art, ${inf.keptCells} kept` +
|
|
462
467
|
(inf.islands > 1 || joined > 0
|
|
463
468
|
? ` (${inf.islands} island(s) joined into one outline, ${joined} cell(s) added that hold no art)`
|
|
@@ -1008,6 +1013,13 @@ const FLAG_MEANINGS: Record<string, string> = {
|
|
|
1008
1013
|
'with where they are (the world box of their posed vertices) and how big (their setup-pose area, and its share of the mesh\'s). ' +
|
|
1009
1014
|
'One line per mesh that folds; a triangle degenerate or inverted at the setup pose is said once and never counted. ' +
|
|
1010
1015
|
'A reading, not a gate. Not with --slot/--hide: it reads every mesh the pose shows',
|
|
1016
|
+
environment:
|
|
1017
|
+
'the world the rig\'s physics answers, in time: a file { "spec": "rigc-environment/1", "steps": [{ "t": <seconds>, ' +
|
|
1018
|
+
'"wind"?: [x, y], "gravity"?: [x, y] }, …] }, x right and y down, each step holding from its t until the next and set ' +
|
|
1019
|
+
'before the step that poses the first frame at or after it, on either poser. Before the first step the world is the ' +
|
|
1020
|
+
'runtime\'s, wind [1, 0] and gravity [0, 1]. frames.json and geometry.json record it, and the framing reads it. A ' +
|
|
1021
|
+
'malformed file, a half no physics constraint answers, two steps on one frame and a step after every set\'s last frame ' +
|
|
1022
|
+
'are refused by name. An inspection of what the rig offers, not a scene',
|
|
1011
1023
|
poser:
|
|
1012
1024
|
"`render`, `inspect` and `check`: which implementation poses the frames (on `check`, the candidate's) — `core` (rigc's own, reading the skeleton.model.json a " +
|
|
1013
1025
|
'build writes beside the pair) or `spine` (spine-core). Default: `core` when that document and the atlas sit ' +
|
|
@@ -1189,6 +1201,7 @@ const FLAG_VALUES: Record<string, string> = {
|
|
|
1189
1201
|
'pack-shape': 'rect|polygon',
|
|
1190
1202
|
'texture-from': '<path>',
|
|
1191
1203
|
poser: 'core|spine',
|
|
1204
|
+
environment: '<schedule.json>',
|
|
1192
1205
|
reference: '<dir|skeleton.json>',
|
|
1193
1206
|
'reference-atlas': '<path>',
|
|
1194
1207
|
bones: `<correspondence.json|${IDENTITY_CORRESPONDENCE}>`,
|
|
@@ -1594,8 +1607,9 @@ export const COMMANDS: CommandDoc[] = [
|
|
|
1594
1607
|
usage: [
|
|
1595
1608
|
'rigc render --candidate <dir | skeleton.json> [--animation <name>] [--skin <name>] [--fps 12] [--max 256] [--geometry] [--folds] [--poser core|spine] [--out render/]',
|
|
1596
1609
|
'rigc render … --slot <name[,name…]> | --hide <name[,name…]> (a subset of the slots, on the whole rig\'s grid)',
|
|
1610
|
+
'rigc render … --environment <schedule.json> (the wind and gravity the physics answers, in time)',
|
|
1597
1611
|
],
|
|
1598
|
-
flags: ['candidate', 'atlas', 'animation', 'skin', 'slot', 'hide', 'fps', 'max', 'geometry', 'folds', 'poser', 'out'],
|
|
1612
|
+
flags: ['candidate', 'atlas', 'animation', 'skin', 'slot', 'hide', 'fps', 'max', 'geometry', 'folds', 'environment', 'poser', 'out'],
|
|
1599
1613
|
overrides: {
|
|
1600
1614
|
out: { value: '<dir>', meaning: 'directory to write the frame series into (default `render/`)' },
|
|
1601
1615
|
fps: { meaning: `frames per second to sample the animation at (default ${PROTOCOL_FPS})` },
|
package/src/compile.ts
CHANGED
|
@@ -63,6 +63,8 @@ import {
|
|
|
63
63
|
type RigEvent,
|
|
64
64
|
type RigLinkedMeshAttachment,
|
|
65
65
|
type RigMeshAttachment,
|
|
66
|
+
type RigSegmentsFalloff,
|
|
67
|
+
RIG_INFLUENCE_SHAPES,
|
|
66
68
|
type RigMeshBinding,
|
|
67
69
|
type RigPathAttachment,
|
|
68
70
|
type RigRegionAttachment,
|
|
@@ -87,9 +89,12 @@ import {
|
|
|
87
89
|
ringControlAngles,
|
|
88
90
|
buildSegmentsLattice,
|
|
89
91
|
segmentShares,
|
|
92
|
+
influenceShares,
|
|
90
93
|
traceOutline,
|
|
91
94
|
type MeshBoneRef,
|
|
92
|
-
type
|
|
95
|
+
type EntrySegment,
|
|
96
|
+
type SegmentsInfluence,
|
|
97
|
+
type SegmentsFalloff,
|
|
93
98
|
type MeshFitReport,
|
|
94
99
|
type MeshGeometry,
|
|
95
100
|
type MeshVertexWeight,
|
|
@@ -6502,6 +6507,95 @@ function finitePair(v: unknown): [number, number] | null {
|
|
|
6502
6507
|
: null;
|
|
6503
6508
|
}
|
|
6504
6509
|
|
|
6510
|
+
/** The `falloff` block's four numbers, each refused by name when it cannot be one. */
|
|
6511
|
+
function segmentsFalloffOf(f: RigSegmentsFalloff, where: string): SegmentsFalloff {
|
|
6512
|
+
if (typeof f.radius !== 'number' || !Number.isFinite(f.radius) || !(f.radius > 0)) {
|
|
6513
|
+
throw new CompileError(
|
|
6514
|
+
`${where}: "falloff.radius" is ${JSON.stringify(f.radius)}; it is added to every distance before the power is ` +
|
|
6515
|
+
"taken, a positive number of the part's pixels — at 0 a vertex on a segment would carry an infinite pull",
|
|
6516
|
+
);
|
|
6517
|
+
}
|
|
6518
|
+
const power = f.power ?? SEGMENTS_DEFAULTS.power;
|
|
6519
|
+
if (typeof power !== 'number' || !Number.isFinite(power) || !(power > 0)) {
|
|
6520
|
+
throw new CompileError(
|
|
6521
|
+
`${where}: "falloff.power" is ${JSON.stringify(power)}; it is the exponent a pull falls off with distance by, ` +
|
|
6522
|
+
'a positive number — at 0 or below the far segment pulls as hard as the near one, or harder',
|
|
6523
|
+
);
|
|
6524
|
+
}
|
|
6525
|
+
const maxBones = f.maxBones ?? SEGMENTS_DEFAULTS.maxBones;
|
|
6526
|
+
if (typeof maxBones !== 'number' || !Number.isInteger(maxBones) || maxBones < 1) {
|
|
6527
|
+
throw new CompileError(
|
|
6528
|
+
`${where}: "falloff.maxBones" is ${JSON.stringify(maxBones)}; it is how many bones may pull one vertex, a ` +
|
|
6529
|
+
'whole number of at least 1',
|
|
6530
|
+
);
|
|
6531
|
+
}
|
|
6532
|
+
const minWeight = f.minWeight ?? SEGMENTS_DEFAULTS.minWeight;
|
|
6533
|
+
if (typeof minWeight !== 'number' || !Number.isFinite(minWeight) || minWeight < SEGMENTS_WEIGHT_STEP || minWeight >= 1) {
|
|
6534
|
+
throw new CompileError(
|
|
6535
|
+
`${where}: "falloff.minWeight" is ${JSON.stringify(minWeight)}; it is the share under which a bone's pull ` +
|
|
6536
|
+
`is dropped, so it lies in ${SEGMENTS_WEIGHT_STEP}..1 with 1 excluded — at 1 or above every share is dropped, ` +
|
|
6537
|
+
`and under ${SEGMENTS_WEIGHT_STEP}, one step of the 6-decimal grid weights are written on, a kept binding ` +
|
|
6538
|
+
'could be written as 0',
|
|
6539
|
+
);
|
|
6540
|
+
}
|
|
6541
|
+
return { power, radius: f.radius, maxBones, minWeight };
|
|
6542
|
+
}
|
|
6543
|
+
|
|
6544
|
+
/**
|
|
6545
|
+
* The `influence` block's four fields (issue #1415), each refused by name: none
|
|
6546
|
+
* has a default, because the reach and the cap are judgements about this
|
|
6547
|
+
* cloth and the shape decides how a link enters a vertex's blend. Stated
|
|
6548
|
+
* beside `falloff` it is refused too — the two are two models of one share.
|
|
6549
|
+
*/
|
|
6550
|
+
function segmentsInfluenceOf(
|
|
6551
|
+
generator: Extract<NonNullable<RigMeshAttachment['generator']>, { kind: 'segments' }>,
|
|
6552
|
+
where: string,
|
|
6553
|
+
): SegmentsInfluence {
|
|
6554
|
+
const spec: unknown = generator.influence;
|
|
6555
|
+
if (generator.falloff !== undefined) {
|
|
6556
|
+
throw new CompileError(
|
|
6557
|
+
`${where}: the "segments" generator states both "falloff" and "influence". Both turn a distance into a ` +
|
|
6558
|
+
'share — "falloff" as 1 / (d + radius)^power over every segment, "influence" as a pull that reaches zero at ' +
|
|
6559
|
+
'its "radius" — and a mesh is weighted by one of them: remove the other',
|
|
6560
|
+
);
|
|
6561
|
+
}
|
|
6562
|
+
if (typeof spec !== 'object' || spec === null || Array.isArray(spec)) {
|
|
6563
|
+
throw new CompileError(
|
|
6564
|
+
`${where}: "influence" is ${JSON.stringify(spec)}; it is { "radius", "across", "max", "shape" }, every one required`,
|
|
6565
|
+
);
|
|
6566
|
+
}
|
|
6567
|
+
const i = spec as Partial<Record<keyof SegmentsInfluence, unknown>>;
|
|
6568
|
+
if (typeof i.radius !== 'number' || !Number.isFinite(i.radius) || !(i.radius > 0)) {
|
|
6569
|
+
throw new CompileError(
|
|
6570
|
+
`${where}: "influence.radius" is ${JSON.stringify(i.radius) ?? 'absent'}; it is how far a link reaches, in ` +
|
|
6571
|
+
"the part's pixels, a positive number with no default — a length on this art: a link whose segment is that " +
|
|
6572
|
+
'far from a vertex or farther never pulls it',
|
|
6573
|
+
);
|
|
6574
|
+
}
|
|
6575
|
+
if (typeof i.across !== 'boolean') {
|
|
6576
|
+
throw new CompileError(
|
|
6577
|
+
`${where}: "influence.across" is ${JSON.stringify(i.across) ?? 'absent'}; it is true (every link within ` +
|
|
6578
|
+
'"radius" pulls, across the entries of "bones") or false (only the links of the entry nearest the vertex), ' +
|
|
6579
|
+
"with no default — whether a chain may pull its neighbour's cloth is the decision this field is",
|
|
6580
|
+
);
|
|
6581
|
+
}
|
|
6582
|
+
if (typeof i.max !== 'number' || !Number.isInteger(i.max) || i.max < 1) {
|
|
6583
|
+
throw new CompileError(
|
|
6584
|
+
`${where}: "influence.max" is ${JSON.stringify(i.max) ?? 'absent'}; it is how many links may pull one vertex, ` +
|
|
6585
|
+
'the strongest kept, a whole number of at least 1 with no default',
|
|
6586
|
+
);
|
|
6587
|
+
}
|
|
6588
|
+
const shape = RIG_INFLUENCE_SHAPES.find((name) => name === i.shape);
|
|
6589
|
+
if (shape === undefined) {
|
|
6590
|
+
throw new CompileError(
|
|
6591
|
+
`${where}: "influence.shape" is ${JSON.stringify(i.shape) ?? 'absent'}; one of ` +
|
|
6592
|
+
`${RIG_INFLUENCE_SHAPES.map((name) => JSON.stringify(name)).join(', ')} — how a pull falls from 1 on a link ` +
|
|
6593
|
+
'to 0 at "radius", with no default: "linear" is 1 - d/radius, "smooth" arrives at both ends with zero slope',
|
|
6594
|
+
);
|
|
6595
|
+
}
|
|
6596
|
+
return { radius: i.radius, across: i.across, max: i.max, shape };
|
|
6597
|
+
}
|
|
6598
|
+
|
|
6505
6599
|
/**
|
|
6506
6600
|
* Build a `segments` mesh: a lattice over the part's alpha, weighted by distance
|
|
6507
6601
|
* to the bone segments `bones` names.
|
|
@@ -6550,45 +6644,18 @@ function buildSegmentsAttachment(
|
|
|
6550
6644
|
);
|
|
6551
6645
|
}
|
|
6552
6646
|
|
|
6553
|
-
// -- the falloff
|
|
6647
|
+
// -- the influence (issue #1415), or else the falloff ----------------------
|
|
6648
|
+
const influence = generator.influence === undefined ? null : segmentsInfluenceOf(generator, where);
|
|
6554
6649
|
const falloffSpec: unknown = generator.falloff;
|
|
6555
|
-
if (typeof falloffSpec !== 'object' || falloffSpec === null || Array.isArray(falloffSpec)) {
|
|
6650
|
+
if (influence === null && (typeof falloffSpec !== 'object' || falloffSpec === null || Array.isArray(falloffSpec))) {
|
|
6556
6651
|
throw new CompileError(
|
|
6557
6652
|
`${where}: the "segments" generator states no "falloff" block. Its "radius" — added to every distance, in ` +
|
|
6558
6653
|
"the part's pixels — has no default, because it is a length on this art: " +
|
|
6559
|
-
'"falloff": { "radius": <pixels> } is the least it takes'
|
|
6560
|
-
|
|
6561
|
-
}
|
|
6562
|
-
const f = generator.falloff;
|
|
6563
|
-
if (typeof f.radius !== 'number' || !Number.isFinite(f.radius) || !(f.radius > 0)) {
|
|
6564
|
-
throw new CompileError(
|
|
6565
|
-
`${where}: "falloff.radius" is ${JSON.stringify(f.radius)}; it is added to every distance before the power is ` +
|
|
6566
|
-
"taken, a positive number of the part's pixels — at 0 a vertex on a segment would carry an infinite pull",
|
|
6567
|
-
);
|
|
6568
|
-
}
|
|
6569
|
-
const power = f.power ?? SEGMENTS_DEFAULTS.power;
|
|
6570
|
-
if (typeof power !== 'number' || !Number.isFinite(power) || !(power > 0)) {
|
|
6571
|
-
throw new CompileError(
|
|
6572
|
-
`${where}: "falloff.power" is ${JSON.stringify(power)}; it is the exponent a pull falls off with distance by, ` +
|
|
6573
|
-
'a positive number — at 0 or below the far segment pulls as hard as the near one, or harder',
|
|
6574
|
-
);
|
|
6575
|
-
}
|
|
6576
|
-
const maxBones = f.maxBones ?? SEGMENTS_DEFAULTS.maxBones;
|
|
6577
|
-
if (typeof maxBones !== 'number' || !Number.isInteger(maxBones) || maxBones < 1) {
|
|
6578
|
-
throw new CompileError(
|
|
6579
|
-
`${where}: "falloff.maxBones" is ${JSON.stringify(maxBones)}; it is how many bones may pull one vertex, a ` +
|
|
6580
|
-
'whole number of at least 1',
|
|
6581
|
-
);
|
|
6582
|
-
}
|
|
6583
|
-
const minWeight = f.minWeight ?? SEGMENTS_DEFAULTS.minWeight;
|
|
6584
|
-
if (typeof minWeight !== 'number' || !Number.isFinite(minWeight) || minWeight < SEGMENTS_WEIGHT_STEP || minWeight >= 1) {
|
|
6585
|
-
throw new CompileError(
|
|
6586
|
-
`${where}: "falloff.minWeight" is ${JSON.stringify(minWeight)}; it is the share under which a bone's pull ` +
|
|
6587
|
-
`is dropped, so it lies in ${SEGMENTS_WEIGHT_STEP}..1 with 1 excluded — at 1 or above every share is dropped, ` +
|
|
6588
|
-
`and under ${SEGMENTS_WEIGHT_STEP}, one step of the 6-decimal grid weights are written on, a kept binding ` +
|
|
6589
|
-
'could be written as 0',
|
|
6654
|
+
'"falloff": { "radius": <pixels> } is the least it takes (or an "influence" block, whose pull reaches zero ' +
|
|
6655
|
+
'at its own "radius")',
|
|
6590
6656
|
);
|
|
6591
6657
|
}
|
|
6658
|
+
const falloff = influence === null ? segmentsFalloffOf(generator.falloff!, where) : null;
|
|
6592
6659
|
const threshold = generator.alpha ?? SEGMENTS_DEFAULTS.alpha;
|
|
6593
6660
|
|
|
6594
6661
|
// -- the lattice -----------------------------------------------------------
|
|
@@ -6650,7 +6717,7 @@ function buildSegmentsAttachment(
|
|
|
6650
6717
|
const parentOf = new Map(ctx.bones.map((b) => [b.name, b.parent ?? null]));
|
|
6651
6718
|
const named: string[] = [];
|
|
6652
6719
|
const refs: MeshBoneRef[] = [];
|
|
6653
|
-
const segments:
|
|
6720
|
+
const segments: EntrySegment[] = [];
|
|
6654
6721
|
const boneOf = (name: unknown, at: string): { index: number; transform: BoneTransform } => {
|
|
6655
6722
|
if (typeof name !== 'string' || !parentOf.has(name)) {
|
|
6656
6723
|
throw new CompileError(`${where}: ${at} names mesh bone ${JSON.stringify(name)}, which is not in the rig's bone list`);
|
|
@@ -6680,7 +6747,7 @@ function buildSegmentsAttachment(
|
|
|
6680
6747
|
const at = `"bones"[${i}]`;
|
|
6681
6748
|
if (typeof entry === 'string') {
|
|
6682
6749
|
const { index, transform } = boneOf(entry, at);
|
|
6683
|
-
segments.push({ bone: index, a: [transform.worldX, transform.worldY], b: tipOf(entry, transform, at, false) });
|
|
6750
|
+
segments.push({ bone: index, entry: i, a: [transform.worldX, transform.worldY], b: tipOf(entry, transform, at, false) });
|
|
6684
6751
|
return;
|
|
6685
6752
|
}
|
|
6686
6753
|
if (Array.isArray(entry)) {
|
|
@@ -6692,7 +6759,7 @@ function buildSegmentsAttachment(
|
|
|
6692
6759
|
const name = link.name as string;
|
|
6693
6760
|
const origin: [number, number] = [link.transform.worldX, link.transform.worldY];
|
|
6694
6761
|
if (k === links.length - 1) {
|
|
6695
|
-
segments.push({ bone: link.index, a: origin, b: tipOf(name, link.transform, `${at}[${k}]`, true) });
|
|
6762
|
+
segments.push({ bone: link.index, entry: i, a: origin, b: tipOf(name, link.transform, `${at}[${k}]`, true) });
|
|
6696
6763
|
return;
|
|
6697
6764
|
}
|
|
6698
6765
|
const next = links[k + 1];
|
|
@@ -6714,7 +6781,7 @@ function buildSegmentsAttachment(
|
|
|
6714
6781
|
`"${nextName}" its own position, or name "${name}" alone with a "length"`,
|
|
6715
6782
|
);
|
|
6716
6783
|
}
|
|
6717
|
-
segments.push({ bone: link.index, a: origin, b });
|
|
6784
|
+
segments.push({ bone: link.index, entry: i, a: origin, b });
|
|
6718
6785
|
});
|
|
6719
6786
|
return;
|
|
6720
6787
|
}
|
|
@@ -6729,7 +6796,7 @@ function buildSegmentsAttachment(
|
|
|
6729
6796
|
`${JSON.stringify(from === null ? span.from : span.to)}; both ends are [x, y] in the part's pixels, y down`,
|
|
6730
6797
|
);
|
|
6731
6798
|
}
|
|
6732
|
-
segments.push({ bone: index, a: measuredAt(from[0], from[1]), b: measuredAt(to[0], to[1]) });
|
|
6799
|
+
segments.push({ bone: index, entry: i, a: measuredAt(from[0], from[1]), b: measuredAt(to[0], to[1]) });
|
|
6733
6800
|
return;
|
|
6734
6801
|
}
|
|
6735
6802
|
throw new CompileError(
|
|
@@ -6739,10 +6806,27 @@ function buildSegmentsAttachment(
|
|
|
6739
6806
|
});
|
|
6740
6807
|
|
|
6741
6808
|
// -- the weights -----------------------------------------------------------
|
|
6742
|
-
|
|
6809
|
+
let capped = 0;
|
|
6810
|
+
let widest = 0;
|
|
6743
6811
|
const weights: MeshVertexWeight[][] = lattice.points.map(([px, py], v) => {
|
|
6744
|
-
|
|
6812
|
+
if (influence !== null) {
|
|
6813
|
+
const reached = influenceShares(measuredAt(px, py), segments, influence);
|
|
6814
|
+
if ('weights' in reached) {
|
|
6815
|
+
if (reached.inReach > influence.max) capped++;
|
|
6816
|
+
widest = Math.max(widest, reached.inReach);
|
|
6817
|
+
return reached.weights;
|
|
6818
|
+
}
|
|
6819
|
+
throw new CompileError(
|
|
6820
|
+
`${where}: vertex ${v}, at (${px}, ${py}) in the part's pixels, is reached by no link — the nearest is ` +
|
|
6821
|
+
`"${named[reached.nearest.bone]}" at ${reached.nearest.distance.toFixed(4)}, and "influence.radius" is ` +
|
|
6822
|
+
`${influence.radius}${influence.across ? '' : ' (with "across": false, the nearest entry\'s links only)'}. ` +
|
|
6823
|
+
'A vertex needs a link, and rigc will not hand it one the reach does not: raise "radius" over that distance, ' +
|
|
6824
|
+
'or name a segment nearer this part of the art',
|
|
6825
|
+
);
|
|
6826
|
+
}
|
|
6827
|
+
const got = segmentShares(measuredAt(px, py), segments, falloff!);
|
|
6745
6828
|
if ('weights' in got) return got.weights;
|
|
6829
|
+
const { maxBones, minWeight } = falloff!;
|
|
6746
6830
|
throw new CompileError(
|
|
6747
6831
|
`${where}: vertex ${v}, at (${px}, ${py}) in the part's pixels, keeps no bone — the ${got.dropped.length} ` +
|
|
6748
6832
|
`strongest pull(s) share it as ${got.dropped.map((d) => `"${named[d.bone]}" ${d.share.toFixed(4)}`).join(', ')}, ` +
|
|
@@ -6803,6 +6887,7 @@ function buildSegmentsAttachment(
|
|
|
6803
6887
|
artCells: lattice.report.artCells,
|
|
6804
6888
|
keptCells: lattice.report.keptCells,
|
|
6805
6889
|
islands: lattice.report.islands,
|
|
6890
|
+
...(influence === null ? {} : { reach: { ...influence, capped, widest } }),
|
|
6806
6891
|
},
|
|
6807
6892
|
});
|
|
6808
6893
|
return {
|
package/src/core/raw.ts
CHANGED
|
@@ -499,8 +499,26 @@ export function poseRawAnimation(doc: CompiledDocument, animation: string, steps
|
|
|
499
499
|
* (`physicsClockStep` below, issue #1380); the track time and
|
|
500
500
|
* the animation time are the steps' running sum either way.
|
|
501
501
|
*/
|
|
502
|
-
export function poseRawAnimationEach(doc: CompiledDocument, animation: string, steps: readonly number[], plant: TimelinePlant, reset: RawReset, visit: (pose: RawPose, index: number) => void, clock?: (dt: number) => number): void {
|
|
503
|
-
walkEach(doc, animation, steps, plant, reset, clock === undefined ? RAW_MODE : { ...RAW_MODE, clock: (dt) => clock(dt) }, RAW_SHAPE, visit);
|
|
502
|
+
export function poseRawAnimationEach(doc: CompiledDocument, animation: string, steps: readonly number[], plant: TimelinePlant, reset: RawReset, visit: (pose: RawPose, index: number) => void, clock?: (dt: number) => number, environment?: StepEnvironment): void {
|
|
503
|
+
walkEach(doc, animation, steps, plant, reset, clock === undefined ? RAW_MODE : { ...RAW_MODE, clock: (dt) => clock(dt) }, RAW_SHAPE, visit, environment);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* What a batch walk sets on its physics before a pose (issue #1416, part C):
|
|
508
|
+
* pose `index`'s change to the world every physics constraint answers — either
|
|
509
|
+
* half, merged over the one standing as a live track's `setEnvironment` merges
|
|
510
|
+
* it (`setEnvironmentOn`) — or `undefined` for none. Pose 0 is the reset pose;
|
|
511
|
+
* pose `k + 1` is the one step `k` makes. `rigc render --environment` is the
|
|
512
|
+
* caller (`src/render_core.ts`), and spine-core's poser sets the same values on
|
|
513
|
+
* its `Skeleton` before the same update (`src/render.ts`), so the two posers
|
|
514
|
+
* see one world at every step.
|
|
515
|
+
*/
|
|
516
|
+
export type StepEnvironment = (index: number) => Partial<PhysicsEnvironment> | undefined;
|
|
517
|
+
|
|
518
|
+
/** `environment`'s change for pose `index`, set on `ctx` as a live track sets one — validated and merged by `setEnvironmentOn`. */
|
|
519
|
+
function setStepEnvironment(ctx: PhysicsStepContext, environment: StepEnvironment | undefined, index: number): void {
|
|
520
|
+
const change = environment?.(index);
|
|
521
|
+
if (change !== undefined) setEnvironmentOn(ctx, change, `the walk's, at pose ${index},`);
|
|
504
522
|
}
|
|
505
523
|
|
|
506
524
|
/**
|
|
@@ -755,10 +773,12 @@ function animationOf(doc: CompiledDocument, animation: string): CompiledDocument
|
|
|
755
773
|
* `reset: 'setup'` pose 0 is the setup pose, reset; under `animation` it is the
|
|
756
774
|
* animation applied at 0 and reset there, a step of 0 (`stepWalk`'s `first`).
|
|
757
775
|
*/
|
|
758
|
-
function openWalk<P>(doc: CompiledDocument, anim: CompiledDocument['animations'][number], plant: TimelinePlant, reset: RawReset, mode: WalkMode, shape: PoseShape<P>, adjust?: LocalAdjust): { track: TrackState<P>; first: P } {
|
|
776
|
+
function openWalk<P>(doc: CompiledDocument, anim: CompiledDocument['animations'][number], plant: TimelinePlant, reset: RawReset, mode: WalkMode, shape: PoseShape<P>, adjust?: LocalAdjust, before?: (ctx: PhysicsStepContext) => void): { track: TrackState<P>; first: P } {
|
|
759
777
|
const why = constraintsAbsentWhy(doc) ?? pathAnimationsWhy(doc) ?? steppedPreviousPassWhy(doc);
|
|
760
778
|
if (why !== null) throw new CoreInputError(`the raw walk leaves the bones out: ${why}`);
|
|
761
779
|
const ctx = freshStepContext(plant.physicsStep);
|
|
780
|
+
// What the caller sets before pose 0 (issue #1416, part C): the batch walk's environment for the reset pose.
|
|
781
|
+
before?.(ctx);
|
|
762
782
|
if (mode.loop && mode.wrapResets !== false) ctx.loop = true;
|
|
763
783
|
const track: TrackState<P> = { doc, anim, duration: anim.timelines.duration, mode, plant, raw: { ...plant, ...roundPlant(mode) }, resolve: plant.shown ?? shownAttachment, shape, ctx, adjust, trackTime: 0, last: -1 };
|
|
764
784
|
if (reset === 'setup') {
|
|
@@ -890,14 +910,17 @@ export function openRawTracksPath(doc: CompiledDocument, plant: TimelinePlant):
|
|
|
890
910
|
}
|
|
891
911
|
|
|
892
912
|
/** `walkIn`'s walk, each pose assembled in `shape` and handed to `visit` in order as it is posed (`poseRawAnimationEach`): the walk opened, then stepped over `steps`. */
|
|
893
|
-
function walkEach<P>(doc: CompiledDocument, animation: string, steps: readonly number[], plant: TimelinePlant, reset: RawReset, mode: WalkMode, shape: PoseShape<P>, visit: (pose: P, index: number) => void): void {
|
|
913
|
+
function walkEach<P>(doc: CompiledDocument, animation: string, steps: readonly number[], plant: TimelinePlant, reset: RawReset, mode: WalkMode, shape: PoseShape<P>, visit: (pose: P, index: number) => void, environment?: StepEnvironment): void {
|
|
894
914
|
const anim = animationOf(doc, animation);
|
|
895
915
|
const bad = steps.findIndex((s) => !Number.isFinite(s) || s < 0);
|
|
896
916
|
if (bad >= 0) throw new CoreInputError(`step ${bad} is ${steps[bad]}, not a finite time at or above 0`);
|
|
897
|
-
const { track, first } = openWalk(doc, anim, plant, reset, mode, shape);
|
|
917
|
+
const { track, first } = openWalk(doc, anim, plant, reset, mode, shape, undefined, environment === undefined ? undefined : (ctx) => setStepEnvironment(ctx, environment, 0));
|
|
898
918
|
let visited = 0;
|
|
899
919
|
visit(first, visited++);
|
|
900
|
-
for (const dt of steps)
|
|
920
|
+
for (const dt of steps) {
|
|
921
|
+
setStepEnvironment(track.ctx, environment, visited);
|
|
922
|
+
visit(stepWalk(track, dt), visited++);
|
|
923
|
+
}
|
|
901
924
|
}
|
|
902
925
|
|
|
903
926
|
/**
|