@rig-c/parts 1.5.5 → 1.7.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 +16 -9
- package/cli.ts +19 -6
- package/docs/AUTHORING.md +110 -18
- package/docs/STABILITY.md +2 -2
- package/package.json +2 -2
- package/src/assemble.ts +329 -9
- package/src/build.ts +163 -11
- package/src/check.ts +224 -28
- package/src/instruments.ts +34 -10
- package/src/parts.ts +39 -2
- package/src/propose.ts +374 -45
- package/src/requirements.ts +58 -3
- package/src/rig.ts +214 -11
- package/src/scene.ts +14 -1
package/src/propose.ts
CHANGED
|
@@ -43,7 +43,13 @@
|
|
|
43
43
|
* including every `painting:` patch (`assemble.patches`), which has no tag,
|
|
44
44
|
* so no rule above ever reads it: it is never a mesh or a chain, it moves
|
|
45
45
|
* no bone the rules place, and the note says the bone is the config's to
|
|
46
|
-
* name in `regions.<name>`.
|
|
46
|
+
* name in `regions.<name>`. A cut's piece (`assemble.cuts`, recorded with
|
|
47
|
+
* `cut_from`) goes the same way (issue #230): it shares its base's tag, and
|
|
48
|
+
* the rules that run once per part of a tag would name the base's bones and
|
|
49
|
+
* chains a second time. The one exception is the sleeve rule, which names
|
|
50
|
+
* its chains by side and takes a cut sleeve as a blob of its own (#199). A
|
|
51
|
+
* `parts.json` sharing a layer between parts with no `cut_from` (an
|
|
52
|
+
* assemble before 1.6.0) is refused, `PROPOSE_CUT_FROM_RECORDED`.
|
|
47
53
|
*
|
|
48
54
|
* What it cannot know is written into `notes` when it guessed, and otherwise
|
|
49
55
|
* left to the person correcting: things hidden inside a layer (a sash tail
|
|
@@ -73,6 +79,14 @@
|
|
|
73
79
|
* never moved by a joint. `lint` then reads the torso and the sleeves by the
|
|
74
80
|
* relations the joints declare instead of by screen y. Without joints every
|
|
75
81
|
* value, note and LINT line is the one written before the option existed.
|
|
82
|
+
* One rule runs only with joints (issue #241): a `legwear` part holding the
|
|
83
|
+
* ankle of a leg whose hip, knee and ankle are given is meshed on a leg chain
|
|
84
|
+
* (hip -> knee, tip at the ankle; one per leg, or one through the midpoints
|
|
85
|
+
* when the part's art does not separate the legs — {@link legsFromJoints}),
|
|
86
|
+
* where without them it rides `hip` as a region. And with a joint at either
|
|
87
|
+
* end of the trunk (issue #243), the trunk parts' fixed segments run along
|
|
88
|
+
* neck -> hip, the line the chest is placed on, instead of down the screen
|
|
89
|
+
* ({@link trunkLineOf}).
|
|
76
90
|
*
|
|
77
91
|
* Issue #86 adds what the proposer and lint looked at, and changes nothing
|
|
78
92
|
* they wrote: {@link proposeWithBasis} records, at each placement, what the
|
|
@@ -236,6 +250,54 @@ function konst(name: string, value: number): BasisConstant {
|
|
|
236
250
|
return { name, value };
|
|
237
251
|
}
|
|
238
252
|
|
|
253
|
+
/**
|
|
254
|
+
* The `assemble` section {@link checkProposal} hands the loader, rebuilt from
|
|
255
|
+
* parts.json: every See-through part a plan entry or a cut, every patch a
|
|
256
|
+
* patches entry. Exported so a control can read which part went back as the
|
|
257
|
+
* plan part and which as the piece (issue #230).
|
|
258
|
+
*/
|
|
259
|
+
export function proposalAssemble(P: PartSet): Record<string, unknown> {
|
|
260
|
+
// A patch is not a plan entry; it goes back as the patches entry it came
|
|
261
|
+
// from, so the loader holds it to its own rule (a region, never a mesh).
|
|
262
|
+
// Its box, alpha and draw are the record's own box and placeholders the
|
|
263
|
+
// loader only type-checks: nothing here is written anywhere.
|
|
264
|
+
// A part that shares another's `from` is the piece of an `assemble.cuts`
|
|
265
|
+
// entry (issue #170) — the only way two parts share a layer — and goes back
|
|
266
|
+
// as a cut of its base, its polygon the record's box, a placeholder the
|
|
267
|
+
// loader only type-checks like the patches' fields. The piece names its base
|
|
268
|
+
// (`cut_from`, issue #230); file order cannot, as a piece drawn "back" comes
|
|
269
|
+
// before its base. A file with an unmarked shared layer (an assemble before
|
|
270
|
+
// 1.6.0, which propose refuses) goes back with the first part of the layer
|
|
271
|
+
// as the plan part, as it did before the field.
|
|
272
|
+
const run = P.recs.filter((r) => splitFrom(r.from)[0] !== PAINTING_RUN);
|
|
273
|
+
const painted = P.recs.filter((r) => splitFrom(r.from)[0] === PAINTING_RUN);
|
|
274
|
+
const firstOf = new Map<string, string>();
|
|
275
|
+
for (const r of run) if (r.cut_from === undefined && !firstOf.has(r.from)) firstOf.set(r.from, r.name);
|
|
276
|
+
const baseOf = (r: PartRecord): string => r.cut_from ?? (firstOf.get(r.from) as string);
|
|
277
|
+
const planned = run.filter((r) => r.cut_from === undefined && firstOf.get(r.from) === r.name);
|
|
278
|
+
const pieces = run.filter((r) => r.cut_from !== undefined || firstOf.get(r.from) !== r.name);
|
|
279
|
+
return {
|
|
280
|
+
rig_scale: 1,
|
|
281
|
+
plan: planned.map((r) => [r.name, ...splitFrom(r.from)]),
|
|
282
|
+
patches: painted.map((r) => ({ name: r.name, box: [r.x, r.y, r.x + r.w, r.y + r.h], alpha: 'box', draw: 'front' })),
|
|
283
|
+
...(pieces.length === 0
|
|
284
|
+
? {}
|
|
285
|
+
: {
|
|
286
|
+
cuts: pieces.map((r) => ({
|
|
287
|
+
from: baseOf(r),
|
|
288
|
+
into: r.name,
|
|
289
|
+
polygon: [
|
|
290
|
+
[r.x, r.y],
|
|
291
|
+
[r.x + r.w, r.y],
|
|
292
|
+
[r.x + r.w, r.y + r.h],
|
|
293
|
+
],
|
|
294
|
+
draw: 'front',
|
|
295
|
+
overlap: 0,
|
|
296
|
+
})),
|
|
297
|
+
}),
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
239
301
|
/**
|
|
240
302
|
* Hold the proposal to the config contract before it is written: it is copied
|
|
241
303
|
* into a config field by field, so a proposal the loader would refuse is a
|
|
@@ -246,42 +308,9 @@ function konst(name: string, value: number): BasisConstant {
|
|
|
246
308
|
* refusals.
|
|
247
309
|
*/
|
|
248
310
|
export function checkProposal(P: PartSet, p: Proposal): void {
|
|
249
|
-
// A patch is not a plan entry; it goes back as the patches entry it came
|
|
250
|
-
// from, so the loader holds it to its own rule (a region, never a mesh).
|
|
251
|
-
// Its box, alpha and draw are the record's own box and placeholders the
|
|
252
|
-
// loader only type-checks: nothing here is written anywhere.
|
|
253
|
-
// A part whose `from` an earlier part already has is the piece of an
|
|
254
|
-
// `assemble.cuts` entry (issue #170) — the only way two parts share a layer
|
|
255
|
-
// — and goes back as a cut of that earlier part, its polygon the record's
|
|
256
|
-
// box, a placeholder the loader only type-checks like the patches' fields.
|
|
257
|
-
const run = P.recs.filter((r) => splitFrom(r.from)[0] !== PAINTING_RUN);
|
|
258
|
-
const painted = P.recs.filter((r) => splitFrom(r.from)[0] === PAINTING_RUN);
|
|
259
|
-
const firstOf = new Map<string, string>();
|
|
260
|
-
for (const r of run) if (!firstOf.has(r.from)) firstOf.set(r.from, r.name);
|
|
261
|
-
const planned = run.filter((r) => firstOf.get(r.from) === r.name);
|
|
262
|
-
const pieces = run.filter((r) => firstOf.get(r.from) !== r.name);
|
|
263
311
|
parseConfig({
|
|
264
312
|
key: 'proposal',
|
|
265
|
-
assemble:
|
|
266
|
-
rig_scale: 1,
|
|
267
|
-
plan: planned.map((r) => [r.name, ...splitFrom(r.from)]),
|
|
268
|
-
patches: painted.map((r) => ({ name: r.name, box: [r.x, r.y, r.x + r.w, r.y + r.h], alpha: 'box', draw: 'front' })),
|
|
269
|
-
...(pieces.length === 0
|
|
270
|
-
? {}
|
|
271
|
-
: {
|
|
272
|
-
cuts: pieces.map((r) => ({
|
|
273
|
-
from: firstOf.get(r.from) as string,
|
|
274
|
-
into: r.name,
|
|
275
|
-
polygon: [
|
|
276
|
-
[r.x, r.y],
|
|
277
|
-
[r.x + r.w, r.y],
|
|
278
|
-
[r.x + r.w, r.y + r.h],
|
|
279
|
-
],
|
|
280
|
-
draw: 'front',
|
|
281
|
-
overlap: 0,
|
|
282
|
-
})),
|
|
283
|
-
}),
|
|
284
|
-
},
|
|
313
|
+
assemble: proposalAssemble(P),
|
|
285
314
|
bones: p.bones,
|
|
286
315
|
meshes: p.meshes,
|
|
287
316
|
regions: p.regions,
|
|
@@ -312,14 +341,33 @@ export class PartSet {
|
|
|
312
341
|
this.images = images;
|
|
313
342
|
}
|
|
314
343
|
|
|
315
|
-
/**
|
|
316
|
-
|
|
344
|
+
/**
|
|
345
|
+
* Parts whose `from` tag is exactly `tag`, optionally from one run, in
|
|
346
|
+
* parts.json order. A `painting:` patch has no tag and is never one, and a
|
|
347
|
+
* cut's piece (`cut_from`, issue #230) is not one either unless `pieces` is
|
|
348
|
+
* set: it shares its base's layer, and a rule that runs once per part of a
|
|
349
|
+
* tag would run for its base a second time — the same chains named twice.
|
|
350
|
+
*/
|
|
351
|
+
byTag(tag: string, run?: 'full' | 'head', pieces = false): PartRecord[] {
|
|
317
352
|
return this.recs.filter((p) => {
|
|
318
353
|
const [r, t] = splitFrom(p.from);
|
|
319
|
-
return r !== PAINTING_RUN && t === tag && (run === undefined || r === run);
|
|
354
|
+
return r !== PAINTING_RUN && t === tag && (run === undefined || r === run) && (pieces || p.cut_from === undefined);
|
|
320
355
|
});
|
|
321
356
|
}
|
|
322
357
|
|
|
358
|
+
/**
|
|
359
|
+
* The layers more than one part holds with no `cut_from` to tell the piece
|
|
360
|
+
* from its base: a `parts.json` an `assemble` before 1.6.0 wrote with
|
|
361
|
+
* `assemble.cuts`. parts.json is in draw order and a piece drawn `"back"`
|
|
362
|
+
* comes before its base, so which part takes the layer's rule cannot be
|
|
363
|
+
* read off the file. Each entry is the layer and its parts, in file order.
|
|
364
|
+
*/
|
|
365
|
+
unmarkedShares(): Array<{ from: string; parts: string[] }> {
|
|
366
|
+
const by = new Map<string, string[]>();
|
|
367
|
+
for (const p of this.layered()) if (p.cut_from === undefined) by.set(p.from, [...(by.get(p.from) ?? []), p.name]);
|
|
368
|
+
return [...by.entries()].filter(([, names]) => names.length > 1).map(([from, parts]) => ({ from, parts }));
|
|
369
|
+
}
|
|
370
|
+
|
|
323
371
|
/** The See-through parts: every part but the `painting:` patches. */
|
|
324
372
|
layered(): PartRecord[] {
|
|
325
373
|
return this.recs.filter((p) => splitFrom(p.from)[0] !== PAINTING_RUN);
|
|
@@ -830,6 +878,16 @@ export function propose(P: PartSet, joints?: RigJoints, wristSnap: number | null
|
|
|
830
878
|
* alone, and every value and note is the one written before it existed.
|
|
831
879
|
*/
|
|
832
880
|
export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: number | null = null): { proposal: Proposal; basis: ProposalBasis } {
|
|
881
|
+
// Issue #230: a piece is not given its base's rule a second time, which
|
|
882
|
+
// needs to know which part is the piece. A file that cannot say is refused
|
|
883
|
+
// here, and only here: every other reader of parts.json still takes it.
|
|
884
|
+
refuseIfAny(
|
|
885
|
+
P.unmarkedShares().map(({ from, parts }) => ({
|
|
886
|
+
code: 'PROPOSE_CUT_FROM_RECORDED',
|
|
887
|
+
object: `parts ${parts.map((n) => `"${n}"`).join(', ')} (${from})`,
|
|
888
|
+
detail: `share one layer and none of them records cut_from, so which is the plan part and which the piece an assemble.cuts entry took cannot be read off parts.json (it is in draw order, and a piece drawn "back" comes before its base); re-run assemble (1.6.0+) to record cut_from`,
|
|
889
|
+
})),
|
|
890
|
+
);
|
|
833
891
|
const bones: BoneEntry[] = [];
|
|
834
892
|
const records: BoneBasis[] = [];
|
|
835
893
|
// No prototype: a part may be named anything file-safe, "__proto__" included.
|
|
@@ -946,6 +1004,13 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
|
|
|
946
1004
|
// down the screen, because the torso need not be upright.
|
|
947
1005
|
const chestAlong = neckJ !== null || hipFromJoints;
|
|
948
1006
|
const chest: [number, number] = chestAlong ? [(neckAt[0] + hip[0]) / 2, (neckAt[1] + hip[1]) / 2] : [hip[0], neckY + 0.5 * (hip[1] - neckY)];
|
|
1007
|
+
// Issue #243: on that same line, neck -> hip, the trunk parts' fixed segments are laid too (neck, topwear,
|
|
1008
|
+
// bottomwear; {@link trunkSegments}), not down the screen through the bones' x. Without a joint at either end,
|
|
1009
|
+
// today's verticals; with the two ends on one point the line has no direction, and a note says the verticals stay.
|
|
1010
|
+
const trunk = chestAlong ? trunkLineOf(neckAt, hip) : null;
|
|
1011
|
+
if (chestAlong && trunk === null) {
|
|
1012
|
+
notes.push(`trunk segments: neck and hip are one point, ${fmtAt(hip)}, so neck -> hip declares no line; the trunk parts' segments are drafted down the screen, as without keypoints`);
|
|
1013
|
+
}
|
|
949
1014
|
// Each placement hands its basis with its value: one record per bone, in the order the bones are written.
|
|
950
1015
|
const B = (name: string, parent: string, at: readonly [number, number], why: Placement, tip?: readonly [number, number], tipWhy?: Placement): void => {
|
|
951
1016
|
bones.push(tip === undefined ? { name, parent, at: rnd(at) } : { name, parent, at: rnd(at), tip: rnd(tip) });
|
|
@@ -1018,6 +1083,8 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
|
|
|
1018
1083
|
const eyeless: string[] = [];
|
|
1019
1084
|
const eyelessSides = new Set<'r' | 'l'>();
|
|
1020
1085
|
for (const p of P.layered()) {
|
|
1086
|
+
// A cut's piece takes no tag's role (issue #230): it falls to the trunk rule below, which says so.
|
|
1087
|
+
if (p.cut_from !== undefined) continue;
|
|
1021
1088
|
const t = splitFrom(p.from)[1];
|
|
1022
1089
|
let role: string | undefined = TAG_REGION[t];
|
|
1023
1090
|
if (browOf.has(p.name)) role = browOf.get(p.name);
|
|
@@ -1376,14 +1443,16 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
|
|
|
1376
1443
|
grid: g,
|
|
1377
1444
|
r,
|
|
1378
1445
|
segments: [
|
|
1379
|
-
['chest', rnd([axis, neckY + 18]), rnd([axis, chest[1]])],
|
|
1446
|
+
trunk === null ? ['chest', rnd([axis, neckY + 18]), rnd([axis, chest[1]])] : ['chest', rnd(along(neckAt, trunk.u, 18)), rnd(chest)],
|
|
1380
1447
|
['head', rnd(head), rnd([axis, head[1] - 50])],
|
|
1381
1448
|
],
|
|
1382
1449
|
};
|
|
1383
1450
|
}
|
|
1384
1451
|
|
|
1385
1452
|
// ---- sleeves
|
|
1386
|
-
|
|
1453
|
+
// A cut's piece counts here (issue #230): the sleeve rule names its chains by side, not once per part, so a piece
|
|
1454
|
+
// never repeats one, and cutting one sleeve out of a both-arms part is how #199's note says to separate them.
|
|
1455
|
+
const hw = [...P.byTag('handwear-r', undefined, true), ...P.byTag('handwear-l', undefined, true)].filter((p) => p.opaque_px > 500);
|
|
1387
1456
|
const sides = new Map<'r' | 'l', PartRecord[]>();
|
|
1388
1457
|
for (const p of hw) {
|
|
1389
1458
|
const s = center(p)[0] < axis ? 'r' : 'l';
|
|
@@ -1520,10 +1589,17 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
|
|
|
1520
1589
|
const mask = P.alpha(p);
|
|
1521
1590
|
const top = chest[1] - 0.3 * fh;
|
|
1522
1591
|
const bot = p.y + p.h;
|
|
1523
|
-
const segs: Segment[] =
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1592
|
+
const segs: Segment[] =
|
|
1593
|
+
trunk === null
|
|
1594
|
+
? [
|
|
1595
|
+
['chest', rnd([chest[0], p.y]), rnd([chest[0], hip[1] - 20])],
|
|
1596
|
+
['hip', rnd([chest[0], hip[1] - 20]), rnd([chest[0], hip[1] + 100])],
|
|
1597
|
+
]
|
|
1598
|
+
: [
|
|
1599
|
+
// The part's top read along the trunk: the nearest its art comes to the neck end of the line.
|
|
1600
|
+
['chest', rnd(along(hip, trunk.u, artExtent(mask, p, hip, trunk.u)[0])), rnd(along(hip, trunk.u, -20))],
|
|
1601
|
+
['hip', rnd(along(hip, trunk.u, -20)), rnd(along(hip, trunk.u, 100))],
|
|
1602
|
+
];
|
|
1527
1603
|
if (bw.length > 0 && bot < hip[1] + 0.25 * fh) {
|
|
1528
1604
|
// A bodice that stops at the waist: robe chains down to the hem would fall off the art.
|
|
1529
1605
|
meshes[p.name] = { grid: g, r, segments: segs };
|
|
@@ -1577,10 +1653,19 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
|
|
|
1577
1653
|
const mask = P.alpha(p);
|
|
1578
1654
|
const bot = p.y + p.h;
|
|
1579
1655
|
const y0 = hip[1] + 20;
|
|
1580
|
-
|
|
1656
|
+
let segs: Segment[] = [
|
|
1581
1657
|
['chest', rnd([chest[0], chest[1] - 30]), rnd([chest[0], hip[1] - 20])],
|
|
1582
1658
|
['hip', rnd([p.x + 0.09 * p.w, hip[1] + 5]), rnd([p.x + 0.91 * p.w, hip[1] + 5])],
|
|
1583
1659
|
];
|
|
1660
|
+
if (trunk !== null) {
|
|
1661
|
+
// The waist line runs across the trunk, 5 px down it from the hip, over 0.09 to 0.91 of the art's width across it.
|
|
1662
|
+
const [s0, s1] = artExtent(mask, p, hip, trunk.v);
|
|
1663
|
+
const waist = along(hip, trunk.u, 5);
|
|
1664
|
+
segs = [
|
|
1665
|
+
['chest', rnd(along(chest, trunk.u, -30)), rnd(along(hip, trunk.u, -20))],
|
|
1666
|
+
['hip', rnd(along(waist, trunk.v, s0 + 0.09 * (s1 - s0))), rnd(along(waist, trunk.v, s0 + 0.91 * (s1 - s0)))],
|
|
1667
|
+
];
|
|
1668
|
+
}
|
|
1584
1669
|
for (const [tag, f, ph] of [
|
|
1585
1670
|
['skirt_r', 0.3, 0.3],
|
|
1586
1671
|
['skirt_c', 0.5, 0.34],
|
|
@@ -1604,6 +1689,41 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
|
|
|
1604
1689
|
meshes[p.name] = { grid: g, r, segments: segs };
|
|
1605
1690
|
}
|
|
1606
1691
|
|
|
1692
|
+
// ---- legs (issue #241)
|
|
1693
|
+
// Only with joints: a legwear part whose art holds the ankle of a leg whose hip, knee and ankle are all given is
|
|
1694
|
+
// meshed on a leg chain hip -> knee, tip at the ankle; without them no line below runs, and the part stays the
|
|
1695
|
+
// region the trunk rule gives it, its note unchanged. No idle track is written for a leg chain: the chain is
|
|
1696
|
+
// drafted whatever the pose, and a standing figure's legs are planted — whether they move is the scene's.
|
|
1697
|
+
const lw = P.byTag('legwear', undefined, true).filter((p) => p.opaque_px > 500);
|
|
1698
|
+
const legPlan = joints !== undefined && lw.length > 0 ? legsFromJoints(P, lw, joints) : null;
|
|
1699
|
+
if (legPlan !== null && legPlan.chains !== null) {
|
|
1700
|
+
for (const c of legPlan.chains) {
|
|
1701
|
+
const J = (joints as RigJoints).joints;
|
|
1702
|
+
const used = (n: KeypointName): Placement => placement('joint', `the ${n} joint, ${usedAs(J[n])}`, { joints: [jointRef(n)] });
|
|
1703
|
+
const mid = (what: string, a: KeypointName, b: KeypointName): Placement => placement('joint', `the midpoint of the ${a} and ${b} joints (${what}) — a point between two joints, not a joint`, { joints: [jointRef(a), jointRef(b)] });
|
|
1704
|
+
const why =
|
|
1705
|
+
c.side === null
|
|
1706
|
+
? [mid('the hips', 'r_hip', 'l_hip'), mid('the knees', 'r_knee', 'l_knee')]
|
|
1707
|
+
: [used(`${c.side}_hip`), used(`${c.side}_knee`)];
|
|
1708
|
+
const tipWhy = c.side === null ? mid('the ankles', 'r_ankle', 'l_ankle') : used(`${c.side}_ankle`);
|
|
1709
|
+
C(c.chain, 'hip', [c.hip, c.knee], c.ankle, why, tipWhy);
|
|
1710
|
+
}
|
|
1711
|
+
for (const p of lw) {
|
|
1712
|
+
const [g, r] = gridR(p);
|
|
1713
|
+
const segs: Segment[] = [];
|
|
1714
|
+
for (const c of legPlan.chains.filter((q) => q.part === p)) {
|
|
1715
|
+
// The sleeve's chest stub, laid along the thigh from the hip: 14 px above the hip to 2 px below it.
|
|
1716
|
+
const len = Math.hypot(c.knee[0] - c.hip[0], c.knee[1] - c.hip[1]);
|
|
1717
|
+
const d: [number, number] = [(c.knee[0] - c.hip[0]) / len, (c.knee[1] - c.hip[1]) / len];
|
|
1718
|
+
segs.push(['hip', rnd([c.hip[0] - 14 * d[0], c.hip[1] - 14 * d[1]]), rnd([c.hip[0] + 2 * d[0], c.hip[1] + 2 * d[1]])], c.chain);
|
|
1719
|
+
}
|
|
1720
|
+
meshes[p.name] = { grid: g, r, segments: segs };
|
|
1721
|
+
}
|
|
1722
|
+
notes.push(...legPlan.notes);
|
|
1723
|
+
} else if (legPlan !== null && legPlan.why !== null) {
|
|
1724
|
+
notes.push(`${lw.map((p) => p.name).join(', ')}: no leg chain — ${legPlan.why}; the trunk rule places ${lw.length === 1 ? 'it' : 'them'}`);
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1607
1727
|
// Anything no rule claimed rides its nearest trunk bone rigidly, so the rig stage never meets an unassigned part.
|
|
1608
1728
|
for (const p of P.recs) {
|
|
1609
1729
|
if (!(p.name in meshes) && !(p.name in regions)) {
|
|
@@ -1611,9 +1731,21 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
|
|
|
1611
1731
|
regions[p.name] = cy < neckY ? 'head' : cy < hip[1] ? 'chest' : 'hip';
|
|
1612
1732
|
if (splitFrom(p.from)[0] === PAINTING_RUN) {
|
|
1613
1733
|
notes.push(`${p.name} (${p.from}): a painting patch has no tag, so no rule -> region on ${regions[p.name]}, the nearest trunk bone; config.regions.${p.name} is the bone it rides`);
|
|
1734
|
+
} else if (p.cut_from !== undefined) {
|
|
1735
|
+
notes.push(
|
|
1736
|
+
`${p.name} (${p.from}): a piece an assemble.cuts entry took from ${p.cut_from}, so not given that layer's rule a second time (it would name ${p.cut_from}'s bones and chains again) -> region on ${regions[p.name]}, the nearest trunk bone; config.regions.${p.name} is the bone it rides, and a mesh in config.meshes is the author's to give it`,
|
|
1737
|
+
);
|
|
1614
1738
|
} else notes.push(`${p.name} (${p.from}): no rule -> region on ${regions[p.name]}`);
|
|
1615
1739
|
}
|
|
1616
1740
|
}
|
|
1741
|
+
if (trunk !== null) {
|
|
1742
|
+
const laid = [...P.headFirst('neck'), ...tw, ...bw].map((p) => p.name);
|
|
1743
|
+
if (laid.length > 0) {
|
|
1744
|
+
notes.push(
|
|
1745
|
+
`trunk segments (${laid.join(', ')}): laid along neck -> hip, the line the chest was placed on (${pyFixed((Math.atan2(trunk.u[0], trunk.u[1]) * 180) / Math.PI, 1)} degrees off the screen's downward vertical, positive towards +x): each [bone, from, to] segment the rule drafts down the screen runs along that line instead, and the waist segment across it`,
|
|
1746
|
+
);
|
|
1747
|
+
}
|
|
1748
|
+
}
|
|
1617
1749
|
if (joints !== undefined) {
|
|
1618
1750
|
notes.push(
|
|
1619
1751
|
...jointNotes(joints, {
|
|
@@ -1622,6 +1754,7 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
|
|
|
1622
1754
|
chest: chestAlong ? { neck: neckAt, hip } : null,
|
|
1623
1755
|
sleeveRule,
|
|
1624
1756
|
armWhy: armPlan === null ? null : armPlan.why,
|
|
1757
|
+
legs: legPlan === null ? null : legPlan.chains,
|
|
1625
1758
|
arms: armPlan === null ? null : armPlan.arms,
|
|
1626
1759
|
wristSnap,
|
|
1627
1760
|
eyes: (['r', 'l'] as const).map((s) => {
|
|
@@ -1638,6 +1771,55 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
|
|
|
1638
1771
|
// external keypoints (issue #75)
|
|
1639
1772
|
// ---------------------------------------------------------------------------
|
|
1640
1773
|
|
|
1774
|
+
/**
|
|
1775
|
+
* The trunk's line (issue #243), from the neck bone's origin to the hip's, when a joint placed either end: `u` is
|
|
1776
|
+
* its unit direction, down the trunk, and `v` the unit across it, `u` turned a quarter (`[u_y, -u_x]`). An upright
|
|
1777
|
+
* trunk's `u` is `[0, 1]` and its `v` `[1, 0]`, the screen's y and x, which is what the vertical drafts read: each
|
|
1778
|
+
* trunk segment is that draft with "down the screen" read as "along `u`" and "across the screen" as "along `v`".
|
|
1779
|
+
* `null` when the two ends are one point: a line through one point has no direction.
|
|
1780
|
+
*/
|
|
1781
|
+
interface TrunkLine {
|
|
1782
|
+
u: [number, number];
|
|
1783
|
+
v: [number, number];
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
function trunkLineOf(neck: readonly [number, number], hip: readonly [number, number]): TrunkLine | null {
|
|
1787
|
+
const dx = hip[0] - neck[0];
|
|
1788
|
+
const dy = hip[1] - neck[1];
|
|
1789
|
+
const len = Math.hypot(dx, dy);
|
|
1790
|
+
if (len === 0) return null;
|
|
1791
|
+
const u: [number, number] = [dx / len, dy / len];
|
|
1792
|
+
return { u, v: [u[1], -u[0]] };
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
/** The point `k` px from `p` along the unit direction `d`. */
|
|
1796
|
+
function along(p: readonly [number, number], d: readonly [number, number], k: number): [number, number] {
|
|
1797
|
+
return [p[0] + k * d[0], p[1] + k * d[1]];
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
/**
|
|
1801
|
+
* The extent of a part's art along the unit direction `d`, measured from `o`: the least and the greatest
|
|
1802
|
+
* projection onto `d` of its opaque pixels, each taken as the unit square it covers. An axis-aligned box tight on
|
|
1803
|
+
* its art therefore projects onto `[1, 0]` as its own left and right edges and onto `[0, 1]` as its top and bottom,
|
|
1804
|
+
* the values the vertical drafts read off the box. A part with no opaque pixel is read as its box.
|
|
1805
|
+
*/
|
|
1806
|
+
function artExtent(mask: Mask, p: PartRecord, o: readonly [number, number], d: readonly [number, number]): [number, number] {
|
|
1807
|
+
let lo = Infinity;
|
|
1808
|
+
let hi = -Infinity;
|
|
1809
|
+
for (let y = Math.max(0, p.y); y < Math.min(mask.height, p.y + p.h); y++) {
|
|
1810
|
+
for (let x = Math.max(0, p.x); x < Math.min(mask.width, p.x + p.w); x++) {
|
|
1811
|
+
if (mask.data[y * mask.width + x] !== 1) continue;
|
|
1812
|
+
const t = (x - o[0]) * d[0] + (y - o[1]) * d[1];
|
|
1813
|
+
if (t < lo) lo = t;
|
|
1814
|
+
if (t > hi) hi = t;
|
|
1815
|
+
}
|
|
1816
|
+
}
|
|
1817
|
+
const w = lo === Infinity ? p.w : 1;
|
|
1818
|
+
const h = lo === Infinity ? p.h : 1;
|
|
1819
|
+
if (lo === Infinity) lo = hi = (p.x - o[0]) * d[0] + (p.y - o[1]) * d[1];
|
|
1820
|
+
return [lo + Math.min(0, d[0]) * w + Math.min(0, d[1]) * h, hi + Math.max(0, d[0]) * w + Math.max(0, d[1]) * h];
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1641
1823
|
/**
|
|
1642
1824
|
* How far a wrist joint may lie from a handwear part's art and still be on
|
|
1643
1825
|
* it: the LINT's own tolerance, the part's mask dilated this many px each way
|
|
@@ -1750,6 +1932,128 @@ function armsFromJoints(P: PartSet, hw: readonly PartRecord[], joints: RigJoints
|
|
|
1750
1932
|
return { arms, why: null };
|
|
1751
1933
|
}
|
|
1752
1934
|
|
|
1935
|
+
/**
|
|
1936
|
+
* One leg chain drafted from joints (issue #241): its name, the side whose
|
|
1937
|
+
* joints it runs through (`null` for the one chain that carries both legs,
|
|
1938
|
+
* through the midpoints of the two hips, the two knees and the two ankles),
|
|
1939
|
+
* its two links and tip in rig px, and the legwear part it meshes.
|
|
1940
|
+
*/
|
|
1941
|
+
interface LegChain {
|
|
1942
|
+
chain: string;
|
|
1943
|
+
side: 'r' | 'l' | null;
|
|
1944
|
+
hip: [number, number];
|
|
1945
|
+
knee: [number, number];
|
|
1946
|
+
ankle: [number, number];
|
|
1947
|
+
part: PartRecord;
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
/**
|
|
1951
|
+
* Whether `m`'s art breaks along the straight line from `a` to `b`: walked
|
|
1952
|
+
* one pixel step at a time (the larger of the x and y gaps, in steps of at
|
|
1953
|
+
* most one pixel, each point read as {@link isOn} reads it), some point with
|
|
1954
|
+
* no art lies between two points with art. Art that only starts or stops
|
|
1955
|
+
* along the line — an end point off the art — is no break.
|
|
1956
|
+
*/
|
|
1957
|
+
function artBreaks(m: Mask, a: readonly [number, number], b: readonly [number, number]): boolean {
|
|
1958
|
+
const n = Math.ceil(Math.max(Math.abs(b[0] - a[0]), Math.abs(b[1] - a[1])));
|
|
1959
|
+
let seenArt = false;
|
|
1960
|
+
let gapAfterArt = false;
|
|
1961
|
+
for (let i = 0; i <= n; i++) {
|
|
1962
|
+
const t = n === 0 ? 0 : i / n;
|
|
1963
|
+
const on = isOn(m, a[0] + t * (b[0] - a[0]), a[1] + t * (b[1] - a[1]));
|
|
1964
|
+
if (on && gapAfterArt) return true;
|
|
1965
|
+
if (on) seenArt = true;
|
|
1966
|
+
else if (seenArt) gapAfterArt = true;
|
|
1967
|
+
}
|
|
1968
|
+
return false;
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
/**
|
|
1972
|
+
* The leg chains a keypoint file drafts for the legwear parts, or why none
|
|
1973
|
+
* is drafted (issue #241) — the sleeve's {@link armsFromJoints} for the legs.
|
|
1974
|
+
* A leg is complete when its hip, knee and ankle all have a position and the
|
|
1975
|
+
* hip and knee are two points; a legwear part carries a leg when the ankle
|
|
1976
|
+
* lies on its art within the LINT's own tolerance ({@link WRIST_ON_PART_PX}).
|
|
1977
|
+
* The joints are used only when every complete leg's ankle is on exactly one
|
|
1978
|
+
* part and every legwear part carries one, as for the sleeves; when a leg is
|
|
1979
|
+
* complete and they are not, `why` says so. With no complete leg nothing is
|
|
1980
|
+
* drafted and `why` is `null`: the proposal is the one written before the
|
|
1981
|
+
* rule existed.
|
|
1982
|
+
*
|
|
1983
|
+
* One chain or two is read off the part and the joints. A part carrying one
|
|
1984
|
+
* leg gets that leg's chain, `leg_<side>`. A part carrying both gets one
|
|
1985
|
+
* chain per leg only when its art breaks ({@link artBreaks}) along the line
|
|
1986
|
+
* from r_knee to l_knee and along the line from r_ankle to l_ankle — the
|
|
1987
|
+
* legs painted apart at both; otherwise the legs touch, cross or overlap in
|
|
1988
|
+
* its art, two chains would pull one stretch of it two ways, and it gets one
|
|
1989
|
+
* chain, `leg`, through the midpoints of the hips, the knees and the ankles.
|
|
1990
|
+
* Each part gets one note saying which and why.
|
|
1991
|
+
*/
|
|
1992
|
+
function legsFromJoints(P: PartSet, lw: readonly PartRecord[], joints: RigJoints): { chains: LegChain[] | null; notes: string[]; why: string | null } {
|
|
1993
|
+
const complete: Array<{ side: 'r' | 'l'; hip: [number, number]; knee: [number, number]; ankle: [number, number] }> = [];
|
|
1994
|
+
const gaps: string[] = [];
|
|
1995
|
+
for (const s of ['r', 'l'] as const) {
|
|
1996
|
+
const hp = joints.joints[`${s}_hip`].at;
|
|
1997
|
+
const kn = joints.joints[`${s}_knee`].at;
|
|
1998
|
+
const an = joints.joints[`${s}_ankle`].at;
|
|
1999
|
+
if (hp === null || kn === null || an === null) {
|
|
2000
|
+
const absent = [hp === null ? `${s}_hip` : null, kn === null ? `${s}_knee` : null, an === null ? `${s}_ankle` : null].filter((n): n is string => n !== null);
|
|
2001
|
+
gaps.push(`${absent.join(', ')} ${absent.length === 1 ? 'has' : 'have'} no position`);
|
|
2002
|
+
} else if (hp[0] === kn[0] && hp[1] === kn[1]) gaps.push(`${s}_hip and ${s}_knee are one point, so the thigh has no direction`);
|
|
2003
|
+
else complete.push({ side: s, hip: hp, knee: kn, ankle: an });
|
|
2004
|
+
}
|
|
2005
|
+
// No complete leg is the file before this rule existed: nothing is said, and the part stays the trunk rule's.
|
|
2006
|
+
if (complete.length === 0) return { chains: null, notes: [], why: null };
|
|
2007
|
+
const near = new Map(lw.map((p) => [p, dilate(P.alpha(p), 2 * WRIST_ON_PART_PX + 1)]));
|
|
2008
|
+
const bound: Array<(typeof complete)[number] & { part: PartRecord }> = [];
|
|
2009
|
+
for (const g of complete) {
|
|
2010
|
+
const on = lw.filter((p) => isOn(near.get(p) as Mask, g.ankle[0], g.ankle[1]));
|
|
2011
|
+
if (on.length !== 1) {
|
|
2012
|
+
const where = on.length === 0 ? `the art of no legwear part within ${WRIST_ON_PART_PX} px` : `the art of ${on.length} legwear parts (${on.map((p) => p.name).join(', ')})`;
|
|
2013
|
+
return { chains: null, notes: [], why: `${g.side}_ankle at ${fmtAt(g.ankle)} lies on ${where}, so it does not say which part its leg is` };
|
|
2014
|
+
}
|
|
2015
|
+
bound.push({ ...g, part: on[0] });
|
|
2016
|
+
}
|
|
2017
|
+
const idle = lw.filter((p) => !bound.some((g) => g.part === p));
|
|
2018
|
+
if (idle.length > 0) {
|
|
2019
|
+
const why = gaps.length > 0 ? ` (${gaps.join('; ')})` : '';
|
|
2020
|
+
return { chains: null, notes: [], why: `${idle.map((p) => p.name).join(', ')} ${idle.length === 1 ? 'carries' : 'carry'} the ankle of no leg whose three joints are given${why}` };
|
|
2021
|
+
}
|
|
2022
|
+
const chains: LegChain[] = [];
|
|
2023
|
+
const notes: string[] = [];
|
|
2024
|
+
const still = 'no idle track is written for it: the chain is drafted whatever the pose, and whether the legs move is the scene\'s to key or drive';
|
|
2025
|
+
for (const p of lw) {
|
|
2026
|
+
const on = bound.filter((g) => g.part === p);
|
|
2027
|
+
if (on.length === 1) {
|
|
2028
|
+
const g = on[0];
|
|
2029
|
+
chains.push({ chain: `leg_${g.side}`, side: g.side, hip: g.hip, knee: g.knee, ankle: g.ankle, part: p });
|
|
2030
|
+
const other = g.side === 'r' ? 'l' : 'r';
|
|
2031
|
+
const lack = complete.some((q) => q.side === other) ? `${other}_ankle lies on another part` : gaps.join('; ');
|
|
2032
|
+
notes.push(`${p.name} (${p.from}): one leg chain, leg_${g.side}, hip -> knee -> ankle from the ${g.side} joints — ${g.side}_ankle lies on its art and no ${other} leg is bound to it (${lack}); ${still}`);
|
|
2033
|
+
continue;
|
|
2034
|
+
}
|
|
2035
|
+
const [r, l] = on;
|
|
2036
|
+
const m = P.alpha(p);
|
|
2037
|
+
const kneesBreak = artBreaks(m, r.knee, l.knee);
|
|
2038
|
+
const anklesBreak = artBreaks(m, r.ankle, l.ankle);
|
|
2039
|
+
if (kneesBreak && anklesBreak) {
|
|
2040
|
+
for (const g of on) chains.push({ chain: `leg_${g.side}`, side: g.side, hip: g.hip, knee: g.knee, ankle: g.ankle, part: p });
|
|
2041
|
+
notes.push(
|
|
2042
|
+
`${p.name} (${p.from}): one chain per leg, leg_r and leg_l, sharing its mesh — both ankles lie on its art, and its art breaks along the line from r_knee to l_knee and along the line from r_ankle to l_ankle, so the legs are painted apart; each runs hip -> knee -> ankle from its side's joints, and ${still}`,
|
|
2043
|
+
);
|
|
2044
|
+
continue;
|
|
2045
|
+
}
|
|
2046
|
+
const mid = (a: [number, number], b: [number, number]): [number, number] => [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
|
|
2047
|
+
chains.push({ chain: 'leg', side: null, hip: mid(r.hip, l.hip), knee: mid(r.knee, l.knee), ankle: mid(r.ankle, l.ankle), part: p });
|
|
2048
|
+
const unbroken = [kneesBreak ? null : 'from r_knee to l_knee', anklesBreak ? null : 'from r_ankle to l_ankle'].filter((s): s is string => s !== null);
|
|
2049
|
+
notes.push(
|
|
2050
|
+
`${p.name} (${p.from}): one leg chain, leg, for both legs — both ankles lie on its art, and its art runs unbroken along the line ${unbroken.join(' and along the line ')}, so the legs touch, cross or overlap in it and a chain per leg would pull one stretch of art two ways; ` +
|
|
2051
|
+
`the chain runs hip -> knee -> ankle through the midpoints of the two hips, the two knees and the two ankles, and ${still}`,
|
|
2052
|
+
);
|
|
2053
|
+
}
|
|
2054
|
+
return { chains, notes, why: null };
|
|
2055
|
+
}
|
|
2056
|
+
|
|
1753
2057
|
/** A rig-px point as the notes and LINT lines print it: one decimal place. */
|
|
1754
2058
|
function fmtAt(p: readonly [number, number]): string {
|
|
1755
2059
|
return `[${pyFixed(p[0], 1)}, ${pyFixed(p[1], 1)}]`;
|
|
@@ -1775,6 +2079,8 @@ interface JointNoteFacts {
|
|
|
1775
2079
|
sleeveRule: string;
|
|
1776
2080
|
armWhy: string | null;
|
|
1777
2081
|
arms: JointArm[] | null;
|
|
2082
|
+
/** The leg chains drafted (issue #241), or `null` when none was: then no line here names a leg joint. */
|
|
2083
|
+
legs: LegChain[] | null;
|
|
1778
2084
|
/** `propose --wrist-snap`, or `null` without it. */
|
|
1779
2085
|
wristSnap: number | null;
|
|
1780
2086
|
eyes: ReadonlyArray<{ bone: string; part: string; at: [number, number] } | null>;
|
|
@@ -1844,6 +2150,29 @@ function jointNotes(joints: RigJoints, f: JointNoteFacts): string[] {
|
|
|
1844
2150
|
`to separate them, cut one sleeve out of ${both} with an assemble.cuts entry (a polygon round it) and propose again`,
|
|
1845
2151
|
);
|
|
1846
2152
|
}
|
|
2153
|
+
// Issue #241: the knees and ankles a leg chain was drafted from, and the hips as its first link.
|
|
2154
|
+
for (const c of f.legs ?? []) {
|
|
2155
|
+
const on = `the ankle${c.side === null ? 's lie' : ' lies'} on ${c.part.name}`;
|
|
2156
|
+
if (c.side === null) {
|
|
2157
|
+
out.push(`chain leg: link 0 is the midpoint of r_hip and l_hip, ${fmtAt(c.hip)} — the point bone hip stands on`);
|
|
2158
|
+
for (const [joint, role, pair] of [
|
|
2159
|
+
['r_knee', 'link 1', 'r_knee and l_knee'],
|
|
2160
|
+
['l_knee', 'link 1', 'r_knee and l_knee'],
|
|
2161
|
+
['r_ankle', 'the tip', 'r_ankle and l_ankle'],
|
|
2162
|
+
['l_ankle', 'the tip', 'r_ankle and l_ankle'],
|
|
2163
|
+
] as const) {
|
|
2164
|
+
tell(joint, `${said(joint, J[joint])}: ${usedAs(J[joint])} for chain leg, ${role}, the midpoint of ${pair} — a point between two joints, not a joint (${on})`);
|
|
2165
|
+
}
|
|
2166
|
+
continue;
|
|
2167
|
+
}
|
|
2168
|
+
out.push(`chain leg_${c.side}: link 0 is the ${c.side}_hip joint, ${fmtAt(c.hip)}`);
|
|
2169
|
+
for (const [joint, role] of [
|
|
2170
|
+
[`${c.side}_knee`, 'link 1'],
|
|
2171
|
+
[`${c.side}_ankle`, 'the tip'],
|
|
2172
|
+
] as const) {
|
|
2173
|
+
tell(joint, `${said(joint, J[joint])}: ${usedAs(J[joint])} for chain leg_${c.side}, ${role} (${on})`);
|
|
2174
|
+
}
|
|
2175
|
+
}
|
|
1847
2176
|
(['r', 'l'] as const).forEach((s, i) => {
|
|
1848
2177
|
const n = `${s}_eye` as const;
|
|
1849
2178
|
const j = J[n];
|
package/src/requirements.ts
CHANGED
|
@@ -147,6 +147,28 @@ export interface RequirementsFile {
|
|
|
147
147
|
fps: number;
|
|
148
148
|
targets: SceneTarget[];
|
|
149
149
|
requirements: Requirement[];
|
|
150
|
+
/**
|
|
151
|
+
* Issue #231: the parts the scene's figure does not stand on, by name — each
|
|
152
|
+
* a part `check` would otherwise read as a planted foot (a `footwear` part),
|
|
153
|
+
* which the feet halves of `BREATH_VISIBLE` and `STILL_REGIONS_DARK` then
|
|
154
|
+
* name as declared and do not hold still. Empty when the file does not
|
|
155
|
+
* declare it: every footwear part is a planted foot, as before.
|
|
156
|
+
*/
|
|
157
|
+
not_planted: string[];
|
|
158
|
+
/**
|
|
159
|
+
* Issue #231: the swinging parts (`handwear`, `bottomwear`) the scene does
|
|
160
|
+
* not mean to swing — a piece cut off and left still — by name.
|
|
161
|
+
* `TIP_OVER_ROOT` names each in its line and does not hold it to the bar.
|
|
162
|
+
* Empty when not declared.
|
|
163
|
+
*/
|
|
164
|
+
not_swinging: string[];
|
|
165
|
+
/**
|
|
166
|
+
* Issue #231: the swinging parts whose tip is up — a raised forearm, its
|
|
167
|
+
* chain rising from the elbow — by name. `TIP_OVER_ROOT` reads their upper
|
|
168
|
+
* half as the tip's, after checking that the chain carrying each does rise,
|
|
169
|
+
* and names each in its line. Empty when not declared: every part hangs.
|
|
170
|
+
*/
|
|
171
|
+
rises: string[];
|
|
150
172
|
}
|
|
151
173
|
|
|
152
174
|
/** What `check --requirements` and `build --requirements` name, said once for the help and the refusals. */
|
|
@@ -182,10 +204,24 @@ const KIND_FIELDS: Record<RequirementKind, readonly string[]> = {
|
|
|
182
204
|
|
|
183
205
|
const NAME = /^[A-Za-z][A-Za-z0-9_]*$/;
|
|
184
206
|
|
|
207
|
+
/** The three lists of parts a scene excepts from a bar that assumes a standing figure (issue #231), in the order they are read. */
|
|
208
|
+
export const EXCEPTION_LISTS = ['not_planted', 'not_swinging', 'rises'] as const;
|
|
209
|
+
type ExceptionList = (typeof EXCEPTION_LISTS)[number];
|
|
210
|
+
|
|
211
|
+
/** What each list's absence declares. */
|
|
212
|
+
const EXCEPTION_ABSENT: Record<ExceptionList, string> = {
|
|
213
|
+
not_planted: 'every footwear part a planted foot',
|
|
214
|
+
not_swinging: 'every handwear and bottomwear part one that swings',
|
|
215
|
+
rises: 'every swinging part one that hangs',
|
|
216
|
+
};
|
|
217
|
+
|
|
185
218
|
/**
|
|
186
219
|
* Read and shape-check a requirements file. Every problem is collected before
|
|
187
220
|
* one refusal: the file, `spec`, `fps` (required, a positive number: no
|
|
188
|
-
* default), `targets` (optional)
|
|
221
|
+
* default), `targets` (optional), `requirements` (required, possibly empty)
|
|
222
|
+
* and `not_planted`, `not_swinging` and `rises` (each optional, issue #231: a
|
|
223
|
+
* non-empty list of distinct part names, no part in both of the last two;
|
|
224
|
+
* which parts they are is read against parts.json and the rig by `check`),
|
|
189
225
|
* each entry's every field and bar by name, a key the format does not read,
|
|
190
226
|
* and a name used twice. Resolving the names against the rig is
|
|
191
227
|
* {@link resolveRequirements}'s.
|
|
@@ -209,7 +245,7 @@ export function readRequirements(path: string): RequirementsFile {
|
|
|
209
245
|
problems.push({ code: 'REQUIREMENTS_FIELD', object, detail });
|
|
210
246
|
};
|
|
211
247
|
if (raw.spec !== REQUIREMENTS_SPEC) field(`${at} field "spec"`, `is ${JSON.stringify(raw.spec)}; ${JSON.stringify(REQUIREMENTS_SPEC)} is required`);
|
|
212
|
-
for (const k of Object.keys(raw)) if (!['spec', 'fps', 'targets', 'requirements'].includes(k) && !freeKey(k, raw[k])) field(`${at} field "${k}"`, 'is not a field this format reads (spec, fps, targets, requirements; note or *_note as a string; x-<name> as a record)');
|
|
248
|
+
for (const k of Object.keys(raw)) if (!['spec', 'fps', 'targets', 'requirements', ...EXCEPTION_LISTS].includes(k) && !freeKey(k, raw[k])) field(`${at} field "${k}"`, 'is not a field this format reads (spec, fps, targets, requirements, not_planted, not_swinging, rises; note or *_note as a string; x-<name> as a record)');
|
|
213
249
|
let fps = NaN;
|
|
214
250
|
if (!(finite(raw.fps) && raw.fps > 0)) field(`${at} field "fps"`, `is ${JSON.stringify(raw.fps)}; a positive number of frames per second is required — every named animation is sampled at it, and there is no default`);
|
|
215
251
|
else fps = raw.fps;
|
|
@@ -336,8 +372,27 @@ export function readRequirements(path: string): RequirementsFile {
|
|
|
336
372
|
});
|
|
337
373
|
}
|
|
338
374
|
}
|
|
375
|
+
const lists = {} as Record<ExceptionList, string[]>;
|
|
376
|
+
for (const key of EXCEPTION_LISTS) {
|
|
377
|
+
const names: string[] = [];
|
|
378
|
+
lists[key] = names;
|
|
379
|
+
const v = raw[key];
|
|
380
|
+
if (v === undefined) continue;
|
|
381
|
+
if (!Array.isArray(v) || v.length === 0) {
|
|
382
|
+
field(`${at} field "${key}"`, `is ${JSON.stringify(v)?.slice(0, 60)}; a non-empty list of part names is required when the field is given — leave it out to declare ${EXCEPTION_ABSENT[key]}`);
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
v.forEach((n, i) => {
|
|
386
|
+
if (typeof n !== 'string' || n.length === 0) field(`${at} field "${key}"[${i}]`, `is ${JSON.stringify(n)?.slice(0, 40)}; a part name is required`);
|
|
387
|
+
else if (names.includes(n)) field(`${at} field "${key}"[${i}]`, `names "${n}" a second time; each part is declared once`);
|
|
388
|
+
else names.push(n);
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
lists.rises.forEach((n, i) => {
|
|
392
|
+
if (lists.not_swinging.includes(n)) field(`${at} field "rises"[${i}]`, `names "${n}", which "not_swinging" names too; a part that does not swing has no tip to read up or down`);
|
|
393
|
+
});
|
|
339
394
|
refuseIfAny(problems);
|
|
340
|
-
return { path: at, fps, targets, requirements };
|
|
395
|
+
return { path: at, fps, targets, requirements, not_planted: lists.not_planted, not_swinging: lists.not_swinging, rises: lists.rises };
|
|
341
396
|
}
|
|
342
397
|
|
|
343
398
|
// ---------------------------------------------------------------------------
|