spine-rigc 2.55.0 → 2.57.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 +4 -2
- package/docs/AUTHORING.md +549 -8
- package/package.json +1 -1
- package/skills/rigc/SKILL.md +4 -1
- package/src/anglerange.ts +190 -0
- package/src/cli/core_commands.ts +218 -13
- package/src/cli/shared.ts +160 -9
- package/src/compile.ts +69 -8
- package/src/core/entry.ts +6 -1
- package/src/deformgen.ts +333 -2
- package/src/meshsplit.ts +683 -0
- 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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spine-rigc",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.57.0",
|
|
4
4
|
"description": "AI-authored 2D rigging and animation, verified before it is written. rigc compiles a rig spec into skeleton data in the Spine 4.3 format, gates it with named assertions held to a spine-core round trip's verdicts, and emits nothing that fails. Ships as an agent skill.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/skills/rigc/SKILL.md
CHANGED
|
@@ -100,7 +100,10 @@ row for each.
|
|
|
100
100
|
parts were not kept, and writes only after every region and the skeleton are
|
|
101
101
|
shown unchanged (`--accept-skeleton-differences` writes a rebuild whose skeleton
|
|
102
102
|
differs, every difference printed; `--stage-box <slot>` reads a build's stage box)
|
|
103
|
-
— AUTHORING §0.4.
|
|
103
|
+
— AUTHORING §0.4. A mesh refused because its triangles are several pieces that
|
|
104
|
+
share no vertex: `rigc split --rig … --motion … --slot <slot> --out <dir>` writes
|
|
105
|
+
both specs back with one mesh per piece and every deform key renumbered — build
|
|
106
|
+
that — AUTHORING §3.4.11.
|
|
104
107
|
7. Every finished unit ends with `rigc preview --candidate <out>`, and the report
|
|
105
108
|
names the `.html` it wrote. The hand-off to a person is part of the work.
|
|
106
109
|
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How far one bone turns against another over an animation — the reading behind
|
|
3
|
+
* `rigc render --angle-range <bone>:<relative-to>[,…]` (issue #1414).
|
|
4
|
+
*
|
|
5
|
+
* ⭐ **A reading, not a gate**, as `--folds` and `--pass-through` are. A physics
|
|
6
|
+
* value has no scale an author can see: §4.6 lists `inertia`, `strength` and the
|
|
7
|
+
* rest with their bounds, and nothing said what `inertia` 0.5 does to a 54-unit
|
|
8
|
+
* chain under a hip swing — the first try on a real rig was four times too big.
|
|
9
|
+
* The answer is a number the pose already holds: how far the link turned against
|
|
10
|
+
* the bone it hangs from. So this reads it off the frames `render` draws and
|
|
11
|
+
* decides nothing.
|
|
12
|
+
*
|
|
13
|
+
* ## What the range is
|
|
14
|
+
*
|
|
15
|
+
* At each frame the bones are posed by whichever poser draws the frames, and each
|
|
16
|
+
* bone's **world direction** is that poser's own `rotationX` — the direction its x
|
|
17
|
+
* axis points, degrees counter-clockwise, y up (`BoneSnapshot`), never a second
|
|
18
|
+
* opinion recomputed from the matrix here. The relative angle is
|
|
19
|
+
*
|
|
20
|
+
* rotationX(bone) − rotationX(relative-to)
|
|
21
|
+
*
|
|
22
|
+
* taken into (−180, 180] on the first frame read, and then **followed**: each
|
|
23
|
+
* later frame's difference is moved by whole turns to within half a turn of the
|
|
24
|
+
* frame before it. So a link that sweeps 200° reads 200°, not −160°, and one that
|
|
25
|
+
* sways through ±180° does not read a jump of a whole turn. Following assumes no
|
|
26
|
+
* two consecutive frames are half a turn or more apart, which a frame grid that
|
|
27
|
+
* coarse could not show anyway.
|
|
28
|
+
*
|
|
29
|
+
* ⚠️ It is the angle **drawn**, not the bone's local `rotation`. The two agree for
|
|
30
|
+
* a child of the other bone when the parent carries no non-uniform scale or shear;
|
|
31
|
+
* otherwise they differ, and the drawn one is what a viewer sees.
|
|
32
|
+
*
|
|
33
|
+
* The range is over the frames on the render's own grid — `--fps`, from t = 0 to
|
|
34
|
+
* the animation's last frame — so a peak between two frames is not read. A bone
|
|
35
|
+
* the posed skin leaves unposed, or one scaled to nothing, has no direction: its
|
|
36
|
+
* world matrix is zero, and the reading is refused naming the bone and the frame
|
|
37
|
+
* rather than read as 0°.
|
|
38
|
+
*
|
|
39
|
+
* Pure: no clock, no randomness, no runtime. The bone snapshots are handed in by
|
|
40
|
+
* whichever poser drew the frames.
|
|
41
|
+
*/
|
|
42
|
+
import type { BoneSnapshot } from './render_shared.ts';
|
|
43
|
+
|
|
44
|
+
/** One pair as the flag names it: the bone whose angle is read, and the bone it is read against. */
|
|
45
|
+
export interface BonePair {
|
|
46
|
+
bone: string;
|
|
47
|
+
relativeTo: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A malformed `--angle-range` value, or a pose the reading has no angle for — said by name. */
|
|
51
|
+
export class AngleRangeError extends Error {}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* `--angle-range`'s value — `<bone>:<relative-to>[,<bone>:<relative-to>…]` —
|
|
55
|
+
* read against the skeleton's bones, or refused naming the entry: an empty
|
|
56
|
+
* entry, one without exactly one `:`, an empty name, a bone the skeleton does not
|
|
57
|
+
* have (with the bones it does), a bone read against itself (always 0°), and a
|
|
58
|
+
* pair named twice.
|
|
59
|
+
*/
|
|
60
|
+
export function readBonePairs(raw: string, bones: readonly string[]): BonePair[] {
|
|
61
|
+
const form = '<bone>:<relative-to>[,<bone>:<relative-to>…]';
|
|
62
|
+
const pairs: BonePair[] = [];
|
|
63
|
+
const entries = raw.split(',');
|
|
64
|
+
for (let i = 0; i < entries.length; i++) {
|
|
65
|
+
const entry = entries[i].trim();
|
|
66
|
+
if (entry === '') throw new AngleRangeError(`--angle-range ${JSON.stringify(raw)}: entry ${i} is empty — it takes ${form}`);
|
|
67
|
+
const parts = entry.split(':');
|
|
68
|
+
if (parts.length !== 2) {
|
|
69
|
+
throw new AngleRangeError(`--angle-range: entry ${i} ${JSON.stringify(entry)} has ${parts.length - 1} ":" — it takes ${form}, one ":" per pair`);
|
|
70
|
+
}
|
|
71
|
+
const [bone, relativeTo] = parts.map((p) => p.trim());
|
|
72
|
+
for (const [role, name] of [['bone', bone], ['relative-to bone', relativeTo]] as const) {
|
|
73
|
+
if (name === '') throw new AngleRangeError(`--angle-range: entry ${i} ${JSON.stringify(entry)} names no ${role} — it takes ${form}`);
|
|
74
|
+
if (!bones.includes(name)) {
|
|
75
|
+
throw new AngleRangeError(`--angle-range: entry ${i} names ${role} ${JSON.stringify(name)}, which this skeleton does not have; its bones are [${bones.join(', ')}]`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (bone === relativeTo) {
|
|
79
|
+
throw new AngleRangeError(`--angle-range: entry ${i} reads bone ${JSON.stringify(bone)} against itself, which is 0° on every frame — name a second bone`);
|
|
80
|
+
}
|
|
81
|
+
const again = pairs.findIndex((p) => p.bone === bone && p.relativeTo === relativeTo);
|
|
82
|
+
if (again >= 0) throw new AngleRangeError(`--angle-range: entry ${i} names "${bone}:${relativeTo}" again — entry ${again} already reads it`);
|
|
83
|
+
pairs.push({ bone, relativeTo });
|
|
84
|
+
}
|
|
85
|
+
return pairs;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** One sampled frame: its index, its time, and every bone's world transform. */
|
|
89
|
+
export interface AngleFrame {
|
|
90
|
+
index: number;
|
|
91
|
+
time: number;
|
|
92
|
+
bones: readonly BoneSnapshot[];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** One extreme of a range: the angle, degrees, and the frame that reached it first. */
|
|
96
|
+
export interface AngleExtreme {
|
|
97
|
+
degrees: number;
|
|
98
|
+
index: number;
|
|
99
|
+
time: number;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** One pair's reading over one set. */
|
|
103
|
+
export interface AngleRangeReading {
|
|
104
|
+
bone: string;
|
|
105
|
+
relativeTo: string;
|
|
106
|
+
/** Frames read — every frame of the set. */
|
|
107
|
+
frames: number;
|
|
108
|
+
low: AngleExtreme;
|
|
109
|
+
high: AngleExtreme;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Wrong readings the suite plants to see its controls go red (issue #1414):
|
|
114
|
+
* `worldAngle` reads the bone's own world direction and ignores the bone it is
|
|
115
|
+
* read against; `noFollow` leaves each frame's difference in (−180, 180] rather
|
|
116
|
+
* than following it from the frame before.
|
|
117
|
+
*/
|
|
118
|
+
export interface AnglePlant {
|
|
119
|
+
worldAngle?: true;
|
|
120
|
+
noFollow?: true;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** `d` taken into (−180, 180]. */
|
|
124
|
+
function wrapped(d: number): number {
|
|
125
|
+
let r = d % 360;
|
|
126
|
+
if (r <= -180) r += 360;
|
|
127
|
+
if (r > 180) r -= 360;
|
|
128
|
+
return r;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** A world matrix with no direction — every entry zero: an unposed bone, or one scaled to nothing. */
|
|
132
|
+
function directionless(b: BoneSnapshot): boolean {
|
|
133
|
+
return b.a === 0 && b.b === 0 && b.c === 0 && b.d === 0;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Each pair's range over `frames`, in the pairs' order — refused by
|
|
138
|
+
* `AngleRangeError`, naming the bone and the frame, where a bone has no
|
|
139
|
+
* direction (`directionless`), and naming the bone where a frame carries no
|
|
140
|
+
* snapshot of it at all (frames sampled without `{ bones: true }`).
|
|
141
|
+
*/
|
|
142
|
+
export function angleRangesOf(pairs: readonly BonePair[], frames: readonly AngleFrame[], where: string, plant: AnglePlant = {}): AngleRangeReading[] {
|
|
143
|
+
return pairs.map(({ bone, relativeTo }) => {
|
|
144
|
+
let previous: number | null = null;
|
|
145
|
+
let low: AngleExtreme | null = null;
|
|
146
|
+
let high: AngleExtreme | null = null;
|
|
147
|
+
for (const frame of frames) {
|
|
148
|
+
const at = (name: string): BoneSnapshot => {
|
|
149
|
+
const found = frame.bones.find((b) => b.name === name);
|
|
150
|
+
if (found === undefined) throw new AngleRangeError(`--angle-range, ${where} frame ${frame.index}: no world transform for bone ${JSON.stringify(name)} was sampled`);
|
|
151
|
+
if (directionless(found)) {
|
|
152
|
+
throw new AngleRangeError(
|
|
153
|
+
`--angle-range, ${where} frame ${frame.index} (t=${frame.time.toFixed(4)}s): bone ${JSON.stringify(name)} has no direction — its world matrix is zero ` +
|
|
154
|
+
'(a bone the posed skin leaves unposed, or one scaled to nothing), so it has no angle to read',
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
return found;
|
|
158
|
+
};
|
|
159
|
+
const a = at(bone);
|
|
160
|
+
const b = at(relativeTo);
|
|
161
|
+
const difference = plant.worldAngle === true ? a.rotationX : a.rotationX - b.rotationX;
|
|
162
|
+
let angle = wrapped(difference);
|
|
163
|
+
if (previous !== null && plant.noFollow !== true) angle += 360 * Math.round((previous - angle) / 360);
|
|
164
|
+
previous = angle;
|
|
165
|
+
if (low === null || angle < low.degrees) low = { degrees: angle, index: frame.index, time: frame.time };
|
|
166
|
+
if (high === null || angle > high.degrees) high = { degrees: angle, index: frame.index, time: frame.time };
|
|
167
|
+
}
|
|
168
|
+
if (low === null || high === null) throw new AngleRangeError(`--angle-range, ${where}: no frame was sampled`);
|
|
169
|
+
return { bone, relativeTo, frames: frames.length, low, high };
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Degrees to four places, with a rounding to zero printed as `0.0000` rather than `-0.0000`. */
|
|
174
|
+
export function degreesText(d: number): string {
|
|
175
|
+
const text = d.toFixed(4);
|
|
176
|
+
return text === '-0.0000' ? '0.0000' : text;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The printed line: the set, the pair, the low and the high with the frame each
|
|
181
|
+
* was first reached on, the span, and the grid it was read on.
|
|
182
|
+
*/
|
|
183
|
+
export function angleRangeLine(animation: string | null, fps: number, r: AngleRangeReading): string {
|
|
184
|
+
const where = animation === null ? 'the setup pose' : JSON.stringify(animation);
|
|
185
|
+
return (
|
|
186
|
+
`${where}: bone ${JSON.stringify(r.bone)} against ${JSON.stringify(r.relativeTo)} ranges ${degreesText(r.low.degrees)}° to ${degreesText(r.high.degrees)}° ` +
|
|
187
|
+
`(a span of ${degreesText(r.high.degrees - r.low.degrees)}°) over ${r.frames} frame(s) at ${fps} fps; ` +
|
|
188
|
+
`lowest at frame ${r.low.index} (t=${r.low.time.toFixed(4)}s), highest at frame ${r.high.index} (t=${r.high.time.toFixed(4)}s)`
|
|
189
|
+
);
|
|
190
|
+
}
|
package/src/cli/core_commands.ts
CHANGED
|
@@ -21,7 +21,8 @@ import { chainFitLines, type ChainFitOptions, estimateChainFit } from '../chainf
|
|
|
21
21
|
import { checkLines, CheckPlates } from '../check.ts';
|
|
22
22
|
import { writeCheckPictures } from '../checkpics.ts';
|
|
23
23
|
import { compile, type CompileOptions, droppedStateReason, relativeImagesPath } from '../compile.ts';
|
|
24
|
-
import { CoreInputError } from '../core/index.ts';
|
|
24
|
+
import { CoreInputError, readModel } from '../core/index.ts';
|
|
25
|
+
import { leverFractionWhy, leverHeader, leverLines, leverReading, type LeverReading, type WhirlAnimation, WHIRL_HEADER, whirlLines, whirlReading } from '../whirl.ts';
|
|
25
26
|
import { surveyOfBuild } from '../deformbuild.ts';
|
|
26
27
|
import type { DeformSurvey } from '../deformsurvey.ts';
|
|
27
28
|
import { deformReportBlock } from '../deformreport.ts';
|
|
@@ -30,12 +31,16 @@ import { findingLine, ingest, type IngestFinding, IngestSpecRefused, type Ingest
|
|
|
30
31
|
import { PARSER_DEFAULTS, parserReading } from '../keyorder.ts';
|
|
31
32
|
import { MODEL_DOCUMENT_FILE, modelDocument } from '../model.ts';
|
|
32
33
|
import { parseMotionSpec } from '../motion.ts';
|
|
34
|
+
import { splitMesh, SplitError } from '../meshsplit.ts';
|
|
35
|
+
import { parseRigSpec } from '../rig.ts';
|
|
33
36
|
import { type InspectCompare, type InspectFrame, InspectError, inspectCandidate, INSPECT_DEFAULT_FPS, legendText, readBonePatterns, readShow, readTimes, readZoom } from '../inspect.ts';
|
|
34
37
|
import { FOLDS_FILE, foldLine, foldsFileOf, foldsOf, foldsText, setupFaultLine } from '../folds.ts';
|
|
38
|
+
import { AngleRangeError, angleRangeLine, type AngleRangeReading, angleRangesOf, type BonePair, readBonePairs } from '../anglerange.ts';
|
|
39
|
+
import { outsideLine, outsideViewport, pinnedRecord, readViewportBox, viewportOfFramesFile, ViewportError } from '../viewportpin.ts';
|
|
35
40
|
import { PASS_THROUGH_FILE, passFrameLine, type PassPair, passSummaryLine, passThroughFileOf, passThroughOf, passThroughText, PassThroughError, readPassPairs } from '../passthrough.ts';
|
|
36
41
|
import { environmentCollisions, environmentFrame, EnvironmentError, environmentResponse, readEnvironmentSchedule, type EnvironmentStep } from '../environment.ts';
|
|
37
42
|
import { estimatePose, poseLines, type PoseOptions } from '../pose.ts';
|
|
38
|
-
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';
|
|
43
|
+
import { BACKGROUND, contactSheet, type Frame, FRAMES_SIDECAR, FRAMES_SPEC, frameSetDir, 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, type Viewport, viewportFor } from '../render_shared.ts';
|
|
39
44
|
import { type CompileResult } from '../types.ts';
|
|
40
45
|
import { attachmentRegionJoins } from '../region_joins.ts';
|
|
41
46
|
import { cmdRepack } from './repack.ts';
|
|
@@ -556,6 +561,59 @@ function readPassThroughFlag(raw: string | undefined, facts: SkeletonFacts): Pas
|
|
|
556
561
|
}
|
|
557
562
|
}
|
|
558
563
|
|
|
564
|
+
/**
|
|
565
|
+
* `--angle-range`'s pairs, read against the skeleton's bones (issue #1414,
|
|
566
|
+
* `../anglerange.ts`), or refused as a usage error before anything is posed: a
|
|
567
|
+
* malformed list, an empty name, a bone the skeleton does not have, a bone read
|
|
568
|
+
* against itself, a pair named twice — each by its entry. `undefined` without the flag.
|
|
569
|
+
*/
|
|
570
|
+
function readAngleRangeFlag(raw: string | undefined, facts: SkeletonFacts): BonePair[] | undefined {
|
|
571
|
+
if (raw === undefined) return undefined;
|
|
572
|
+
try {
|
|
573
|
+
return readBonePairs(raw, facts.bones.map((bone) => bone.name));
|
|
574
|
+
} catch (err) {
|
|
575
|
+
if (err instanceof AngleRangeError) throw new UsageError(err.message);
|
|
576
|
+
throw err;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/** A box `render` was given rather than fitting one (issue #1414): the box flag's numbers, or an earlier render's grid. */
|
|
581
|
+
type GivenViewport =
|
|
582
|
+
| { by: '--viewport'; box: { x: number; y: number; width: number; height: number } }
|
|
583
|
+
| { by: '--viewport-from'; path: string; viewport: Viewport };
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* `--viewport` and `--viewport-from`, read before anything is read off the
|
|
587
|
+
* candidate (`../viewportpin.ts`): the two together are refused, and so is
|
|
588
|
+
* `--max` beside `--viewport-from`, whose file already fixes the pixel size; a
|
|
589
|
+
* malformed box, a path with no `frames.json` and a file whose grid is not one
|
|
590
|
+
* are refused by name. `undefined` without either flag.
|
|
591
|
+
*/
|
|
592
|
+
function readGivenViewport(flags: Record<string, string>): GivenViewport | undefined {
|
|
593
|
+
const box = flags.viewport;
|
|
594
|
+
const from = flags['viewport-from'];
|
|
595
|
+
if (box !== undefined && from !== undefined) {
|
|
596
|
+
throw new UsageError('--viewport and --viewport-from both give the box the frames are of; give one of them');
|
|
597
|
+
}
|
|
598
|
+
try {
|
|
599
|
+
if (box !== undefined) return { by: '--viewport', box: readViewportBox(box) };
|
|
600
|
+
if (from === undefined) return undefined;
|
|
601
|
+
if (flags.max !== undefined) {
|
|
602
|
+
throw new UsageError(
|
|
603
|
+
`--viewport-from takes no --max: the frames.json it reads fixes the pixel size as well as the box, so --max ${flags.max} ` +
|
|
604
|
+
'would put this render on another grid. Give --viewport <x,y,w,h> with --max for a box at another size',
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
const given = resolve(from);
|
|
608
|
+
const path = existsSync(given) && statSync(given).isDirectory() ? join(given, FRAMES_SIDECAR) : given;
|
|
609
|
+
if (!existsSync(path)) throw new UsageError(`--viewport-from ${from}: no ${FRAMES_SIDECAR} at ${path} — it takes an earlier render's ${FRAMES_SIDECAR}, or the directory \`render --out\` wrote it into`);
|
|
610
|
+
return { by: '--viewport-from', path, viewport: viewportOfFramesFile(readJsonFile(path), path) };
|
|
611
|
+
} catch (err) {
|
|
612
|
+
if (err instanceof ViewportError) throw new UsageError(err.message);
|
|
613
|
+
throw err;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
559
617
|
/**
|
|
560
618
|
* render — the frame series, drawn by the same rasteriser `check` measures with.
|
|
561
619
|
*
|
|
@@ -574,6 +632,8 @@ export function cmdRender(flags: Record<string, string>): void {
|
|
|
574
632
|
// Read before anything is read off the candidate, so a malformed schedule is refused before a line is printed.
|
|
575
633
|
const environment = readEnvironmentFlag(flags);
|
|
576
634
|
const maxSide = readPositiveNumber(flags, 'max', 256, 16);
|
|
635
|
+
// A box given rather than fitted (issue #1414), read before a line is printed, so a malformed one is refused first.
|
|
636
|
+
const given = readGivenViewport(flags);
|
|
577
637
|
const outRoot = resolve(flags.out ?? 'render');
|
|
578
638
|
const geometry = flags.geometry !== undefined;
|
|
579
639
|
// The fold reading (issue #1383) is its own flag rather than a part of --geometry, so a caller that asked for
|
|
@@ -643,6 +703,8 @@ export function cmdRender(flags: Record<string, string>): void {
|
|
|
643
703
|
const skin = readSkinFlag(flags, [...facts.skins]);
|
|
644
704
|
const subset = readSlotSubsetFlags(flags, facts, skin);
|
|
645
705
|
const passPairs = readPassThroughFlag(passThrough, facts);
|
|
706
|
+
// The angle reading (issue #1414) reads bones, which a subset of the drawn slots does not change, so it takes --slot/--hide.
|
|
707
|
+
const anglePairs = readAngleRangeFlag(flags['angle-range'], facts);
|
|
646
708
|
// One object, so the framing and the frames cannot be posed under two
|
|
647
709
|
// different skins — which would frame one shot with another shot's box.
|
|
648
710
|
// Not annotated `PoseOptions`: that name is `src/pose.ts`'s in this file, and
|
|
@@ -686,14 +748,22 @@ export function cmdRender(flags: Record<string, string>): void {
|
|
|
686
748
|
// sits at one point — every drawn bone unposed by the skin, or collapsed — as
|
|
687
749
|
// an `UnframeablePoseError` (issue #997); neither reaches this. The roster is what
|
|
688
750
|
// lets the second name the skins that pose a bone, whichever poser draws.
|
|
689
|
-
const
|
|
690
|
-
if (!
|
|
751
|
+
const fitted = framingViewport(poser, maxSide, pose, roster);
|
|
752
|
+
if (!fitted) {
|
|
691
753
|
throw new UsageError(
|
|
692
754
|
`${skeletonPath} posed no drawable attachment in any animation or in its setup pose${
|
|
693
755
|
skin === undefined ? '' : ` under skin ${JSON.stringify(skin)}`
|
|
694
756
|
} — there is nothing to draw`,
|
|
695
757
|
);
|
|
696
758
|
}
|
|
759
|
+
// ⭐ A given box replaces the fitted one, and the framing still runs: it is what refuses a pose with nothing to draw,
|
|
760
|
+
// one that is not finite and one with no extent, and a box given over such a pose would draw it in silence.
|
|
761
|
+
const viewport =
|
|
762
|
+
given === undefined
|
|
763
|
+
? fitted
|
|
764
|
+
: given.by === '--viewport'
|
|
765
|
+
? viewportFor(given.box.x, given.box.y, given.box.x + given.box.width, given.box.y + given.box.height, maxSide)
|
|
766
|
+
: given.viewport;
|
|
697
767
|
|
|
698
768
|
// `sampleAll` covers the skeleton with no animation at all, which files its one
|
|
699
769
|
// setup-pose frame under the reserved name. Narrowing to one animation reuses
|
|
@@ -702,7 +772,10 @@ export function cmdRender(flags: Record<string, string>): void {
|
|
|
702
772
|
// `--geometry` rides on the SAME call (issue #864): the bones and the whole
|
|
703
773
|
// attachments are read off the skeleton at the step that drew each frame, so
|
|
704
774
|
// the export's grid is this frame set's by construction.
|
|
705
|
-
|
|
775
|
+
// The bones ride along for --geometry and --angle-range, the whole attachments for --geometry and the two readings off them.
|
|
776
|
+
const wantsBones = geometry || anglePairs !== undefined;
|
|
777
|
+
const wantsGeometry = geometry || folds || passPairs !== undefined;
|
|
778
|
+
const sampling = !wantsBones && !wantsGeometry ? pose : { ...pose, ...(wantsBones ? { bones: true } : {}), ...(wantsGeometry ? { geometry: true } : {}) };
|
|
706
779
|
const sampled: Map<string, Frame[]> =
|
|
707
780
|
only === undefined
|
|
708
781
|
? sampleAll(poser, fps, sampling)
|
|
@@ -755,9 +828,22 @@ export function cmdRender(flags: Record<string, string>): void {
|
|
|
755
828
|
}
|
|
756
829
|
}
|
|
757
830
|
}
|
|
758
|
-
|
|
831
|
+
// The angle reading, off the very bones the frames were posed with — one per set, every pair the flag names.
|
|
832
|
+
const angleReadings = new Map<string, AngleRangeReading[]>();
|
|
833
|
+
if (anglePairs !== undefined) {
|
|
834
|
+
for (const [name, frames] of sampled) {
|
|
835
|
+
const read = frames.map((frame) => ({ index: frame.index, time: frame.time, bones: frame.bones ?? [] }));
|
|
836
|
+
try {
|
|
837
|
+
angleReadings.set(name, angleRangesOf(anglePairs, read, JSON.stringify(name)));
|
|
838
|
+
} catch (err) {
|
|
839
|
+
if (err instanceof AngleRangeError) throw new UsageError(err.message);
|
|
840
|
+
throw err;
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
return { viewport, fitted, sampled, geometryTexts, foldReadings, passReadings, angleReadings };
|
|
759
845
|
});
|
|
760
|
-
const { viewport, sampled, geometryTexts, foldReadings, passReadings } = posed.value;
|
|
846
|
+
const { viewport, fitted, sampled, geometryTexts, foldReadings, passReadings, angleReadings } = posed.value;
|
|
761
847
|
console.log(` .. poser ${posed.note}`);
|
|
762
848
|
if (environment !== undefined) {
|
|
763
849
|
const response = environmentResponse(skeletonText);
|
|
@@ -768,6 +854,17 @@ export function cmdRender(flags: Record<string, string>): void {
|
|
|
768
854
|
}
|
|
769
855
|
console.log(` .. ${viewport.width}x${viewport.height}px at ${fps} fps, ${sampled.size} set(s) -> ${outRoot}`);
|
|
770
856
|
if (!facts.declaresStage) console.log(` .. ${STAGELESS_FRAMING.render}`);
|
|
857
|
+
// A given box (issue #1414): said with the box it replaced, and every slot it leaves part of out, by set and frame.
|
|
858
|
+
const outside = given === undefined ? [] : outsideViewport(sampled, viewport);
|
|
859
|
+
if (given !== undefined) {
|
|
860
|
+
const f = (n: number): string => String(Number(n.toFixed(4)));
|
|
861
|
+
const box = (v: Viewport): string => `world x ${f(v.minX)}..${f(v.maxX)}, y ${f(v.minY)}..${f(v.maxY)}`;
|
|
862
|
+
console.log(
|
|
863
|
+
` .. viewport given by ${given.by}${given.by === '--viewport-from' ? ` ${given.path}` : ''}: ${box(viewport)} at ${f(viewport.scale)} px per unit, y up ` +
|
|
864
|
+
`(fitted, the box would be ${box(fitted)}) — ${outside.length === 0 ? 'every drawn piece of every frame is inside it' : `${outside.length} slot(s) reach past it, named below`}`,
|
|
865
|
+
);
|
|
866
|
+
for (const reading of outside) console.log(` .. outside ${outsideLine(reading, fps)}`);
|
|
867
|
+
}
|
|
771
868
|
|
|
772
869
|
mkdirSync(outRoot, { recursive: true });
|
|
773
870
|
const sets: FrameSet[] = [];
|
|
@@ -776,7 +873,7 @@ export function cmdRender(flags: Record<string, string>): void {
|
|
|
776
873
|
for (const [name, frames] of sampled) {
|
|
777
874
|
// Same naming as a reference render: the protocol rate says nothing, any
|
|
778
875
|
// other rate says itself, so two rates of one animation sit side by side.
|
|
779
|
-
const dirName =
|
|
876
|
+
const dirName = frameSetDir(name, fps);
|
|
780
877
|
const dir = join(outRoot, dirName);
|
|
781
878
|
// Cleared rather than written over: a shorter animation would otherwise leave
|
|
782
879
|
// the tail of a longer previous run on disk, and stale frames in a frame set
|
|
@@ -825,6 +922,9 @@ export function cmdRender(flags: Record<string, string>): void {
|
|
|
825
922
|
console.log(` .. pass ${passSummaryLine(passReading?.animation ?? null, fps, pair)}`);
|
|
826
923
|
for (const frame of pair.frames) console.log(` .. pass ${passFrameLine(passReading?.animation ?? null, pair, frame)}`);
|
|
827
924
|
}
|
|
925
|
+
for (const reading of angleReadings.get(name) ?? []) {
|
|
926
|
+
console.log(` .. angle ${angleRangeLine(name === SETUP_POSE_DIR && facts.animations.length === 0 ? null : name, fps, reading)}`);
|
|
927
|
+
}
|
|
828
928
|
}
|
|
829
929
|
|
|
830
930
|
// The sidecar is what makes this a frame SET rather than a pile of pictures:
|
|
@@ -842,6 +942,8 @@ export function cmdRender(flags: Record<string, string>): void {
|
|
|
842
942
|
...subsetFields(subset),
|
|
843
943
|
// Written only when a schedule was given (issue #1416): a render without one says nothing and stays the bytes it was.
|
|
844
944
|
...(environment === undefined ? {} : { environment: environment.steps }),
|
|
945
|
+
// Written only when the box was given (issue #1414): a fitted render says nothing and stays the bytes it was.
|
|
946
|
+
...(given === undefined ? {} : { pinned: pinnedRecord(given.by, outside) }),
|
|
845
947
|
background: BACKGROUND,
|
|
846
948
|
// The one spelling `geometry.json` repeats, so the two files cannot state
|
|
847
949
|
// two boxes for one grid.
|
|
@@ -895,12 +997,12 @@ export function cmdInspect(flags: Record<string, string>): void {
|
|
|
895
997
|
let frame: InspectFrame = { kind: 'shot' };
|
|
896
998
|
if (flags.zoom !== undefined) frame = { kind: 'zoom', ...readZoom(flags.zoom) };
|
|
897
999
|
if (flags.viewport !== undefined) {
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
1000
|
+
try {
|
|
1001
|
+
frame = { kind: 'viewport', ...readViewportBox(flags.viewport) };
|
|
1002
|
+
} catch (err) {
|
|
1003
|
+
if (err instanceof ViewportError) throw new UsageError(err.message);
|
|
1004
|
+
throw err;
|
|
901
1005
|
}
|
|
902
|
-
if (parts[2] <= 0 || parts[3] <= 0) throw new UsageError('--viewport width and height must be positive');
|
|
903
|
-
frame = { kind: 'viewport', x: parts[0], y: parts[1], width: parts[2], height: parts[3] };
|
|
904
1006
|
}
|
|
905
1007
|
const target = resolve(flags.out ?? DEFAULT_INSPECT_OUT);
|
|
906
1008
|
const out = existsSync(target) && statSync(target).isDirectory() ? join(target, DEFAULT_INSPECT_OUT) : target;
|
|
@@ -1176,6 +1278,11 @@ function refuseUnposableArt(result: CompileResult, opts: CompileOptions): void {
|
|
|
1176
1278
|
export function cmdExplain(flags: Record<string, string>): void {
|
|
1177
1279
|
// `--poser` is refused by its spelling before anything compiles (issue #1019): it chooses the deform survey's reader and poser.
|
|
1178
1280
|
const poser = readPoserFlag(flags);
|
|
1281
|
+
// `--lever-fold` is refused by its value before anything compiles, too (issue #1427): the fraction is the caller's and
|
|
1282
|
+
// has no default, so a value that is not one is a usage error rather than a reading taken at a guess.
|
|
1283
|
+
const leverFold = flags['lever-fold'] === undefined ? null : Number(flags['lever-fold']);
|
|
1284
|
+
const leverWhy = leverFold === null ? null : leverFractionWhy(leverFold);
|
|
1285
|
+
if (leverWhy !== null) throw new UsageError(`--lever-fold ${JSON.stringify(flags['lever-fold'])}: ${leverWhy}`);
|
|
1179
1286
|
const { label, opts } = resolveCut(flags);
|
|
1180
1287
|
console.log(`rigc explain ${label}`);
|
|
1181
1288
|
// See the identical pair of lines in `cmdBuild` for why both paths are named
|
|
@@ -1372,6 +1479,24 @@ export function cmdExplain(flags: Record<string, string>): void {
|
|
|
1372
1479
|
console.log(` transform ${gen.kind} ${gen.stated}`);
|
|
1373
1480
|
console.log(` ${gen.formula}`);
|
|
1374
1481
|
for (const line of gen.derived) console.log(` ${line}`);
|
|
1482
|
+
// The envelope's weights, one per vertex, because they are half
|
|
1483
|
+
// of every number below (issue #1413): each offset is
|
|
1484
|
+
// round(w × the un-enveloped offset), and a reader holding these
|
|
1485
|
+
// and the model reproduces the run without a second evaluation.
|
|
1486
|
+
if (gen.envelope !== undefined) {
|
|
1487
|
+
const env = gen.envelope;
|
|
1488
|
+
console.log(` envelope ${env.stated}`);
|
|
1489
|
+
console.log(` ${env.formula}`);
|
|
1490
|
+
console.log(
|
|
1491
|
+
` each offset below is round(w × the un-enveloped offset): ${env.zero} vertex(es) at w 0, ` +
|
|
1492
|
+
`${env.full} at 1, ${gen.vertexCount - env.zero - env.full} between`,
|
|
1493
|
+
);
|
|
1494
|
+
for (let v = 0; v < env.weights.length; v += 8) {
|
|
1495
|
+
const ws: string[] = [];
|
|
1496
|
+
for (let k = v; k < Math.min(v + 8, env.weights.length); k++) ws.push(`v${String(k).padStart(3)} ${env.weights[k]}`);
|
|
1497
|
+
console.log(` w ${ws.join(' ')}`);
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1375
1500
|
console.log(
|
|
1376
1501
|
` ${gen.vertexCount} vertices, largest offset ${gen.maxOffset}px at vertex ${gen.maxOffsetVertex}`,
|
|
1377
1502
|
);
|
|
@@ -1436,6 +1561,36 @@ export function cmdExplain(flags: Record<string, string>): void {
|
|
|
1436
1561
|
for (const ph of result.physics) {
|
|
1437
1562
|
console.log(` ${ph.name.padEnd(12)} bone=${ph.bone.padEnd(14)} components=[${ph.components.join(', ')}] mix=${ph.mix} drivesMesh=${ph.drivesMesh}`);
|
|
1438
1563
|
}
|
|
1564
|
+
// The whirl reading (issue #1423): how far each constraint's bone turns in one step, walked by the core from the
|
|
1565
|
+
// setup pose — `src/whirl.ts` says why that walk and not render's. It decides nothing, so it sits in the report
|
|
1566
|
+
// rather than in the gate, and explain's exit is the same whatever it prints.
|
|
1567
|
+
console.log(`\n${WHIRL_HEADER}`);
|
|
1568
|
+
let reading: WhirlAnimation[] | null = null;
|
|
1569
|
+
try {
|
|
1570
|
+
reading = whirlReading(readModel(modelDocument(result.model, result.skeletonText, result.atlasText), `the whirl reading of ${label}`));
|
|
1571
|
+
} catch (err) {
|
|
1572
|
+
if (!(err instanceof CoreInputError)) throw err;
|
|
1573
|
+
console.log(` .. turn not read — the core refused the model document: ${err.message}`);
|
|
1574
|
+
}
|
|
1575
|
+
if (reading !== null && reading.length === 0) console.log(' .. turn not read — the skeleton declares no animation to walk');
|
|
1576
|
+
for (const line of whirlLines(reading ?? [])) console.log(` .. turn ${line}`);
|
|
1577
|
+
// The lever reading (issue #1427): on the same walk, the steps a scaleX drive's inertia lever divides by a collapsed
|
|
1578
|
+
// divisor, and the steps its scale factor jumps. Only when asked for, because its fraction has no default —
|
|
1579
|
+
// and said so when not asked, rather than left out in silence.
|
|
1580
|
+
if (leverFold === null) {
|
|
1581
|
+
console.log('\n .. lever not read — `--lever-fold <fraction>` states the fraction of its setup divisor a scaleX lever is named for dividing below, and it has no default');
|
|
1582
|
+
} else {
|
|
1583
|
+
console.log(`\n${leverHeader(leverFold)}`);
|
|
1584
|
+
let levers: LeverReading | null = null;
|
|
1585
|
+
try {
|
|
1586
|
+
levers = leverReading(readModel(modelDocument(result.model, result.skeletonText, result.atlasText), `the lever reading of ${label}`), leverFold);
|
|
1587
|
+
} catch (err) {
|
|
1588
|
+
if (!(err instanceof CoreInputError)) throw err;
|
|
1589
|
+
console.log(` .. lever not read — the core refused the model document: ${err.message}`);
|
|
1590
|
+
}
|
|
1591
|
+
if (levers !== null && levers.animations.length === 0 && levers.unread.length < result.physics.length) console.log(' .. lever not read — the skeleton declares no animation to walk');
|
|
1592
|
+
for (const line of levers === null ? [] : leverLines(levers)) console.log(` .. lever ${line}`);
|
|
1593
|
+
}
|
|
1439
1594
|
}
|
|
1440
1595
|
|
|
1441
1596
|
// Path constraints, with the curve each one follows MEASURED — its length and
|
|
@@ -1947,6 +2102,55 @@ export const MODEL_AND_TEXT_GATE: BuildGate = {
|
|
|
1947
2102
|
look: (outDir) => `rigc: look at it: rigc render --candidate ${outDir}`,
|
|
1948
2103
|
};
|
|
1949
2104
|
|
|
2105
|
+
/**
|
|
2106
|
+
* split — one authored mesh's pieces, proposed as one mesh each (issue #1414).
|
|
2107
|
+
*
|
|
2108
|
+
* Reads the two spec files and nothing else: a mesh of several pieces does not
|
|
2109
|
+
* compile, so there is no build to read. Writes the proposal — both specs and
|
|
2110
|
+
* `split.json` — into an `--out` that is absent or empty, which is the whole of
|
|
2111
|
+
* how it never overwrites anything: there is no in-place flag, because the
|
|
2112
|
+
* specs it read are the only statement of what the proposal must reproduce,
|
|
2113
|
+
* and a proposal nobody has built yet is not something to put over them.
|
|
2114
|
+
*/
|
|
2115
|
+
function cmdSplit(flags: Record<string, string>): void {
|
|
2116
|
+
for (const flag of ['rig', 'motion', 'slot', 'out'] as const) {
|
|
2117
|
+
if (flags[flag] === undefined) throw new UsageError(`split needs --${flag}`);
|
|
2118
|
+
}
|
|
2119
|
+
const rigPath = resolve(flags.rig);
|
|
2120
|
+
const motionPath = resolve(flags.motion);
|
|
2121
|
+
const outDir = resolve(flags.out);
|
|
2122
|
+
for (const [flag, path] of [['rig', rigPath], ['motion', motionPath]] as const) {
|
|
2123
|
+
if (!existsSync(path) || !statSync(path).isFile()) throw new SplitError(`--${flag} ${path}: no file there`, 2);
|
|
2124
|
+
}
|
|
2125
|
+
if (existsSync(outDir) && (!statSync(outDir).isDirectory() || readdirSync(outDir).length > 0)) {
|
|
2126
|
+
throw new SplitError(
|
|
2127
|
+
`--out ${outDir} ${statSync(outDir).isDirectory() ? `holds ${readdirSync(outDir).length} entr${readdirSync(outDir).length === 1 ? 'y' : 'ies'}` : 'is a file'}; ` +
|
|
2128
|
+
'the proposal is written into an absent or empty directory only, so it never lands on top of a file — the specs it read included',
|
|
2129
|
+
2,
|
|
2130
|
+
);
|
|
2131
|
+
}
|
|
2132
|
+
const rig = parseRigSpec(readJsonFile(rigPath), rigPath);
|
|
2133
|
+
const motion = parseMotionSpec(readJsonFile(motionPath), motionPath);
|
|
2134
|
+
// The rig spec's `images` is relative to the rig file, and the proposal is written somewhere else: restate it from
|
|
2135
|
+
// there, so every `image` resolves to the file it resolved to before.
|
|
2136
|
+
const imagesDir = resolve(dirname(rigPath), rig.images ?? '.');
|
|
2137
|
+
const images = relativeImagesPath(outDir, imagesDir);
|
|
2138
|
+
console.log(`rigc split ${rigPath}`);
|
|
2139
|
+
console.log(` .. motion ${motionPath}`);
|
|
2140
|
+
const result = splitMesh({ rig, motion, slot: flags.slot, ...(flags.attachment === undefined ? {} : { attachment: flags.attachment }), ...(flags.skin === undefined ? {} : { skin: flags.skin }), images });
|
|
2141
|
+
if (result.kind === 'whole') {
|
|
2142
|
+
console.log(`rigc split: ${result.line}`);
|
|
2143
|
+
return;
|
|
2144
|
+
}
|
|
2145
|
+
for (const line of result.lines) console.log(` .. ${line}`);
|
|
2146
|
+
console.log(` .. images ${images} (the rig spec's own, restated from ${outDir} — it resolves to ${imagesDir})`);
|
|
2147
|
+
mkdirSync(outDir, { recursive: true });
|
|
2148
|
+
writeJson(join(outDir, 'rig.json'), result.rig);
|
|
2149
|
+
writeJson(join(outDir, 'motion.json'), result.motion);
|
|
2150
|
+
writeJson(join(outDir, 'split.json'), result.record);
|
|
2151
|
+
console.log(`rigc: build it with rigc build --rig ${join(outDir, 'rig.json')} --motion ${join(outDir, 'motion.json')} --out <dir>`);
|
|
2152
|
+
}
|
|
2153
|
+
|
|
1950
2154
|
/**
|
|
1951
2155
|
* The bodies the second entry runs under a name whose full-entry body is the
|
|
1952
2156
|
* runtime's (`runtime.core` in `COMMANDS`, issue #1060) — registered by
|
|
@@ -1968,6 +2172,7 @@ export const CORE_ENTRY_RUNS: Readonly<Record<string, CommandRun>> = {
|
|
|
1968
2172
|
export const CORE_COMMAND_RUNS: Readonly<Record<string, CommandRun>> = {
|
|
1969
2173
|
explain: ({ flags }) => cmdExplain(flags),
|
|
1970
2174
|
ingest: ({ flags, positional }) => cmdIngest(flags, positional),
|
|
2175
|
+
split: ({ flags }) => cmdSplit(flags),
|
|
1971
2176
|
diff: ({ flags, lists, positional }) => cmdDiff(flags, lists, positional),
|
|
1972
2177
|
check: ({ flags }) => cmdCheck(flags),
|
|
1973
2178
|
render: ({ flags }) => cmdRender(flags),
|