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/src/stop.ts
ADDED
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `stops` — a swinging chain held off a leg (issue #1413).
|
|
3
|
+
*
|
|
4
|
+
* Spine has no collider and no angle limit — a physics constraint's `limit` is
|
|
5
|
+
* a velocity cap — so a skirt chain driven by physics swings its hem straight
|
|
6
|
+
* through the thigh it hangs in front of. The working stand-in, tuned by hand on
|
|
7
|
+
* a dancer's pleated skirt, is a small network per chain: a point on the leg, a
|
|
8
|
+
* frame that aims at it from the chain's root, the swinging link's rotation
|
|
9
|
+
* copied onto that frame, and the DRAWN link — the one the mesh is weighted to —
|
|
10
|
+
* held by a transform clamp, so it may swing away from the leg and not past its
|
|
11
|
+
* rest angle toward it. One declaration here expands to exactly that network,
|
|
12
|
+
* and to nothing else: what it writes is what an author would write by hand,
|
|
13
|
+
* bone for bone and constraint for constraint, which `LG01` holds byte for byte.
|
|
14
|
+
*
|
|
15
|
+
* ⭐ **The expansion happens before anything else reads the rig.** `parseRigSpec`
|
|
16
|
+
* calls `expandStops` ahead of its own name resolution, so every reader after it
|
|
17
|
+
* — the parse's own checks, `compile`, the gate — sees ordinary bones and
|
|
18
|
+
* constraints and nothing that knows a stop was there. A stop therefore cannot
|
|
19
|
+
* buy an exemption, a default or a code path of its own: it is spelling.
|
|
20
|
+
*
|
|
21
|
+
* Every position and angle is the declaration's, or derived from it and the
|
|
22
|
+
* setup pose the rig spec states — no value is measured off the art and none is
|
|
23
|
+
* defaulted. What is derived, and from what:
|
|
24
|
+
*
|
|
25
|
+
* - the point bone's `x`/`y` are `at`, in `against`'s local space, as written;
|
|
26
|
+
* - the frame hangs where the first link hangs — its parent, `x`, `y` and
|
|
27
|
+
* `length` are the link's — and its `rotation` points it at the point;
|
|
28
|
+
* - the drawn link's rest rotation in the frame is the angle from the aim line
|
|
29
|
+
* to the link, so at rest the drawn link lies on the link;
|
|
30
|
+
* - which way is **inward** is the way from the link toward the aim line: the
|
|
31
|
+
* point is on the leg, so turning toward it is turning into the leg;
|
|
32
|
+
* - the clamp's two bounds are that rest angle moved by `inward` and `outward`.
|
|
33
|
+
*
|
|
34
|
+
* The angles are read in the exact frame (`computeExactFrameTransforms`): they
|
|
35
|
+
* are statements about the rig as authored, the rule `src/transform.ts` states
|
|
36
|
+
* for what is measured rather than bound.
|
|
37
|
+
*/
|
|
38
|
+
import { CompileError } from './errors.ts';
|
|
39
|
+
import type { RigBone, RigConstraint, RigIkConstraint, RigSpec, RigStop, RigTransformConstraint } from './rig.ts';
|
|
40
|
+
import {
|
|
41
|
+
computeExactFrameTransforms,
|
|
42
|
+
directionDegrees,
|
|
43
|
+
localDirectionDegrees,
|
|
44
|
+
toWorld,
|
|
45
|
+
type BoneTransform,
|
|
46
|
+
} from './transform.ts';
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A planted wrong expansion, for the selftest's mutants (`LG` controls) — each
|
|
50
|
+
* a mistake a hand-built network could carry and still build green.
|
|
51
|
+
*
|
|
52
|
+
* - `swapRange` — inward taken the other way round, so the clamp holds the
|
|
53
|
+
* link off the side it should swing to and lets it into the leg;
|
|
54
|
+
* - `aimAtAgainst` — the frame aimed at the `against` bone's origin rather
|
|
55
|
+
* than at the point on it;
|
|
56
|
+
* - `appendConstraints` — every generated constraint at the end of the
|
|
57
|
+
* array, after the physics of the links below, which then swing from a
|
|
58
|
+
* drawn link not yet clamped.
|
|
59
|
+
*
|
|
60
|
+
* @internal
|
|
61
|
+
*/
|
|
62
|
+
export interface StopPlant {
|
|
63
|
+
swapRange?: boolean;
|
|
64
|
+
aimAtAgainst?: boolean;
|
|
65
|
+
appendConstraints?: boolean;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The names one stop generates, all derived from its `chain` and its links' names. */
|
|
69
|
+
export interface StopNames {
|
|
70
|
+
/** `<chain>_touch` — the point, a child of `against`. */
|
|
71
|
+
point: string;
|
|
72
|
+
/** `<chain>_stop` — the frame, aimed at the point. */
|
|
73
|
+
frame: string;
|
|
74
|
+
/** `<chain>_stop_aim` — the ik constraint that aims it. */
|
|
75
|
+
aim: string;
|
|
76
|
+
/** `<first link>_rel` — the first link's rotation, copied onto the frame. */
|
|
77
|
+
rel: string;
|
|
78
|
+
/** `<first link>_rel_copy` — the transform constraint that copies it. */
|
|
79
|
+
copy: string;
|
|
80
|
+
/** `<link>_d` per link — the drawn link. */
|
|
81
|
+
drawn: string[];
|
|
82
|
+
/** `<link>_clamp` per link — the transform constraint that holds it. */
|
|
83
|
+
clamp: string[];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The generated names of a stop, in one place so the refusals and the expansion cannot spell them apart. */
|
|
87
|
+
export function stopNames(chain: string, links: readonly string[]): StopNames {
|
|
88
|
+
return {
|
|
89
|
+
point: `${chain}_touch`,
|
|
90
|
+
frame: `${chain}_stop`,
|
|
91
|
+
aim: `${chain}_stop_aim`,
|
|
92
|
+
rel: `${links[0]}_rel`,
|
|
93
|
+
copy: `${links[0]}_rel_copy`,
|
|
94
|
+
drawn: links.map((l) => `${l}_d`),
|
|
95
|
+
clamp: links.map((l) => `${l}_clamp`),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function isObj(v: unknown): v is Record<string, unknown> {
|
|
100
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** One stop, read and checked: every field present, every name resolved. */
|
|
104
|
+
interface ReadStop {
|
|
105
|
+
who: string;
|
|
106
|
+
chain: string;
|
|
107
|
+
links: string[];
|
|
108
|
+
against: string;
|
|
109
|
+
at: [number, number];
|
|
110
|
+
inward: number;
|
|
111
|
+
outward: number;
|
|
112
|
+
lower: { inward: number; outward: number } | null;
|
|
113
|
+
names: StopNames;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The transform fields a drawn link copies from its link (the frame copies `x`, `y` and `length` only). */
|
|
117
|
+
const TWIN_FIELDS = ['length', 'x', 'y', 'rotation', 'scaleX', 'scaleY', 'shearX', 'shearY', 'inherit'] as const;
|
|
118
|
+
|
|
119
|
+
/** A degree bound a clamp can reach: a local rotation read off a world copy is in (-180, 180]. */
|
|
120
|
+
const BOUND = 180;
|
|
121
|
+
|
|
122
|
+
/** An angle in (-540, 540) brought into (-180, 180] by at most one whole turn. */
|
|
123
|
+
function halfTurn(deg: number): number {
|
|
124
|
+
if (deg > 180) return deg - 360;
|
|
125
|
+
if (deg <= -180) return deg + 360;
|
|
126
|
+
return deg;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The rig spec with every `stops` entry expanded into the bones and constraints
|
|
131
|
+
* it stands for, or the spec itself when it declares no `stops`.
|
|
132
|
+
*
|
|
133
|
+
* Bones: the frame immediately before the first link; the first link's `_rel`
|
|
134
|
+
* and `_d` immediately after it; each lower link's `_d` immediately after that
|
|
135
|
+
* link; the point after every other bone. Constraints: the aim immediately
|
|
136
|
+
* before the last constraint that writes the first link, the copy and the first
|
|
137
|
+
* clamp immediately after it, and each lower clamp immediately after the last
|
|
138
|
+
* constraint that writes its link — so the frame is aimed before the link
|
|
139
|
+
* swings, and every swing is copied and clamped before anything below it reads
|
|
140
|
+
* the drawn link.
|
|
141
|
+
*
|
|
142
|
+
* The returned spec keeps `stops` as written, so `compile` can refuse a link a
|
|
143
|
+
* motion-spec physics table also drives (that table is emitted after every rig
|
|
144
|
+
* constraint, past the clamps); nothing emits it.
|
|
145
|
+
*/
|
|
146
|
+
export function expandStops(spec: RigSpec, where: string, plant: StopPlant = {}): RigSpec {
|
|
147
|
+
const declared: unknown = spec.stops;
|
|
148
|
+
if (declared === undefined) return spec;
|
|
149
|
+
if (!Array.isArray(declared)) {
|
|
150
|
+
throw new CompileError(`${where}: "stops" is ${JSON.stringify(declared)}; it is an array of { "chain", "links", "against", "at", "inward", "outward", "lower"? }`);
|
|
151
|
+
}
|
|
152
|
+
const authored = new Map<string, RigBone>();
|
|
153
|
+
for (const bone of spec.bones) if (isObj(bone) && typeof bone.name === 'string') authored.set(bone.name, bone);
|
|
154
|
+
const constraints = spec.constraints ?? [];
|
|
155
|
+
|
|
156
|
+
// -- 1. every field, every name ----------------------------------------------
|
|
157
|
+
const stops: ReadStop[] = [];
|
|
158
|
+
const stoppedBy = new Map<string, string>();
|
|
159
|
+
for (const [i, entry] of declared.entries()) {
|
|
160
|
+
if (!isObj(entry)) throw new CompileError(`${where}: stops[${i}] is ${JSON.stringify(entry)}; a stop is an object`);
|
|
161
|
+
const chain = entry.chain;
|
|
162
|
+
if (typeof chain !== 'string' || chain.length === 0) {
|
|
163
|
+
throw new CompileError(`${where}: stops[${i}] needs a "chain" — the name its point, frame and aim are called after ("<chain>_touch", "<chain>_stop", "<chain>_stop_aim")`);
|
|
164
|
+
}
|
|
165
|
+
const who = `${where}: stop "${chain}"`;
|
|
166
|
+
const links: unknown = entry.links;
|
|
167
|
+
if (!Array.isArray(links) || links.length === 0 || !links.every((l): l is string => typeof l === 'string')) {
|
|
168
|
+
throw new CompileError(`${who} states "links" ${JSON.stringify(links) ?? 'absent'}; it is the chain's swinging links, root first — a non-empty array of bone names`);
|
|
169
|
+
}
|
|
170
|
+
for (const [k, link] of links.entries()) {
|
|
171
|
+
const bone = authored.get(link);
|
|
172
|
+
if (bone === undefined) throw new CompileError(`${who}: links[${k}] names bone "${link}", which this rig does not declare`);
|
|
173
|
+
const other = stoppedBy.get(link);
|
|
174
|
+
if (other !== undefined) {
|
|
175
|
+
throw new CompileError(
|
|
176
|
+
`${who}: links[${k}] "${link}" is already stopped by stop "${other}" — one drawn link "${link}_d" and one clamp "${link}_clamp" per link, so a link is stopped once`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
stoppedBy.set(link, chain);
|
|
180
|
+
if (bone.from !== undefined) {
|
|
181
|
+
throw new CompileError(`${who}: links[${k}] "${link}" takes its place from the manifest ("from"), and a stop copies the link's stated x, y and rotation onto its drawn link — state them`);
|
|
182
|
+
}
|
|
183
|
+
if (bone.parent === undefined) {
|
|
184
|
+
throw new CompileError(`${who}: links[${k}] "${link}" is the skeleton's root, and a stop hangs its frame and its drawn link where the link hangs — a link has a parent`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
const against = entry.against;
|
|
188
|
+
if (typeof against !== 'string' || against.length === 0) {
|
|
189
|
+
throw new CompileError(`${who} needs "against" — the bone the point rides on, the leg the chain is held off`);
|
|
190
|
+
}
|
|
191
|
+
if (!authored.has(against)) throw new CompileError(`${who}: "against" names bone "${against}", which this rig does not declare`);
|
|
192
|
+
const at: unknown = entry.at;
|
|
193
|
+
if (at === undefined) {
|
|
194
|
+
throw new CompileError(`${who} states no "at" — the point on "${against}" the frame aims at, [x, y] in "${against}"'s local space; the compiler places no point of its own`);
|
|
195
|
+
}
|
|
196
|
+
if (!Array.isArray(at) || at.length !== 2 || !at.every((v): v is number => typeof v === 'number')) {
|
|
197
|
+
throw new CompileError(`${who} states "at" ${JSON.stringify(at)}; it is [x, y], two numbers in "${against}"'s local space`);
|
|
198
|
+
}
|
|
199
|
+
const range = (node: Record<string, unknown>, field: string): { inward: number; outward: number } => {
|
|
200
|
+
const out: Record<'inward' | 'outward', number> = { inward: 0, outward: 0 };
|
|
201
|
+
for (const end of ['inward', 'outward'] as const) {
|
|
202
|
+
const v = node[end];
|
|
203
|
+
if (typeof v !== 'number') {
|
|
204
|
+
throw new CompileError(
|
|
205
|
+
`${who} states no "${field}${end}" — degrees from the link's rest angle, counted positive away from "${against}"; the range is [inward, outward], and the compiler states neither end for you`,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
out[end] = v;
|
|
209
|
+
}
|
|
210
|
+
if (out.inward > out.outward) {
|
|
211
|
+
throw new CompileError(
|
|
212
|
+
`${who}: "${field}inward" ${out.inward} is greater than "${field}outward" ${out.outward} — the range is [inward, outward] in degrees counted positive away from "${against}", and this one holds no angle`,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
return out;
|
|
216
|
+
};
|
|
217
|
+
const first = range(entry, '');
|
|
218
|
+
const lowerRaw = entry.lower;
|
|
219
|
+
let lower: { inward: number; outward: number } | null = null;
|
|
220
|
+
if (links.length > 1) {
|
|
221
|
+
if (!isObj(lowerRaw)) {
|
|
222
|
+
throw new CompileError(`${who} states no "lower" — the range [inward, outward] each link below the first may turn through off its own rest bend; the chain has ${links.length - 1} of them`);
|
|
223
|
+
}
|
|
224
|
+
lower = range(lowerRaw, 'lower.');
|
|
225
|
+
} else if (lowerRaw !== undefined) {
|
|
226
|
+
throw new CompileError(`${who} states "lower" and its chain has one link — there is no link below the first for it to hold`);
|
|
227
|
+
}
|
|
228
|
+
stops.push({ who, chain, links, against, at: [at[0], at[1]], inward: first.inward, outward: first.outward, lower, names: stopNames(chain, links) });
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// -- 2. the generated names are new -------------------------------------------
|
|
232
|
+
const generatedBones = new Map<string, string>();
|
|
233
|
+
const generatedConstraints = new Map<string, string>();
|
|
234
|
+
const authoredConstraints = new Set(constraints.filter(isObj).map((c) => `${String(c.type)} ${String(c.name)}`));
|
|
235
|
+
for (const stop of stops) {
|
|
236
|
+
const { names } = stop;
|
|
237
|
+
const lowerDrawn = names.drawn.slice(1);
|
|
238
|
+
for (const name of [names.frame, names.rel, names.drawn[0], ...lowerDrawn, names.point]) {
|
|
239
|
+
if (authored.has(name)) throw new CompileError(`${stop.who} generates bone "${name}", which this rig already declares — rename the bone, or the stop's "chain"`);
|
|
240
|
+
const by = generatedBones.get(name);
|
|
241
|
+
if (by !== undefined) throw new CompileError(`${stop.who} generates bone "${name}", which stop "${by}" generates too — rename one "chain"`);
|
|
242
|
+
generatedBones.set(name, stop.chain);
|
|
243
|
+
}
|
|
244
|
+
for (const [type, name] of [['ik', names.aim], ['transform', names.copy], ...names.clamp.map((c) => ['transform', c] as const)] as const) {
|
|
245
|
+
const key = `${type} ${name}`;
|
|
246
|
+
if (authoredConstraints.has(key)) throw new CompileError(`${stop.who} generates ${type} constraint "${name}", which this rig already declares — rename the constraint, or the stop's "chain"`);
|
|
247
|
+
const by = generatedConstraints.get(key);
|
|
248
|
+
if (by !== undefined) throw new CompileError(`${stop.who} generates ${type} constraint "${name}", which stop "${by}" generates too — rename one "chain"`);
|
|
249
|
+
generatedConstraints.set(key, stop.chain);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// -- 3. what swings each link ---------------------------------------------------
|
|
254
|
+
const writes = (c: unknown, bone: string): boolean => {
|
|
255
|
+
if (!isObj(c)) return false;
|
|
256
|
+
if (c.type === 'physics') return c.bone === bone;
|
|
257
|
+
if (c.type === 'ik' || c.type === 'transform' || c.type === 'path') return Array.isArray(c.bones) && c.bones.includes(bone);
|
|
258
|
+
return false;
|
|
259
|
+
};
|
|
260
|
+
const lastWriter = (stop: ReadStop, k: number): number => {
|
|
261
|
+
const link = stop.links[k];
|
|
262
|
+
let at = -1;
|
|
263
|
+
constraints.forEach((c, i) => {
|
|
264
|
+
if (writes(c, link)) at = i;
|
|
265
|
+
});
|
|
266
|
+
if (at < 0) {
|
|
267
|
+
throw new CompileError(
|
|
268
|
+
`${stop.who}: links[${k}] "${link}" is written by no constraint of this rig spec — a stop holds a link something swings (a physics, ik, transform or path constraint in "constraints"), and its copy and clamp run right after that. A physics constraint in the motion spec's table is emitted after every rig constraint, past the clamp; declare it here`,
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
return at;
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
// -- 4. the tree the expansion makes, by name ----------------------------------
|
|
275
|
+
const parentOf = new Map<string, string | undefined>();
|
|
276
|
+
for (const [name, bone] of authored) parentOf.set(name, bone.parent);
|
|
277
|
+
for (const stop of stops) {
|
|
278
|
+
const { names, links } = stop;
|
|
279
|
+
parentOf.set(names.frame, authored.get(links[0])?.parent);
|
|
280
|
+
parentOf.set(names.rel, names.frame);
|
|
281
|
+
parentOf.set(names.drawn[0], names.frame);
|
|
282
|
+
for (let k = 1; k < links.length; k++) parentOf.set(names.drawn[k], authored.get(links[k])?.parent);
|
|
283
|
+
parentOf.set(names.point, stop.against);
|
|
284
|
+
}
|
|
285
|
+
const ancestry = (name: string): string[] => {
|
|
286
|
+
const out: string[] = [];
|
|
287
|
+
const seen = new Set<string>();
|
|
288
|
+
for (let at = parentOf.get(name); at !== undefined && !seen.has(at); at = parentOf.get(at)) {
|
|
289
|
+
seen.add(at);
|
|
290
|
+
out.push(at);
|
|
291
|
+
}
|
|
292
|
+
return out;
|
|
293
|
+
};
|
|
294
|
+
for (const stop of stops) {
|
|
295
|
+
const { names, links } = stop;
|
|
296
|
+
for (let k = 1; k < links.length; k++) {
|
|
297
|
+
if (!ancestry(links[k]).includes(names.drawn[k - 1])) {
|
|
298
|
+
throw new CompileError(
|
|
299
|
+
`${stop.who}: links[${k}] "${links[k]}" does not hang below "${names.drawn[k - 1]}", the drawn link of links[${k - 1}] — a chain's next link hangs from the drawn link above it, so the clamp that holds that one carries this one; parent it there`,
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
const held = new Set([names.frame, names.rel, ...names.drawn, ...links]);
|
|
304
|
+
const under = [stop.against, ...ancestry(stop.against)].find((b) => held.has(b));
|
|
305
|
+
if (under !== undefined) {
|
|
306
|
+
throw new CompileError(`${stop.who}: "against" "${stop.against}" hangs below "${under}", which the stop moves — the point would swing with the chain it holds off; name the leg`);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// -- 5. the angles, off the setup pose as stated -------------------------------
|
|
311
|
+
const generated = new Map<string, RigBone>();
|
|
312
|
+
const known = (name: string): RigBone | undefined => authored.get(name) ?? generated.get(name);
|
|
313
|
+
const before = new Map<number, RigConstraint[]>();
|
|
314
|
+
const after = new Map<number, RigConstraint[]>();
|
|
315
|
+
const appended: RigConstraint[] = [];
|
|
316
|
+
const queue = (map: Map<number, RigConstraint[]>, at: number, c: RigConstraint): void => {
|
|
317
|
+
if (plant.appendConstraints) appended.push(c);
|
|
318
|
+
else map.set(at, [...(map.get(at) ?? []), c]);
|
|
319
|
+
};
|
|
320
|
+
const conformal = (m: BoneTransform): boolean => {
|
|
321
|
+
const scale = Math.max(Math.abs(m.a), Math.abs(m.b), Math.abs(m.c), Math.abs(m.d));
|
|
322
|
+
return m.a * m.d - m.b * m.c > 0 && Math.abs(m.a - m.d) <= 1e-9 * scale && Math.abs(m.b + m.c) <= 1e-9 * scale;
|
|
323
|
+
};
|
|
324
|
+
const clampOf = (name: string, source: string, target: string, lo: number, hi: number): RigTransformConstraint => ({
|
|
325
|
+
name,
|
|
326
|
+
type: 'transform',
|
|
327
|
+
source,
|
|
328
|
+
bones: [target],
|
|
329
|
+
localSource: true,
|
|
330
|
+
localTarget: true,
|
|
331
|
+
clamp: true,
|
|
332
|
+
properties: { rotate: { offset: lo, to: { rotate: { offset: lo, max: hi, scale: 1 } } } },
|
|
333
|
+
mixRotate: 1,
|
|
334
|
+
});
|
|
335
|
+
const bounds = (stop: ReadStop, rest: number, sign: number, range: { inward: number; outward: number }, field: string, what: string): [number, number] => {
|
|
336
|
+
const a = rest - sign * range.outward;
|
|
337
|
+
const b = rest - sign * range.inward;
|
|
338
|
+
for (const [end, value] of [['outward', a], ['inward', b]] as const) {
|
|
339
|
+
if (Math.abs(value) > BOUND) {
|
|
340
|
+
throw new CompileError(
|
|
341
|
+
`${stop.who}: "${field}${end}" ${range[end]} puts ${what}'s bound at ${value}°, past ±${BOUND}° — the clamp reads a local rotation in (-180, 180], so a bound past it is never reached and the range it states is not the one that holds`,
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return [Math.min(a, b), Math.max(a, b)];
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
for (const stop of stops) {
|
|
349
|
+
const { names, links, who } = stop;
|
|
350
|
+
const link = authored.get(links[0]) as RigBone;
|
|
351
|
+
const parent = link.parent as string;
|
|
352
|
+
// The bones the angles are read through, root first: the first link's
|
|
353
|
+
// ancestry and the leg's, each parent before its child.
|
|
354
|
+
const chain: RigBone[] = [];
|
|
355
|
+
const listed = new Set<string>();
|
|
356
|
+
for (const tip of [links[0], stop.against]) {
|
|
357
|
+
for (const name of [...ancestry(tip).reverse(), tip]) {
|
|
358
|
+
if (listed.has(name)) continue;
|
|
359
|
+
const bone = known(name);
|
|
360
|
+
if (bone === undefined) {
|
|
361
|
+
throw new CompileError(`${who}: "${tip}" hangs below "${name}", which a later stop generates — declare that stop first, so its angles are known when this one is read`);
|
|
362
|
+
}
|
|
363
|
+
if (bone.from !== undefined) {
|
|
364
|
+
throw new CompileError(`${who}: "${name}" takes its place from the manifest ("from"), and a stop's angles are read off the setup pose this rig spec states — state its x, y and rotation`);
|
|
365
|
+
}
|
|
366
|
+
listed.add(name);
|
|
367
|
+
chain.push(bone);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
const frames = computeExactFrameTransforms(chain);
|
|
371
|
+
const parentFrame = frames.get(parent) as BoneTransform;
|
|
372
|
+
if (!conformal(parentFrame)) {
|
|
373
|
+
throw new CompileError(
|
|
374
|
+
`${who}: "${parent}", where the frame hangs, is scaled unevenly, sheared or reflected at the setup pose — the frame's angles add to its parent's only through a rotation and an even scale, and inward is a turn's sense`,
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
for (let k = 1; k < links.length; k++) {
|
|
378
|
+
for (let at = authored.get(links[k])?.parent; at !== undefined && at !== names.drawn[k - 1]; at = parentOf.get(at)) {
|
|
379
|
+
const bone = authored.get(at);
|
|
380
|
+
if (bone === undefined) break;
|
|
381
|
+
const plain = (bone.scaleX ?? 1) === (bone.scaleY ?? 1) && (bone.scaleX ?? 1) > 0 && (bone.shearX ?? 0) === 0 && (bone.shearY ?? 0) === 0 && (bone.inherit ?? 'normal') === 'normal';
|
|
382
|
+
if (!plain) {
|
|
383
|
+
throw new CompileError(
|
|
384
|
+
`${who}: "${at}", between links[${k}] "${links[k]}" and the drawn link above it, is scaled unevenly, sheared, reflected or inherits other than normally — a lower clamp reads the link's turn in its parent, and inward is a turn's sense`,
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
const linkFrame = frames.get(links[0]) as BoneTransform;
|
|
390
|
+
const point = toWorld(frames.get(stop.against) as BoneTransform, stop.at[0], stop.at[1]);
|
|
391
|
+
const target = plant.aimAtAgainst ? [(frames.get(stop.against) as BoneTransform).worldX, (frames.get(stop.against) as BoneTransform).worldY] : point;
|
|
392
|
+
const dx = target[0] - linkFrame.worldX;
|
|
393
|
+
const dy = target[1] - linkFrame.worldY;
|
|
394
|
+
if (dx === 0 && dy === 0) {
|
|
395
|
+
throw new CompileError(`${who}: "at" [${stop.at.join(', ')}] on "${stop.against}" sits on the root of "${links[0]}" at the setup pose — the frame aims from the root at the point, and there is no direction to a point it stands on`);
|
|
396
|
+
}
|
|
397
|
+
const aim = directionDegrees(dx, dy);
|
|
398
|
+
// Each angle is brought into (-180, 180] by one whole turn at most, so a
|
|
399
|
+
// link and an aim already in range subtract with no turn added and taken
|
|
400
|
+
// away again — the rest angle is the plain difference an author computes.
|
|
401
|
+
const rest = halfTurn(halfTurn(linkFrame.worldRotation) - aim);
|
|
402
|
+
if (rest === 0) {
|
|
403
|
+
throw new CompileError(
|
|
404
|
+
`${who}: "at" [${stop.at.join(', ')}] on "${stop.against}" lies on "${links[0]}"'s own line at the setup pose — inward is the turn from the link toward the point, and a point on the link's line has no side`,
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
// Inward turns the link toward the aim line: counter-clockwise when the link
|
|
408
|
+
// lies clockwise of it (a negative rest angle), clockwise otherwise.
|
|
409
|
+
const sign = (rest < 0 ? 1 : -1) * (plant.swapRange ? -1 : 1);
|
|
410
|
+
const [lo, hi] = bounds(stop, rest, sign, stop, '', `"${names.drawn[0]}"`);
|
|
411
|
+
|
|
412
|
+
const frame: RigBone = { name: names.frame, parent };
|
|
413
|
+
if (link.x !== undefined) frame.x = link.x;
|
|
414
|
+
if (link.y !== undefined) frame.y = link.y;
|
|
415
|
+
if (link.length !== undefined) frame.length = link.length;
|
|
416
|
+
frame.rotation = localDirectionDegrees(parentFrame, dx, dy);
|
|
417
|
+
const onFrame = (name: string): RigBone => {
|
|
418
|
+
const bone: RigBone = { name, parent: names.frame };
|
|
419
|
+
if (link.length !== undefined) bone.length = link.length;
|
|
420
|
+
bone.rotation = rest;
|
|
421
|
+
return bone;
|
|
422
|
+
};
|
|
423
|
+
generated.set(names.frame, frame);
|
|
424
|
+
generated.set(names.rel, onFrame(names.rel));
|
|
425
|
+
generated.set(names.drawn[0], onFrame(names.drawn[0]));
|
|
426
|
+
for (let k = 1; k < links.length; k++) {
|
|
427
|
+
const source = authored.get(links[k]) as RigBone;
|
|
428
|
+
const twin: RigBone = { name: names.drawn[k], parent: source.parent };
|
|
429
|
+
for (const field of TWIN_FIELDS) {
|
|
430
|
+
if (source[field] !== undefined) Object.assign(twin, { [field]: source[field] });
|
|
431
|
+
}
|
|
432
|
+
generated.set(names.drawn[k], twin);
|
|
433
|
+
}
|
|
434
|
+
generated.set(names.point, { name: names.point, parent: stop.against, x: stop.at[0], y: stop.at[1] });
|
|
435
|
+
|
|
436
|
+
const firstWriter = lastWriter(stop, 0);
|
|
437
|
+
const aimAt: RigIkConstraint = { name: names.aim, type: 'ik', bones: [names.frame], target: plant.aimAtAgainst ? stop.against : names.point, mix: 1 };
|
|
438
|
+
queue(before, firstWriter, aimAt);
|
|
439
|
+
queue(after, firstWriter, { name: names.copy, type: 'transform', source: links[0], bones: [names.rel], properties: { rotate: { to: { rotate: {} } } }, mixRotate: 1 });
|
|
440
|
+
queue(after, firstWriter, clampOf(names.clamp[0], names.rel, names.drawn[0], lo, hi));
|
|
441
|
+
for (let k = 1; k < links.length; k++) {
|
|
442
|
+
const source = authored.get(links[k]) as RigBone;
|
|
443
|
+
const [l, h] = bounds(stop, source.rotation ?? 0, sign, stop.lower as { inward: number; outward: number }, 'lower.', `"${names.drawn[k]}"`);
|
|
444
|
+
queue(after, lastWriter(stop, k), clampOf(names.clamp[k], links[k], names.drawn[k], l, h));
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// -- 6. spliced in -------------------------------------------------------------
|
|
449
|
+
const bones: RigBone[] = [];
|
|
450
|
+
const firstOf = new Map<string, ReadStop>();
|
|
451
|
+
const lowerOf = new Map<string, { stop: ReadStop; k: number }>();
|
|
452
|
+
for (const stop of stops) {
|
|
453
|
+
firstOf.set(stop.links[0], stop);
|
|
454
|
+
for (let k = 1; k < stop.links.length; k++) lowerOf.set(stop.links[k], { stop, k });
|
|
455
|
+
}
|
|
456
|
+
const take = (name: string): RigBone => generated.get(name) as RigBone;
|
|
457
|
+
for (const bone of spec.bones) {
|
|
458
|
+
const first = isObj(bone) ? firstOf.get(bone.name) : undefined;
|
|
459
|
+
if (first !== undefined) bones.push(take(first.names.frame));
|
|
460
|
+
bones.push(bone);
|
|
461
|
+
if (first !== undefined) bones.push(take(first.names.rel), take(first.names.drawn[0]));
|
|
462
|
+
const lower = isObj(bone) ? lowerOf.get(bone.name) : undefined;
|
|
463
|
+
if (lower !== undefined) bones.push(take(lower.stop.names.drawn[lower.k]));
|
|
464
|
+
}
|
|
465
|
+
for (const stop of stops) bones.push(take(stop.names.point));
|
|
466
|
+
const out: RigConstraint[] = [];
|
|
467
|
+
constraints.forEach((c, i) => {
|
|
468
|
+
out.push(...(before.get(i) ?? []), c, ...(after.get(i) ?? []));
|
|
469
|
+
});
|
|
470
|
+
out.push(...appended);
|
|
471
|
+
return { ...spec, bones, constraints: out };
|
|
472
|
+
}
|
package/src/transform.ts
CHANGED
|
@@ -287,6 +287,26 @@ export function toWorld(m: BoneTransform, localX: number, localY: number): [numb
|
|
|
287
287
|
return [m.a * localX + m.b * localY + m.worldX, m.c * localX + m.d * localY + m.worldY];
|
|
288
288
|
}
|
|
289
289
|
|
|
290
|
+
/**
|
|
291
|
+
* The direction of a displacement, in degrees CCW from +x, y up, in (-180, 180]
|
|
292
|
+
* — radians to degrees by the exact frame's division (`EXACT_FRAME_ARITHMETIC`),
|
|
293
|
+
* so an angle read here off `computeExactFrameTransforms` and that frame's own
|
|
294
|
+
* `worldRotation` are one arithmetic. Not rounded, for `toWorld`'s reason.
|
|
295
|
+
*/
|
|
296
|
+
export function directionDegrees(dx: number, dy: number): number {
|
|
297
|
+
return EXACT_FRAME_ARITHMETIC.degreesOf(Math.atan2(dy, dx));
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* A world displacement's direction in a bone's local space — the rotation a
|
|
302
|
+
* child of that bone states to point along it. `toBoneLocalVector`, then
|
|
303
|
+
* `directionDegrees`; a stop's frame (`src/stop.ts`) is aimed with it.
|
|
304
|
+
*/
|
|
305
|
+
export function localDirectionDegrees(m: BoneTransform, worldDX: number, worldDY: number): number {
|
|
306
|
+
const [lx, ly] = toBoneLocalVector(m, worldDX, worldDY);
|
|
307
|
+
return directionDegrees(lx, ly);
|
|
308
|
+
}
|
|
309
|
+
|
|
290
310
|
/**
|
|
291
311
|
* Normalise degrees into (-180, 180], which is how an editor shows a rotation.
|
|
292
312
|
*
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A render's box given rather than fitted — `rigc render --viewport` and
|
|
3
|
+
* `rigc render --viewport-from` (issue #1414) — and what the frames leave out of it.
|
|
4
|
+
*
|
|
5
|
+
* ⭐ **Why a box is given.** `render` fits its box to the rig it draws, so two
|
|
6
|
+
* candidates — a rig before and after a polish — land on two grids (377×480 against
|
|
7
|
+
* 385×480 on the run that asked for this), and a comparison of their pixels needs a
|
|
8
|
+
* transform written by hand. A given box puts both on one grid: the same world box
|
|
9
|
+
* and the same pixel size, so a world point lands on the same pixel in both.
|
|
10
|
+
*
|
|
11
|
+
* Two ways to give it, and only two:
|
|
12
|
+
*
|
|
13
|
+
* - `--viewport <x,y,w,h>` — a world box, y up, `x,y` its bottom-left corner: the
|
|
14
|
+
* spelling `check --viewport` and `inspect --viewport` already take, and the
|
|
15
|
+
* numbers `frames.json`'s `viewport` records. Its pixel size follows from `--max`
|
|
16
|
+
* as a fitted box's does.
|
|
17
|
+
* - `--viewport-from <frames.json | render root>` — an earlier render's grid exactly:
|
|
18
|
+
* its box, its scale and its pixel size as that `frames.json` records them, so the
|
|
19
|
+
* second render is on the first one's pixels to the bit. `--max` is refused beside
|
|
20
|
+
* it, because the file already fixes the pixel size.
|
|
21
|
+
*
|
|
22
|
+
* ⚠️ **Nothing is cropped silently.** A given box can leave part of the rig out,
|
|
23
|
+
* which a fitted box — measured over the rig's own poses at 60 fps and padded —
|
|
24
|
+
* is built not to. Each frame is the box — what lies past it is not
|
|
25
|
+
* drawn — so every slot whose drawn geometry reaches past it is named, by set and
|
|
26
|
+
* frame, with how far and on which side, and `frames.json` records the same list
|
|
27
|
+
* under `pinned` (`outsideViewport`).
|
|
28
|
+
*
|
|
29
|
+
* Pure: no clock, no randomness, no runtime.
|
|
30
|
+
*/
|
|
31
|
+
import { indexRuns } from './folds.ts';
|
|
32
|
+
import { FRAMES_SPEC, type Frame, type PinnedViewport, type Viewport, unionBounds, viewportOfSize } from './render_shared.ts';
|
|
33
|
+
|
|
34
|
+
/** A given box that cannot be used — said by name. */
|
|
35
|
+
export class ViewportError extends Error {}
|
|
36
|
+
|
|
37
|
+
/** `--viewport`'s value: four numbers, `x,y,w,h`, the world box, y up — refused by name otherwise. Shared by `render`, `check` and `inspect`. */
|
|
38
|
+
export function readViewportBox(raw: string): { x: number; y: number; width: number; height: number } {
|
|
39
|
+
const parts = raw.split(',').map((s) => Number(s.trim()));
|
|
40
|
+
if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n))) {
|
|
41
|
+
throw new ViewportError('--viewport takes four numbers: <x>,<y>,<width>,<height> — the world box, y up');
|
|
42
|
+
}
|
|
43
|
+
if (parts[2] <= 0 || parts[3] <= 0) throw new ViewportError('--viewport width and height must be positive');
|
|
44
|
+
return { x: parts[0], y: parts[1], width: parts[2], height: parts[3] };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The grid a `frames.json` records — `value` is the parsed file, `where` its path —
|
|
49
|
+
* as the viewport a second render draws on: the box, the scale and the pixel size
|
|
50
|
+
* exactly as written (`viewportOfSize`, the reading `check` takes of the same
|
|
51
|
+
* block). Refused naming the field and its value where the file is not a frame
|
|
52
|
+
* set's sidecar or its `viewport` is not a grid.
|
|
53
|
+
*/
|
|
54
|
+
export function viewportOfFramesFile(value: unknown, where: string): Viewport {
|
|
55
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new ViewportError(`--viewport-from ${where}: the file is not a JSON object`);
|
|
56
|
+
const file = value as Record<string, unknown>;
|
|
57
|
+
if (file.spec !== FRAMES_SPEC) {
|
|
58
|
+
throw new ViewportError(`--viewport-from ${where}: spec is ${JSON.stringify(file.spec)}, and the grid is read off a frame set's ${FRAMES_SPEC} sidecar — the frames.json \`render\` writes`);
|
|
59
|
+
}
|
|
60
|
+
const v = file.viewport;
|
|
61
|
+
if (typeof v !== 'object' || v === null || Array.isArray(v)) throw new ViewportError(`--viewport-from ${where}: no viewport object`);
|
|
62
|
+
const box = v as Record<string, unknown>;
|
|
63
|
+
const field = (name: string, rule: 'finite' | 'positive' | 'pixels'): number => {
|
|
64
|
+
const n = box[name];
|
|
65
|
+
const ok =
|
|
66
|
+
typeof n === 'number' &&
|
|
67
|
+
Number.isFinite(n) &&
|
|
68
|
+
(rule === 'finite' || n > 0) &&
|
|
69
|
+
(rule !== 'pixels' || Number.isInteger(n));
|
|
70
|
+
if (!ok) {
|
|
71
|
+
const need = rule === 'finite' ? 'a finite number' : rule === 'positive' ? 'a number above 0' : 'a whole number of pixels above 0';
|
|
72
|
+
throw new ViewportError(`--viewport-from ${where}: viewport.${name} is ${JSON.stringify(n) ?? 'absent'}; it must be ${need}`);
|
|
73
|
+
}
|
|
74
|
+
return n as number;
|
|
75
|
+
};
|
|
76
|
+
return viewportOfSize(
|
|
77
|
+
field('x', 'finite'),
|
|
78
|
+
field('y', 'finite'),
|
|
79
|
+
field('width', 'positive'),
|
|
80
|
+
field('height', 'positive'),
|
|
81
|
+
field('scale', 'positive'),
|
|
82
|
+
field('pixelWidth', 'pixels'),
|
|
83
|
+
field('pixelHeight', 'pixels'),
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The four sides of a box, world terms, y up, in the order a line names them. */
|
|
88
|
+
export const SIDES = ['left', 'right', 'bottom', 'top'] as const;
|
|
89
|
+
export type Side = (typeof SIDES)[number];
|
|
90
|
+
|
|
91
|
+
/** One slot that reaches past the box on one set: the frames, how far at most (world units) and on which sides. */
|
|
92
|
+
export interface OutsideReading {
|
|
93
|
+
set: string;
|
|
94
|
+
slot: string;
|
|
95
|
+
frames: number[];
|
|
96
|
+
/** The farthest any of its vertices reaches past the box, on any frame and side, world units. */
|
|
97
|
+
most: number;
|
|
98
|
+
sides: Side[];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* A wrong reading the suite plants to see its control go red (issue #1414):
|
|
103
|
+
* `ownBox` measures each set against the box its own frames fill rather than the
|
|
104
|
+
* given one — a reading that ignores the viewport.
|
|
105
|
+
*/
|
|
106
|
+
export interface OutsidePlant {
|
|
107
|
+
ownBox?: true;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Every slot whose drawn geometry reaches past `viewport` — a piece's posed
|
|
112
|
+
* vertex strictly outside the box, as the framing measures a piece — per set in
|
|
113
|
+
* `sets`' order, the slots in the order they first reach past it. Read off the
|
|
114
|
+
* frames as drawn: a slot `--slot`/`--hide` leaves out draws no piece and is not
|
|
115
|
+
* read, and a clip's cut is what is read.
|
|
116
|
+
*/
|
|
117
|
+
export function outsideViewport(sets: ReadonlyMap<string, readonly Frame[]>, viewport: Viewport, plant: OutsidePlant = {}): OutsideReading[] {
|
|
118
|
+
const out: OutsideReading[] = [];
|
|
119
|
+
for (const [set, frames] of sets) {
|
|
120
|
+
const box = plant.ownBox === true ? unionBounds([[...frames]]) : viewport;
|
|
121
|
+
const bySlot = new Map<string, { frames: Set<number>; most: number; sides: Set<Side> }>();
|
|
122
|
+
for (const frame of frames) {
|
|
123
|
+
for (const piece of frame.pieces) {
|
|
124
|
+
for (let i = 0; i + 1 < piece.world.length; i += 2) {
|
|
125
|
+
const x = piece.world[i];
|
|
126
|
+
const y = piece.world[i + 1];
|
|
127
|
+
const past: Array<[Side, number]> = [
|
|
128
|
+
['left', box.minX - x],
|
|
129
|
+
['right', x - box.maxX],
|
|
130
|
+
['bottom', box.minY - y],
|
|
131
|
+
['top', y - box.maxY],
|
|
132
|
+
];
|
|
133
|
+
for (const [side, by] of past) {
|
|
134
|
+
if (!(by > 0)) continue;
|
|
135
|
+
let slot = bySlot.get(piece.slot);
|
|
136
|
+
if (slot === undefined) {
|
|
137
|
+
slot = { frames: new Set(), most: 0, sides: new Set() };
|
|
138
|
+
bySlot.set(piece.slot, slot);
|
|
139
|
+
}
|
|
140
|
+
slot.frames.add(frame.index);
|
|
141
|
+
slot.most = Math.max(slot.most, by);
|
|
142
|
+
slot.sides.add(side);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
for (const [slot, read] of bySlot) {
|
|
148
|
+
out.push({ set, slot, frames: [...read.frames].sort((a, b) => a - b), most: read.most, sides: SIDES.filter((s) => read.sides.has(s)) });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** `frames.json`'s `pinned` record for a given box and its reading. */
|
|
155
|
+
export function pinnedRecord(by: PinnedViewport['by'], outside: readonly OutsideReading[]): PinnedViewport {
|
|
156
|
+
return { by, outside: outside.map(({ set, slot, frames }) => ({ set, slot, frames })) };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The printed line for one slot past the box: the set, the slot, the frames, how
|
|
161
|
+
* far at most and on which sides — and what happens to what lies past it.
|
|
162
|
+
*/
|
|
163
|
+
export function outsideLine(r: OutsideReading, fps: number): string {
|
|
164
|
+
return (
|
|
165
|
+
`${JSON.stringify(r.set)}: slot ${JSON.stringify(r.slot)} reaches past the box on ${r.frames.length} frame(s) [${indexRuns(r.frames)}] at ${fps} fps, ` +
|
|
166
|
+
`by up to ${r.most.toFixed(2)} units (${r.sides.join(', ')}) — what lies past the box is not drawn: each frame is the box`
|
|
167
|
+
);
|
|
168
|
+
}
|