@rig-c/parts 1.9.1 → 1.11.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 +2 -1
- package/cli.ts +128 -15
- package/docs/AUTHORING.md +193 -20
- package/docs/STABILITY.md +18 -8
- package/package.json +1 -1
- package/src/assemble.ts +100 -22
- package/src/build.ts +62 -9
- package/src/check.ts +150 -32
- package/src/config.ts +82 -7
- package/src/contour.ts +40 -2
- package/src/layers.ts +12 -7
- package/src/meshdraft.ts +888 -0
- package/src/parts.ts +41 -10
- package/src/propose.ts +137 -25
- package/src/requirements.ts +21 -6
- package/src/rig.ts +86 -18
- package/src/tags.ts +86 -0
- package/src/turn.ts +31 -0
- package/src/weights.ts +69 -11
package/src/rig.ts
CHANGED
|
@@ -161,7 +161,7 @@ import { alphaAbove, crop, pad, type Raster } from './raster/index.ts';
|
|
|
161
161
|
import { pyRound } from './round.ts';
|
|
162
162
|
import { boneHeat, heatInfluences, heatRow, type HeatRow, latticeSilhouette, tracedSilhouette } from './heat.ts';
|
|
163
163
|
import { TURN_BONE, type TurnSpec, turnConstraintName, turnFractions, turnTwin } from './turn.ts';
|
|
164
|
-
import {
|
|
164
|
+
import { DISTANCE_EXPONENT, type FloorForm, type Influence, influences, type InfluenceLimits, type MeshInfluences, meshLimits, type Segment } from './weights.ts';
|
|
165
165
|
|
|
166
166
|
/** Transparent pixels added round every part image — the reference's `PAD`. */
|
|
167
167
|
export const PAD = 4;
|
|
@@ -309,12 +309,47 @@ export function eyeLine(parts: PartsFile, eyeBones: ReadonlyMap<string, Point>):
|
|
|
309
309
|
|
|
310
310
|
/**
|
|
311
311
|
* The weights a contour vertex is written with: a vertex no region reaches is
|
|
312
|
-
* rounded as the lattice rounds (
|
|
313
|
-
*
|
|
312
|
+
* rounded as the lattice rounds ({@link latticeShares}), one a region reaches
|
|
313
|
+
* by {@link roundShares} — and so is every vertex of a mesh that declares the
|
|
314
|
+
* continuous floor (issue #284), whose shares, like a region's ramp, reach
|
|
315
|
+
* below 0.03 and may round to 0.
|
|
314
316
|
*/
|
|
315
|
-
export function writtenShares(li: LocalInfluence): Influence[] {
|
|
316
|
-
if (li.region >= 0) return roundShares(li.influences);
|
|
317
|
-
|
|
317
|
+
export function writtenShares(li: LocalInfluence, floor?: FloorForm): Influence[] {
|
|
318
|
+
if (li.region >= 0 || floor === 'continuous') return roundShares(li.influences);
|
|
319
|
+
return latticeShares(li.influences);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* The refusal for a mesh some of whose vertices the cut leaves with no bone (issue #284): the first such vertex, with
|
|
324
|
+
* its capped shares before the floor, and how many more there are.
|
|
325
|
+
*
|
|
326
|
+
* When it can happen, by arithmetic: the capped shares of a vertex are normalised, so over k kept bones they sum to 1
|
|
327
|
+
* and the largest is at least 1/k. The cut keeps a share of at least 0.03 and the continuous floor one above 0.03, so a
|
|
328
|
+
* vertex keeps no bone only if 1/k ≤ 0.03, that is k ≥ 34 (1/33 = 0.0303… keeps its largest under both forms). At the
|
|
329
|
+
* default cap of 4 the largest share is at least 0.25, so a mesh that declares nothing never reaches this; only a
|
|
330
|
+
* declared cap of 34 or more, or `null`, over 34 or more bones whose shares are all at or below the floor does — and a
|
|
331
|
+
* share that is not a number (raw values summing to 0) lands here too rather than in the file. Rare, so refused by
|
|
332
|
+
* name rather than left to a crash in the rounding.
|
|
333
|
+
*/
|
|
334
|
+
export function nothingKeptProblem(part: string, floor: FloorForm | undefined, first: { vertex: number; at: Point; capped: readonly Influence[] }, more: number): Problem {
|
|
335
|
+
const largest = first.capped.length === 0 ? null : Math.max(...first.capped.map((e) => e.weight));
|
|
336
|
+
const form = floor === 'continuous' ? 'the continuous floor (0.03 subtracted from every share) keeps none at or below 0.03' : 'the cut drops every share below 0.03';
|
|
337
|
+
return {
|
|
338
|
+
code: 'RIG_INFLUENCES_KEPT',
|
|
339
|
+
object: `config.meshes.${part}.influences`,
|
|
340
|
+
detail: `vertex ${first.vertex} at rig (${first.at[0]}, ${first.at[1]})${more > 0 ? ` and ${more} more` : ''} keeps no bone: its ${first.capped.length} capped share(s) have the largest ${largest === null || !Number.isFinite(largest) ? String(largest) : pyRound(largest, 6)}, and ${form}; a largest share above 0.03 is required, which every vertex has when 33 or fewer bones are kept (the largest of k shares is at least 1/k) — declare maxInfluences 33 or less, or name fewer bones in segments`,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* The lattice's rounding: each weight to 5 places, the last entry written as
|
|
346
|
+
* `1 − sum(others)`. Safe because under the cut every kept entry is at least
|
|
347
|
+
* `MIN_WEIGHT`, so the last cannot be taken negative; a mesh that declares the
|
|
348
|
+
* continuous floor is rounded by {@link roundShares} instead (issue #284, as
|
|
349
|
+
* Stage E wrote it: `docs/evidence/weight-cap.md`).
|
|
350
|
+
*/
|
|
351
|
+
export function latticeShares(list: readonly Influence[]): Influence[] {
|
|
352
|
+
const shares = list.map(({ bone, weight }) => ({ bone, weight: pyRound(weight, 5) }));
|
|
318
353
|
let others = 0;
|
|
319
354
|
for (let k = 0; k < shares.length - 1; k++) others += shares[k].weight;
|
|
320
355
|
shares[shares.length - 1].weight = pyRound(1 - others, 5);
|
|
@@ -613,6 +648,8 @@ export interface LatticeMeshReport {
|
|
|
613
648
|
grid: number;
|
|
614
649
|
/** The distance rule's exponent the mesh declared (issue #161); absent when it declared none and ran at 2. */
|
|
615
650
|
exponent?: number;
|
|
651
|
+
/** The cap and floor form the mesh declared, as declared (issue #284); absent when it declared none and ran at 4 and the cut. */
|
|
652
|
+
influences?: MeshInfluences;
|
|
616
653
|
/** Only under `rule: "heat"` (issue #161): the rule and the solve it ran. Absent otherwise, so a distance mesh writes the row it always wrote. */
|
|
617
654
|
heat?: HeatRow;
|
|
618
655
|
}
|
|
@@ -636,9 +673,12 @@ export interface ContourMeshReport {
|
|
|
636
673
|
mode: 'contour';
|
|
637
674
|
/** The distance rule's exponent the mesh declared (issue #161); absent when it declared none and ran at 2. */
|
|
638
675
|
exponent?: number;
|
|
676
|
+
/** As the lattice row's (issue #284). */
|
|
677
|
+
influences?: MeshInfluences;
|
|
639
678
|
/** Only when the mesh declared ribs (issue #188): what it declared and what was placed. Absent otherwise, so a mesh without ribs writes the row it always wrote. */
|
|
640
679
|
ribs?: RibsRow;
|
|
641
|
-
|
|
680
|
+
/** `maxEdge` only when the mesh declared it (issue #265), so a mesh without it writes the row it always wrote. */
|
|
681
|
+
params: { tolerance: number; margin: number; spacing: number; maxEdge?: number; budget: number | null; stray: number | null };
|
|
642
682
|
contour: ContourReport;
|
|
643
683
|
regions: Array<{ name: string; bone: string; reached: number; whole: number }>;
|
|
644
684
|
/** Only under `rule: "heat"` (issue #161), as the lattice row's. */
|
|
@@ -1320,8 +1360,10 @@ export function buildRig(
|
|
|
1320
1360
|
segs: Segment[],
|
|
1321
1361
|
rule: WeightRule,
|
|
1322
1362
|
ribSpec: { segments: ReadonlyArray<string | readonly [string, Point, Point]>; ribs?: RibsSpec },
|
|
1363
|
+
declared: MeshInfluences | undefined,
|
|
1323
1364
|
out: Problem[],
|
|
1324
1365
|
): { attachment: MeshAttachment; report: ContourMeshReport } | null => {
|
|
1366
|
+
const limits = meshLimits(declared);
|
|
1325
1367
|
const ox = p.x - PAD;
|
|
1326
1368
|
const oy = p.y - PAD;
|
|
1327
1369
|
const w = img.width;
|
|
@@ -1335,7 +1377,7 @@ export function buildRig(
|
|
|
1335
1377
|
: { name: rg.name, shape: 'polygon', points: rg.points.map(([x, y]) => [x - ox, y - oy] as [number, number]), spacing: rg.spacing, band: rg.band },
|
|
1336
1378
|
);
|
|
1337
1379
|
const ribs = chainRibs(ribSpec.segments, ribSpec.ribs, ox, oy);
|
|
1338
|
-
const cm = contourMesh(p.name, { width: w, height: h, alpha }, { threshold: ART_ALPHA, tolerance: spec.tolerance, margin: spec.margin, spacing: spec.spacing, budget: spec.budget, stray: spec.stray, regions: inPart, ...(ribs.length === 0 ? {} : { ribs }) });
|
|
1380
|
+
const cm = contourMesh(p.name, { width: w, height: h, alpha }, { threshold: ART_ALPHA, tolerance: spec.tolerance, margin: spec.margin, spacing: spec.spacing, ...(spec.maxEdge === undefined ? {} : { maxEdge: spec.maxEdge }), budget: spec.budget, stray: spec.stray, regions: inPart, ...(ribs.length === 0 ? {} : { ribs }) });
|
|
1339
1381
|
if (Array.isArray(cm)) {
|
|
1340
1382
|
out.push(...cm);
|
|
1341
1383
|
return null;
|
|
@@ -1353,10 +1395,12 @@ export function buildRig(
|
|
|
1353
1395
|
let infl = 0;
|
|
1354
1396
|
let maxInfl = 0;
|
|
1355
1397
|
let overlapped = false;
|
|
1398
|
+
let emptied: { vertex: number; at: Point; capped: Influence[] } | null = null;
|
|
1399
|
+
let emptiedMore = 0;
|
|
1356
1400
|
cm.vertices.forEach(([vx, vy], vi) => {
|
|
1357
1401
|
const wx = vx + ox;
|
|
1358
1402
|
const wy = vy + oy;
|
|
1359
|
-
const li = localInfluences([wx, wy], segs, r, regions,
|
|
1403
|
+
const li = localInfluences([wx, wy], segs, r, regions, limits, exponent ?? DISTANCE_EXPONENT, heat === null ? undefined : heatInfluences(heat, [vx, vy], limits));
|
|
1360
1404
|
if ('first' in li) {
|
|
1361
1405
|
if (!overlapped) {
|
|
1362
1406
|
out.push({
|
|
@@ -1368,11 +1412,17 @@ export function buildRig(
|
|
|
1368
1412
|
overlapped = true;
|
|
1369
1413
|
return;
|
|
1370
1414
|
}
|
|
1415
|
+
if (li.influences.length === 0) {
|
|
1416
|
+
// issue #284: a declared cut that keeps no bone (nothingKeptProblem says when that can be); a region's bone would be kept.
|
|
1417
|
+
if (emptied === null) emptied = { vertex: vi, at: [wx, wy], capped: heat === null ? influences([wx, wy], segs, r, { maxInfluences: limits.maxInfluences, minWeight: 0 }, exponent ?? DISTANCE_EXPONENT) : heatInfluences(heat, [vx, vy], { maxInfluences: limits.maxInfluences, minWeight: 0 }) };
|
|
1418
|
+
else emptiedMore++;
|
|
1419
|
+
return;
|
|
1420
|
+
}
|
|
1371
1421
|
if (li.region >= 0) {
|
|
1372
1422
|
reached[li.region]++;
|
|
1373
1423
|
if (li.g >= 1) whole[li.region]++;
|
|
1374
1424
|
}
|
|
1375
|
-
const shares = writtenShares(li);
|
|
1425
|
+
const shares = writtenShares(li, limits.floor);
|
|
1376
1426
|
const ent: WeightEntry[] = shares.map(({ bone, weight }) => {
|
|
1377
1427
|
const [x, y] = toBoneLocal(world.get(bone) as BoneTransform, spineX(wx), spineY(wy));
|
|
1378
1428
|
return { bone, x: places(x), y: places(y), weight };
|
|
@@ -1382,6 +1432,10 @@ export function buildRig(
|
|
|
1382
1432
|
maxInfl = Math.max(maxInfl, ent.length);
|
|
1383
1433
|
});
|
|
1384
1434
|
if (overlapped) return null;
|
|
1435
|
+
if (emptied !== null) {
|
|
1436
|
+
out.push(nothingKeptProblem(p.name, limits.floor, emptied, emptiedMore));
|
|
1437
|
+
return null;
|
|
1438
|
+
}
|
|
1385
1439
|
const uvs: number[] = [];
|
|
1386
1440
|
for (const [x, y] of cm.vertices) uvs.push(pyRound(x / w, 6), pyRound(y / h, 6));
|
|
1387
1441
|
const bones = new Set(segs.map((s) => s.bone));
|
|
@@ -1400,8 +1454,9 @@ export function buildRig(
|
|
|
1400
1454
|
art_coverage: pyRound(rep.coveredArtPixels / (rep.artPixels + rep.strayPixels), 5),
|
|
1401
1455
|
mode: 'contour',
|
|
1402
1456
|
...(exponent === undefined ? {} : { exponent }),
|
|
1457
|
+
...(declared === undefined ? {} : { influences: declared }),
|
|
1403
1458
|
...(ribSpec.ribs === undefined ? {} : { ribs: ribsRow(ribSpec.ribs, cm.ribs ?? []) }),
|
|
1404
|
-
params: { tolerance: spec.tolerance, margin: spec.margin, spacing: spec.spacing, budget: spec.budget ?? null, stray: spec.stray ?? null },
|
|
1459
|
+
params: { tolerance: spec.tolerance, margin: spec.margin, spacing: spec.spacing, ...(spec.maxEdge === undefined ? {} : { maxEdge: spec.maxEdge }), budget: spec.budget ?? null, stray: spec.stray ?? null },
|
|
1405
1460
|
contour: rep,
|
|
1406
1461
|
regions: regions.map((rg, k) => ({ name: rg.name, bone: rg.bone, reached: reached[k], whole: whole[k] })),
|
|
1407
1462
|
...(heat === null ? {} : { heat: heatRow(heat) }),
|
|
@@ -1683,7 +1738,7 @@ export function buildRig(
|
|
|
1683
1738
|
continue;
|
|
1684
1739
|
}
|
|
1685
1740
|
if ('contour' in mesh) {
|
|
1686
|
-
const row = contourAttachment(p, img, file, mesh.contour, mesh.r, mesh.exponent, segs, mesh.rule ?? 'distance', mesh, problems);
|
|
1741
|
+
const row = contourAttachment(p, img, file, mesh.contour, mesh.r, mesh.exponent, segs, mesh.rule ?? 'distance', mesh, mesh.influences, problems);
|
|
1687
1742
|
if (row === null) continue;
|
|
1688
1743
|
skin[p.name] = { [p.name]: row.attachment };
|
|
1689
1744
|
slots.push({ name: p.name, bone: segs[0].bone, attachment: p.name });
|
|
@@ -1710,20 +1765,32 @@ export function buildRig(
|
|
|
1710
1765
|
const weights: WeightEntry[][] = [];
|
|
1711
1766
|
let infl = 0;
|
|
1712
1767
|
let maxInfl = 0;
|
|
1713
|
-
|
|
1768
|
+
// issue #284: the mesh's declared cap and floor form, or DEFAULT_LIMITS itself when it declares none.
|
|
1769
|
+
const limits: InfluenceLimits = meshLimits(mesh.influences);
|
|
1770
|
+
let emptied: { vertex: number; at: Point; capped: Influence[] } | null = null;
|
|
1771
|
+
let emptiedMore = 0;
|
|
1772
|
+
for (const [vi, [vx0, vy0]] of lm.vertices.entries()) {
|
|
1714
1773
|
const wx = vx0 + ox;
|
|
1715
1774
|
const wy = vy0 + oy;
|
|
1716
|
-
const
|
|
1775
|
+
const shares = heat === null ? influences([wx, wy], segs, mesh.r, limits, mesh.exponent ?? DISTANCE_EXPONENT) : heatInfluences(heat, [vx0, vy0], limits);
|
|
1776
|
+
if (shares.length === 0) {
|
|
1777
|
+
// issue #284: a declared cut that keeps no bone (nothingKeptProblem says when that can be).
|
|
1778
|
+
if (emptied === null) emptied = { vertex: vi, at: [wx, wy], capped: heat === null ? influences([wx, wy], segs, mesh.r, { maxInfluences: limits.maxInfluences, minWeight: 0 }, mesh.exponent ?? DISTANCE_EXPONENT) : heatInfluences(heat, [vx0, vy0], { maxInfluences: limits.maxInfluences, minWeight: 0 }) };
|
|
1779
|
+
else emptiedMore++;
|
|
1780
|
+
continue;
|
|
1781
|
+
}
|
|
1782
|
+
const ent: WeightEntry[] = (limits.floor === 'continuous' ? roundShares(shares) : latticeShares(shares)).map(({ bone, weight }) => {
|
|
1717
1783
|
const [x, y] = toBoneLocal(world.get(bone) as BoneTransform, spineX(wx), spineY(wy));
|
|
1718
|
-
return { bone, x: places(x), y: places(y), weight
|
|
1784
|
+
return { bone, x: places(x), y: places(y), weight };
|
|
1719
1785
|
});
|
|
1720
|
-
let others = 0;
|
|
1721
|
-
for (let k = 0; k < ent.length - 1; k++) others += ent[k].weight;
|
|
1722
|
-
ent[ent.length - 1].weight = pyRound(1 - others, 5);
|
|
1723
1786
|
weights.push(ent);
|
|
1724
1787
|
infl += ent.length;
|
|
1725
1788
|
maxInfl = Math.max(maxInfl, ent.length);
|
|
1726
1789
|
}
|
|
1790
|
+
if (emptied !== null) {
|
|
1791
|
+
problems.push(nothingKeptProblem(p.name, limits.floor, emptied, emptiedMore));
|
|
1792
|
+
continue;
|
|
1793
|
+
}
|
|
1727
1794
|
const uvs: number[] = [];
|
|
1728
1795
|
for (const [x, y] of lm.vertices) uvs.push(pyRound(x / w, 6), pyRound(y / h, 6));
|
|
1729
1796
|
skin[p.name] = { [p.name]: { type: 'mesh', image: file, width: w, height: h, uvs, triangles: lm.triangles, hull: lm.hull, weights } };
|
|
@@ -1739,6 +1806,7 @@ export function buildRig(
|
|
|
1739
1806
|
art_coverage: pyRound(artCoverage(lm, art), 5),
|
|
1740
1807
|
grid: mesh.grid,
|
|
1741
1808
|
...(mesh.exponent === undefined ? {} : { exponent: mesh.exponent }),
|
|
1809
|
+
...(mesh.influences === undefined ? {} : { influences: mesh.influences }),
|
|
1742
1810
|
...(heat === null ? {} : { heat: heatRow(heat) }),
|
|
1743
1811
|
});
|
|
1744
1812
|
}
|
package/src/tags.ts
CHANGED
|
@@ -26,6 +26,22 @@
|
|
|
26
26
|
* Nothing downstream classifies a part by its NAME. The role of a part — what
|
|
27
27
|
* a proposer may do with it — is read from the tag it came from, which is why
|
|
28
28
|
* `parts.json` records `from` for every part.
|
|
29
|
+
*
|
|
30
|
+
* ⭐ **An author-made layer** (issue #256) is a layer the author split or
|
|
31
|
+
* painted by hand outside the tool — a hand laid over a hip taken out of
|
|
32
|
+
* `handwear`, a loop of hair over a shoulder taken out of `back hair` — and
|
|
33
|
+
* added to a run. It is named `author:<label>` ({@link AUTHOR_PREFIX}), the
|
|
34
|
+
* label the author's own. It is not a See-through tag and carries no role:
|
|
35
|
+
* {@link readTag} never reads it, so no rule that reads roles from tags ever
|
|
36
|
+
* sees it, and every reader that admits a layer reads it through
|
|
37
|
+
* {@link readLayerTag}, which says `group: "author"`. Why a prefix and this
|
|
38
|
+
* one: a single reserved name would let a run hold one such layer (a tag
|
|
39
|
+
* names one layer, `LAYERS_NAME_UNIQUE`, and one layer makes one part); no
|
|
40
|
+
* v3 tag holds a colon, so the prefix cannot be read as a See-through tag or
|
|
41
|
+
* as one of its `-r`/`-l` halves; and `<origin>:<name>` is already how
|
|
42
|
+
* `parts.json` says "not a See-through layer" for a patch (`painting:<name>`).
|
|
43
|
+
* Every reader of a part's `from` splits at the first colon, so
|
|
44
|
+
* `full:author:hand` is the full run's layer `author:hand`.
|
|
29
45
|
*/
|
|
30
46
|
|
|
31
47
|
export const BODY_TAGS = [
|
|
@@ -119,3 +135,73 @@ export function pairOf(name: string): string | null {
|
|
|
119
135
|
if (t === null || t.side === null) return null;
|
|
120
136
|
return `${t.base}-${t.side === 'r' ? 'l' : 'r'}`;
|
|
121
137
|
}
|
|
138
|
+
|
|
139
|
+
/** The prefix of an author-made layer's name (issue #256): `author:<label>`. */
|
|
140
|
+
export const AUTHOR_PREFIX = 'author:';
|
|
141
|
+
|
|
142
|
+
/** An author-made layer's name read: the label after {@link AUTHOR_PREFIX}, and no tag, so no role. */
|
|
143
|
+
export interface AuthorTagReading {
|
|
144
|
+
/** The name as the layer carries it: `author:hand`. */
|
|
145
|
+
name: string;
|
|
146
|
+
/** What follows the prefix: the author's own word, read by nothing as a role. */
|
|
147
|
+
label: string;
|
|
148
|
+
group: 'author';
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** What a layer name reads as: a See-through v3 tag, or an author-made layer's name. */
|
|
152
|
+
export type LayerTagReading = TagReading | AuthorTagReading;
|
|
153
|
+
|
|
154
|
+
/** What an author-made layer's label must be, as a refusal quotes it. */
|
|
155
|
+
export const AUTHOR_LABEL_RULE = 'a label that is not empty, does not start with "." or whitespace, does not end with whitespace, and holds no slash, backslash or colon';
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Whether `label` may follow {@link AUTHOR_PREFIX}. It becomes part of a part
|
|
159
|
+
* name the plan proposes and of a file name the wrapper form may look for
|
|
160
|
+
* (`parts/<name>.png`), so it is held to what a part name may be, and it holds
|
|
161
|
+
* no colon, so the name keeps exactly one.
|
|
162
|
+
*/
|
|
163
|
+
export function authorLabelOk(label: string): boolean {
|
|
164
|
+
return label !== '' && !label.startsWith('.') && label.trim() === label && !/[\\/:]/.test(label);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Read a layer name as an author-made layer's, or `null` when it is not one (no prefix, or a label {@link authorLabelOk} refuses). */
|
|
168
|
+
export function readAuthorTag(name: string): AuthorTagReading | null {
|
|
169
|
+
if (!name.startsWith(AUTHOR_PREFIX)) return null;
|
|
170
|
+
const label = name.slice(AUTHOR_PREFIX.length);
|
|
171
|
+
return authorLabelOk(label) ? { name, label, group: 'author' } : null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Read a layer name as a v3 tag or an author-made layer's name, or `null` when it is neither. */
|
|
175
|
+
export function readLayerTag(name: string): LayerTagReading | null {
|
|
176
|
+
return readTag(name) ?? readAuthorTag(name);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Whether a tag string names an author-made layer. */
|
|
180
|
+
export function isAuthorTag(name: string): boolean {
|
|
181
|
+
return readAuthorTag(name) !== null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Why `name` is no layer tag, as the detail of a `*_TAG_KNOWN` refusal: an
|
|
186
|
+
* `author:` name whose label is refused says what the label must be; any
|
|
187
|
+
* other names the accepted v3 tags and the author form.
|
|
188
|
+
*/
|
|
189
|
+
export function layerTagRequirement(name: string): string {
|
|
190
|
+
if (name.startsWith(AUTHOR_PREFIX)) {
|
|
191
|
+
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
|
+
}
|
|
193
|
+
return `is not a See-through v3 tag; one of ${acceptedTagNames().join(', ')} is required, or "${AUTHOR_PREFIX}<label>" for a layer the author made`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* What every stage says of an author-made layer (issue #256), so it never
|
|
198
|
+
* reads as a See-through layer: `layers` prints it per layer, `assemble` per
|
|
199
|
+
* part, and `--propose-plan` and `propose` carry it in their notes.
|
|
200
|
+
*/
|
|
201
|
+
export const AUTHOR_NO_ROLE =
|
|
202
|
+
'an author-made layer, not a See-through one: no tag, so no role — it rides the bone the config gives it (config.regions.<part>, or a mesh in config.meshes), and its draw position is its place in config.assemble.plan';
|
|
203
|
+
|
|
204
|
+
/** The `layers` line for an author-made layer. */
|
|
205
|
+
export function authorLayerLine(name: string): string {
|
|
206
|
+
return `author-made: layer "${name}" — ${AUTHOR_NO_ROLE}`;
|
|
207
|
+
}
|
package/src/turn.ts
CHANGED
|
@@ -30,6 +30,37 @@
|
|
|
30
30
|
*/
|
|
31
31
|
import { pyRound } from './round.ts';
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* The layers that ride on the face (issue #271): See-through makes no depth
|
|
35
|
+
* of their own for them, so `propose` does not read theirs. The eye group —
|
|
36
|
+
* eyewhite, irides, eyelash, eyebrow — is cut from one depth map predicted for
|
|
37
|
+
* the combined eyes layer (`compose_list` in See-through's
|
|
38
|
+
* `common/utils/inference_utils.py`, commit df019de), which is why
|
|
39
|
+
* `eyewhite-r` and `eyewhite-l` read the same median in every run measured
|
|
40
|
+
* for #251; nose and mouth are clamped by See-through to the face's median
|
|
41
|
+
* minus {@link TURN_RIDER_OFFSET} when they read behind it (the same file,
|
|
42
|
+
* lines 528-531). Their own medians disagreed across the two runs by up to
|
|
43
|
+
* 0.474 (demo `eyebrow-r`, #251's table), and the disagreement swapped their
|
|
44
|
+
* order against the face. Matched on the layer's base tag, either side.
|
|
45
|
+
*/
|
|
46
|
+
export const TURN_RIDER_TAGS: readonly string[] = ['eyewhite', 'irides', 'eyelash', 'eyebrow', 'nose', 'mouth'];
|
|
47
|
+
|
|
48
|
+
/** The tag of the part the riders take their depth from. */
|
|
49
|
+
export const TURN_CARRIER_TAG = 'face';
|
|
50
|
+
|
|
51
|
+
/** See-through's own offset between a clamped face feature and the face (`face − 0.001`, the file named at {@link TURN_RIDER_TAGS}). */
|
|
52
|
+
export const TURN_RIDER_OFFSET = 0.001;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A rider's depth: its carrier's, {@link TURN_RIDER_OFFSET} nearer when the
|
|
56
|
+
* rider is drawn after the carrier (above it) and as much further when it is
|
|
57
|
+
* drawn before (under it), so the draw order the depths imply is the one
|
|
58
|
+
* parts.json holds.
|
|
59
|
+
*/
|
|
60
|
+
export function turnRiderDepth(carrier: number, above: boolean): number {
|
|
61
|
+
return above ? carrier - TURN_RIDER_OFFSET : carrier + TURN_RIDER_OFFSET;
|
|
62
|
+
}
|
|
63
|
+
|
|
33
64
|
/** The control bone the rig stage adds under `root` when the config has `turn`: nothing keys it, and its local x is the dial. */
|
|
34
65
|
export const TURN_BONE = 'turn';
|
|
35
66
|
|
package/src/weights.ts
CHANGED
|
@@ -18,6 +18,13 @@
|
|
|
18
18
|
* rounding. Ties in the sort keep the order the bones first appeared among the
|
|
19
19
|
* segments (Python's `sorted` is stable, and so is `Array.prototype.sort`).
|
|
20
20
|
*
|
|
21
|
+
* The cap and the floor's form are the author's per mesh in the lattice and
|
|
22
|
+
* contour modes (issue #284, `meshes.<part>.influences`): a declared
|
|
23
|
+
* `maxInfluences` (a whole number, or `null`: no cap) replaces the 4, and
|
|
24
|
+
* `floor: "continuous"` replaces the drop at 0.03 by the continuous floor
|
|
25
|
+
* ({@link FloorForm}). Absent, both are the constants above, and every byte is
|
|
26
|
+
* the one written before the field existed.
|
|
27
|
+
*
|
|
21
28
|
* Distances are the reference's arithmetic, operation for operation — the
|
|
22
29
|
* projection `t = clip(((p - a) . ab) / |ab|^2, 0, 1)` and `sqrt(dx^2 + dy^2)`,
|
|
23
30
|
* not `Math.hypot`, whose last bit differs — because a weight rounded at the
|
|
@@ -31,12 +38,36 @@
|
|
|
31
38
|
*/
|
|
32
39
|
import type { Point } from './config.ts';
|
|
33
40
|
|
|
34
|
-
/**
|
|
41
|
+
/**
|
|
42
|
+
* At most this many bones pull one vertex where a mesh declares no cap: the reference implementation's `[:4]`,
|
|
43
|
+
* carried over as a port constant, and nothing more. The format sets no limit — spine-core 4.3.13 reads a weighted
|
|
44
|
+
* vertex as a bone count followed by that many bindings, with no bound on the count (`SkeletonJson.js`,
|
|
45
|
+
* `SkeletonBinary.js`, and `computeWorldVertices` in `attachments/Attachment.js`) — and rig-c 2.40.0 neither refuses
|
|
46
|
+
* nor warns above four: its one assertion on a mesh's weights, `A20_MESH_WEIGHTS_COHERENT`, checks that they are
|
|
47
|
+
* coherent (every vertex bound, every weight finite and non-negative, each vertex summing to 1), not how many there
|
|
48
|
+
* are. Read and cited in `docs/evidence/weight-cap.md` (issue #284, Stage E). A mesh may declare its own cap
|
|
49
|
+
* (`meshes.<part>.influences.maxInfluences`).
|
|
50
|
+
*/
|
|
35
51
|
export const MAX_INFLUENCES = 4;
|
|
36
52
|
|
|
37
|
-
/**
|
|
53
|
+
/**
|
|
54
|
+
* A normalised weight under this is dropped before the final normalisation — the reference's `>= 0.03`. Under the
|
|
55
|
+
* continuous floor ({@link FloorForm}) it is subtracted from every kept share instead.
|
|
56
|
+
*/
|
|
38
57
|
export const MIN_WEIGHT = 0.03;
|
|
39
58
|
|
|
59
|
+
/**
|
|
60
|
+
* The floor's form (issue #284): `"cut"` — the reference's, and what an absent form means: the capped shares,
|
|
61
|
+
* normalised, any under the floor dropped, the rest normalised again — or `"continuous"`: the capped shares,
|
|
62
|
+
* normalised, the floor subtracted from every one, those at or below zero dropped, the rest normalised again. Under
|
|
63
|
+
* the continuous floor a share reaches zero exactly where the cut would drop it, so a bone fades out of a vertex
|
|
64
|
+
* instead of leaving it in one step; this is Stage A's "continuous floor" (`tools/tear_survey.ts`), the form Stage E
|
|
65
|
+
* swept at every cap (`docs/evidence/weight-cap.md`).
|
|
66
|
+
*/
|
|
67
|
+
export type FloorForm = 'cut' | 'continuous';
|
|
68
|
+
|
|
69
|
+
export const FLOOR_FORMS: readonly FloorForm[] = ['cut', 'continuous'];
|
|
70
|
+
|
|
40
71
|
/**
|
|
41
72
|
* The exponent of the distance rule where a mesh declares none: `1/(d + r)²`,
|
|
42
73
|
* the reference's rule. At this value the weight is computed as
|
|
@@ -76,20 +107,42 @@ export interface Influence {
|
|
|
76
107
|
|
|
77
108
|
/**
|
|
78
109
|
* The cap and the floor `influences()` applies. The lattice and the contour
|
|
79
|
-
* mode
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
110
|
+
* mode run at {@link DEFAULT_LIMITS} (the reference's `[:4]` and `>= 0.03`)
|
|
111
|
+
* unless the mesh declares `influences` (issue #284; {@link meshLimits}); the
|
|
112
|
+
* automatic mode (`src/automesh.ts`, issue #126) passes the author's numbers
|
|
113
|
+
* instead, because rig-c's reduction is handed explicit influence limits on
|
|
114
|
+
* every weighted call and the 0.03 floor is not one the author wrote (P19).
|
|
115
|
+
* The arithmetic is the same either way, so with the defaults every byte the
|
|
116
|
+
* two older modes write is unchanged.
|
|
85
117
|
*/
|
|
86
118
|
export interface InfluenceLimits {
|
|
87
119
|
maxInfluences: number;
|
|
88
120
|
minWeight: number;
|
|
121
|
+
/** The floor's form; absent is `"cut"`, the arithmetic as it always was. */
|
|
122
|
+
floor?: FloorForm;
|
|
89
123
|
}
|
|
90
124
|
|
|
91
125
|
export const DEFAULT_LIMITS: InfluenceLimits = { maxInfluences: MAX_INFLUENCES, minWeight: MIN_WEIGHT };
|
|
92
126
|
|
|
127
|
+
/** `meshes.<part>.influences` in the lattice and contour modes (issue #284): either field may be absent. */
|
|
128
|
+
export interface MeshInfluences {
|
|
129
|
+
/** A whole number, 1 or more, or `null`, no cap — every bone the mesh's segments name is kept (the config's spelling of a bound declared absent, as `maxOvershoot` and `weightJump`). Absent: {@link MAX_INFLUENCES}. */
|
|
130
|
+
maxInfluences?: number | null;
|
|
131
|
+
/** Absent: `"cut"`. */
|
|
132
|
+
floor?: FloorForm;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The limits a lattice or contour mesh runs at: {@link DEFAULT_LIMITS} itself when it declares no `influences`, so
|
|
137
|
+
* nothing about such a mesh changes; otherwise its declared cap (`null` is a count no vertex reaches) and floor form,
|
|
138
|
+
* with the floor's value {@link MIN_WEIGHT} either way — the declaration chooses the form of the cut, not where it falls.
|
|
139
|
+
*/
|
|
140
|
+
export function meshLimits(declared: MeshInfluences | undefined): InfluenceLimits {
|
|
141
|
+
if (declared === undefined) return DEFAULT_LIMITS;
|
|
142
|
+
const cap = declared.maxInfluences;
|
|
143
|
+
return { maxInfluences: cap === undefined ? MAX_INFLUENCES : cap === null ? Number.MAX_SAFE_INTEGER : cap, minWeight: MIN_WEIGHT, floor: declared.floor ?? 'cut' };
|
|
144
|
+
}
|
|
145
|
+
|
|
93
146
|
/**
|
|
94
147
|
* The influences on one vertex at `p` (rig pixels), before rounding, under
|
|
95
148
|
* `w = 1/(d + r)^exponent`. The rig stage passes every mesh's exponent
|
|
@@ -110,14 +163,19 @@ export function influences(p: Point, segments: readonly Segment[], r: number, li
|
|
|
110
163
|
|
|
111
164
|
/**
|
|
112
165
|
* The cap and the floor, on raw per-bone values in first-appearance order: sorted (stable), the top
|
|
113
|
-
* `maxInfluences` normalised,
|
|
114
|
-
*
|
|
166
|
+
* `maxInfluences` normalised, then the floor in its form ({@link FloorForm}) — under `"cut"` those under `minWeight`
|
|
167
|
+
* dropped, under `"continuous"` `minWeight` subtracted from each and those at or below zero dropped — and the rest
|
|
168
|
+
* normalised again. The distance rule above and bone heat (`src/heat.ts`, issue #161) both end here, so the two
|
|
169
|
+
* rules share one cap and one floor.
|
|
115
170
|
*/
|
|
116
171
|
export function capAndFloor(raw: ReadonlyArray<readonly [string, number]>, limits: InfluenceLimits): Influence[] {
|
|
117
172
|
const top = [...raw].sort((x, y) => y[1] - x[1]).slice(0, limits.maxInfluences);
|
|
118
173
|
let s = 0;
|
|
119
174
|
for (const [, v] of top) s += v;
|
|
120
|
-
const kept =
|
|
175
|
+
const kept =
|
|
176
|
+
limits.floor === 'continuous'
|
|
177
|
+
? top.map(([bone, v]) => ({ bone, weight: v / s - limits.minWeight })).filter((e) => e.weight > 0)
|
|
178
|
+
: top.map(([bone, v]) => ({ bone, weight: v / s })).filter((e) => e.weight >= limits.minWeight);
|
|
121
179
|
let s2 = 0;
|
|
122
180
|
for (const e of kept) s2 += e.weight;
|
|
123
181
|
return kept.map((e) => ({ bone: e.bone, weight: e.weight / s2 }));
|