@rig-c/parts 1.19.0 → 1.21.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 -3
- package/cli.ts +24 -6
- package/docs/AUTHORING.md +172 -12
- package/docs/MIGRATION.md +39 -0
- package/docs/PAINTING.md +4 -3
- package/docs/STABILITY.md +11 -7
- package/package.json +2 -2
- package/skills/rig-parts/SKILL.md +17 -3
- package/src/assemble.ts +109 -21
- package/src/build.ts +20 -2
- package/src/check.ts +397 -7
- package/src/cloth.ts +378 -0
- package/src/config.ts +199 -18
- package/src/limbs.ts +87 -5
- package/src/parts.ts +44 -6
- package/src/propose.ts +187 -15
- package/src/rig.ts +21 -0
- package/src/scene.ts +1 -1
- package/src/structure.ts +2 -1
package/src/config.ts
CHANGED
|
@@ -87,6 +87,8 @@
|
|
|
87
87
|
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
88
88
|
import { dirname, resolve } from 'node:path';
|
|
89
89
|
import { RIG_BONE_INHERIT, RIG_SKIN_CONSTRAINT_KEYS, type RigBoneInherit, type RigConstraint, type RigSkinConstraintKey } from 'rig-c/src/rig.ts';
|
|
90
|
+
import { pyRound } from './round.ts';
|
|
91
|
+
import { CLOTH_TARGETS, type ClothRecipes, RECIPE_STRENGTH, RIGC_DEFAULT_DAMPING, springStable } from './cloth.ts';
|
|
90
92
|
import { GRID, MAX_SIDE } from './contour.ts';
|
|
91
93
|
import { type Problem, refuseIfAny } from './errors.ts';
|
|
92
94
|
import { isSkeletonName, KEYPOINT_NAMES, type KeypointName, type PosePoints, SKELETON_NAMES, type SkeletonName } from './skeleton.ts';
|
|
@@ -211,6 +213,16 @@ export interface Cut {
|
|
|
211
213
|
* pixels; it is read nowhere else. Absent: the base keeps only its band.
|
|
212
214
|
*/
|
|
213
215
|
under?: string;
|
|
216
|
+
/**
|
|
217
|
+
* Issue #360 (item 11), optional, one value: `"colour"` — where the under
|
|
218
|
+
* fills a removed pixel, the base takes the under's colour and keeps the
|
|
219
|
+
* alpha the cut removed there, so the part's silhouette is the uncut
|
|
220
|
+
* layer's and never grows. The under is `under`'s image or the part's
|
|
221
|
+
* hidden-art layer (issue #359); a cut with neither is refused by the
|
|
222
|
+
* assemble stage (`ASSEMBLE_UNDER_FILL_SOURCE`). Absent: the base takes the
|
|
223
|
+
* under's colour and alpha, issue #193's rule.
|
|
224
|
+
*/
|
|
225
|
+
under_fill?: 'colour';
|
|
214
226
|
}
|
|
215
227
|
|
|
216
228
|
export interface Assemble {
|
|
@@ -641,9 +653,15 @@ export interface ConfigConstraint {
|
|
|
641
653
|
* pixels it takes (`from`: a plan part or a cut's piece), and the one bone it
|
|
642
654
|
* rides with weight 1. Every segment after the first sits below a joint at
|
|
643
655
|
* its bone's origin: `joint` names it, `range` is the bend the joint may take
|
|
644
|
-
* from rest, `[min, max]` degrees (
|
|
645
|
-
* by rig-c), and `cap` an
|
|
646
|
-
*
|
|
656
|
+
* from rest, `[min, max]` degrees (carried into `parts.json` and posed by
|
|
657
|
+
* `check`'s `SEAM_OVER_RANGE`, issue #360; not read by rig-c), and `cap` an
|
|
658
|
+
* image, relative to the config's directory, of the cap that overlaps the
|
|
659
|
+
* joint (`limbCaps` in `src/assemble.ts`). Two opt-in fields shape the cut at
|
|
660
|
+
* the joint (issue #360, `src/limbs.ts`): `edge: "feather"` fades the upper
|
|
661
|
+
* piece out across the cap, and `cap_shape: "disc"` sizes the cap by the
|
|
662
|
+
* limb's half-width about the joint and fills it from the art, the upper
|
|
663
|
+
* segment's included. Absent, the cut
|
|
664
|
+
* is hard and the cap the layer's half-disc of half the limb's width.
|
|
647
665
|
*/
|
|
648
666
|
export interface LimbSegment {
|
|
649
667
|
part: string;
|
|
@@ -652,6 +670,8 @@ export interface LimbSegment {
|
|
|
652
670
|
joint?: string;
|
|
653
671
|
range?: [number, number];
|
|
654
672
|
cap?: string;
|
|
673
|
+
edge?: 'feather';
|
|
674
|
+
cap_shape?: 'disc';
|
|
655
675
|
}
|
|
656
676
|
|
|
657
677
|
/** A limb (issue #349): its segments, upper first; each segment's bone is the child of the one before. */
|
|
@@ -676,6 +696,8 @@ export interface CharacterConfig {
|
|
|
676
696
|
turn?: TurnSpec;
|
|
677
697
|
/** The painting is a scene (issue #242): absent, no byte of any output moves. */
|
|
678
698
|
scene?: SceneSpec;
|
|
699
|
+
/** The cloth and hair recipe `propose --cloth` drafts from (issue #364, `src/cloth.ts`): read by propose only, so no byte of any rig output moves with it. */
|
|
700
|
+
cloth?: ClothRecipes;
|
|
679
701
|
}
|
|
680
702
|
|
|
681
703
|
/** `config.scene` (issue #242): the declaration that the painting is a scene. It holds no field today. */
|
|
@@ -909,7 +931,7 @@ export function loadConfigAndAnimations(path: string): { config: CharacterConfig
|
|
|
909
931
|
export type EarlyDoor = 'paint' | 'layers' | 'assemble';
|
|
910
932
|
|
|
911
933
|
/** Every loader: the three early doors, and `full` — `parseConfig`, in front of `rig`, `build` and `propose --from-config`/`--compare`. */
|
|
912
|
-
export type ConfigDoor = EarlyDoor | 'full';
|
|
934
|
+
export type ConfigDoor = EarlyDoor | 'full' | 'cloth';
|
|
913
935
|
|
|
914
936
|
/**
|
|
915
937
|
* 🔒 The one statement of what each loader requires, as dotted field paths.
|
|
@@ -923,6 +945,7 @@ export const CONFIG_REQUIRES: Readonly<Record<ConfigDoor, readonly string[]>> =
|
|
|
923
945
|
layers: ['key', 'seethrough', 'assemble.rig_scale'],
|
|
924
946
|
assemble: ['key', 'seethrough', 'assemble.rig_scale', 'assemble.plan'],
|
|
925
947
|
full: ['key', 'assemble.rig_scale', 'assemble.plan', 'bones', 'meshes', 'regions', 'motion'],
|
|
948
|
+
cloth: ['key', 'cloth'],
|
|
926
949
|
};
|
|
927
950
|
|
|
928
951
|
/** The top-level keys a loader requires, in `CONFIG_REQUIRES` order. */
|
|
@@ -964,7 +987,7 @@ export interface AssembleConfig {
|
|
|
964
987
|
bones?: BoneEntry[];
|
|
965
988
|
}
|
|
966
989
|
|
|
967
|
-
const TOP_KEYS = ['key', 'generation', 'seethrough', 'assemble', 'limbs', 'bones', 'meshes', 'regions', 'motion', 'constraints', 'turn', 'scene'] as const;
|
|
990
|
+
const TOP_KEYS = ['key', 'generation', 'seethrough', 'assemble', 'limbs', 'bones', 'meshes', 'regions', 'motion', 'constraints', 'turn', 'scene', 'cloth'] as const;
|
|
968
991
|
|
|
969
992
|
/**
|
|
970
993
|
* The one partial entry point. It validates exactly what its door requires —
|
|
@@ -1022,6 +1045,7 @@ function parseEarlyConfigFrom(raw: Json, door: EarlyDoor, base: string | null):
|
|
|
1022
1045
|
checkLimbs(c, t.limbs, made, names);
|
|
1023
1046
|
}
|
|
1024
1047
|
}
|
|
1048
|
+
if ('cloth' in t) checkCloth(c, t.cloth);
|
|
1025
1049
|
const pose = base === null ? null : poseBeside(c, t.generation, base);
|
|
1026
1050
|
refuseIfAny(c.problems);
|
|
1027
1051
|
return { config: raw as PaintConfig | EarlyConfig | AssembleConfig, pose };
|
|
@@ -1175,6 +1199,7 @@ function parseConfigFrom(raw: Json, base: string | null): { config: CharacterCon
|
|
|
1175
1199
|
if ('constraints' in t) checkConstraints(c, t.constraints, names.bones, names.parents);
|
|
1176
1200
|
if ('turn' in t) checkTurn(c, t.turn, attachedParts(t.meshes, t.regions), names.bones);
|
|
1177
1201
|
if ('scene' in t) checkScene(c, t.scene);
|
|
1202
|
+
if ('cloth' in t) checkCloth(c, t.cloth);
|
|
1178
1203
|
const from = isPlainObject(t.motion) ? t.motion.animations_from : undefined;
|
|
1179
1204
|
const animations = base !== null && typeof from === 'string' && from !== '' ? readAnimationsFrom(c, resolve(base, from)) : null;
|
|
1180
1205
|
if (base !== null) poseBeside(c, t.generation, base);
|
|
@@ -1333,25 +1358,36 @@ export interface SkeletonSections {
|
|
|
1333
1358
|
|
|
1334
1359
|
/**
|
|
1335
1360
|
* A `proposal.json` as `propose` writes it — `bones`, `meshes`, `regions`,
|
|
1336
|
-
* `motion` and `notes` (strings)
|
|
1337
|
-
*
|
|
1338
|
-
*
|
|
1339
|
-
*
|
|
1340
|
-
*
|
|
1361
|
+
* `motion` and `notes` (strings), `limbs` when `propose --keypoints` drafted
|
|
1362
|
+
* them (issue #349) and `constraints` when `propose --cloth` did (issue #364;
|
|
1363
|
+
* both read here since issue #372, whose `wind` example tracks such a
|
|
1364
|
+
* proposal) — under the full loader's own rules for those sections (issue
|
|
1365
|
+
* #85). There is no plan to hold the meshes, regions and limbs against, so
|
|
1366
|
+
* the coverage rule (every plan part exactly one of a mesh or a region) is
|
|
1367
|
+
* not run, and a limb segment's `from` is held to no plan part (the parts
|
|
1368
|
+
* the limbs take are the ones they name); `propose` held its proposal to
|
|
1369
|
+
* both before writing (`checkProposal`). A key the loader would not take is
|
|
1341
1370
|
* refused by name, as in a config. Refusals name the fields `proposal.…`.
|
|
1342
1371
|
*/
|
|
1343
|
-
export function parseProposalSections(raw: Json): SkeletonSections {
|
|
1372
|
+
export function parseProposalSections(raw: Json): SkeletonSections & { limbs?: Record<string, Limb>; constraints?: ConfigConstraint[] } {
|
|
1344
1373
|
const c = new Check();
|
|
1345
|
-
const top = c.object('config', raw, ['bones', 'meshes', 'regions', 'motion'], ['notes']);
|
|
1374
|
+
const top = c.object('config', raw, ['bones', 'meshes', 'regions', 'motion'], ['notes', 'limbs', 'constraints']);
|
|
1346
1375
|
if (top !== null) {
|
|
1347
1376
|
const names = 'bones' in top ? checkBones(c, top.bones) : { bones: new Set<string>([ROOT_BONE]), chains: new Map<string, number>(), parents: new Map<string, string>() };
|
|
1377
|
+
if ('limbs' in top) {
|
|
1378
|
+
const taken = isPlainObject(top.limbs)
|
|
1379
|
+
? Object.values(top.limbs).flatMap((l) => (isPlainObject(l) && Array.isArray(l.segments) ? l.segments.flatMap((sg) => (isPlainObject(sg) && typeof sg.from === 'string' ? [sg.from] : [])) : []))
|
|
1380
|
+
: [];
|
|
1381
|
+
checkLimbs(c, top.limbs, { parts: [...new Set(taken)], patches: [], cuts: [] }, names);
|
|
1382
|
+
}
|
|
1348
1383
|
if ('meshes' in top) checkMeshes(c, top.meshes, names.bones, names.chains);
|
|
1349
1384
|
if ('regions' in top) checkRegions(c, top.regions, names.bones);
|
|
1350
1385
|
if ('motion' in top) checkMotion(c, top.motion, names.bones, names.chains, 'regions' in top ? top.regions : undefined);
|
|
1386
|
+
if ('constraints' in top) checkConstraints(c, top.constraints, names.bones, names.parents);
|
|
1351
1387
|
if ('notes' in top && c.array('config.notes', top.notes, false)) (top.notes as Json[]).forEach((n, i) => c.string(`config.notes[${i}]`, n, false));
|
|
1352
1388
|
}
|
|
1353
1389
|
refuseIfAny(c.problems.map((p) => ({ ...p, object: p.object.replace(/^config\b/, 'proposal') })));
|
|
1354
|
-
return raw as SkeletonSections;
|
|
1390
|
+
return raw as SkeletonSections & { limbs?: Record<string, Limb>; constraints?: ConfigConstraint[] };
|
|
1355
1391
|
}
|
|
1356
1392
|
|
|
1357
1393
|
/**
|
|
@@ -1541,7 +1577,9 @@ function checkAssemble(c: Check, v: Json, door: 'assemble' | 'full'): { parts: s
|
|
|
1541
1577
|
/**
|
|
1542
1578
|
* `assemble.cuts` (issue #170): each entry `{from, into, polygon, draw, overlap}`
|
|
1543
1579
|
* and optionally `under` (issue #193, a non-empty path string; the assemble
|
|
1544
|
-
* stage reads the file)
|
|
1580
|
+
* stage reads the file) and `under_fill` (issue #360, the one value
|
|
1581
|
+
* `"colour"`; whether the cut has an under to fill with is the assemble
|
|
1582
|
+
* stage's to judge, since a hidden-art layer can be one), refused field by field. `from` must be a plan part — a cut of a patch or of
|
|
1545
1583
|
* another cut's piece is refused, so every piece's provenance is one layer and
|
|
1546
1584
|
* one polygon. `into` is a new part name: not a plan part, not a patch, not
|
|
1547
1585
|
* another cut's. Returns the names the cuts make, in order.
|
|
@@ -1553,11 +1591,14 @@ function checkCuts(c: Check, v: Json, plan: string[], patches: string[]): string
|
|
|
1553
1591
|
const seen = new Map<string, number>();
|
|
1554
1592
|
(v as Json[]).forEach((entry, i) => {
|
|
1555
1593
|
const at = `${p}[${i}]`;
|
|
1556
|
-
const e = c.object(at, entry, ['from', 'into', 'polygon', 'draw', 'overlap'], ['under']);
|
|
1594
|
+
const e = c.object(at, entry, ['from', 'into', 'polygon', 'draw', 'overlap'], ['under', 'under_fill']);
|
|
1557
1595
|
if (e === null) return;
|
|
1558
1596
|
if ('under' in e && !(typeof e.under === 'string' && e.under !== '')) {
|
|
1559
1597
|
c.fail('CONFIG_FIELD_TYPE', `${at}.under`, `is ${show(e.under)}; a non-empty path to a PNG is required, relative to the config's directory — the image of what lies under the piece, in rig pixels over the polygon's bounding box`);
|
|
1560
1598
|
}
|
|
1599
|
+
if ('under_fill' in e && e.under_fill !== 'colour') {
|
|
1600
|
+
c.fail('CONFIG_FIELD_TYPE', `${at}.under_fill`, `is ${show(e.under_fill)}; "colour" is required — the base takes the under's colour and keeps the alpha the cut removed, so its silhouette does not grow; leave under_fill out for the under's colour and alpha`);
|
|
1601
|
+
}
|
|
1561
1602
|
if ('overlap' in e && !(typeof e.overlap === 'number' && Number.isInteger(e.overlap) && e.overlap >= 0)) {
|
|
1562
1603
|
c.fail('CONFIG_FIELD_TYPE', `${at}.overlap`, `is ${show(e.overlap)}; a non-negative integer of rig pixels is required — the band inside the cut's edge the part keeps under the piece, 0 for none`);
|
|
1563
1604
|
}
|
|
@@ -2158,7 +2199,10 @@ interface LimbNames {
|
|
|
2158
2199
|
* the joint, its origin); the first segment has no joint, every other names
|
|
2159
2200
|
* one (`CONFIG_KEY_KNOWN`, `CONFIG_FIELD_PRESENT`); `range` two finite
|
|
2160
2201
|
* 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
|
|
2202
|
+
* is measured from rest, so rest is in it); `cap` a non-empty path; `edge`
|
|
2203
|
+
* `"feather"` and `cap_shape` `"disc"` (issue #360), each the one value its
|
|
2204
|
+
* field takes — absent is the hard cut and the layer's half-disc
|
|
2205
|
+
* (`CONFIG_FIELD_TYPE` on any other). A limb
|
|
2162
2206
|
* has two segments or more. With `names` null (no `bones`), bones are not
|
|
2163
2207
|
* resolved. Returns the limb parts and the parts the limbs take.
|
|
2164
2208
|
*/
|
|
@@ -2183,13 +2227,13 @@ function checkLimbs(c: Check, v: Json, made: { parts: string[]; patches: string[
|
|
|
2183
2227
|
const bones: Array<string | null> = [];
|
|
2184
2228
|
segs.forEach((entry, i) => {
|
|
2185
2229
|
const sat = `${at}.segments[${i}]`;
|
|
2186
|
-
const e = c.object(sat, entry, ['part', 'from', 'bone'], ['joint', 'range', 'cap']);
|
|
2230
|
+
const e = c.object(sat, entry, ['part', 'from', 'bone'], ['joint', 'range', 'cap', 'edge', 'cap_shape']);
|
|
2187
2231
|
froms.push(null);
|
|
2188
2232
|
bones.push(null);
|
|
2189
2233
|
if (e === null) return;
|
|
2190
2234
|
// The upper segment sits below no joint; every other sits below one, which it names.
|
|
2191
2235
|
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)`);
|
|
2236
|
+
for (const k of ['joint', 'range', 'cap', 'edge', 'cap_shape'].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
2237
|
} 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
2238
|
if ('part' in e && c.string(`${sat}.part`, e.part)) {
|
|
2195
2239
|
const name = e.part;
|
|
@@ -2227,6 +2271,13 @@ function checkLimbs(c: Check, v: Json, made: { parts: string[]; patches: string[
|
|
|
2227
2271
|
if ('cap' in e && !(typeof e.cap === 'string' && e.cap !== '')) {
|
|
2228
2272
|
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
2273
|
}
|
|
2274
|
+
// Issue #360: the cut's geometry at the joint, each field one value; absent is the hard cut and the layer's half-disc.
|
|
2275
|
+
if ('edge' in e && e.edge !== 'feather') {
|
|
2276
|
+
c.fail('CONFIG_FIELD_TYPE', `${sat}.edge`, `is ${show(e.edge)}; "feather" is required — the upper piece fades out across the cap instead of stopping at the joint line; leave edge out for the hard cut`);
|
|
2277
|
+
}
|
|
2278
|
+
if ('cap_shape' in e && e.cap_shape !== 'disc') {
|
|
2279
|
+
c.fail('CONFIG_FIELD_TYPE', `${sat}.cap_shape`, `is ${show(e.cap_shape)}; "disc" is required — the cap a disc of the limb's half-width about the joint, filled from the art, the upper segment's included; leave cap_shape out for the layer's half-disc of half the limb's width`);
|
|
2280
|
+
}
|
|
2230
2281
|
});
|
|
2231
2282
|
froms.forEach((f, i) => {
|
|
2232
2283
|
if (f === null || i === 0 || froms[i - 1] === f) return;
|
|
@@ -2582,6 +2633,136 @@ function attachedParts(meshes: Json, regions: Json): Set<string> {
|
|
|
2582
2633
|
* declaration. Not `true`: an object is what a later field can be added to
|
|
2583
2634
|
* without changing what an existing config means.
|
|
2584
2635
|
*/
|
|
2636
|
+
/**
|
|
2637
|
+
* `config.cloth` (issue #364): a recipe per tag `propose --cloth` drafts trees
|
|
2638
|
+
* for. Every field is checked here, and every combination that would draft
|
|
2639
|
+
* something other than what it says is refused by name:
|
|
2640
|
+
*
|
|
2641
|
+
* - a tag that is not one of {@link CLOTH_TARGETS} (`CONFIG_KEY_KNOWN`);
|
|
2642
|
+
* - `split.at` at or past `links` — no link would be left for the branches
|
|
2643
|
+
* (`CONFIG_CLOTH_SPLIT_BEFORE_TIP`);
|
|
2644
|
+
* - a `wind` or `gravity` pair whose tip is below its root: the response is
|
|
2645
|
+
* growing toward the tip (`CONFIG_CLOTH_RESPONSE_GROWS_TO_TIP`);
|
|
2646
|
+
* - `alternate_damping` without `damping`, or equal to it: neighbouring trees
|
|
2647
|
+
* are declared out of phase, and an alternate that alternates nothing is
|
|
2648
|
+
* not a declaration (`CONFIG_CLOTH_ALTERNATE_DIFFERS`);
|
|
2649
|
+
* - `swing` on a tag other than `bottomwear`, or `swing.scales` not one per
|
|
2650
|
+
* tree (`CONFIG_CLOTH_SWING_SCALE_PER_TREE`);
|
|
2651
|
+
* - a strength, declared or the recipe's default, whose spring is unstable at
|
|
2652
|
+
* a damping the recipe runs at — k = strength·massInverse·step² at or past
|
|
2653
|
+
* 2(1 + damping^(60·step)), rig-c's default fps 60 and mass 1, the damping
|
|
2654
|
+
* `damping`, `alternate_damping` or rig-c's default 0.85
|
|
2655
|
+
* (`CONFIG_CLOTH_SPRING_STABLE`, {@link springStable}).
|
|
2656
|
+
*/
|
|
2657
|
+
function checkCloth(c: Check, v: Json): void {
|
|
2658
|
+
const at = 'config.cloth';
|
|
2659
|
+
if (!isPlainObject(v)) {
|
|
2660
|
+
c.fail('CONFIG_FIELD_TYPE', at, `is ${show(v)}; an object is required — one recipe per tag, ${CLOTH_TARGETS.map((t) => `"${t}"`).join(', ')}`);
|
|
2661
|
+
return;
|
|
2662
|
+
}
|
|
2663
|
+
// The keys are tags, not fields: read here rather than by c.object, whose lists are field names.
|
|
2664
|
+
for (const key of Object.keys(v)) {
|
|
2665
|
+
if (isRecordKey(key) || (CLOTH_TARGETS as readonly string[]).includes(key)) continue;
|
|
2666
|
+
if (key === 'note' || key.endsWith('_note')) {
|
|
2667
|
+
if (typeof v[key] !== 'string') c.fail('CONFIG_FIELD_TYPE', `${at}.${key}`, `is ${show(v[key])}; an annotation is a string`);
|
|
2668
|
+
continue;
|
|
2669
|
+
}
|
|
2670
|
+
c.fail('CONFIG_KEY_KNOWN', `${at}.${JSON.stringify(key)}`, `is not a tag a recipe is drafted for; known: ${CLOTH_TARGETS.map((t) => `"${t}"`).join(', ')} — ${DOORS}`);
|
|
2671
|
+
}
|
|
2672
|
+
if (Object.keys(v).filter((k) => (CLOTH_TARGETS as readonly string[]).includes(k)).length === 0) {
|
|
2673
|
+
c.fail('CONFIG_FIELD_PRESENT', at, `names no tag; one recipe per tag, ${CLOTH_TARGETS.map((t) => `"${t}"`).join(', ')}, is required (leave cloth out for none)`);
|
|
2674
|
+
}
|
|
2675
|
+
for (const tag of CLOTH_TARGETS) {
|
|
2676
|
+
if (!(tag in v)) continue;
|
|
2677
|
+
const path = `${at}.${JSON.stringify(tag)}`;
|
|
2678
|
+
const r = c.object(path, v[tag], ['chains', 'links', 'wind', 'gravity'], ['split', 'length_ratio', 'strength', 'damping', 'alternate_damping', 'translate', 'swing']);
|
|
2679
|
+
if (r === null) continue;
|
|
2680
|
+
const chains = 'chains' in r && c.int(`${path}.chains`, r.chains, 1) ? (r.chains as number) : null;
|
|
2681
|
+
const links = 'links' in r && c.int(`${path}.links`, r.links, 1) ? (r.links as number) : null;
|
|
2682
|
+
if ('split' in r) {
|
|
2683
|
+
const sp = c.object(`${path}.split`, r.split, ['at', 'into'], []);
|
|
2684
|
+
if (sp !== null) {
|
|
2685
|
+
const sAt = 'at' in sp && c.int(`${path}.split.at`, sp.at, 1) ? (sp.at as number) : null;
|
|
2686
|
+
if ('into' in sp) c.int(`${path}.split.into`, sp.into, 2);
|
|
2687
|
+
if (sAt !== null && links !== null && sAt >= links) {
|
|
2688
|
+
c.fail('CONFIG_CLOTH_SPLIT_BEFORE_TIP', `${path}.split.at`, `is ${sAt} and links is ${links}; the trunk's ${sAt} link(s) would leave no link for the branches — split.at below links is required`);
|
|
2689
|
+
}
|
|
2690
|
+
}
|
|
2691
|
+
}
|
|
2692
|
+
if ('length_ratio' in r) c.number(`${path}.length_ratio`, r.length_ratio, 'positive');
|
|
2693
|
+
if ('strength' in r) {
|
|
2694
|
+
const st = c.object(`${path}.strength`, r.strength, ['trunk', 'branch', 'edge'], []);
|
|
2695
|
+
if (st !== null) for (const k of ['trunk', 'branch', 'edge'] as const) if (k in st) c.number(`${path}.strength.${k}`, st[k], 'positive');
|
|
2696
|
+
}
|
|
2697
|
+
const damping = 'damping' in r && c.number(`${path}.damping`, r.damping, 'unit') ? (r.damping as number) : null;
|
|
2698
|
+
if ('alternate_damping' in r && c.number(`${path}.alternate_damping`, r.alternate_damping, 'unit')) {
|
|
2699
|
+
if (!('damping' in r)) c.fail('CONFIG_CLOTH_ALTERNATE_DIFFERS', `${path}.alternate_damping`, 'is declared and damping is absent; the odd trees take alternate_damping in place of the even trees\' damping, so damping is required beside it');
|
|
2700
|
+
else if (damping !== null && damping === r.alternate_damping) c.fail('CONFIG_CLOTH_ALTERNATE_DIFFERS', `${path}.alternate_damping`, `is ${damping}, the same as damping; neighbouring trees are declared out of phase, so a different value is required (or leave alternate_damping out)`);
|
|
2701
|
+
}
|
|
2702
|
+
const st = isPlainObject(r.strength) ? r.strength : null;
|
|
2703
|
+
const strengths = (['trunk', 'branch', 'edge'] as const).map((k) => [k, st === null ? RECIPE_STRENGTH[k] : st[k]] as const).filter((e): e is readonly ['trunk' | 'branch' | 'edge', number] => typeof e[1] === 'number' && Number.isFinite(e[1]) && e[1] > 0);
|
|
2704
|
+
const dampings = [damping ?? RIGC_DEFAULT_DAMPING, ...(typeof r.alternate_damping === 'number' && r.alternate_damping >= 0 && r.alternate_damping <= 1 ? [r.alternate_damping] : [])];
|
|
2705
|
+
for (const [k, value] of strengths) {
|
|
2706
|
+
for (const d of dampings) {
|
|
2707
|
+
const b = springStable(value, d);
|
|
2708
|
+
if (!b.ok) {
|
|
2709
|
+
c.fail(
|
|
2710
|
+
'CONFIG_CLOTH_SPRING_STABLE',
|
|
2711
|
+
`${path}.strength.${k}`,
|
|
2712
|
+
`is ${value}${st === null ? " (the recipe's default)" : ''}; at damping ${d}, rig-c's default fps 60 and mass 1, k = strength·massInverse·step² = ${pyRound(b.k, 6)}, and a spring is stable only below 2(1 + damping^(60·step)) = ${pyRound(b.bound, 6)} — lower the strength`,
|
|
2713
|
+
);
|
|
2714
|
+
}
|
|
2715
|
+
}
|
|
2716
|
+
}
|
|
2717
|
+
for (const k of ['wind', 'gravity'] as const) {
|
|
2718
|
+
if (!(k in r)) continue;
|
|
2719
|
+
const pair = r[k];
|
|
2720
|
+
if (!Array.isArray(pair) || pair.length !== 2 || !pair.every((n) => typeof n === 'number' && Number.isFinite(n) && n >= 0)) {
|
|
2721
|
+
c.fail('CONFIG_FIELD_TYPE', `${path}.${k}`, `is ${show(pair)}; [root, tip], two numbers at or above 0 — the ${k} response of a path's first link and of its last — is required`);
|
|
2722
|
+
continue;
|
|
2723
|
+
}
|
|
2724
|
+
if ((pair[1] as number) < (pair[0] as number)) {
|
|
2725
|
+
c.fail('CONFIG_CLOTH_RESPONSE_GROWS_TO_TIP', `${path}.${k}`, `is [${pair[0]}, ${pair[1]}]; the response grows from the root to the tip, where the motion accumulates down the chain, so tip >= root is required`);
|
|
2726
|
+
}
|
|
2727
|
+
}
|
|
2728
|
+
if ('translate' in r && c.number(`${path}.translate`, r.translate, 'unit') && r.translate === 0) {
|
|
2729
|
+
c.fail('CONFIG_FIELD_TYPE', `${path}.translate`, 'is 0; a mix above 0 and at most 1 is required (leave translate out for none)');
|
|
2730
|
+
}
|
|
2731
|
+
if ('swing' in r) {
|
|
2732
|
+
if (tag !== 'bottomwear') {
|
|
2733
|
+
c.fail('CONFIG_CLOTH_SWING_SCALE_PER_TREE', `${path}.swing`, `is declared on "${tag}"; the shared swing is the skirt's (bottomwear) — leave it out here`);
|
|
2734
|
+
} else {
|
|
2735
|
+
const sw = c.object(`${path}.swing`, r.swing, ['scales'], []);
|
|
2736
|
+
if (sw !== null && 'scales' in sw && c.array(`${path}.swing.scales`, sw.scales, true)) {
|
|
2737
|
+
const scales = sw.scales as Json[];
|
|
2738
|
+
scales.forEach((n, i) => c.number(`${path}.swing.scales[${i}]`, n, 'positive'));
|
|
2739
|
+
if (chains !== null && scales.length !== chains) {
|
|
2740
|
+
c.fail('CONFIG_CLOTH_SWING_SCALE_PER_TREE', `${path}.swing.scales`, `holds ${scales.length} scale(s) and chains is ${chains}; one scale per tree, in tree order across the piece, is required`);
|
|
2741
|
+
}
|
|
2742
|
+
}
|
|
2743
|
+
}
|
|
2744
|
+
}
|
|
2745
|
+
}
|
|
2746
|
+
}
|
|
2747
|
+
|
|
2748
|
+
/**
|
|
2749
|
+
* `propose --cloth`'s loader (issue #364): the `cloth` door — `key` and
|
|
2750
|
+
* `cloth`, under the full loader's rules for each — and nothing else read.
|
|
2751
|
+
* Every other key must still be one a config knows.
|
|
2752
|
+
*/
|
|
2753
|
+
export function loadClothConfig(path: string): { key: string; cloth: ClothRecipes } {
|
|
2754
|
+
const raw = readConfigFile(path);
|
|
2755
|
+
const c = new Check();
|
|
2756
|
+
const required = topRequired('cloth');
|
|
2757
|
+
const top = c.object('config', raw, required, TOP_KEYS.filter((k) => !required.includes(k)));
|
|
2758
|
+
if (top === null) refuseIfAny(c.problems);
|
|
2759
|
+
const t = top as Record<string, Json>;
|
|
2760
|
+
if ('key' in t) c.string('config.key', t.key);
|
|
2761
|
+
if ('cloth' in t) checkCloth(c, t.cloth);
|
|
2762
|
+
refuseIfAny(c.problems);
|
|
2763
|
+
return raw as { key: string; cloth: ClothRecipes };
|
|
2764
|
+
}
|
|
2765
|
+
|
|
2585
2766
|
function checkScene(c: Check, v: Json): void {
|
|
2586
2767
|
if (!isPlainObject(v)) {
|
|
2587
2768
|
c.fail('CONFIG_FIELD_TYPE', 'config.scene', `is ${show(v)}; an object is required — {} declares the painting a scene`);
|
package/src/limbs.ts
CHANGED
|
@@ -41,6 +41,27 @@
|
|
|
41
41
|
* shows no gap. The tool invents none of them: each is the config's cap
|
|
42
42
|
* image's where that image is not transparent, else the layer's where the
|
|
43
43
|
* limb has art there, else missing — counted, and named in the limb line.
|
|
44
|
+
* - **`cap_shape: "disc"`** (issue #360, items 10 and 12; opt-in on the
|
|
45
|
+
* segment below the joint). The radius is the limb's half-width about the
|
|
46
|
+
* joint, not half its width: the distance from the joint to the nearer end
|
|
47
|
+
* of the same run along the joint line, each end half a pixel past its
|
|
48
|
+
* centre ({@link halfWidthAbout}) — so the disc lies on the limb's art
|
|
49
|
+
* across the line however far off the run's middle the bone sits. The cap
|
|
50
|
+
* takes, after the image's and the layer's, the upper segment's art at a
|
|
51
|
+
* pixel the lower's layer has none of: the whole half-disc comes from the
|
|
52
|
+
* art. The upper piece's corner at the cut is kept where it is: on a joint
|
|
53
|
+
* off the limb's middle, a corner trimmed to the disc leaves a notch at
|
|
54
|
+
* rest when nothing takes it, or a stub out of the bent lower piece's
|
|
55
|
+
* outline when the lower takes it (issue #360, unit B's sheets).
|
|
56
|
+
* - **`edge: "feather"`** (issue #360, item 9; opt-in on the segment below the
|
|
57
|
+
* joint). The overlap band is the disc about the joint that the lower
|
|
58
|
+
* piece covers at every turn ({@link coveredRadius}: the cap's disc, less
|
|
59
|
+
* any pixel the cap is missing and a bilinear sample's reach). Inside it,
|
|
60
|
+
* on the upper side of the line, the upper piece's alpha is scaled by
|
|
61
|
+
* {@link featherWeight}: 0 on the line, 1 on the band's arc. The band fades
|
|
62
|
+
* out across its width over art that stays opaque under it at any angle,
|
|
63
|
+
* so at rest the two pieces composite to what they did and a bend never
|
|
64
|
+
* shows through it what lies behind the limb.
|
|
44
65
|
*/
|
|
45
66
|
import type { BoneEntry, Limb } from './config.ts';
|
|
46
67
|
|
|
@@ -144,11 +165,12 @@ export function segmentOf(lines: ReadonlyArray<JointLine | null>, first: number,
|
|
|
144
165
|
}
|
|
145
166
|
|
|
146
167
|
/**
|
|
147
|
-
* The limb's
|
|
148
|
-
*
|
|
149
|
-
*
|
|
168
|
+
* The limb's run of art along a joint line (module comment): the offsets
|
|
169
|
+
* along the line, from the joint, of the first and the last pixel centre of
|
|
170
|
+
* the run through the joint, `[first, last]` with `first <= last`. Null when
|
|
171
|
+
* no pixel of the run lies within 1 px of the joint.
|
|
150
172
|
*/
|
|
151
|
-
export function
|
|
173
|
+
export function limbRun(line: JointLine, art: (x: number, y: number) => boolean, box: readonly [number, number, number, number]): [number, number] | null {
|
|
152
174
|
const [ax, ay] = line.axis;
|
|
153
175
|
const ts: number[] = [];
|
|
154
176
|
for (let y = box[1]; y <= box[3]; y++) {
|
|
@@ -170,7 +192,67 @@ export function limbWidth(line: JointLine, art: (x: number, y: number) => boolea
|
|
|
170
192
|
let hi = near;
|
|
171
193
|
while (lo > 0 && ts[lo] - ts[lo - 1] <= 1.5) lo--;
|
|
172
194
|
while (hi + 1 < ts.length && ts[hi + 1] - ts[hi] <= 1.5) hi++;
|
|
173
|
-
return ts[
|
|
195
|
+
return [ts[lo], ts[hi]];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** The limb's width at a joint (module comment): its run's first to last pixel centre, plus one pixel. Null with no run. */
|
|
199
|
+
export function limbWidth(line: JointLine, art: (x: number, y: number) => boolean, box: readonly [number, number, number, number]): number | null {
|
|
200
|
+
const r = limbRun(line, art, box);
|
|
201
|
+
return r === null ? null : r[1] - r[0] + 1;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* The limb's half-width about the joint (issue #360, `cap_shape: "disc"`): the
|
|
206
|
+
* distance from the joint to the nearer end of its run, each end half a pixel
|
|
207
|
+
* past its centre — `min(last + 0.5, 0.5 - first)`. Half the width when the
|
|
208
|
+
* joint sits in the middle of the run; less when it sits off it. Zero or below
|
|
209
|
+
* when the joint sits on the run's edge.
|
|
210
|
+
*/
|
|
211
|
+
export function halfWidthAbout(run: readonly [number, number]): number {
|
|
212
|
+
return Math.min(run[1] + 0.5, 0.5 - run[0]);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Where a pixel's centre lies from a joint: `along` the axis (above 0 past the line), and `across` it (the run's offset). */
|
|
216
|
+
export function jointOffsets(line: JointLine, px: number, py: number): { along: number; across: number } {
|
|
217
|
+
const dx = px + 0.5 - line.at[0];
|
|
218
|
+
const dy = py + 0.5 - line.at[1];
|
|
219
|
+
return { along: dx * line.axis[0] + dy * line.axis[1], across: -dx * line.axis[1] + dy * line.axis[0] };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* The radius the feathered edge fades over (issue #360, `edge: "feather"`):
|
|
224
|
+
* the disc about the joint the lower piece covers at every turn. It is the
|
|
225
|
+
* distance from the joint to the nearest pixel centre the lower piece does not
|
|
226
|
+
* hold at alpha 255 (`opaque` false; searched out to `radius` + 2), less
|
|
227
|
+
* sqrt(2), the farthest a bilinear sample reaches to its four texels — so a
|
|
228
|
+
* pixel within it, turned by any angle about the joint, still samples only
|
|
229
|
+
* the lower's opaque art — and at most `radius`. Zero or below: no pixel is
|
|
230
|
+
* covered at every turn, and nothing is feathered.
|
|
231
|
+
*/
|
|
232
|
+
export function coveredRadius(at: Pt, radius: number, opaque: (x: number, y: number) => boolean): number {
|
|
233
|
+
const [x0, y0, w, h] = capBox(at, radius + 2);
|
|
234
|
+
let near = radius + 2;
|
|
235
|
+
for (let y = y0; y < y0 + h; y++) {
|
|
236
|
+
for (let x = x0; x < x0 + w; x++) {
|
|
237
|
+
if (opaque(x, y)) continue;
|
|
238
|
+
const d = Math.sqrt((x + 0.5 - at[0]) ** 2 + (y + 0.5 - at[1]) ** 2);
|
|
239
|
+
if (d < near) near = d;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return Math.min(radius, near - Math.SQRT2);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* The feathered edge's weight on the upper piece (issue #360, `edge:
|
|
247
|
+
* "feather"`) at a pixel inside the joint's disc on the upper side of its line:
|
|
248
|
+
* its distance above the line over the disc's height there,
|
|
249
|
+
* `-along / sqrt(radius² - across²)` — 0 on the line, 1 on the disc's arc, so
|
|
250
|
+
* the overlap band fades out across its width. 1 where the disc has no height.
|
|
251
|
+
*/
|
|
252
|
+
export function featherWeight(radius: number, along: number, across: number): number {
|
|
253
|
+
const h2 = radius * radius - across * across;
|
|
254
|
+
if (h2 <= 0) return 1;
|
|
255
|
+
return Math.max(0, Math.min(1, -along / Math.sqrt(h2)));
|
|
174
256
|
}
|
|
175
257
|
|
|
176
258
|
/** 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. */
|
package/src/parts.ts
CHANGED
|
@@ -45,7 +45,8 @@
|
|
|
45
45
|
* 1.6.0 has none, and still reads. `limb`, written only on a part a
|
|
46
46
|
* `config.limbs` segment made (issue #349), names the limb, the segment's
|
|
47
47
|
* index (0 the upper), the part it was cut from (`of`), the one bone it rides
|
|
48
|
-
* and, below a joint, the joint
|
|
48
|
+
* and, below a joint, the joint, its declared `range` when there is one
|
|
49
|
+
* (issue #360) and its cap ({@link LimbRecord}).
|
|
49
50
|
*
|
|
50
51
|
* ⭐ **A patch is 100 % source.** Every pixel of a `painting:` part is the
|
|
51
52
|
* painting's own, so it is all visible (the painting shows it; nothing of it
|
|
@@ -118,7 +119,9 @@ export interface PartRecord {
|
|
|
118
119
|
/**
|
|
119
120
|
* A limb segment's provenance (issue #349): the limb, the segment's index
|
|
120
121
|
* (0 the upper), the part its pixels were cut from (`of`), the bone it rides
|
|
121
|
-
* with weight 1, and, on a segment below a joint, the joint's name
|
|
122
|
+
* with weight 1, and, on a segment below a joint, the joint's name, the
|
|
123
|
+
* `range` its config segment declares (issue #360: `[min, max]` degrees from
|
|
124
|
+
* rest, written only when declared; `check` poses the joint there) and its cap.
|
|
122
125
|
*/
|
|
123
126
|
export interface LimbRecord {
|
|
124
127
|
name: string;
|
|
@@ -126,6 +129,7 @@ export interface LimbRecord {
|
|
|
126
129
|
of: string;
|
|
127
130
|
bone: string;
|
|
128
131
|
joint?: string;
|
|
132
|
+
range?: [number, number];
|
|
129
133
|
cap?: CapRecord;
|
|
130
134
|
}
|
|
131
135
|
|
|
@@ -147,10 +151,27 @@ export interface CapRecord {
|
|
|
147
151
|
/** Issue #359: with `image_px`, the hidden-art layer the cap's image came from (`<run>:<layer>:hidden`), when it came from one rather than the config's cap file. */
|
|
148
152
|
image_layer?: string;
|
|
149
153
|
missing_px: number;
|
|
154
|
+
/**
|
|
155
|
+
* Issue #360, written only under the segment's `cap_shape: "disc"`: the
|
|
156
|
+
* shape; the cap's pixels taken from the upper segment's art where the
|
|
157
|
+
* lower's layer had none (`upper_px`). `radius` is then the half-width about the
|
|
158
|
+
* joint, at most half of `width`.
|
|
159
|
+
*/
|
|
160
|
+
shape?: 'disc';
|
|
161
|
+
upper_px?: number;
|
|
162
|
+
/**
|
|
163
|
+
* Issue #360, written only under the segment's `edge: "feather"`: the edge,
|
|
164
|
+
* the radius of the band it fades over (the disc the lower piece covers at
|
|
165
|
+
* every turn, `coveredRadius` in `src/limbs.ts`), and the upper piece's
|
|
166
|
+
* pixels whose alpha it lowered.
|
|
167
|
+
*/
|
|
168
|
+
edge?: 'feather';
|
|
169
|
+
feather_radius?: number;
|
|
170
|
+
feathered_px?: number;
|
|
150
171
|
}
|
|
151
172
|
|
|
152
|
-
const LIMB_KEYS = ['name', 'segment', 'of', 'bone', 'joint', 'cap'] as const;
|
|
153
|
-
const CAP_KEYS = ['at', 'width', 'radius', 'box', 'layer_px', 'image_px', 'image_layer', 'missing_px'] as const;
|
|
173
|
+
const LIMB_KEYS = ['name', 'segment', 'of', 'bone', 'joint', 'range', 'cap'] as const;
|
|
174
|
+
const CAP_KEYS = ['at', 'width', 'radius', 'box', 'layer_px', 'image_px', 'image_layer', 'missing_px', 'shape', 'upper_px', 'edge', 'feather_radius', 'feathered_px'] as const;
|
|
154
175
|
|
|
155
176
|
/** The limb record with its keys in the fixed order, so two runs write the same bytes. */
|
|
156
177
|
function orderedLimb(l: LimbRecord): Record<string, unknown> {
|
|
@@ -470,6 +491,14 @@ function checkParts(raw: unknown, path: string): PartsFile {
|
|
|
470
491
|
const below = isCount(q.segment) && q.segment > 0;
|
|
471
492
|
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`);
|
|
472
493
|
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`);
|
|
494
|
+
// Issue #360: a declared range rides below a joint only, and holds the loader's rule — two degrees, rest in it.
|
|
495
|
+
if ('range' in q) {
|
|
496
|
+
const r = q.range;
|
|
497
|
+
if (!below) fail('PARTS_FIELD_PRESENT', `${object} "range"`, `is on segment ${show(q.segment)}; a range is the bend of a joint, so only a segment below one (index above 0) carries it`);
|
|
498
|
+
else if (!(Array.isArray(r) && r.length === 2 && r.every((n) => typeof n === 'number' && Number.isFinite(n)) && (r[0] as number) <= 0 && 0 <= (r[1] as number))) {
|
|
499
|
+
fail('PARTS_FIELD_TYPE', `${object} "range"`, `is ${show(r)}; [min, max], two finite degrees from rest with min <= 0 <= max, is required — config.limbs' rule (CONFIG_LIMB_RANGE)`);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
473
502
|
if ('cap' in q) {
|
|
474
503
|
const c = q.cap;
|
|
475
504
|
const co = typeof c === 'object' && c !== null && !Array.isArray(c) ? (c as Record<string, unknown>) : null;
|
|
@@ -484,8 +513,17 @@ function checkParts(raw: unknown, path: string): PartsFile {
|
|
|
484
513
|
isCount(co.layer_px) &&
|
|
485
514
|
isCount(co.missing_px) &&
|
|
486
515
|
(!('image_px' in co) || isCount(co.image_px)) &&
|
|
487
|
-
(!('image_layer' in co) || ('image_px' in co && typeof co.image_layer === 'string' && co.image_layer !== ''))
|
|
488
|
-
|
|
516
|
+
(!('image_layer' in co) || ('image_px' in co && typeof co.image_layer === 'string' && co.image_layer !== '')) &&
|
|
517
|
+
// Issue #360: a disc cap carries its count, a feathered edge its band and count, and neither carries them alone.
|
|
518
|
+
('shape' in co ? co.shape === 'disc' && isCount(co.upper_px) : !('upper_px' in co)) &&
|
|
519
|
+
('edge' in co ? co.edge === 'feather' && typeof co.feather_radius === 'number' && Number.isFinite(co.feather_radius) && co.feather_radius >= 0 && isCount(co.feathered_px) : !('feather_radius' in co) && !('feathered_px' in co));
|
|
520
|
+
if (!ok) {
|
|
521
|
+
fail(
|
|
522
|
+
'PARTS_FIELD_TYPE',
|
|
523
|
+
`${object} "cap"`,
|
|
524
|
+
`is ${show(c)}; {at: [x, y], width, radius, box: [x, y, w, h], layer_px, image_px?, image_layer? (with image_px), missing_px, shape? ("disc", with upper_px), edge? ("feather", with feather_radius and feathered_px)} is required`,
|
|
525
|
+
);
|
|
526
|
+
}
|
|
489
527
|
}
|
|
490
528
|
}
|
|
491
529
|
}
|