@rig-c/parts 1.7.0 → 1.9.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 +13 -12
- package/cli.ts +33 -5
- package/docs/AUTHORING.md +61 -15
- package/docs/STABILITY.md +9 -4
- package/package.json +1 -1
- package/src/assemble.ts +199 -16
- package/src/build.ts +27 -4
- package/src/check.ts +120 -14
- package/src/config.ts +75 -1
- package/src/headframe.ts +207 -0
- package/src/motion.ts +20 -3
- package/src/propose.ts +517 -18
- package/src/rig.ts +236 -19
- package/src/turn.ts +99 -0
package/src/propose.ts
CHANGED
|
@@ -29,7 +29,10 @@
|
|
|
29
29
|
* midline, split at the eye axis — unless the blob is clasped hands (at the
|
|
30
30
|
* axis and at most half the shoulders' width), which ride `hip` as a region.
|
|
31
31
|
* - `front hair` -> three fringe chains + one lock chain per strand that hangs
|
|
32
|
-
* 0.3 face heights below the chin.
|
|
32
|
+
* 0.3 face heights below the chin. The fringe chains are the reference's
|
|
33
|
+
* template turned into the head's frame (issue #249): columns along the
|
|
34
|
+
* eye line's perpendicular ({@link headRollOf}), each rooted on the part's
|
|
35
|
+
* own art, and a note saying they are a template, not the part's locks.
|
|
33
36
|
* - `back hair` -> one `bun` bone if it ends above the shoulders, else two
|
|
34
37
|
* hanging chains.
|
|
35
38
|
* - `headwear` / `earwear` -> the biggest layer of the tag gets a rigid bone
|
|
@@ -87,6 +90,18 @@
|
|
|
87
90
|
* end of the trunk (issue #243), the trunk parts' fixed segments run along
|
|
88
91
|
* neck -> hip, the line the chest is placed on, instead of down the screen
|
|
89
92
|
* ({@link trunkLineOf}).
|
|
93
|
+
* One departure moves every published proposal, the reference's own two
|
|
94
|
+
* included (issue #249): the reference stood the fringe's three columns on
|
|
95
|
+
* the page and rooted them 13 px below the part's box, which on a tilted head
|
|
96
|
+
* put them on a hat above the hair and across the locks; here they stand in
|
|
97
|
+
* the head's frame and are rooted 13 px below the art on each column, and
|
|
98
|
+
* `lint` names a fringe link that does not lean with the head.
|
|
99
|
+
*
|
|
100
|
+
* The idle departs from the reference's too (issue #252): it is
|
|
101
|
+
* {@link IDLE_FRAMES} frames at 12 fps rather than 4 s, every period scaled
|
|
102
|
+
* with it, and the chest's breath travels {@link BREATH_FRACTION} of the torso
|
|
103
|
+
* part's height ({@link breathTrack}) rather than a fixed 1.3 px amplitude, so
|
|
104
|
+
* no tracked proposal is the reference's output any more in its `motion`.
|
|
90
105
|
*
|
|
91
106
|
* Issue #86 adds what the proposer and lint looked at, and changes nothing
|
|
92
107
|
* they wrote: {@link proposeWithBasis} records, at each placement, what the
|
|
@@ -101,7 +116,8 @@ import { join } from 'node:path';
|
|
|
101
116
|
import { drawText, GLYPH_H } from 'rig-c/tools/font5x7.ts';
|
|
102
117
|
import { type BoneEntry, type ConfigConstraint, type MeshSpec, parseConfig, type Point, type Segment, type SkeletonSections } from './config.ts';
|
|
103
118
|
import { PartsError, type Problem, refuseIfAny } from './errors.ts';
|
|
104
|
-
import {
|
|
119
|
+
import { eyeLineDegrees, type FrameFeature, headAxes, type HeadAxes, headFrame, type HeadFrame } from './headframe.ts';
|
|
120
|
+
import { type LayerSet, OPAQUE_ALPHA_ABOVE } from './layers.ts';
|
|
105
121
|
import { PAINTING_RUN, type PartRecord, type PartsFile, readParts } from './parts.ts';
|
|
106
122
|
import { connectedComponents } from './raster/components.ts';
|
|
107
123
|
import { dilate, morphGradient } from './raster/morph.ts';
|
|
@@ -110,8 +126,11 @@ import { resize } from './raster/resize.ts';
|
|
|
110
126
|
import { type Mask, newMask, newRaster, type Raster } from './raster/types.ts';
|
|
111
127
|
import { npMean, npMedian, npPercentile, pyFixed, pyInt, pyIntList, pyRepr, pyRound, pyStrList } from './pyfmt.ts';
|
|
112
128
|
import type { JointState, RigJoint, RigJoints } from './keypoints.ts';
|
|
129
|
+
import { IDLE_FPS } from './motion.ts';
|
|
130
|
+
import { pyRound as roundTo } from './round.ts';
|
|
113
131
|
import { KEYPOINT_NAMES, type KeypointName } from './skeleton.ts';
|
|
114
132
|
import { configRoles, type RoleOf } from './structure.ts';
|
|
133
|
+
import { TURN_BONE, turnFractions, turnNearest, turnPivot, type TurnSpec } from './turn.ts';
|
|
115
134
|
|
|
116
135
|
// ---------------------------------------------------------------------------
|
|
117
136
|
// the output shape — config-shaped, key order as the reference writes it
|
|
@@ -149,6 +168,41 @@ export const EYE_GROUP_TAGS: readonly string[] = ['eyewhite-r', 'eyewhite-l'];
|
|
|
149
168
|
/** The tags whose parts make the brow bones, the blink's `brows` group. */
|
|
150
169
|
export const BROW_GROUP_TAGS: readonly string[] = ['eyebrow-r', 'eyebrow-l'];
|
|
151
170
|
|
|
171
|
+
/**
|
|
172
|
+
* The drafted idle's length, in frames at {@link IDLE_FPS} (issue #252): 40
|
|
173
|
+
* frames, 40/12 s. The reference drafted a 4 s idle; HQ calibrated the breath
|
|
174
|
+
* over 40 frames at 12 fps, and a duration of a whole number of frames is what
|
|
175
|
+
* `check`'s loop compares frame 0 with (issue #244).
|
|
176
|
+
*/
|
|
177
|
+
export const IDLE_FRAMES = 40;
|
|
178
|
+
/** {@link IDLE_FRAMES} in seconds: 3.3333333333333335 as a double, the time rigc's last frame is read at within `TIME_SLACK`. */
|
|
179
|
+
export const IDLE_SECONDS = IDLE_FRAMES / IDLE_FPS;
|
|
180
|
+
/**
|
|
181
|
+
* The idle the reference drafted, in seconds. Every drafted track's period is
|
|
182
|
+
* written against it (4 s: one cycle per idle; 2 s: two) and scaled by
|
|
183
|
+
* `IDLE_SECONDS / REFERENCE_IDLE_SECONDS` when the proposal is returned, so
|
|
184
|
+
* each keeps its number of cycles per idle — the loader refuses a period that
|
|
185
|
+
* does not divide the idle (`CONFIG_PERIOD_DIVIDES_DURATION`). The scale is
|
|
186
|
+
* 10/12 = 0.8333333333333334, and 4 and 2 times it are 40/12 and 20/12 to the
|
|
187
|
+
* last bit, since only a power of two multiplies it.
|
|
188
|
+
*/
|
|
189
|
+
export const REFERENCE_IDLE_SECONDS = 4;
|
|
190
|
+
/**
|
|
191
|
+
* The breath's travel, peak to peak, as a fraction of the torso part's height
|
|
192
|
+
* (issue #252). The size is the height because the breath is a `translatey`:
|
|
193
|
+
* its travel is read along y, against the part's extent along y. HQ's
|
|
194
|
+
* calibration: about 2.5 %. On the trailer's seated character the torso part
|
|
195
|
+
* (`topwear`) is 267 px tall and the reference's fixed 2.6 px peak to peak
|
|
196
|
+
* (amplitude 1.3, the base 1.3 so the chest rises from its setup pose and
|
|
197
|
+
* returns) was 0.97 % of it, read by its owner as a still paper doll; the
|
|
198
|
+
* production scene rig that replaced it breathes 6.4 px, 2.4 %.
|
|
199
|
+
*/
|
|
200
|
+
export const BREATH_FRACTION = 0.025;
|
|
201
|
+
/** The breath's amplitude the reference drafted on every figure, kept only where there is no torso part to scale by. */
|
|
202
|
+
export const REFERENCE_BREATH_AMP = 1.3;
|
|
203
|
+
/** The See-through tag of the torso part, whose height the breath is a fraction of. */
|
|
204
|
+
export const BREATH_SIZE_TAG = 'topwear';
|
|
205
|
+
|
|
152
206
|
export interface Proposal {
|
|
153
207
|
bones: BoneEntry[];
|
|
154
208
|
meshes: Record<string, MeshSpec>;
|
|
@@ -159,6 +213,8 @@ export interface Proposal {
|
|
|
159
213
|
/** Absent when no part feeds the `eyes` group ({@link EYE_GROUP_TAGS}): a blink with nothing to blink is not written. */
|
|
160
214
|
blink?: ProposedBlink;
|
|
161
215
|
};
|
|
216
|
+
/** Only with `propose --full`/`--head` (issue #251, {@link draftTurn}); absent otherwise, so a proposal without them is the one written before it. */
|
|
217
|
+
turn?: TurnSpec;
|
|
162
218
|
notes: string[];
|
|
163
219
|
}
|
|
164
220
|
|
|
@@ -315,6 +371,7 @@ export function checkProposal(P: PartSet, p: Proposal): void {
|
|
|
315
371
|
meshes: p.meshes,
|
|
316
372
|
regions: p.regions,
|
|
317
373
|
motion: p.motion,
|
|
374
|
+
...(p.turn === undefined ? {} : { turn: p.turn }),
|
|
318
375
|
});
|
|
319
376
|
}
|
|
320
377
|
|
|
@@ -323,6 +380,98 @@ export function serializeProposal(p: Proposal): string {
|
|
|
323
380
|
return `${JSON.stringify(p, null, 2)}\n`;
|
|
324
381
|
}
|
|
325
382
|
|
|
383
|
+
// ---------------------------------------------------------------------------
|
|
384
|
+
// the turn control (issue #251)
|
|
385
|
+
// ---------------------------------------------------------------------------
|
|
386
|
+
|
|
387
|
+
/** The See-through runs `propose --full/--head` reads depths from, by run. */
|
|
388
|
+
export type TurnRuns = Partial<Record<'full' | 'head', LayerSet>>;
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Draft `turn` (issue #251, `src/turn.ts`) from the parts and the See-through
|
|
392
|
+
* runs they came from: each part's depth is its layer's `depth_median` — the
|
|
393
|
+
* layer of its run named by its tag, a cut's piece its base's — and the pivot
|
|
394
|
+
* is {@link turnPivot} over the parts' `opaque_px`. A `painting:` patch has no
|
|
395
|
+
* layer and so no depth: it is not carried, and a note names it. What a depth
|
|
396
|
+
* cannot be read for is refused by name, every one at once:
|
|
397
|
+
*
|
|
398
|
+
* - `PROPOSE_TURN_RUN_GIVEN`: a part from a run whose layers were not given.
|
|
399
|
+
* - `PROPOSE_TURN_LAYER_PRESENT`: the run given holds no layer of the part's tag.
|
|
400
|
+
* - `PROPOSE_TURN_DEPTH_PRESENT`: the layer carries no depth (a PSD carries none).
|
|
401
|
+
*
|
|
402
|
+
* With no part to carry, or every carried part at one depth (nothing nearer
|
|
403
|
+
* than the pivot), no `turn` is drafted and a note says why. Otherwise one note
|
|
404
|
+
* states the pivot and its rule, the law, and every part's fraction with the
|
|
405
|
+
* depth it came from.
|
|
406
|
+
*/
|
|
407
|
+
export function draftTurn(P: PartSet, runs: TurnRuns): { turn: TurnSpec | null; notes: string[] } {
|
|
408
|
+
const problems: Problem[] = [];
|
|
409
|
+
const rows: Array<{ part: string; depth: number; area: number }> = [];
|
|
410
|
+
const patches: string[] = [];
|
|
411
|
+
const unread = new Map<string, string[]>();
|
|
412
|
+
for (const r of P.recs) {
|
|
413
|
+
const [run, tag] = splitFrom(r.from);
|
|
414
|
+
if (run === PAINTING_RUN) {
|
|
415
|
+
patches.push(r.name);
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
const set = run === 'full' || run === 'head' ? runs[run] : undefined;
|
|
419
|
+
if (set === undefined) {
|
|
420
|
+
unread.set(run, [...(unread.get(run) ?? []), r.name]);
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
const layer = set.layers.find((l) => l.name === tag);
|
|
424
|
+
if (layer === undefined) {
|
|
425
|
+
problems.push({ code: 'PROPOSE_TURN_LAYER_PRESENT', object: `part "${r.name}" (${r.from})`, detail: `came from the ${run} run's "${tag}" layer, and ${set.source} holds no layer of that name; the run its parts were assembled from is required` });
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
if (layer.depth === null) {
|
|
429
|
+
problems.push({ code: 'PROPOSE_TURN_DEPTH_PRESENT', object: `part "${r.name}" (${r.from})`, detail: `its layer in ${set.source} carries no depth_median (a ${set.form} input); the turn orders parts by See-through's per-layer depth, so a run that carries one is required` });
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
rows.push({ part: r.name, depth: layer.depth, area: r.opaque_px });
|
|
433
|
+
}
|
|
434
|
+
for (const [run, names] of unread) {
|
|
435
|
+
problems.push({ code: 'PROPOSE_TURN_RUN_GIVEN', object: `the ${run} run`, detail: `part(s) ${names.map((n) => `"${n}"`).join(', ')} came from it and its layers were not given; --${run} <dir | layers.json> is required to read their depth` });
|
|
436
|
+
}
|
|
437
|
+
refuseIfAny(problems);
|
|
438
|
+
const notes: string[] = [];
|
|
439
|
+
if (patches.length > 0) {
|
|
440
|
+
notes.push(`turn: painting patch(es) ${patches.map((n) => `"${n}"`).join(', ')} carry no See-through depth and are not carried — each moves only as its bone does, as the pivot depth does; a value in config.turn.depths carries one`);
|
|
441
|
+
}
|
|
442
|
+
const pivot = turnPivot(rows);
|
|
443
|
+
const depths = Object.create(null) as Record<string, number>;
|
|
444
|
+
for (const r of rows) depths[r.part] = r.depth;
|
|
445
|
+
const nearest = turnNearest(depths);
|
|
446
|
+
if (pivot === null || nearest === null) {
|
|
447
|
+
notes.push('turn: no part carries a See-through depth with any opaque pixel, so no turn control is drafted');
|
|
448
|
+
return { turn: null, notes };
|
|
449
|
+
}
|
|
450
|
+
if (!(pivot > nearest.depth)) {
|
|
451
|
+
notes.push(`turn: every carried part lies at depth ${nearest.depth}, so none is nearer than the pivot and no turn control is drafted`);
|
|
452
|
+
return { turn: null, notes };
|
|
453
|
+
}
|
|
454
|
+
const turn: TurnSpec = { pivot, depths };
|
|
455
|
+
const fr = turnFractions(turn, rows.map((r) => r.part));
|
|
456
|
+
notes.push(
|
|
457
|
+
`turn: control "${TURN_BONE}" under root, keyed by nothing — its x is the dial. Pivot depth ${pivot}: the mean of the ${rows.length} carried parts' See-through depth_median weighted by opaque_px, the one depth at which the painted pixels' centroid does not slide (correct it in turn.pivot). ` +
|
|
458
|
+
`Each part travels (pivot - depth) / (pivot - ${nearest.depth}) of the control's x, "${nearest.part}" (nearest) all of it: ${fr.map((f) => `${f.part} ${f.fraction} (depth ${f.depth})`).join(', ')}`,
|
|
459
|
+
);
|
|
460
|
+
notes.push(...TURN_DEPTH_CAVEAT);
|
|
461
|
+
return { turn, notes };
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** The notes beside every drafted turn (issue #251): what a See-through depth is, where it misleads, and that the two runs' axes are not reconciled. */
|
|
465
|
+
export const TURN_DEPTH_CAVEAT: readonly string[] = [
|
|
466
|
+
"turn: depth is the See-through layer's median and each part takes its own run's axis; a seated or forward-reaching limb can read further back than it is (trailer legwear 0.770 sits behind the pivot)",
|
|
467
|
+
"turn: the two runs' depth axes are not reconciled (parts#271)",
|
|
468
|
+
];
|
|
469
|
+
|
|
470
|
+
/** The proposal with `turn` written after `motion` and `notes` extended; the proposal itself when `turn` is null. */
|
|
471
|
+
export function withTurn(p: Proposal, draft: { turn: TurnSpec | null; notes: string[] }): Proposal {
|
|
472
|
+
return { bones: p.bones, meshes: p.meshes, regions: p.regions, motion: p.motion, ...(draft.turn === null ? {} : { turn: draft.turn }), notes: [...p.notes, ...draft.notes] };
|
|
473
|
+
}
|
|
474
|
+
|
|
326
475
|
// ---------------------------------------------------------------------------
|
|
327
476
|
// the parts, as masks
|
|
328
477
|
// ---------------------------------------------------------------------------
|
|
@@ -1072,6 +1221,9 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
|
|
|
1072
1221
|
B('mouth', 'head', [cc.stats[best].cx, cc.stats[best].cy], placement('mask', `the centroid of the biggest 8-connected blob of ${mouth[0].name}`, { parts: [mouth[0].name] }));
|
|
1073
1222
|
} else B('mouth', 'head', center(mouth[0]), placement('mask', `the centre of ${mouth[0].name}'s box`, { parts: [mouth[0].name], fallback: 'its mask has no opaque pixel, so no blob' }));
|
|
1074
1223
|
}
|
|
1224
|
+
// Issue #250: the head's frame, read off the head run once, written as notes. The bar is the line through the eye
|
|
1225
|
+
// bones' origins as written here: the one roll the rig turns the face bones to (src/headframe.ts).
|
|
1226
|
+
notes.push(...headFrameNotes(P, bones));
|
|
1075
1227
|
// An eye bone comes only from an eyewhite (EYE_GROUP_TAGS). An iris or a
|
|
1076
1228
|
// lash on a side with none has no eye bone to ride, and writing its region
|
|
1077
1229
|
// as `eye_<s>` anyway named a bone that does not exist — refused by the
|
|
@@ -1118,8 +1270,13 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
|
|
|
1118
1270
|
if (Object.keys(still).length > 0) blink.still = still;
|
|
1119
1271
|
if (browBonesProposed.length === 0) notes.push(`blink without brows: no eyebrow part (looked for: ${BROW_GROUP_TAGS.join(', ')}), so the blink has no brows group and no brow_drop`);
|
|
1120
1272
|
}
|
|
1273
|
+
// Issue #252: the breath's travel is BREATH_FRACTION of the torso part's height, not the reference's fixed 1.3 px
|
|
1274
|
+
// amplitude. Its base stays its amplitude (the chest rises from the setup pose and returns). The other three tracks
|
|
1275
|
+
// are an angle and a ratio and stay as drafted. A rig with no torso part keeps the reference's amplitude, and a note.
|
|
1276
|
+
const breath = breathTrack(P);
|
|
1277
|
+
notes.push(breath.note);
|
|
1121
1278
|
tracks.push(
|
|
1122
|
-
|
|
1279
|
+
breath.track,
|
|
1123
1280
|
{ bone: 'chest', prop: 'scalex', amp: 0.004, period: 4.0, phase: 0.0, base: 1.004 },
|
|
1124
1281
|
{ bone: 'head', prop: 'rotate', amp: 0.9, period: 4.0, phase: 0.12 },
|
|
1125
1282
|
{ bone: 'neck', prop: 'rotate', amp: 0.35, period: 4.0, phase: 0.08 },
|
|
@@ -1170,43 +1327,78 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
|
|
|
1170
1327
|
}
|
|
1171
1328
|
|
|
1172
1329
|
// ---- front hair: fringe + locks
|
|
1330
|
+
// Issue #249: the fringe's columns stand in the head's frame — the roll read once, off the eye bones as written
|
|
1331
|
+
// (headRollOf, the one derivation) — and each column's root sits on the part's own art. With no eye pair the
|
|
1332
|
+
// frame is the page's, said in a note.
|
|
1333
|
+
const roll = headRollOf(bones, P.H);
|
|
1334
|
+
const ax = headAxes('none' in roll ? 0 : roll.degrees, P.H);
|
|
1335
|
+
const eyeMid: [number, number] = [axis, eyeY];
|
|
1336
|
+
const turned = 'none' in roll ? 'on the page' : `along the head's down axis (the eye line's perpendicular, roll ${pyFixed(roll.degrees, 3)} degrees)`;
|
|
1337
|
+
const rollBones = 'none' in roll ? [] : ['eye_r', 'eye_l'];
|
|
1173
1338
|
for (const p of P.headFirst('front hair')) {
|
|
1174
1339
|
const [g, r] = gridR(p);
|
|
1175
1340
|
const mask = P.alpha(p);
|
|
1176
1341
|
const top = p.y;
|
|
1177
1342
|
const segs: Segment[] = [['head', rnd(head), rnd([axis, top])]];
|
|
1178
|
-
|
|
1343
|
+
// Depths along the face's down axis from the eye line (through the eye midpoint): the brow line and the tip's.
|
|
1344
|
+
const browD = -0.18 * fh;
|
|
1345
|
+
const tipD = browD + 19;
|
|
1346
|
+
const columns: string[] = [];
|
|
1179
1347
|
for (const [tag, f, dx] of [
|
|
1180
|
-
['bang_r', 0.25, -
|
|
1348
|
+
['bang_r', 0.25, -FRINGE_STEP_PX],
|
|
1181
1349
|
['bang_c', 0.5, 0],
|
|
1182
|
-
['bang_l', 0.75,
|
|
1350
|
+
['bang_l', 0.75, FRINGE_STEP_PX],
|
|
1183
1351
|
] as const) {
|
|
1184
|
-
const
|
|
1352
|
+
const foot = ax.at(eyeMid, fx0 + f * fw - axis, 0);
|
|
1353
|
+
// The root is 13 px into a run of the column's art and above the brow line: the lowest run that holds such a
|
|
1354
|
+
// point. A strand's end under the fringe (demo's centre column has one, 7 px below it) or a lock that starts
|
|
1355
|
+
// at the brow line is not where the fringe's lock starts.
|
|
1356
|
+
const runs = columnRuns(mask, p, ax, foot);
|
|
1357
|
+
const held = runs.filter(([s0, s1]) => s0 + 13 < browD && s0 + 13 <= s1);
|
|
1358
|
+
const artD = held.length === 0 ? null : held[held.length - 1][0];
|
|
1359
|
+
const d0 = (artD ?? boxEntry(p, ax, foot)) + 13;
|
|
1360
|
+
const d1 = (d0 + browD) / 2 + 6;
|
|
1361
|
+
const rootWhy = placement(
|
|
1362
|
+
'mask',
|
|
1363
|
+
`link 0: on the column at ${f} of the face box's width from its left, ${turned}, 13 px below where ${p.name}'s art starts on that column — the lowest run of its art there that holds a point 13 px in and above the brow line (0.18 face heights above the eye line)`,
|
|
1364
|
+
{
|
|
1365
|
+
parts: [p.name],
|
|
1366
|
+
bones: rollBones,
|
|
1367
|
+
frame: ['face_box', 'eye_axis', 'eye_line'],
|
|
1368
|
+
constants: [konst('of_face_width', f), konst('px_below_art_start', 13)],
|
|
1369
|
+
fallback: artD === null ? `no run of ${p.name}'s art on the column holds a point 13 px in above the brow line: 13 px below the top of its box along the column` : null,
|
|
1370
|
+
},
|
|
1371
|
+
);
|
|
1185
1372
|
C(
|
|
1186
1373
|
tag,
|
|
1187
1374
|
'head',
|
|
1375
|
+
[ax.at(foot, 0, d0), ax.at(foot, dx, d1)],
|
|
1376
|
+
ax.at(foot, dx * 1.8, tipD),
|
|
1188
1377
|
[
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
],
|
|
1192
|
-
[x0 + dx * 1.8, browY + 19],
|
|
1193
|
-
[
|
|
1194
|
-
placement('ratio', `link 0: ${f} of the face box's width from its left, 13 px below the top of ${p.name}`, { parts: [p.name], frame: ['face_box'], constants: [konst('of_face_width', f), konst('px_below_part_top', 13)] }),
|
|
1195
|
-
placement('ratio', `link 1: ${dx} px across from link 0, 6 px below half way from link 0's row to the brow line (0.18 face heights above the eye line)`, {
|
|
1378
|
+
rootWhy,
|
|
1379
|
+
placement('ratio', `link 1: ${dx} px across the column from link 0, 6 px below half way from link 0 to the brow line (0.18 face heights above the eye line), ${turned}`, {
|
|
1196
1380
|
parts: [p.name],
|
|
1381
|
+
bones: rollBones,
|
|
1197
1382
|
frame: ['face_box', 'eye_line'],
|
|
1198
1383
|
constants: [konst('px_across', dx), konst('px_below_half_way', 6), konst('brow_line_face_heights_above_eye_line', 0.18)],
|
|
1199
1384
|
}),
|
|
1200
1385
|
],
|
|
1201
|
-
placement('ratio', `${dx * 1.8} px across
|
|
1386
|
+
placement('ratio', `${dx * 1.8} px across the column (1.8 times link 1's step), 19 px below the brow line (0.18 face heights above the eye line), ${turned}`, {
|
|
1387
|
+
bones: rollBones,
|
|
1202
1388
|
frame: ['face_box', 'eye_line'],
|
|
1203
1389
|
constants: [konst('times_link_1_offset', 1.8), konst('px_below_brow_line', 19), konst('brow_line_face_heights_above_eye_line', 0.18)],
|
|
1204
1390
|
}),
|
|
1205
1391
|
);
|
|
1206
1392
|
segs.push(tag);
|
|
1393
|
+
columns.push(tag);
|
|
1207
1394
|
const a = tag === 'bang_c' ? 0.8 : 1.0;
|
|
1208
1395
|
tracks.push({ chain: tag, amps: [a, Number((a * 2.2).toFixed(4))], period: 4.0, phase: 0.18, lag: 0.1 });
|
|
1209
1396
|
}
|
|
1397
|
+
notes.push(
|
|
1398
|
+
`${p.name} (${p.from}): ${columns.join(', ')} are a template, not its locks — columns at 0.25, 0.5 and 0.75 of the face box's width, ${turned}, each rooted on the part's art; ` +
|
|
1399
|
+
`the opaque shape does not separate the locks painted inside one mass of hair, so a lock that runs elsewhere is not followed — move the chains onto the locks` +
|
|
1400
|
+
('none' in roll ? `; no head frame to turn the columns to: ${roll.none}` : ''),
|
|
1401
|
+
);
|
|
1210
1402
|
const below = newMask(P.W, P.H);
|
|
1211
1403
|
below.data.set(mask.data);
|
|
1212
1404
|
const [c0, c1] = pySlice(0, pyInt(chin) + 4, P.H);
|
|
@@ -1666,6 +1858,20 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
|
|
|
1666
1858
|
['hip', rnd(along(waist, trunk.v, s0 + 0.09 * (s1 - s0))), rnd(along(waist, trunk.v, s0 + 0.91 * (s1 - s0)))],
|
|
1667
1859
|
];
|
|
1668
1860
|
}
|
|
1861
|
+
// Issue #262: a skirt whose art starts below the chest bone hangs from the waist. Its root is the hip line's,
|
|
1862
|
+
// and the chest segment goes: with it the breath carries the waistband (a vertex near the chest segment's
|
|
1863
|
+
// lower end takes chest weight), so the root travels with the breath while the hem, on the skirt's chains,
|
|
1864
|
+
// does not. A bottomwear reaching up past the chest (the long robe the hip rule above reads the waist for) has
|
|
1865
|
+
// art on the chest and keeps the segment. Read along the trunk where there is one, down the screen otherwise.
|
|
1866
|
+
const down: readonly [number, number] = trunk === null ? [0, 1] : trunk.u;
|
|
1867
|
+
const artTop = artExtent(mask, p, hip, down)[0];
|
|
1868
|
+
const chestAt = (chest[0] - hip[0]) * down[0] + (chest[1] - hip[1]) * down[1];
|
|
1869
|
+
if (artTop >= chestAt) {
|
|
1870
|
+
segs = segs.filter((s) => typeof s === 'string' || s[0] !== 'chest');
|
|
1871
|
+
notes.push(
|
|
1872
|
+
`${p.name} (${p.from}): its art starts ${pyFixed(artTop - chestAt, 1)} px below chest's origin, so it hangs from the waist — its mesh takes the hip line and its skirt chains, no chest segment, and the breath does not carry its root`,
|
|
1873
|
+
);
|
|
1874
|
+
}
|
|
1669
1875
|
for (const [tag, f, ph] of [
|
|
1670
1876
|
['skirt_r', 0.3, 0.3],
|
|
1671
1877
|
['skirt_c', 0.5, 0.34],
|
|
@@ -1764,7 +1970,40 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
|
|
|
1764
1970
|
}),
|
|
1765
1971
|
);
|
|
1766
1972
|
}
|
|
1767
|
-
|
|
1973
|
+
// Issue #252: the idle is IDLE_FRAMES long, and every period drafted against the reference's 4 s idle keeps its
|
|
1974
|
+
// cycles per idle (REFERENCE_IDLE_SECONDS).
|
|
1975
|
+
const scale = IDLE_SECONDS / REFERENCE_IDLE_SECONDS;
|
|
1976
|
+
const idleTracks = tracks.map((tr) => ({ ...tr, period: tr.period * scale }));
|
|
1977
|
+
return { proposal: { bones, meshes, regions, motion: blink === undefined ? { duration: IDLE_SECONDS, tracks: idleTracks } : { duration: IDLE_SECONDS, tracks: idleTracks, blink }, notes }, basis: { frame, bones: records } };
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
/**
|
|
1981
|
+
* The drafted breath (issue #252): `chest` `translatey`, its travel peak to
|
|
1982
|
+
* peak {@link BREATH_FRACTION} of the height of the {@link BREATH_SIZE_TAG}
|
|
1983
|
+
* layer — the box around every part from it, cut pieces included, since a cut
|
|
1984
|
+
* does not shorten the torso — amplitude half of it to two decimals, and the
|
|
1985
|
+
* base the amplitude. With no such part there is no height to take a fraction
|
|
1986
|
+
* of, and inventing one is not done: the track keeps the reference's
|
|
1987
|
+
* amplitude, {@link REFERENCE_BREATH_AMP}, and the note says so.
|
|
1988
|
+
*/
|
|
1989
|
+
export function breathTrack(P: PartSet): { track: ProposedSingleTrack; note: string } {
|
|
1990
|
+
const parts = P.byTag(BREATH_SIZE_TAG, undefined, true);
|
|
1991
|
+
if (parts.length === 0) {
|
|
1992
|
+
const a = REFERENCE_BREATH_AMP;
|
|
1993
|
+
return {
|
|
1994
|
+
track: { bone: 'chest', prop: 'translatey', amp: a, period: REFERENCE_IDLE_SECONDS, phase: 0.0, base: a },
|
|
1995
|
+
note: `breath: no ${BREATH_SIZE_TAG} part, so no torso height to take a fraction of: chest translatey keeps the reference's amplitude ${a} (${pyFixed(2 * a, 2)} px peak to peak), not ${pyFixed(BREATH_FRACTION * 100, 1)} % of a torso — scale it to the figure`,
|
|
1996
|
+
};
|
|
1997
|
+
}
|
|
1998
|
+
const top = Math.min(...parts.map((p) => p.y));
|
|
1999
|
+
const bottom = Math.max(...parts.map((p) => p.y + p.h));
|
|
2000
|
+
const height = bottom - top;
|
|
2001
|
+
const amp = roundTo((BREATH_FRACTION * height) / 2, 2);
|
|
2002
|
+
const layers = [...new Set(parts.map((p) => p.from))].join(', ');
|
|
2003
|
+
return {
|
|
2004
|
+
track: { bone: 'chest', prop: 'translatey', amp, period: REFERENCE_IDLE_SECONDS, phase: 0.0, base: amp },
|
|
2005
|
+
note: `breath: chest translatey ${pyFixed(2 * amp, 2)} px peak to peak (amp ${pyFixed(amp, 2)}, base ${pyFixed(amp, 2)}) = ${pyFixed(BREATH_FRACTION * 100, 1)} % of the torso's height, ${height} px (layer ${layers}, y ${top}..${bottom}), once per ${IDLE_FRAMES}-frame idle at ${IDLE_FPS} fps`,
|
|
2006
|
+
};
|
|
1768
2007
|
}
|
|
1769
2008
|
|
|
1770
2009
|
// ---------------------------------------------------------------------------
|
|
@@ -2211,6 +2450,178 @@ function pct(v: number): string {
|
|
|
2211
2450
|
return `${Math.round(v * 100)} %`;
|
|
2212
2451
|
}
|
|
2213
2452
|
|
|
2453
|
+
/**
|
|
2454
|
+
* The frontal reference the pitch proportions are written beside (issue #250):
|
|
2455
|
+
* each figure's range, `[min, max]`, over three drawn faces that face front —
|
|
2456
|
+
* `demo` and `scarf` as built here, and the seated character of
|
|
2457
|
+
* https://github.com/rig-c/trailer (its `inputs/`, assembled by this port at
|
|
2458
|
+
* the same plan) — read by {@link headFrame} with a `nose` part added to each
|
|
2459
|
+
* plan, which `assemble --propose-plan` never proposes (the reference's rule):
|
|
2460
|
+
* where the nose sits along eye midpoint -> mouth, demo 0.474, scarf 0.564,
|
|
2461
|
+
* trailer 0.630; the stem against the bar, demo 0.629, scarf 0.675, trailer
|
|
2462
|
+
* 0.510 (each on the eye bones `propose` writes). A drawn face does not carry a human face's ratios (the eyes are
|
|
2463
|
+
* larger and lower, the nose a dot), so no published human model's are used.
|
|
2464
|
+
*
|
|
2465
|
+
* The turn is not written (issue #250): on a face painted three-quarter
|
|
2466
|
+
* toward image left, the stem via the nose (skew −45.9 degrees, frontal
|
|
2467
|
+
* −8.2 to 0.8) and via the mouth (−11.1, frontal −5.3 to 3.6) and the face's
|
|
2468
|
+
* halves (image-left over image-right 0.681, frontal 0.839 to 1.032) all read
|
|
2469
|
+
* it beyond the frontal range on the turned side, but the eyewhites' widths
|
|
2470
|
+
* (0.913) read it inside the frontal range (0.898 to 1.000) — the trailer
|
|
2471
|
+
* character, facing front, reads more turned by that figure than the turned
|
|
2472
|
+
* face. Three readings that do not agree in order are not reported as one.
|
|
2473
|
+
*/
|
|
2474
|
+
export const FRONTAL_REFERENCE = {
|
|
2475
|
+
faces: ['demo', 'scarf', 'the rig-c/trailer character'],
|
|
2476
|
+
noseAlongStem: [0.474, 0.63],
|
|
2477
|
+
stemOverBar: [0.51, 0.675],
|
|
2478
|
+
} as const;
|
|
2479
|
+
|
|
2480
|
+
/** The centroid of a part's biggest 8-connected blob — the mouth bone's rule — or why it has none. */
|
|
2481
|
+
function blobPoint(P: PartSet, tag: string): FrameFeature {
|
|
2482
|
+
const ps = P.headFirst(tag);
|
|
2483
|
+
if (ps.length === 0) return { missing: `no part tagged ${tag}` };
|
|
2484
|
+
const cc = connectedComponents(P.alpha(ps[0]), 8);
|
|
2485
|
+
if (cc.count < 2) return { missing: `${ps[0].name} (${tag}) has no opaque pixel` };
|
|
2486
|
+
let best = 1;
|
|
2487
|
+
for (let i = 2; i < cc.count; i++) if (cc.stats[i].area > cc.stats[best].area) best = i;
|
|
2488
|
+
return { part: ps[0].name, at: [cc.stats[best].cx, cc.stats[best].cy] };
|
|
2489
|
+
}
|
|
2490
|
+
|
|
2491
|
+
/**
|
|
2492
|
+
* The head's roll as the hair chains and their lint read it (issue #249):
|
|
2493
|
+
* {@link eyeLineDegrees} on the origins of the single bones `eye_r` and
|
|
2494
|
+
* `eye_l` as `bones` writes them — the one derivation of the roll, the one
|
|
2495
|
+
* {@link headFrameNotes} prints and the rig turns the face bones to. With no
|
|
2496
|
+
* such pair, or a pair in one column, why there is none.
|
|
2497
|
+
*/
|
|
2498
|
+
export function headRollOf(bones: readonly BoneEntry[], H: number): { degrees: number; eyes: [Point, Point] } | { none: string } {
|
|
2499
|
+
const at = (n: string): Point | null => {
|
|
2500
|
+
const e = bones.find((b) => 'name' in b && b.name === n);
|
|
2501
|
+
return e === undefined || 'chain' in e ? null : e.at;
|
|
2502
|
+
};
|
|
2503
|
+
const r = at('eye_r');
|
|
2504
|
+
const l = at('eye_l');
|
|
2505
|
+
if (r === null || l === null) return { none: `no single bone ${[r === null ? 'eye_r' : null, l === null ? 'eye_l' : null].filter((n) => n !== null).join(' or ')}, so there is no eye line to turn to` };
|
|
2506
|
+
if (r[0] === l[0]) return { none: `eye_r ${fmtAt(r)} and eye_l ${fmtAt(l)} share the column x = ${r[0]}, so the eye line has no left-to-right direction` };
|
|
2507
|
+
return { degrees: eyeLineDegrees(r, l, H), eyes: [r, l] };
|
|
2508
|
+
}
|
|
2509
|
+
|
|
2510
|
+
/**
|
|
2511
|
+
* The px a fringe link of the template steps across its column, image-outward
|
|
2512
|
+
* (issue #249 keeps it; the reference's `dx`): `bang_r`'s first link 10 px
|
|
2513
|
+
* toward image-left, `bang_l`'s toward image-right, each tip 1.8 times that
|
|
2514
|
+
* from its root. It is the widest step across the column the fringe rule
|
|
2515
|
+
* writes in one link (the second steps 0.8 of it), so the off-lock LINT
|
|
2516
|
+
* ({@link lint}) allows a link this much drift across the head's down axis.
|
|
2517
|
+
*/
|
|
2518
|
+
export const FRINGE_STEP_PX = 10;
|
|
2519
|
+
|
|
2520
|
+
/**
|
|
2521
|
+
* The runs of `mask`'s art along a column of the head's frame (issue #249):
|
|
2522
|
+
* the column is the line through `foot` along the face's down axis, and the
|
|
2523
|
+
* pixels it reads are those inside the part's box whose centre lies within
|
|
2524
|
+
* half a pixel of that line. Ordered by the depth of their centre along the
|
|
2525
|
+
* down axis, two pixels whose centres are within sqrt(2) px in depth
|
|
2526
|
+
* (8-connected neighbours at most) are one run. Each run is `[start, end]`:
|
|
2527
|
+
* its first centre's depth less half a pixel and its last's plus half a
|
|
2528
|
+
* pixel — on the page's axes, exactly the top edge of its first row and the
|
|
2529
|
+
* bottom edge of its last. Shallowest first.
|
|
2530
|
+
*/
|
|
2531
|
+
function columnRuns(mask: Mask, box: PartRecord, ax: HeadAxes, foot: readonly [number, number]): Array<[number, number]> {
|
|
2532
|
+
const ds: number[] = [];
|
|
2533
|
+
for (let y = box.y; y < box.y + box.h; y++) {
|
|
2534
|
+
for (let x = box.x; x < box.x + box.w; x++) {
|
|
2535
|
+
if (mask.data[y * mask.width + x] !== 1) continue;
|
|
2536
|
+
const [a, d] = ax.of(foot, [x + 0.5, y + 0.5]);
|
|
2537
|
+
if (Math.abs(a) <= 0.5) ds.push(d);
|
|
2538
|
+
}
|
|
2539
|
+
}
|
|
2540
|
+
ds.sort((p, q) => p - q);
|
|
2541
|
+
const runs: Array<[number, number]> = [];
|
|
2542
|
+
for (let i = 0; i < ds.length; i++) {
|
|
2543
|
+
if (i === 0 || ds[i] - ds[i - 1] > Math.SQRT2) runs.push([ds[i] - 0.5, ds[i] + 0.5]);
|
|
2544
|
+
else runs[runs.length - 1][1] = ds[i] + 0.5;
|
|
2545
|
+
}
|
|
2546
|
+
return runs;
|
|
2547
|
+
}
|
|
2548
|
+
|
|
2549
|
+
/**
|
|
2550
|
+
* The depth along the face's down axis at which the column through `foot`
|
|
2551
|
+
* enters the part's box (issue #249): on the page's axes, the box's top row
|
|
2552
|
+
* less the foot's y, which is where the reference rooted the fringe. A column
|
|
2553
|
+
* that misses the box enters it nowhere, and its corners' least depth stands
|
|
2554
|
+
* in.
|
|
2555
|
+
*/
|
|
2556
|
+
function boxEntry(box: PartRecord, ax: HeadAxes, foot: readonly [number, number]): number {
|
|
2557
|
+
const o = ax.at(foot, 0, 0);
|
|
2558
|
+
const one = ax.at(foot, 0, 1);
|
|
2559
|
+
const n: [number, number] = [one[0] - o[0], one[1] - o[1]];
|
|
2560
|
+
const corners = (): number => Math.min(...[[box.x, box.y], [box.x + box.w, box.y], [box.x, box.y + box.h], [box.x + box.w, box.y + box.h]].map((c) => ax.of(foot, [c[0], c[1]])[1]));
|
|
2561
|
+
let lo = -Infinity;
|
|
2562
|
+
let hi = Infinity;
|
|
2563
|
+
for (const [k, a, b] of [
|
|
2564
|
+
[0, box.x, box.x + box.w],
|
|
2565
|
+
[1, box.y, box.y + box.h],
|
|
2566
|
+
] as const) {
|
|
2567
|
+
if (n[k] === 0) {
|
|
2568
|
+
if (o[k] < a || o[k] > b) return corners();
|
|
2569
|
+
continue;
|
|
2570
|
+
}
|
|
2571
|
+
const t0 = (a - o[k]) / n[k];
|
|
2572
|
+
const t1 = (b - o[k]) / n[k];
|
|
2573
|
+
lo = Math.max(lo, Math.min(t0, t1));
|
|
2574
|
+
hi = Math.min(hi, Math.max(t0, t1));
|
|
2575
|
+
}
|
|
2576
|
+
return lo <= hi ? lo : corners();
|
|
2577
|
+
}
|
|
2578
|
+
|
|
2579
|
+
/** `[min, max]` as `a..b` at 3 places. */
|
|
2580
|
+
function refRange(r: readonly [number, number] | readonly number[]): string {
|
|
2581
|
+
return `${pyFixed(r[0], 3)}..${pyFixed(r[1], 3)}`;
|
|
2582
|
+
}
|
|
2583
|
+
|
|
2584
|
+
/**
|
|
2585
|
+
* The head frame's notes (issue #250): the bar through the eye bones'
|
|
2586
|
+
* origins as `bones` writes them, its roll, the eye midpoint and the
|
|
2587
|
+
* inter-eye distance; then the pitch proportions beside their frontal
|
|
2588
|
+
* reference ({@link FRONTAL_REFERENCE}). The turn is read by
|
|
2589
|
+
* {@link headFrame} but not written (the reference says why). A figure whose
|
|
2590
|
+
* part is missing is named, never estimated.
|
|
2591
|
+
* With no pair of eye bones there is no bar, and one note says why.
|
|
2592
|
+
*/
|
|
2593
|
+
export function headFrameNotes(P: PartSet, bones: readonly BoneEntry[]): string[] {
|
|
2594
|
+
const at = (n: string): Point | null => {
|
|
2595
|
+
const e = bones.find((b) => 'name' in b && b.name === n);
|
|
2596
|
+
return e === undefined || 'chain' in e ? null : e.at;
|
|
2597
|
+
};
|
|
2598
|
+
const r = at('eye_r');
|
|
2599
|
+
const l = at('eye_l');
|
|
2600
|
+
if (r === null || l === null) {
|
|
2601
|
+
const missing = [r === null ? 'eye_r' : null, l === null ? 'eye_l' : null].filter((n) => n !== null).join(' and ');
|
|
2602
|
+
return [`head frame: not read — no ${missing} bone (an eye bone comes only from an eyewhite), so there is no bar to read the roll, the turn or the pitch against`];
|
|
2603
|
+
}
|
|
2604
|
+
if (r[0] === l[0]) return [`head frame: not read — eye_r ${fmtAt(r)} and eye_l ${fmtAt(l)} share the column x = ${r[0]}, so the bar has no left-to-right direction`];
|
|
2605
|
+
const ew = (['eyewhite-r', 'eyewhite-l'] as const).map((t, i) => {
|
|
2606
|
+
const p = P.byTag(t)[0];
|
|
2607
|
+
return { part: p.name, mask: P.alpha(p), eye: i === 0 ? r : l };
|
|
2608
|
+
});
|
|
2609
|
+
const facePart = P.headFirst('face')[0];
|
|
2610
|
+
const hf: HeadFrame = headFrame([r, l], P.H, { nose: blobPoint(P, 'nose'), mouth: blobPoint(P, 'mouth') }, ew, facePart === undefined ? { missing: 'no part tagged face' } : { part: facePart.name, mask: P.alpha(facePart) });
|
|
2611
|
+
const f3 = (v: number): string => pyFixed(v, 3);
|
|
2612
|
+
const [el, er] = hf.eyes;
|
|
2613
|
+
const bar =
|
|
2614
|
+
`head frame: the bar runs through ${el === l ? 'eye_l' : 'eye_r'} ${fmtAt(el)} and ${er === l ? 'eye_l' : 'eye_r'} ${fmtAt(er)}, the eye bones' origins, ${f3(hf.bar)} px; ` +
|
|
2615
|
+
`roll ${f3(hf.roll)} degrees (Spine's, counter-clockwise) — the line the rig turns the face bones to and declares as invariants.axes; eye midpoint ${fmtAt(hf.mid)}`;
|
|
2616
|
+
const ref = FRONTAL_REFERENCE;
|
|
2617
|
+
const nose = hf.nose;
|
|
2618
|
+
const mouth = hf.mouth;
|
|
2619
|
+
const pitch =
|
|
2620
|
+
`head pitch: the nose sits ${hf.noseAlongStem === null ? `— (not read: ${'missing' in nose ? nose.missing : 'missing' in mouth ? mouth.missing : 'the mouth sits on the bar'})` : f3(hf.noseAlongStem)} of the way from the eye midpoint to the mouth (frontal ${refRange(ref.noseAlongStem)}); ` +
|
|
2621
|
+
`the stem, eye midpoint -> mouth, is ${'missing' in mouth ? `— (not read: ${mouth.missing})` : `${f3(mouth.down)} of the bar`} (frontal ${refRange(ref.stemOverBar)}); frontal ranges read on ${ref.faces.join(', ')}; the turn is not reported (src/propose.ts FRONTAL_REFERENCE says why)`;
|
|
2622
|
+
return [bar, pitch];
|
|
2623
|
+
}
|
|
2624
|
+
|
|
2214
2625
|
/**
|
|
2215
2626
|
* The blink squashes each eye's parts about the eye bone, so anything painted
|
|
2216
2627
|
* into the eyelash layer above the lid — a double-eyelid crease — is squashed
|
|
@@ -2299,6 +2710,26 @@ export interface OffArtFinding {
|
|
|
2299
2710
|
at: [number, number];
|
|
2300
2711
|
}
|
|
2301
2712
|
|
|
2713
|
+
/**
|
|
2714
|
+
* A link of a lock the head carries whose line leaves the lock's run (issue
|
|
2715
|
+
* #249): it drifts across the head's down axis by more than the fringe
|
|
2716
|
+
* template's widest step, {@link FRINGE_STEP_PX}, plus a pixel's rounding at
|
|
2717
|
+
* each end. `degrees` is the link's angle off that axis and `limit` the angle
|
|
2718
|
+
* that drift allows over the link's reach down the axis; a link that does not
|
|
2719
|
+
* reach down the axis at all has no limit (`null`) and is named whatever its
|
|
2720
|
+
* angle.
|
|
2721
|
+
*/
|
|
2722
|
+
export interface OffLockFinding {
|
|
2723
|
+
kind: 'off-lock';
|
|
2724
|
+
bone: string;
|
|
2725
|
+
mesh: string;
|
|
2726
|
+
from: [number, number];
|
|
2727
|
+
to: [number, number];
|
|
2728
|
+
degrees: number;
|
|
2729
|
+
limit: number | null;
|
|
2730
|
+
roll: number;
|
|
2731
|
+
}
|
|
2732
|
+
|
|
2302
2733
|
/** The hip at or above the chest: breathing and every skirt chain hang from the wrong end of the torso. */
|
|
2303
2734
|
export interface HipAboveChestFinding {
|
|
2304
2735
|
kind: 'hip-not-below-chest';
|
|
@@ -2358,7 +2789,7 @@ export interface ChainNotAdvancingFinding {
|
|
|
2358
2789
|
toJoint: KeypointName;
|
|
2359
2790
|
}
|
|
2360
2791
|
|
|
2361
|
-
export type LintFinding = OffArtFinding | HipAboveChestFinding | HipTooHighFinding | ChestNotBetweenFinding | HipNotPastChestFinding | HipNearerNeckFinding | ChainNotAdvancingFinding;
|
|
2792
|
+
export type LintFinding = OffArtFinding | OffLockFinding | HipAboveChestFinding | HipTooHighFinding | ChestNotBetweenFinding | HipNotPastChestFinding | HipNearerNeckFinding | ChainNotAdvancingFinding;
|
|
2362
2793
|
|
|
2363
2794
|
/** Which rule set read the torso and the sleeve chains, under `--keypoints`. */
|
|
2364
2795
|
export interface LintBasis {
|
|
@@ -2372,6 +2803,8 @@ export interface LintResult {
|
|
|
2372
2803
|
findings: LintFinding[];
|
|
2373
2804
|
/** Meshes naming no part in parts.json: nothing to lint them against. */
|
|
2374
2805
|
unknownMeshes: string[];
|
|
2806
|
+
/** Why the off-lock check (issue #249) read nothing though a head-run front hair mesh names a chain hung from head: no head frame, or no face box to find the chin by. Empty when it ran or had nothing to read. */
|
|
2807
|
+
lockUnread: string[];
|
|
2375
2808
|
/** Single bones the torso checks read (`hip`, `chest`) that the bones do not hold: those checks did not run. */
|
|
2376
2809
|
missingTorsoBones: string[];
|
|
2377
2810
|
/** Present only under `--keypoints`: the rule set that read the torso and the chains. */
|
|
@@ -2416,6 +2849,17 @@ export function coverageOutcome(c: BoneCoverage): CoverageOutcome {
|
|
|
2416
2849
|
* the links `<segment><digits>`, so a single bone named as a segment has none,
|
|
2417
2850
|
* and an explicit `[bone, from, to]` segment is a line, not an origin.
|
|
2418
2851
|
*
|
|
2852
|
+
* Then the fringe (issue #249): every chain hung from `head` that a head-run
|
|
2853
|
+
* `front hair` part's mesh names, its tip no lower than the chin (the face
|
|
2854
|
+
* box's bottom centre) along the head's down axis — the eye line's
|
|
2855
|
+
* perpendicular, read by {@link headRollOf} — is a lock the head carries,
|
|
2856
|
+
* and a link of it that drifts across that axis by more than
|
|
2857
|
+
* {@link FRINGE_STEP_PX} plus sqrt(2) px (the template's widest step, and
|
|
2858
|
+
* each end rounded to whole px) is named, with its angle and the angle its
|
|
2859
|
+
* reach allows. A strand that hangs past the chin hangs with its weight and
|
|
2860
|
+
* is not held to the head. With no head frame or no face box, `lockUnread`
|
|
2861
|
+
* says why nothing was read.
|
|
2862
|
+
*
|
|
2419
2863
|
* Then the torso, which the reference did not lint: a single bone `hip` that
|
|
2420
2864
|
* is not below a single bone `chest` (larger y), and a `hip` above
|
|
2421
2865
|
* `HIP_MIN_FRACTION` of the figure's height (every part's union, top row to
|
|
@@ -2474,6 +2918,52 @@ export function lint(
|
|
|
2474
2918
|
if (!isOn(mask, x, y)) findings.push({ kind: 'off-art', bone: n, mesh: mname, at: [x, y] });
|
|
2475
2919
|
}
|
|
2476
2920
|
}
|
|
2921
|
+
// Issue #249: a lock the head carries runs along the head's down axis. The links read are those of every chain
|
|
2922
|
+
// hung from head that a head-run front hair part's mesh names, when the chain's tip lies no lower along that axis
|
|
2923
|
+
// than the chin (the face box's bottom centre): a strand that hangs past the chin hangs with its weight, not with
|
|
2924
|
+
// the head, and is not held to the head's axis. The roll is headRollOf's, the one derivation.
|
|
2925
|
+
const lockUnread: string[] = [];
|
|
2926
|
+
const lockMeshes = Object.entries(spec.meshes).filter(([mname]) => {
|
|
2927
|
+
const part = parts.get(mname);
|
|
2928
|
+
return part !== undefined && splitFrom(part.from)[0] === 'head' && splitFrom(part.from)[1] === 'front hair';
|
|
2929
|
+
});
|
|
2930
|
+
const lockChains = lockMeshes.flatMap(([mname, m]) =>
|
|
2931
|
+
m.segments.flatMap((sp) => {
|
|
2932
|
+
if (typeof sp !== 'string') return [];
|
|
2933
|
+
const e = spec.bones.find((b) => 'chain' in b && b.chain === sp);
|
|
2934
|
+
return e !== undefined && 'chain' in e && e.parent === 'head' ? [{ mesh: mname, entry: e }] : [];
|
|
2935
|
+
}),
|
|
2936
|
+
);
|
|
2937
|
+
if (lockChains.length > 0) {
|
|
2938
|
+
const roll = headRollOf(spec.bones, P.H);
|
|
2939
|
+
let chin: [number, number] | null = null;
|
|
2940
|
+
try {
|
|
2941
|
+
const [fx0, fy0, fw, fh] = faceBoxOf(P).box;
|
|
2942
|
+
chin = [fx0 + fw / 2, fy0 + fh];
|
|
2943
|
+
} catch (err) {
|
|
2944
|
+
if (!(err instanceof PartsError)) throw err;
|
|
2945
|
+
lockUnread.push(`no face box to find the chin by (${err.message.split('\n')[0]}), so no chain was read as a lock the head carries`);
|
|
2946
|
+
}
|
|
2947
|
+
if ('none' in roll) lockUnread.push(`${roll.none}, so no chain was read against the head's down axis`);
|
|
2948
|
+
else if (chin !== null) {
|
|
2949
|
+
const ax = headAxes(roll.degrees, P.H);
|
|
2950
|
+
const mid: [number, number] = [(roll.eyes[0][0] + roll.eyes[1][0]) / 2, (roll.eyes[0][1] + roll.eyes[1][1]) / 2];
|
|
2951
|
+
const chinD = ax.of(mid, chin)[1];
|
|
2952
|
+
for (const { mesh, entry } of lockChains) {
|
|
2953
|
+
if (ax.of(mid, entry.tip)[1] > chinD) continue;
|
|
2954
|
+
const pts = [...entry.points, entry.tip];
|
|
2955
|
+
entry.points.forEach((q, i) => {
|
|
2956
|
+
const bone = `${entry.chain}${i}`;
|
|
2957
|
+
readBy(bone, `off-lock on mesh ${pyRepr(mesh)}`);
|
|
2958
|
+
const [across, down] = ax.of(q, pts[i + 1]);
|
|
2959
|
+
const degrees = (Math.atan2(Math.abs(across), down) * 180) / Math.PI;
|
|
2960
|
+
// A drift of FRINGE_STEP_PX across, and each end within sqrt(2)/2 px of the point it was rounded from.
|
|
2961
|
+
const limit = down > 0 ? (Math.atan2(FRINGE_STEP_PX + Math.SQRT2, down) * 180) / Math.PI : null;
|
|
2962
|
+
if (limit === null || degrees > limit) findings.push({ kind: 'off-lock', bone, mesh, from: [q[0], q[1]], to: [pts[i + 1][0], pts[i + 1][1]], degrees, limit, roll: roll.degrees });
|
|
2963
|
+
});
|
|
2964
|
+
}
|
|
2965
|
+
}
|
|
2966
|
+
}
|
|
2477
2967
|
const single = (n: string): [number, number] | null => {
|
|
2478
2968
|
const e = spec.bones.find((b) => 'name' in b && b.name === n);
|
|
2479
2969
|
return e !== undefined && 'name' in e ? [e.at[0], e.at[1]] : null;
|
|
@@ -2525,13 +3015,14 @@ export function lint(
|
|
|
2525
3015
|
}
|
|
2526
3016
|
}
|
|
2527
3017
|
const coverage = lintCoverage(spec, byName, read, findings, unknownMeshes, basis);
|
|
2528
|
-
return basis === undefined ? { findings, unknownMeshes, missingTorsoBones, coverage } : { findings, unknownMeshes, missingTorsoBones, basis, coverage };
|
|
3018
|
+
return basis === undefined ? { findings, unknownMeshes, lockUnread, missingTorsoBones, coverage } : { findings, unknownMeshes, lockUnread, missingTorsoBones, basis, coverage };
|
|
2529
3019
|
}
|
|
2530
3020
|
|
|
2531
3021
|
/** 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. */
|
|
2532
3022
|
function findingBones(f: LintFinding, bones: readonly BoneEntry[]): string[] {
|
|
2533
3023
|
switch (f.kind) {
|
|
2534
3024
|
case 'off-art':
|
|
3025
|
+
case 'off-lock':
|
|
2535
3026
|
return [f.bone];
|
|
2536
3027
|
case 'hip-not-below-chest':
|
|
2537
3028
|
case 'hip-not-past-chest':
|
|
@@ -2688,6 +3179,14 @@ export function lintLine(f: LintFinding): string {
|
|
|
2688
3179
|
switch (f.kind) {
|
|
2689
3180
|
case 'off-art':
|
|
2690
3181
|
return `LINT ${f.bone} at ${pyIntList(f.at)} is off the art of mesh ${pyRepr(f.mesh)}`;
|
|
3182
|
+
case 'off-lock':
|
|
3183
|
+
return (
|
|
3184
|
+
`LINT ${f.bone} at ${pyIntList(f.from)} runs to ${pyIntList(f.to)} ${pyFixed(f.degrees, 1)} degrees off the head's down axis (the eye line's perpendicular, roll ${pyFixed(f.roll, 1)}), ` +
|
|
3185
|
+
(f.limit === null
|
|
3186
|
+
? 'and does not reach down it at all'
|
|
3187
|
+
: `more than the ${pyFixed(f.limit, 1)} its reach allows (a drift of ${FRINGE_STEP_PX} px across, the fringe template's widest step, and a pixel's rounding at each end)`) +
|
|
3188
|
+
`: a lock of mesh ${pyRepr(f.mesh)} the head carries runs along the head, and this link leaves it`
|
|
3189
|
+
);
|
|
2691
3190
|
case 'hip-not-below-chest':
|
|
2692
3191
|
return `LINT hip at ${pyIntList(f.hip)} is not below chest at ${pyIntList(f.chest)}: the hip must have the larger y, or breathing and the skirt hang from the shoulders`;
|
|
2693
3192
|
case 'hip-too-high':
|