@rig-c/parts 1.9.0 → 1.10.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 +5 -4
- package/cli.ts +128 -15
- package/docs/AUTHORING.md +128 -25
- package/docs/STABILITY.md +16 -7
- package/package.json +1 -1
- package/src/assemble.ts +100 -22
- package/src/autoenvelope.ts +2 -1
- package/src/automotion.ts +41 -0
- package/src/build.ts +69 -12
- package/src/check.ts +152 -33
- package/src/config.ts +52 -6
- package/src/contour.ts +40 -2
- package/src/headframe.ts +2 -1
- package/src/instruments.ts +9 -5
- package/src/layers.ts +12 -7
- package/src/length.ts +21 -0
- package/src/meshdraft.ts +887 -0
- package/src/parts.ts +41 -10
- package/src/propose.ts +184 -68
- package/src/requirements.ts +24 -8
- package/src/rig.ts +4 -3
- package/src/skeleton.ts +2 -1
- package/src/structure.ts +5 -4
- package/src/tags.ts +86 -0
- package/src/turn.ts +31 -0
package/src/parts.ts
CHANGED
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
* came from (`from` = `<run>:<tag>`, the See-through run and tag — the only
|
|
7
7
|
* thing downstream stages classify a part by — or `painting:<name>` for an
|
|
8
8
|
* `assemble.patches` entry, cut from the painting itself and named for the
|
|
9
|
-
* part it made, which no tag classifies
|
|
9
|
+
* part it made, which no tag classifies; a part from a layer the author made
|
|
10
|
+
* is `<run>:author:<label>`, issue #256, which no tag classifies either),
|
|
11
|
+
* where it sits in rig pixels
|
|
10
12
|
* (`x`/`y`/`w`/`h`, y down, origin top-left: the PNG's own box), and what the
|
|
11
13
|
* assembler did to its pixels, counted:
|
|
12
14
|
*
|
|
@@ -73,7 +75,7 @@
|
|
|
73
75
|
*/
|
|
74
76
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
75
77
|
import { type Problem, refuseIfAny } from './errors.ts';
|
|
76
|
-
import {
|
|
78
|
+
import { readLayerTag } from './tags.ts';
|
|
77
79
|
|
|
78
80
|
export interface PartRecord {
|
|
79
81
|
name: string;
|
|
@@ -134,10 +136,26 @@ export interface PartsFile {
|
|
|
134
136
|
ghost_px: Record<string, number>;
|
|
135
137
|
/** Absent in a `parts.json` the reference wrote; `assemble` always writes it. */
|
|
136
138
|
recomposite?: RecompositeRecord;
|
|
139
|
+
/** Written only under `config.scene` (issue #242): the recomposite was read inside this figure; absent otherwise. */
|
|
140
|
+
scene?: SceneRecord;
|
|
137
141
|
}
|
|
138
142
|
|
|
139
|
-
|
|
140
|
-
|
|
143
|
+
/**
|
|
144
|
+
* `parts.json`'s `scene` block (issue #242): the painting is a scene, so the
|
|
145
|
+
* `recomposite` block beside it was measured inside the figure — the rig pixels
|
|
146
|
+
* where a full-run layer's alpha is above `figure_alpha_above` — and `check`
|
|
147
|
+
* reads `RECOMPOSITE_HOLES` and the seam that way. `figure_px` is the figure's
|
|
148
|
+
* size; `figure_mask` the image `assemble` wrote of it, beside the recomposite.
|
|
149
|
+
*/
|
|
150
|
+
export interface SceneRecord {
|
|
151
|
+
figure_alpha_above: number;
|
|
152
|
+
figure_px: number;
|
|
153
|
+
figure_mask: string;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const TOP_KEYS = ['rig_size', 'scale_rig_per_source', 'parts', 'ghost_px', 'recomposite', 'scene'] as const;
|
|
157
|
+
const REQUIRED_TOP_KEYS = TOP_KEYS.filter((k) => k !== 'recomposite' && k !== 'scene');
|
|
158
|
+
export const SCENE_KEYS = ['figure_alpha_above', 'figure_px', 'figure_mask'] as const;
|
|
141
159
|
export const RECOMPOSITE_KEYS = ['mean_abs', 'within_limit', 'within_share', 'error_limit', 'error_px', 'covered_alpha', 'uncovered_error_px', 'hole_count', 'holes_listed', 'holes'] as const;
|
|
142
160
|
const RECOMPOSITE_COUNTS = ['within_limit', 'error_limit', 'error_px', 'covered_alpha', 'uncovered_error_px', 'hole_count', 'holes_listed'] as const;
|
|
143
161
|
export const HOLE_KEYS = ['px', 'x', 'y', 'w', 'h', 'borders'] as const;
|
|
@@ -170,22 +188,22 @@ const COUNT_KEYS = PART_KEYS.slice(2).filter((k) => k !== FRINGE_KEY && k !== CU
|
|
|
170
188
|
export const VISIBILITY_KEYS = ['visible_px', 'occluded_px', 'visible_not_projected_px'] as const;
|
|
171
189
|
const REQUIRED_KEYS = PART_KEYS.filter((k) => !(VISIBILITY_KEYS as readonly string[]).includes(k) && k !== FRINGE_KEY && k !== CUT_FROM_KEY);
|
|
172
190
|
|
|
173
|
-
/** Where a part came from: a See-through run and a v3 tag, or the painting itself for a patch. */
|
|
191
|
+
/** Where a part came from: a See-through run and a v3 tag or an author-made layer's `author:<label>` (issue #256), or the painting itself for a patch. */
|
|
174
192
|
export type From = { run: 'full' | 'head'; tag: string } | { run: 'painting'; patch: string };
|
|
175
193
|
|
|
176
194
|
/** The `from` a patch's record carries. */
|
|
177
195
|
export const PAINTING_RUN = 'painting';
|
|
178
196
|
|
|
179
|
-
/** `<run>:<tag>` with run `full` or `head` and a v3 tag, `painting:<patch name>`, or null. */
|
|
197
|
+
/** `<run>:<tag>` with run `full` or `head` and a v3 tag or an `author:<label>` (`full:author:hand`), `painting:<patch name>`, or null. */
|
|
180
198
|
export function readFrom(from: string): From | null {
|
|
181
199
|
const p = /^painting:(.+)$/.exec(from);
|
|
182
200
|
if (p !== null) return { run: 'painting', patch: p[1] };
|
|
183
201
|
const m = /^(full|head):(.+)$/.exec(from);
|
|
184
|
-
if (m === null ||
|
|
202
|
+
if (m === null || readLayerTag(m[2]) === null) return null;
|
|
185
203
|
return { run: m[1] as 'full' | 'head', tag: m[2] };
|
|
186
204
|
}
|
|
187
205
|
|
|
188
|
-
/** The
|
|
206
|
+
/** The tag a part came from — a See-through tag, or an author-made layer's `author:<label>`, which no role reader matches — or null for a `painting:` patch (or a `from` that is neither). */
|
|
189
207
|
export function tagOf(from: string): string | null {
|
|
190
208
|
const f = readFrom(from);
|
|
191
209
|
return f === null || f.run === 'painting' ? null : f.tag;
|
|
@@ -207,6 +225,7 @@ export function serializeParts(file: PartsFile): string {
|
|
|
207
225
|
holes: file.recomposite.holes.map((h) => ({ ...Object.fromEntries(HOLE_KEYS.filter((k) => k !== 'borders').map((k) => [k, h[k]])), borders: h.borders.map((b) => ({ part: b.part, px: b.px })) })),
|
|
208
226
|
},
|
|
209
227
|
}),
|
|
228
|
+
...(file.scene === undefined ? {} : { scene: Object.fromEntries(SCENE_KEYS.map((k) => [k, (file.scene as SceneRecord)[k]])) }),
|
|
210
229
|
};
|
|
211
230
|
return `${JSON.stringify(ordered, null, 2)}\n`;
|
|
212
231
|
}
|
|
@@ -257,7 +276,7 @@ function checkParts(raw: unknown, path: string): PartsFile {
|
|
|
257
276
|
if (typeof g !== 'object' || g === null || Array.isArray(g)) fail('PARTS_FIELD_TYPE', `${path} field "ghost_px"`, `is ${show(g)}; an object of "<run>:<tag>" -> pixels is required`);
|
|
258
277
|
else {
|
|
259
278
|
for (const [k, v] of Object.entries(g as Record<string, unknown>)) {
|
|
260
|
-
if (tagOf(k) === null) fail('PARTS_FROM_KNOWN', `${path} ghost_px key "${k}"`, 'is not "<full|head>:<v3 tag>" (a patch has no See-through layer, so no ghost count)');
|
|
279
|
+
if (tagOf(k) === null) fail('PARTS_FROM_KNOWN', `${path} ghost_px key "${k}"`, 'is not "<full|head>:<v3 tag>" or "<full|head>:author:<label>" (a patch has no See-through layer, so no ghost count)');
|
|
261
280
|
if (!(Number.isInteger(v) && (v as number) >= 0)) fail('PARTS_FIELD_TYPE', `${path} ghost_px "${k}"`, `is ${show(v)}; a pixel count is required`);
|
|
262
281
|
}
|
|
263
282
|
}
|
|
@@ -284,7 +303,7 @@ function checkParts(raw: unknown, path: string): PartsFile {
|
|
|
284
303
|
} else if (seen.has(r.name)) fail('PARTS_NAME_UNIQUE', label, `appears at parts[${seen.get(r.name)}] and parts[${i}]`);
|
|
285
304
|
else seen.set(r.name, i);
|
|
286
305
|
const from = typeof r.from === 'string' ? readFrom(r.from) : null;
|
|
287
|
-
if (from === null) fail('PARTS_FROM_KNOWN', `${label} field "from"`, `is ${show(r.from)}; "<full|head>:<v3 tag>", or "painting:<the part's own name>" for a patch, is required`);
|
|
306
|
+
if (from === null) fail('PARTS_FROM_KNOWN', `${label} field "from"`, `is ${show(r.from)}; "<full|head>:<v3 tag>", "<full|head>:author:<label>" for a layer the author made, or "painting:<the part's own name>" for a patch, is required`);
|
|
288
307
|
else if (from.run === 'painting' && from.patch !== r.name) {
|
|
289
308
|
fail('PARTS_FROM_KNOWN', `${label} field "from"`, `is ${show(r.from)}; a patch's provenance names the patch itself, so "painting:${String(r.name)}" is required`);
|
|
290
309
|
}
|
|
@@ -355,6 +374,18 @@ function checkParts(raw: unknown, path: string): PartsFile {
|
|
|
355
374
|
else if (b.from !== r.from) fail('PARTS_CUT_FROM_KNOWN', object, `names "${base}", whose from is ${show(b.from)}; this piece's is ${show(r.from)}, and a piece shares its base's layer`);
|
|
356
375
|
}
|
|
357
376
|
}
|
|
377
|
+
if ('scene' in o) {
|
|
378
|
+
const sc = o.scene;
|
|
379
|
+
if (typeof sc !== 'object' || sc === null || Array.isArray(sc)) fail('PARTS_FIELD_TYPE', `${path} field "scene"`, `is ${show(sc)}; an object is required`);
|
|
380
|
+
else {
|
|
381
|
+
const r = sc as Record<string, unknown>;
|
|
382
|
+
for (const k of Object.keys(r)) if (!(SCENE_KEYS as readonly string[]).includes(k)) fail('PARTS_KEY_KNOWN', `${path} scene key "${k}"`, `is not a scene field; known: ${SCENE_KEYS.join(', ')}`);
|
|
383
|
+
for (const k of SCENE_KEYS) if (!(k in r)) fail('PARTS_FIELD_PRESENT', `${path} scene field "${k}"`, 'is absent and required');
|
|
384
|
+
for (const k of ['figure_alpha_above', 'figure_px'] as const) if (k in r && !(Number.isInteger(r[k]) && (r[k] as number) >= 0)) fail('PARTS_FIELD_TYPE', `${path} scene field "${k}"`, `is ${show(r[k])}; a non-negative integer is required`);
|
|
385
|
+
if ('figure_mask' in r && !(typeof r.figure_mask === 'string' && r.figure_mask !== '')) fail('PARTS_FIELD_TYPE', `${path} scene field "figure_mask"`, `is ${show(r.figure_mask)}; the mask's file name is required`);
|
|
386
|
+
if (!('recomposite' in o)) fail('PARTS_FIELD_PRESENT', `${path} field "recomposite"`, 'is absent; a scene block says how the recomposite block beside it was measured, so it is required with one');
|
|
387
|
+
}
|
|
388
|
+
}
|
|
358
389
|
if ('recomposite' in o && sizeOk) {
|
|
359
390
|
const names = Array.isArray(o.parts) ? new Set(o.parts.map((p) => (typeof p === 'object' && p !== null ? (p as Record<string, unknown>).name : undefined)).filter((n): n is string => typeof n === 'string')) : new Set<string>();
|
|
360
391
|
checkRecomposite(o.recomposite, path, size as [number, number], names, fail);
|
package/src/propose.ts
CHANGED
|
@@ -43,7 +43,10 @@
|
|
|
43
43
|
* strand and what became of it — a strand left without a chain is never
|
|
44
44
|
* silent (issue #24).
|
|
45
45
|
* - anything no rule claims rides its nearest trunk bone as a region —
|
|
46
|
-
* including every
|
|
46
|
+
* including every part from an author-made layer (`<run>:author:<label>`,
|
|
47
|
+
* issue #256), which is no See-through tag and so has no role: no rule
|
|
48
|
+
* reads it, and its note says the bone is the config's to name — and
|
|
49
|
+
* every `painting:` patch (`assemble.patches`), which has no tag,
|
|
47
50
|
* so no rule above ever reads it: it is never a mesh or a chain, it moves
|
|
48
51
|
* no bone the rules place, and the note says the bone is the config's to
|
|
49
52
|
* name in `regions.<name>`. A cut's piece (`assemble.cuts`, recorded with
|
|
@@ -99,8 +102,8 @@
|
|
|
99
102
|
*
|
|
100
103
|
* The idle departs from the reference's too (issue #252): it is
|
|
101
104
|
* {@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
|
-
*
|
|
105
|
+
* with it, and the chest's breath travels {@link BREATH_FRACTION} of the torso's
|
|
106
|
+
* length, neck to hip ({@link breathTrack}; issue #270), rather than a fixed 1.3 px amplitude, so
|
|
104
107
|
* no tracked proposal is the reference's output any more in its `motion`.
|
|
105
108
|
*
|
|
106
109
|
* Issue #86 adds what the proposer and lint looked at, and changes nothing
|
|
@@ -130,7 +133,9 @@ import { IDLE_FPS } from './motion.ts';
|
|
|
130
133
|
import { pyRound as roundTo } from './round.ts';
|
|
131
134
|
import { KEYPOINT_NAMES, type KeypointName } from './skeleton.ts';
|
|
132
135
|
import { configRoles, type RoleOf } from './structure.ts';
|
|
133
|
-
import {
|
|
136
|
+
import { AUTHOR_NO_ROLE, isAuthorTag } from './tags.ts';
|
|
137
|
+
import { TURN_BONE, TURN_CARRIER_TAG, TURN_PLACES, TURN_RIDER_OFFSET, TURN_RIDER_TAGS, turnFractions, turnNearest, turnPivot, turnRiderDepth, type TurnSpec } from './turn.ts';
|
|
138
|
+
import { edgeLength } from './length.ts';
|
|
134
139
|
|
|
135
140
|
// ---------------------------------------------------------------------------
|
|
136
141
|
// the output shape — config-shaped, key order as the reference writes it
|
|
@@ -188,20 +193,60 @@ export const IDLE_SECONDS = IDLE_FRAMES / IDLE_FPS;
|
|
|
188
193
|
*/
|
|
189
194
|
export const REFERENCE_IDLE_SECONDS = 4;
|
|
190
195
|
/**
|
|
191
|
-
* The breath's travel, peak to peak, as a fraction of the torso
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
196
|
+
* The breath's travel, peak to peak, as a fraction of the torso (issue #252):
|
|
197
|
+
* HQ's calibration, about 2.5 %. Issue #252 took the torso as the `topwear`
|
|
198
|
+
* part's height; on the trailer's seated character that is 267 px, and the
|
|
199
|
+
* reference's fixed 2.6 px peak to peak (amplitude 1.3, the base 1.3 so the
|
|
200
|
+
* chest rises from its setup pose and returns) was 0.97 % of it, read by its
|
|
201
|
+
* owner as a still paper doll; the production scene rig that replaced it
|
|
202
|
+
* breathes 6.4 px, 2.4 %. Issue #270 measures the torso as the trunk the rig
|
|
203
|
+
* has instead — the neck bone to the hip bone, {@link breathTrack} — because a
|
|
204
|
+
* garment's box is not the body: `scarf`'s sweater runs to the thighs (690 px)
|
|
205
|
+
* and breathed 2.9 times as deep as `demo`.
|
|
199
206
|
*/
|
|
200
207
|
export const BREATH_FRACTION = 0.025;
|
|
201
|
-
/** The breath's amplitude the reference drafted on every figure, kept only where
|
|
208
|
+
/** The breath's amplitude the reference drafted on every figure, kept only where the trunk has no length to scale by. */
|
|
202
209
|
export const REFERENCE_BREATH_AMP = 1.3;
|
|
203
|
-
|
|
204
|
-
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* How a drafted lattice's spacing (`meshes.<part>.grid`) is chosen (issue #265):
|
|
213
|
+
*
|
|
214
|
+
* - `figure`, the default: one spacing for the figure, `round(H / FIGURE_GRID_DIVISOR)` rig px, H the height of the
|
|
215
|
+
* union of every part's box in parts.json ({@link figureGrid}); a part whose own spacing (the `part` rule) is
|
|
216
|
+
* already coarser keeps its own. `r` is the `part` rule's either way, so the weights a vertex would take at a point
|
|
217
|
+
* do not move, only where the vertices are.
|
|
218
|
+
* - `part`: the reference implementation's rule, a tenth of the part's own short side, clamped to 5..36. The
|
|
219
|
+
* oracle examples (`reference-demo`, `reference-sample`) are proposed under it, because their `proposal.json` holds
|
|
220
|
+
* this proposer to the reference's.
|
|
221
|
+
*/
|
|
222
|
+
export type GridRule = 'figure' | 'part';
|
|
223
|
+
export const GRID_RULES: readonly GridRule[] = ['figure', 'part'];
|
|
224
|
+
export const DEFAULT_GRID_RULE: GridRule = 'figure';
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The divisor of the figure's spacing: Stage A of issue #265 (docs/evidence/density-survey.md) built every lattice
|
|
228
|
+
* of demo, scarf and the public trailer at round(H / c) for c in {26, 37, 52}; 37 is the coarsest at which every
|
|
229
|
+
* `check` bar passed on all three (c 26 failed scarf's TIP_OVER_ROOT). Calibrated on those three figures only, all
|
|
230
|
+
* of similar size (H 1106, 1108 and 1185 rig px).
|
|
231
|
+
*/
|
|
232
|
+
export const FIGURE_GRID_DIVISOR = 37;
|
|
233
|
+
|
|
234
|
+
/** The largest idle displacement Stage A measured at {@link FIGURE_GRID_DIVISOR} against each example's finer lattice, rig px (the trailer's hairpin), which no `check` bar reads. */
|
|
235
|
+
export const FIGURE_GRID_IDLE_PX = 1.427;
|
|
236
|
+
|
|
237
|
+
/** The figure's spacing: the union of every part's box in parts.json, its height H, and round(H / {@link FIGURE_GRID_DIVISOR}). */
|
|
238
|
+
export function figureGrid(recs: readonly PartRecord[]): { top: number; bottom: number; height: number; grid: number } {
|
|
239
|
+
const top = Math.min(...recs.map((r) => r.y));
|
|
240
|
+
const bottom = Math.max(...recs.map((r) => r.y + r.h));
|
|
241
|
+
const height = bottom - top;
|
|
242
|
+
return { top, bottom, height, grid: pyInt(pyRound(height / FIGURE_GRID_DIVISOR)) };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** The `part` rule's spacing and the weight falloff `r` both rules draft (the reference's). */
|
|
246
|
+
export function partGrid(p: { w: number; h: number }): [number, number] {
|
|
247
|
+
const g = pyInt(Math.min(36, Math.max(5, pyRound(Math.min(p.w, p.h) / 10))));
|
|
248
|
+
return [g, Math.max(3, pyInt(pyRound(g * 0.42)))];
|
|
249
|
+
}
|
|
205
250
|
|
|
206
251
|
export interface Proposal {
|
|
207
252
|
bones: BoneEntry[];
|
|
@@ -389,48 +434,57 @@ export type TurnRuns = Partial<Record<'full' | 'head', LayerSet>>;
|
|
|
389
434
|
|
|
390
435
|
/**
|
|
391
436
|
* Draft `turn` (issue #251, `src/turn.ts`) from the parts and the See-through
|
|
392
|
-
* runs they came from: each
|
|
393
|
-
* layer of
|
|
394
|
-
* is {@link turnPivot} over the parts'
|
|
395
|
-
* layer and so no depth: it is not
|
|
396
|
-
* cannot be read for is refused by
|
|
437
|
+
* runs they came from: each carrier's depth is its layer's `depth_median` —
|
|
438
|
+
* the layer of the run its part was assembled from, named by its tag, a cut's
|
|
439
|
+
* piece its base's — and the pivot is {@link turnPivot} over the parts'
|
|
440
|
+
* `opaque_px`. A `painting:` patch has no layer and so no depth: it is not
|
|
441
|
+
* carried, and a note names it. What a depth cannot be read for is refused by
|
|
442
|
+
* name, every one at once:
|
|
397
443
|
*
|
|
398
444
|
* - `PROPOSE_TURN_RUN_GIVEN`: a part from a run whose layers were not given.
|
|
399
445
|
* - `PROPOSE_TURN_LAYER_PRESENT`: the run given holds no layer of the part's tag.
|
|
400
446
|
* - `PROPOSE_TURN_DEPTH_PRESENT`: the layer carries no depth (a PSD carries none).
|
|
401
447
|
*
|
|
448
|
+
* A rider (issue #271, {@link TURN_RIDER_TAGS}: the eye group, nose, mouth)
|
|
449
|
+
* does not carry its own median: it takes the face part's, offset by
|
|
450
|
+
* {@link turnRiderDepth} by draw order. With no face part, or more than one,
|
|
451
|
+
* the riders keep their own medians and a note says so. Every other layer is
|
|
452
|
+
* its own carrier.
|
|
453
|
+
*
|
|
402
454
|
* With no part to carry, or every carried part at one depth (nothing nearer
|
|
403
455
|
* than the pivot), no `turn` is drafted and a note says why. Otherwise one note
|
|
404
456
|
* states the pivot and its rule, the law, and every part's fraction with the
|
|
405
|
-
* depth it came from
|
|
457
|
+
* depth it came from; one lists where each depth came from; and one line names
|
|
458
|
+
* each carrier the other run places more than {@link TURN_RUN_APART} of the
|
|
459
|
+
* span away.
|
|
406
460
|
*/
|
|
407
461
|
export function draftTurn(P: PartSet, runs: TurnRuns): { turn: TurnSpec | null; notes: string[] } {
|
|
408
462
|
const problems: Problem[] = [];
|
|
409
|
-
const
|
|
463
|
+
const read: Array<{ part: string; run: 'full' | 'head'; tag: string; base: string | null; own: number; area: number; order: number; piece: boolean }> = [];
|
|
410
464
|
const patches: string[] = [];
|
|
411
465
|
const unread = new Map<string, string[]>();
|
|
412
|
-
|
|
466
|
+
P.recs.forEach((r, order) => {
|
|
413
467
|
const [run, tag] = splitFrom(r.from);
|
|
414
468
|
if (run === PAINTING_RUN) {
|
|
415
469
|
patches.push(r.name);
|
|
416
|
-
|
|
470
|
+
return;
|
|
417
471
|
}
|
|
418
472
|
const set = run === 'full' || run === 'head' ? runs[run] : undefined;
|
|
419
473
|
if (set === undefined) {
|
|
420
474
|
unread.set(run, [...(unread.get(run) ?? []), r.name]);
|
|
421
|
-
|
|
475
|
+
return;
|
|
422
476
|
}
|
|
423
477
|
const layer = set.layers.find((l) => l.name === tag);
|
|
424
478
|
if (layer === undefined) {
|
|
425
479
|
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
|
-
|
|
480
|
+
return;
|
|
427
481
|
}
|
|
428
482
|
if (layer.depth === null) {
|
|
429
483
|
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
|
-
|
|
484
|
+
return;
|
|
431
485
|
}
|
|
432
|
-
|
|
433
|
-
}
|
|
486
|
+
read.push({ part: r.name, run: run as 'full' | 'head', tag, base: 'base' in layer.tag ? layer.tag.base : null, own: layer.depth, area: r.opaque_px, order, piece: r.cut_from !== undefined });
|
|
487
|
+
});
|
|
434
488
|
for (const [run, names] of unread) {
|
|
435
489
|
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
490
|
}
|
|
@@ -439,6 +493,22 @@ export function draftTurn(P: PartSet, runs: TurnRuns): { turn: TurnSpec | null;
|
|
|
439
493
|
if (patches.length > 0) {
|
|
440
494
|
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
495
|
}
|
|
496
|
+
// Issue #271: the riders take the face's depth; everything else is its own carrier.
|
|
497
|
+
// An author-made layer (issue #256) has no tag, so it is neither a rider nor the carrier: it carries itself at its own depth.
|
|
498
|
+
const isRider = (x: { base: string | null }): boolean => x.base !== null && TURN_RIDER_TAGS.includes(x.base);
|
|
499
|
+
const faces = read.filter((x) => x.base === TURN_CARRIER_TAG && !x.piece);
|
|
500
|
+
const face = faces.length === 1 ? faces[0] : null;
|
|
501
|
+
const riders = read.filter(isRider);
|
|
502
|
+
if (riders.length > 0 && face === null) {
|
|
503
|
+
notes.push(
|
|
504
|
+
`turn: rider(s) ${riders.map((x) => `"${x.part}"`).join(', ')} keep their own See-through depth_median: ${faces.length === 0 ? `no part comes from a "${TURN_CARRIER_TAG}" layer` : `${faces.length} parts (${faces.map((x) => `"${x.part}"`).join(', ')}) come from a "${TURN_CARRIER_TAG}" layer, and which one carries them cannot be read off the parts`} — a value in config.turn.depths sets one`,
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
const rows = read.map((x) => {
|
|
508
|
+
if (face === null || !isRider(x)) return { ...x, depth: x.own, source: `own, ${x.run}:${x.tag}` };
|
|
509
|
+
const above = x.order > face.order;
|
|
510
|
+
return { ...x, depth: turnRiderDepth(face.own, above), source: `"${face.part}" ${above ? '-' : '+'} ${TURN_RIDER_OFFSET}, drawn ${above ? 'above' : 'under'} it; own ${x.run}:${x.tag} ${x.own}` };
|
|
511
|
+
});
|
|
442
512
|
const pivot = turnPivot(rows);
|
|
443
513
|
const depths = Object.create(null) as Record<string, number>;
|
|
444
514
|
for (const r of rows) depths[r.part] = r.depth;
|
|
@@ -454,17 +524,36 @@ export function draftTurn(P: PartSet, runs: TurnRuns): { turn: TurnSpec | null;
|
|
|
454
524
|
const turn: TurnSpec = { pivot, depths };
|
|
455
525
|
const fr = turnFractions(turn, rows.map((r) => r.part));
|
|
456
526
|
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'
|
|
527
|
+
`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' depths weighted by opaque_px, the one depth at which the painted pixels' centroid does not slide (correct it in turn.pivot). ` +
|
|
458
528
|
`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
529
|
);
|
|
530
|
+
notes.push(`turn: where each depth came from — ${rows.map((r) => `${r.part} ${r.depth} (${r.source})`).join('; ')}`);
|
|
531
|
+
// Issue #271: a carrier the other run places elsewhere is named; the run read stays the part's own.
|
|
532
|
+
const span = pivot - nearest.depth;
|
|
533
|
+
for (const r of rows) {
|
|
534
|
+
if (isRider(r) && face !== null) continue;
|
|
535
|
+
const other = r.run === 'full' ? 'head' : 'full';
|
|
536
|
+
const there = runs[other]?.layers.find((l) => l.name === r.tag);
|
|
537
|
+
if (there === undefined || there.depth === null || there.opaquePx === 0 || !(there.depth < 1)) continue;
|
|
538
|
+
if (Math.abs(there.depth - r.own) > TURN_RUN_APART * span) {
|
|
539
|
+
notes.push(`turn: "${r.part}" reads ${r.own} in the ${r.run} run and ${there.depth} in the ${other} run, more than ${TURN_RUN_APART * 100} % of the span ${roundTo(span, TURN_PLACES)} apart; the ${r.run} run it was assembled from is read`);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
460
542
|
notes.push(...TURN_DEPTH_CAVEAT);
|
|
461
543
|
return { turn, notes };
|
|
462
544
|
}
|
|
463
545
|
|
|
464
|
-
/**
|
|
546
|
+
/**
|
|
547
|
+
* The share of the span (`pivot − nearest`) past which a carrier's two runs
|
|
548
|
+
* are said to disagree: issue #271's judgment line, set before it was measured
|
|
549
|
+
* (from #251's record). It only writes a note; the run read does not change.
|
|
550
|
+
*/
|
|
551
|
+
export const TURN_RUN_APART = 0.1;
|
|
552
|
+
|
|
553
|
+
/** The notes beside every drafted turn (issues #251, #271): what a See-through depth is, where it misleads, and that the two runs' axes are not reconciled. */
|
|
465
554
|
export const TURN_DEPTH_CAVEAT: readonly string[] = [
|
|
466
|
-
"turn: depth is the See-through layer's median
|
|
467
|
-
"turn: the two runs' depth axes are not reconciled (parts#271)",
|
|
555
|
+
"turn: depth is the See-through layer's median, a carrier's read from the run its part was assembled from and a rider's (eye group, nose, mouth) taken from the face; a seated or forward-reaching limb can read further back than it is (trailer legwear 0.770 sits behind the pivot)",
|
|
556
|
+
"turn: the two runs' depth axes are not reconciled — a linear map cannot reconcile them, since the runs order the shared layers differently (parts#271)",
|
|
468
557
|
];
|
|
469
558
|
|
|
470
559
|
/** The proposal with `turn` written after `motion` and `notes` extended; the proposal itself when `turn` is null. */
|
|
@@ -1012,8 +1101,8 @@ const SIDES = [
|
|
|
1012
1101
|
* which rule stood in; without it every value and note is the one this
|
|
1013
1102
|
* function wrote before the parameter existed.
|
|
1014
1103
|
*/
|
|
1015
|
-
export function propose(P: PartSet, joints?: RigJoints, wristSnap: number | null = null): Proposal {
|
|
1016
|
-
return proposeWithBasis(P, joints, wristSnap).proposal;
|
|
1104
|
+
export function propose(P: PartSet, joints?: RigJoints, wristSnap: number | null = null, gridRule: GridRule = DEFAULT_GRID_RULE): Proposal {
|
|
1105
|
+
return proposeWithBasis(P, joints, wristSnap, gridRule).proposal;
|
|
1017
1106
|
}
|
|
1018
1107
|
|
|
1019
1108
|
/**
|
|
@@ -1026,7 +1115,7 @@ export function propose(P: PartSet, joints?: RigJoints, wristSnap: number | null
|
|
|
1026
1115
|
* `joints`: see {@link armsFromJoints}. `null` is the built-in tolerance
|
|
1027
1116
|
* alone, and every value and note is the one written before it existed.
|
|
1028
1117
|
*/
|
|
1029
|
-
export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: number | null = null): { proposal: Proposal; basis: ProposalBasis } {
|
|
1118
|
+
export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: number | null = null, gridRule: GridRule = DEFAULT_GRID_RULE): { proposal: Proposal; basis: ProposalBasis } {
|
|
1030
1119
|
// Issue #230: a piece is not given its base's rule a second time, which
|
|
1031
1120
|
// needs to know which part is the piece. A file that cannot say is refused
|
|
1032
1121
|
// here, and only here: every other reader of parts.json still takes it.
|
|
@@ -1270,10 +1359,12 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
|
|
|
1270
1359
|
if (Object.keys(still).length > 0) blink.still = still;
|
|
1271
1360
|
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`);
|
|
1272
1361
|
}
|
|
1273
|
-
// Issue #252: the breath's travel is BREATH_FRACTION of the torso
|
|
1274
|
-
//
|
|
1275
|
-
//
|
|
1276
|
-
|
|
1362
|
+
// Issue #252: the breath's travel is BREATH_FRACTION of the torso, not the reference's fixed 1.3 px amplitude; issue
|
|
1363
|
+
// #270: the torso is the neck -> hip run as the two bones are written above, not a garment's height. Its base stays
|
|
1364
|
+
// its amplitude (the chest rises from the setup pose and returns). The other three tracks are an angle and a ratio
|
|
1365
|
+
// and stay as drafted. A trunk of no length (neck and hip on one point) keeps the reference's amplitude, and a note.
|
|
1366
|
+
const writtenAt = (n: string): Point => (bones.find((b) => 'name' in b && b.name === n) as { at: Point }).at;
|
|
1367
|
+
const breath = breathTrack(writtenAt('neck'), writtenAt('hip'));
|
|
1277
1368
|
notes.push(breath.note);
|
|
1278
1369
|
tracks.push(
|
|
1279
1370
|
breath.track,
|
|
@@ -1282,9 +1373,13 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
|
|
|
1282
1373
|
{ bone: 'neck', prop: 'rotate', amp: 0.35, period: 4.0, phase: 0.08 },
|
|
1283
1374
|
);
|
|
1284
1375
|
|
|
1376
|
+
// Issue #265: the figure's spacing unless the reference's rule is asked for; r is the part rule's either way.
|
|
1377
|
+
const fig = figureGrid(P.recs);
|
|
1378
|
+
const ownGrid = new Map<string, number>();
|
|
1285
1379
|
const gridR = (p: PartRecord): [number, number] => {
|
|
1286
|
-
const g =
|
|
1287
|
-
|
|
1380
|
+
const [g, r] = partGrid(p);
|
|
1381
|
+
ownGrid.set(p.name, g);
|
|
1382
|
+
return [gridRule === 'part' ? g : Math.max(g, fig.grid), r];
|
|
1288
1383
|
};
|
|
1289
1384
|
|
|
1290
1385
|
// ---- back hair
|
|
@@ -1680,7 +1775,7 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
|
|
|
1680
1775
|
const [g, r] = gridR(p);
|
|
1681
1776
|
const segs: Segment[] = [];
|
|
1682
1777
|
for (const a of armPlan.arms.filter((q) => q.part === p)) {
|
|
1683
|
-
const len =
|
|
1778
|
+
const len = edgeLength(a.elbow[0] - a.shoulder[0], a.elbow[1] - a.shoulder[1]);
|
|
1684
1779
|
const d: [number, number] = [(a.elbow[0] - a.shoulder[0]) / len, (a.elbow[1] - a.shoulder[1]) / len];
|
|
1685
1780
|
segs.push(['chest', rnd([a.shoulder[0] - 14 * d[0], a.shoulder[1] - 14 * d[1]]), rnd([a.shoulder[0] + 2 * d[0], a.shoulder[1] + 2 * d[1]])], `sleeve_${a.side}`);
|
|
1686
1781
|
}
|
|
@@ -1919,7 +2014,7 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
|
|
|
1919
2014
|
const segs: Segment[] = [];
|
|
1920
2015
|
for (const c of legPlan.chains.filter((q) => q.part === p)) {
|
|
1921
2016
|
// The sleeve's chest stub, laid along the thigh from the hip: 14 px above the hip to 2 px below it.
|
|
1922
|
-
const len =
|
|
2017
|
+
const len = edgeLength(c.knee[0] - c.hip[0], c.knee[1] - c.hip[1]);
|
|
1923
2018
|
const d: [number, number] = [(c.knee[0] - c.hip[0]) / len, (c.knee[1] - c.hip[1]) / len];
|
|
1924
2019
|
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);
|
|
1925
2020
|
}
|
|
@@ -1937,6 +2032,9 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
|
|
|
1937
2032
|
regions[p.name] = cy < neckY ? 'head' : cy < hip[1] ? 'chest' : 'hip';
|
|
1938
2033
|
if (splitFrom(p.from)[0] === PAINTING_RUN) {
|
|
1939
2034
|
notes.push(`${p.name} (${p.from}): a painting patch has no tag, so no rule -> region on ${regions[p.name]}, the nearest trunk bone; config.regions.${p.name} is the bone it rides`);
|
|
2035
|
+
} else if (isAuthorTag(splitFrom(p.from)[1])) {
|
|
2036
|
+
// Issue #256: no rule reads an author-made layer, and none is guessed for it.
|
|
2037
|
+
notes.push(`${p.name} (${p.from}): ${AUTHOR_NO_ROLE}; no rule reads it -> region on ${regions[p.name]}, the nearest trunk bone, until config.regions.${p.name} names the bone it rides`);
|
|
1940
2038
|
} else if (p.cut_from !== undefined) {
|
|
1941
2039
|
notes.push(
|
|
1942
2040
|
`${p.name} (${p.from}): a piece an assemble.cuts entry took from ${p.cut_from}, so not given that layer's rule a second time (it would name ${p.cut_from}'s bones and chains again) -> region on ${regions[p.name]}, the nearest trunk bone; config.regions.${p.name} is the bone it rides, and a mesh in config.meshes is the author's to give it`,
|
|
@@ -1952,6 +2050,10 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
|
|
|
1952
2050
|
);
|
|
1953
2051
|
}
|
|
1954
2052
|
}
|
|
2053
|
+
if (gridRule === 'figure') {
|
|
2054
|
+
const latt = Object.keys(meshes).filter((m) => 'grid' in meshes[m] && ownGrid.has(m));
|
|
2055
|
+
if (latt.length > 0) notes.push(gridNote(fig, latt.map((m) => [m, ownGrid.get(m) as number])));
|
|
2056
|
+
}
|
|
1955
2057
|
if (joints !== undefined) {
|
|
1956
2058
|
notes.push(
|
|
1957
2059
|
...jointNotes(joints, {
|
|
@@ -1978,31 +2080,45 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
|
|
|
1978
2080
|
}
|
|
1979
2081
|
|
|
1980
2082
|
/**
|
|
1981
|
-
* The
|
|
1982
|
-
*
|
|
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.
|
|
2083
|
+
* The note the `figure` grid rule writes (issue #265): the spacing and where it came from, per lattice part its own
|
|
2084
|
+
* spacing and the one drafted, what it costs and what it was calibrated on.
|
|
1988
2085
|
*/
|
|
1989
|
-
export function
|
|
1990
|
-
const
|
|
1991
|
-
|
|
2086
|
+
export function gridNote(fig: { top: number; bottom: number; height: number; grid: number }, parts: ReadonlyArray<readonly [string, number]>): string {
|
|
2087
|
+
const took = parts.filter(([, g]) => g < fig.grid).map(([m, g]) => `${m} ${g} -> ${fig.grid}`);
|
|
2088
|
+
const kept = parts.filter(([, g]) => g >= fig.grid).map(([m, g]) => `${m} ${g}`);
|
|
2089
|
+
return (
|
|
2090
|
+
`mesh spacing: grid ${fig.grid} = round(H / ${FIGURE_GRID_DIVISOR}), H ${fig.height} rig px the height of every part's box together (rows ${fig.top} to ${fig.bottom}), in place of a tenth of each part's short side (${took.length > 0 ? took.join(', ') : 'none'}; kept, already as coarse: ${kept.length > 0 ? kept.join(', ') : 'none'}); r is the part's own. ` +
|
|
2091
|
+
`Calibrated on three figures of similar size (H 1106-1185 rig px, issue #265 Stage A), where every check bar passed at this divisor and the idle moved by up to ${FIGURE_GRID_IDLE_PX} rig px against the finer lattice — no bar reads that. propose --grid-rule part drafts the part's own spacing`
|
|
2092
|
+
);
|
|
2093
|
+
}
|
|
2094
|
+
|
|
2095
|
+
/**
|
|
2096
|
+
* The drafted breath (issues #252, #270): `chest` `translatey`, its travel peak
|
|
2097
|
+
* to peak {@link BREATH_FRACTION} of the torso's length — the distance from the
|
|
2098
|
+
* `neck` bone's origin to the `hip` bone's, as the proposal writes them (so the
|
|
2099
|
+
* figure can be recomputed from proposal.json): the run the chest is placed
|
|
2100
|
+
* half way along and the trunk segments are laid along (issue #243), whichever
|
|
2101
|
+
* rule placed the two ends (joints, the face and bottomwear, the waist, or the
|
|
2102
|
+
* figure's proportion — the hip's own note names it). Amplitude half of it to
|
|
2103
|
+
* two decimals, and the base the amplitude. When neck and hip are one point the
|
|
2104
|
+
* trunk has no length to take a fraction of, and inventing one is not done: the
|
|
2105
|
+
* track keeps the reference's amplitude, {@link REFERENCE_BREATH_AMP}, and the
|
|
2106
|
+
* note says so.
|
|
2107
|
+
*/
|
|
2108
|
+
export function breathTrack(neck: readonly [number, number], hip: readonly [number, number]): { track: ProposedSingleTrack; note: string } {
|
|
2109
|
+
const length = edgeLength(hip[0] - neck[0], hip[1] - neck[1]);
|
|
2110
|
+
const run = `neck ${pyIntList(neck)} -> hip ${pyIntList(hip)}`;
|
|
2111
|
+
if (length === 0) {
|
|
1992
2112
|
const a = REFERENCE_BREATH_AMP;
|
|
1993
2113
|
return {
|
|
1994
2114
|
track: { bone: 'chest', prop: 'translatey', amp: a, period: REFERENCE_IDLE_SECONDS, phase: 0.0, base: a },
|
|
1995
|
-
note: `breath:
|
|
2115
|
+
note: `breath: ${run} are one point, so the trunk has no length 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
2116
|
};
|
|
1997
2117
|
}
|
|
1998
|
-
const
|
|
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(', ');
|
|
2118
|
+
const amp = roundTo((BREATH_FRACTION * length) / 2, 2);
|
|
2003
2119
|
return {
|
|
2004
2120
|
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
|
|
2121
|
+
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 length, ${pyFixed(length, 1)} px (${run}), once per ${IDLE_FRAMES}-frame idle at ${IDLE_FPS} fps`,
|
|
2006
2122
|
};
|
|
2007
2123
|
}
|
|
2008
2124
|
|
|
@@ -2025,7 +2141,7 @@ interface TrunkLine {
|
|
|
2025
2141
|
function trunkLineOf(neck: readonly [number, number], hip: readonly [number, number]): TrunkLine | null {
|
|
2026
2142
|
const dx = hip[0] - neck[0];
|
|
2027
2143
|
const dy = hip[1] - neck[1];
|
|
2028
|
-
const len =
|
|
2144
|
+
const len = edgeLength(dx, dy);
|
|
2029
2145
|
if (len === 0) return null;
|
|
2030
2146
|
const u: [number, number] = [dx / len, dy / len];
|
|
2031
2147
|
return { u, v: [u[1], -u[0]] };
|
|
@@ -2418,7 +2534,7 @@ function jointNotes(joints: RigJoints, f: JointNoteFacts): string[] {
|
|
|
2418
2534
|
if (j.at === null) return;
|
|
2419
2535
|
const e = f.eyes[i];
|
|
2420
2536
|
if (e === null) tell(n, `${said(n, j)}: no bone takes it — an eye bone comes only from an eyewhite part`);
|
|
2421
|
-
else tell(n, `${said(n, j)}: bone ${e.bone} is measured off ${e.part} (its box centre ${fmtAt(e.at)}) and is not moved; the joint is ${pyFixed(
|
|
2537
|
+
else tell(n, `${said(n, j)}: bone ${e.bone} is measured off ${e.part} (its box centre ${fmtAt(e.at)}) and is not moved; the joint is ${pyFixed(edgeLength(j.at[0] - e.at[0], j.at[1] - e.at[1]), 1)} px from it`);
|
|
2422
2538
|
});
|
|
2423
2539
|
const rest = KEYPOINT_NAMES.filter((n) => !told.has(n));
|
|
2424
2540
|
if (rest.length > 0) out.push(`no rule reads ${rest.map((n) => said(n, J[n])).join(', ')} — no bone is authored from them`);
|
|
@@ -2985,8 +3101,8 @@ export function lint(
|
|
|
2985
3101
|
if (chest !== null && !(along(chest) > 0 && along(chest) < 1)) findings.push({ kind: 'chest-not-between', chest, t: along(chest), neck: T.neck, hips: T.hips });
|
|
2986
3102
|
if (hip !== null && chest !== null && along(hip) <= along(chest)) findings.push({ kind: 'hip-not-past-chest', hip, chest, tHip: along(hip), tChest: along(chest) });
|
|
2987
3103
|
if (hip !== null) {
|
|
2988
|
-
const dNeck =
|
|
2989
|
-
const dHips =
|
|
3104
|
+
const dNeck = edgeLength(hip[0] - T.neck[0], hip[1] - T.neck[1]);
|
|
3105
|
+
const dHips = edgeLength(hip[0] - T.hips[0], hip[1] - T.hips[1]);
|
|
2990
3106
|
if (dNeck < dHips) findings.push({ kind: 'hip-nearer-neck', hip, neck: T.neck, hips: T.hips, dNeck, dHips });
|
|
2991
3107
|
}
|
|
2992
3108
|
} else {
|
|
@@ -3003,7 +3119,7 @@ export function lint(
|
|
|
3003
3119
|
for (const c of basis.chains) {
|
|
3004
3120
|
if (c.line === null) continue;
|
|
3005
3121
|
const { from, to } = c.line;
|
|
3006
|
-
const len =
|
|
3122
|
+
const len = edgeLength(to[0] - from[0], to[1] - from[1]);
|
|
3007
3123
|
const e = spec.bones.find((b) => 'chain' in b && b.chain === c.chain);
|
|
3008
3124
|
if (e === undefined || !('chain' in e)) continue;
|
|
3009
3125
|
const pts: Array<[string, Point]> = [...e.points.map((q, i): [string, Point] => [`${c.chain}${i}`, q]), [`${c.chain} tip`, e.tip]];
|
|
@@ -3215,7 +3331,7 @@ export function compare(proposal: readonly BoneEntry[], config: readonly BoneEnt
|
|
|
3215
3331
|
const rows: Array<[string, number]> = [];
|
|
3216
3332
|
for (const [n, cb] of b) {
|
|
3217
3333
|
const pb = a.get(n);
|
|
3218
|
-
if (pb !== undefined) rows.push([n,
|
|
3334
|
+
if (pb !== undefined) rows.push([n, edgeLength(pb.x - cb.x, pb.y - cb.y)]);
|
|
3219
3335
|
}
|
|
3220
3336
|
return { rows, onlyConfig: [...b.keys()].filter((n) => !a.has(n)), onlyProposal: [...a.keys()].filter((n) => !b.has(n)) };
|
|
3221
3337
|
}
|
|
@@ -3284,7 +3400,7 @@ function line(r: Raster, a: readonly [number, number], b: readonly [number, numb
|
|
|
3284
3400
|
function dot(r: Raster, cx: number, cy: number): void {
|
|
3285
3401
|
for (let dy = -4; dy <= 4; dy++) {
|
|
3286
3402
|
for (let dx = -4; dx <= 4; dx++) {
|
|
3287
|
-
const d =
|
|
3403
|
+
const d = edgeLength(dx, dy);
|
|
3288
3404
|
if (d <= 3.5) setPx(r, Math.round(cx) + dx, Math.round(cy) + dy, [255, 0, 0]);
|
|
3289
3405
|
else if (d <= 4.5) setPx(r, Math.round(cx) + dx, Math.round(cy) + dy, [0, 0, 0]);
|
|
3290
3406
|
}
|