@rig-c/parts 0.0.0-stage → 1.5.4
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/LICENSE +21 -0
- package/NOTICE.md +134 -0
- package/README.md +433 -2
- package/bin/rig-parts.cjs +43 -0
- package/cli.ts +1240 -0
- package/docs/AUTHORING.md +2387 -0
- package/docs/MIGRATION.md +150 -0
- package/docs/STABILITY.md +262 -0
- package/package.json +70 -4
- package/skills/rig-parts/SKILL.md +167 -0
- package/src/apng.ts +381 -0
- package/src/assemble.ts +2259 -0
- package/src/autoamplitude.ts +194 -0
- package/src/autoenvelope.ts +404 -0
- package/src/automesh.ts +585 -0
- package/src/automotion.ts +447 -0
- package/src/autoreplay.ts +516 -0
- package/src/build.ts +1240 -0
- package/src/check.ts +2942 -0
- package/src/comfy/client.ts +245 -0
- package/src/comfy/index.ts +19 -0
- package/src/comfy/painting.ts +136 -0
- package/src/comfy/seethrough.ts +171 -0
- package/src/config.ts +2283 -0
- package/src/contour.ts +1901 -0
- package/src/coords.ts +26 -0
- package/src/diagnostics.ts +198 -0
- package/src/errors.ts +39 -0
- package/src/gif.ts +213 -0
- package/src/graphs.ts +302 -0
- package/src/headbox.ts +173 -0
- package/src/heat.ts +313 -0
- package/src/inputs.ts +100 -0
- package/src/instruments.ts +1145 -0
- package/src/keypoints.ts +296 -0
- package/src/layers.ts +598 -0
- package/src/localweights.ts +217 -0
- package/src/mesh.ts +362 -0
- package/src/motion.ts +311 -0
- package/src/palette.ts +208 -0
- package/src/parts.ts +410 -0
- package/src/propose.ts +2540 -0
- package/src/pyfmt.ts +135 -0
- package/src/raster/blur.ts +80 -0
- package/src/raster/components.ts +165 -0
- package/src/raster/composite.ts +81 -0
- package/src/raster/index.ts +9 -0
- package/src/raster/morph.ts +116 -0
- package/src/raster/png.ts +82 -0
- package/src/raster/poly.ts +166 -0
- package/src/raster/resize.ts +301 -0
- package/src/raster/types.ts +89 -0
- package/src/raster/warp.ts +121 -0
- package/src/requirements.ts +1240 -0
- package/src/rig.ts +1440 -0
- package/src/round.ts +62 -0
- package/src/scene.ts +1414 -0
- package/src/sheet.ts +128 -0
- package/src/skeleton.ts +400 -0
- package/src/structure.ts +858 -0
- package/src/tags.ts +121 -0
- package/src/weights.ts +124 -0
- package/workflows/seethrough.json +68 -0
package/src/rig.ts
ADDED
|
@@ -0,0 +1,1440 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rig stage: a character config and its assembled parts in, a rig spec
|
|
3
|
+
* and a motion spec in rig-c's format out.
|
|
4
|
+
*
|
|
5
|
+
* config (bones, meshes, regions, motion) + parts.json + parts/*.png
|
|
6
|
+
* -> images/*.png, rig.json, motion.json, mesh_report.json
|
|
7
|
+
*
|
|
8
|
+
* Everything written is something rig-c takes verbatim; no rigc
|
|
9
|
+
* generator is used. What is authored:
|
|
10
|
+
*
|
|
11
|
+
* - **Bones**, unscaled, at the config's landmarks. A chain
|
|
12
|
+
* `{chain: "c", points: [p0 .. pn]}` becomes the bones `c0 .. cn`, each the
|
|
13
|
+
* parent of the next. `root` sits at the bottom centre of the rig canvas.
|
|
14
|
+
* Each chain link is **turned along its chain** (issue #73): `rotation` is
|
|
15
|
+
* the direction from its origin to the next link's — the last link's to
|
|
16
|
+
* the chain's `tip` — in Spine degrees (counter-clockwise, y up, local to
|
|
17
|
+
* the parent; measured through rigc with a one-bone spec), and `length` is
|
|
18
|
+
* that distance, so a physics constraint a consumer adds finds a lever
|
|
19
|
+
* (spine-core's solver reads `bone.data.length`). Under `idleKeys: 'ctl'`
|
|
20
|
+
* the link's control carries the turn and the length and the link sits at
|
|
21
|
+
* local rotation 0 under it; under `direct` the link carries them. Every
|
|
22
|
+
* other bone keeps world rotation 0 — one whose parent is a link is turned
|
|
23
|
+
* back. Nothing moves: a child's `x, y`, a weight's bind `x, y` and a
|
|
24
|
+
* region's `x, y` are the landmark carried into the turned frame as written
|
|
25
|
+
* (rigc's inverse, `src/coords.ts`), a region on a turned bone is turned
|
|
26
|
+
* back upright by `rotation`, and {@link flattenRig} gives the unturned
|
|
27
|
+
* numbers the reference wrote. A key the turn would re-aim — a translate
|
|
28
|
+
* key under a turned parent, a scale or shear key on a turned bone — is
|
|
29
|
+
* refused (`RIG_KEY_FRAME_UNTURNED`); rotate keys mean what they meant.
|
|
30
|
+
* A single bone with a `tip` is not turned (its tip is still a segment
|
|
31
|
+
* end for the weights); giving it a lever would be the same change on
|
|
32
|
+
* that bone and its children, and is not made here.
|
|
33
|
+
* - **Meshes** over the parts named in `meshes`: a square lattice (`src/mesh.ts`)
|
|
34
|
+
* weighted by distance to the mesh's candidate segments (`src/weights.ts`),
|
|
35
|
+
* written in rigc's by-name `weights` form. The slot's bone is the first
|
|
36
|
+
* segment's bone. A mesh whose entry says `contour` (issue #84) is
|
|
37
|
+
* `src/contour.ts`'s mesh over the padded image instead, each vertex
|
|
38
|
+
* weighted by `src/localweights.ts` (its regions' bones by their declared
|
|
39
|
+
* falloff, the rest by the same segments), every refusal collected with the
|
|
40
|
+
* others; a lattice mesh writes what it always wrote.
|
|
41
|
+
* - **Regions** for the parts named in `regions`: the image centred where the
|
|
42
|
+
* part sits, offset from its bone. A part named in `motion.blink.still` is
|
|
43
|
+
* two regions cut at its row: the rows from `row` down on the part's own
|
|
44
|
+
* slot and bone (they blink), the rows above on `<part>_still` and the
|
|
45
|
+
* entry's `bone`, drawn right after it (they do not). The cut row must carry
|
|
46
|
+
* no art: cut through art, each piece is resampled against its own
|
|
47
|
+
* transparent edge and the render changes at rest — measured on the demo
|
|
48
|
+
* example, 36 px of the setup pose (max 7 levels) — while a clear-row cut
|
|
49
|
+
* changes no pixel outside the blink (issue #26, `RG16`). A `painting:`
|
|
50
|
+
* patch (`assemble.patches`) is always a region; its slot sits where
|
|
51
|
+
* parts.json puts it, which is where its `draw` put it.
|
|
52
|
+
* - **The idle** and, under `idleKeys: 'ctl'` (the default), its control
|
|
53
|
+
* bones (`src/motion.ts`). Under `idleKeys: 'direct'` the keys stay on the
|
|
54
|
+
* bones the meshes are weighted to, no `<bone>_ctl` is added, and the rig
|
|
55
|
+
* spec declares `invariants.idleDrivesMeshes` with
|
|
56
|
+
* {@link IDLE_DRIVES_MESHES_WHY} — rig-c 1.3.0's statement that this
|
|
57
|
+
* idle deforms meshes on purpose, which `A15_IDLE_NO_MESH_BONE_KEYS` then
|
|
58
|
+
* reports as a SKIP with its cost instead of refusing each bone. The
|
|
59
|
+
* declaration is written only when the idle keys at least one mesh-driving
|
|
60
|
+
* bone: rigc refuses a declaration that switches nothing off.
|
|
61
|
+
* - **Constraints**, only when the config has `constraints` (issue #92):
|
|
62
|
+
* handed to rigc as `rig.json`'s `constraints`, in the order written, each
|
|
63
|
+
* as written but its records and annotations. Nothing here solves or
|
|
64
|
+
* validates them; rigc's gate does, and a field it refuses is its refusal,
|
|
65
|
+
* in its words (`RIG_RIGC_GREEN`) — a two-bone ik whose child the idle
|
|
66
|
+
* keys through a control included (rig-c 2.15.0, issue #103). Every
|
|
67
|
+
* bone a constraint follows is declared detached from the bones it drives
|
|
68
|
+
* ({@link detachedRules}, `invariants.detached`, rigc's `A25`). Without the
|
|
69
|
+
* field, no byte of any output moves.
|
|
70
|
+
*
|
|
71
|
+
* Every part image is padded by {@link PAD} transparent pixels on each side
|
|
72
|
+
* before it is meshed or placed, and the padded image is what `images/`
|
|
73
|
+
* holds: a lattice vertex on the part's own edge would otherwise sit on the
|
|
74
|
+
* texture's edge, where filtering samples outside the art.
|
|
75
|
+
*
|
|
76
|
+
* Coordinates in the config and in parts.json are rig pixels, y down. The one
|
|
77
|
+
* y flip goes through `src/coords.ts` (rig-c's `cropToSpineY`); x is
|
|
78
|
+
* measured from the canvas centre, which is where `root` stands.
|
|
79
|
+
*
|
|
80
|
+
* Pure: the caller reads the files and writes the outputs; nothing here
|
|
81
|
+
* touches the disk, and the same inputs give the same bytes (key order is the
|
|
82
|
+
* order the objects are built in, and every number is rounded by `pyRound`).
|
|
83
|
+
*/
|
|
84
|
+
import { type AutoSpec, type BoneEntry, type WeightRule, type CharacterConfig, type ConfigConstraint, CONSTRAINT_FOLLOWS, constraintForRig, type ContourSpec, type Point, type RibsSpec, ROOT_BONE, weightsABone } from './config.ts';
|
|
85
|
+
import { type BoneTransform, computeExactFrameTransforms, cropToSpineY, normaliseDegrees, toBoneLocal, toWorld } from './coords.ts';
|
|
86
|
+
import { type Problem, refuseIfAny } from './errors.ts';
|
|
87
|
+
import type { MeshCounts, MeshQualityReport, MeshReductionInput, MotionAmplitude, ReducedMesh, Termination } from 'rig-c/mesh';
|
|
88
|
+
import { type AmplitudeRow, amplitudeRow, partAmplitude, withAmplitude } from './autoamplitude.ts';
|
|
89
|
+
import { type ResidualRow, residualRow, slotEnvelope, withSkinning } from './autoenvelope.ts';
|
|
90
|
+
import { type AutoMotionCase, type AutoSearch, DEFORM_MAY_FOLD_WHY, type MotionDeformation, type StimulusPose, stimulusAnimationNames } from './automotion.ts';
|
|
91
|
+
import { type MultiIntervalRow, refinementSteps, replayVerdict, type ReplayRow } from './autoreplay.ts';
|
|
92
|
+
import {
|
|
93
|
+
autoReductionInput,
|
|
94
|
+
autoSource,
|
|
95
|
+
autoVerdict,
|
|
96
|
+
AUTO_THRESHOLD,
|
|
97
|
+
DEFORMATION_UNMEASURED,
|
|
98
|
+
legacyArtCoverage,
|
|
99
|
+
qualityDocument,
|
|
100
|
+
type Reducer,
|
|
101
|
+
type Residual,
|
|
102
|
+
residuals,
|
|
103
|
+
runReduction,
|
|
104
|
+
sourceWeights,
|
|
105
|
+
type StageBOptIns,
|
|
106
|
+
type StageBResult,
|
|
107
|
+
stageBResult,
|
|
108
|
+
worstRegion,
|
|
109
|
+
worstResidual,
|
|
110
|
+
} from './automesh.ts';
|
|
111
|
+
import { type ContourChainRibs, type ContourReport, contourMesh, type ContourRegion, type ContourRib } from './contour.ts';
|
|
112
|
+
import { type LocalInfluence, localInfluences } from './localweights.ts';
|
|
113
|
+
import { artCoverage, ART_ALPHA, latticeMesh, ONE_LOOP_PASSES } from './mesh.ts';
|
|
114
|
+
import { BLINK, blinkHoldMisses, blinkSpan, CONTROL_SUFFIX, controlledBones, IDLE_FPS, idleMotion, type MotionSpec, moveKeysToControls } from './motion.ts';
|
|
115
|
+
import { PAINTING_RUN, type PartsFile, readFrom } from './parts.ts';
|
|
116
|
+
import { alphaAbove, crop, pad, type Raster } from './raster/index.ts';
|
|
117
|
+
import { pyRound } from './round.ts';
|
|
118
|
+
import { boneHeat, heatInfluences, heatRow, type HeatRow, latticeSilhouette, tracedSilhouette } from './heat.ts';
|
|
119
|
+
import { DEFAULT_LIMITS, DISTANCE_EXPONENT, type Influence, influences, type Segment } from './weights.ts';
|
|
120
|
+
|
|
121
|
+
/** Transparent pixels added round every part image — the reference's `PAD`. */
|
|
122
|
+
export const PAD = 4;
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The places a bone's rotation and every local offset (a bone's `x, y`, a
|
|
126
|
+
* weight's bind `x, y`, a region's `x, y` and `rotation`) are written to.
|
|
127
|
+
* The reference wrote offsets to 3, and could: its frames were never turned,
|
|
128
|
+
* so an offset was a difference of two landmarks. Under a turned frame an
|
|
129
|
+
* offset is that difference carried through a rotation, and 3 places would
|
|
130
|
+
* move a world position by up to 0.0007 px — enough that turning it back
|
|
131
|
+
* ({@link flattenRig}, to the reference's 3) would land a value on the other
|
|
132
|
+
* side of a rounding step. Each offset is placed through its parent's frame
|
|
133
|
+
* as written, so the errors do not add down a chain: every world position is
|
|
134
|
+
* within 7.1e-7 of its landmark, and the round trip is exact unless a
|
|
135
|
+
* landmark difference lies within that of a 3-place rounding step, which a
|
|
136
|
+
* difference of whole and half pixels never does. An unturned frame writes
|
|
137
|
+
* the same numbers it always wrote.
|
|
138
|
+
*/
|
|
139
|
+
export const OFFSET_PLACES = 6;
|
|
140
|
+
|
|
141
|
+
/** The places the reference wrote an offset to, which {@link flattenRig} gives back. */
|
|
142
|
+
export const FLAT_PLACES = 3;
|
|
143
|
+
|
|
144
|
+
/** {@link pyRound} to `n` places, with a negative zero written as 0. */
|
|
145
|
+
function placed(v: number, n: number): number {
|
|
146
|
+
const r = pyRound(v, n);
|
|
147
|
+
return r === 0 ? 0 : r;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function places(v: number): number {
|
|
151
|
+
return placed(v, OFFSET_PLACES);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The weights a contour vertex is written with: a vertex no region reaches is
|
|
156
|
+
* rounded as the lattice rounds (each to 5 places, the last `1 − sum(others)`),
|
|
157
|
+
* one a region reaches by {@link roundShares}.
|
|
158
|
+
*/
|
|
159
|
+
export function writtenShares(li: LocalInfluence): Influence[] {
|
|
160
|
+
if (li.region >= 0) return roundShares(li.influences);
|
|
161
|
+
const shares = li.influences.map(({ bone, weight }) => ({ bone, weight: pyRound(weight, 5) }));
|
|
162
|
+
let others = 0;
|
|
163
|
+
for (let k = 0; k < shares.length - 1; k++) others += shares[k].weight;
|
|
164
|
+
shares[shares.length - 1].weight = pyRound(1 - others, 5);
|
|
165
|
+
return shares;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* A region-weighted vertex's weights at 5 places (issue #84): each rounded
|
|
170
|
+
* with `pyRound`; an entry that rounds to 0 is dropped (a bone at weight 0
|
|
171
|
+
* pulls nothing); and the rounding remainder goes to the heaviest entry, the
|
|
172
|
+
* first of equals, written as `1 − sum(others)`, so the vertex sums to exactly
|
|
173
|
+
* 1. The lattice writes the remainder into its LAST entry, which is safe only
|
|
174
|
+
* because `influences()` floors every entry at `MIN_WEIGHT`; a region's ramp
|
|
175
|
+
* reaches the shares below that floor (`src/localweights.ts`), and a share
|
|
176
|
+
* near 0 must not absorb a remainder that could take it negative. A vertex no
|
|
177
|
+
* region reaches is rounded exactly as the lattice rounds, not by this.
|
|
178
|
+
*/
|
|
179
|
+
export function roundShares(list: readonly Influence[]): Influence[] {
|
|
180
|
+
const kept = list.map((e) => ({ bone: e.bone, weight: pyRound(e.weight, 5) })).filter((e) => e.weight > 0);
|
|
181
|
+
let heavy = 0;
|
|
182
|
+
for (let k = 1; k < kept.length; k++) if (kept[k].weight > kept[heavy].weight) heavy = k;
|
|
183
|
+
let others = 0;
|
|
184
|
+
for (let k = 0; k < kept.length; k++) if (k !== heavy) others += kept[k].weight;
|
|
185
|
+
kept[heavy].weight = pyRound(1 - others, 5);
|
|
186
|
+
return kept;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
interface Bone {
|
|
190
|
+
name: string;
|
|
191
|
+
parent: string | null;
|
|
192
|
+
x: number;
|
|
193
|
+
y: number;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export interface RigBone {
|
|
197
|
+
name: string;
|
|
198
|
+
parent?: string;
|
|
199
|
+
/** A chain link and its control: the distance to the next link, or to the chain's tip. */
|
|
200
|
+
length?: number;
|
|
201
|
+
/** Spine degrees, CCW in a y-up world, local to the parent — written on a chain link and its control, and on a bone whose parent is turned. */
|
|
202
|
+
rotation?: number;
|
|
203
|
+
x: number;
|
|
204
|
+
y: number;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export interface WeightEntry {
|
|
208
|
+
bone: string;
|
|
209
|
+
x: number;
|
|
210
|
+
y: number;
|
|
211
|
+
weight: number;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export interface MeshAttachment {
|
|
215
|
+
type: 'mesh';
|
|
216
|
+
image: string;
|
|
217
|
+
width: number;
|
|
218
|
+
height: number;
|
|
219
|
+
uvs: number[];
|
|
220
|
+
triangles: number[];
|
|
221
|
+
hull: number;
|
|
222
|
+
weights: WeightEntry[][];
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export interface RegionAttachment {
|
|
226
|
+
image: string;
|
|
227
|
+
x: number;
|
|
228
|
+
y: number;
|
|
229
|
+
/** Written only on a turned bone: the bone's world rotation, cancelled, so the image stays upright. */
|
|
230
|
+
rotation?: number;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export interface RigSpec {
|
|
234
|
+
spec: 'rigc-rig/1';
|
|
235
|
+
name: string;
|
|
236
|
+
images: 'images';
|
|
237
|
+
skeleton: { x: number; y: number; width: number; height: number };
|
|
238
|
+
bones: RigBone[];
|
|
239
|
+
slots: Array<{ name: string; bone: string; attachment: string }>;
|
|
240
|
+
skins: { default: Record<string, Record<string, MeshAttachment | RegionAttachment>> };
|
|
241
|
+
/**
|
|
242
|
+
* `config.constraints`, in the order written, each as the config wrote it
|
|
243
|
+
* but its records and annotations ({@link constraintForRig}). Written only
|
|
244
|
+
* when the config has the field.
|
|
245
|
+
*/
|
|
246
|
+
constraints?: Array<Record<string, unknown>>;
|
|
247
|
+
/**
|
|
248
|
+
* `idleDrivesMeshes`: written only under `idleKeys: 'direct'`, and only when
|
|
249
|
+
* the idle keys a mesh-driving bone. `detached`: written only when a
|
|
250
|
+
* constraint follows a bone ({@link detachedRules}).
|
|
251
|
+
*/
|
|
252
|
+
invariants?: { idleDrivesMeshes?: { why: string }; detached?: DetachedRule[]; deformMayFold?: Array<{ slot: string; why: string }> };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** One `invariants.detached` entry: rig-c's `RigDetachedRule`, checked by its gate rule `A25`. */
|
|
256
|
+
export interface DetachedRule {
|
|
257
|
+
bone: string;
|
|
258
|
+
notUnder: string;
|
|
259
|
+
why: string;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* `invariants.detached` for a config's constraints (issue #92): for each
|
|
264
|
+
* constraint that follows a bone — an ik's `target`, a transform's `source`
|
|
265
|
+
* — one rule per bone it drives, `{bone: <followed>, notUnder: <driven>}`,
|
|
266
|
+
* in constraint order and then `bones` order, each pair once. rig-c's
|
|
267
|
+
* `A25_DETACHED_BONE_PARENTAGE` fails a rule whose `bone` is a descendant of
|
|
268
|
+
* its `notUnder` (rig-c 2.10.1, `src/assertions/bodies/a25.ts`):
|
|
269
|
+
* measured on the rig fixture, a target declared detached and parented under
|
|
270
|
+
* the link it drives fails the gate under both `--idle-keys` values, and the
|
|
271
|
+
* same rig with nothing declared gates green with `A25` a SKIP. The loader
|
|
272
|
+
* refuses that parentage first (`CONFIG_CONSTRAINT_TARGET_DETACHED`); the
|
|
273
|
+
* declaration is what keeps it measured on the built rig, which a consumer
|
|
274
|
+
* composes into a scene. Empty when no constraint follows a bone.
|
|
275
|
+
*/
|
|
276
|
+
export function detachedRules(constraints: readonly ConfigConstraint[]): DetachedRule[] {
|
|
277
|
+
const out: DetachedRule[] = [];
|
|
278
|
+
const seen = new Set<string>();
|
|
279
|
+
constraints.forEach((c, i) => {
|
|
280
|
+
const field = CONSTRAINT_FOLLOWS[c.type];
|
|
281
|
+
if (field === undefined) return;
|
|
282
|
+
const followed = c[field];
|
|
283
|
+
const driven = c.bones;
|
|
284
|
+
if (typeof followed !== 'string' || !Array.isArray(driven)) return;
|
|
285
|
+
for (const b of driven) {
|
|
286
|
+
if (typeof b !== 'string' || seen.has(`${followed}\u0000${b}`)) continue;
|
|
287
|
+
seen.add(`${followed}\u0000${b}`);
|
|
288
|
+
out.push({ bone: followed, notUnder: b, why: `rig-parts: "${followed}" is the ${field} of ${c.type} constraint "${c.name}" (config.constraints[${i}]), and "${b}" is a bone it drives; under it, driving it would move what it follows` });
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
return out;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Where the idle's keys on a mesh-driving bone go (`rig --idle-keys`).
|
|
296
|
+
*
|
|
297
|
+
* - `ctl`: onto a same-origin `<bone>_ctl` parent, which passes
|
|
298
|
+
* `A15_IDLE_NO_MESH_BONE_KEYS` under every rig-c this package has run
|
|
299
|
+
* on. It satisfies the rule's wording only — see `src/motion.ts`.
|
|
300
|
+
* - `direct`: onto the bone itself, with `invariants.idleDrivesMeshes`
|
|
301
|
+
* declared, which needs rig-c 1.3.0 or later (an older rigc refuses
|
|
302
|
+
* the unknown invariant by name).
|
|
303
|
+
*
|
|
304
|
+
* Measured on the two public examples (rig-parts #13, `tools/idle_cost.ts`):
|
|
305
|
+
* `direct` removes 31 of 72 bones (demo) and 24 of 56 (sample); every shown
|
|
306
|
+
* mesh (8 and 6) has a driving bone whose world transform changes on every
|
|
307
|
+
* idle frame under both, so the meshes a dirty-skip renderer could skip are 0
|
|
308
|
+
* in both; and the per-frame pose time differs only in
|
|
309
|
+
* `updateWorldTransform` (1.2 against 0.7 us on the demo), about 3 % of a
|
|
310
|
+
* frame dominated by `computeWorldVertices`. The controls buy no renderer
|
|
311
|
+
* work. `ctl` stays the default because the examples' expected `rig.json` and
|
|
312
|
+
* `motion.json` are the reference implementation's output, and because the
|
|
313
|
+
* declaration lives in the rig spec only: `rigc validate <build> --profile
|
|
314
|
+
* spine-html`, which has no rig spec to read, refuses a `direct` build once
|
|
315
|
+
* per keyed mesh bone.
|
|
316
|
+
*/
|
|
317
|
+
export const IDLE_KEYS = ['ctl', 'direct'] as const;
|
|
318
|
+
export type IdleKeys = (typeof IDLE_KEYS)[number];
|
|
319
|
+
export const DEFAULT_IDLE_KEYS: IdleKeys = 'ctl';
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* `--idle-keys <value>` as `rig` and `build` both take it, or its default: one
|
|
323
|
+
* reading for the two commands, so neither spells the flag its own way. A value
|
|
324
|
+
* not in {@link IDLE_KEYS} is returned as the usage message naming the two.
|
|
325
|
+
*/
|
|
326
|
+
export function idleKeysOf(value: string | undefined): IdleKeys | string {
|
|
327
|
+
const keys = value ?? DEFAULT_IDLE_KEYS;
|
|
328
|
+
return (IDLE_KEYS as readonly string[]).includes(keys) ? (keys as IdleKeys) : `--idle-keys ${keys}; one of ${IDLE_KEYS.join(', ')} is required`;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export function isIdleKeys(v: IdleKeys | string): v is IdleKeys {
|
|
332
|
+
return (IDLE_KEYS as readonly string[]).includes(v);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* The command that ran the rig stage, which the rig stage names when it tells
|
|
337
|
+
* the author which flag to pass beside rigc's refusal of a pair a control
|
|
338
|
+
* splits (`ctlRemedies`, src/build.ts): `rig` and `build` both take
|
|
339
|
+
* `--idle-keys`, and the line has to name the one that was run. It changes no
|
|
340
|
+
* byte the stage writes — only that line's text.
|
|
341
|
+
*/
|
|
342
|
+
export type RigCommand = 'rig' | 'build';
|
|
343
|
+
|
|
344
|
+
/** The `why` of the `invariants.idleDrivesMeshes` that `idleKeys: 'direct'` declares. */
|
|
345
|
+
export const IDLE_DRIVES_MESHES_WHY = 'painting rig: the idle is meant to deform the meshes it keys (rig-parts rig --idle-keys direct)';
|
|
346
|
+
|
|
347
|
+
/** One row of `mesh_report.json`: a lattice mesh's row is as it always was; a contour mesh's says so. */
|
|
348
|
+
export type MeshReport = LatticeMeshReport | ContourMeshReport | AutoMeshReport;
|
|
349
|
+
|
|
350
|
+
export interface LatticeMeshReport {
|
|
351
|
+
part: string;
|
|
352
|
+
vertices: number;
|
|
353
|
+
triangles: number;
|
|
354
|
+
hull: number;
|
|
355
|
+
bones: string[];
|
|
356
|
+
max_influences: number;
|
|
357
|
+
mean_influences: number;
|
|
358
|
+
art_coverage: number;
|
|
359
|
+
grid: number;
|
|
360
|
+
/** The distance rule's exponent the mesh declared (issue #161); absent when it declared none and ran at 2. */
|
|
361
|
+
exponent?: number;
|
|
362
|
+
/** 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. */
|
|
363
|
+
heat?: HeatRow;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* A contour mesh's row (issue #84): the lattice row's figures, then the mode,
|
|
368
|
+
* the parameters it ran at, `src/contour.ts`'s report whole, and per region
|
|
369
|
+
* the vertices its bone reaches (g > 0) and holds alone (g = 1).
|
|
370
|
+
* `art_coverage` is over ALL the part's art, stray islands left out included,
|
|
371
|
+
* so a mesh that leaves pixels undrawn does not read 1.
|
|
372
|
+
*/
|
|
373
|
+
export interface ContourMeshReport {
|
|
374
|
+
part: string;
|
|
375
|
+
vertices: number;
|
|
376
|
+
triangles: number;
|
|
377
|
+
hull: number;
|
|
378
|
+
bones: string[];
|
|
379
|
+
max_influences: number;
|
|
380
|
+
mean_influences: number;
|
|
381
|
+
art_coverage: number;
|
|
382
|
+
mode: 'contour';
|
|
383
|
+
/** The distance rule's exponent the mesh declared (issue #161); absent when it declared none and ran at 2. */
|
|
384
|
+
exponent?: number;
|
|
385
|
+
/** 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. */
|
|
386
|
+
ribs?: RibsRow;
|
|
387
|
+
params: { tolerance: number; margin: number; spacing: number; budget: number | null; stray: number | null };
|
|
388
|
+
contour: ContourReport;
|
|
389
|
+
regions: Array<{ name: string; bone: string; reached: number; whole: number }>;
|
|
390
|
+
/** Only under `rule: "heat"` (issue #161), as the lattice row's. */
|
|
391
|
+
heat?: HeatRow;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* A mesh's ribs as its row echoes them (issue #188): the stations each chain declared, the ribs placed (one per link
|
|
396
|
+
* joint and station, chain by chain) and the vertices they hold, ends included. Each rib's own vertex list is in the
|
|
397
|
+
* contour report's `ribs` (the source's, in the automatic mode).
|
|
398
|
+
*/
|
|
399
|
+
export interface RibsRow {
|
|
400
|
+
chains: RibsSpec;
|
|
401
|
+
count: number;
|
|
402
|
+
vertices: number;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** The row's echo of a mesh's ribs (issue #188). */
|
|
406
|
+
function ribsRow(spec: RibsSpec, placed: readonly ContourRib[]): RibsRow {
|
|
407
|
+
return { chains: spec, count: placed.length, vertices: placed.reduce((n, r) => n + r.vertices.length, 0) };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** What a declared `source.stray` cleared from an automatic part's art (issue #172): islands, their pixels, and the art they were part of. */
|
|
411
|
+
export interface StrayCleared {
|
|
412
|
+
islands: number;
|
|
413
|
+
pixels: number;
|
|
414
|
+
art_pixels: number;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* An automatic mesh's row (issue #126, item 2): the lattice row's figures,
|
|
419
|
+
* then the mode; `settings`, every number the call saw (the effective
|
|
420
|
+
* settings, as rig-c echoes them, are also in the document); the source
|
|
421
|
+
* — the contour mesh's own report and rig-c's counts of it — and the
|
|
422
|
+
* result's counts; every residual, the worst one and the worst region; the
|
|
423
|
+
* termination; what the weights lost to the 5-place rounding; per region the
|
|
424
|
+
* source vertices its bone reaches and holds alone and the result vertices
|
|
425
|
+
* bound to it; `deformation` (below); and the whole
|
|
426
|
+
* `mesh-quality-report/1` document rig-c wrote — inside the row rather
|
|
427
|
+
* than beside it, so the rig stage stays pure and one file holds every part's
|
|
428
|
+
* evidence, as it does for the other modes. `art_coverage` is over alpha above
|
|
429
|
+
* 8, the other modes' reading, of the art every reader of the mesh takes (under
|
|
430
|
+
* a declared `source.stray`, the islands the trace left out are not in it —
|
|
431
|
+
* `stray_cleared`); the threshold-1 coverage is a residual.
|
|
432
|
+
*/
|
|
433
|
+
export interface AutoMeshReport {
|
|
434
|
+
part: string;
|
|
435
|
+
vertices: number;
|
|
436
|
+
triangles: number;
|
|
437
|
+
hull: number;
|
|
438
|
+
bones: string[];
|
|
439
|
+
max_influences: number;
|
|
440
|
+
mean_influences: number;
|
|
441
|
+
art_coverage: number;
|
|
442
|
+
mode: 'auto';
|
|
443
|
+
/** The distance rule's exponent the mesh declared (issue #161); absent when it declared none and ran at 2. */
|
|
444
|
+
exponent?: number;
|
|
445
|
+
/** Only when the mesh declared ribs (issue #188), as the contour row's; `vertices` counts the source's, which the reduction keeps. */
|
|
446
|
+
ribs?: RibsRow;
|
|
447
|
+
settings: {
|
|
448
|
+
threshold: number;
|
|
449
|
+
source: { tolerance: number; margin: number; spacing: number; stray: number | null };
|
|
450
|
+
sourceBounds: { minCoverage: number; maxOvershoot: number | null; maxUndercut: number | null };
|
|
451
|
+
targets: { artFit: { minCoverage: number; maxOvershoot: number | null; maxUndercut: number | null }; maxBoundaryDeviation: number; minAngle: number | null };
|
|
452
|
+
protect: { hull: boolean; vertices: number[]; edges: Array<[number, number]>; regionBoundaries: string[]; weightJump: number | null; influences: string[] };
|
|
453
|
+
influences: { maxInfluences: number; minWeight: number };
|
|
454
|
+
budget: { maxCandidates: number };
|
|
455
|
+
minArtSamples: number;
|
|
456
|
+
/** A density-only region (issue #155) echoes `bone` and `band` as null: it declares neither. */
|
|
457
|
+
regions: Array<{ name: string; bone: string | null; band: number | null; maxEdgeLength: number; transition: number; grade: number; minArtSamples: number; approximation: { from: string; policy: string; maxError: number } | null }>;
|
|
458
|
+
preset: null;
|
|
459
|
+
/** The amplitude rig-c read and echoed (`effective.motionAmplitude`), after the opt-ins; absent when none was sent. */
|
|
460
|
+
motionAmplitude?: MotionAmplitude;
|
|
461
|
+
} & StageBOptIns;
|
|
462
|
+
source: { contour: ContourReport; counts: MeshCounts | null };
|
|
463
|
+
/**
|
|
464
|
+
* Only on a part whose author declared `source.stray` (issue #172): the islands the trace left out and their pixels,
|
|
465
|
+
* cleared from the art every reader of the mesh takes (the reduction, its source bounds, `art_coverage`, the motion
|
|
466
|
+
* gate), against `art_pixels` — the part's art at alpha 1 and above with those islands counted in. Zero islands
|
|
467
|
+
* when the declaration left nothing out. Absent without the declaration, so such a part writes the row it always wrote.
|
|
468
|
+
*/
|
|
469
|
+
stray_cleared?: StrayCleared;
|
|
470
|
+
/** The counts; with a Stage B opt-in set, `boundary_runs` and `retriangulation` after them (`stageBResult`), each only when its opt-in is. */
|
|
471
|
+
result: { counts: MeshCounts; removedVertices: number; insertedVertices: number } & StageBResult;
|
|
472
|
+
residuals: Residual[];
|
|
473
|
+
worst_residual: (Residual & { used: number }) | null;
|
|
474
|
+
worst_region: string | null;
|
|
475
|
+
/**
|
|
476
|
+
* The motion amplitude rig-c's `MQ_ALLOCATION_CONTRAST` and `MQ_DEFORM_LOAD`
|
|
477
|
+
* read (issue #126 Q2; `src/autoamplitude.ts`): `sent` when every track term
|
|
478
|
+
* was derived from the idle and the amplitude — with the author's
|
|
479
|
+
* `motion.gradation`, or null — handed to `reduceMesh` and to the motion
|
|
480
|
+
* gate's comparison, and otherwise each term that stopped it, by path — so a
|
|
481
|
+
* reader of the two rows' `not-measurable` learns which number is missing.
|
|
482
|
+
*/
|
|
483
|
+
motion_amplitude: AmplitudeRow;
|
|
484
|
+
/**
|
|
485
|
+
* Only on a part whose author declared `auto.stimulus` (issue #196): each declared bend as the motion gate walked it —
|
|
486
|
+
* the chain, the degrees, the links turned and the two animations (+ and −) its comparison builds carry. Absent
|
|
487
|
+
* otherwise, so a part without the field writes the row it always wrote.
|
|
488
|
+
*/
|
|
489
|
+
stimulus?: Array<{ chain: string; degrees: number; links: string[]; animations: [string, string] }>;
|
|
490
|
+
/**
|
|
491
|
+
* Only on a part whose author set `motion.residual` (issue #126, rig-c 2.31.0, rigc#1295; `src/autoenvelope.ts`):
|
|
492
|
+
* the envelope sent and the written mesh's `MQ_SKINNING_RESIDUAL` against its bound, or every stop that left the
|
|
493
|
+
* residual not measurable — the veto then not applied. A pose-free bound, not the motion verdict. Absent otherwise,
|
|
494
|
+
* so a part without the field writes the row it always wrote.
|
|
495
|
+
*/
|
|
496
|
+
skinning_residual?: ResidualRow;
|
|
497
|
+
termination: Termination;
|
|
498
|
+
weights: { sharesDroppedOnGrid: number; sharesPruned: number; droppedAtFivePlaces: number };
|
|
499
|
+
/** A density-only region (issue #155): `bone` null, and 0 vertices reached, whole and bound — it weights nothing. */
|
|
500
|
+
regions: Array<{ name: string; bone: string | null; reached: number; whole: number; bound_in_result: number }>;
|
|
501
|
+
/**
|
|
502
|
+
* Only on a part whose full reduction the motion gate refused and whose
|
|
503
|
+
* acceptance loop wrote a replayed step instead (`src/autoreplay.ts`): the
|
|
504
|
+
* search — N, I, the step chosen, the replays and the candidates they tried,
|
|
505
|
+
* every probe, and the chosen step's selection and held-out readings. The
|
|
506
|
+
* row's other fields are then that step's (its `termination` is
|
|
507
|
+
* `replayed-to-accepted-step`). Absent on every other row, so a part the
|
|
508
|
+
* gate accepted outright writes the row it always wrote. With the author's
|
|
509
|
+
* `motion.selection` (issue #148) it is the multi-interval search's row,
|
|
510
|
+
* which says `policy` and carries the tested set, the passing and untested
|
|
511
|
+
* intervals, the chosen counts, the termination and the roles.
|
|
512
|
+
*/
|
|
513
|
+
replay?: ReplayRow | MultiIntervalRow;
|
|
514
|
+
/**
|
|
515
|
+
* {@link DEFORMATION_UNMEASURED} as `buildRig` writes the row — it measures
|
|
516
|
+
* geometry alone; the rig stage replaces it with the motion gate's rows
|
|
517
|
+
* (`src/automotion.ts`) before anything is written, and adds
|
|
518
|
+
* `motion_report`, rig-c's whole `compare` document, after
|
|
519
|
+
* `quality_report`. A written row never says unmeasured: a part whose motion
|
|
520
|
+
* was not measured and passed is refused.
|
|
521
|
+
*/
|
|
522
|
+
deformation: string | MotionDeformation;
|
|
523
|
+
/** Only under `rule: "heat"` (issue #161): the rule and the solve the source's weights came from. */
|
|
524
|
+
heat?: HeatRow;
|
|
525
|
+
quality_report: unknown;
|
|
526
|
+
motion_report?: unknown;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
export interface RigOutput {
|
|
530
|
+
rig: RigSpec;
|
|
531
|
+
motion: MotionSpec;
|
|
532
|
+
meshReport: MeshReport[];
|
|
533
|
+
/** The padded images, by file name (`<part>.png`), in parts.json order. */
|
|
534
|
+
images: Array<[string, Raster]>;
|
|
535
|
+
/** The bones that got a `<bone>_ctl`, sorted; empty under `idleKeys: 'direct'`. */
|
|
536
|
+
controls: string[];
|
|
537
|
+
/** The idle-keyed bones some mesh is weighted to, sorted — the bones `ctl` moves the keys off, and `direct` keys in place. */
|
|
538
|
+
meshKeyed: string[];
|
|
539
|
+
idleKeys: IdleKeys;
|
|
540
|
+
/** The one-loop passes each mesh took, for the printed report. */
|
|
541
|
+
loopPasses: Record<string, number>;
|
|
542
|
+
/** Per automatic mesh, in parts.json order: what the rig stage's motion gate compares (`src/automotion.ts`). Nothing written. */
|
|
543
|
+
autoMotion: AutoMotionCase[];
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* `RIG_BLINK_HOLD_SPANS_A_FRAME` (issue #32): the eyes' hold must put an
|
|
548
|
+
* idle frame inside the closed window for every `blink.t`, or the idle
|
|
549
|
+
* frames, the contact sheet and the loop show a blink that never closes. The
|
|
550
|
+
* three numbers are the tree's own constants (`BLINK.shut`, `BLINK.hold`,
|
|
551
|
+
* `IDLE_FPS`), which the rig stage passes on every build that has a blink;
|
|
552
|
+
* they are parameters so the refusal can be planted without editing them.
|
|
553
|
+
*/
|
|
554
|
+
export function blinkHoldProblems(shut: number, hold: number, fps: number): Problem[] {
|
|
555
|
+
const m = blinkHoldMisses(shut, hold, fps);
|
|
556
|
+
if (m.misses === 0) return [];
|
|
557
|
+
return [
|
|
558
|
+
{
|
|
559
|
+
code: 'RIG_BLINK_HOLD_SPANS_A_FRAME',
|
|
560
|
+
object: 'BLINK.hold (src/motion.ts)',
|
|
561
|
+
detail:
|
|
562
|
+
`is ${hold} s; the idle is rendered at IDLE_FPS = ${fps}, a frame every ${pyRound(1 / fps, 6)} s, so the closed window [t + ${shut}, t + ${pyRound(shut + hold, 6)}] s holds no frame ` +
|
|
563
|
+
`for ${m.misses} of the ${m.phases} phases a 6-decimal blink.t takes against the frame grid (the first at t = ${m.first} s), and the idle frames and the loop show no closed eye there; ` +
|
|
564
|
+
`a hold of at least one frame, 1/${fps} s, is required`,
|
|
565
|
+
},
|
|
566
|
+
];
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/** The slot, attachment and image name suffix of a `motion.blink.still` part's upper piece. */
|
|
570
|
+
export const STILL_SUFFIX = '_still';
|
|
571
|
+
|
|
572
|
+
/** Pixels with alpha above 0 in rows [y0, y1) of `img`. */
|
|
573
|
+
function alphaCount(img: Raster, y0: number, y1: number): number {
|
|
574
|
+
let n = 0;
|
|
575
|
+
for (let y = y0; y < y1; y++) for (let x = 0; x < img.width; x++) if (img.data[(y * img.width + x) * 4 + 3] > 0) n++;
|
|
576
|
+
return n;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function same(a: Point, b: Point): boolean {
|
|
580
|
+
return a[0] === b[0] && a[1] === b[1];
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function pt(p: Point): string {
|
|
584
|
+
return `[${p[0]}, ${p[1]}]`;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Build the rig. `images` holds every parts.json part's PNG by part name.
|
|
589
|
+
* Every problem found is thrown at once, as one `PartsError`. `reduce` runs each
|
|
590
|
+
* automatic part's reduction, `runReduction` unless a caller hands one that
|
|
591
|
+
* reuses a reduction it already ran on the same input (issue #135).
|
|
592
|
+
*/
|
|
593
|
+
export function buildRig(
|
|
594
|
+
cfg: CharacterConfig,
|
|
595
|
+
parts: PartsFile,
|
|
596
|
+
images: ReadonlyMap<string, Raster>,
|
|
597
|
+
maxLoopPasses: number = ONE_LOOP_PASSES,
|
|
598
|
+
idleKeys: IdleKeys = DEFAULT_IDLE_KEYS,
|
|
599
|
+
reduce: Reducer = runReduction,
|
|
600
|
+
): RigOutput {
|
|
601
|
+
const problems: Problem[] = [];
|
|
602
|
+
const fail = (code: string, object: string, detail: string): void => {
|
|
603
|
+
problems.push({ code, object, detail });
|
|
604
|
+
};
|
|
605
|
+
const [W, H] = parts.rig_size;
|
|
606
|
+
const CX = W / 2;
|
|
607
|
+
const byPart = new Map(parts.parts.map((p) => [p.name, p]));
|
|
608
|
+
|
|
609
|
+
// ---- bones -------------------------------------------------------------
|
|
610
|
+
const B = new Map<string, Bone>();
|
|
611
|
+
B.set(ROOT_BONE, { name: ROOT_BONE, parent: null, x: CX, y: H });
|
|
612
|
+
const tips = new Map<string, Point>();
|
|
613
|
+
const child = new Map<string, Point>();
|
|
614
|
+
const chains = new Map<string, string[]>();
|
|
615
|
+
/** Each chain's points and tip, rig px, as declared (issue #188: the ribs are placed along them). */
|
|
616
|
+
const chainLines = new Map<string, Point[]>();
|
|
617
|
+
cfg.bones.forEach((e: BoneEntry, i) => {
|
|
618
|
+
if ('chain' in e) {
|
|
619
|
+
const names: string[] = [];
|
|
620
|
+
const polyline = [...e.points, e.tip];
|
|
621
|
+
if (e.points.length < 1) {
|
|
622
|
+
fail('RIG_CHAIN_POINTS', `config.bones[${i}] chain "${e.chain}"`, `has ${e.points.length} point(s) and a tip; at least one point and a tip — 2 points — are required to make a link`);
|
|
623
|
+
}
|
|
624
|
+
for (let k = 0; k + 1 < polyline.length; k++) {
|
|
625
|
+
if (same(polyline[k], polyline[k + 1])) {
|
|
626
|
+
fail(
|
|
627
|
+
'RIG_CHAIN_POINTS',
|
|
628
|
+
`config.bones[${i}] chain "${e.chain}" link ${k}`,
|
|
629
|
+
`runs from ${pt(polyline[k])} to ${k + 1 < e.points.length ? `points[${k + 1}]` : 'the tip'} ${pt(polyline[k + 1])}, the same point — the chain has fewer than 2 distinct points along that link, so the link has no direction to weight along; each link must end at a point other than its origin`,
|
|
630
|
+
);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
e.points.forEach(([x, y], k) => {
|
|
634
|
+
const n = `${e.chain}${k}`;
|
|
635
|
+
B.set(n, { name: n, parent: k === 0 ? e.parent : names[names.length - 1], x, y });
|
|
636
|
+
if (names.length > 0) child.set(names[names.length - 1], [x, y]);
|
|
637
|
+
names.push(n);
|
|
638
|
+
});
|
|
639
|
+
if (names.length > 0) tips.set(names[names.length - 1], [e.tip[0], e.tip[1]]);
|
|
640
|
+
chains.set(e.chain, names);
|
|
641
|
+
chainLines.set(e.chain, polyline);
|
|
642
|
+
} else {
|
|
643
|
+
B.set(e.name, { name: e.name, parent: e.parent, x: e.at[0], y: e.at[1] });
|
|
644
|
+
if (e.tip !== undefined) tips.set(e.name, [e.tip[0], e.tip[1]]);
|
|
645
|
+
}
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
// ---- segments ----------------------------------------------------------
|
|
649
|
+
const seg = (bone: string, at: string): Segment | null => {
|
|
650
|
+
const b = B.get(bone);
|
|
651
|
+
if (b === undefined) {
|
|
652
|
+
fail('RIG_NAME_RESOLVES', at, `names the bone "${bone}", which config.bones does not declare`);
|
|
653
|
+
return null;
|
|
654
|
+
}
|
|
655
|
+
const end = child.get(bone) ?? tips.get(bone);
|
|
656
|
+
if (end === undefined) {
|
|
657
|
+
fail(
|
|
658
|
+
'RIG_SEGMENT_DEFINED',
|
|
659
|
+
at,
|
|
660
|
+
`names the bone "${bone}", which has no tip and no next chain link, so it has an origin and no segment; give config.bones "${bone}" a "tip", or write the segment out as ["${bone}", [x0, y0], [x1, y1]]`,
|
|
661
|
+
);
|
|
662
|
+
return null;
|
|
663
|
+
}
|
|
664
|
+
return { bone, a: [b.x, b.y], b: end };
|
|
665
|
+
};
|
|
666
|
+
const meshSegments = new Map<string, Segment[]>();
|
|
667
|
+
for (const [part, m] of Object.entries(cfg.meshes)) {
|
|
668
|
+
if ('contour' in m) {
|
|
669
|
+
(m.contour.regions ?? []).forEach((rg, i) => {
|
|
670
|
+
if (!B.has(rg.bone)) fail('RIG_NAME_RESOLVES', `config.meshes.${part}.contour.regions[${i}].bone`, `names the bone "${rg.bone}", which config.bones does not declare`);
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
if ('auto' in m) {
|
|
674
|
+
(m.auto.regions ?? []).forEach((rg, i) => {
|
|
675
|
+
if (weightsABone(rg) && !B.has(rg.bone)) fail('RIG_NAME_RESOLVES', `config.meshes.${part}.auto.regions[${i}].bone`, `names the bone "${rg.bone}", which config.bones does not declare`);
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
const out: Segment[] = [];
|
|
679
|
+
m.segments.forEach((sp, i) => {
|
|
680
|
+
const at = `config.meshes.${part}.segments[${i}]`;
|
|
681
|
+
if (typeof sp === 'string') {
|
|
682
|
+
const links = chains.get(sp);
|
|
683
|
+
if (links !== undefined) {
|
|
684
|
+
for (const l of links) {
|
|
685
|
+
const s = seg(l, `${at} (chain "${sp}" link "${l}")`);
|
|
686
|
+
if (s !== null) out.push(s);
|
|
687
|
+
}
|
|
688
|
+
} else {
|
|
689
|
+
const s = seg(sp, at);
|
|
690
|
+
if (s !== null) out.push(s);
|
|
691
|
+
}
|
|
692
|
+
} else {
|
|
693
|
+
if (!B.has(sp[0])) fail('RIG_NAME_RESOLVES', `${at}[0]`, `names the bone "${sp[0]}", which config.bones does not declare`);
|
|
694
|
+
else out.push({ bone: sp[0], a: [sp[1][0], sp[1][1]], b: [sp[2][0], sp[2][1]] });
|
|
695
|
+
}
|
|
696
|
+
});
|
|
697
|
+
meshSegments.set(part, out);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// ---- parts ↔ config ----------------------------------------------------
|
|
701
|
+
const where = (part: string): string => `parts.json holds ${parts.parts.length} part(s): ${parts.parts.map((p) => p.name).join(', ')}`;
|
|
702
|
+
for (const part of Object.keys(cfg.meshes)) if (!byPart.has(part)) fail('RIG_PART_PRESENT', `config.meshes.${part}`, `names the part "${part}", which is not in parts.json; ${where(part)}`);
|
|
703
|
+
for (const [part, bone] of Object.entries(cfg.regions)) {
|
|
704
|
+
if (!byPart.has(part)) fail('RIG_PART_PRESENT', `config.regions.${part}`, `names the part "${part}", which is not in parts.json; ${where(part)}`);
|
|
705
|
+
if (!B.has(bone)) fail('RIG_NAME_RESOLVES', `config.regions.${part}`, `names the bone "${bone}", which config.bones does not declare`);
|
|
706
|
+
}
|
|
707
|
+
for (const p of parts.parts) {
|
|
708
|
+
const inM = p.name in cfg.meshes;
|
|
709
|
+
const inR = p.name in cfg.regions;
|
|
710
|
+
if (!inM && !inR) fail('RIG_PART_ATTACHED', `part "${p.name}"`, 'is in parts.json and has neither a config.meshes nor a config.regions entry; exactly one is required');
|
|
711
|
+
if (inM && inR) fail('RIG_PART_ATTACHED', `part "${p.name}"`, 'has both a config.meshes and a config.regions entry; exactly one is required');
|
|
712
|
+
else if (inM && readFrom(p.from)?.run === PAINTING_RUN) {
|
|
713
|
+
fail('RIG_PART_ATTACHED', `part "${p.name}"`, `is a painting patch (${p.from}) and has a config.meshes entry; a patch is a region — config.regions.${p.name} names its bone`);
|
|
714
|
+
}
|
|
715
|
+
const img = images.get(p.name);
|
|
716
|
+
if (img === undefined) fail('RIG_PNG_PRESENT', `part "${p.name}"`, `has no parts/${p.name}.png`);
|
|
717
|
+
else if (img.width !== p.w || img.height !== p.h) {
|
|
718
|
+
fail('RIG_PNG_MATCHES_BOX', `part "${p.name}"`, `parts/${p.name}.png is ${img.width}x${img.height}; parts.json's box is ${p.w}x${p.h}`);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// ---- motion ------------------------------------------------------------
|
|
723
|
+
const bl = cfg.motion.blink;
|
|
724
|
+
const span = bl === undefined ? 0 : blinkSpan(bl);
|
|
725
|
+
if (bl !== undefined && !(bl.t > 0 && bl.t + span < cfg.motion.duration)) {
|
|
726
|
+
fail(
|
|
727
|
+
'RIG_BLINK_INSIDE_IDLE',
|
|
728
|
+
'config.motion.blink.t',
|
|
729
|
+
`is ${bl.t} s; the blink's keys run from t to t + ${pyRound(span, 6)} s between the idle's first key at 0 and its last at ${cfg.motion.duration} s, so 0 < t < ${pyRound(cfg.motion.duration - span, 6)} is required`,
|
|
730
|
+
);
|
|
731
|
+
}
|
|
732
|
+
if (bl !== undefined) problems.push(...blinkHoldProblems(BLINK.shut, BLINK.hold, IDLE_FPS));
|
|
733
|
+
// ---- blink.still: a blinking region cut at a row ----------------------
|
|
734
|
+
const stills = bl?.still ?? {};
|
|
735
|
+
const partNames = new Set(parts.parts.map((p) => p.name));
|
|
736
|
+
for (const [part, st] of Object.entries(stills)) {
|
|
737
|
+
const at = `config.motion.blink.still.${part}`;
|
|
738
|
+
const p = byPart.get(part);
|
|
739
|
+
if (!(part in cfg.regions)) fail('RIG_NAME_RESOLVES', at, `names "${part}", which config.regions does not attach; a region part is required`);
|
|
740
|
+
if (!B.has(st.bone)) fail('RIG_NAME_RESOLVES', `${at}.bone`, `names the bone "${st.bone}", which config.bones does not declare`);
|
|
741
|
+
if (p === undefined) continue;
|
|
742
|
+
const still = `${part}${STILL_SUFFIX}`;
|
|
743
|
+
if (partNames.has(still)) fail('RIG_STILL_NAME_FREE', at, `the still piece is drawn by the slot "${still}", and parts.json already holds a part of that name; rename that part`);
|
|
744
|
+
if (!(st.row > p.y && st.row < p.y + p.h)) {
|
|
745
|
+
fail('RIG_STILL_ROW_INSIDE_PART', `${at}.row`, `is ${st.row}; "${part}" spans rows ${p.y}..${p.y + p.h - 1}, so a row with part rows on both sides, ${p.y + 1}..${p.y + p.h - 1}, is required`);
|
|
746
|
+
continue;
|
|
747
|
+
}
|
|
748
|
+
const img = images.get(part);
|
|
749
|
+
if (img === undefined || img.width !== p.w || img.height !== p.h) continue;
|
|
750
|
+
const r = st.row - p.y;
|
|
751
|
+
const crossing: number[] = [];
|
|
752
|
+
for (let x = 0; x < img.width; x++) if (img.data[(r * img.width + x) * 4 + 3] > 0) crossing.push(x);
|
|
753
|
+
if (crossing.length > 0) {
|
|
754
|
+
fail(
|
|
755
|
+
'RIG_STILL_ROW_CLEAR',
|
|
756
|
+
`${at}.row`,
|
|
757
|
+
`is ${st.row}, and "${part}" has ${crossing.length} pixel(s) with alpha above 0 on that row (the first at x = ${p.x + crossing[0]}); a row with no art across the part's whole width is required — two pieces cut through art are each resampled against their own transparent edge, so the render changes at rest, not only while the eye moves`,
|
|
758
|
+
);
|
|
759
|
+
continue;
|
|
760
|
+
}
|
|
761
|
+
const artAbove = alphaCount(img, 0, r);
|
|
762
|
+
const artBelow = alphaCount(img, r, img.height);
|
|
763
|
+
if (artAbove === 0 || artBelow === 0) {
|
|
764
|
+
fail('RIG_STILL_PIECES_HAVE_ART', `${at}.row`, `is ${st.row}; "${part}" has ${artAbove} art pixel(s) above it and ${artBelow} below; both pieces need art, or the cut holds nothing still (or nothing blinks)`);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
refuseIfAny(problems);
|
|
768
|
+
|
|
769
|
+
const motion = idleMotion(cfg, chains);
|
|
770
|
+
const meshBones = new Set<string>();
|
|
771
|
+
for (const segs of meshSegments.values()) for (const s of segs) meshBones.add(s.bone);
|
|
772
|
+
// A contour region's control bone is weighted to like a segment's bone (issue #84), so the idle's keys on it move
|
|
773
|
+
// to its control under `ctl` as theirs do. A config with no contour region adds nothing here.
|
|
774
|
+
for (const m of Object.values(cfg.meshes)) if ('contour' in m) for (const rg of m.contour.regions ?? []) meshBones.add(rg.bone);
|
|
775
|
+
for (const m of Object.values(cfg.meshes)) if ('auto' in m) for (const rg of m.auto.regions ?? []) if (weightsABone(rg)) meshBones.add(rg.bone);
|
|
776
|
+
const meshKeyed = controlledBones(motion, meshBones);
|
|
777
|
+
const controls = idleKeys === 'ctl' ? meshKeyed : [];
|
|
778
|
+
for (const k of controls) {
|
|
779
|
+
const ctl = `${k}${CONTROL_SUFFIX}`;
|
|
780
|
+
if (B.has(ctl)) {
|
|
781
|
+
fail('RIG_CONTROL_NAME_FREE', `bone "${k}"`, `is keyed by the idle and weighted to by a mesh, so it needs the control bone "${ctl}", and config.bones already declares a bone of that name`);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
// A two-bone ik over a link and its child link stops being one when the
|
|
785
|
+
// child gets a control: `<child>_ctl` stands between them. rig-c 2.15.0
|
|
786
|
+
// refuses that pair by name (issue #103); the rig stage adds the flag to
|
|
787
|
+
// rigc's line (`ctlRemedies`, src/build.ts).
|
|
788
|
+
refuseIfAny(problems);
|
|
789
|
+
for (const k of controls) {
|
|
790
|
+
const b = B.get(k) as Bone;
|
|
791
|
+
const ctl = `${k}${CONTROL_SUFFIX}`;
|
|
792
|
+
B.set(ctl, { name: ctl, parent: b.parent, x: b.x, y: b.y });
|
|
793
|
+
B.set(k, { ...b, parent: ctl });
|
|
794
|
+
}
|
|
795
|
+
moveKeysToControls(motion, controls);
|
|
796
|
+
// Parents first: a depth-first walk over the table in insertion order, each
|
|
797
|
+
// bone after its parent — which puts every control directly above its bone.
|
|
798
|
+
const ordered: Bone[] = [];
|
|
799
|
+
const done = new Set<string>();
|
|
800
|
+
const visit = (n: string): void => {
|
|
801
|
+
if (done.has(n)) return;
|
|
802
|
+
const b = B.get(n) as Bone;
|
|
803
|
+
if (b.parent !== null) visit(b.parent);
|
|
804
|
+
done.add(n);
|
|
805
|
+
ordered.push(b);
|
|
806
|
+
};
|
|
807
|
+
for (const n of B.keys()) visit(n);
|
|
808
|
+
|
|
809
|
+
const spineX = (x: number): number => x - CX;
|
|
810
|
+
const spineY = (y: number): number => cropToSpineY(y, H);
|
|
811
|
+
|
|
812
|
+
// ---- direction: every chain link points along its chain (issue #73) ----
|
|
813
|
+
// From the link's origin to the next link's, the last link to the chain's
|
|
814
|
+
// tip, in Spine's axes; a link's control (same origin) points the same way.
|
|
815
|
+
const aim = new Map<string, { rotation: number; length: number }>();
|
|
816
|
+
for (const links of chains.values()) {
|
|
817
|
+
for (const l of links) {
|
|
818
|
+
const b = B.get(l) as Bone;
|
|
819
|
+
const end = (child.get(l) ?? tips.get(l)) as Point;
|
|
820
|
+
const dx = spineX(end[0]) - spineX(b.x);
|
|
821
|
+
const dy = spineY(end[1]) - spineY(b.y);
|
|
822
|
+
const a = { rotation: (Math.atan2(dy, dx) * 180) / Math.PI, length: pyRound(Math.sqrt(dx * dx + dy * dy), 3) };
|
|
823
|
+
aim.set(l, a);
|
|
824
|
+
if (controls.includes(l)) aim.set(`${l}${CONTROL_SUFFIX}`, a);
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
// Each bone's local rotation turns it from its parent's world rotation to
|
|
828
|
+
// the one it should have: its chain's direction, or 0 for every other bone,
|
|
829
|
+
// so a named bone under a chain link is turned back upright. `turn` is the
|
|
830
|
+
// world rotation as written, the sum of the written local rotations down the
|
|
831
|
+
// tree. Each local offset is the bone's origin carried into its parent's
|
|
832
|
+
// setup frame as written, through rigc's inverse (`src/coords.ts`).
|
|
833
|
+
const turn = new Map<string, number>();
|
|
834
|
+
const bones: RigBone[] = [];
|
|
835
|
+
for (const b of ordered) {
|
|
836
|
+
if (b.parent === null) {
|
|
837
|
+
bones.push({ name: b.name, x: 0, y: 0 });
|
|
838
|
+
turn.set(b.name, 0);
|
|
839
|
+
continue;
|
|
840
|
+
}
|
|
841
|
+
const up = turn.get(b.parent) as number;
|
|
842
|
+
const a = aim.get(b.name);
|
|
843
|
+
const rotation = places(normaliseDegrees((a?.rotation ?? 0) - up));
|
|
844
|
+
turn.set(b.name, places(up + rotation));
|
|
845
|
+
const frame = computeExactFrameTransforms(bones).get(b.parent) as BoneTransform;
|
|
846
|
+
const [x, y] = toBoneLocal(frame, spineX(b.x), spineY(b.y));
|
|
847
|
+
bones.push({ name: b.name, parent: b.parent, ...(a === undefined ? {} : { length: a.length }), ...(a !== undefined || rotation !== 0 ? { rotation } : {}), x: places(x), y: places(y) });
|
|
848
|
+
}
|
|
849
|
+
const world = computeExactFrameTransforms(bones);
|
|
850
|
+
|
|
851
|
+
// ---- keys a turned frame would change ----------------------------------
|
|
852
|
+
// A rotate key turns a bone about its origin whatever its frame, so the
|
|
853
|
+
// idle's chain sways mean what they meant. A translate key moves a bone
|
|
854
|
+
// along its parent's axes and a scale or shear key stretches it along its
|
|
855
|
+
// own: on a turned frame those are other directions than the config wrote.
|
|
856
|
+
const keyFrame: Array<{ bone: string; prop: string; at: string }> = [];
|
|
857
|
+
cfg.motion.tracks.forEach((tr, i) => {
|
|
858
|
+
if (!('chain' in tr)) keyFrame.push({ bone: tr.bone, prop: tr.prop, at: `config.motion.tracks[${i}]` });
|
|
859
|
+
});
|
|
860
|
+
bl?.eyes.forEach((m, i) => keyFrame.push({ bone: m, prop: 'scaley', at: `config.motion.blink.eyes[${i}]` }));
|
|
861
|
+
if (bl?.brow_drop !== undefined) bl.brows?.forEach((m, i) => keyFrame.push({ bone: m, prop: 'translatey', at: `config.motion.blink.brows[${i}]` }));
|
|
862
|
+
for (const k of keyFrame) {
|
|
863
|
+
if (k.prop === 'rotate') continue;
|
|
864
|
+
const own = B.get(k.bone) as Bone;
|
|
865
|
+
const parent = (controls.includes(k.bone) ? (B.get(`${k.bone}${CONTROL_SUFFIX}`) as Bone).parent : own.parent) as string;
|
|
866
|
+
const translate = k.prop.startsWith('translate');
|
|
867
|
+
const frameBone = translate ? parent : k.bone;
|
|
868
|
+
const deg = turn.get(frameBone) as number;
|
|
869
|
+
if (deg === 0) continue;
|
|
870
|
+
const axis = k.prop.slice(-1);
|
|
871
|
+
fail(
|
|
872
|
+
'RIG_KEY_FRAME_UNTURNED',
|
|
873
|
+
`${k.at} (bone "${k.bone}", ${k.prop})`,
|
|
874
|
+
translate
|
|
875
|
+
? `moves "${k.bone}" along the axes of its parent "${parent}", which is turned ${deg} degrees to point along its chain (issue #73), so the key would move it along that turned axis and not along the picture's ${axis}; a translate key is allowed only on a bone whose parent is not turned — key the chain's own parent, or key rotate`
|
|
876
|
+
: `stretches "${k.bone}" along its own axes, and "${k.bone}" is turned ${deg} degrees to point along its chain (issue #73), so the key would stretch it along that turned axis and not along the picture's ${axis}; a scale or shear key is allowed only on a bone that is not turned — key the chain's own parent, or key rotate`,
|
|
877
|
+
);
|
|
878
|
+
}
|
|
879
|
+
refuseIfAny(problems);
|
|
880
|
+
|
|
881
|
+
// ---- attachments -------------------------------------------------------
|
|
882
|
+
const slots: RigSpec['slots'] = [];
|
|
883
|
+
const skin: RigSpec['skins']['default'] = {};
|
|
884
|
+
const meshReport: MeshReport[] = [];
|
|
885
|
+
const outImages: Array<[string, Raster]> = [];
|
|
886
|
+
const loopPasses: Record<string, number> = {};
|
|
887
|
+
const autoMotion: AutoMotionCase[] = [];
|
|
888
|
+
// A region's centre in its bone's setup frame; on a turned bone the image
|
|
889
|
+
// is turned back by the bone's world rotation, so it is drawn upright.
|
|
890
|
+
const region = (image: string, bone: string, cx: number, cy: number): RegionAttachment => {
|
|
891
|
+
const [x, y] = toBoneLocal(world.get(bone) as BoneTransform, spineX(cx), spineY(cy));
|
|
892
|
+
const deg = turn.get(bone) as number;
|
|
893
|
+
return deg === 0 ? { image, x: places(x), y: places(y) } : { image, x: places(x), y: places(y), rotation: places(normaliseDegrees(-deg)) };
|
|
894
|
+
};
|
|
895
|
+
// issue #188: the chains a mesh declared ribs on, in the order its segments name them, each chain's points and tip
|
|
896
|
+
// carried into the part image by the same translation as every other position. No ribs declared, none.
|
|
897
|
+
const chainRibs = (segments: ReadonlyArray<string | readonly [string, Point, Point]>, ribs: RibsSpec | undefined, ox: number, oy: number): ContourChainRibs[] => {
|
|
898
|
+
if (ribs === undefined) return [];
|
|
899
|
+
const named = [...new Set(segments.filter((sp): sp is string => typeof sp === 'string'))];
|
|
900
|
+
return named.flatMap((chain) => {
|
|
901
|
+
const line = chainLines.get(chain);
|
|
902
|
+
const declared = ribs[chain];
|
|
903
|
+
if (line === undefined || declared === undefined) return [];
|
|
904
|
+
return [{ chain, points: line.map(([x, y]) => [x - ox, y - oy] as [number, number]), stations: declared.stations }];
|
|
905
|
+
});
|
|
906
|
+
};
|
|
907
|
+
// A contour mesh (issue #84): `src/contour.ts` over the padded image, every refusal collected with the others;
|
|
908
|
+
// the weights by `src/localweights.ts`; slot, uvs, bind positions and by-name weights written as the lattice's.
|
|
909
|
+
// The config's region numbers are rig px; the part image's are rig px less (p.x − PAD, p.y − PAD) — a
|
|
910
|
+
// translation, so every length (tolerance, margin, spacing, band, r) is the same number in both.
|
|
911
|
+
const contourAttachment = (
|
|
912
|
+
p: PartsFile['parts'][number],
|
|
913
|
+
img: Raster,
|
|
914
|
+
file: string,
|
|
915
|
+
spec: ContourSpec,
|
|
916
|
+
r: number,
|
|
917
|
+
exponent: number | undefined,
|
|
918
|
+
segs: Segment[],
|
|
919
|
+
rule: WeightRule,
|
|
920
|
+
ribSpec: { segments: ReadonlyArray<string | readonly [string, Point, Point]>; ribs?: RibsSpec },
|
|
921
|
+
out: Problem[],
|
|
922
|
+
): { attachment: MeshAttachment; report: ContourMeshReport } | null => {
|
|
923
|
+
const ox = p.x - PAD;
|
|
924
|
+
const oy = p.y - PAD;
|
|
925
|
+
const w = img.width;
|
|
926
|
+
const h = img.height;
|
|
927
|
+
const alpha = new Uint8Array(w * h);
|
|
928
|
+
for (let i = 0; i < alpha.length; i++) alpha[i] = img.data[i * 4 + 3];
|
|
929
|
+
const regions = spec.regions ?? [];
|
|
930
|
+
const inPart: ContourRegion[] = regions.map((rg) =>
|
|
931
|
+
rg.shape === 'circle'
|
|
932
|
+
? { name: rg.name, shape: 'circle', cx: rg.cx - ox, cy: rg.cy - oy, r: rg.r, spacing: rg.spacing, band: rg.band }
|
|
933
|
+
: { name: rg.name, shape: 'polygon', points: rg.points.map(([x, y]) => [x - ox, y - oy] as [number, number]), spacing: rg.spacing, band: rg.band },
|
|
934
|
+
);
|
|
935
|
+
const ribs = chainRibs(ribSpec.segments, ribSpec.ribs, ox, oy);
|
|
936
|
+
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 }) });
|
|
937
|
+
if (Array.isArray(cm)) {
|
|
938
|
+
out.push(...cm);
|
|
939
|
+
return null;
|
|
940
|
+
}
|
|
941
|
+
// issue #161: under rule "heat", the field over the silhouette the outline was traced from (art above 8, holes
|
|
942
|
+
// filled, grown by the margin), and every vertex's segment shares read off it instead of the distance rule.
|
|
943
|
+
const heat = rule === 'heat' ? boneHeat(`config.meshes.${p.name}`, tracedSilhouette(cm.mask, ART_ALPHA, spec.margin), segs, ox, oy) : null;
|
|
944
|
+
if (Array.isArray(heat)) {
|
|
945
|
+
out.push(...heat);
|
|
946
|
+
return null;
|
|
947
|
+
}
|
|
948
|
+
const weights: WeightEntry[][] = [];
|
|
949
|
+
const reached = regions.map(() => 0);
|
|
950
|
+
const whole = regions.map(() => 0);
|
|
951
|
+
let infl = 0;
|
|
952
|
+
let maxInfl = 0;
|
|
953
|
+
let overlapped = false;
|
|
954
|
+
cm.vertices.forEach(([vx, vy], vi) => {
|
|
955
|
+
const wx = vx + ox;
|
|
956
|
+
const wy = vy + oy;
|
|
957
|
+
const li = localInfluences([wx, wy], segs, r, regions, DEFAULT_LIMITS, exponent ?? DISTANCE_EXPONENT, heat === null ? undefined : heatInfluences(heat, [vx, vy], DEFAULT_LIMITS));
|
|
958
|
+
if ('first' in li) {
|
|
959
|
+
if (!overlapped) {
|
|
960
|
+
out.push({
|
|
961
|
+
code: 'RIG_CONTOUR_REGIONS_OVERLAP',
|
|
962
|
+
object: `config.meshes.${p.name}.contour.regions`,
|
|
963
|
+
detail: `vertex ${vi} at rig (${wx}, ${wy}) takes weight ${pyRound(li.g1, 5)} from region "${regions[li.first].name}" and ${pyRound(li.g2, 5)} from region "${regions[li.second].name}"; each vertex may be reached by one region's falloff (its region and band) — move the regions apart or narrow a band`,
|
|
964
|
+
});
|
|
965
|
+
}
|
|
966
|
+
overlapped = true;
|
|
967
|
+
return;
|
|
968
|
+
}
|
|
969
|
+
if (li.region >= 0) {
|
|
970
|
+
reached[li.region]++;
|
|
971
|
+
if (li.g >= 1) whole[li.region]++;
|
|
972
|
+
}
|
|
973
|
+
const shares = writtenShares(li);
|
|
974
|
+
const ent: WeightEntry[] = shares.map(({ bone, weight }) => {
|
|
975
|
+
const [x, y] = toBoneLocal(world.get(bone) as BoneTransform, spineX(wx), spineY(wy));
|
|
976
|
+
return { bone, x: places(x), y: places(y), weight };
|
|
977
|
+
});
|
|
978
|
+
weights.push(ent);
|
|
979
|
+
infl += ent.length;
|
|
980
|
+
maxInfl = Math.max(maxInfl, ent.length);
|
|
981
|
+
});
|
|
982
|
+
if (overlapped) return null;
|
|
983
|
+
const uvs: number[] = [];
|
|
984
|
+
for (const [x, y] of cm.vertices) uvs.push(pyRound(x / w, 6), pyRound(y / h, 6));
|
|
985
|
+
const bones = new Set(segs.map((s) => s.bone));
|
|
986
|
+
for (const rg of regions) bones.add(rg.bone);
|
|
987
|
+
const rep = cm.report;
|
|
988
|
+
return {
|
|
989
|
+
attachment: { type: 'mesh', image: file, width: w, height: h, uvs, triangles: cm.triangles, hull: cm.hull, weights },
|
|
990
|
+
report: {
|
|
991
|
+
part: p.name,
|
|
992
|
+
vertices: cm.vertices.length,
|
|
993
|
+
triangles: cm.triangles.length / 3,
|
|
994
|
+
hull: cm.hull,
|
|
995
|
+
bones: [...bones].sort(),
|
|
996
|
+
max_influences: maxInfl,
|
|
997
|
+
mean_influences: pyRound(infl / cm.vertices.length, 2),
|
|
998
|
+
art_coverage: pyRound(rep.coveredArtPixels / (rep.artPixels + rep.strayPixels), 5),
|
|
999
|
+
mode: 'contour',
|
|
1000
|
+
...(exponent === undefined ? {} : { exponent }),
|
|
1001
|
+
...(ribSpec.ribs === undefined ? {} : { ribs: ribsRow(ribSpec.ribs, cm.ribs ?? []) }),
|
|
1002
|
+
params: { tolerance: spec.tolerance, margin: spec.margin, spacing: spec.spacing, budget: spec.budget ?? null, stray: spec.stray ?? null },
|
|
1003
|
+
contour: rep,
|
|
1004
|
+
regions: regions.map((rg, k) => ({ name: rg.name, bone: rg.bone, reached: reached[k], whole: whole[k] })),
|
|
1005
|
+
...(heat === null ? {} : { heat: heatRow(heat) }),
|
|
1006
|
+
},
|
|
1007
|
+
};
|
|
1008
|
+
};
|
|
1009
|
+
// An automatic mesh (issue #126, item 2): the contour source at alpha 1 and above, rig-c's reduceMesh, and
|
|
1010
|
+
// the result used only when accepted (`src/automesh.ts`). Its weights are rig-c's by name — a survivor's are
|
|
1011
|
+
// the source's bit for bit, an inserted vertex's interpolated — rounded by roundShares (the author's minWeight
|
|
1012
|
+
// replaces the 0.03 floor that makes the lattice's rounding safe, so every vertex closes on its heaviest entry)
|
|
1013
|
+
// and bound exactly as a contour vertex is.
|
|
1014
|
+
const boneOrder = bones.map((b) => b.name);
|
|
1015
|
+
// What the amplitude derivation reads (src/autoamplitude.ts), named here because autoAttachment's own `bones` and `motion` shadow them.
|
|
1016
|
+
const rigBones = bones;
|
|
1017
|
+
const idle = motion;
|
|
1018
|
+
// Each bone's setup joint in crop px (src/autoenvelope.ts reads it as the pivot of the skinning envelope), off the
|
|
1019
|
+
// rig's bones as written: x back across the root, y through the one door — cropToSpineY is its own inverse (y ↦ H − y).
|
|
1020
|
+
const joints = new Map<string, readonly [number, number]>(bones.map((b) => {
|
|
1021
|
+
const m = world.get(b.name) as BoneTransform;
|
|
1022
|
+
return [b.name, [m.worldX + CX, cropToSpineY(m.worldY, H)] as const];
|
|
1023
|
+
}));
|
|
1024
|
+
const autoAttachment = (
|
|
1025
|
+
p: PartsFile['parts'][number],
|
|
1026
|
+
img: Raster,
|
|
1027
|
+
file: string,
|
|
1028
|
+
spec: AutoSpec,
|
|
1029
|
+
r: number,
|
|
1030
|
+
exponent: number | undefined,
|
|
1031
|
+
segs: Segment[],
|
|
1032
|
+
rule: WeightRule,
|
|
1033
|
+
ribSpec: { segments: ReadonlyArray<string | readonly [string, Point, Point]>; ribs?: RibsSpec },
|
|
1034
|
+
out: Problem[],
|
|
1035
|
+
): { attachment: MeshAttachment; report: AutoMeshReport; motion: AutoMotionCase } | null => {
|
|
1036
|
+
const ox = p.x - PAD;
|
|
1037
|
+
const oy = p.y - PAD;
|
|
1038
|
+
const w = img.width;
|
|
1039
|
+
const h = img.height;
|
|
1040
|
+
const alpha = new Uint8Array(w * h);
|
|
1041
|
+
for (let i = 0; i < alpha.length; i++) alpha[i] = img.data[i * 4 + 3];
|
|
1042
|
+
const mask = { width: w, height: h, alpha };
|
|
1043
|
+
const object = `config.meshes.${p.name}.auto`;
|
|
1044
|
+
const regions = spec.regions ?? [];
|
|
1045
|
+
// issue #196: the declared bends, as a list, each chain's links resolved (the loader held each chain to the segments).
|
|
1046
|
+
const segmentChains = ribSpec.segments.filter((x): x is string => typeof x === 'string' && chains.has(x));
|
|
1047
|
+
const stimulus: StimulusPose[] | null =
|
|
1048
|
+
spec.stimulus === undefined ? null : (Array.isArray(spec.stimulus) ? spec.stimulus : [spec.stimulus]).map((x) => ({ chain: x.chain, degrees: x.degrees, links: [...(chains.get(x.chain) ?? [])] }));
|
|
1049
|
+
const source = autoSource(p.name, mask, spec, chainRibs(ribSpec.segments, ribSpec.ribs, ox, oy));
|
|
1050
|
+
if (Array.isArray(source)) {
|
|
1051
|
+
out.push(...source.map((q) => ({ ...q, detail: `${q.detail} (the automatic mode's source, at alpha ${AUTO_THRESHOLD} and above)` })));
|
|
1052
|
+
return null;
|
|
1053
|
+
}
|
|
1054
|
+
// One mask for every reader of the part (issue #172): under a declared `source.stray` the islands the trace left
|
|
1055
|
+
// out are cleared from the art the reduction, its source bounds, the coverage and the motion gate read — the mask
|
|
1056
|
+
// `contourMesh` traced. Nothing left out, it is `mask` itself, so a part without stray reads what it always read.
|
|
1057
|
+
// The image written for the part is not touched (the reason is in docs/AUTHORING.md's `stray` row).
|
|
1058
|
+
const art = source.mask;
|
|
1059
|
+
// issue #161: under rule "heat", the field over the silhouette the source was traced from (the same mask, at the
|
|
1060
|
+
// automatic mode's alpha 1 and above, holes filled, grown by source.margin).
|
|
1061
|
+
const heat = rule === 'heat' ? boneHeat(`config.meshes.${p.name}`, tracedSilhouette(art, AUTO_THRESHOLD - 1, spec.source.margin), segs, ox, oy) : null;
|
|
1062
|
+
if (Array.isArray(heat)) {
|
|
1063
|
+
out.push(...heat);
|
|
1064
|
+
return null;
|
|
1065
|
+
}
|
|
1066
|
+
const sw = sourceWeights(source.vertices, ox, oy, segs, r, spec, exponent ?? DISTANCE_EXPONENT, heat);
|
|
1067
|
+
if ('overlap' in sw) {
|
|
1068
|
+
out.push({
|
|
1069
|
+
code: 'RIG_CONTOUR_REGIONS_OVERLAP',
|
|
1070
|
+
object: `${object}.regions`,
|
|
1071
|
+
detail: `source vertex ${sw.vertex} at rig (${sw.at[0]}, ${sw.at[1]}) takes weight ${pyRound(sw.overlap.g1, 5)} from region "${regions[sw.overlap.first].name}" and ${pyRound(sw.overlap.g2, 5)} from region "${regions[sw.overlap.second].name}"; each vertex may be reached by one region's falloff (its region and band) — move the regions apart or narrow a band`,
|
|
1072
|
+
});
|
|
1073
|
+
return null;
|
|
1074
|
+
}
|
|
1075
|
+
// rig-c's allocation rows read an amplitude this package derives from its own idle (issue #126 Q2), with the
|
|
1076
|
+
// author's gradation or null (rig-c 2.29.0, rigc#1291); sent whenever every track term is derived
|
|
1077
|
+
// (src/autoamplitude.ts). The same amplitude goes on the motion gate's comparison (src/automotion.ts).
|
|
1078
|
+
const amplitude = partAmplitude({ bones: rigBones, motion: idle, constraints: cfg.constraints?.length ?? 0 }, sw.weights, spec.motion);
|
|
1079
|
+
// issue #126 (rig-c 2.31.0, rigc#1295): the skinning residual as a per-step veto, only when the author set
|
|
1080
|
+
// motion.residual — its envelope derived from the same idle (src/autoenvelope.ts), the reference the lowest common
|
|
1081
|
+
// ancestor of the slot's bone and every bound bone (issue #165; the slot's bone itself when every bound bone hangs
|
|
1082
|
+
// below it, Q4). A part whose envelope stops is sent nothing: the veto is not applied and the comparison decides alone.
|
|
1083
|
+
const residualSpec = spec.motion?.residual;
|
|
1084
|
+
const envelope = residualSpec === undefined ? null : slotEnvelope({ bones: rigBones, joints, motion: idle, constraints: cfg.constraints ?? [] }, segs[0].bone, sw.weights.flatMap((v) => v.map((e) => e.bone)), [ox, oy]);
|
|
1085
|
+
const input: MeshReductionInput = withSkinning(withAmplitude(autoReductionInput({ part: p.name, mask: art, ox, oy, spec, source, weights: sw.weights, boneOrder }), amplitude), envelope?.derivation ?? null, residualSpec?.maxResidual);
|
|
1086
|
+
const ran = reduce(object, input);
|
|
1087
|
+
if ('code' in ran) {
|
|
1088
|
+
out.push(ran);
|
|
1089
|
+
return null;
|
|
1090
|
+
}
|
|
1091
|
+
const verdict = autoVerdict(object, ran);
|
|
1092
|
+
if (!verdict.accepted) {
|
|
1093
|
+
out.push(verdict.problem);
|
|
1094
|
+
return null;
|
|
1095
|
+
}
|
|
1096
|
+
// One binding for the result and for the source the motion gate compares it with: rounded by roundShares, bound in each bone's frame.
|
|
1097
|
+
const bind = (points: ReadonlyArray<readonly [number, number]>, lists: ReadonlyArray<ReadonlyArray<{ bone: string; weight: number }>>) => {
|
|
1098
|
+
const weights: WeightEntry[][] = [];
|
|
1099
|
+
let infl = 0;
|
|
1100
|
+
let maxInfl = 0;
|
|
1101
|
+
let droppedAtFivePlaces = 0;
|
|
1102
|
+
const boundTo = regions.map(() => 0);
|
|
1103
|
+
points.forEach(([vx, vy], vi) => {
|
|
1104
|
+
const wx = vx + ox;
|
|
1105
|
+
const wy = vy + oy;
|
|
1106
|
+
const list = lists[vi];
|
|
1107
|
+
const shares = roundShares(list);
|
|
1108
|
+
droppedAtFivePlaces += list.length - shares.length;
|
|
1109
|
+
regions.forEach((rg, k) => {
|
|
1110
|
+
if (weightsABone(rg) && shares.some((s) => s.bone === rg.bone)) boundTo[k]++;
|
|
1111
|
+
});
|
|
1112
|
+
const ent: WeightEntry[] = shares.map(({ bone, weight }) => {
|
|
1113
|
+
const [x, y] = toBoneLocal(world.get(bone) as BoneTransform, spineX(wx), spineY(wy));
|
|
1114
|
+
return { bone, x: places(x), y: places(y), weight };
|
|
1115
|
+
});
|
|
1116
|
+
weights.push(ent);
|
|
1117
|
+
infl += ent.length;
|
|
1118
|
+
maxInfl = Math.max(maxInfl, ent.length);
|
|
1119
|
+
});
|
|
1120
|
+
return { weights, infl, maxInfl, droppedAtFivePlaces, boundTo };
|
|
1121
|
+
};
|
|
1122
|
+
// The reference of the motion gate (src/automotion.ts): the source exactly as reduceMesh was handed it — its UVs, its
|
|
1123
|
+
// triangles in that winding, its hull — and its own weights, bound as the result's are.
|
|
1124
|
+
const srcBound = bind(source.vertices, sw.weights);
|
|
1125
|
+
const reference: MeshAttachment = { type: 'mesh', image: file, width: w, height: h, uvs: [...input.source.uvs], triangles: [...input.source.triangles], hull: input.source.hull, weights: srcBound.weights };
|
|
1126
|
+
const reached = regions.map(() => 0);
|
|
1127
|
+
const whole = regions.map(() => 0);
|
|
1128
|
+
if ('local' in sw) {
|
|
1129
|
+
for (const li of sw.local) {
|
|
1130
|
+
if (li.region < 0) continue;
|
|
1131
|
+
reached[li.region]++;
|
|
1132
|
+
if (li.g >= 1) whole[li.region]++;
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
const bones = new Set(segs.map((s) => s.bone));
|
|
1136
|
+
for (const rg of regions) if (weightsABone(rg)) bones.add(rg.bone);
|
|
1137
|
+
const pro = input.protect;
|
|
1138
|
+
// The attachment and the row of one accepted mesh and the report it came with: the full result's, or a replay's (src/autoreplay.ts).
|
|
1139
|
+
// The Stage B opt-ins the author set, echoed after `preset` only when set (rigc#1271): a part that sets none writes the row it always wrote.
|
|
1140
|
+
const stageB: StageBOptIns = {
|
|
1141
|
+
...(spec.boundaryRuns === undefined ? {} : { boundaryRuns: { maxVertices: spec.boundaryRuns.maxVertices } }),
|
|
1142
|
+
...(spec.retriangulate === undefined ? {} : { retriangulate: spec.retriangulate }),
|
|
1143
|
+
...(spec.removalOrder === undefined ? {} : { removalOrder: spec.removalOrder }),
|
|
1144
|
+
};
|
|
1145
|
+
// What the declared stray cleared, against the part's art at alpha 1 and above with the cleared islands counted in.
|
|
1146
|
+
const strayCleared: StrayCleared = { islands: source.report.strayIslands, pixels: source.report.strayPixels, art_pixels: source.report.artPixels + source.report.strayPixels };
|
|
1147
|
+
const rowOf = (mesh: ReducedMesh, report: MeshQualityReport): { attachment: MeshAttachment; row: AutoMeshReport } => {
|
|
1148
|
+
const { weights, infl, maxInfl, droppedAtFivePlaces, boundTo } = bind(mesh.points, mesh.weights as Array<Array<{ bone: string; weight: number }>>);
|
|
1149
|
+
const candidate = report.candidates[0];
|
|
1150
|
+
const rows = residuals(report);
|
|
1151
|
+
return {
|
|
1152
|
+
attachment: { type: 'mesh', image: file, width: w, height: h, uvs: [...mesh.uvs], triangles: [...mesh.triangles], hull: mesh.hull, weights },
|
|
1153
|
+
row: {
|
|
1154
|
+
part: p.name,
|
|
1155
|
+
vertices: mesh.points.length,
|
|
1156
|
+
triangles: mesh.triangles.length / 3,
|
|
1157
|
+
hull: mesh.hull,
|
|
1158
|
+
bones: [...bones].sort(),
|
|
1159
|
+
max_influences: maxInfl,
|
|
1160
|
+
mean_influences: pyRound(infl / mesh.points.length, 2),
|
|
1161
|
+
art_coverage: pyRound(legacyArtCoverage(art, mesh.points, mesh.triangles), 5),
|
|
1162
|
+
mode: 'auto',
|
|
1163
|
+
...(exponent === undefined ? {} : { exponent }),
|
|
1164
|
+
...(ribSpec.ribs === undefined ? {} : { ribs: ribsRow(ribSpec.ribs, source.ribs ?? []) }),
|
|
1165
|
+
settings: {
|
|
1166
|
+
threshold: input.art.threshold,
|
|
1167
|
+
source: { tolerance: spec.source.tolerance, margin: spec.source.margin, spacing: spec.source.spacing, stray: spec.source.stray ?? null },
|
|
1168
|
+
sourceBounds: input.sourceBounds,
|
|
1169
|
+
targets: { artFit: input.targets.artFit, maxBoundaryDeviation: input.targets.maxBoundaryDeviation, minAngle: input.targets.minAngle ?? null },
|
|
1170
|
+
protect: { hull: pro.hull, vertices: pro.vertices, edges: pro.edges, regionBoundaries: pro.regionBoundaries, weightJump: pro.weightJump, influences: pro.influences },
|
|
1171
|
+
influences: input.influences as { maxInfluences: number; minWeight: number },
|
|
1172
|
+
budget: input.budget,
|
|
1173
|
+
minArtSamples: input.minArtSamples,
|
|
1174
|
+
regions: regions.map((rg, k) => ({
|
|
1175
|
+
name: rg.name,
|
|
1176
|
+
bone: weightsABone(rg) ? rg.bone : null,
|
|
1177
|
+
band: weightsABone(rg) ? rg.band : null,
|
|
1178
|
+
maxEdgeLength: rg.maxEdgeLength,
|
|
1179
|
+
transition: rg.transition,
|
|
1180
|
+
grade: rg.grade,
|
|
1181
|
+
minArtSamples: rg.minArtSamples,
|
|
1182
|
+
approximation: input.targets.regions[k].approximation,
|
|
1183
|
+
})),
|
|
1184
|
+
preset: null,
|
|
1185
|
+
...stageB,
|
|
1186
|
+
// rig-c's own echo of the amplitude it read (`effective.motionAmplitude`, rigc#1287/#1291), only when one was sent.
|
|
1187
|
+
...(report.effective.motionAmplitude === undefined || report.effective.motionAmplitude === null ? {} : { motionAmplitude: report.effective.motionAmplitude }),
|
|
1188
|
+
},
|
|
1189
|
+
source: { contour: source.report, counts: report.sourceCounts },
|
|
1190
|
+
...(spec.source.stray === undefined ? {} : { stray_cleared: strayCleared }),
|
|
1191
|
+
result: { counts: mesh.counts, removedVertices: candidate.changes?.removedVertices ?? 0, insertedVertices: candidate.changes?.insertedVertices ?? 0, ...stageBResult(report, stageB) },
|
|
1192
|
+
residuals: rows,
|
|
1193
|
+
worst_residual: worstResidual(rows),
|
|
1194
|
+
worst_region: worstRegion(rows),
|
|
1195
|
+
motion_amplitude: amplitudeRow(amplitude),
|
|
1196
|
+
...(stimulus === null ? {} : { stimulus: stimulus.map((x) => ({ chain: x.chain, degrees: x.degrees, links: [...x.links], animations: stimulusAnimationNames(x) })) }),
|
|
1197
|
+
...(residualSpec === undefined || envelope === null ? {} : { skinning_residual: residualRow(residualSpec.maxResidual, envelope.reference, envelope.derivation, report, segs[0].bone) }),
|
|
1198
|
+
termination: report.termination as Termination,
|
|
1199
|
+
weights: { sharesDroppedOnGrid: candidate.changes?.sharesDroppedOnGrid ?? 0, sharesPruned: candidate.changes?.sharesPruned ?? 0, droppedAtFivePlaces },
|
|
1200
|
+
...(heat === null ? {} : { heat: heatRow(heat) }),
|
|
1201
|
+
regions: regions.map((rg, k) => ({ name: rg.name, bone: weightsABone(rg) ? rg.bone : null, reached: reached[k], whole: whole[k], bound_in_result: boundTo[k] })),
|
|
1202
|
+
deformation: DEFORMATION_UNMEASURED,
|
|
1203
|
+
quality_report: qualityDocument(report),
|
|
1204
|
+
},
|
|
1205
|
+
};
|
|
1206
|
+
};
|
|
1207
|
+
const changes = ran.report.candidates[0]?.changes;
|
|
1208
|
+
const acceptedAt = [...(changes?.acceptedAt ?? [])];
|
|
1209
|
+
const search: AutoSearch = {
|
|
1210
|
+
acceptedAt,
|
|
1211
|
+
inserted: refinementSteps(acceptedAt),
|
|
1212
|
+
replay: (step) => {
|
|
1213
|
+
const replayed = reduce(object, { ...input, stopAfterAccepted: step });
|
|
1214
|
+
if ('code' in replayed) return replayed;
|
|
1215
|
+
const v = replayVerdict(object, step, acceptedAt, replayed);
|
|
1216
|
+
if (!v.accepted) return v.problem;
|
|
1217
|
+
return { ...rowOf(v.mesh, replayed.report), candidatesTried: v.candidatesTried };
|
|
1218
|
+
},
|
|
1219
|
+
};
|
|
1220
|
+
const motion: AutoMotionCase = {
|
|
1221
|
+
part: p.name,
|
|
1222
|
+
object,
|
|
1223
|
+
motion: spec.motion,
|
|
1224
|
+
reference,
|
|
1225
|
+
mask: art,
|
|
1226
|
+
sourceBounds: input.sourceBounds,
|
|
1227
|
+
artFit: input.targets.artFit,
|
|
1228
|
+
minArtSamples: input.minArtSamples,
|
|
1229
|
+
regions: input.targets.regions.map((rg, k) => ({ name: rg.name, polygon: rg.polygon.map(([x, y]) => [x, y] as [number, number]), minArtSamples: regions[k].minArtSamples })),
|
|
1230
|
+
boundBones: [...new Set(srcBound.weights.flatMap((v) => v.map((e) => e.bone)))].sort(),
|
|
1231
|
+
segmentChains,
|
|
1232
|
+
stimulus,
|
|
1233
|
+
amplitude: 'amplitude' in amplitude ? amplitude.amplitude : null,
|
|
1234
|
+
search,
|
|
1235
|
+
};
|
|
1236
|
+
const full = rowOf(verdict.mesh, ran.report);
|
|
1237
|
+
return { motion, attachment: full.attachment, report: full.row };
|
|
1238
|
+
};
|
|
1239
|
+
for (const p of parts.parts) {
|
|
1240
|
+
const img = pad(images.get(p.name) as Raster, PAD, PAD, PAD, PAD, [0, 0, 0, 0]);
|
|
1241
|
+
const file = `${p.name}.png`;
|
|
1242
|
+
outImages.push([file, img]);
|
|
1243
|
+
const mesh = cfg.meshes[p.name];
|
|
1244
|
+
if (mesh === undefined) {
|
|
1245
|
+
const bone = B.get(cfg.regions[p.name]) as Bone;
|
|
1246
|
+
const cx = p.x + p.w / 2;
|
|
1247
|
+
const st = stills[p.name];
|
|
1248
|
+
if (st !== undefined) {
|
|
1249
|
+
// The part cut at st.row: the lower piece keeps the part's slot and
|
|
1250
|
+
// blinks; the upper one is drawn right after it, by <part>_still on
|
|
1251
|
+
// st.bone. Each piece sits exactly where its rows sat in the part.
|
|
1252
|
+
const src = images.get(p.name) as Raster;
|
|
1253
|
+
const r = st.row - p.y;
|
|
1254
|
+
const still = `${p.name}${STILL_SUFFIX}`;
|
|
1255
|
+
const stillFile = `${still}.png`;
|
|
1256
|
+
const lower = pad(crop(src, 0, r, p.w, p.h - r), PAD, PAD, PAD, PAD, [0, 0, 0, 0]);
|
|
1257
|
+
const upper = pad(crop(src, 0, 0, p.w, r), PAD, PAD, PAD, PAD, [0, 0, 0, 0]);
|
|
1258
|
+
const sb = B.get(st.bone) as Bone;
|
|
1259
|
+
outImages[outImages.length - 1] = [file, lower];
|
|
1260
|
+
outImages.push([stillFile, upper]);
|
|
1261
|
+
const cyLow = st.row + (p.h - r) / 2;
|
|
1262
|
+
const cyUp = p.y + r / 2;
|
|
1263
|
+
skin[p.name] = { [p.name]: region(file, bone.name, cx, cyLow) };
|
|
1264
|
+
skin[still] = { [still]: region(stillFile, sb.name, cx, cyUp) };
|
|
1265
|
+
slots.push({ name: p.name, bone: bone.name, attachment: p.name }, { name: still, bone: sb.name, attachment: still });
|
|
1266
|
+
continue;
|
|
1267
|
+
}
|
|
1268
|
+
const cy = p.y + p.h / 2;
|
|
1269
|
+
skin[p.name] = { [p.name]: region(file, bone.name, cx, cy) };
|
|
1270
|
+
slots.push({ name: p.name, bone: bone.name, attachment: p.name });
|
|
1271
|
+
continue;
|
|
1272
|
+
}
|
|
1273
|
+
const segs = meshSegments.get(p.name) as Segment[];
|
|
1274
|
+
if ('auto' in mesh) {
|
|
1275
|
+
const row = autoAttachment(p, img, file, mesh.auto, mesh.r, mesh.exponent, segs, mesh.rule ?? 'distance', mesh, problems);
|
|
1276
|
+
if (row === null) continue;
|
|
1277
|
+
skin[p.name] = { [p.name]: row.attachment };
|
|
1278
|
+
slots.push({ name: p.name, bone: segs[0].bone, attachment: p.name });
|
|
1279
|
+
meshReport.push(row.report);
|
|
1280
|
+
autoMotion.push(row.motion);
|
|
1281
|
+
continue;
|
|
1282
|
+
}
|
|
1283
|
+
if ('contour' in mesh) {
|
|
1284
|
+
const row = contourAttachment(p, img, file, mesh.contour, mesh.r, mesh.exponent, segs, mesh.rule ?? 'distance', mesh, problems);
|
|
1285
|
+
if (row === null) continue;
|
|
1286
|
+
skin[p.name] = { [p.name]: row.attachment };
|
|
1287
|
+
slots.push({ name: p.name, bone: segs[0].bone, attachment: p.name });
|
|
1288
|
+
meshReport.push(row.report);
|
|
1289
|
+
continue;
|
|
1290
|
+
}
|
|
1291
|
+
const art = alphaAbove(img, ART_ALPHA);
|
|
1292
|
+
const lm = latticeMesh(p.name, art, mesh.grid, maxLoopPasses);
|
|
1293
|
+
if ('code' in lm) {
|
|
1294
|
+
problems.push(lm);
|
|
1295
|
+
continue;
|
|
1296
|
+
}
|
|
1297
|
+
loopPasses[p.name] = lm.passes;
|
|
1298
|
+
const ox = p.x - PAD;
|
|
1299
|
+
const oy = p.y - PAD;
|
|
1300
|
+
// issue #161: under rule "heat", the field over the lattice's art (alpha above 8) with its holes filled.
|
|
1301
|
+
const heat = mesh.rule === 'heat' ? boneHeat(`config.meshes.${p.name}`, latticeSilhouette(art), segs, ox, oy) : null;
|
|
1302
|
+
if (Array.isArray(heat)) {
|
|
1303
|
+
problems.push(...heat);
|
|
1304
|
+
continue;
|
|
1305
|
+
}
|
|
1306
|
+
const w = img.width;
|
|
1307
|
+
const h = img.height;
|
|
1308
|
+
const weights: WeightEntry[][] = [];
|
|
1309
|
+
let infl = 0;
|
|
1310
|
+
let maxInfl = 0;
|
|
1311
|
+
for (const [vx0, vy0] of lm.vertices) {
|
|
1312
|
+
const wx = vx0 + ox;
|
|
1313
|
+
const wy = vy0 + oy;
|
|
1314
|
+
const ent: WeightEntry[] = (heat === null ? influences([wx, wy], segs, mesh.r, undefined, mesh.exponent ?? DISTANCE_EXPONENT) : heatInfluences(heat, [vx0, vy0], DEFAULT_LIMITS)).map(({ bone, weight }) => {
|
|
1315
|
+
const [x, y] = toBoneLocal(world.get(bone) as BoneTransform, spineX(wx), spineY(wy));
|
|
1316
|
+
return { bone, x: places(x), y: places(y), weight: pyRound(weight, 5) };
|
|
1317
|
+
});
|
|
1318
|
+
let others = 0;
|
|
1319
|
+
for (let k = 0; k < ent.length - 1; k++) others += ent[k].weight;
|
|
1320
|
+
ent[ent.length - 1].weight = pyRound(1 - others, 5);
|
|
1321
|
+
weights.push(ent);
|
|
1322
|
+
infl += ent.length;
|
|
1323
|
+
maxInfl = Math.max(maxInfl, ent.length);
|
|
1324
|
+
}
|
|
1325
|
+
const uvs: number[] = [];
|
|
1326
|
+
for (const [x, y] of lm.vertices) uvs.push(pyRound(x / w, 6), pyRound(y / h, 6));
|
|
1327
|
+
skin[p.name] = { [p.name]: { type: 'mesh', image: file, width: w, height: h, uvs, triangles: lm.triangles, hull: lm.hull, weights } };
|
|
1328
|
+
slots.push({ name: p.name, bone: segs[0].bone, attachment: p.name });
|
|
1329
|
+
meshReport.push({
|
|
1330
|
+
part: p.name,
|
|
1331
|
+
vertices: lm.vertices.length,
|
|
1332
|
+
triangles: lm.triangles.length / 3,
|
|
1333
|
+
hull: lm.hull,
|
|
1334
|
+
bones: [...new Set(segs.map((s) => s.bone))].sort(),
|
|
1335
|
+
max_influences: maxInfl,
|
|
1336
|
+
mean_influences: pyRound(infl / lm.vertices.length, 2),
|
|
1337
|
+
art_coverage: pyRound(artCoverage(lm, art), 5),
|
|
1338
|
+
grid: mesh.grid,
|
|
1339
|
+
...(mesh.exponent === undefined ? {} : { exponent: mesh.exponent }),
|
|
1340
|
+
...(heat === null ? {} : { heat: heatRow(heat) }),
|
|
1341
|
+
});
|
|
1342
|
+
}
|
|
1343
|
+
refuseIfAny(problems);
|
|
1344
|
+
|
|
1345
|
+
const name = `${cfg.key}_painting`;
|
|
1346
|
+
const rig: RigSpec = {
|
|
1347
|
+
spec: 'rigc-rig/1',
|
|
1348
|
+
name,
|
|
1349
|
+
images: 'images',
|
|
1350
|
+
skeleton: { x: -CX, y: 0, width: W, height: H },
|
|
1351
|
+
bones,
|
|
1352
|
+
slots,
|
|
1353
|
+
skins: { default: skin },
|
|
1354
|
+
};
|
|
1355
|
+
if (cfg.constraints !== undefined) rig.constraints = cfg.constraints.map(constraintForRig);
|
|
1356
|
+
const detached = detachedRules(cfg.constraints ?? []);
|
|
1357
|
+
if (idleKeys === 'direct' && meshKeyed.length > 0) rig.invariants = { idleDrivesMeshes: { why: IDLE_DRIVES_MESHES_WHY } };
|
|
1358
|
+
if (detached.length > 0) rig.invariants = { ...rig.invariants, detached };
|
|
1359
|
+
// issue #126 item 3: an automatic part whose author set motion.deformMayFold — written only then, so no other rig moves.
|
|
1360
|
+
const mayFold = autoMotion.filter((c) => c.motion?.deformMayFold === true).map((c) => ({ slot: c.part, why: DEFORM_MAY_FOLD_WHY }));
|
|
1361
|
+
if (mayFold.length > 0) rig.invariants = { ...rig.invariants, deformMayFold: mayFold };
|
|
1362
|
+
return { rig, motion, meshReport, images: outImages, controls, meshKeyed, idleKeys, loopPasses, autoMotion };
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
/**
|
|
1366
|
+
* The JSON text of a rig output file. One space of indent, as the reference
|
|
1367
|
+
* wrote, and a closing newline; the key order is the order the objects were
|
|
1368
|
+
* built in, which `buildRig` fixes.
|
|
1369
|
+
*/
|
|
1370
|
+
export function rigJsonText(value: unknown): string {
|
|
1371
|
+
return `${JSON.stringify(value, null, 1)}\n`;
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
/**
|
|
1375
|
+
* The rig as the reference wrote it: every bone unturned, so each local
|
|
1376
|
+
* offset is a plain difference of world positions. Each bone's `x, y`, each
|
|
1377
|
+
* weight's bind `x, y` and each region's `x, y` is carried out of its frame as
|
|
1378
|
+
* written (rigc's `toWorld`, `src/coords.ts`) and back in as a difference from
|
|
1379
|
+
* the frame's origin, at the reference's 3 places; `length` and `rotation` are
|
|
1380
|
+
* dropped. Everything else is copied.
|
|
1381
|
+
*
|
|
1382
|
+
* It exists so the reference's outputs stay an oracle: the chain suite and
|
|
1383
|
+
* `scripts/rig_oracle.ts` compare `flattenRig(built)` with the reference's
|
|
1384
|
+
* `rig.json`. It is the oriented emitter's inverse, and it lives beside it so
|
|
1385
|
+
* a change to one is read against the other.
|
|
1386
|
+
*
|
|
1387
|
+
* ⛔ The flat form cannot draw a turned image. A region whose `rotation` does
|
|
1388
|
+
* not cancel its bone's world rotation is refused by name
|
|
1389
|
+
* (`FLATTEN_REGION_UPRIGHT`) rather than flattened into an upright one the
|
|
1390
|
+
* oriented rig does not draw.
|
|
1391
|
+
*
|
|
1392
|
+
* `sense` is the direction rotations are read in: 1, always, but for the
|
|
1393
|
+
* planted control that shows a flatten turning the wrong way is caught.
|
|
1394
|
+
*/
|
|
1395
|
+
export function flattenRig(rig: RigSpec, sense: 1 | -1 = 1): RigSpec {
|
|
1396
|
+
const problems: Problem[] = [];
|
|
1397
|
+
const read = rig.bones.map((b) => ({ ...b, rotation: (b.rotation ?? 0) * sense }));
|
|
1398
|
+
const world = computeExactFrameTransforms(read);
|
|
1399
|
+
const turn = new Map<string, number>();
|
|
1400
|
+
for (const b of rig.bones) turn.set(b.name, placed((b.parent === undefined ? 0 : (turn.get(b.parent) as number)) + (b.rotation ?? 0), OFFSET_PLACES));
|
|
1401
|
+
const origin = (bone: string): BoneTransform => world.get(bone) as BoneTransform;
|
|
1402
|
+
const out = (m: BoneTransform, x: number, y: number): [number, number] => {
|
|
1403
|
+
const [wx, wy] = toWorld(m, x, y);
|
|
1404
|
+
return [placed(wx - m.worldX, FLAT_PLACES), placed(wy - m.worldY, FLAT_PLACES)];
|
|
1405
|
+
};
|
|
1406
|
+
const bones: RigBone[] = rig.bones.map((b) => {
|
|
1407
|
+
if (b.parent === undefined) return { name: b.name, x: b.x, y: b.y };
|
|
1408
|
+
const [x, y] = out(origin(b.parent), b.x, b.y);
|
|
1409
|
+
return { name: b.name, parent: b.parent, x, y };
|
|
1410
|
+
});
|
|
1411
|
+
const slotBone = new Map(rig.slots.map((s) => [s.name, s.bone]));
|
|
1412
|
+
const skin: RigSpec['skins']['default'] = {};
|
|
1413
|
+
for (const [slot, atts] of Object.entries(rig.skins.default)) {
|
|
1414
|
+
const bone = slotBone.get(slot) as string;
|
|
1415
|
+
const flat: Record<string, MeshAttachment | RegionAttachment> = {};
|
|
1416
|
+
for (const [name, att] of Object.entries(atts)) {
|
|
1417
|
+
if ('type' in att) {
|
|
1418
|
+
flat[name] = { ...att, weights: att.weights.map((v) => v.map((w) => {
|
|
1419
|
+
const [x, y] = out(origin(w.bone), w.x, w.y);
|
|
1420
|
+
return { bone: w.bone, x, y, weight: w.weight };
|
|
1421
|
+
})) };
|
|
1422
|
+
continue;
|
|
1423
|
+
}
|
|
1424
|
+
const left = normaliseDegrees((turn.get(bone) as number) + (att.rotation ?? 0));
|
|
1425
|
+
if (Math.abs(left) > 1e-6) {
|
|
1426
|
+
problems.push({
|
|
1427
|
+
code: 'FLATTEN_REGION_UPRIGHT',
|
|
1428
|
+
object: `skins.default.${slot}.${name}`,
|
|
1429
|
+
detail: `rides "${bone}", turned ${turn.get(bone)} degrees, with rotation ${att.rotation ?? 'absent'}, so it is drawn turned ${placed(left, OFFSET_PLACES)} degrees; the flat form draws every region upright, so the region's rotation must cancel its bone's turn`,
|
|
1430
|
+
});
|
|
1431
|
+
continue;
|
|
1432
|
+
}
|
|
1433
|
+
const [x, y] = out(origin(bone), att.x, att.y);
|
|
1434
|
+
flat[name] = { image: att.image, x, y };
|
|
1435
|
+
}
|
|
1436
|
+
skin[slot] = flat;
|
|
1437
|
+
}
|
|
1438
|
+
refuseIfAny(problems);
|
|
1439
|
+
return { ...rig, bones, skins: { default: skin } };
|
|
1440
|
+
}
|