@rig-c/parts 1.11.0 → 1.13.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 +15 -11
- package/cli.ts +13 -4
- package/docs/AUTHORING.md +188 -32
- package/docs/STABILITY.md +3 -3
- package/package.json +2 -2
- package/skills/rig-parts/SKILL.md +8 -5
- package/src/assemble.ts +85 -2
- package/src/build.ts +16 -2
- package/src/check.ts +65 -4
- package/src/config.ts +22 -1
- package/src/heat.ts +42 -6
- package/src/mesh.ts +61 -0
- package/src/meshdraft.ts +43 -9
- package/src/propose.ts +55 -2
- package/src/rig.ts +19 -5
- package/src/squeeze.ts +274 -0
package/src/propose.ts
CHANGED
|
@@ -136,6 +136,8 @@ import { configRoles, type RoleOf } from './structure.ts';
|
|
|
136
136
|
import { AUTHOR_NO_ROLE, isAuthorTag } from './tags.ts';
|
|
137
137
|
import { TURN_BONE, TURN_CARRIER_TAG, TURN_PLACES, TURN_RIDER_OFFSET, TURN_RIDER_TAGS, turnFractions, turnNearest, turnPivot, turnRiderDepth, type TurnSpec } from './turn.ts';
|
|
138
138
|
import { edgeLength } from './length.ts';
|
|
139
|
+
import { chainSqueezes, type SqueezeFinding } from './squeeze.ts';
|
|
140
|
+
import { TEXTURE_STRETCH_CEILING } from './check.ts';
|
|
139
141
|
|
|
140
142
|
// ---------------------------------------------------------------------------
|
|
141
143
|
// the output shape — config-shaped, key order as the reference writes it
|
|
@@ -2905,7 +2907,10 @@ export interface ChainNotAdvancingFinding {
|
|
|
2905
2907
|
toJoint: KeypointName;
|
|
2906
2908
|
}
|
|
2907
2909
|
|
|
2908
|
-
|
|
2910
|
+
/** Issue #314: two chains on one lattice whose own swing closes or opens an edge between them past check's TEXTURE_STRETCH bar (`src/squeeze.ts`). */
|
|
2911
|
+
export type SqueezeLintFinding = { kind: 'squeeze' } & SqueezeFinding;
|
|
2912
|
+
|
|
2913
|
+
export type LintFinding = OffArtFinding | OffLockFinding | HipAboveChestFinding | HipTooHighFinding | ChestNotBetweenFinding | HipNotPastChestFinding | HipNearerNeckFinding | ChainNotAdvancingFinding | SqueezeLintFinding;
|
|
2909
2914
|
|
|
2910
2915
|
/** Which rule set read the torso and the sleeve chains, under `--keypoints`. */
|
|
2911
2916
|
export interface LintBasis {
|
|
@@ -2923,6 +2928,8 @@ export interface LintResult {
|
|
|
2923
2928
|
lockUnread: string[];
|
|
2924
2929
|
/** Single bones the torso checks read (`hip`, `chest`) that the bones do not hold: those checks did not run. */
|
|
2925
2930
|
missingTorsoBones: string[];
|
|
2931
|
+
/** Issue #314: the pairs of keyed chains on one mesh the squeeze check could not build a lattice for, and why. Empty when it read every pair or had none. */
|
|
2932
|
+
squeezeUnread: string[];
|
|
2926
2933
|
/** Present only under `--keypoints`: the rule set that read the torso and the chains. */
|
|
2927
2934
|
basis?: LintBasis;
|
|
2928
2935
|
/** One record per bone of the spec, in its bone order (chain links as `<chain><i>`): which checks read it, or why none did (issue #86). */
|
|
@@ -3130,8 +3137,21 @@ export function lint(
|
|
|
3130
3137
|
}
|
|
3131
3138
|
}
|
|
3132
3139
|
}
|
|
3140
|
+
// Issue #314: every pair of keyed chains a lattice mesh names, swung on the rig stage's own lattice and weights, held
|
|
3141
|
+
// to check's TEXTURE_STRETCH bar (src/squeeze.ts). Only with a motion: without one nothing swings.
|
|
3142
|
+
const sq = chainSqueezes({ bones: spec.bones, meshes: spec.meshes, motion: spec.motion }, new Map(P.recs.filter((p) => P.images.has(p.name)).map((p) => [p.name, { x: p.x, y: p.y, image: P.images.get(p.name) as Raster }])));
|
|
3143
|
+
for (const { mesh, a, b } of sq.read) {
|
|
3144
|
+
for (const [c, other] of [[a, b], [b, a]]) {
|
|
3145
|
+
const e = spec.bones.find((x) => 'chain' in x && x.chain === c);
|
|
3146
|
+
if (e !== undefined && 'chain' in e) e.points.forEach((_, i) => readBy(`${c}${i}`, `squeeze with ${other} on mesh ${pyRepr(mesh)}`));
|
|
3147
|
+
}
|
|
3148
|
+
}
|
|
3149
|
+
for (const f of sq.findings) findings.push({ kind: 'squeeze', ...f });
|
|
3150
|
+
const squeezeUnread = sq.unread;
|
|
3133
3151
|
const coverage = lintCoverage(spec, byName, read, findings, unknownMeshes, basis);
|
|
3134
|
-
return basis === undefined
|
|
3152
|
+
return basis === undefined
|
|
3153
|
+
? { findings, unknownMeshes, lockUnread, missingTorsoBones, squeezeUnread, coverage }
|
|
3154
|
+
: { findings, unknownMeshes, lockUnread, missingTorsoBones, squeezeUnread, basis, coverage };
|
|
3135
3155
|
}
|
|
3136
3156
|
|
|
3137
3157
|
/** The bones a finding names: the off-art link; hip and chest for a relation between them; a chain's last link for its tip. */
|
|
@@ -3148,6 +3168,8 @@ function findingBones(f: LintFinding, bones: readonly BoneEntry[]): string[] {
|
|
|
3148
3168
|
return ['hip'];
|
|
3149
3169
|
case 'chest-not-between':
|
|
3150
3170
|
return ['chest'];
|
|
3171
|
+
case 'squeeze':
|
|
3172
|
+
return [...new Set(f.vertices.map((v) => v.weights[0]?.bone).filter((n): n is string => n !== undefined))];
|
|
3151
3173
|
case 'chain-not-advancing': {
|
|
3152
3174
|
if (!f.point.endsWith(' tip')) return [f.point];
|
|
3153
3175
|
const chain = f.point.slice(0, -' tip'.length);
|
|
@@ -3313,11 +3335,42 @@ export function lintLine(f: LintFinding): string {
|
|
|
3313
3335
|
return `LINT hip at ${pyIntList(f.hip)} is not past chest at ${pyIntList(f.chest)} on the way from the neck joint to the hip joints (hip at ${pyFixed(f.tHip, 2)}, chest at ${pyFixed(f.tChest, 2)} of the way): breathing and the skirt hang from the wrong end of the torso`;
|
|
3314
3336
|
case 'hip-nearer-neck':
|
|
3315
3337
|
return `LINT hip at ${pyIntList(f.hip)} is nearer the neck joint ${fmtAt(f.neck)} (${pyFixed(f.dNeck, 1)} px) than the midpoint of the hip joints ${fmtAt(f.hips)} (${pyFixed(f.dHips, 1)} px): a hip at the shoulders`;
|
|
3338
|
+
case 'squeeze':
|
|
3339
|
+
return squeezeLine(f);
|
|
3316
3340
|
case 'chain-not-advancing':
|
|
3317
3341
|
return `LINT ${f.point} at ${pyIntList(f.at)} does not advance from ${f.fromJoint} toward ${f.toJoint}: it falls ${pyFixed(f.t, 1)} px along that line and ${f.previous} ${pyFixed(f.tPrevious, 1)} px, and each must fall further than the one before`;
|
|
3318
3342
|
}
|
|
3319
3343
|
}
|
|
3320
3344
|
|
|
3345
|
+
/**
|
|
3346
|
+
* The squeeze LINT line (issue #314): the pair and the mesh, the worst edge with both swinging and the bone each of
|
|
3347
|
+
* its two vertices rides most, the bar it is held to (check's), each chain swung alone, the tips' distance and the
|
|
3348
|
+
* amplitudes, and the remedies whose figure passes the bar — one sign when the amplitudes are opposed, dropping a
|
|
3349
|
+
* chain when the other alone passes — or, when none does, that the amplitudes are too large for this lattice.
|
|
3350
|
+
*/
|
|
3351
|
+
function squeezeLine(f: SqueezeLintFinding): string {
|
|
3352
|
+
const e = f.both.at;
|
|
3353
|
+
const C = TEXTURE_STRETCH_CEILING;
|
|
3354
|
+
const rides = f.vertices.map((v) => (v.weights.length === 0 ? `vertex ${v.vertex} rides no bone` : `vertex ${v.vertex} rides ${v.weights[0].bone} ${v.weights[0].weight}`)).join(', ');
|
|
3355
|
+
const amps = (c: { amps: readonly number[] }): string => `[${c.amps.join(', ')}]`;
|
|
3356
|
+
const remedies: string[] = [];
|
|
3357
|
+
if (f.turned !== null && f.turned <= C) remedies.push(`give both the same sign (then ${pyFixed(f.turned, 3)})`);
|
|
3358
|
+
if (f.aloneA <= C && f.aloneB <= C) remedies.push('drop one chain');
|
|
3359
|
+
else if (f.aloneA <= C) remedies.push(`drop ${f.b.chain}, which alone is past the bar too`);
|
|
3360
|
+
else if (f.aloneB <= C) remedies.push(`drop ${f.a.chain}, which alone is past the bar too`);
|
|
3361
|
+
const said =
|
|
3362
|
+
f.turned !== null
|
|
3363
|
+
? `their amplitudes are opposed (${f.a.chain} ${amps(f.a)}, ${f.b.chain} ${amps(f.b)}), so their tips swing towards each other and apart`
|
|
3364
|
+
: `their amplitudes are ${f.a.chain} ${amps(f.a)} and ${f.b.chain} ${amps(f.b)}`;
|
|
3365
|
+
const remedy = remedies.length > 0 ? remedies.join(', or ') : 'neither one sign nor either chain alone passes the bar: lower the amplitudes';
|
|
3366
|
+
return (
|
|
3367
|
+
`LINT ${f.a.chain} and ${f.b.chain} on mesh ${pyRepr(f.mesh)} ${e.ratio >= 1 ? 'stretch' : 'squeeze'} it: swung together, every other bone held, ` +
|
|
3368
|
+
`edge ${e.edge.join('-')} of triangle ${e.triangle} ${e.ratio >= 1 ? 'opens' : 'closes'} to ${pyFixed(e.ratio, 3)} times its rest length at idle frame ${e.frame} (max(ratio, 1/ratio) ${pyFixed(f.both.severity, 3)}; ` +
|
|
3369
|
+
`<= ${C} is required, check's TEXTURE_STRETCH bar) — ${rides}; alone, ${f.a.chain} reads ${pyFixed(f.aloneA, 3)} and ${f.b.chain} ${pyFixed(f.aloneB, 3)}. ` +
|
|
3370
|
+
`Their tips are ${pyFixed(f.tipGap, 1)} px apart and ${said}: ${remedy}`
|
|
3371
|
+
);
|
|
3372
|
+
}
|
|
3373
|
+
|
|
3321
3374
|
export interface Comparison {
|
|
3322
3375
|
rows: Array<[string, number]>;
|
|
3323
3376
|
onlyConfig: string[];
|
package/src/rig.ts
CHANGED
|
@@ -153,13 +153,13 @@ import {
|
|
|
153
153
|
} from './automesh.ts';
|
|
154
154
|
import { type ContourChainRibs, type ContourReport, contourMesh, type ContourRegion, type ContourRib } from './contour.ts';
|
|
155
155
|
import { type LocalInfluence, localInfluences } from './localweights.ts';
|
|
156
|
-
import { artCoverage, ART_ALPHA, latticeMesh, ONE_LOOP_PASSES } from './mesh.ts';
|
|
156
|
+
import { artCoverage, ART_ALPHA, latticeMesh, latticeStray, ONE_LOOP_PASSES } from './mesh.ts';
|
|
157
157
|
import { BLINK, blinkHoldMisses, blinkSpan, CONTROL_SUFFIX, controlledBones, IDLE_FPS, idleMotion, type MotionSpec, moveKeysToControls } from './motion.ts';
|
|
158
158
|
import { PAINTING_RUN, type PartsFile, readFrom, tagOf } from './parts.ts';
|
|
159
159
|
import { EYE_GROUP_TAGS } from './propose.ts';
|
|
160
160
|
import { alphaAbove, crop, pad, type Raster } from './raster/index.ts';
|
|
161
161
|
import { pyRound } from './round.ts';
|
|
162
|
-
import { boneHeat, heatInfluences, heatRow, type HeatRow, latticeSilhouette, tracedSilhouette } from './heat.ts';
|
|
162
|
+
import { boneHeat, type HeatDust, heatInfluences, HEAT_LIMITS, heatRow, type HeatRow, latticeSilhouette, tracedSilhouette } from './heat.ts';
|
|
163
163
|
import { TURN_BONE, type TurnSpec, turnConstraintName, turnFractions, turnTwin } from './turn.ts';
|
|
164
164
|
import { DISTANCE_EXPONENT, type FloorForm, type Influence, influences, type InfluenceLimits, type MeshInfluences, meshLimits, type Segment } from './weights.ts';
|
|
165
165
|
|
|
@@ -650,6 +650,10 @@ export interface LatticeMeshReport {
|
|
|
650
650
|
exponent?: number;
|
|
651
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
652
|
influences?: MeshInfluences;
|
|
653
|
+
/** The `stray` the mesh declared (issue #303); absent when it declared none. */
|
|
654
|
+
stray?: number;
|
|
655
|
+
/** Only beside a declared `stray` (issue #303): the islands it left out of the lattice, their art px, and the part's art px. */
|
|
656
|
+
stray_cleared?: StrayCleared;
|
|
653
657
|
/** 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. */
|
|
654
658
|
heat?: HeatRow;
|
|
655
659
|
}
|
|
@@ -1745,7 +1749,11 @@ export function buildRig(
|
|
|
1745
1749
|
meshReport.push(row.report);
|
|
1746
1750
|
continue;
|
|
1747
1751
|
}
|
|
1748
|
-
const
|
|
1752
|
+
const allArt = alphaAbove(img, ART_ALPHA);
|
|
1753
|
+
// issue #303: under a declared `stray`, the islands it leaves out are cleared from the art the lattice and its heat
|
|
1754
|
+
// silhouette are built over; absent, `art` is `allArt` itself and nothing downstream moves.
|
|
1755
|
+
const stray = mesh.stray === undefined ? null : latticeStray(allArt, mesh.stray);
|
|
1756
|
+
const art = stray === null ? allArt : stray.art;
|
|
1749
1757
|
const lm = latticeMesh(p.name, art, mesh.grid, maxLoopPasses);
|
|
1750
1758
|
if ('code' in lm) {
|
|
1751
1759
|
problems.push(lm);
|
|
@@ -1755,7 +1763,12 @@ export function buildRig(
|
|
|
1755
1763
|
const ox = p.x - PAD;
|
|
1756
1764
|
const oy = p.y - PAD;
|
|
1757
1765
|
// issue #161: under rule "heat", the field over the lattice's art (alpha above 8) with its holes filled.
|
|
1758
|
-
|
|
1766
|
+
// issue #303: an island no bone reaches is worded against the lattice's own `stray` when it is dust.
|
|
1767
|
+
const dust = (): HeatDust => {
|
|
1768
|
+
const read = stray ?? latticeStray(allArt, 0);
|
|
1769
|
+
return { field: `config.meshes.${p.name}.stray`, declared: mesh.stray ?? null, artPixels: read.artPixels, largest: read.largest };
|
|
1770
|
+
};
|
|
1771
|
+
const heat = mesh.rule === 'heat' ? boneHeat(`config.meshes.${p.name}`, latticeSilhouette(art), segs, ox, oy, HEAT_LIMITS, dust) : null;
|
|
1759
1772
|
if (Array.isArray(heat)) {
|
|
1760
1773
|
problems.push(...heat);
|
|
1761
1774
|
continue;
|
|
@@ -1803,10 +1816,11 @@ export function buildRig(
|
|
|
1803
1816
|
bones: [...new Set(segs.map((s) => s.bone))].sort(),
|
|
1804
1817
|
max_influences: maxInfl,
|
|
1805
1818
|
mean_influences: pyRound(infl / lm.vertices.length, 2),
|
|
1806
|
-
art_coverage: pyRound(artCoverage(lm,
|
|
1819
|
+
art_coverage: pyRound(artCoverage(lm, allArt), 5),
|
|
1807
1820
|
grid: mesh.grid,
|
|
1808
1821
|
...(mesh.exponent === undefined ? {} : { exponent: mesh.exponent }),
|
|
1809
1822
|
...(mesh.influences === undefined ? {} : { influences: mesh.influences }),
|
|
1823
|
+
...(stray === null ? {} : { stray: mesh.stray as number, stray_cleared: { islands: stray.islands, pixels: stray.pixels, art_pixels: stray.artPixels } }),
|
|
1810
1824
|
...(heat === null ? {} : { heat: heatRow(heat) }),
|
|
1811
1825
|
});
|
|
1812
1826
|
}
|
package/src/squeeze.ts
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Two chains on one lattice that the idle swings into each other (issue #314).
|
|
3
|
+
*
|
|
4
|
+
* A pair of chains a mesh's `segments` both name is a squeeze when their own
|
|
5
|
+
* swing, every other bone held at the setup pose, closes (or opens) a lattice
|
|
6
|
+
* edge between the art each one carries past the bar `check`'s
|
|
7
|
+
* `TEXTURE_STRETCH` holds every edge to ({@link TEXTURE_STRETCH_CEILING}, on
|
|
8
|
+
* max(ratio, 1/ratio)). Read here from what `propose` and `--from-config` hold —
|
|
9
|
+
* the parts, the bones, the mesh's spec and the idle's chain tracks — so the
|
|
10
|
+
* squeeze is named before anything is built.
|
|
11
|
+
*
|
|
12
|
+
* What is built is the rig stage's own lattice and weights, by the rig stage's
|
|
13
|
+
* own functions: the part's image padded by {@link PAD}, its art (alpha above
|
|
14
|
+
* {@link ART_ALPHA}, less what a declared `stray` leaves out) cut into the
|
|
15
|
+
* lattice by `latticeMesh`, and each vertex weighted by `influences` under the
|
|
16
|
+
* mesh's `r`, `exponent` and `influences`, rounded as the lattice rounds — so
|
|
17
|
+
* the vertex and triangle numbers are the ones `rig.json` and `check` use. Only
|
|
18
|
+
* the lattice mode under the distance rule is read; a contour or automatic mesh,
|
|
19
|
+
* or `rule: "heat"`, is said not read, by name.
|
|
20
|
+
*
|
|
21
|
+
* Each chain's links are posed as the idle's sine states them —
|
|
22
|
+
* `amps[i] * sin(2 pi (t / period - (phase + lag * i)))` degrees on link `i`,
|
|
23
|
+
* `src/motion.ts` — turned down the chain (a link carries every link below it),
|
|
24
|
+
* at the frames `check` renders: `t = f / IDLE_FPS` for every whole frame up to
|
|
25
|
+
* the idle's duration. Every other bone stays where the setup pose has it, so a
|
|
26
|
+
* figure is the pair's own: the head's roll or the breath carry both chains
|
|
27
|
+
* alike and are not in it. A vertex moves as linear blend skinning moves it,
|
|
28
|
+
* each weight times where that bone's motion takes the vertex. The edge ratios
|
|
29
|
+
* are `check`'s own reader on these poses ({@link stretchFigures}).
|
|
30
|
+
*
|
|
31
|
+
* The pair is named when both swinging together pass the bar, with each
|
|
32
|
+
* chain swung alone beside it — so the remedy "drop one chain" carries the
|
|
33
|
+
* figure the other is left with, and a chain past the bar on its own reads as
|
|
34
|
+
* that. With the two chains' amplitudes opposed link for link (every `amps[i]`
|
|
35
|
+
* of one the other sign of the other's), the same reading is taken with the
|
|
36
|
+
* second chain's signs turned, so the remedy "give both the same sign" carries
|
|
37
|
+
* its own figure too. A remedy whose figure is past the bar is not offered.
|
|
38
|
+
*/
|
|
39
|
+
import { TEXTURE_STRETCH_CEILING } from './check.ts';
|
|
40
|
+
import type { BoneEntry, MeshSpec, Motion, Point } from './config.ts';
|
|
41
|
+
import { stretchFigures, stretchSeverity, type StretchAt } from './instruments.ts';
|
|
42
|
+
import { edgeLength } from './length.ts';
|
|
43
|
+
import { ART_ALPHA, latticeMesh, latticeStray } from './mesh.ts';
|
|
44
|
+
import { IDLE_FPS, TIME_SLACK } from './motion.ts';
|
|
45
|
+
import { pad } from './raster/composite.ts';
|
|
46
|
+
import { alphaAbove, type Raster } from './raster/types.ts';
|
|
47
|
+
import { latticeShares, PAD, roundShares } from './rig.ts';
|
|
48
|
+
import { DISTANCE_EXPONENT, influences, meshLimits, type Segment } from './weights.ts';
|
|
49
|
+
|
|
50
|
+
/** One chain of the pair as the finding prints it: its name, its `amps` as written, and its tip. */
|
|
51
|
+
export interface SqueezeChain {
|
|
52
|
+
chain: string;
|
|
53
|
+
amps: number[];
|
|
54
|
+
tip: Point;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The worst edge of one reading: where, its ratio, and max(ratio, 1/ratio). */
|
|
58
|
+
export interface SqueezeEdge {
|
|
59
|
+
at: StretchAt;
|
|
60
|
+
severity: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A pair named: both chains, the mesh, the worst edge with both swinging, and each alone (and turned, when opposed). */
|
|
64
|
+
export interface SqueezeFinding {
|
|
65
|
+
mesh: string;
|
|
66
|
+
a: SqueezeChain;
|
|
67
|
+
b: SqueezeChain;
|
|
68
|
+
/** The two tips' distance at the setup pose, rig px. */
|
|
69
|
+
tipGap: number;
|
|
70
|
+
both: SqueezeEdge;
|
|
71
|
+
/** The two vertices of `both`'s edge, each with its two largest weights (bone, weight as rig.json writes it), and how many it has. */
|
|
72
|
+
vertices: Array<{ vertex: number; weights: Array<{ bone: string; weight: number }>; of: number }>;
|
|
73
|
+
/** The worst edge with the first chain swung alone, and with the second. */
|
|
74
|
+
aloneA: number;
|
|
75
|
+
aloneB: number;
|
|
76
|
+
/** Both opposed link for link: the worst edge with the second chain's signs turned. Null when they are not opposed. */
|
|
77
|
+
turned: number | null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface SqueezeResult {
|
|
81
|
+
findings: SqueezeFinding[];
|
|
82
|
+
/** Pairs on one mesh this did not read, and why. */
|
|
83
|
+
unread: string[];
|
|
84
|
+
/** Every pair read: the mesh and the two chains. */
|
|
85
|
+
read: Array<{ mesh: string; a: string; b: string }>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
type ChainTrackLike = { chain: string; amps: number[]; period: number; phase: number; lag: number };
|
|
89
|
+
|
|
90
|
+
/** Each frame's time, as `check` renders them: every whole frame at {@link IDLE_FPS} up to the idle's duration. */
|
|
91
|
+
function frameTimes(duration: number): number[] {
|
|
92
|
+
const out: number[] = [];
|
|
93
|
+
for (let f = 0; f / IDLE_FPS <= duration + TIME_SLACK; f++) out.push(f / IDLE_FPS);
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* A chain's posed links at time `t`: per link, its setup origin, its posed origin, and the turn it carries (degrees,
|
|
99
|
+
* the sum of its own and every link above it). Positive is Spine's counter-clockwise (y up), which on the crop's y-down
|
|
100
|
+
* page turns a vector (dx, dy) to (dx cos + dy sin, -dx sin + dy cos).
|
|
101
|
+
*/
|
|
102
|
+
function posedLinks(pts: readonly Point[], track: ChainTrackLike, sign: 1 | -1, t: number): Array<{ rest: Point; at: [number, number]; turn: number }> {
|
|
103
|
+
const out: Array<{ rest: Point; at: [number, number]; turn: number }> = [];
|
|
104
|
+
let at: [number, number] = [pts[0][0], pts[0][1]];
|
|
105
|
+
let turn = 0;
|
|
106
|
+
for (let i = 0; i + 1 < pts.length; i++) {
|
|
107
|
+
const amp = i < track.amps.length ? track.amps[i] : 0;
|
|
108
|
+
turn += sign * amp * Math.sin(2 * Math.PI * (t / track.period - (track.phase + track.lag * i)));
|
|
109
|
+
out.push({ rest: pts[i], at, turn });
|
|
110
|
+
const rad = (turn * Math.PI) / 180;
|
|
111
|
+
const dx = pts[i + 1][0] - pts[i][0];
|
|
112
|
+
const dy = pts[i + 1][1] - pts[i][1];
|
|
113
|
+
at = [at[0] + dx * Math.cos(rad) + dy * Math.sin(rad), at[1] - dx * Math.sin(rad) + dy * Math.cos(rad)];
|
|
114
|
+
}
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Where a bone's motion takes the point `v`: a link turns it about its origin and carries it to the link's posed origin. */
|
|
119
|
+
function moved(link: { rest: Point; at: [number, number]; turn: number }, v: readonly [number, number]): [number, number] {
|
|
120
|
+
const rad = (link.turn * Math.PI) / 180;
|
|
121
|
+
const dx = v[0] - link.rest[0];
|
|
122
|
+
const dy = v[1] - link.rest[1];
|
|
123
|
+
return [link.at[0] + dx * Math.cos(rad) + dy * Math.sin(rad), link.at[1] - dx * Math.sin(rad) + dy * Math.cos(rad)];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Every pair of chains a lattice mesh's `segments` name, both keyed by a chain track, read as the module doc says.
|
|
128
|
+
* `images` holds each part's PNG by name, its `box` its parts.json box (rig px).
|
|
129
|
+
*/
|
|
130
|
+
export function chainSqueezes(
|
|
131
|
+
spec: { bones: readonly BoneEntry[]; meshes: Readonly<Record<string, MeshSpec>>; motion?: Motion },
|
|
132
|
+
parts: ReadonlyMap<string, { x: number; y: number; image: Raster }>,
|
|
133
|
+
): SqueezeResult {
|
|
134
|
+
const findings: SqueezeFinding[] = [];
|
|
135
|
+
const unread: string[] = [];
|
|
136
|
+
const read: SqueezeResult['read'] = [];
|
|
137
|
+
const motion = spec.motion;
|
|
138
|
+
if (motion === undefined) return { findings, unread, read };
|
|
139
|
+
const tracks = new Map<string, ChainTrackLike>();
|
|
140
|
+
for (const t of motion.tracks) if ('chain' in t) tracks.set(t.chain, t);
|
|
141
|
+
const chains = new Map<string, { points: Point[]; tip: Point; parent: string }>();
|
|
142
|
+
for (const e of spec.bones) if ('chain' in e) chains.set(e.chain, { points: e.points, tip: e.tip, parent: e.parent });
|
|
143
|
+
// Every bone's parent, chain links as <chain><i>, so a bone under a link is carried by it.
|
|
144
|
+
const parent = new Map<string, string>();
|
|
145
|
+
for (const e of spec.bones) {
|
|
146
|
+
if ('chain' in e) e.points.forEach((_, i) => parent.set(`${e.chain}${i}`, i === 0 ? e.parent : `${e.chain}${i - 1}`));
|
|
147
|
+
else parent.set(e.name, e.parent);
|
|
148
|
+
}
|
|
149
|
+
const linkOf = new Map<string, [string, number]>();
|
|
150
|
+
for (const [c, e] of chains) e.points.forEach((_, i) => linkOf.set(`${c}${i}`, [c, i]));
|
|
151
|
+
// The nearest link at or above a bone, among the chains in `moving`.
|
|
152
|
+
const carrier = (bone: string, moving: ReadonlySet<string>): [string, number] | null => {
|
|
153
|
+
const seen = new Set<string>();
|
|
154
|
+
for (let b: string | undefined = bone; b !== undefined && !seen.has(b); b = parent.get(b)) {
|
|
155
|
+
const l = linkOf.get(b);
|
|
156
|
+
if (l !== undefined && moving.has(l[0])) return l;
|
|
157
|
+
seen.add(b);
|
|
158
|
+
}
|
|
159
|
+
return null;
|
|
160
|
+
};
|
|
161
|
+
const times = frameTimes(motion.duration);
|
|
162
|
+
|
|
163
|
+
for (const [mname, m] of Object.entries(spec.meshes)) {
|
|
164
|
+
const keyed = m.segments.filter((s): s is string => typeof s === 'string' && chains.has(s) && tracks.has(s));
|
|
165
|
+
if (keyed.length < 2) continue;
|
|
166
|
+
const pairs: Array<[string, string]> = [];
|
|
167
|
+
for (let i = 0; i < keyed.length; i++) for (let j = i + 1; j < keyed.length; j++) pairs.push([keyed[i], keyed[j]]);
|
|
168
|
+
const names = pairs.map(([a, b]) => `${a} and ${b}`).join(', ');
|
|
169
|
+
if ('contour' in m || 'auto' in m) {
|
|
170
|
+
unread.push(`mesh ${JSON.stringify(mname)} (${names}) is a ${'contour' in m ? 'contour' : 'automatic'} mesh; only a lattice is built here`);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (m.rule === 'heat') {
|
|
174
|
+
unread.push(`mesh ${JSON.stringify(mname)} (${names}) is weighted by rule "heat"; only the distance rule is read here`);
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
const part = parts.get(mname);
|
|
178
|
+
if (part === undefined) continue;
|
|
179
|
+
// The rig stage's segments: a chain is its links, each from its origin to the next link or the tip.
|
|
180
|
+
const segs: Segment[] = [];
|
|
181
|
+
let unresolved: string | null = null;
|
|
182
|
+
for (const sp of m.segments) {
|
|
183
|
+
if (typeof sp !== 'string') {
|
|
184
|
+
segs.push({ bone: sp[0], a: [sp[1][0], sp[1][1]], b: [sp[2][0], sp[2][1]] });
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
const c = chains.get(sp);
|
|
188
|
+
if (c !== undefined) {
|
|
189
|
+
const line = [...c.points, c.tip];
|
|
190
|
+
c.points.forEach((q, i) => segs.push({ bone: `${sp}${i}`, a: [q[0], q[1]], b: [line[i + 1][0], line[i + 1][1]] }));
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
const single = spec.bones.find((e) => 'name' in e && e.name === sp);
|
|
194
|
+
if (single === undefined || !('name' in single) || single.tip === undefined) {
|
|
195
|
+
unresolved = `its segment "${sp}" is not a chain or a single bone with a tip`;
|
|
196
|
+
break;
|
|
197
|
+
}
|
|
198
|
+
segs.push({ bone: sp, a: [single.at[0], single.at[1]], b: [single.tip[0], single.tip[1]] });
|
|
199
|
+
}
|
|
200
|
+
if (unresolved !== null) {
|
|
201
|
+
unread.push(`mesh ${JSON.stringify(mname)} (${names}): ${unresolved}, so it has no weights to read`);
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
const img = pad(part.image, PAD, PAD, PAD, PAD, [0, 0, 0, 0]);
|
|
205
|
+
const allArt = alphaAbove(img, ART_ALPHA);
|
|
206
|
+
const art = m.stray === undefined ? allArt : latticeStray(allArt, m.stray).art;
|
|
207
|
+
const lm = latticeMesh(mname, art, m.grid);
|
|
208
|
+
if ('code' in lm) {
|
|
209
|
+
unread.push(`mesh ${JSON.stringify(mname)} (${names}): its lattice is refused (${lm.code}), so there is no triangle to read`);
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
const ox = part.x - PAD;
|
|
213
|
+
const oy = part.y - PAD;
|
|
214
|
+
const limits = meshLimits(m.influences);
|
|
215
|
+
const rest: Array<[number, number]> = lm.vertices.map(([x, y]) => [x + ox, y + oy]);
|
|
216
|
+
const weights = rest.map((p) => {
|
|
217
|
+
const shares = influences(p, segs, m.r, limits, m.exponent ?? DISTANCE_EXPONENT);
|
|
218
|
+
return shares.length === 0 ? [] : limits.floor === 'continuous' ? roundShares(shares) : latticeShares(shares);
|
|
219
|
+
});
|
|
220
|
+
const restFlat = rest.flatMap(([x, y]) => [x, y]);
|
|
221
|
+
const worst = (moving: ReadonlyMap<string, 1 | -1>): SqueezeEdge | null => {
|
|
222
|
+
const set = new Set(moving.keys());
|
|
223
|
+
const frames = times.map((t, index) => {
|
|
224
|
+
const posed = new Map<string, ReturnType<typeof posedLinks>>();
|
|
225
|
+
for (const [c, sign] of moving) {
|
|
226
|
+
const e = chains.get(c) as { points: Point[]; tip: Point };
|
|
227
|
+
posed.set(c, posedLinks([...e.points, e.tip], tracks.get(c) as ChainTrackLike, sign, t));
|
|
228
|
+
}
|
|
229
|
+
const vertices: number[] = [];
|
|
230
|
+
rest.forEach((v, vi) => {
|
|
231
|
+
let x = 0;
|
|
232
|
+
let y = 0;
|
|
233
|
+
for (const { bone, weight } of weights[vi]) {
|
|
234
|
+
const l = carrier(bone, set);
|
|
235
|
+
const to = l === null ? v : moved((posed.get(l[0]) as ReturnType<typeof posedLinks>)[l[1]], v);
|
|
236
|
+
x += weight * to[0];
|
|
237
|
+
y += weight * to[1];
|
|
238
|
+
}
|
|
239
|
+
vertices.push(x, y);
|
|
240
|
+
});
|
|
241
|
+
return { index, attachments: [{ slot: mname, attachment: mname, vertices }] };
|
|
242
|
+
});
|
|
243
|
+
const [fig] = stretchFigures([{ slot: mname, attachment: mname, vertices: restFlat, triangles: lm.triangles }], frames);
|
|
244
|
+
if (fig.max === null || fig.min === null) return null;
|
|
245
|
+
const sMax = stretchSeverity(fig.max.ratio);
|
|
246
|
+
const sMin = stretchSeverity(fig.min.ratio);
|
|
247
|
+
return sMin > sMax ? { at: fig.min, severity: sMin } : { at: fig.max, severity: sMax };
|
|
248
|
+
};
|
|
249
|
+
for (const [a, b] of pairs) {
|
|
250
|
+
read.push({ mesh: mname, a, b });
|
|
251
|
+
const both = worst(new Map([[a, 1], [b, 1]]));
|
|
252
|
+
if (both === null || both.severity <= TEXTURE_STRETCH_CEILING) continue;
|
|
253
|
+
const aloneA = (worst(new Map([[a, 1]])) as SqueezeEdge).severity;
|
|
254
|
+
const aloneB = (worst(new Map([[b, 1]])) as SqueezeEdge).severity;
|
|
255
|
+
const ta = tracks.get(a) as ChainTrackLike;
|
|
256
|
+
const tb = tracks.get(b) as ChainTrackLike;
|
|
257
|
+
const opposed = ta.amps.length === tb.amps.length && ta.amps.every((v, i) => v * tb.amps[i] < 0);
|
|
258
|
+
const ca = chains.get(a) as { tip: Point };
|
|
259
|
+
const cb = chains.get(b) as { tip: Point };
|
|
260
|
+
findings.push({
|
|
261
|
+
mesh: mname,
|
|
262
|
+
a: { chain: a, amps: [...ta.amps], tip: ca.tip },
|
|
263
|
+
b: { chain: b, amps: [...tb.amps], tip: cb.tip },
|
|
264
|
+
tipGap: edgeLength(ca.tip[0] - cb.tip[0], ca.tip[1] - cb.tip[1]),
|
|
265
|
+
both,
|
|
266
|
+
vertices: both.at.edge.map((vertex) => ({ vertex, weights: [...weights[vertex]].sort((p, q) => q.weight - p.weight).slice(0, 2), of: weights[vertex].length })),
|
|
267
|
+
aloneA,
|
|
268
|
+
aloneB,
|
|
269
|
+
turned: opposed ? (worst(new Map<string, 1 | -1>([[a, 1], [b, -1]])) as SqueezeEdge).severity : null,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return { findings, unread, read };
|
|
274
|
+
}
|