@rig-c/parts 1.16.0 → 1.17.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/cli.ts +19 -6
- package/docs/AUTHORING.md +203 -15
- package/docs/STABILITY.md +8 -6
- package/package.json +1 -1
- package/skills/rig-parts/SKILL.md +3 -1
- package/src/assemble.ts +325 -23
- package/src/autoamplitude.ts +23 -3
- package/src/autoenvelope.ts +17 -5
- package/src/build.ts +78 -5
- package/src/check.ts +632 -11
- package/src/config.ts +185 -8
- package/src/limbs.ts +188 -0
- package/src/parts.ts +89 -4
- package/src/propose.ts +137 -107
- package/src/rig.ts +43 -8
package/src/config.ts
CHANGED
|
@@ -86,7 +86,7 @@
|
|
|
86
86
|
*/
|
|
87
87
|
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
88
88
|
import { dirname, resolve } from 'node:path';
|
|
89
|
-
import { RIG_SKIN_CONSTRAINT_KEYS, type RigConstraint, type RigSkinConstraintKey } from 'rig-c/src/rig.ts';
|
|
89
|
+
import { RIG_BONE_INHERIT, RIG_SKIN_CONSTRAINT_KEYS, type RigBoneInherit, type RigConstraint, type RigSkinConstraintKey } from 'rig-c/src/rig.ts';
|
|
90
90
|
import { GRID, MAX_SIDE } from './contour.ts';
|
|
91
91
|
import { type Problem, refuseIfAny } from './errors.ts';
|
|
92
92
|
import { isSkeletonName, KEYPOINT_NAMES, type KeypointName, type PosePoints, SKELETON_NAMES, type SkeletonName } from './skeleton.ts';
|
|
@@ -227,6 +227,15 @@ export interface SingleBone {
|
|
|
227
227
|
parent: string;
|
|
228
228
|
at: Point;
|
|
229
229
|
tip?: Point;
|
|
230
|
+
/**
|
|
231
|
+
* What the bone takes from its parent's transform (issue #348): one of rig-c's
|
|
232
|
+
* own mode names (`RIG_BONE_INHERIT`, rig-c's `RigBone.inherit`), passed to
|
|
233
|
+
* rig.json as written. Absent, rig.json carries no `inherit` and the bone
|
|
234
|
+
* takes the parent's whole transform (rig-c's default, `normal`). A spelling
|
|
235
|
+
* outside rig-c's table is refused by name (`CONFIG_BONE_INHERIT`): the
|
|
236
|
+
* runtime would fold the first letter, and this loader folds and maps nothing.
|
|
237
|
+
*/
|
|
238
|
+
inherit?: RigBoneInherit;
|
|
230
239
|
}
|
|
231
240
|
|
|
232
241
|
export interface ChainBones {
|
|
@@ -627,12 +636,37 @@ export interface ConfigConstraint {
|
|
|
627
636
|
[field: string]: unknown;
|
|
628
637
|
}
|
|
629
638
|
|
|
639
|
+
/**
|
|
640
|
+
* One segment of a limb (issue #349): the part it makes, the part whose
|
|
641
|
+
* pixels it takes (`from`: a plan part or a cut's piece), and the one bone it
|
|
642
|
+
* rides with weight 1. Every segment after the first sits below a joint at
|
|
643
|
+
* its bone's origin: `joint` names it, `range` is the bend the joint may take
|
|
644
|
+
* from rest, `[min, max]` degrees (metadata: read by the stress readers, not
|
|
645
|
+
* by rig-c), and `cap` an image, relative to the config's directory, of the
|
|
646
|
+
* cap that overlaps the joint (`limbCaps` in `src/assemble.ts`).
|
|
647
|
+
*/
|
|
648
|
+
export interface LimbSegment {
|
|
649
|
+
part: string;
|
|
650
|
+
from: string;
|
|
651
|
+
bone: string;
|
|
652
|
+
joint?: string;
|
|
653
|
+
range?: [number, number];
|
|
654
|
+
cap?: string;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/** A limb (issue #349): its segments, upper first; each segment's bone is the child of the one before. */
|
|
658
|
+
export interface Limb {
|
|
659
|
+
segments: LimbSegment[];
|
|
660
|
+
}
|
|
661
|
+
|
|
630
662
|
export interface CharacterConfig {
|
|
631
663
|
key: string;
|
|
632
664
|
note?: string;
|
|
633
665
|
generation?: Generation;
|
|
634
666
|
seethrough?: SeeThrough;
|
|
635
667
|
assemble: Assemble;
|
|
668
|
+
/** Limbs cut at their joints (issue #349): absent, no part is cut at a joint and no byte of any output moves. */
|
|
669
|
+
limbs?: Record<string, Limb>;
|
|
636
670
|
bones: BoneEntry[];
|
|
637
671
|
meshes: Record<string, MeshSpec>;
|
|
638
672
|
regions: Record<string, string>;
|
|
@@ -925,9 +959,12 @@ export interface AssembleConfig {
|
|
|
925
959
|
seethrough: SeeThrough;
|
|
926
960
|
assemble: Assemble;
|
|
927
961
|
scene?: SceneSpec;
|
|
962
|
+
/** Issue #349: read with `bones` (the joints are the bones' origins) when present. */
|
|
963
|
+
limbs?: Record<string, Limb>;
|
|
964
|
+
bones?: BoneEntry[];
|
|
928
965
|
}
|
|
929
966
|
|
|
930
|
-
const TOP_KEYS = ['key', 'generation', 'seethrough', 'assemble', 'bones', 'meshes', 'regions', 'motion', 'constraints', 'turn', 'scene'] as const;
|
|
967
|
+
const TOP_KEYS = ['key', 'generation', 'seethrough', 'assemble', 'limbs', 'bones', 'meshes', 'regions', 'motion', 'constraints', 'turn', 'scene'] as const;
|
|
931
968
|
|
|
932
969
|
/**
|
|
933
970
|
* The one partial entry point. It validates exactly what its door requires —
|
|
@@ -976,8 +1013,14 @@ function parseEarlyConfigFrom(raw: Json, door: EarlyDoor, base: string | null):
|
|
|
976
1013
|
}
|
|
977
1014
|
if (door === 'assemble') {
|
|
978
1015
|
if ('seethrough' in t) checkSeeThrough(c, t.seethrough);
|
|
979
|
-
|
|
1016
|
+
const made = 'assemble' in t ? checkAssemble(c, t.assemble, door) : { parts: [], patches: [], cuts: [] };
|
|
980
1017
|
if ('scene' in t) checkScene(c, t.scene);
|
|
1018
|
+
// Issue #349: the limbs' joints are their bones' origins, so the bones are read with them, and only then.
|
|
1019
|
+
if ('limbs' in t) {
|
|
1020
|
+
if (!('bones' in t)) c.fail('CONFIG_FIELD_PRESENT', 'config.bones', 'is absent; config.limbs names bones, and each joint is its bone\'s origin, so assemble reads the bones with the limbs');
|
|
1021
|
+
const names = 'bones' in t ? checkBones(c, t.bones) : null;
|
|
1022
|
+
checkLimbs(c, t.limbs, made, names);
|
|
1023
|
+
}
|
|
981
1024
|
}
|
|
982
1025
|
const pose = base === null ? null : poseBeside(c, t.generation, base);
|
|
983
1026
|
refuseIfAny(c.problems);
|
|
@@ -1124,9 +1167,10 @@ function parseConfigFrom(raw: Json, base: string | null): { config: CharacterCon
|
|
|
1124
1167
|
if ('seethrough' in t) checkSeeThrough(c, t.seethrough);
|
|
1125
1168
|
const { parts, patches, cuts } = 'assemble' in t ? checkAssemble(c, t.assemble, 'full') : { parts: [], patches: [], cuts: [] };
|
|
1126
1169
|
const names = 'bones' in t ? checkBones(c, t.bones) : { bones: new Set<string>([ROOT_BONE]), chains: new Map<string, number>(), parents: new Map<string, string>() };
|
|
1170
|
+
const limbs = 'limbs' in t ? checkLimbs(c, t.limbs, { parts, patches, cuts }, 'bones' in t ? names : null) : null;
|
|
1127
1171
|
if ('meshes' in t) checkMeshes(c, t.meshes, names.bones, names.chains);
|
|
1128
1172
|
if ('regions' in t) checkRegions(c, t.regions, names.bones);
|
|
1129
|
-
if ('meshes' in t && 'regions' in t && 'assemble' in t) checkCoverage(c, [...parts, ...cuts], patches, t.meshes, t.regions);
|
|
1173
|
+
if ('meshes' in t && 'regions' in t && 'assemble' in t) checkCoverage(c, [...parts, ...cuts], patches, t.meshes, t.regions, limbs);
|
|
1130
1174
|
if ('motion' in t) checkMotion(c, t.motion, names.bones, names.chains, 'regions' in t ? t.regions : undefined);
|
|
1131
1175
|
if ('constraints' in t) checkConstraints(c, t.constraints, names.bones, names.parents);
|
|
1132
1176
|
if ('turn' in t) checkTurn(c, t.turn, attachedParts(t.meshes, t.regions), names.bones);
|
|
@@ -1651,8 +1695,16 @@ function checkBones(c: Check, v: Json): { bones: Set<string>; chains: Map<string
|
|
|
1651
1695
|
}
|
|
1652
1696
|
return;
|
|
1653
1697
|
}
|
|
1654
|
-
const b = c.object(at, entry, ['name', 'parent', 'at'], ['tip']);
|
|
1698
|
+
const b = c.object(at, entry, ['name', 'parent', 'at'], ['tip', 'inherit']);
|
|
1655
1699
|
if (b === null) return;
|
|
1700
|
+
// Issue #348: rig-c's own mode names, as its table spells them; nothing is folded, mapped or defaulted.
|
|
1701
|
+
if ('inherit' in b && c.string(`${at}.inherit`, b.inherit) && !(RIG_BONE_INHERIT as readonly string[]).includes(b.inherit)) {
|
|
1702
|
+
c.fail(
|
|
1703
|
+
'CONFIG_BONE_INHERIT',
|
|
1704
|
+
`${at}.inherit`,
|
|
1705
|
+
`is ${show(b.inherit)}; one of rig-c's inherit modes, spelled as its table spells them, is required — ${RIG_BONE_INHERIT.join(', ')} (rig-c's RigBone.inherit; nothing here folds a case or maps a name)`,
|
|
1706
|
+
);
|
|
1707
|
+
}
|
|
1656
1708
|
if ('parent' in b && c.string(`${at}.parent`, b.parent) && !bones.has(b.parent)) {
|
|
1657
1709
|
c.fail('CONFIG_NAME_RESOLVES', `${at}.parent`, `names "${b.parent}", which is not "${ROOT_BONE}" or a bone declared above it; parents come first`);
|
|
1658
1710
|
}
|
|
@@ -2087,14 +2139,139 @@ function checkRegions(c: Check, v: Json, bones: Set<string>): void {
|
|
|
2087
2139
|
}
|
|
2088
2140
|
}
|
|
2089
2141
|
|
|
2142
|
+
/** What the loader read off `config.limbs` (issue #349): each limb part with its bone, and each part a limb takes, with the first limb taking it. */
|
|
2143
|
+
interface LimbNames {
|
|
2144
|
+
parts: Map<string, { bone: string; at: string }>;
|
|
2145
|
+
consumed: Map<string, string>;
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
/**
|
|
2149
|
+
* `config.limbs` (issue #349): `{<limb>: {segments: [{part, from, bone}, {part,
|
|
2150
|
+
* from, bone, joint, range?, cap?}, …]}}`, upper segment first. Refused field
|
|
2151
|
+
* by field: `part` a new part name (`CONFIG_PART_NAME`, `CONFIG_PART_UNIQUE`
|
|
2152
|
+
* against the plan, the patches, the cuts and every other limb part); `from`
|
|
2153
|
+
* a plan part or a cut's piece (`CONFIG_NAME_RESOLVES`), the segments of one
|
|
2154
|
+
* limb that take one part consecutive (`CONFIG_LIMB_FROM_RUN`: the cut
|
|
2155
|
+
* between them is a joint line, and a joint line has two sides); `bone` a
|
|
2156
|
+
* declared bone, each segment's bone the child of the one before
|
|
2157
|
+
* (`CONFIG_LIMB_BONES_CHAIN`: a bend is a rotation of the lower bone about
|
|
2158
|
+
* the joint, its origin); the first segment has no joint, every other names
|
|
2159
|
+
* one (`CONFIG_KEY_KNOWN`, `CONFIG_FIELD_PRESENT`); `range` two finite
|
|
2160
|
+
* degrees `[min, max]` with `min <= 0 <= max` (`CONFIG_LIMB_RANGE`: the range
|
|
2161
|
+
* is measured from rest, so rest is in it); `cap` a non-empty path. A limb
|
|
2162
|
+
* has two segments or more. With `names` null (no `bones`), bones are not
|
|
2163
|
+
* resolved. Returns the limb parts and the parts the limbs take.
|
|
2164
|
+
*/
|
|
2165
|
+
function checkLimbs(c: Check, v: Json, made: { parts: string[]; patches: string[]; cuts: string[] }, names: { bones: Set<string>; parents: Map<string, string> } | null): LimbNames {
|
|
2166
|
+
const out: LimbNames = { parts: new Map(), consumed: new Map() };
|
|
2167
|
+
if (!isPlainObject(v)) {
|
|
2168
|
+
c.fail('CONFIG_FIELD_TYPE', 'config.limbs', `is ${show(v)}; an object of limb name -> {"segments": [...]} is required`);
|
|
2169
|
+
return out;
|
|
2170
|
+
}
|
|
2171
|
+
const sources = new Set([...made.parts, ...made.cuts]);
|
|
2172
|
+
const firstAt = new Map<string, string>();
|
|
2173
|
+
for (const [limb, spec] of Object.entries(v)) {
|
|
2174
|
+
const at = `config.limbs.${limb}`;
|
|
2175
|
+
const o = c.object(at, spec, ['segments'], []);
|
|
2176
|
+
if (o === null || !('segments' in o)) continue;
|
|
2177
|
+
if (!Array.isArray(o.segments) || o.segments.length < 2) {
|
|
2178
|
+
c.fail('CONFIG_FIELD_TYPE', `${at}.segments`, `is ${show(o.segments)}; an array of two segments or more, upper first, is required — a limb is cut at a joint, and a joint has a segment on each side`);
|
|
2179
|
+
continue;
|
|
2180
|
+
}
|
|
2181
|
+
const segs = o.segments as Json[];
|
|
2182
|
+
const froms: Array<string | null> = [];
|
|
2183
|
+
const bones: Array<string | null> = [];
|
|
2184
|
+
segs.forEach((entry, i) => {
|
|
2185
|
+
const sat = `${at}.segments[${i}]`;
|
|
2186
|
+
const e = c.object(sat, entry, ['part', 'from', 'bone'], ['joint', 'range', 'cap']);
|
|
2187
|
+
froms.push(null);
|
|
2188
|
+
bones.push(null);
|
|
2189
|
+
if (e === null) return;
|
|
2190
|
+
// The upper segment sits below no joint; every other sits below one, which it names.
|
|
2191
|
+
if (i === 0) {
|
|
2192
|
+
for (const k of ['joint', 'range', 'cap'].filter((q) => q in e)) c.fail('CONFIG_KEY_KNOWN', `${sat}.${k}`, `is not a field on the upper segment; it sits below no joint, so ${k} belongs to the segment below the joint it names (segments[1] and on)`);
|
|
2193
|
+
} else if (!('joint' in e)) c.fail('CONFIG_FIELD_PRESENT', `${sat}.joint`, 'is absent and required; a segment after the first sits below a joint, at its bone\'s origin, and names it');
|
|
2194
|
+
if ('part' in e && c.string(`${sat}.part`, e.part)) {
|
|
2195
|
+
const name = e.part;
|
|
2196
|
+
if (!partNameOk(name)) c.fail('CONFIG_PART_NAME', `${sat}.part`, `names the part ${show(name)}; a part name is a file name, so it may not be empty, start with "." or hold a slash`);
|
|
2197
|
+
else if (made.parts.includes(name) || made.patches.includes(name) || made.cuts.includes(name)) {
|
|
2198
|
+
c.fail('CONFIG_PART_UNIQUE', `${sat}.part`, `names the part "${name}", which ${made.parts.includes(name) ? 'assemble.plan' : made.patches.includes(name) ? 'assemble.patches' : 'assemble.cuts'} already makes; a limb segment makes a part of its own`);
|
|
2199
|
+
} else if (firstAt.has(name)) c.fail('CONFIG_PART_UNIQUE', `${sat}.part`, `names the part "${name}" again (first at ${firstAt.get(name)})`);
|
|
2200
|
+
else {
|
|
2201
|
+
firstAt.set(name, sat);
|
|
2202
|
+
if ('bone' in e && typeof e.bone === 'string') out.parts.set(name, { bone: e.bone, at: `config.limbs.${limb} segment ${i}` });
|
|
2203
|
+
}
|
|
2204
|
+
}
|
|
2205
|
+
if ('from' in e) {
|
|
2206
|
+
if (typeof e.from !== 'string' || !sources.has(e.from)) {
|
|
2207
|
+
c.fail('CONFIG_NAME_RESOLVES', `${sat}.from`, `is ${show(e.from)}; a part named in assemble.plan or made by an assemble.cuts entry is required — the layer the segment's pixels are cut from`);
|
|
2208
|
+
} else {
|
|
2209
|
+
froms[i] = e.from;
|
|
2210
|
+
if (!out.consumed.has(e.from)) out.consumed.set(e.from, limb);
|
|
2211
|
+
}
|
|
2212
|
+
}
|
|
2213
|
+
if ('bone' in e && c.string(`${sat}.bone`, e.bone)) {
|
|
2214
|
+
if (names !== null && !names.bones.has(e.bone)) c.fail('CONFIG_NAME_RESOLVES', `${sat}.bone`, `is "${e.bone}"; a bone config.bones declares is required (a chain's links are <chain>0, <chain>1, …)`);
|
|
2215
|
+
else bones[i] = e.bone;
|
|
2216
|
+
}
|
|
2217
|
+
if ('joint' in e) c.string(`${sat}.joint`, e.joint);
|
|
2218
|
+
if ('range' in e) {
|
|
2219
|
+
const r = e.range;
|
|
2220
|
+
const ok = Array.isArray(r) && r.length === 2 && r.every((n) => typeof n === 'number' && Number.isFinite(n));
|
|
2221
|
+
if (!ok) c.fail('CONFIG_FIELD_TYPE', `${sat}.range`, `is ${show(r)}; [min, max], two finite numbers of degrees from rest, is required`);
|
|
2222
|
+
else {
|
|
2223
|
+
const [lo, hi] = r as [number, number];
|
|
2224
|
+
if (!(lo <= 0 && 0 <= hi)) c.fail('CONFIG_LIMB_RANGE', `${sat}.range`, `is [${lo}, ${hi}]; min <= 0 <= max is required — the range is the bend from rest, and rest (0) is a pose the joint takes`);
|
|
2225
|
+
}
|
|
2226
|
+
}
|
|
2227
|
+
if ('cap' in e && !(typeof e.cap === 'string' && e.cap !== '')) {
|
|
2228
|
+
c.fail('CONFIG_FIELD_TYPE', `${sat}.cap`, `is ${show(e.cap)}; a non-empty path to a PNG is required, relative to the config's directory — the cap's image over the box assemble's limb line prints`);
|
|
2229
|
+
}
|
|
2230
|
+
});
|
|
2231
|
+
froms.forEach((f, i) => {
|
|
2232
|
+
if (f === null || i === 0 || froms[i - 1] === f) return;
|
|
2233
|
+
const back = froms.slice(0, i - 1).lastIndexOf(f);
|
|
2234
|
+
if (back >= 0) {
|
|
2235
|
+
c.fail('CONFIG_LIMB_FROM_RUN', `${at}.segments[${i}].from`, `is "${f}", which segments[${back}] takes too with another part between them; the segments that take one part must be consecutive — the cut between two of them is the joint line, and it has two sides`);
|
|
2236
|
+
}
|
|
2237
|
+
});
|
|
2238
|
+
if (names !== null) {
|
|
2239
|
+
bones.forEach((b, i) => {
|
|
2240
|
+
if (i === 0 || b === null || bones[i - 1] === null) return;
|
|
2241
|
+
const parent = names.parents.get(b);
|
|
2242
|
+
if (parent !== bones[i - 1]) {
|
|
2243
|
+
c.fail('CONFIG_LIMB_BONES_CHAIN', `${at}.segments[${i}].bone`, `is "${b}", whose parent is ${parent === undefined ? 'not declared' : `"${parent}"`}; "${bones[i - 1]}", the bone of segments[${i - 1}], is required — the lower segment turns about the joint on a bone that hangs from the upper's`);
|
|
2244
|
+
}
|
|
2245
|
+
});
|
|
2246
|
+
}
|
|
2247
|
+
}
|
|
2248
|
+
return out;
|
|
2249
|
+
}
|
|
2250
|
+
|
|
2090
2251
|
/**
|
|
2091
2252
|
* Every plan part — and every cut's piece, which the caller lists with them —
|
|
2092
2253
|
* is exactly one of a mesh or a region, every patch is a region, and every
|
|
2093
2254
|
* mesh or region is one of those parts or a patch.
|
|
2094
2255
|
*/
|
|
2095
|
-
function checkCoverage(c: Check,
|
|
2256
|
+
function checkCoverage(c: Check, made: string[], patches: string[], meshes: Json, regions: Json, limbs: LimbNames | null = null): void {
|
|
2096
2257
|
const m = typeof meshes === 'object' && meshes !== null ? Object.keys(meshes) : [];
|
|
2097
2258
|
const r = typeof regions === 'object' && regions !== null ? Object.keys(regions) : [];
|
|
2259
|
+
// Issue #349: a part a limb takes is cut into the limb's parts and is not made itself; the limb's parts are.
|
|
2260
|
+
const parts = limbs === null ? made : [...made.filter((n) => !limbs.consumed.has(n)), ...limbs.parts.keys()];
|
|
2261
|
+
if (limbs !== null) {
|
|
2262
|
+
for (const name of [...m, ...r]) {
|
|
2263
|
+
const by = limbs.consumed.get(name);
|
|
2264
|
+
if (by !== undefined) {
|
|
2265
|
+
c.fail('CONFIG_NAME_RESOLVES', `config.${m.includes(name) ? 'meshes' : 'regions'}.${name}`, `names "${name}", which config.limbs.${by} cuts into its parts at the joints, so no part of that name is made — attach the limb's parts instead`);
|
|
2266
|
+
}
|
|
2267
|
+
}
|
|
2268
|
+
for (const [part, seg] of limbs.parts) {
|
|
2269
|
+
const bone = isPlainObject(regions) ? regions[part] : undefined;
|
|
2270
|
+
if (typeof bone === 'string' && bone !== seg.bone) {
|
|
2271
|
+
c.fail('CONFIG_LIMB_BONE', `config.regions.${part}`, `is "${bone}"; "${part}" is ${seg.at}, which rides "${seg.bone}" with weight 1, so config.regions.${part} "${seg.bone}" is required`);
|
|
2272
|
+
}
|
|
2273
|
+
}
|
|
2274
|
+
}
|
|
2098
2275
|
for (const patch of patches) {
|
|
2099
2276
|
if (m.includes(patch)) c.fail('CONFIG_PART_ATTACHED', `part "${patch}"`, `is an assemble.patches entry and has a meshes entry; a patch is a region — drop config.meshes.${patch} and name its bone in config.regions.${patch}`);
|
|
2100
2277
|
else if (!r.includes(patch)) c.fail('CONFIG_PART_ATTACHED', `part "${patch}"`, `is an assemble.patches entry with no regions entry; a patch is a region, so config.regions.${patch} naming its bone is required`);
|
|
@@ -2106,8 +2283,8 @@ function checkCoverage(c: Check, parts: string[], patches: string[], meshes: Jso
|
|
|
2106
2283
|
if (inM && inR) c.fail('CONFIG_PART_ATTACHED', `part "${part}"`, 'has both a meshes entry and a regions entry; exactly one is required');
|
|
2107
2284
|
}
|
|
2108
2285
|
for (const name of [...m, ...r]) {
|
|
2109
|
-
if (!parts.includes(name) && !patches.includes(name)) {
|
|
2110
|
-
c.fail('CONFIG_NAME_RESOLVES', `config.${m.includes(name) ? 'meshes' : 'regions'}.${name}`, 'names a part that neither assemble.plan nor assemble.patches makes (nor an assemble.cuts entry)');
|
|
2286
|
+
if (!parts.includes(name) && !patches.includes(name) && !(limbs?.consumed.has(name) ?? false)) {
|
|
2287
|
+
c.fail('CONFIG_NAME_RESOLVES', `config.${m.includes(name) ? 'meshes' : 'regions'}.${name}`, 'names a part that neither assemble.plan nor assemble.patches makes (nor an assemble.cuts entry, nor config.limbs)');
|
|
2111
2288
|
}
|
|
2112
2289
|
}
|
|
2113
2290
|
}
|
package/src/limbs.ts
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Limbs cut at their joints (issue #349): the geometry `assemble` cuts a
|
|
3
|
+
* declared limb's layer by, and the cap each lower segment carries past its
|
|
4
|
+
* joint. Pure arithmetic on rig pixels (y down, origin top-left, the space of
|
|
5
|
+
* `parts.json` and of `config.bones`); no clock, no randomness.
|
|
6
|
+
*
|
|
7
|
+
* - **Where a bone is.** A single bone sits at its `at` and ends at its `tip`
|
|
8
|
+
* (no tip: no end); a chain's link `k` sits at `points[k]` and ends at
|
|
9
|
+
* `points[k + 1]`, the last link at `tip` ({@link bonePlaces}).
|
|
10
|
+
* - **Which limb a pixel belongs to.** Every pixel of a part a limb takes
|
|
11
|
+
* goes to one limb: the one whose polyline — its segments' bone origins in
|
|
12
|
+
* order, then the last bone's end when it has one — passes nearest the
|
|
13
|
+
* pixel's centre, by squared Euclidean distance to the polyline's pieces; a
|
|
14
|
+
* tie goes to the limb `config.limbs` declares first ({@link nearestLimb}).
|
|
15
|
+
* Two legs in one `legwear` layer are split this way, with no polygon.
|
|
16
|
+
* - **The joint line.** The joint between segments `k − 1` and `k` is the
|
|
17
|
+
* origin of segment `k`'s bone. Its line runs through that point,
|
|
18
|
+
* perpendicular to the limb's axis there: the sum of the unit directions of
|
|
19
|
+
* the upper bone (its origin to the joint) and the lower bone (the joint to
|
|
20
|
+
* its end, when it has one; the upper's alone when it has none) — on a
|
|
21
|
+
* straight limb the plain perpendicular, on a bent one the bisector
|
|
22
|
+
* ({@link jointLine}). A pixel is past the line when its centre's dot
|
|
23
|
+
* product with the axis, from the joint, is above 0; a centre on the line
|
|
24
|
+
* stays on the upper side.
|
|
25
|
+
* - **Which segment a pixel belongs to.** Among the consecutive segments of a
|
|
26
|
+
* limb that take one part, a pixel starts on the first and moves down one
|
|
27
|
+
* segment for each joint line it is past, in order, stopping at the first it
|
|
28
|
+
* is not past ({@link segmentOf}). A segment whose part is not the previous
|
|
29
|
+
* segment's takes its limb's share of its own part whole: no line is cut
|
|
30
|
+
* between two layers (the shin's legwear and the foot's footwear).
|
|
31
|
+
* - **The limb's width at a joint.** The pixels of the limb's art (alpha above
|
|
32
|
+
* 8, both segments' parts) whose centre lies within half a pixel of the
|
|
33
|
+
* joint line, read along the line: the run of them through the joint — the
|
|
34
|
+
* pixel nearest the joint must lie within 1 px of it, and the run grows while
|
|
35
|
+
* neighbours along the line are at most 1.5 px apart (a diagonal line's
|
|
36
|
+
* centres are up to sqrt(2) apart) — from its first to its last, plus one
|
|
37
|
+
* pixel ({@link limbWidth}). The cap's radius is half of it.
|
|
38
|
+
* - **The cap.** The pixels whose centre lies within the radius of the joint
|
|
39
|
+
* and on the upper side of its line, inside the box
|
|
40
|
+
* {@link capBox} prints: the overlap the lower segment carries so a bend
|
|
41
|
+
* shows no gap. The tool invents none of them: each is the config's cap
|
|
42
|
+
* image's where that image is not transparent, else the layer's where the
|
|
43
|
+
* limb has art there, else missing — counted, and named in the limb line.
|
|
44
|
+
*/
|
|
45
|
+
import type { BoneEntry, Limb } from './config.ts';
|
|
46
|
+
|
|
47
|
+
export type Pt = [number, number];
|
|
48
|
+
|
|
49
|
+
/** A bone's origin and, when it has one, its end, in rig px. */
|
|
50
|
+
export interface BonePlace {
|
|
51
|
+
at: Pt;
|
|
52
|
+
end: Pt | null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Where every bone `bones` declares sits and ends (module comment). */
|
|
56
|
+
export function bonePlaces(bones: readonly BoneEntry[]): Map<string, BonePlace> {
|
|
57
|
+
const out = new Map<string, BonePlace>();
|
|
58
|
+
for (const b of bones) {
|
|
59
|
+
if ('chain' in b) {
|
|
60
|
+
b.points.forEach((p, k) => out.set(`${b.chain}${k}`, { at: [p[0], p[1]], end: k + 1 < b.points.length ? [b.points[k + 1][0], b.points[k + 1][1]] : [b.tip[0], b.tip[1]] }));
|
|
61
|
+
} else out.set(b.name, { at: [b.at[0], b.at[1]], end: b.tip === undefined ? null : [b.tip[0], b.tip[1]] });
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A limb's polyline: its segments' bone origins in order, then the last bone's end when it has one. */
|
|
67
|
+
export function limbPolyline(limb: Limb, places: ReadonlyMap<string, BonePlace>): Pt[] {
|
|
68
|
+
const pts = limb.segments.map((s) => (places.get(s.bone) as BonePlace).at);
|
|
69
|
+
const last = places.get(limb.segments[limb.segments.length - 1].bone) as BonePlace;
|
|
70
|
+
return last.end === null ? pts : [...pts, last.end];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The squared distance from `(x, y)` to the polyline `pts` (one point: to that point). */
|
|
74
|
+
export function polylineDist2(pts: readonly Pt[], x: number, y: number): number {
|
|
75
|
+
if (pts.length === 1) return (x - pts[0][0]) ** 2 + (y - pts[0][1]) ** 2;
|
|
76
|
+
let best = Infinity;
|
|
77
|
+
for (let i = 0; i + 1 < pts.length; i++) {
|
|
78
|
+
const [ax, ay] = pts[i];
|
|
79
|
+
const dx = pts[i + 1][0] - ax;
|
|
80
|
+
const dy = pts[i + 1][1] - ay;
|
|
81
|
+
const len2 = dx * dx + dy * dy;
|
|
82
|
+
const t = len2 === 0 ? 0 : Math.max(0, Math.min(1, ((x - ax) * dx + (y - ay) * dy) / len2));
|
|
83
|
+
const d = (x - ax - t * dx) ** 2 + (y - ay - t * dy) ** 2;
|
|
84
|
+
if (d < best) best = d;
|
|
85
|
+
}
|
|
86
|
+
return best;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The index, among `lines`, of the polyline nearest the pixel `(px, py)`'s centre; the first wins a tie. */
|
|
90
|
+
export function nearestLimb(lines: ReadonlyArray<readonly Pt[]>, px: number, py: number): number {
|
|
91
|
+
let best = 0;
|
|
92
|
+
let bestD = Infinity;
|
|
93
|
+
lines.forEach((l, i) => {
|
|
94
|
+
const d = polylineDist2(l, px + 0.5, py + 0.5);
|
|
95
|
+
if (d < bestD) {
|
|
96
|
+
bestD = d;
|
|
97
|
+
best = i;
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
return best;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** A joint's line: through `at`, perpendicular to the unit `axis` (pointing from the upper segment to the lower). */
|
|
104
|
+
export interface JointLine {
|
|
105
|
+
at: Pt;
|
|
106
|
+
axis: Pt;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The unit vector along `(x, y)`, or null when it has no length. */
|
|
110
|
+
function unit(x: number, y: number): Pt | null {
|
|
111
|
+
const l = Math.sqrt(x * x + y * y);
|
|
112
|
+
return l === 0 ? null : [x / l, y / l];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The joint line between a bone whose origin is `upper` and one whose origin
|
|
117
|
+
* is `at` and whose end is `end` (null: none) — module comment. Null when no
|
|
118
|
+
* axis exists: the upper bone has no length, or the limb folds straight back
|
|
119
|
+
* on itself.
|
|
120
|
+
*/
|
|
121
|
+
export function jointLine(upper: Pt, at: Pt, end: Pt | null): JointLine | null {
|
|
122
|
+
const u = unit(at[0] - upper[0], at[1] - upper[1]);
|
|
123
|
+
if (u === null) return null;
|
|
124
|
+
const w = end === null ? null : unit(end[0] - at[0], end[1] - at[1]);
|
|
125
|
+
const a = w === null ? u : unit(u[0] + w[0], u[1] + w[1]);
|
|
126
|
+
return a === null ? null : { at, axis: a };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Whether the pixel `(px, py)`'s centre lies past the line (on the lower segment's side); on the line is not past. */
|
|
130
|
+
export function pastJoint(line: JointLine, px: number, py: number): boolean {
|
|
131
|
+
return (px + 0.5 - line.at[0]) * line.axis[0] + (py + 0.5 - line.at[1]) * line.axis[1] > 0;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The segment, among `first .. last` (consecutive segments taking one part),
|
|
136
|
+
* a pixel belongs to: `first`, moved down one for each joint line in order it
|
|
137
|
+
* is past, stopping at the first it is not. `lines[k]` is the line above
|
|
138
|
+
* segment `k`.
|
|
139
|
+
*/
|
|
140
|
+
export function segmentOf(lines: ReadonlyArray<JointLine | null>, first: number, last: number, px: number, py: number): number {
|
|
141
|
+
let k = first;
|
|
142
|
+
while (k < last && pastJoint(lines[k + 1] as JointLine, px, py)) k++;
|
|
143
|
+
return k;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The limb's width at a joint (module comment): `art(x, y)` says whether the
|
|
148
|
+
* limb has art at a pixel; the box `[x0, y0, x1, y1]` (inclusive) bounds the
|
|
149
|
+
* search. Null when no pixel of the run lies within 1 px of the joint.
|
|
150
|
+
*/
|
|
151
|
+
export function limbWidth(line: JointLine, art: (x: number, y: number) => boolean, box: readonly [number, number, number, number]): number | null {
|
|
152
|
+
const [ax, ay] = line.axis;
|
|
153
|
+
const ts: number[] = [];
|
|
154
|
+
for (let y = box[1]; y <= box[3]; y++) {
|
|
155
|
+
for (let x = box[0]; x <= box[2]; x++) {
|
|
156
|
+
const dx = x + 0.5 - line.at[0];
|
|
157
|
+
const dy = y + 0.5 - line.at[1];
|
|
158
|
+
if (Math.abs(dx * ax + dy * ay) > 0.5 || !art(x, y)) continue;
|
|
159
|
+
ts.push(-dx * ay + dy * ax);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (ts.length === 0) return null;
|
|
163
|
+
ts.sort((a, b) => a - b);
|
|
164
|
+
let near = 0;
|
|
165
|
+
ts.forEach((t, i) => {
|
|
166
|
+
if (Math.abs(t) < Math.abs(ts[near])) near = i;
|
|
167
|
+
});
|
|
168
|
+
if (Math.abs(ts[near]) > 1) return null;
|
|
169
|
+
let lo = near;
|
|
170
|
+
let hi = near;
|
|
171
|
+
while (lo > 0 && ts[lo] - ts[lo - 1] <= 1.5) lo--;
|
|
172
|
+
while (hi + 1 < ts.length && ts[hi + 1] - ts[hi] <= 1.5) hi++;
|
|
173
|
+
return ts[hi] - ts[lo] + 1;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The box `[x, y, w, h]` (rig px) of the pixels whose centre lies within `radius` of `at`: the cap image's size and place. */
|
|
177
|
+
export function capBox(at: Pt, radius: number): [number, number, number, number] {
|
|
178
|
+
const x0 = Math.ceil(at[0] - radius - 0.5);
|
|
179
|
+
const y0 = Math.ceil(at[1] - radius - 0.5);
|
|
180
|
+
const x1 = Math.floor(at[0] + radius - 0.5);
|
|
181
|
+
const y1 = Math.floor(at[1] + radius - 0.5);
|
|
182
|
+
return [x0, y0, x1 - x0 + 1, y1 - y0 + 1];
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Whether the pixel `(px, py)`'s centre lies within `radius` of `at`. */
|
|
186
|
+
export function inDisc(at: Pt, radius: number, px: number, py: number): boolean {
|
|
187
|
+
return (px + 0.5 - at[0]) ** 2 + (py + 0.5 - at[1]) ** 2 <= radius * radius;
|
|
188
|
+
}
|
package/src/parts.ts
CHANGED
|
@@ -42,7 +42,10 @@
|
|
|
42
42
|
* cut from, which shares its `from`. The record is in draw order, and a piece
|
|
43
43
|
* drawn `"back"` comes before its base, so order cannot say which of two parts
|
|
44
44
|
* sharing a layer is the piece; this field does. A `parts.json` written before
|
|
45
|
-
* 1.6.0 has none, and still reads.
|
|
45
|
+
* 1.6.0 has none, and still reads. `limb`, written only on a part a
|
|
46
|
+
* `config.limbs` segment made (issue #349), names the limb, the segment's
|
|
47
|
+
* index (0 the upper), the part it was cut from (`of`), the one bone it rides
|
|
48
|
+
* and, below a joint, the joint and its cap ({@link LimbRecord}).
|
|
46
49
|
*
|
|
47
50
|
* ⭐ **A patch is 100 % source.** Every pixel of a `painting:` part is the
|
|
48
51
|
* painting's own, so it is all visible (the painting shows it; nothing of it
|
|
@@ -104,6 +107,49 @@ export interface PartRecord {
|
|
|
104
107
|
seam_override_px: number;
|
|
105
108
|
/** Absent when the part had no fringe pushed back (and in a `parts.json` the reference wrote). */
|
|
106
109
|
fringe_pushed_back?: Array<{ part: string; px: number }>;
|
|
110
|
+
/** Written only on a part a `config.limbs` segment made (issue #349). */
|
|
111
|
+
limb?: LimbRecord;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* A limb segment's provenance (issue #349): the limb, the segment's index
|
|
116
|
+
* (0 the upper), the part its pixels were cut from (`of`), the bone it rides
|
|
117
|
+
* with weight 1, and, on a segment below a joint, the joint's name and its cap.
|
|
118
|
+
*/
|
|
119
|
+
export interface LimbRecord {
|
|
120
|
+
name: string;
|
|
121
|
+
segment: number;
|
|
122
|
+
of: string;
|
|
123
|
+
bone: string;
|
|
124
|
+
joint?: string;
|
|
125
|
+
cap?: CapRecord;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The cap a lower segment carries past its joint (issue #349): the disc's
|
|
130
|
+
* centre (the joint, rig px), its radius (half the limb's width across the
|
|
131
|
+
* joint line, `width`), the box its image covers `[x, y, w, h]`, and its
|
|
132
|
+
* pixels on the upper side of the joint line counted by where each came from
|
|
133
|
+
* — the layer (`layer_px`), the config's cap image (`image_px`, only with one)
|
|
134
|
+
* — or none (`missing_px`: no pixel in the layer and none in an image).
|
|
135
|
+
*/
|
|
136
|
+
export interface CapRecord {
|
|
137
|
+
at: [number, number];
|
|
138
|
+
width: number;
|
|
139
|
+
radius: number;
|
|
140
|
+
box: [number, number, number, number];
|
|
141
|
+
layer_px: number;
|
|
142
|
+
image_px?: number;
|
|
143
|
+
missing_px: number;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const LIMB_KEYS = ['name', 'segment', 'of', 'bone', 'joint', 'cap'] as const;
|
|
147
|
+
const CAP_KEYS = ['at', 'width', 'radius', 'box', 'layer_px', 'image_px', 'missing_px'] as const;
|
|
148
|
+
|
|
149
|
+
/** The limb record with its keys in the fixed order, so two runs write the same bytes. */
|
|
150
|
+
function orderedLimb(l: LimbRecord): Record<string, unknown> {
|
|
151
|
+
const cap = l.cap === undefined ? undefined : Object.fromEntries(CAP_KEYS.filter((k) => (l.cap as CapRecord)[k] !== undefined).map((k) => [k, (l.cap as CapRecord)[k]]));
|
|
152
|
+
return Object.fromEntries(LIMB_KEYS.filter((k) => (k === 'cap' ? cap : l[k]) !== undefined).map((k) => [k, k === 'cap' ? cap : l[k]]));
|
|
107
153
|
}
|
|
108
154
|
|
|
109
155
|
/** One listed hole: its area, its box in rig pixels, and the parts beside it (`src/assemble.ts` `uncoveredHoles`). */
|
|
@@ -178,15 +224,18 @@ export const PART_KEYS = [
|
|
|
178
224
|
'merged_px',
|
|
179
225
|
'seam_override_px',
|
|
180
226
|
'fringe_pushed_back',
|
|
227
|
+
'limb',
|
|
181
228
|
] as const;
|
|
182
229
|
/** The one part field that is not a count: a list of `{part, px}`, written only when it is not empty. */
|
|
183
230
|
const FRINGE_KEY = 'fringe_pushed_back';
|
|
184
231
|
/** The one part field that names another part: a cut's piece names its base, written only on a piece. */
|
|
185
232
|
const CUT_FROM_KEY = 'cut_from';
|
|
186
|
-
|
|
233
|
+
/** The one part field that is a record of its own: a limb segment's provenance (issue #349), written only on one. */
|
|
234
|
+
const LIMB_KEY = 'limb';
|
|
235
|
+
const COUNT_KEYS = PART_KEYS.slice(2).filter((k) => k !== FRINGE_KEY && k !== CUT_FROM_KEY && k !== LIMB_KEY) as ReadonlyArray<(typeof PART_KEYS)[number]>;
|
|
187
236
|
/** The port's own counts: all three or none (none = a record the reference wrote). */
|
|
188
237
|
export const VISIBILITY_KEYS = ['visible_px', 'occluded_px', 'visible_not_projected_px'] as const;
|
|
189
|
-
const REQUIRED_KEYS = PART_KEYS.filter((k) => !(VISIBILITY_KEYS as readonly string[]).includes(k) && k !== FRINGE_KEY && k !== CUT_FROM_KEY);
|
|
238
|
+
const REQUIRED_KEYS = PART_KEYS.filter((k) => !(VISIBILITY_KEYS as readonly string[]).includes(k) && k !== FRINGE_KEY && k !== CUT_FROM_KEY && k !== LIMB_KEY);
|
|
190
239
|
|
|
191
240
|
/** Where a part came from: a See-through run and a v3 tag or an author-made layer's `author:<label>` (issue #256), or the painting itself for a patch. */
|
|
192
241
|
export type From = { run: 'full' | 'head'; tag: string } | { run: 'painting'; patch: string };
|
|
@@ -214,7 +263,7 @@ export function serializeParts(file: PartsFile): string {
|
|
|
214
263
|
rig_size: file.rig_size,
|
|
215
264
|
scale_rig_per_source: file.scale_rig_per_source,
|
|
216
265
|
parts: file.parts.map((p) =>
|
|
217
|
-
Object.fromEntries(PART_KEYS.filter((k) => p[k] !== undefined).map((k) => [k, k === FRINGE_KEY ? (p.fringe_pushed_back ?? []).map((q) => ({ part: q.part, px: q.px })) : p[k]])),
|
|
266
|
+
Object.fromEntries(PART_KEYS.filter((k) => p[k] !== undefined).map((k) => [k, k === FRINGE_KEY ? (p.fringe_pushed_back ?? []).map((q) => ({ part: q.part, px: q.px })) : k === LIMB_KEY ? orderedLimb(p.limb as LimbRecord) : p[k]])),
|
|
218
267
|
),
|
|
219
268
|
ghost_px: Object.fromEntries(Object.keys(file.ghost_px).sort().map((k) => [k, file.ghost_px[k]])),
|
|
220
269
|
...(file.recomposite === undefined
|
|
@@ -374,6 +423,42 @@ function checkParts(raw: unknown, path: string): PartsFile {
|
|
|
374
423
|
else if (b.from !== r.from) fail('PARTS_CUT_FROM_KNOWN', object, `names "${base}", whose from is ${show(b.from)}; this piece's is ${show(r.from)}, and a piece shares its base's layer`);
|
|
375
424
|
}
|
|
376
425
|
}
|
|
426
|
+
if (Array.isArray(o.parts)) {
|
|
427
|
+
// A limb segment (issue #349) names its limb, its index, the part it was cut from, its bone, and below a joint the joint and its cap.
|
|
428
|
+
for (const p of o.parts) {
|
|
429
|
+
if (typeof p !== 'object' || p === null || Array.isArray(p) || !(LIMB_KEY in p)) continue;
|
|
430
|
+
const r = p as Record<string, unknown>;
|
|
431
|
+
const object = `part "${String(r.name)}" field "${LIMB_KEY}"`;
|
|
432
|
+
const l = r[LIMB_KEY];
|
|
433
|
+
if (typeof l !== 'object' || l === null || Array.isArray(l)) {
|
|
434
|
+
fail('PARTS_FIELD_TYPE', object, `is ${show(l)}; an object {name, segment, of, bone, joint?, cap?} is required`);
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
const q = l as Record<string, unknown>;
|
|
438
|
+
for (const k of Object.keys(q)) if (!(LIMB_KEYS as readonly string[]).includes(k)) fail('PARTS_KEY_KNOWN', `${object} key "${k}"`, `is not a limb field; known: ${LIMB_KEYS.join(', ')}`);
|
|
439
|
+
for (const k of ['name', 'of', 'bone'] as const) if (!(typeof q[k] === 'string' && q[k] !== '')) fail('PARTS_FIELD_TYPE', `${object} "${k}"`, `is ${show(q[k])}; a non-empty string is required`);
|
|
440
|
+
if (!isCount(q.segment)) fail('PARTS_FIELD_TYPE', `${object} "segment"`, `is ${show(q.segment)}; the segment's index, a non-negative integer, is required`);
|
|
441
|
+
const below = isCount(q.segment) && q.segment > 0;
|
|
442
|
+
if (below !== ('joint' in q) || below !== ('cap' in q)) fail('PARTS_FIELD_PRESENT', object, `segment ${show(q.segment)} with${'joint' in q ? '' : 'out'} a joint and with${'cap' in q ? '' : 'out'} a cap; a segment below a joint (index above 0) has both, the upper segment neither`);
|
|
443
|
+
if ('joint' in q && !(typeof q.joint === 'string' && q.joint !== '')) fail('PARTS_FIELD_TYPE', `${object} "joint"`, `is ${show(q.joint)}; the joint's name is required`);
|
|
444
|
+
if ('cap' in q) {
|
|
445
|
+
const c = q.cap;
|
|
446
|
+
const co = typeof c === 'object' && c !== null && !Array.isArray(c) ? (c as Record<string, unknown>) : null;
|
|
447
|
+
const pt = (v: unknown, n: number): boolean => Array.isArray(v) && v.length === n && v.every((x) => typeof x === 'number' && Number.isFinite(x));
|
|
448
|
+
const ok =
|
|
449
|
+
co !== null &&
|
|
450
|
+
Object.keys(co).every((k) => (CAP_KEYS as readonly string[]).includes(k)) &&
|
|
451
|
+
pt(co.at, 2) &&
|
|
452
|
+
typeof co.width === 'number' &&
|
|
453
|
+
typeof co.radius === 'number' &&
|
|
454
|
+
pt(co.box, 4) &&
|
|
455
|
+
isCount(co.layer_px) &&
|
|
456
|
+
isCount(co.missing_px) &&
|
|
457
|
+
(!('image_px' in co) || isCount(co.image_px));
|
|
458
|
+
if (!ok) fail('PARTS_FIELD_TYPE', `${object} "cap"`, `is ${show(c)}; {at: [x, y], width, radius, box: [x, y, w, h], layer_px, image_px?, missing_px} is required`);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
377
462
|
if ('scene' in o) {
|
|
378
463
|
const sc = o.scene;
|
|
379
464
|
if (typeof sc !== 'object' || sc === null || Array.isArray(sc)) fail('PARTS_FIELD_TYPE', `${path} field "scene"`, `is ${show(sc)}; an object is required`);
|