@rig-c/parts 1.6.0 → 1.8.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 +16 -11
- package/cli.ts +17 -6
- package/docs/AUTHORING.md +65 -16
- package/docs/STABILITY.md +1 -1
- package/package.json +2 -2
- package/src/assemble.ts +199 -16
- package/src/build.ts +172 -11
- package/src/check.ts +7 -4
- package/src/headframe.ts +207 -0
- package/src/motion.ts +20 -3
- package/src/parts.ts +2 -2
- package/src/propose.ts +687 -27
- package/src/rig.ts +322 -12
- package/src/scene.ts +14 -1
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
|
|
@@ -49,7 +52,7 @@
|
|
|
49
52
|
* chains a second time. The one exception is the sleeve rule, which names
|
|
50
53
|
* its chains by side and takes a cut sleeve as a blob of its own (#199). A
|
|
51
54
|
* `parts.json` sharing a layer between parts with no `cut_from` (an
|
|
52
|
-
* assemble before 1.
|
|
55
|
+
* assemble before 1.6.0) is refused, `PROPOSE_CUT_FROM_RECORDED`.
|
|
53
56
|
*
|
|
54
57
|
* What it cannot know is written into `notes` when it guessed, and otherwise
|
|
55
58
|
* left to the person correcting: things hidden inside a layer (a sash tail
|
|
@@ -79,6 +82,26 @@
|
|
|
79
82
|
* never moved by a joint. `lint` then reads the torso and the sleeves by the
|
|
80
83
|
* relations the joints declare instead of by screen y. Without joints every
|
|
81
84
|
* value, note and LINT line is the one written before the option existed.
|
|
85
|
+
* One rule runs only with joints (issue #241): a `legwear` part holding the
|
|
86
|
+
* ankle of a leg whose hip, knee and ankle are given is meshed on a leg chain
|
|
87
|
+
* (hip -> knee, tip at the ankle; one per leg, or one through the midpoints
|
|
88
|
+
* when the part's art does not separate the legs — {@link legsFromJoints}),
|
|
89
|
+
* where without them it rides `hip` as a region. And with a joint at either
|
|
90
|
+
* end of the trunk (issue #243), the trunk parts' fixed segments run along
|
|
91
|
+
* neck -> hip, the line the chest is placed on, instead of down the screen
|
|
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`.
|
|
82
105
|
*
|
|
83
106
|
* Issue #86 adds what the proposer and lint looked at, and changes nothing
|
|
84
107
|
* they wrote: {@link proposeWithBasis} records, at each placement, what the
|
|
@@ -93,6 +116,7 @@ import { join } from 'node:path';
|
|
|
93
116
|
import { drawText, GLYPH_H } from 'rig-c/tools/font5x7.ts';
|
|
94
117
|
import { type BoneEntry, type ConfigConstraint, type MeshSpec, parseConfig, type Point, type Segment, type SkeletonSections } from './config.ts';
|
|
95
118
|
import { PartsError, type Problem, refuseIfAny } from './errors.ts';
|
|
119
|
+
import { eyeLineDegrees, type FrameFeature, headAxes, type HeadAxes, headFrame, type HeadFrame } from './headframe.ts';
|
|
96
120
|
import { OPAQUE_ALPHA_ABOVE } from './layers.ts';
|
|
97
121
|
import { PAINTING_RUN, type PartRecord, type PartsFile, readParts } from './parts.ts';
|
|
98
122
|
import { connectedComponents } from './raster/components.ts';
|
|
@@ -102,6 +126,8 @@ import { resize } from './raster/resize.ts';
|
|
|
102
126
|
import { type Mask, newMask, newRaster, type Raster } from './raster/types.ts';
|
|
103
127
|
import { npMean, npMedian, npPercentile, pyFixed, pyInt, pyIntList, pyRepr, pyRound, pyStrList } from './pyfmt.ts';
|
|
104
128
|
import type { JointState, RigJoint, RigJoints } from './keypoints.ts';
|
|
129
|
+
import { IDLE_FPS } from './motion.ts';
|
|
130
|
+
import { pyRound as roundTo } from './round.ts';
|
|
105
131
|
import { KEYPOINT_NAMES, type KeypointName } from './skeleton.ts';
|
|
106
132
|
import { configRoles, type RoleOf } from './structure.ts';
|
|
107
133
|
|
|
@@ -141,6 +167,41 @@ export const EYE_GROUP_TAGS: readonly string[] = ['eyewhite-r', 'eyewhite-l'];
|
|
|
141
167
|
/** The tags whose parts make the brow bones, the blink's `brows` group. */
|
|
142
168
|
export const BROW_GROUP_TAGS: readonly string[] = ['eyebrow-r', 'eyebrow-l'];
|
|
143
169
|
|
|
170
|
+
/**
|
|
171
|
+
* The drafted idle's length, in frames at {@link IDLE_FPS} (issue #252): 40
|
|
172
|
+
* frames, 40/12 s. The reference drafted a 4 s idle; HQ calibrated the breath
|
|
173
|
+
* over 40 frames at 12 fps, and a duration of a whole number of frames is what
|
|
174
|
+
* `check`'s loop compares frame 0 with (issue #244).
|
|
175
|
+
*/
|
|
176
|
+
export const IDLE_FRAMES = 40;
|
|
177
|
+
/** {@link IDLE_FRAMES} in seconds: 3.3333333333333335 as a double, the time rigc's last frame is read at within `TIME_SLACK`. */
|
|
178
|
+
export const IDLE_SECONDS = IDLE_FRAMES / IDLE_FPS;
|
|
179
|
+
/**
|
|
180
|
+
* The idle the reference drafted, in seconds. Every drafted track's period is
|
|
181
|
+
* written against it (4 s: one cycle per idle; 2 s: two) and scaled by
|
|
182
|
+
* `IDLE_SECONDS / REFERENCE_IDLE_SECONDS` when the proposal is returned, so
|
|
183
|
+
* each keeps its number of cycles per idle — the loader refuses a period that
|
|
184
|
+
* does not divide the idle (`CONFIG_PERIOD_DIVIDES_DURATION`). The scale is
|
|
185
|
+
* 10/12 = 0.8333333333333334, and 4 and 2 times it are 40/12 and 20/12 to the
|
|
186
|
+
* last bit, since only a power of two multiplies it.
|
|
187
|
+
*/
|
|
188
|
+
export const REFERENCE_IDLE_SECONDS = 4;
|
|
189
|
+
/**
|
|
190
|
+
* The breath's travel, peak to peak, as a fraction of the torso part's height
|
|
191
|
+
* (issue #252). The size is the height because the breath is a `translatey`:
|
|
192
|
+
* its travel is read along y, against the part's extent along y. HQ's
|
|
193
|
+
* calibration: about 2.5 %. On the trailer's seated character the torso part
|
|
194
|
+
* (`topwear`) is 267 px tall and the reference's fixed 2.6 px peak to peak
|
|
195
|
+
* (amplitude 1.3, the base 1.3 so the chest rises from its setup pose and
|
|
196
|
+
* returns) was 0.97 % of it, read by its owner as a still paper doll; the
|
|
197
|
+
* production scene rig that replaced it breathes 6.4 px, 2.4 %.
|
|
198
|
+
*/
|
|
199
|
+
export const BREATH_FRACTION = 0.025;
|
|
200
|
+
/** The breath's amplitude the reference drafted on every figure, kept only where there is no torso part to scale by. */
|
|
201
|
+
export const REFERENCE_BREATH_AMP = 1.3;
|
|
202
|
+
/** The See-through tag of the torso part, whose height the breath is a fraction of. */
|
|
203
|
+
export const BREATH_SIZE_TAG = 'topwear';
|
|
204
|
+
|
|
144
205
|
export interface Proposal {
|
|
145
206
|
bones: BoneEntry[];
|
|
146
207
|
meshes: Record<string, MeshSpec>;
|
|
@@ -259,7 +320,7 @@ export function proposalAssemble(P: PartSet): Record<string, unknown> {
|
|
|
259
320
|
// loader only type-checks like the patches' fields. The piece names its base
|
|
260
321
|
// (`cut_from`, issue #230); file order cannot, as a piece drawn "back" comes
|
|
261
322
|
// before its base. A file with an unmarked shared layer (an assemble before
|
|
262
|
-
// 1.
|
|
323
|
+
// 1.6.0, which propose refuses) goes back with the first part of the layer
|
|
263
324
|
// as the plan part, as it did before the field.
|
|
264
325
|
const run = P.recs.filter((r) => splitFrom(r.from)[0] !== PAINTING_RUN);
|
|
265
326
|
const painted = P.recs.filter((r) => splitFrom(r.from)[0] === PAINTING_RUN);
|
|
@@ -349,7 +410,7 @@ export class PartSet {
|
|
|
349
410
|
|
|
350
411
|
/**
|
|
351
412
|
* The layers more than one part holds with no `cut_from` to tell the piece
|
|
352
|
-
* from its base: a `parts.json` an `assemble` before 1.
|
|
413
|
+
* from its base: a `parts.json` an `assemble` before 1.6.0 wrote with
|
|
353
414
|
* `assemble.cuts`. parts.json is in draw order and a piece drawn `"back"`
|
|
354
415
|
* comes before its base, so which part takes the layer's rule cannot be
|
|
355
416
|
* read off the file. Each entry is the layer and its parts, in file order.
|
|
@@ -877,7 +938,7 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
|
|
|
877
938
|
P.unmarkedShares().map(({ from, parts }) => ({
|
|
878
939
|
code: 'PROPOSE_CUT_FROM_RECORDED',
|
|
879
940
|
object: `parts ${parts.map((n) => `"${n}"`).join(', ')} (${from})`,
|
|
880
|
-
detail: `share one layer and none of them records cut_from, so which is the plan part and which the piece an assemble.cuts entry took cannot be read off parts.json (it is in draw order, and a piece drawn "back" comes before its base); re-run assemble (1.
|
|
941
|
+
detail: `share one layer and none of them records cut_from, so which is the plan part and which the piece an assemble.cuts entry took cannot be read off parts.json (it is in draw order, and a piece drawn "back" comes before its base); re-run assemble (1.6.0+) to record cut_from`,
|
|
881
942
|
})),
|
|
882
943
|
);
|
|
883
944
|
const bones: BoneEntry[] = [];
|
|
@@ -996,6 +1057,13 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
|
|
|
996
1057
|
// down the screen, because the torso need not be upright.
|
|
997
1058
|
const chestAlong = neckJ !== null || hipFromJoints;
|
|
998
1059
|
const chest: [number, number] = chestAlong ? [(neckAt[0] + hip[0]) / 2, (neckAt[1] + hip[1]) / 2] : [hip[0], neckY + 0.5 * (hip[1] - neckY)];
|
|
1060
|
+
// Issue #243: on that same line, neck -> hip, the trunk parts' fixed segments are laid too (neck, topwear,
|
|
1061
|
+
// bottomwear; {@link trunkSegments}), not down the screen through the bones' x. Without a joint at either end,
|
|
1062
|
+
// today's verticals; with the two ends on one point the line has no direction, and a note says the verticals stay.
|
|
1063
|
+
const trunk = chestAlong ? trunkLineOf(neckAt, hip) : null;
|
|
1064
|
+
if (chestAlong && trunk === null) {
|
|
1065
|
+
notes.push(`trunk segments: neck and hip are one point, ${fmtAt(hip)}, so neck -> hip declares no line; the trunk parts' segments are drafted down the screen, as without keypoints`);
|
|
1066
|
+
}
|
|
999
1067
|
// Each placement hands its basis with its value: one record per bone, in the order the bones are written.
|
|
1000
1068
|
const B = (name: string, parent: string, at: readonly [number, number], why: Placement, tip?: readonly [number, number], tipWhy?: Placement): void => {
|
|
1001
1069
|
bones.push(tip === undefined ? { name, parent, at: rnd(at) } : { name, parent, at: rnd(at), tip: rnd(tip) });
|
|
@@ -1057,6 +1125,9 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
|
|
|
1057
1125
|
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] }));
|
|
1058
1126
|
} 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' }));
|
|
1059
1127
|
}
|
|
1128
|
+
// Issue #250: the head's frame, read off the head run once, written as notes. The bar is the line through the eye
|
|
1129
|
+
// bones' origins as written here: the one roll the rig turns the face bones to (src/headframe.ts).
|
|
1130
|
+
notes.push(...headFrameNotes(P, bones));
|
|
1060
1131
|
// An eye bone comes only from an eyewhite (EYE_GROUP_TAGS). An iris or a
|
|
1061
1132
|
// lash on a side with none has no eye bone to ride, and writing its region
|
|
1062
1133
|
// as `eye_<s>` anyway named a bone that does not exist — refused by the
|
|
@@ -1103,8 +1174,13 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
|
|
|
1103
1174
|
if (Object.keys(still).length > 0) blink.still = still;
|
|
1104
1175
|
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`);
|
|
1105
1176
|
}
|
|
1177
|
+
// Issue #252: the breath's travel is BREATH_FRACTION of the torso part's height, not the reference's fixed 1.3 px
|
|
1178
|
+
// amplitude. Its base stays its amplitude (the chest rises from the setup pose and returns). The other three tracks
|
|
1179
|
+
// are an angle and a ratio and stay as drafted. A rig with no torso part keeps the reference's amplitude, and a note.
|
|
1180
|
+
const breath = breathTrack(P);
|
|
1181
|
+
notes.push(breath.note);
|
|
1106
1182
|
tracks.push(
|
|
1107
|
-
|
|
1183
|
+
breath.track,
|
|
1108
1184
|
{ bone: 'chest', prop: 'scalex', amp: 0.004, period: 4.0, phase: 0.0, base: 1.004 },
|
|
1109
1185
|
{ bone: 'head', prop: 'rotate', amp: 0.9, period: 4.0, phase: 0.12 },
|
|
1110
1186
|
{ bone: 'neck', prop: 'rotate', amp: 0.35, period: 4.0, phase: 0.08 },
|
|
@@ -1155,43 +1231,78 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
|
|
|
1155
1231
|
}
|
|
1156
1232
|
|
|
1157
1233
|
// ---- front hair: fringe + locks
|
|
1234
|
+
// Issue #249: the fringe's columns stand in the head's frame — the roll read once, off the eye bones as written
|
|
1235
|
+
// (headRollOf, the one derivation) — and each column's root sits on the part's own art. With no eye pair the
|
|
1236
|
+
// frame is the page's, said in a note.
|
|
1237
|
+
const roll = headRollOf(bones, P.H);
|
|
1238
|
+
const ax = headAxes('none' in roll ? 0 : roll.degrees, P.H);
|
|
1239
|
+
const eyeMid: [number, number] = [axis, eyeY];
|
|
1240
|
+
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)`;
|
|
1241
|
+
const rollBones = 'none' in roll ? [] : ['eye_r', 'eye_l'];
|
|
1158
1242
|
for (const p of P.headFirst('front hair')) {
|
|
1159
1243
|
const [g, r] = gridR(p);
|
|
1160
1244
|
const mask = P.alpha(p);
|
|
1161
1245
|
const top = p.y;
|
|
1162
1246
|
const segs: Segment[] = [['head', rnd(head), rnd([axis, top])]];
|
|
1163
|
-
|
|
1247
|
+
// Depths along the face's down axis from the eye line (through the eye midpoint): the brow line and the tip's.
|
|
1248
|
+
const browD = -0.18 * fh;
|
|
1249
|
+
const tipD = browD + 19;
|
|
1250
|
+
const columns: string[] = [];
|
|
1164
1251
|
for (const [tag, f, dx] of [
|
|
1165
|
-
['bang_r', 0.25, -
|
|
1252
|
+
['bang_r', 0.25, -FRINGE_STEP_PX],
|
|
1166
1253
|
['bang_c', 0.5, 0],
|
|
1167
|
-
['bang_l', 0.75,
|
|
1254
|
+
['bang_l', 0.75, FRINGE_STEP_PX],
|
|
1168
1255
|
] as const) {
|
|
1169
|
-
const
|
|
1256
|
+
const foot = ax.at(eyeMid, fx0 + f * fw - axis, 0);
|
|
1257
|
+
// 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
|
|
1258
|
+
// point. A strand's end under the fringe (demo's centre column has one, 7 px below it) or a lock that starts
|
|
1259
|
+
// at the brow line is not where the fringe's lock starts.
|
|
1260
|
+
const runs = columnRuns(mask, p, ax, foot);
|
|
1261
|
+
const held = runs.filter(([s0, s1]) => s0 + 13 < browD && s0 + 13 <= s1);
|
|
1262
|
+
const artD = held.length === 0 ? null : held[held.length - 1][0];
|
|
1263
|
+
const d0 = (artD ?? boxEntry(p, ax, foot)) + 13;
|
|
1264
|
+
const d1 = (d0 + browD) / 2 + 6;
|
|
1265
|
+
const rootWhy = placement(
|
|
1266
|
+
'mask',
|
|
1267
|
+
`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)`,
|
|
1268
|
+
{
|
|
1269
|
+
parts: [p.name],
|
|
1270
|
+
bones: rollBones,
|
|
1271
|
+
frame: ['face_box', 'eye_axis', 'eye_line'],
|
|
1272
|
+
constants: [konst('of_face_width', f), konst('px_below_art_start', 13)],
|
|
1273
|
+
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,
|
|
1274
|
+
},
|
|
1275
|
+
);
|
|
1170
1276
|
C(
|
|
1171
1277
|
tag,
|
|
1172
1278
|
'head',
|
|
1279
|
+
[ax.at(foot, 0, d0), ax.at(foot, dx, d1)],
|
|
1280
|
+
ax.at(foot, dx * 1.8, tipD),
|
|
1173
1281
|
[
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
],
|
|
1177
|
-
[x0 + dx * 1.8, browY + 19],
|
|
1178
|
-
[
|
|
1179
|
-
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)] }),
|
|
1180
|
-
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)`, {
|
|
1282
|
+
rootWhy,
|
|
1283
|
+
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}`, {
|
|
1181
1284
|
parts: [p.name],
|
|
1285
|
+
bones: rollBones,
|
|
1182
1286
|
frame: ['face_box', 'eye_line'],
|
|
1183
1287
|
constants: [konst('px_across', dx), konst('px_below_half_way', 6), konst('brow_line_face_heights_above_eye_line', 0.18)],
|
|
1184
1288
|
}),
|
|
1185
1289
|
],
|
|
1186
|
-
placement('ratio', `${dx * 1.8} px across
|
|
1290
|
+
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}`, {
|
|
1291
|
+
bones: rollBones,
|
|
1187
1292
|
frame: ['face_box', 'eye_line'],
|
|
1188
1293
|
constants: [konst('times_link_1_offset', 1.8), konst('px_below_brow_line', 19), konst('brow_line_face_heights_above_eye_line', 0.18)],
|
|
1189
1294
|
}),
|
|
1190
1295
|
);
|
|
1191
1296
|
segs.push(tag);
|
|
1297
|
+
columns.push(tag);
|
|
1192
1298
|
const a = tag === 'bang_c' ? 0.8 : 1.0;
|
|
1193
1299
|
tracks.push({ chain: tag, amps: [a, Number((a * 2.2).toFixed(4))], period: 4.0, phase: 0.18, lag: 0.1 });
|
|
1194
1300
|
}
|
|
1301
|
+
notes.push(
|
|
1302
|
+
`${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; ` +
|
|
1303
|
+
`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` +
|
|
1304
|
+
('none' in roll ? `; no head frame to turn the columns to: ${roll.none}` : ''),
|
|
1305
|
+
);
|
|
1195
1306
|
const below = newMask(P.W, P.H);
|
|
1196
1307
|
below.data.set(mask.data);
|
|
1197
1308
|
const [c0, c1] = pySlice(0, pyInt(chin) + 4, P.H);
|
|
@@ -1428,7 +1539,7 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
|
|
|
1428
1539
|
grid: g,
|
|
1429
1540
|
r,
|
|
1430
1541
|
segments: [
|
|
1431
|
-
['chest', rnd([axis, neckY + 18]), rnd([axis, chest[1]])],
|
|
1542
|
+
trunk === null ? ['chest', rnd([axis, neckY + 18]), rnd([axis, chest[1]])] : ['chest', rnd(along(neckAt, trunk.u, 18)), rnd(chest)],
|
|
1432
1543
|
['head', rnd(head), rnd([axis, head[1] - 50])],
|
|
1433
1544
|
],
|
|
1434
1545
|
};
|
|
@@ -1574,10 +1685,17 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
|
|
|
1574
1685
|
const mask = P.alpha(p);
|
|
1575
1686
|
const top = chest[1] - 0.3 * fh;
|
|
1576
1687
|
const bot = p.y + p.h;
|
|
1577
|
-
const segs: Segment[] =
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1688
|
+
const segs: Segment[] =
|
|
1689
|
+
trunk === null
|
|
1690
|
+
? [
|
|
1691
|
+
['chest', rnd([chest[0], p.y]), rnd([chest[0], hip[1] - 20])],
|
|
1692
|
+
['hip', rnd([chest[0], hip[1] - 20]), rnd([chest[0], hip[1] + 100])],
|
|
1693
|
+
]
|
|
1694
|
+
: [
|
|
1695
|
+
// The part's top read along the trunk: the nearest its art comes to the neck end of the line.
|
|
1696
|
+
['chest', rnd(along(hip, trunk.u, artExtent(mask, p, hip, trunk.u)[0])), rnd(along(hip, trunk.u, -20))],
|
|
1697
|
+
['hip', rnd(along(hip, trunk.u, -20)), rnd(along(hip, trunk.u, 100))],
|
|
1698
|
+
];
|
|
1581
1699
|
if (bw.length > 0 && bot < hip[1] + 0.25 * fh) {
|
|
1582
1700
|
// A bodice that stops at the waist: robe chains down to the hem would fall off the art.
|
|
1583
1701
|
meshes[p.name] = { grid: g, r, segments: segs };
|
|
@@ -1631,10 +1749,19 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
|
|
|
1631
1749
|
const mask = P.alpha(p);
|
|
1632
1750
|
const bot = p.y + p.h;
|
|
1633
1751
|
const y0 = hip[1] + 20;
|
|
1634
|
-
|
|
1752
|
+
let segs: Segment[] = [
|
|
1635
1753
|
['chest', rnd([chest[0], chest[1] - 30]), rnd([chest[0], hip[1] - 20])],
|
|
1636
1754
|
['hip', rnd([p.x + 0.09 * p.w, hip[1] + 5]), rnd([p.x + 0.91 * p.w, hip[1] + 5])],
|
|
1637
1755
|
];
|
|
1756
|
+
if (trunk !== null) {
|
|
1757
|
+
// The waist line runs across the trunk, 5 px down it from the hip, over 0.09 to 0.91 of the art's width across it.
|
|
1758
|
+
const [s0, s1] = artExtent(mask, p, hip, trunk.v);
|
|
1759
|
+
const waist = along(hip, trunk.u, 5);
|
|
1760
|
+
segs = [
|
|
1761
|
+
['chest', rnd(along(chest, trunk.u, -30)), rnd(along(hip, trunk.u, -20))],
|
|
1762
|
+
['hip', rnd(along(waist, trunk.v, s0 + 0.09 * (s1 - s0))), rnd(along(waist, trunk.v, s0 + 0.91 * (s1 - s0)))],
|
|
1763
|
+
];
|
|
1764
|
+
}
|
|
1638
1765
|
for (const [tag, f, ph] of [
|
|
1639
1766
|
['skirt_r', 0.3, 0.3],
|
|
1640
1767
|
['skirt_c', 0.5, 0.34],
|
|
@@ -1658,6 +1785,41 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
|
|
|
1658
1785
|
meshes[p.name] = { grid: g, r, segments: segs };
|
|
1659
1786
|
}
|
|
1660
1787
|
|
|
1788
|
+
// ---- legs (issue #241)
|
|
1789
|
+
// Only with joints: a legwear part whose art holds the ankle of a leg whose hip, knee and ankle are all given is
|
|
1790
|
+
// meshed on a leg chain hip -> knee, tip at the ankle; without them no line below runs, and the part stays the
|
|
1791
|
+
// region the trunk rule gives it, its note unchanged. No idle track is written for a leg chain: the chain is
|
|
1792
|
+
// drafted whatever the pose, and a standing figure's legs are planted — whether they move is the scene's.
|
|
1793
|
+
const lw = P.byTag('legwear', undefined, true).filter((p) => p.opaque_px > 500);
|
|
1794
|
+
const legPlan = joints !== undefined && lw.length > 0 ? legsFromJoints(P, lw, joints) : null;
|
|
1795
|
+
if (legPlan !== null && legPlan.chains !== null) {
|
|
1796
|
+
for (const c of legPlan.chains) {
|
|
1797
|
+
const J = (joints as RigJoints).joints;
|
|
1798
|
+
const used = (n: KeypointName): Placement => placement('joint', `the ${n} joint, ${usedAs(J[n])}`, { joints: [jointRef(n)] });
|
|
1799
|
+
const mid = (what: string, a: KeypointName, b: KeypointName): Placement => placement('joint', `the midpoint of the ${a} and ${b} joints (${what}) — a point between two joints, not a joint`, { joints: [jointRef(a), jointRef(b)] });
|
|
1800
|
+
const why =
|
|
1801
|
+
c.side === null
|
|
1802
|
+
? [mid('the hips', 'r_hip', 'l_hip'), mid('the knees', 'r_knee', 'l_knee')]
|
|
1803
|
+
: [used(`${c.side}_hip`), used(`${c.side}_knee`)];
|
|
1804
|
+
const tipWhy = c.side === null ? mid('the ankles', 'r_ankle', 'l_ankle') : used(`${c.side}_ankle`);
|
|
1805
|
+
C(c.chain, 'hip', [c.hip, c.knee], c.ankle, why, tipWhy);
|
|
1806
|
+
}
|
|
1807
|
+
for (const p of lw) {
|
|
1808
|
+
const [g, r] = gridR(p);
|
|
1809
|
+
const segs: Segment[] = [];
|
|
1810
|
+
for (const c of legPlan.chains.filter((q) => q.part === p)) {
|
|
1811
|
+
// The sleeve's chest stub, laid along the thigh from the hip: 14 px above the hip to 2 px below it.
|
|
1812
|
+
const len = Math.hypot(c.knee[0] - c.hip[0], c.knee[1] - c.hip[1]);
|
|
1813
|
+
const d: [number, number] = [(c.knee[0] - c.hip[0]) / len, (c.knee[1] - c.hip[1]) / len];
|
|
1814
|
+
segs.push(['hip', rnd([c.hip[0] - 14 * d[0], c.hip[1] - 14 * d[1]]), rnd([c.hip[0] + 2 * d[0], c.hip[1] + 2 * d[1]])], c.chain);
|
|
1815
|
+
}
|
|
1816
|
+
meshes[p.name] = { grid: g, r, segments: segs };
|
|
1817
|
+
}
|
|
1818
|
+
notes.push(...legPlan.notes);
|
|
1819
|
+
} else if (legPlan !== null && legPlan.why !== null) {
|
|
1820
|
+
notes.push(`${lw.map((p) => p.name).join(', ')}: no leg chain — ${legPlan.why}; the trunk rule places ${lw.length === 1 ? 'it' : 'them'}`);
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1661
1823
|
// Anything no rule claimed rides its nearest trunk bone rigidly, so the rig stage never meets an unassigned part.
|
|
1662
1824
|
for (const p of P.recs) {
|
|
1663
1825
|
if (!(p.name in meshes) && !(p.name in regions)) {
|
|
@@ -1672,6 +1834,14 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
|
|
|
1672
1834
|
} else notes.push(`${p.name} (${p.from}): no rule -> region on ${regions[p.name]}`);
|
|
1673
1835
|
}
|
|
1674
1836
|
}
|
|
1837
|
+
if (trunk !== null) {
|
|
1838
|
+
const laid = [...P.headFirst('neck'), ...tw, ...bw].map((p) => p.name);
|
|
1839
|
+
if (laid.length > 0) {
|
|
1840
|
+
notes.push(
|
|
1841
|
+
`trunk segments (${laid.join(', ')}): laid along neck -> hip, the line the chest was placed on (${pyFixed((Math.atan2(trunk.u[0], trunk.u[1]) * 180) / Math.PI, 1)} degrees off the screen's downward vertical, positive towards +x): each [bone, from, to] segment the rule drafts down the screen runs along that line instead, and the waist segment across it`,
|
|
1842
|
+
);
|
|
1843
|
+
}
|
|
1844
|
+
}
|
|
1675
1845
|
if (joints !== undefined) {
|
|
1676
1846
|
notes.push(
|
|
1677
1847
|
...jointNotes(joints, {
|
|
@@ -1680,6 +1850,7 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
|
|
|
1680
1850
|
chest: chestAlong ? { neck: neckAt, hip } : null,
|
|
1681
1851
|
sleeveRule,
|
|
1682
1852
|
armWhy: armPlan === null ? null : armPlan.why,
|
|
1853
|
+
legs: legPlan === null ? null : legPlan.chains,
|
|
1683
1854
|
arms: armPlan === null ? null : armPlan.arms,
|
|
1684
1855
|
wristSnap,
|
|
1685
1856
|
eyes: (['r', 'l'] as const).map((s) => {
|
|
@@ -1689,13 +1860,95 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
|
|
|
1689
1860
|
}),
|
|
1690
1861
|
);
|
|
1691
1862
|
}
|
|
1692
|
-
|
|
1863
|
+
// Issue #252: the idle is IDLE_FRAMES long, and every period drafted against the reference's 4 s idle keeps its
|
|
1864
|
+
// cycles per idle (REFERENCE_IDLE_SECONDS).
|
|
1865
|
+
const scale = IDLE_SECONDS / REFERENCE_IDLE_SECONDS;
|
|
1866
|
+
const idleTracks = tracks.map((tr) => ({ ...tr, period: tr.period * scale }));
|
|
1867
|
+
return { proposal: { bones, meshes, regions, motion: blink === undefined ? { duration: IDLE_SECONDS, tracks: idleTracks } : { duration: IDLE_SECONDS, tracks: idleTracks, blink }, notes }, basis: { frame, bones: records } };
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
/**
|
|
1871
|
+
* The drafted breath (issue #252): `chest` `translatey`, its travel peak to
|
|
1872
|
+
* peak {@link BREATH_FRACTION} of the height of the {@link BREATH_SIZE_TAG}
|
|
1873
|
+
* layer — the box around every part from it, cut pieces included, since a cut
|
|
1874
|
+
* does not shorten the torso — amplitude half of it to two decimals, and the
|
|
1875
|
+
* base the amplitude. With no such part there is no height to take a fraction
|
|
1876
|
+
* of, and inventing one is not done: the track keeps the reference's
|
|
1877
|
+
* amplitude, {@link REFERENCE_BREATH_AMP}, and the note says so.
|
|
1878
|
+
*/
|
|
1879
|
+
export function breathTrack(P: PartSet): { track: ProposedSingleTrack; note: string } {
|
|
1880
|
+
const parts = P.byTag(BREATH_SIZE_TAG, undefined, true);
|
|
1881
|
+
if (parts.length === 0) {
|
|
1882
|
+
const a = REFERENCE_BREATH_AMP;
|
|
1883
|
+
return {
|
|
1884
|
+
track: { bone: 'chest', prop: 'translatey', amp: a, period: REFERENCE_IDLE_SECONDS, phase: 0.0, base: a },
|
|
1885
|
+
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`,
|
|
1886
|
+
};
|
|
1887
|
+
}
|
|
1888
|
+
const top = Math.min(...parts.map((p) => p.y));
|
|
1889
|
+
const bottom = Math.max(...parts.map((p) => p.y + p.h));
|
|
1890
|
+
const height = bottom - top;
|
|
1891
|
+
const amp = roundTo((BREATH_FRACTION * height) / 2, 2);
|
|
1892
|
+
const layers = [...new Set(parts.map((p) => p.from))].join(', ');
|
|
1893
|
+
return {
|
|
1894
|
+
track: { bone: 'chest', prop: 'translatey', amp, period: REFERENCE_IDLE_SECONDS, phase: 0.0, base: amp },
|
|
1895
|
+
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`,
|
|
1896
|
+
};
|
|
1693
1897
|
}
|
|
1694
1898
|
|
|
1695
1899
|
// ---------------------------------------------------------------------------
|
|
1696
1900
|
// external keypoints (issue #75)
|
|
1697
1901
|
// ---------------------------------------------------------------------------
|
|
1698
1902
|
|
|
1903
|
+
/**
|
|
1904
|
+
* The trunk's line (issue #243), from the neck bone's origin to the hip's, when a joint placed either end: `u` is
|
|
1905
|
+
* its unit direction, down the trunk, and `v` the unit across it, `u` turned a quarter (`[u_y, -u_x]`). An upright
|
|
1906
|
+
* trunk's `u` is `[0, 1]` and its `v` `[1, 0]`, the screen's y and x, which is what the vertical drafts read: each
|
|
1907
|
+
* trunk segment is that draft with "down the screen" read as "along `u`" and "across the screen" as "along `v`".
|
|
1908
|
+
* `null` when the two ends are one point: a line through one point has no direction.
|
|
1909
|
+
*/
|
|
1910
|
+
interface TrunkLine {
|
|
1911
|
+
u: [number, number];
|
|
1912
|
+
v: [number, number];
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1915
|
+
function trunkLineOf(neck: readonly [number, number], hip: readonly [number, number]): TrunkLine | null {
|
|
1916
|
+
const dx = hip[0] - neck[0];
|
|
1917
|
+
const dy = hip[1] - neck[1];
|
|
1918
|
+
const len = Math.hypot(dx, dy);
|
|
1919
|
+
if (len === 0) return null;
|
|
1920
|
+
const u: [number, number] = [dx / len, dy / len];
|
|
1921
|
+
return { u, v: [u[1], -u[0]] };
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
/** The point `k` px from `p` along the unit direction `d`. */
|
|
1925
|
+
function along(p: readonly [number, number], d: readonly [number, number], k: number): [number, number] {
|
|
1926
|
+
return [p[0] + k * d[0], p[1] + k * d[1]];
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
/**
|
|
1930
|
+
* The extent of a part's art along the unit direction `d`, measured from `o`: the least and the greatest
|
|
1931
|
+
* projection onto `d` of its opaque pixels, each taken as the unit square it covers. An axis-aligned box tight on
|
|
1932
|
+
* its art therefore projects onto `[1, 0]` as its own left and right edges and onto `[0, 1]` as its top and bottom,
|
|
1933
|
+
* the values the vertical drafts read off the box. A part with no opaque pixel is read as its box.
|
|
1934
|
+
*/
|
|
1935
|
+
function artExtent(mask: Mask, p: PartRecord, o: readonly [number, number], d: readonly [number, number]): [number, number] {
|
|
1936
|
+
let lo = Infinity;
|
|
1937
|
+
let hi = -Infinity;
|
|
1938
|
+
for (let y = Math.max(0, p.y); y < Math.min(mask.height, p.y + p.h); y++) {
|
|
1939
|
+
for (let x = Math.max(0, p.x); x < Math.min(mask.width, p.x + p.w); x++) {
|
|
1940
|
+
if (mask.data[y * mask.width + x] !== 1) continue;
|
|
1941
|
+
const t = (x - o[0]) * d[0] + (y - o[1]) * d[1];
|
|
1942
|
+
if (t < lo) lo = t;
|
|
1943
|
+
if (t > hi) hi = t;
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
const w = lo === Infinity ? p.w : 1;
|
|
1947
|
+
const h = lo === Infinity ? p.h : 1;
|
|
1948
|
+
if (lo === Infinity) lo = hi = (p.x - o[0]) * d[0] + (p.y - o[1]) * d[1];
|
|
1949
|
+
return [lo + Math.min(0, d[0]) * w + Math.min(0, d[1]) * h, hi + Math.max(0, d[0]) * w + Math.max(0, d[1]) * h];
|
|
1950
|
+
}
|
|
1951
|
+
|
|
1699
1952
|
/**
|
|
1700
1953
|
* How far a wrist joint may lie from a handwear part's art and still be on
|
|
1701
1954
|
* it: the LINT's own tolerance, the part's mask dilated this many px each way
|
|
@@ -1808,6 +2061,128 @@ function armsFromJoints(P: PartSet, hw: readonly PartRecord[], joints: RigJoints
|
|
|
1808
2061
|
return { arms, why: null };
|
|
1809
2062
|
}
|
|
1810
2063
|
|
|
2064
|
+
/**
|
|
2065
|
+
* One leg chain drafted from joints (issue #241): its name, the side whose
|
|
2066
|
+
* joints it runs through (`null` for the one chain that carries both legs,
|
|
2067
|
+
* through the midpoints of the two hips, the two knees and the two ankles),
|
|
2068
|
+
* its two links and tip in rig px, and the legwear part it meshes.
|
|
2069
|
+
*/
|
|
2070
|
+
interface LegChain {
|
|
2071
|
+
chain: string;
|
|
2072
|
+
side: 'r' | 'l' | null;
|
|
2073
|
+
hip: [number, number];
|
|
2074
|
+
knee: [number, number];
|
|
2075
|
+
ankle: [number, number];
|
|
2076
|
+
part: PartRecord;
|
|
2077
|
+
}
|
|
2078
|
+
|
|
2079
|
+
/**
|
|
2080
|
+
* Whether `m`'s art breaks along the straight line from `a` to `b`: walked
|
|
2081
|
+
* one pixel step at a time (the larger of the x and y gaps, in steps of at
|
|
2082
|
+
* most one pixel, each point read as {@link isOn} reads it), some point with
|
|
2083
|
+
* no art lies between two points with art. Art that only starts or stops
|
|
2084
|
+
* along the line — an end point off the art — is no break.
|
|
2085
|
+
*/
|
|
2086
|
+
function artBreaks(m: Mask, a: readonly [number, number], b: readonly [number, number]): boolean {
|
|
2087
|
+
const n = Math.ceil(Math.max(Math.abs(b[0] - a[0]), Math.abs(b[1] - a[1])));
|
|
2088
|
+
let seenArt = false;
|
|
2089
|
+
let gapAfterArt = false;
|
|
2090
|
+
for (let i = 0; i <= n; i++) {
|
|
2091
|
+
const t = n === 0 ? 0 : i / n;
|
|
2092
|
+
const on = isOn(m, a[0] + t * (b[0] - a[0]), a[1] + t * (b[1] - a[1]));
|
|
2093
|
+
if (on && gapAfterArt) return true;
|
|
2094
|
+
if (on) seenArt = true;
|
|
2095
|
+
else if (seenArt) gapAfterArt = true;
|
|
2096
|
+
}
|
|
2097
|
+
return false;
|
|
2098
|
+
}
|
|
2099
|
+
|
|
2100
|
+
/**
|
|
2101
|
+
* The leg chains a keypoint file drafts for the legwear parts, or why none
|
|
2102
|
+
* is drafted (issue #241) — the sleeve's {@link armsFromJoints} for the legs.
|
|
2103
|
+
* A leg is complete when its hip, knee and ankle all have a position and the
|
|
2104
|
+
* hip and knee are two points; a legwear part carries a leg when the ankle
|
|
2105
|
+
* lies on its art within the LINT's own tolerance ({@link WRIST_ON_PART_PX}).
|
|
2106
|
+
* The joints are used only when every complete leg's ankle is on exactly one
|
|
2107
|
+
* part and every legwear part carries one, as for the sleeves; when a leg is
|
|
2108
|
+
* complete and they are not, `why` says so. With no complete leg nothing is
|
|
2109
|
+
* drafted and `why` is `null`: the proposal is the one written before the
|
|
2110
|
+
* rule existed.
|
|
2111
|
+
*
|
|
2112
|
+
* One chain or two is read off the part and the joints. A part carrying one
|
|
2113
|
+
* leg gets that leg's chain, `leg_<side>`. A part carrying both gets one
|
|
2114
|
+
* chain per leg only when its art breaks ({@link artBreaks}) along the line
|
|
2115
|
+
* from r_knee to l_knee and along the line from r_ankle to l_ankle — the
|
|
2116
|
+
* legs painted apart at both; otherwise the legs touch, cross or overlap in
|
|
2117
|
+
* its art, two chains would pull one stretch of it two ways, and it gets one
|
|
2118
|
+
* chain, `leg`, through the midpoints of the hips, the knees and the ankles.
|
|
2119
|
+
* Each part gets one note saying which and why.
|
|
2120
|
+
*/
|
|
2121
|
+
function legsFromJoints(P: PartSet, lw: readonly PartRecord[], joints: RigJoints): { chains: LegChain[] | null; notes: string[]; why: string | null } {
|
|
2122
|
+
const complete: Array<{ side: 'r' | 'l'; hip: [number, number]; knee: [number, number]; ankle: [number, number] }> = [];
|
|
2123
|
+
const gaps: string[] = [];
|
|
2124
|
+
for (const s of ['r', 'l'] as const) {
|
|
2125
|
+
const hp = joints.joints[`${s}_hip`].at;
|
|
2126
|
+
const kn = joints.joints[`${s}_knee`].at;
|
|
2127
|
+
const an = joints.joints[`${s}_ankle`].at;
|
|
2128
|
+
if (hp === null || kn === null || an === null) {
|
|
2129
|
+
const absent = [hp === null ? `${s}_hip` : null, kn === null ? `${s}_knee` : null, an === null ? `${s}_ankle` : null].filter((n): n is string => n !== null);
|
|
2130
|
+
gaps.push(`${absent.join(', ')} ${absent.length === 1 ? 'has' : 'have'} no position`);
|
|
2131
|
+
} else if (hp[0] === kn[0] && hp[1] === kn[1]) gaps.push(`${s}_hip and ${s}_knee are one point, so the thigh has no direction`);
|
|
2132
|
+
else complete.push({ side: s, hip: hp, knee: kn, ankle: an });
|
|
2133
|
+
}
|
|
2134
|
+
// No complete leg is the file before this rule existed: nothing is said, and the part stays the trunk rule's.
|
|
2135
|
+
if (complete.length === 0) return { chains: null, notes: [], why: null };
|
|
2136
|
+
const near = new Map(lw.map((p) => [p, dilate(P.alpha(p), 2 * WRIST_ON_PART_PX + 1)]));
|
|
2137
|
+
const bound: Array<(typeof complete)[number] & { part: PartRecord }> = [];
|
|
2138
|
+
for (const g of complete) {
|
|
2139
|
+
const on = lw.filter((p) => isOn(near.get(p) as Mask, g.ankle[0], g.ankle[1]));
|
|
2140
|
+
if (on.length !== 1) {
|
|
2141
|
+
const where = on.length === 0 ? `the art of no legwear part within ${WRIST_ON_PART_PX} px` : `the art of ${on.length} legwear parts (${on.map((p) => p.name).join(', ')})`;
|
|
2142
|
+
return { chains: null, notes: [], why: `${g.side}_ankle at ${fmtAt(g.ankle)} lies on ${where}, so it does not say which part its leg is` };
|
|
2143
|
+
}
|
|
2144
|
+
bound.push({ ...g, part: on[0] });
|
|
2145
|
+
}
|
|
2146
|
+
const idle = lw.filter((p) => !bound.some((g) => g.part === p));
|
|
2147
|
+
if (idle.length > 0) {
|
|
2148
|
+
const why = gaps.length > 0 ? ` (${gaps.join('; ')})` : '';
|
|
2149
|
+
return { chains: null, notes: [], why: `${idle.map((p) => p.name).join(', ')} ${idle.length === 1 ? 'carries' : 'carry'} the ankle of no leg whose three joints are given${why}` };
|
|
2150
|
+
}
|
|
2151
|
+
const chains: LegChain[] = [];
|
|
2152
|
+
const notes: string[] = [];
|
|
2153
|
+
const still = 'no idle track is written for it: the chain is drafted whatever the pose, and whether the legs move is the scene\'s to key or drive';
|
|
2154
|
+
for (const p of lw) {
|
|
2155
|
+
const on = bound.filter((g) => g.part === p);
|
|
2156
|
+
if (on.length === 1) {
|
|
2157
|
+
const g = on[0];
|
|
2158
|
+
chains.push({ chain: `leg_${g.side}`, side: g.side, hip: g.hip, knee: g.knee, ankle: g.ankle, part: p });
|
|
2159
|
+
const other = g.side === 'r' ? 'l' : 'r';
|
|
2160
|
+
const lack = complete.some((q) => q.side === other) ? `${other}_ankle lies on another part` : gaps.join('; ');
|
|
2161
|
+
notes.push(`${p.name} (${p.from}): one leg chain, leg_${g.side}, hip -> knee -> ankle from the ${g.side} joints — ${g.side}_ankle lies on its art and no ${other} leg is bound to it (${lack}); ${still}`);
|
|
2162
|
+
continue;
|
|
2163
|
+
}
|
|
2164
|
+
const [r, l] = on;
|
|
2165
|
+
const m = P.alpha(p);
|
|
2166
|
+
const kneesBreak = artBreaks(m, r.knee, l.knee);
|
|
2167
|
+
const anklesBreak = artBreaks(m, r.ankle, l.ankle);
|
|
2168
|
+
if (kneesBreak && anklesBreak) {
|
|
2169
|
+
for (const g of on) chains.push({ chain: `leg_${g.side}`, side: g.side, hip: g.hip, knee: g.knee, ankle: g.ankle, part: p });
|
|
2170
|
+
notes.push(
|
|
2171
|
+
`${p.name} (${p.from}): one chain per leg, leg_r and leg_l, sharing its mesh — both ankles lie on its art, and its art breaks along the line from r_knee to l_knee and along the line from r_ankle to l_ankle, so the legs are painted apart; each runs hip -> knee -> ankle from its side's joints, and ${still}`,
|
|
2172
|
+
);
|
|
2173
|
+
continue;
|
|
2174
|
+
}
|
|
2175
|
+
const mid = (a: [number, number], b: [number, number]): [number, number] => [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
|
|
2176
|
+
chains.push({ chain: 'leg', side: null, hip: mid(r.hip, l.hip), knee: mid(r.knee, l.knee), ankle: mid(r.ankle, l.ankle), part: p });
|
|
2177
|
+
const unbroken = [kneesBreak ? null : 'from r_knee to l_knee', anklesBreak ? null : 'from r_ankle to l_ankle'].filter((s): s is string => s !== null);
|
|
2178
|
+
notes.push(
|
|
2179
|
+
`${p.name} (${p.from}): one leg chain, leg, for both legs — both ankles lie on its art, and its art runs unbroken along the line ${unbroken.join(' and along the line ')}, so the legs touch, cross or overlap in it and a chain per leg would pull one stretch of art two ways; ` +
|
|
2180
|
+
`the chain runs hip -> knee -> ankle through the midpoints of the two hips, the two knees and the two ankles, and ${still}`,
|
|
2181
|
+
);
|
|
2182
|
+
}
|
|
2183
|
+
return { chains, notes, why: null };
|
|
2184
|
+
}
|
|
2185
|
+
|
|
1811
2186
|
/** A rig-px point as the notes and LINT lines print it: one decimal place. */
|
|
1812
2187
|
function fmtAt(p: readonly [number, number]): string {
|
|
1813
2188
|
return `[${pyFixed(p[0], 1)}, ${pyFixed(p[1], 1)}]`;
|
|
@@ -1833,6 +2208,8 @@ interface JointNoteFacts {
|
|
|
1833
2208
|
sleeveRule: string;
|
|
1834
2209
|
armWhy: string | null;
|
|
1835
2210
|
arms: JointArm[] | null;
|
|
2211
|
+
/** The leg chains drafted (issue #241), or `null` when none was: then no line here names a leg joint. */
|
|
2212
|
+
legs: LegChain[] | null;
|
|
1836
2213
|
/** `propose --wrist-snap`, or `null` without it. */
|
|
1837
2214
|
wristSnap: number | null;
|
|
1838
2215
|
eyes: ReadonlyArray<{ bone: string; part: string; at: [number, number] } | null>;
|
|
@@ -1902,6 +2279,29 @@ function jointNotes(joints: RigJoints, f: JointNoteFacts): string[] {
|
|
|
1902
2279
|
`to separate them, cut one sleeve out of ${both} with an assemble.cuts entry (a polygon round it) and propose again`,
|
|
1903
2280
|
);
|
|
1904
2281
|
}
|
|
2282
|
+
// Issue #241: the knees and ankles a leg chain was drafted from, and the hips as its first link.
|
|
2283
|
+
for (const c of f.legs ?? []) {
|
|
2284
|
+
const on = `the ankle${c.side === null ? 's lie' : ' lies'} on ${c.part.name}`;
|
|
2285
|
+
if (c.side === null) {
|
|
2286
|
+
out.push(`chain leg: link 0 is the midpoint of r_hip and l_hip, ${fmtAt(c.hip)} — the point bone hip stands on`);
|
|
2287
|
+
for (const [joint, role, pair] of [
|
|
2288
|
+
['r_knee', 'link 1', 'r_knee and l_knee'],
|
|
2289
|
+
['l_knee', 'link 1', 'r_knee and l_knee'],
|
|
2290
|
+
['r_ankle', 'the tip', 'r_ankle and l_ankle'],
|
|
2291
|
+
['l_ankle', 'the tip', 'r_ankle and l_ankle'],
|
|
2292
|
+
] as const) {
|
|
2293
|
+
tell(joint, `${said(joint, J[joint])}: ${usedAs(J[joint])} for chain leg, ${role}, the midpoint of ${pair} — a point between two joints, not a joint (${on})`);
|
|
2294
|
+
}
|
|
2295
|
+
continue;
|
|
2296
|
+
}
|
|
2297
|
+
out.push(`chain leg_${c.side}: link 0 is the ${c.side}_hip joint, ${fmtAt(c.hip)}`);
|
|
2298
|
+
for (const [joint, role] of [
|
|
2299
|
+
[`${c.side}_knee`, 'link 1'],
|
|
2300
|
+
[`${c.side}_ankle`, 'the tip'],
|
|
2301
|
+
] as const) {
|
|
2302
|
+
tell(joint, `${said(joint, J[joint])}: ${usedAs(J[joint])} for chain leg_${c.side}, ${role} (${on})`);
|
|
2303
|
+
}
|
|
2304
|
+
}
|
|
1905
2305
|
(['r', 'l'] as const).forEach((s, i) => {
|
|
1906
2306
|
const n = `${s}_eye` as const;
|
|
1907
2307
|
const j = J[n];
|
|
@@ -1940,6 +2340,178 @@ function pct(v: number): string {
|
|
|
1940
2340
|
return `${Math.round(v * 100)} %`;
|
|
1941
2341
|
}
|
|
1942
2342
|
|
|
2343
|
+
/**
|
|
2344
|
+
* The frontal reference the pitch proportions are written beside (issue #250):
|
|
2345
|
+
* each figure's range, `[min, max]`, over three drawn faces that face front —
|
|
2346
|
+
* `demo` and `scarf` as built here, and the seated character of
|
|
2347
|
+
* https://github.com/rig-c/trailer (its `inputs/`, assembled by this port at
|
|
2348
|
+
* the same plan) — read by {@link headFrame} with a `nose` part added to each
|
|
2349
|
+
* plan, which `assemble --propose-plan` never proposes (the reference's rule):
|
|
2350
|
+
* where the nose sits along eye midpoint -> mouth, demo 0.474, scarf 0.564,
|
|
2351
|
+
* trailer 0.630; the stem against the bar, demo 0.629, scarf 0.675, trailer
|
|
2352
|
+
* 0.510 (each on the eye bones `propose` writes). A drawn face does not carry a human face's ratios (the eyes are
|
|
2353
|
+
* larger and lower, the nose a dot), so no published human model's are used.
|
|
2354
|
+
*
|
|
2355
|
+
* The turn is not written (issue #250): on a face painted three-quarter
|
|
2356
|
+
* toward image left, the stem via the nose (skew −45.9 degrees, frontal
|
|
2357
|
+
* −8.2 to 0.8) and via the mouth (−11.1, frontal −5.3 to 3.6) and the face's
|
|
2358
|
+
* halves (image-left over image-right 0.681, frontal 0.839 to 1.032) all read
|
|
2359
|
+
* it beyond the frontal range on the turned side, but the eyewhites' widths
|
|
2360
|
+
* (0.913) read it inside the frontal range (0.898 to 1.000) — the trailer
|
|
2361
|
+
* character, facing front, reads more turned by that figure than the turned
|
|
2362
|
+
* face. Three readings that do not agree in order are not reported as one.
|
|
2363
|
+
*/
|
|
2364
|
+
export const FRONTAL_REFERENCE = {
|
|
2365
|
+
faces: ['demo', 'scarf', 'the rig-c/trailer character'],
|
|
2366
|
+
noseAlongStem: [0.474, 0.63],
|
|
2367
|
+
stemOverBar: [0.51, 0.675],
|
|
2368
|
+
} as const;
|
|
2369
|
+
|
|
2370
|
+
/** The centroid of a part's biggest 8-connected blob — the mouth bone's rule — or why it has none. */
|
|
2371
|
+
function blobPoint(P: PartSet, tag: string): FrameFeature {
|
|
2372
|
+
const ps = P.headFirst(tag);
|
|
2373
|
+
if (ps.length === 0) return { missing: `no part tagged ${tag}` };
|
|
2374
|
+
const cc = connectedComponents(P.alpha(ps[0]), 8);
|
|
2375
|
+
if (cc.count < 2) return { missing: `${ps[0].name} (${tag}) has no opaque pixel` };
|
|
2376
|
+
let best = 1;
|
|
2377
|
+
for (let i = 2; i < cc.count; i++) if (cc.stats[i].area > cc.stats[best].area) best = i;
|
|
2378
|
+
return { part: ps[0].name, at: [cc.stats[best].cx, cc.stats[best].cy] };
|
|
2379
|
+
}
|
|
2380
|
+
|
|
2381
|
+
/**
|
|
2382
|
+
* The head's roll as the hair chains and their lint read it (issue #249):
|
|
2383
|
+
* {@link eyeLineDegrees} on the origins of the single bones `eye_r` and
|
|
2384
|
+
* `eye_l` as `bones` writes them — the one derivation of the roll, the one
|
|
2385
|
+
* {@link headFrameNotes} prints and the rig turns the face bones to. With no
|
|
2386
|
+
* such pair, or a pair in one column, why there is none.
|
|
2387
|
+
*/
|
|
2388
|
+
export function headRollOf(bones: readonly BoneEntry[], H: number): { degrees: number; eyes: [Point, Point] } | { none: string } {
|
|
2389
|
+
const at = (n: string): Point | null => {
|
|
2390
|
+
const e = bones.find((b) => 'name' in b && b.name === n);
|
|
2391
|
+
return e === undefined || 'chain' in e ? null : e.at;
|
|
2392
|
+
};
|
|
2393
|
+
const r = at('eye_r');
|
|
2394
|
+
const l = at('eye_l');
|
|
2395
|
+
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` };
|
|
2396
|
+
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` };
|
|
2397
|
+
return { degrees: eyeLineDegrees(r, l, H), eyes: [r, l] };
|
|
2398
|
+
}
|
|
2399
|
+
|
|
2400
|
+
/**
|
|
2401
|
+
* The px a fringe link of the template steps across its column, image-outward
|
|
2402
|
+
* (issue #249 keeps it; the reference's `dx`): `bang_r`'s first link 10 px
|
|
2403
|
+
* toward image-left, `bang_l`'s toward image-right, each tip 1.8 times that
|
|
2404
|
+
* from its root. It is the widest step across the column the fringe rule
|
|
2405
|
+
* writes in one link (the second steps 0.8 of it), so the off-lock LINT
|
|
2406
|
+
* ({@link lint}) allows a link this much drift across the head's down axis.
|
|
2407
|
+
*/
|
|
2408
|
+
export const FRINGE_STEP_PX = 10;
|
|
2409
|
+
|
|
2410
|
+
/**
|
|
2411
|
+
* The runs of `mask`'s art along a column of the head's frame (issue #249):
|
|
2412
|
+
* the column is the line through `foot` along the face's down axis, and the
|
|
2413
|
+
* pixels it reads are those inside the part's box whose centre lies within
|
|
2414
|
+
* half a pixel of that line. Ordered by the depth of their centre along the
|
|
2415
|
+
* down axis, two pixels whose centres are within sqrt(2) px in depth
|
|
2416
|
+
* (8-connected neighbours at most) are one run. Each run is `[start, end]`:
|
|
2417
|
+
* its first centre's depth less half a pixel and its last's plus half a
|
|
2418
|
+
* pixel — on the page's axes, exactly the top edge of its first row and the
|
|
2419
|
+
* bottom edge of its last. Shallowest first.
|
|
2420
|
+
*/
|
|
2421
|
+
function columnRuns(mask: Mask, box: PartRecord, ax: HeadAxes, foot: readonly [number, number]): Array<[number, number]> {
|
|
2422
|
+
const ds: number[] = [];
|
|
2423
|
+
for (let y = box.y; y < box.y + box.h; y++) {
|
|
2424
|
+
for (let x = box.x; x < box.x + box.w; x++) {
|
|
2425
|
+
if (mask.data[y * mask.width + x] !== 1) continue;
|
|
2426
|
+
const [a, d] = ax.of(foot, [x + 0.5, y + 0.5]);
|
|
2427
|
+
if (Math.abs(a) <= 0.5) ds.push(d);
|
|
2428
|
+
}
|
|
2429
|
+
}
|
|
2430
|
+
ds.sort((p, q) => p - q);
|
|
2431
|
+
const runs: Array<[number, number]> = [];
|
|
2432
|
+
for (let i = 0; i < ds.length; i++) {
|
|
2433
|
+
if (i === 0 || ds[i] - ds[i - 1] > Math.SQRT2) runs.push([ds[i] - 0.5, ds[i] + 0.5]);
|
|
2434
|
+
else runs[runs.length - 1][1] = ds[i] + 0.5;
|
|
2435
|
+
}
|
|
2436
|
+
return runs;
|
|
2437
|
+
}
|
|
2438
|
+
|
|
2439
|
+
/**
|
|
2440
|
+
* The depth along the face's down axis at which the column through `foot`
|
|
2441
|
+
* enters the part's box (issue #249): on the page's axes, the box's top row
|
|
2442
|
+
* less the foot's y, which is where the reference rooted the fringe. A column
|
|
2443
|
+
* that misses the box enters it nowhere, and its corners' least depth stands
|
|
2444
|
+
* in.
|
|
2445
|
+
*/
|
|
2446
|
+
function boxEntry(box: PartRecord, ax: HeadAxes, foot: readonly [number, number]): number {
|
|
2447
|
+
const o = ax.at(foot, 0, 0);
|
|
2448
|
+
const one = ax.at(foot, 0, 1);
|
|
2449
|
+
const n: [number, number] = [one[0] - o[0], one[1] - o[1]];
|
|
2450
|
+
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]));
|
|
2451
|
+
let lo = -Infinity;
|
|
2452
|
+
let hi = Infinity;
|
|
2453
|
+
for (const [k, a, b] of [
|
|
2454
|
+
[0, box.x, box.x + box.w],
|
|
2455
|
+
[1, box.y, box.y + box.h],
|
|
2456
|
+
] as const) {
|
|
2457
|
+
if (n[k] === 0) {
|
|
2458
|
+
if (o[k] < a || o[k] > b) return corners();
|
|
2459
|
+
continue;
|
|
2460
|
+
}
|
|
2461
|
+
const t0 = (a - o[k]) / n[k];
|
|
2462
|
+
const t1 = (b - o[k]) / n[k];
|
|
2463
|
+
lo = Math.max(lo, Math.min(t0, t1));
|
|
2464
|
+
hi = Math.min(hi, Math.max(t0, t1));
|
|
2465
|
+
}
|
|
2466
|
+
return lo <= hi ? lo : corners();
|
|
2467
|
+
}
|
|
2468
|
+
|
|
2469
|
+
/** `[min, max]` as `a..b` at 3 places. */
|
|
2470
|
+
function refRange(r: readonly [number, number] | readonly number[]): string {
|
|
2471
|
+
return `${pyFixed(r[0], 3)}..${pyFixed(r[1], 3)}`;
|
|
2472
|
+
}
|
|
2473
|
+
|
|
2474
|
+
/**
|
|
2475
|
+
* The head frame's notes (issue #250): the bar through the eye bones'
|
|
2476
|
+
* origins as `bones` writes them, its roll, the eye midpoint and the
|
|
2477
|
+
* inter-eye distance; then the pitch proportions beside their frontal
|
|
2478
|
+
* reference ({@link FRONTAL_REFERENCE}). The turn is read by
|
|
2479
|
+
* {@link headFrame} but not written (the reference says why). A figure whose
|
|
2480
|
+
* part is missing is named, never estimated.
|
|
2481
|
+
* With no pair of eye bones there is no bar, and one note says why.
|
|
2482
|
+
*/
|
|
2483
|
+
export function headFrameNotes(P: PartSet, bones: readonly BoneEntry[]): string[] {
|
|
2484
|
+
const at = (n: string): Point | null => {
|
|
2485
|
+
const e = bones.find((b) => 'name' in b && b.name === n);
|
|
2486
|
+
return e === undefined || 'chain' in e ? null : e.at;
|
|
2487
|
+
};
|
|
2488
|
+
const r = at('eye_r');
|
|
2489
|
+
const l = at('eye_l');
|
|
2490
|
+
if (r === null || l === null) {
|
|
2491
|
+
const missing = [r === null ? 'eye_r' : null, l === null ? 'eye_l' : null].filter((n) => n !== null).join(' and ');
|
|
2492
|
+
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`];
|
|
2493
|
+
}
|
|
2494
|
+
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`];
|
|
2495
|
+
const ew = (['eyewhite-r', 'eyewhite-l'] as const).map((t, i) => {
|
|
2496
|
+
const p = P.byTag(t)[0];
|
|
2497
|
+
return { part: p.name, mask: P.alpha(p), eye: i === 0 ? r : l };
|
|
2498
|
+
});
|
|
2499
|
+
const facePart = P.headFirst('face')[0];
|
|
2500
|
+
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) });
|
|
2501
|
+
const f3 = (v: number): string => pyFixed(v, 3);
|
|
2502
|
+
const [el, er] = hf.eyes;
|
|
2503
|
+
const bar =
|
|
2504
|
+
`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; ` +
|
|
2505
|
+
`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)}`;
|
|
2506
|
+
const ref = FRONTAL_REFERENCE;
|
|
2507
|
+
const nose = hf.nose;
|
|
2508
|
+
const mouth = hf.mouth;
|
|
2509
|
+
const pitch =
|
|
2510
|
+
`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)}); ` +
|
|
2511
|
+
`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)`;
|
|
2512
|
+
return [bar, pitch];
|
|
2513
|
+
}
|
|
2514
|
+
|
|
1943
2515
|
/**
|
|
1944
2516
|
* The blink squashes each eye's parts about the eye bone, so anything painted
|
|
1945
2517
|
* into the eyelash layer above the lid — a double-eyelid crease — is squashed
|
|
@@ -2028,6 +2600,26 @@ export interface OffArtFinding {
|
|
|
2028
2600
|
at: [number, number];
|
|
2029
2601
|
}
|
|
2030
2602
|
|
|
2603
|
+
/**
|
|
2604
|
+
* A link of a lock the head carries whose line leaves the lock's run (issue
|
|
2605
|
+
* #249): it drifts across the head's down axis by more than the fringe
|
|
2606
|
+
* template's widest step, {@link FRINGE_STEP_PX}, plus a pixel's rounding at
|
|
2607
|
+
* each end. `degrees` is the link's angle off that axis and `limit` the angle
|
|
2608
|
+
* that drift allows over the link's reach down the axis; a link that does not
|
|
2609
|
+
* reach down the axis at all has no limit (`null`) and is named whatever its
|
|
2610
|
+
* angle.
|
|
2611
|
+
*/
|
|
2612
|
+
export interface OffLockFinding {
|
|
2613
|
+
kind: 'off-lock';
|
|
2614
|
+
bone: string;
|
|
2615
|
+
mesh: string;
|
|
2616
|
+
from: [number, number];
|
|
2617
|
+
to: [number, number];
|
|
2618
|
+
degrees: number;
|
|
2619
|
+
limit: number | null;
|
|
2620
|
+
roll: number;
|
|
2621
|
+
}
|
|
2622
|
+
|
|
2031
2623
|
/** The hip at or above the chest: breathing and every skirt chain hang from the wrong end of the torso. */
|
|
2032
2624
|
export interface HipAboveChestFinding {
|
|
2033
2625
|
kind: 'hip-not-below-chest';
|
|
@@ -2087,7 +2679,7 @@ export interface ChainNotAdvancingFinding {
|
|
|
2087
2679
|
toJoint: KeypointName;
|
|
2088
2680
|
}
|
|
2089
2681
|
|
|
2090
|
-
export type LintFinding = OffArtFinding | HipAboveChestFinding | HipTooHighFinding | ChestNotBetweenFinding | HipNotPastChestFinding | HipNearerNeckFinding | ChainNotAdvancingFinding;
|
|
2682
|
+
export type LintFinding = OffArtFinding | OffLockFinding | HipAboveChestFinding | HipTooHighFinding | ChestNotBetweenFinding | HipNotPastChestFinding | HipNearerNeckFinding | ChainNotAdvancingFinding;
|
|
2091
2683
|
|
|
2092
2684
|
/** Which rule set read the torso and the sleeve chains, under `--keypoints`. */
|
|
2093
2685
|
export interface LintBasis {
|
|
@@ -2101,6 +2693,8 @@ export interface LintResult {
|
|
|
2101
2693
|
findings: LintFinding[];
|
|
2102
2694
|
/** Meshes naming no part in parts.json: nothing to lint them against. */
|
|
2103
2695
|
unknownMeshes: string[];
|
|
2696
|
+
/** 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. */
|
|
2697
|
+
lockUnread: string[];
|
|
2104
2698
|
/** Single bones the torso checks read (`hip`, `chest`) that the bones do not hold: those checks did not run. */
|
|
2105
2699
|
missingTorsoBones: string[];
|
|
2106
2700
|
/** Present only under `--keypoints`: the rule set that read the torso and the chains. */
|
|
@@ -2145,6 +2739,17 @@ export function coverageOutcome(c: BoneCoverage): CoverageOutcome {
|
|
|
2145
2739
|
* the links `<segment><digits>`, so a single bone named as a segment has none,
|
|
2146
2740
|
* and an explicit `[bone, from, to]` segment is a line, not an origin.
|
|
2147
2741
|
*
|
|
2742
|
+
* Then the fringe (issue #249): every chain hung from `head` that a head-run
|
|
2743
|
+
* `front hair` part's mesh names, its tip no lower than the chin (the face
|
|
2744
|
+
* box's bottom centre) along the head's down axis — the eye line's
|
|
2745
|
+
* perpendicular, read by {@link headRollOf} — is a lock the head carries,
|
|
2746
|
+
* and a link of it that drifts across that axis by more than
|
|
2747
|
+
* {@link FRINGE_STEP_PX} plus sqrt(2) px (the template's widest step, and
|
|
2748
|
+
* each end rounded to whole px) is named, with its angle and the angle its
|
|
2749
|
+
* reach allows. A strand that hangs past the chin hangs with its weight and
|
|
2750
|
+
* is not held to the head. With no head frame or no face box, `lockUnread`
|
|
2751
|
+
* says why nothing was read.
|
|
2752
|
+
*
|
|
2148
2753
|
* Then the torso, which the reference did not lint: a single bone `hip` that
|
|
2149
2754
|
* is not below a single bone `chest` (larger y), and a `hip` above
|
|
2150
2755
|
* `HIP_MIN_FRACTION` of the figure's height (every part's union, top row to
|
|
@@ -2203,6 +2808,52 @@ export function lint(
|
|
|
2203
2808
|
if (!isOn(mask, x, y)) findings.push({ kind: 'off-art', bone: n, mesh: mname, at: [x, y] });
|
|
2204
2809
|
}
|
|
2205
2810
|
}
|
|
2811
|
+
// Issue #249: a lock the head carries runs along the head's down axis. The links read are those of every chain
|
|
2812
|
+
// hung from head that a head-run front hair part's mesh names, when the chain's tip lies no lower along that axis
|
|
2813
|
+
// than the chin (the face box's bottom centre): a strand that hangs past the chin hangs with its weight, not with
|
|
2814
|
+
// the head, and is not held to the head's axis. The roll is headRollOf's, the one derivation.
|
|
2815
|
+
const lockUnread: string[] = [];
|
|
2816
|
+
const lockMeshes = Object.entries(spec.meshes).filter(([mname]) => {
|
|
2817
|
+
const part = parts.get(mname);
|
|
2818
|
+
return part !== undefined && splitFrom(part.from)[0] === 'head' && splitFrom(part.from)[1] === 'front hair';
|
|
2819
|
+
});
|
|
2820
|
+
const lockChains = lockMeshes.flatMap(([mname, m]) =>
|
|
2821
|
+
m.segments.flatMap((sp) => {
|
|
2822
|
+
if (typeof sp !== 'string') return [];
|
|
2823
|
+
const e = spec.bones.find((b) => 'chain' in b && b.chain === sp);
|
|
2824
|
+
return e !== undefined && 'chain' in e && e.parent === 'head' ? [{ mesh: mname, entry: e }] : [];
|
|
2825
|
+
}),
|
|
2826
|
+
);
|
|
2827
|
+
if (lockChains.length > 0) {
|
|
2828
|
+
const roll = headRollOf(spec.bones, P.H);
|
|
2829
|
+
let chin: [number, number] | null = null;
|
|
2830
|
+
try {
|
|
2831
|
+
const [fx0, fy0, fw, fh] = faceBoxOf(P).box;
|
|
2832
|
+
chin = [fx0 + fw / 2, fy0 + fh];
|
|
2833
|
+
} catch (err) {
|
|
2834
|
+
if (!(err instanceof PartsError)) throw err;
|
|
2835
|
+
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`);
|
|
2836
|
+
}
|
|
2837
|
+
if ('none' in roll) lockUnread.push(`${roll.none}, so no chain was read against the head's down axis`);
|
|
2838
|
+
else if (chin !== null) {
|
|
2839
|
+
const ax = headAxes(roll.degrees, P.H);
|
|
2840
|
+
const mid: [number, number] = [(roll.eyes[0][0] + roll.eyes[1][0]) / 2, (roll.eyes[0][1] + roll.eyes[1][1]) / 2];
|
|
2841
|
+
const chinD = ax.of(mid, chin)[1];
|
|
2842
|
+
for (const { mesh, entry } of lockChains) {
|
|
2843
|
+
if (ax.of(mid, entry.tip)[1] > chinD) continue;
|
|
2844
|
+
const pts = [...entry.points, entry.tip];
|
|
2845
|
+
entry.points.forEach((q, i) => {
|
|
2846
|
+
const bone = `${entry.chain}${i}`;
|
|
2847
|
+
readBy(bone, `off-lock on mesh ${pyRepr(mesh)}`);
|
|
2848
|
+
const [across, down] = ax.of(q, pts[i + 1]);
|
|
2849
|
+
const degrees = (Math.atan2(Math.abs(across), down) * 180) / Math.PI;
|
|
2850
|
+
// A drift of FRINGE_STEP_PX across, and each end within sqrt(2)/2 px of the point it was rounded from.
|
|
2851
|
+
const limit = down > 0 ? (Math.atan2(FRINGE_STEP_PX + Math.SQRT2, down) * 180) / Math.PI : null;
|
|
2852
|
+
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 });
|
|
2853
|
+
});
|
|
2854
|
+
}
|
|
2855
|
+
}
|
|
2856
|
+
}
|
|
2206
2857
|
const single = (n: string): [number, number] | null => {
|
|
2207
2858
|
const e = spec.bones.find((b) => 'name' in b && b.name === n);
|
|
2208
2859
|
return e !== undefined && 'name' in e ? [e.at[0], e.at[1]] : null;
|
|
@@ -2254,13 +2905,14 @@ export function lint(
|
|
|
2254
2905
|
}
|
|
2255
2906
|
}
|
|
2256
2907
|
const coverage = lintCoverage(spec, byName, read, findings, unknownMeshes, basis);
|
|
2257
|
-
return basis === undefined ? { findings, unknownMeshes, missingTorsoBones, coverage } : { findings, unknownMeshes, missingTorsoBones, basis, coverage };
|
|
2908
|
+
return basis === undefined ? { findings, unknownMeshes, lockUnread, missingTorsoBones, coverage } : { findings, unknownMeshes, lockUnread, missingTorsoBones, basis, coverage };
|
|
2258
2909
|
}
|
|
2259
2910
|
|
|
2260
2911
|
/** 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. */
|
|
2261
2912
|
function findingBones(f: LintFinding, bones: readonly BoneEntry[]): string[] {
|
|
2262
2913
|
switch (f.kind) {
|
|
2263
2914
|
case 'off-art':
|
|
2915
|
+
case 'off-lock':
|
|
2264
2916
|
return [f.bone];
|
|
2265
2917
|
case 'hip-not-below-chest':
|
|
2266
2918
|
case 'hip-not-past-chest':
|
|
@@ -2417,6 +3069,14 @@ export function lintLine(f: LintFinding): string {
|
|
|
2417
3069
|
switch (f.kind) {
|
|
2418
3070
|
case 'off-art':
|
|
2419
3071
|
return `LINT ${f.bone} at ${pyIntList(f.at)} is off the art of mesh ${pyRepr(f.mesh)}`;
|
|
3072
|
+
case 'off-lock':
|
|
3073
|
+
return (
|
|
3074
|
+
`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)}), ` +
|
|
3075
|
+
(f.limit === null
|
|
3076
|
+
? 'and does not reach down it at all'
|
|
3077
|
+
: `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)`) +
|
|
3078
|
+
`: a lock of mesh ${pyRepr(f.mesh)} the head carries runs along the head, and this link leaves it`
|
|
3079
|
+
);
|
|
2420
3080
|
case 'hip-not-below-chest':
|
|
2421
3081
|
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`;
|
|
2422
3082
|
case 'hip-too-high':
|