@rig-c/parts 1.16.0 → 1.18.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 +8 -3
- package/cli.ts +191 -24
- package/docs/AUTHORING.md +381 -21
- package/docs/MIGRATION.md +52 -0
- package/docs/PAINTING.md +172 -0
- package/docs/STABILITY.md +14 -9
- package/package.json +2 -1
- package/skills/rig-parts/SKILL.md +31 -2
- package/src/assemble.ts +642 -41
- package/src/autoamplitude.ts +23 -3
- package/src/autoenvelope.ts +17 -5
- package/src/build.ts +109 -13
- package/src/check.ts +632 -11
- package/src/config.ts +185 -8
- package/src/inputs.ts +11 -1
- package/src/layers.ts +231 -18
- package/src/limbs.ts +188 -0
- package/src/paintcheck.ts +250 -0
- package/src/parts.ts +167 -6
- package/src/propose.ts +148 -108
- package/src/rig.ts +43 -8
- package/src/tags.ts +47 -0
package/src/propose.ts
CHANGED
|
@@ -25,7 +25,9 @@
|
|
|
25
25
|
* - `topwear` -> two outer-robe chains, only when it hangs below the hip line;
|
|
26
26
|
* a side whose link falls off the art is dropped.
|
|
27
27
|
* - `handwear-*` -> two blobs: one sleeve chain per blob, its mesh taking that
|
|
28
|
-
* chain and a chest stub at the shoulder
|
|
28
|
+
* chain and nothing else (issue #347: a chest stub at the shoulder pinned the
|
|
29
|
+
* puff to the torso, so the part stretched when the chain turned; without it the
|
|
30
|
+
* part rides its chain rigidly); one blob: both chains and the chest
|
|
29
31
|
* midline, split at the eye axis — unless the blob is clasped hands (at the
|
|
30
32
|
* axis and at most half the shoulders' width), which ride `hip` as a region.
|
|
31
33
|
* - `front hair` -> three fringe chains + one lock chain per strand that hangs
|
|
@@ -117,7 +119,7 @@
|
|
|
117
119
|
import { existsSync } from 'node:fs';
|
|
118
120
|
import { join } from 'node:path';
|
|
119
121
|
import { drawText, GLYPH_H } from 'rig-c/tools/font5x7.ts';
|
|
120
|
-
import { type BoneEntry, type ConfigConstraint, type MeshSpec, parseConfig, type Point, type Segment, type SkeletonSections } from './config.ts';
|
|
122
|
+
import { type BoneEntry, type ConfigConstraint, type Limb, type MeshSpec, parseConfig, type Point, type Segment, type SkeletonSections } from './config.ts';
|
|
121
123
|
import { PartsError, type Problem, refuseIfAny } from './errors.ts';
|
|
122
124
|
import { eyeLineDegrees, type FrameFeature, headAxes, type HeadAxes, headFrame, type HeadFrame } from './headframe.ts';
|
|
123
125
|
import { type LayerSet, OPAQUE_ALPHA_ABOVE } from './layers.ts';
|
|
@@ -254,6 +256,8 @@ export interface Proposal {
|
|
|
254
256
|
bones: BoneEntry[];
|
|
255
257
|
meshes: Record<string, MeshSpec>;
|
|
256
258
|
regions: Record<string, string>;
|
|
259
|
+
/** Only under `--keypoints`, where an arm or a leg is drafted from its joints (issue #349): absent otherwise, so a proposal without one is the one written before it. */
|
|
260
|
+
limbs?: Record<string, Limb>;
|
|
257
261
|
motion: {
|
|
258
262
|
duration: number;
|
|
259
263
|
tracks: Array<ProposedSingleTrack | ProposedChainTrack>;
|
|
@@ -417,6 +421,7 @@ export function checkProposal(P: PartSet, p: Proposal): void {
|
|
|
417
421
|
bones: p.bones,
|
|
418
422
|
meshes: p.meshes,
|
|
419
423
|
regions: p.regions,
|
|
424
|
+
...(p.limbs === undefined ? {} : { limbs: p.limbs }),
|
|
420
425
|
motion: p.motion,
|
|
421
426
|
...(p.turn === undefined ? {} : { turn: p.turn }),
|
|
422
427
|
});
|
|
@@ -465,6 +470,7 @@ export function draftTurn(P: PartSet, runs: TurnRuns): { turn: TurnSpec | null;
|
|
|
465
470
|
const read: Array<{ part: string; run: 'full' | 'head'; tag: string; base: string | null; own: number; area: number; order: number; piece: boolean }> = [];
|
|
466
471
|
const patches: string[] = [];
|
|
467
472
|
const unread = new Map<string, string[]>();
|
|
473
|
+
const depthless: Array<{ part: string; from: string; set: LayerSet }> = [];
|
|
468
474
|
P.recs.forEach((r, order) => {
|
|
469
475
|
const [run, tag] = splitFrom(r.from);
|
|
470
476
|
if (run === PAINTING_RUN) {
|
|
@@ -482,7 +488,7 @@ export function draftTurn(P: PartSet, runs: TurnRuns): { turn: TurnSpec | null;
|
|
|
482
488
|
return;
|
|
483
489
|
}
|
|
484
490
|
if (layer.depth === null) {
|
|
485
|
-
|
|
491
|
+
depthless.push({ part: r.name, from: r.from, set });
|
|
486
492
|
return;
|
|
487
493
|
}
|
|
488
494
|
read.push({ part: r.name, run: run as 'full' | 'head', tag, base: 'base' in layer.tag ? layer.tag.base : null, own: layer.depth, area: r.opaque_px, order, piece: r.cut_from !== undefined });
|
|
@@ -490,6 +496,15 @@ export function draftTurn(P: PartSet, runs: TurnRuns): { turn: TurnSpec | null;
|
|
|
490
496
|
for (const [run, names] of unread) {
|
|
491
497
|
problems.push({ code: 'PROPOSE_TURN_RUN_GIVEN', object: `the ${run} run`, detail: `part(s) ${names.map((n) => `"${n}"`).join(', ')} came from it and its layers were not given; --${run} <dir | layers.json> is required to read their depth` });
|
|
492
498
|
}
|
|
499
|
+
// Issue #359: runs that carry no depth at all (a PSD, or an export of one) are skipped by name, never passed and never
|
|
500
|
+
// given an invented depth; a run where some parts' layers carry one and others do not is refused part by part.
|
|
501
|
+
if (problems.length === 0 && read.length === 0 && depthless.length > 0) {
|
|
502
|
+
const sources = [...new Set(depthless.map((d) => d.set.source))];
|
|
503
|
+
return { turn: null, notes: [`turn: SKIP — ${sources.join(', ')} carr${sources.length === 1 ? 'ies' : 'y'} no depth_median (${[...new Set(depthless.map((d) => (d.set.form === 'psd' ? 'a PSD carries none' : 'null on every layer')))].join('; ')}), so no turn control is drafted from ${depthless.length} part(s); the turn orders parts by See-through's per-layer depth, and none was measured — a config.turn written by hand sets one`] };
|
|
504
|
+
}
|
|
505
|
+
for (const d of depthless) {
|
|
506
|
+
problems.push({ code: 'PROPOSE_TURN_DEPTH_PRESENT', object: `part "${d.part}" (${d.from})`, detail: `its layer in ${d.set.source} carries no depth_median (a ${d.set.form} input) while other parts' layers carry one; the turn orders parts by See-through's per-layer depth, so a run that carries one for every part — or for none, which skips the turn — is required` });
|
|
507
|
+
}
|
|
493
508
|
refuseIfAny(problems);
|
|
494
509
|
const notes: string[] = [];
|
|
495
510
|
if (patches.length > 0) {
|
|
@@ -560,7 +575,20 @@ export const TURN_DEPTH_CAVEAT: readonly string[] = [
|
|
|
560
575
|
|
|
561
576
|
/** The proposal with `turn` written after `motion` and `notes` extended; the proposal itself when `turn` is null. */
|
|
562
577
|
export function withTurn(p: Proposal, draft: { turn: TurnSpec | null; notes: string[] }): Proposal {
|
|
563
|
-
|
|
578
|
+
// Issue #349: a part a limb takes is not made; each of its limb parts carries its depth instead.
|
|
579
|
+
let turn = draft.turn;
|
|
580
|
+
if (turn !== null && p.limbs !== undefined) {
|
|
581
|
+
const depths: Record<string, number> = {};
|
|
582
|
+
const into = new Map<string, string[]>();
|
|
583
|
+
for (const l of Object.values(p.limbs)) for (const sg of l.segments) into.set(sg.from, [...(into.get(sg.from) ?? []), sg.part]);
|
|
584
|
+
for (const [part, d] of Object.entries(turn.depths)) {
|
|
585
|
+
const parts = into.get(part);
|
|
586
|
+
if (parts === undefined) depths[part] = d;
|
|
587
|
+
else for (const q of parts) depths[q] = d;
|
|
588
|
+
}
|
|
589
|
+
turn = { pivot: turn.pivot, depths };
|
|
590
|
+
}
|
|
591
|
+
return { bones: p.bones, meshes: p.meshes, regions: p.regions, ...(p.limbs === undefined ? {} : { limbs: p.limbs }), motion: p.motion, ...(turn === null ? {} : { turn }), notes: [...p.notes, ...draft.notes] };
|
|
564
592
|
}
|
|
565
593
|
|
|
566
594
|
// ---------------------------------------------------------------------------
|
|
@@ -604,7 +632,7 @@ export class PartSet {
|
|
|
604
632
|
*/
|
|
605
633
|
unmarkedShares(): Array<{ from: string; parts: string[] }> {
|
|
606
634
|
const by = new Map<string, string[]>();
|
|
607
|
-
for (const p of this.layered()) if (p.cut_from === undefined) by.set(p.from, [...(by.get(p.from) ?? []), p.name]);
|
|
635
|
+
for (const p of this.layered()) if (p.cut_from === undefined && p.limb === undefined) by.set(p.from, [...(by.get(p.from) ?? []), p.name]);
|
|
608
636
|
return [...by.entries()].filter(([, names]) => names.length > 1).map(([from, parts]) => ({ from, parts }));
|
|
609
637
|
}
|
|
610
638
|
|
|
@@ -1128,8 +1156,24 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
|
|
|
1128
1156
|
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`,
|
|
1129
1157
|
})),
|
|
1130
1158
|
);
|
|
1159
|
+
// Issue #349: limbs are drafted from the parts before their cut — the layer each limb takes is what the rules read.
|
|
1160
|
+
const limbed = P.recs.filter((r) => r.limb !== undefined);
|
|
1161
|
+
refuseIfAny(
|
|
1162
|
+
limbed.length === 0
|
|
1163
|
+
? []
|
|
1164
|
+
: [
|
|
1165
|
+
{
|
|
1166
|
+
code: 'PROPOSE_LIMB_PARTS',
|
|
1167
|
+
object: `parts ${limbed.map((r) => `"${r.name}"`).join(', ')}`,
|
|
1168
|
+
detail: `were cut at their joints by config.limbs (from ${[...new Set(limbed.map((r) => `"${(r.limb as NonNullable<PartRecord['limb']>).of}"`))].join(', ')}), and propose drafts the limbs from the parts before the cut; assemble with the config's limbs left out, propose from that build, and keep the limbs block (and any caps, ranges) the config already holds — or read the config's bones with propose --from-config`,
|
|
1169
|
+
},
|
|
1170
|
+
],
|
|
1171
|
+
);
|
|
1131
1172
|
const bones: BoneEntry[] = [];
|
|
1132
1173
|
const records: BoneBasis[] = [];
|
|
1174
|
+
/** Issue #349: the limbs `--keypoints` drafts, and the parts they take (not made, so not given a mesh or a region). */
|
|
1175
|
+
const limbs: Record<string, Limb> = Object.create(null) as Record<string, Limb>;
|
|
1176
|
+
const consumed = new Set<string>();
|
|
1133
1177
|
// No prototype: a part may be named anything file-safe, "__proto__" included.
|
|
1134
1178
|
const meshes = Object.create(null) as Record<string, MeshSpec>;
|
|
1135
1179
|
const regions = Object.create(null) as Record<string, string>;
|
|
@@ -1764,30 +1808,37 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
|
|
|
1764
1808
|
} else if (armPlan !== null && armPlan.arms !== null) {
|
|
1765
1809
|
// Each chain runs shoulder -> elbow with its tip at the wrist: two links,
|
|
1766
1810
|
// the upper arm and the forearm, each with the amplitude the two-blob rule
|
|
1767
|
-
// gives its link of the same index. Its part's mesh takes the chain
|
|
1768
|
-
//
|
|
1769
|
-
// the
|
|
1811
|
+
// gives its link of the same index. Its part's mesh takes the chain alone,
|
|
1812
|
+
// as the two-blob rule's does since issue #347 (no chest stub: a stub on
|
|
1813
|
+
// chest pinned the puff, and the arm stretched it when it turned).
|
|
1770
1814
|
for (const a of armPlan.arms) {
|
|
1771
1815
|
const sgn = a.side === 'r' ? 1 : -1;
|
|
1772
1816
|
const used = (n: KeypointName): Placement => placement('joint', `the ${n} joint, ${usedAs((joints as RigJoints).joints[n])}`, { joints: [jointRef(n)] });
|
|
1773
1817
|
C(`sleeve_${a.side}`, 'chest', [a.shoulder, a.elbow], a.wrist, [used(`${a.side}_shoulder`), used(`${a.side}_elbow`)], used(`${a.side}_wrist`));
|
|
1774
1818
|
tracks.push({ chain: `sleeve_${a.side}`, amps: [sgn * 0.2, sgn * 0.6], period: 4.0, phase: 0.22 + (a.side === 'l' ? 0.05 : 0), lag: 0.08 });
|
|
1775
1819
|
}
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1820
|
+
// Issue #349: each arm is a limb — the upper arm on link 0 and the forearm (with the hand) on link 1, each rigid on
|
|
1821
|
+
// its link, cut at the elbow — not a mesh blended along the chain. Its handwear part is the layer the limb takes.
|
|
1822
|
+
for (const a of armPlan.arms) {
|
|
1823
|
+
limbs[`arm_${a.side}`] = {
|
|
1824
|
+
segments: [
|
|
1825
|
+
{ part: `upperarm_${a.side}`, from: a.part.name, bone: `sleeve_${a.side}0` },
|
|
1826
|
+
{ part: `forearm_${a.side}`, from: a.part.name, bone: `sleeve_${a.side}1`, joint: 'elbow' },
|
|
1827
|
+
],
|
|
1828
|
+
};
|
|
1829
|
+
regions[`upperarm_${a.side}`] = `sleeve_${a.side}0`;
|
|
1830
|
+
regions[`forearm_${a.side}`] = `sleeve_${a.side}1`;
|
|
1831
|
+
consumed.add(a.part.name);
|
|
1832
|
+
notes.push(
|
|
1833
|
+
`limb arm_${a.side}: ${a.part.name} (${a.part.from}) is cut at the ${a.side}_elbow joint into upperarm_${a.side} on sleeve_${a.side}0 and forearm_${a.side} (with the hand) on sleeve_${a.side}1, each rigid on its link with weight 1 — a bend is a rotation at the joint; assemble with this limbs block cuts the layer, and the forearm carries a cap past the elbow (its line counts the cap's pixels, and names any the layer does not hold)`,
|
|
1834
|
+
);
|
|
1785
1835
|
}
|
|
1786
1836
|
} else if (hw.length === 2 && sides.size === 2) {
|
|
1787
1837
|
sleeveRule = 'the two-blob sleeve rule (one chain per handwear blob, down its rows)';
|
|
1788
|
-
// Arms apart: two blobs. Each mesh gets ONLY its own sleeve chain
|
|
1789
|
-
//
|
|
1790
|
-
//
|
|
1838
|
+
// Arms apart: two blobs. Each mesh gets ONLY its own sleeve chain; the
|
|
1839
|
+
// one-blob recipe made hanging hands breathe with the sternum, and the short
|
|
1840
|
+
// chest segment at the shoulder this rule drafted until issue #347 pinned the
|
|
1841
|
+
// puff to the torso, so the sleeve stretched when its chain turned.
|
|
1791
1842
|
for (const [s, sgn] of SIDES) {
|
|
1792
1843
|
const p = (sides.get(s) as PartRecord[])[0];
|
|
1793
1844
|
const mask = P.alpha(p);
|
|
@@ -1809,10 +1860,9 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
|
|
|
1809
1860
|
C(`sleeve_${s}`, 'chest', pts, [orSaid(bandX(mask, bot - 6), pts[pts.length - 1][0], "no art in the band: the last link's x", tipWhy), bot - 3], why, tipWhy);
|
|
1810
1861
|
tracks.push({ chain: `sleeve_${s}`, amps: [sgn * 0.2, sgn * 0.6, sgn * 1.2], period: 4.0, phase: 0.22 + (s === 'l' ? 0.05 : 0), lag: 0.08 });
|
|
1811
1862
|
const [g, r] = gridR(p);
|
|
1812
|
-
|
|
1813
|
-
meshes[p.name] = { grid: g, r, segments: [['chest', rnd([sx, y0 - 14]), rnd([sx, y0 + 2])], `sleeve_${s}`] };
|
|
1863
|
+
meshes[p.name] = { grid: g, r, segments: [`sleeve_${s}`] };
|
|
1814
1864
|
}
|
|
1815
|
-
notes.push('handwear is two blobs: one sleeve chain per mesh,
|
|
1865
|
+
notes.push('handwear is two blobs: one sleeve chain per mesh, and nothing on chest');
|
|
1816
1866
|
} else if (hw.length > 0) {
|
|
1817
1867
|
sleeveRule = `the one-blob sleeve rule (both chains in one mesh, split at the eye axis x=${pyFixed(axis, 0)})`;
|
|
1818
1868
|
const segs: Segment[] = [['chest', rnd([chest[0], chest[1] - 40]), rnd([chest[0], hip[1]])]];
|
|
@@ -2003,33 +2053,64 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
|
|
|
2003
2053
|
for (const c of legPlan.chains) {
|
|
2004
2054
|
const J = (joints as RigJoints).joints;
|
|
2005
2055
|
const used = (n: KeypointName): Placement => placement('joint', `the ${n} joint, ${usedAs(J[n])}`, { joints: [jointRef(n)] });
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2056
|
+
C(c.chain, 'hip', [c.hip, c.knee], c.ankle, [used(`${c.side}_hip`), used(`${c.side}_knee`)], used(`${c.side}_ankle`));
|
|
2057
|
+
}
|
|
2058
|
+
// Issue #349: each leg is a limb — the thigh on link 0 and the shin on link 1, cut at the knee, and the foot below
|
|
2059
|
+
// the ankle on a bone of its own that does not take the shin's rotation, so the shoe stays level (issue #348). The
|
|
2060
|
+
// thigh rides its link alone: no hip stub pins it.
|
|
2061
|
+
const fw = P.byTag('footwear', undefined, true).filter((q) => q.opaque_px > 500);
|
|
2062
|
+
const nearFoot = new Map(fw.map((q) => [q, dilate(P.alpha(q), 2 * WRIST_ON_PART_PX + 1)]));
|
|
2063
|
+
// The feet are drafted only when every leg's ankle lies on exactly one footwear part: a shoe layer holding both
|
|
2064
|
+
// shoes split between one leg's foot and no other would hand that leg the other shoe.
|
|
2065
|
+
const legChains = legPlan.chains;
|
|
2066
|
+
const feetOn = legChains.map((c) => fw.filter((q) => isOn(nearFoot.get(q) as Mask, c.ankle[0], c.ankle[1])));
|
|
2067
|
+
const feetBound = fw.length > 0 && feetOn.every((on) => on.length === 1);
|
|
2068
|
+
legChains.forEach((c, ci) => {
|
|
2069
|
+
const s = c.side as 'r' | 'l';
|
|
2070
|
+
const J = (joints as RigJoints).joints;
|
|
2071
|
+
const segs: Limb['segments'] = [
|
|
2072
|
+
{ part: `thigh_${s}`, from: c.part.name, bone: `leg_${s}0` },
|
|
2073
|
+
{ part: `shin_${s}`, from: c.part.name, bone: `leg_${s}1`, joint: 'knee' },
|
|
2074
|
+
];
|
|
2075
|
+
regions[`thigh_${s}`] = `leg_${s}0`;
|
|
2076
|
+
regions[`shin_${s}`] = `leg_${s}1`;
|
|
2077
|
+
consumed.add(c.part.name);
|
|
2078
|
+
const on = feetOn[ci];
|
|
2079
|
+
let foot = '';
|
|
2080
|
+
if (feetBound) {
|
|
2081
|
+
B(`foot_${s}`, `leg_${s}1`, c.ankle, placement('joint', `the ${s}_ankle joint, ${usedAs(J[`${s}_ankle`])}`, { joints: [jointRef(`${s}_ankle`)] }));
|
|
2082
|
+
(bones[bones.length - 1] as { inherit?: string }).inherit = FOOT_INHERIT;
|
|
2083
|
+
segs.push({ part: `foot_${s}`, from: on[0].name, bone: `foot_${s}`, joint: 'ankle' });
|
|
2084
|
+
regions[`foot_${s}`] = `foot_${s}`;
|
|
2085
|
+
consumed.add(on[0].name);
|
|
2086
|
+
foot = `, and foot_${s}, ${on[0].name}'s share below the ankle, on bone foot_${s} at the ${s}_ankle joint under leg_${s}1 with inherit "${FOOT_INHERIT}" — the shoe keeps its setup angle while the leg turns (FEET_STAY_LEVEL)`;
|
|
2087
|
+
} else if (fw.length > 0) {
|
|
2088
|
+
const where = (k: number): string => {
|
|
2089
|
+
const o = feetOn[k];
|
|
2090
|
+
const g = legChains[k];
|
|
2091
|
+
if (o.length > 1) return `${g.side}_ankle lies on the art of ${o.length} footwear parts (${o.map((q) => q.name).join(', ')})`;
|
|
2092
|
+
if (o.length === 1) return `${g.side}_ankle lies on ${o[0].name}`;
|
|
2093
|
+
const d = fw.map((q) => ({ q, d: artDistance(P.alpha(q), g.ankle) })).sort((a, b) => a.d - b.d)[0];
|
|
2094
|
+
return `${g.side}_ankle at ${fmtAt(g.ankle)} lies on the art of no footwear part over 500 px within ${WRIST_ON_PART_PX} px — the nearest is ${d.q.name}, ${d.d} px from its art by the larger of the x and y gaps`;
|
|
2095
|
+
};
|
|
2096
|
+
foot = `; no foot segment: every leg's ankle must lie on exactly one footwear part for the feet to be drafted, and ${legChains.map((_, k) => where(k)).join('; ')} — move the ankle joint onto the shoe, or write the foot segment and its bone in config by hand`;
|
|
2097
|
+
} else foot = '; no foot segment: no footwear part is over 500 px';
|
|
2098
|
+
limbs[`leg_${s}`] = { segments: segs };
|
|
2099
|
+
notes.push(`limb leg_${s}: ${c.part.name} (${c.part.from}) is cut at the ${s}_knee joint into thigh_${s} on leg_${s}0 and shin_${s} on leg_${s}1, each rigid on its link with weight 1${foot}; the shin carries a cap past the knee`);
|
|
2100
|
+
});
|
|
2025
2101
|
notes.push(...legPlan.notes);
|
|
2026
2102
|
} else if (legPlan !== null && legPlan.why !== null) {
|
|
2027
2103
|
notes.push(`${lw.map((p) => p.name).join(', ')}: no leg chain — ${legPlan.why}; the trunk rule places ${lw.length === 1 ? 'it' : 'them'}`);
|
|
2028
2104
|
}
|
|
2029
2105
|
|
|
2106
|
+
// Issue #349: a part a limb takes is not made, so the rule that read its tag first (footwear rides root) gives it nothing.
|
|
2107
|
+
for (const name of consumed) {
|
|
2108
|
+
delete meshes[name];
|
|
2109
|
+
delete regions[name];
|
|
2110
|
+
}
|
|
2030
2111
|
// Anything no rule claimed rides its nearest trunk bone rigidly, so the rig stage never meets an unassigned part.
|
|
2031
2112
|
for (const p of P.recs) {
|
|
2032
|
-
if (!(p.name in meshes) && !(p.name in regions)) {
|
|
2113
|
+
if (!(p.name in meshes) && !(p.name in regions) && !consumed.has(p.name)) {
|
|
2033
2114
|
const cy = center(p)[1];
|
|
2034
2115
|
regions[p.name] = cy < neckY ? 'head' : cy < hip[1] ? 'chest' : 'hip';
|
|
2035
2116
|
if (splitFrom(p.from)[0] === PAINTING_RUN) {
|
|
@@ -2078,7 +2159,8 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
|
|
|
2078
2159
|
// cycles per idle (REFERENCE_IDLE_SECONDS).
|
|
2079
2160
|
const scale = IDLE_SECONDS / REFERENCE_IDLE_SECONDS;
|
|
2080
2161
|
const idleTracks = tracks.map((tr) => ({ ...tr, period: tr.period * scale }));
|
|
2081
|
-
|
|
2162
|
+
const drafted = Object.keys(limbs).length === 0 ? {} : { limbs };
|
|
2163
|
+
return { proposal: { bones, meshes, regions, ...drafted, motion: blink === undefined ? { duration: IDLE_SECONDS, tracks: idleTracks } : { duration: IDLE_SECONDS, tracks: idleTracks, blink }, notes }, basis: { frame, bones: records } };
|
|
2082
2164
|
}
|
|
2083
2165
|
|
|
2084
2166
|
/**
|
|
@@ -2191,6 +2273,13 @@ export const WRIST_ON_PART_PX = 15;
|
|
|
2191
2273
|
* when the wrist is within {@link WRIST_ON_PART_PX} of that part's art, else
|
|
2192
2274
|
* the distance `--wrist-snap` bound it across.
|
|
2193
2275
|
*/
|
|
2276
|
+
/**
|
|
2277
|
+
* The `inherit` a drafted foot bone takes (issue #349): the shin's position and not its rotation or reflection, so
|
|
2278
|
+
* the shoe keeps its setup angle while the leg is posed — the mode `FEET_STAY_LEVEL` names (issue #348). The
|
|
2279
|
+
* proposer drafts it; which mode a foot takes stays the author's.
|
|
2280
|
+
*/
|
|
2281
|
+
export const FOOT_INHERIT = 'noRotationOrReflection';
|
|
2282
|
+
|
|
2194
2283
|
interface JointArm {
|
|
2195
2284
|
side: 'r' | 'l';
|
|
2196
2285
|
shoulder: [number, number];
|
|
@@ -2291,40 +2380,18 @@ function armsFromJoints(P: PartSet, hw: readonly PartRecord[], joints: RigJoints
|
|
|
2291
2380
|
|
|
2292
2381
|
/**
|
|
2293
2382
|
* One leg chain drafted from joints (issue #241): its name, the side whose
|
|
2294
|
-
* joints it runs through
|
|
2295
|
-
*
|
|
2296
|
-
* its two links and tip in rig px, and the legwear part it meshes.
|
|
2383
|
+
* joints it runs through, its two links and tip in rig px, and the legwear
|
|
2384
|
+
* part its limb takes (issue #349).
|
|
2297
2385
|
*/
|
|
2298
2386
|
interface LegChain {
|
|
2299
2387
|
chain: string;
|
|
2300
|
-
side: 'r' | 'l'
|
|
2388
|
+
side: 'r' | 'l';
|
|
2301
2389
|
hip: [number, number];
|
|
2302
2390
|
knee: [number, number];
|
|
2303
2391
|
ankle: [number, number];
|
|
2304
2392
|
part: PartRecord;
|
|
2305
2393
|
}
|
|
2306
2394
|
|
|
2307
|
-
/**
|
|
2308
|
-
* Whether `m`'s art breaks along the straight line from `a` to `b`: walked
|
|
2309
|
-
* one pixel step at a time (the larger of the x and y gaps, in steps of at
|
|
2310
|
-
* most one pixel, each point read as {@link isOn} reads it), some point with
|
|
2311
|
-
* no art lies between two points with art. Art that only starts or stops
|
|
2312
|
-
* along the line — an end point off the art — is no break.
|
|
2313
|
-
*/
|
|
2314
|
-
function artBreaks(m: Mask, a: readonly [number, number], b: readonly [number, number]): boolean {
|
|
2315
|
-
const n = Math.ceil(Math.max(Math.abs(b[0] - a[0]), Math.abs(b[1] - a[1])));
|
|
2316
|
-
let seenArt = false;
|
|
2317
|
-
let gapAfterArt = false;
|
|
2318
|
-
for (let i = 0; i <= n; i++) {
|
|
2319
|
-
const t = n === 0 ? 0 : i / n;
|
|
2320
|
-
const on = isOn(m, a[0] + t * (b[0] - a[0]), a[1] + t * (b[1] - a[1]));
|
|
2321
|
-
if (on && gapAfterArt) return true;
|
|
2322
|
-
if (on) seenArt = true;
|
|
2323
|
-
else if (seenArt) gapAfterArt = true;
|
|
2324
|
-
}
|
|
2325
|
-
return false;
|
|
2326
|
-
}
|
|
2327
|
-
|
|
2328
2395
|
/**
|
|
2329
2396
|
* The leg chains a keypoint file drafts for the legwear parts, or why none
|
|
2330
2397
|
* is drafted (issue #241) — the sleeve's {@link armsFromJoints} for the legs.
|
|
@@ -2337,14 +2404,11 @@ function artBreaks(m: Mask, a: readonly [number, number], b: readonly [number, n
|
|
|
2337
2404
|
* drafted and `why` is `null`: the proposal is the one written before the
|
|
2338
2405
|
* rule existed.
|
|
2339
2406
|
*
|
|
2340
|
-
*
|
|
2341
|
-
*
|
|
2342
|
-
*
|
|
2343
|
-
*
|
|
2344
|
-
*
|
|
2345
|
-
* its art, two chains would pull one stretch of it two ways, and it gets one
|
|
2346
|
-
* chain, `leg`, through the midpoints of the hips, the knees and the ankles.
|
|
2347
|
-
* Each part gets one note saying which and why.
|
|
2407
|
+
* Every bound leg gets its own chain, `leg_<side>`, and becomes a limb
|
|
2408
|
+
* (issue #349): a part carrying both legs is split between the two limbs by
|
|
2409
|
+
* the cut itself (each pixel to the leg whose bones pass nearest it), so the
|
|
2410
|
+
* single midpoint chain the mesh needed when the legs touched (issue #241) is
|
|
2411
|
+
* not drafted. Each part gets one note saying which and why.
|
|
2348
2412
|
*/
|
|
2349
2413
|
function legsFromJoints(P: PartSet, lw: readonly PartRecord[], joints: RigJoints): { chains: LegChain[] | null; notes: string[]; why: string | null } {
|
|
2350
2414
|
const complete: Array<{ side: 'r' | 'l'; hip: [number, number]; knee: [number, number]; ankle: [number, number] }> = [];
|
|
@@ -2389,23 +2453,11 @@ function legsFromJoints(P: PartSet, lw: readonly PartRecord[], joints: RigJoints
|
|
|
2389
2453
|
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}`);
|
|
2390
2454
|
continue;
|
|
2391
2455
|
}
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
const
|
|
2395
|
-
const anklesBreak = artBreaks(m, r.ankle, l.ankle);
|
|
2396
|
-
if (kneesBreak && anklesBreak) {
|
|
2397
|
-
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 });
|
|
2398
|
-
notes.push(
|
|
2399
|
-
`${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}`,
|
|
2400
|
-
);
|
|
2401
|
-
continue;
|
|
2402
|
-
}
|
|
2403
|
-
const mid = (a: [number, number], b: [number, number]): [number, number] => [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
|
|
2404
|
-
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 });
|
|
2405
|
-
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);
|
|
2456
|
+
// Issue #349: two legs on one part are two limbs; the cut gives each pixel to the leg whose bones pass nearest it,
|
|
2457
|
+
// so the legs need not be painted apart, and no midpoint chain stands for both.
|
|
2458
|
+
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 });
|
|
2406
2459
|
notes.push(
|
|
2407
|
-
`${p.name} (${p.from}): one
|
|
2408
|
-
`the chain runs hip -> knee -> ankle through the midpoints of the two hips, the two knees and the two ankles, and ${still}`,
|
|
2460
|
+
`${p.name} (${p.from}): one chain per leg, leg_r and leg_l — both ankles lie on its art; each runs hip -> knee -> ankle from its side's joints, the two limbs split the part between them (each pixel to the leg whose bones pass nearest it), and ${still}`,
|
|
2409
2461
|
);
|
|
2410
2462
|
}
|
|
2411
2463
|
return { chains, notes, why: null };
|
|
@@ -2503,25 +2555,13 @@ function jointNotes(joints: RigJoints, f: JointNoteFacts): string[] {
|
|
|
2503
2555
|
const both = f.arms !== null && f.arms.length === 2 && f.arms[0].part === f.arms[1].part ? f.arms[0].part.name : null;
|
|
2504
2556
|
if (both !== null) {
|
|
2505
2557
|
out.push(
|
|
2506
|
-
`one handwear part holds both wrists: r_wrist and l_wrist both end on ${both}, so
|
|
2507
|
-
`to
|
|
2558
|
+
`one handwear part holds both wrists: r_wrist and l_wrist both end on ${both}, so limbs arm_r and arm_l both take it, and the cut gives each pixel to the arm whose bones pass nearest it; ` +
|
|
2559
|
+
`where the arms touch or cross in its art that split is a line no painting draws — to draw it yourself, cut one sleeve out of ${both} with an assemble.cuts entry (a polygon round it) and name the piece as that arm's from`,
|
|
2508
2560
|
);
|
|
2509
2561
|
}
|
|
2510
2562
|
// Issue #241: the knees and ankles a leg chain was drafted from, and the hips as its first link.
|
|
2511
2563
|
for (const c of f.legs ?? []) {
|
|
2512
|
-
const on = `the ankle
|
|
2513
|
-
if (c.side === null) {
|
|
2514
|
-
out.push(`chain leg: link 0 is the midpoint of r_hip and l_hip, ${fmtAt(c.hip)} — the point bone hip stands on`);
|
|
2515
|
-
for (const [joint, role, pair] of [
|
|
2516
|
-
['r_knee', 'link 1', 'r_knee and l_knee'],
|
|
2517
|
-
['l_knee', 'link 1', 'r_knee and l_knee'],
|
|
2518
|
-
['r_ankle', 'the tip', 'r_ankle and l_ankle'],
|
|
2519
|
-
['l_ankle', 'the tip', 'r_ankle and l_ankle'],
|
|
2520
|
-
] as const) {
|
|
2521
|
-
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})`);
|
|
2522
|
-
}
|
|
2523
|
-
continue;
|
|
2524
|
-
}
|
|
2564
|
+
const on = `the ankle lies on ${c.part.name}`;
|
|
2525
2565
|
out.push(`chain leg_${c.side}: link 0 is the ${c.side}_hip joint, ${fmtAt(c.hip)}`);
|
|
2526
2566
|
for (const [joint, role] of [
|
|
2527
2567
|
[`${c.side}_knee`, 'link 1'],
|
package/src/rig.ts
CHANGED
|
@@ -108,6 +108,16 @@
|
|
|
108
108
|
* keys, a leaf twin of every bone a carried part binds, and one transform
|
|
109
109
|
* constraint per part carrying its twins `fraction · turn.x` in world x,
|
|
110
110
|
* after the config's own constraints. Without the field nothing is added.
|
|
111
|
+
* - **A bone's `inherit`**, only where config.bones declares one (issue
|
|
112
|
+
* #348): rig-c's mode name, written to that bone in rig.json as declared.
|
|
113
|
+
* The bone's setup rotation is then the turn from where rig-c's own poser
|
|
114
|
+
* puts it with a local rotation of 0 — under a mode that drops the parent's
|
|
115
|
+
* rotation that is not the parent's turn — so the bone still stands
|
|
116
|
+
* upright, or along its tip, at the setup pose. Where the idle gives the
|
|
117
|
+
* bone a control, the mode goes on the control, the bone that hangs from the
|
|
118
|
+
* declared parent, and the bone under it inherits normally: the idle's keys
|
|
119
|
+
* moved onto the control still turn the bone. Without the field no byte of
|
|
120
|
+
* any output moves.
|
|
111
121
|
*
|
|
112
122
|
* Every part image is padded by {@link PAD} transparent pixels on each side
|
|
113
123
|
* before it is meshed or placed, and the padded image is what `images/`
|
|
@@ -126,6 +136,7 @@ import { type AutoSpec, type BoneEntry, type WeightRule, type CharacterConfig, t
|
|
|
126
136
|
import { type BoneTransform, computeExactFrameTransforms, cropToSpineY, normaliseDegrees, toBoneLocal, toWorld } from './coords.ts';
|
|
127
137
|
import { type Problem, refuseIfAny } from './errors.ts';
|
|
128
138
|
import { eyeLineDegrees } from './headframe.ts';
|
|
139
|
+
import type { RigBoneInherit } from 'rig-c/src/rig.ts';
|
|
129
140
|
import { f32 } from 'rig-c/compile';
|
|
130
141
|
import type { MeshCounts, MeshQualityReport, MeshReductionInput, MotionAmplitude, ReducedMesh, Termination } from 'rig-c/mesh';
|
|
131
142
|
import { type AmplitudeRow, amplitudeRow, partAmplitude, withAmplitude } from './autoamplitude.ts';
|
|
@@ -382,6 +393,8 @@ interface Bone {
|
|
|
382
393
|
parent: string | null;
|
|
383
394
|
x: number;
|
|
384
395
|
y: number;
|
|
396
|
+
/** config.bones' `inherit`, on the bone that hangs from the declared parent (issue #348); absent when none is declared. */
|
|
397
|
+
inherit?: RigBoneInherit;
|
|
385
398
|
}
|
|
386
399
|
|
|
387
400
|
export interface RigBone {
|
|
@@ -393,6 +406,8 @@ export interface RigBone {
|
|
|
393
406
|
rotation?: number;
|
|
394
407
|
x: number;
|
|
395
408
|
y: number;
|
|
409
|
+
/** rig-c's inherit mode, written only where config.bones declares one (issue #348). */
|
|
410
|
+
inherit?: RigBoneInherit;
|
|
396
411
|
}
|
|
397
412
|
|
|
398
413
|
export interface WeightEntry {
|
|
@@ -944,7 +959,7 @@ export function buildRig(
|
|
|
944
959
|
chains.set(e.chain, names);
|
|
945
960
|
chainLines.set(e.chain, polyline);
|
|
946
961
|
} else {
|
|
947
|
-
B.set(e.name, { name: e.name, parent: e.parent, x: e.at[0], y: e.at[1] });
|
|
962
|
+
B.set(e.name, { name: e.name, parent: e.parent, x: e.at[0], y: e.at[1], ...(e.inherit === undefined ? {} : { inherit: e.inherit }) });
|
|
948
963
|
if (e.tip !== undefined) {
|
|
949
964
|
if (same(e.at, e.tip)) {
|
|
950
965
|
fail(
|
|
@@ -1103,9 +1118,10 @@ export function buildRig(
|
|
|
1103
1118
|
// rigc's line (`ctlRemedies`, src/build.ts).
|
|
1104
1119
|
refuseIfAny(problems);
|
|
1105
1120
|
for (const k of controls) {
|
|
1106
|
-
const b = B.get(k) as Bone;
|
|
1121
|
+
const { inherit, ...b } = B.get(k) as Bone;
|
|
1107
1122
|
const ctl = `${k}${CONTROL_SUFFIX}`;
|
|
1108
|
-
|
|
1123
|
+
// Issue #348: the declared mode belongs to the bone hanging from the declared parent, which is now the control.
|
|
1124
|
+
B.set(ctl, { name: ctl, parent: b.parent, x: b.x, y: b.y, ...(inherit === undefined ? {} : { inherit }) });
|
|
1109
1125
|
B.set(k, { ...b, parent: ctl });
|
|
1110
1126
|
}
|
|
1111
1127
|
moveKeysToControls(motion, controls);
|
|
@@ -1197,11 +1213,22 @@ export function buildRig(
|
|
|
1197
1213
|
const inherits = a === undefined && !faceRoots.has(b.name) && faceFramed.has(b.parent);
|
|
1198
1214
|
if (faceRoots.has(b.name) || inherits) faceFramed.add(b.name);
|
|
1199
1215
|
const target = a?.rotation ?? faceTurn.get(b.name) ?? (inherits ? up : 0);
|
|
1200
|
-
const rotation = places(normaliseDegrees(target - up));
|
|
1201
|
-
turn.set(b.name, places(up + rotation));
|
|
1202
1216
|
const frame = computeExactFrameTransforms(bones).get(b.parent) as BoneTransform;
|
|
1203
1217
|
const [x, y] = toBoneLocal(frame, spineX(b.x), spineY(b.y));
|
|
1204
|
-
|
|
1218
|
+
if (b.inherit === undefined) {
|
|
1219
|
+
const rotation = places(normaliseDegrees(target - up));
|
|
1220
|
+
turn.set(b.name, places(up + rotation));
|
|
1221
|
+
bones.push({ name: b.name, parent: b.parent, ...(a === undefined ? {} : { length: a.length }), ...(a !== undefined || rotation !== 0 ? { rotation } : {}), x: places(x), y: places(y) });
|
|
1222
|
+
continue;
|
|
1223
|
+
}
|
|
1224
|
+
// Issue #348: where rig-c's poser puts the bone with no rotation of its own, under its declared mode — the
|
|
1225
|
+
// parent's turn under `normal` and the two scale modes, 0 where the mode drops the parent's rotation — and the
|
|
1226
|
+
// turn from there to the target; its world turn is then read back off the same poser, not summed.
|
|
1227
|
+
const placedAt = { name: b.name, parent: b.parent, x: places(x), y: places(y), inherit: b.inherit };
|
|
1228
|
+
const posed = (r: number): BoneTransform => computeExactFrameTransforms([...bones, { ...placedAt, rotation: r }]).get(b.name) as BoneTransform;
|
|
1229
|
+
const rotation = places(normaliseDegrees(target - normaliseDegrees(posed(0).worldRotation)));
|
|
1230
|
+
turn.set(b.name, places(normaliseDegrees(posed(rotation).worldRotation)));
|
|
1231
|
+
bones.push({ name: b.name, parent: b.parent, ...(a === undefined ? {} : { length: a.length }), ...(a !== undefined || rotation !== 0 ? { rotation } : {}), x: places(x), y: places(y), inherit: b.inherit });
|
|
1205
1232
|
}
|
|
1206
1233
|
// ---- the head's up axis (issue #250) ----------------------------------
|
|
1207
1234
|
// `head` keeps its frame, so every key on it and under it — the idle, the blink's brow drop, an authored
|
|
@@ -1892,7 +1919,15 @@ export function flattenRig(rig: RigSpec, sense: 1 | -1 = 1): RigSpec {
|
|
|
1892
1919
|
const read = rig.bones.map((b) => ({ ...b, rotation: (b.rotation ?? 0) * sense }));
|
|
1893
1920
|
const world = computeExactFrameTransforms(read);
|
|
1894
1921
|
const turn = new Map<string, number>();
|
|
1895
|
-
|
|
1922
|
+
// A bone with a declared inherit mode (issue #348) is read off rig-c's poser, which honours the mode; every other is the sum, as before.
|
|
1923
|
+
for (const b of rig.bones) {
|
|
1924
|
+
turn.set(
|
|
1925
|
+
b.name,
|
|
1926
|
+
b.inherit === undefined
|
|
1927
|
+
? placed((b.parent === undefined ? 0 : (turn.get(b.parent) as number)) + (b.rotation ?? 0), OFFSET_PLACES)
|
|
1928
|
+
: placed(normaliseDegrees((world.get(b.name) as BoneTransform).worldRotation), OFFSET_PLACES),
|
|
1929
|
+
);
|
|
1930
|
+
}
|
|
1896
1931
|
const origin = (bone: string): BoneTransform => world.get(bone) as BoneTransform;
|
|
1897
1932
|
const out = (m: BoneTransform, x: number, y: number): [number, number] => {
|
|
1898
1933
|
const [wx, wy] = toWorld(m, x, y);
|
|
@@ -1902,7 +1937,7 @@ export function flattenRig(rig: RigSpec, sense: 1 | -1 = 1): RigSpec {
|
|
|
1902
1937
|
const bones: RigBone[] = rig.bones.filter((b) => b.name !== HEAD_UP_BONE).map((b) => {
|
|
1903
1938
|
if (b.parent === undefined) return { name: b.name, x: b.x, y: b.y };
|
|
1904
1939
|
const [x, y] = out(origin(b.parent), b.x, b.y);
|
|
1905
|
-
return { name: b.name, parent: b.parent, x, y };
|
|
1940
|
+
return { name: b.name, parent: b.parent, x, y, ...(b.inherit === undefined ? {} : { inherit: b.inherit }) };
|
|
1906
1941
|
});
|
|
1907
1942
|
const slotBone = new Map(rig.slots.map((s) => [s.name, s.bone]));
|
|
1908
1943
|
const skin: RigSpec['skins']['default'] = {};
|
package/src/tags.ts
CHANGED
|
@@ -187,6 +187,9 @@ export function isAuthorTag(name: string): boolean {
|
|
|
187
187
|
* other names the accepted v3 tags and the author form.
|
|
188
188
|
*/
|
|
189
189
|
export function layerTagRequirement(name: string): string {
|
|
190
|
+
if (name.endsWith(HIDDEN_SUFFIX)) {
|
|
191
|
+
return `is a hidden-art layer's name (issue #359) for ${JSON.stringify(name.slice(0, -HIDDEN_SUFFIX.length))}, which is no layer name; ${HIDDEN_NAME_RULE} is required`;
|
|
192
|
+
}
|
|
190
193
|
if (name.startsWith(AUTHOR_PREFIX)) {
|
|
191
194
|
return `is an author-made layer's name with the label ${JSON.stringify(name.slice(AUTHOR_PREFIX.length))}; ${AUTHOR_LABEL_RULE} is required after "${AUTHOR_PREFIX}"`;
|
|
192
195
|
}
|
|
@@ -205,3 +208,47 @@ export const AUTHOR_NO_ROLE =
|
|
|
205
208
|
export function authorLayerLine(name: string): string {
|
|
206
209
|
return `author-made: layer "${name}" — ${AUTHOR_NO_ROLE}`;
|
|
207
210
|
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* ⭐ **A hidden-art layer** (issue #359) holds the art of a layer where
|
|
214
|
+
* something else covers it — the forearm under the sleeve, the inside of the
|
|
215
|
+
* knee, the shirt under a scarf's tail — painted by an artist who has it,
|
|
216
|
+
* because See-through cannot see it. It is named by the layer it belongs to
|
|
217
|
+
* and {@link HIDDEN_SUFFIX}: `handwear-r:hidden` is `handwear-r`'s art where
|
|
218
|
+
* it is covered, `author:hand:hidden` is `author:hand`'s.
|
|
219
|
+
*
|
|
220
|
+
* Why this form and not `author:` with a declared target: the name alone says
|
|
221
|
+
* what the layer is and whose it is, so every reader knows at read time that
|
|
222
|
+
* it is not a part (the proposer never drafts one from it) and which layer it
|
|
223
|
+
* belongs to, with no config field to agree with it. The suffix holds a colon,
|
|
224
|
+
* which no v3 tag holds and an author-made label may not hold
|
|
225
|
+
* ({@link AUTHOR_LABEL_RULE}), so `<name>:hidden` can never be read as a tag,
|
|
226
|
+
* a side, or a label.
|
|
227
|
+
*
|
|
228
|
+
* It is never drawn and never a part. `assemble` reads it as the art the
|
|
229
|
+
* layer's part takes where a stage takes art from under something: a cut's
|
|
230
|
+
* `under` (issue #193) and a limb segment's cap (issue #349) — see
|
|
231
|
+
* `hiddenArt` in `src/assemble.ts`.
|
|
232
|
+
*/
|
|
233
|
+
export const HIDDEN_SUFFIX = ':hidden';
|
|
234
|
+
|
|
235
|
+
/** A hidden-art layer's name read: the layer it belongs to. */
|
|
236
|
+
export interface HiddenTagReading {
|
|
237
|
+
/** The name as the layer carries it: `handwear-r:hidden`. */
|
|
238
|
+
name: string;
|
|
239
|
+
/** The layer it belongs to: `handwear-r`, a v3 tag or an author-made layer's name. */
|
|
240
|
+
of: string;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Read a layer name as a hidden-art layer's, or `null` when it does not end in
|
|
245
|
+
* {@link HIDDEN_SUFFIX} or what precedes it is no layer name ({@link readLayerTag}).
|
|
246
|
+
*/
|
|
247
|
+
export function readHiddenTag(name: string): HiddenTagReading | null {
|
|
248
|
+
if (!name.endsWith(HIDDEN_SUFFIX)) return null;
|
|
249
|
+
const of = name.slice(0, -HIDDEN_SUFFIX.length);
|
|
250
|
+
return readLayerTag(of) === null ? null : { name, of };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** What a hidden-art layer's name must be, as a refusal quotes it. */
|
|
254
|
+
export const HIDDEN_NAME_RULE = `"<layer>${HIDDEN_SUFFIX}", where <layer> is a See-through v3 tag or "${AUTHOR_PREFIX}<label>" that the same input also holds`;
|