@rig-c/parts 1.9.1 → 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 +2 -1
- package/cli.ts +128 -15
- package/docs/AUTHORING.md +121 -20
- package/docs/STABILITY.md +16 -7
- package/package.json +1 -1
- package/src/assemble.ts +100 -22
- package/src/build.ts +62 -9
- package/src/check.ts +150 -32
- package/src/config.ts +52 -6
- package/src/contour.ts +40 -2
- package/src/layers.ts +12 -7
- package/src/meshdraft.ts +887 -0
- package/src/parts.ts +41 -10
- package/src/propose.ts +137 -25
- package/src/requirements.ts +21 -6
- package/src/rig.ts +4 -3
- 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
|
|
@@ -130,7 +133,8 @@ 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';
|
|
134
138
|
import { edgeLength } from './length.ts';
|
|
135
139
|
|
|
136
140
|
// ---------------------------------------------------------------------------
|
|
@@ -204,6 +208,46 @@ export const BREATH_FRACTION = 0.025;
|
|
|
204
208
|
/** The breath's amplitude the reference drafted on every figure, kept only where the trunk has no length to scale by. */
|
|
205
209
|
export const REFERENCE_BREATH_AMP = 1.3;
|
|
206
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
|
+
}
|
|
250
|
+
|
|
207
251
|
export interface Proposal {
|
|
208
252
|
bones: BoneEntry[];
|
|
209
253
|
meshes: Record<string, MeshSpec>;
|
|
@@ -390,48 +434,57 @@ export type TurnRuns = Partial<Record<'full' | 'head', LayerSet>>;
|
|
|
390
434
|
|
|
391
435
|
/**
|
|
392
436
|
* Draft `turn` (issue #251, `src/turn.ts`) from the parts and the See-through
|
|
393
|
-
* runs they came from: each
|
|
394
|
-
* layer of
|
|
395
|
-
* is {@link turnPivot} over the parts'
|
|
396
|
-
* layer and so no depth: it is not
|
|
397
|
-
* 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:
|
|
398
443
|
*
|
|
399
444
|
* - `PROPOSE_TURN_RUN_GIVEN`: a part from a run whose layers were not given.
|
|
400
445
|
* - `PROPOSE_TURN_LAYER_PRESENT`: the run given holds no layer of the part's tag.
|
|
401
446
|
* - `PROPOSE_TURN_DEPTH_PRESENT`: the layer carries no depth (a PSD carries none).
|
|
402
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
|
+
*
|
|
403
454
|
* With no part to carry, or every carried part at one depth (nothing nearer
|
|
404
455
|
* than the pivot), no `turn` is drafted and a note says why. Otherwise one note
|
|
405
456
|
* states the pivot and its rule, the law, and every part's fraction with the
|
|
406
|
-
* 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.
|
|
407
460
|
*/
|
|
408
461
|
export function draftTurn(P: PartSet, runs: TurnRuns): { turn: TurnSpec | null; notes: string[] } {
|
|
409
462
|
const problems: Problem[] = [];
|
|
410
|
-
const
|
|
463
|
+
const read: Array<{ part: string; run: 'full' | 'head'; tag: string; base: string | null; own: number; area: number; order: number; piece: boolean }> = [];
|
|
411
464
|
const patches: string[] = [];
|
|
412
465
|
const unread = new Map<string, string[]>();
|
|
413
|
-
|
|
466
|
+
P.recs.forEach((r, order) => {
|
|
414
467
|
const [run, tag] = splitFrom(r.from);
|
|
415
468
|
if (run === PAINTING_RUN) {
|
|
416
469
|
patches.push(r.name);
|
|
417
|
-
|
|
470
|
+
return;
|
|
418
471
|
}
|
|
419
472
|
const set = run === 'full' || run === 'head' ? runs[run] : undefined;
|
|
420
473
|
if (set === undefined) {
|
|
421
474
|
unread.set(run, [...(unread.get(run) ?? []), r.name]);
|
|
422
|
-
|
|
475
|
+
return;
|
|
423
476
|
}
|
|
424
477
|
const layer = set.layers.find((l) => l.name === tag);
|
|
425
478
|
if (layer === undefined) {
|
|
426
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` });
|
|
427
|
-
|
|
480
|
+
return;
|
|
428
481
|
}
|
|
429
482
|
if (layer.depth === null) {
|
|
430
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` });
|
|
431
|
-
|
|
484
|
+
return;
|
|
432
485
|
}
|
|
433
|
-
|
|
434
|
-
}
|
|
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
|
+
});
|
|
435
488
|
for (const [run, names] of unread) {
|
|
436
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` });
|
|
437
490
|
}
|
|
@@ -440,6 +493,22 @@ export function draftTurn(P: PartSet, runs: TurnRuns): { turn: TurnSpec | null;
|
|
|
440
493
|
if (patches.length > 0) {
|
|
441
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`);
|
|
442
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
|
+
});
|
|
443
512
|
const pivot = turnPivot(rows);
|
|
444
513
|
const depths = Object.create(null) as Record<string, number>;
|
|
445
514
|
for (const r of rows) depths[r.part] = r.depth;
|
|
@@ -455,17 +524,36 @@ export function draftTurn(P: PartSet, runs: TurnRuns): { turn: TurnSpec | null;
|
|
|
455
524
|
const turn: TurnSpec = { pivot, depths };
|
|
456
525
|
const fr = turnFractions(turn, rows.map((r) => r.part));
|
|
457
526
|
notes.push(
|
|
458
|
-
`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). ` +
|
|
459
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(', ')}`,
|
|
460
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
|
+
}
|
|
461
542
|
notes.push(...TURN_DEPTH_CAVEAT);
|
|
462
543
|
return { turn, notes };
|
|
463
544
|
}
|
|
464
545
|
|
|
465
|
-
/**
|
|
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. */
|
|
466
554
|
export const TURN_DEPTH_CAVEAT: readonly string[] = [
|
|
467
|
-
"turn: depth is the See-through layer's median
|
|
468
|
-
"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)",
|
|
469
557
|
];
|
|
470
558
|
|
|
471
559
|
/** The proposal with `turn` written after `motion` and `notes` extended; the proposal itself when `turn` is null. */
|
|
@@ -1013,8 +1101,8 @@ const SIDES = [
|
|
|
1013
1101
|
* which rule stood in; without it every value and note is the one this
|
|
1014
1102
|
* function wrote before the parameter existed.
|
|
1015
1103
|
*/
|
|
1016
|
-
export function propose(P: PartSet, joints?: RigJoints, wristSnap: number | null = null): Proposal {
|
|
1017
|
-
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;
|
|
1018
1106
|
}
|
|
1019
1107
|
|
|
1020
1108
|
/**
|
|
@@ -1027,7 +1115,7 @@ export function propose(P: PartSet, joints?: RigJoints, wristSnap: number | null
|
|
|
1027
1115
|
* `joints`: see {@link armsFromJoints}. `null` is the built-in tolerance
|
|
1028
1116
|
* alone, and every value and note is the one written before it existed.
|
|
1029
1117
|
*/
|
|
1030
|
-
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 } {
|
|
1031
1119
|
// Issue #230: a piece is not given its base's rule a second time, which
|
|
1032
1120
|
// needs to know which part is the piece. A file that cannot say is refused
|
|
1033
1121
|
// here, and only here: every other reader of parts.json still takes it.
|
|
@@ -1285,9 +1373,13 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
|
|
|
1285
1373
|
{ bone: 'neck', prop: 'rotate', amp: 0.35, period: 4.0, phase: 0.08 },
|
|
1286
1374
|
);
|
|
1287
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>();
|
|
1288
1379
|
const gridR = (p: PartRecord): [number, number] => {
|
|
1289
|
-
const g =
|
|
1290
|
-
|
|
1380
|
+
const [g, r] = partGrid(p);
|
|
1381
|
+
ownGrid.set(p.name, g);
|
|
1382
|
+
return [gridRule === 'part' ? g : Math.max(g, fig.grid), r];
|
|
1291
1383
|
};
|
|
1292
1384
|
|
|
1293
1385
|
// ---- back hair
|
|
@@ -1940,6 +2032,9 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
|
|
|
1940
2032
|
regions[p.name] = cy < neckY ? 'head' : cy < hip[1] ? 'chest' : 'hip';
|
|
1941
2033
|
if (splitFrom(p.from)[0] === PAINTING_RUN) {
|
|
1942
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`);
|
|
1943
2038
|
} else if (p.cut_from !== undefined) {
|
|
1944
2039
|
notes.push(
|
|
1945
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`,
|
|
@@ -1955,6 +2050,10 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
|
|
|
1955
2050
|
);
|
|
1956
2051
|
}
|
|
1957
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
|
+
}
|
|
1958
2057
|
if (joints !== undefined) {
|
|
1959
2058
|
notes.push(
|
|
1960
2059
|
...jointNotes(joints, {
|
|
@@ -1980,6 +2079,19 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
|
|
|
1980
2079
|
return { proposal: { bones, meshes, regions, motion: blink === undefined ? { duration: IDLE_SECONDS, tracks: idleTracks } : { duration: IDLE_SECONDS, tracks: idleTracks, blink }, notes }, basis: { frame, bones: records } };
|
|
1981
2080
|
}
|
|
1982
2081
|
|
|
2082
|
+
/**
|
|
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.
|
|
2085
|
+
*/
|
|
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
|
+
|
|
1983
2095
|
/**
|
|
1984
2096
|
* The drafted breath (issues #252, #270): `chest` `translatey`, its travel peak
|
|
1985
2097
|
* to peak {@link BREATH_FRACTION} of the torso's length — the distance from the
|
package/src/requirements.ts
CHANGED
|
@@ -170,6 +170,15 @@ export interface RequirementsFile {
|
|
|
170
170
|
* and names each in its line. Empty when not declared: every part hangs.
|
|
171
171
|
*/
|
|
172
172
|
rises: string[];
|
|
173
|
+
/**
|
|
174
|
+
* Issue #245: the swinging parts whose tip is at rest — a hand lying on the
|
|
175
|
+
* bed while the shoulder moves with the breath — by name. `TIP_OVER_ROOT`
|
|
176
|
+
* holds each to the opposite relation, its tip half travelling no further
|
|
177
|
+
* than its root half, and `CHAIN_LAG` holds the chain carrying each to
|
|
178
|
+
* amplitudes that do not grow toward the tip; each line names them first.
|
|
179
|
+
* Empty when not declared: every swinging part's tip swings.
|
|
180
|
+
*/
|
|
181
|
+
rests: string[];
|
|
173
182
|
}
|
|
174
183
|
|
|
175
184
|
/** What `check --requirements` and `build --requirements` name, said once for the help and the refusals. */
|
|
@@ -205,8 +214,8 @@ const KIND_FIELDS: Record<RequirementKind, readonly string[]> = {
|
|
|
205
214
|
|
|
206
215
|
const NAME = /^[A-Za-z][A-Za-z0-9_]*$/;
|
|
207
216
|
|
|
208
|
-
/** The
|
|
209
|
-
export const EXCEPTION_LISTS = ['not_planted', 'not_swinging', 'rises'] as const;
|
|
217
|
+
/** The lists of parts a scene excepts from a bar that assumes a standing figure (issues #231, #245), in the order they are read. */
|
|
218
|
+
export const EXCEPTION_LISTS = ['not_planted', 'not_swinging', 'rises', 'rests'] as const;
|
|
210
219
|
type ExceptionList = (typeof EXCEPTION_LISTS)[number];
|
|
211
220
|
|
|
212
221
|
/** What each list's absence declares. */
|
|
@@ -214,14 +223,16 @@ const EXCEPTION_ABSENT: Record<ExceptionList, string> = {
|
|
|
214
223
|
not_planted: 'every footwear part a planted foot',
|
|
215
224
|
not_swinging: 'every handwear and bottomwear part one that swings',
|
|
216
225
|
rises: 'every swinging part one that hangs',
|
|
226
|
+
rests: 'every swinging part one whose tip swings further than its root',
|
|
217
227
|
};
|
|
218
228
|
|
|
219
229
|
/**
|
|
220
230
|
* Read and shape-check a requirements file. Every problem is collected before
|
|
221
231
|
* one refusal: the file, `spec`, `fps` (required, a positive number: no
|
|
222
232
|
* default), `targets` (optional), `requirements` (required, possibly empty)
|
|
223
|
-
* and `not_planted`, `not_swinging` and `
|
|
224
|
-
* non-empty list of distinct part names, no part
|
|
233
|
+
* and `not_planted`, `not_swinging`, `rises` and `rests` (each optional,
|
|
234
|
+
* issues #231 and #245: a non-empty list of distinct part names, no part both
|
|
235
|
+
* in `not_swinging` and in `rises` or `rests`;
|
|
225
236
|
* which parts they are is read against parts.json and the rig by `check`),
|
|
226
237
|
* each entry's every field and bar by name, a key the format does not read,
|
|
227
238
|
* and a name used twice. Resolving the names against the rig is
|
|
@@ -246,7 +257,7 @@ export function readRequirements(path: string): RequirementsFile {
|
|
|
246
257
|
problems.push({ code: 'REQUIREMENTS_FIELD', object, detail });
|
|
247
258
|
};
|
|
248
259
|
if (raw.spec !== REQUIREMENTS_SPEC) field(`${at} field "spec"`, `is ${JSON.stringify(raw.spec)}; ${JSON.stringify(REQUIREMENTS_SPEC)} is required`);
|
|
249
|
-
for (const k of Object.keys(raw)) if (!['spec', 'fps', 'targets', 'requirements', ...EXCEPTION_LISTS].includes(k) && !freeKey(k, raw[k])) field(`${at} field "${k}"`, 'is not a field this format reads (spec, fps, targets, requirements, not_planted, not_swinging, rises; note or *_note as a string; x-<name> as a record)');
|
|
260
|
+
for (const k of Object.keys(raw)) if (!['spec', 'fps', 'targets', 'requirements', ...EXCEPTION_LISTS].includes(k) && !freeKey(k, raw[k])) field(`${at} field "${k}"`, 'is not a field this format reads (spec, fps, targets, requirements, not_planted, not_swinging, rises, rests; note or *_note as a string; x-<name> as a record)');
|
|
250
261
|
let fps = NaN;
|
|
251
262
|
if (!(finite(raw.fps) && raw.fps > 0)) field(`${at} field "fps"`, `is ${JSON.stringify(raw.fps)}; a positive number of frames per second is required — every named animation is sampled at it, and there is no default`);
|
|
252
263
|
else fps = raw.fps;
|
|
@@ -392,8 +403,12 @@ export function readRequirements(path: string): RequirementsFile {
|
|
|
392
403
|
lists.rises.forEach((n, i) => {
|
|
393
404
|
if (lists.not_swinging.includes(n)) field(`${at} field "rises"[${i}]`, `names "${n}", which "not_swinging" names too; a part that does not swing has no tip to read up or down`);
|
|
394
405
|
});
|
|
406
|
+
// Issue #245: rests is a reading (the tip no further than the root) and not_swinging is none, so a part cannot be both.
|
|
407
|
+
lists.rests.forEach((n, i) => {
|
|
408
|
+
if (lists.not_swinging.includes(n)) field(`${at} field "rests"[${i}]`, `names "${n}", which "not_swinging" names too; a part declared not swinging is not measured, so there is no tip to hold at rest — leave it in one list`);
|
|
409
|
+
});
|
|
395
410
|
refuseIfAny(problems);
|
|
396
|
-
return { path: at, fps, targets, requirements, not_planted: lists.not_planted, not_swinging: lists.not_swinging, rises: lists.rises };
|
|
411
|
+
return { path: at, fps, targets, requirements, not_planted: lists.not_planted, not_swinging: lists.not_swinging, rises: lists.rises, rests: lists.rests };
|
|
397
412
|
}
|
|
398
413
|
|
|
399
414
|
// ---------------------------------------------------------------------------
|
package/src/rig.ts
CHANGED
|
@@ -638,7 +638,8 @@ export interface ContourMeshReport {
|
|
|
638
638
|
exponent?: number;
|
|
639
639
|
/** Only when the mesh declared ribs (issue #188): what it declared and what was placed. Absent otherwise, so a mesh without ribs writes the row it always wrote. */
|
|
640
640
|
ribs?: RibsRow;
|
|
641
|
-
|
|
641
|
+
/** `maxEdge` only when the mesh declared it (issue #265), so a mesh without it writes the row it always wrote. */
|
|
642
|
+
params: { tolerance: number; margin: number; spacing: number; maxEdge?: number; budget: number | null; stray: number | null };
|
|
642
643
|
contour: ContourReport;
|
|
643
644
|
regions: Array<{ name: string; bone: string; reached: number; whole: number }>;
|
|
644
645
|
/** Only under `rule: "heat"` (issue #161), as the lattice row's. */
|
|
@@ -1335,7 +1336,7 @@ export function buildRig(
|
|
|
1335
1336
|
: { name: rg.name, shape: 'polygon', points: rg.points.map(([x, y]) => [x - ox, y - oy] as [number, number]), spacing: rg.spacing, band: rg.band },
|
|
1336
1337
|
);
|
|
1337
1338
|
const ribs = chainRibs(ribSpec.segments, ribSpec.ribs, ox, oy);
|
|
1338
|
-
const cm = contourMesh(p.name, { width: w, height: h, alpha }, { threshold: ART_ALPHA, tolerance: spec.tolerance, margin: spec.margin, spacing: spec.spacing, budget: spec.budget, stray: spec.stray, regions: inPart, ...(ribs.length === 0 ? {} : { ribs }) });
|
|
1339
|
+
const cm = contourMesh(p.name, { width: w, height: h, alpha }, { threshold: ART_ALPHA, tolerance: spec.tolerance, margin: spec.margin, spacing: spec.spacing, ...(spec.maxEdge === undefined ? {} : { maxEdge: spec.maxEdge }), budget: spec.budget, stray: spec.stray, regions: inPart, ...(ribs.length === 0 ? {} : { ribs }) });
|
|
1339
1340
|
if (Array.isArray(cm)) {
|
|
1340
1341
|
out.push(...cm);
|
|
1341
1342
|
return null;
|
|
@@ -1401,7 +1402,7 @@ export function buildRig(
|
|
|
1401
1402
|
mode: 'contour',
|
|
1402
1403
|
...(exponent === undefined ? {} : { exponent }),
|
|
1403
1404
|
...(ribSpec.ribs === undefined ? {} : { ribs: ribsRow(ribSpec.ribs, cm.ribs ?? []) }),
|
|
1404
|
-
params: { tolerance: spec.tolerance, margin: spec.margin, spacing: spec.spacing, budget: spec.budget ?? null, stray: spec.stray ?? null },
|
|
1405
|
+
params: { tolerance: spec.tolerance, margin: spec.margin, spacing: spec.spacing, ...(spec.maxEdge === undefined ? {} : { maxEdge: spec.maxEdge }), budget: spec.budget ?? null, stray: spec.stray ?? null },
|
|
1405
1406
|
contour: rep,
|
|
1406
1407
|
regions: regions.map((rg, k) => ({ name: rg.name, bone: rg.bone, reached: reached[k], whole: whole[k] })),
|
|
1407
1408
|
...(heat === null ? {} : { heat: heatRow(heat) }),
|
package/src/tags.ts
CHANGED
|
@@ -26,6 +26,22 @@
|
|
|
26
26
|
* Nothing downstream classifies a part by its NAME. The role of a part — what
|
|
27
27
|
* a proposer may do with it — is read from the tag it came from, which is why
|
|
28
28
|
* `parts.json` records `from` for every part.
|
|
29
|
+
*
|
|
30
|
+
* ⭐ **An author-made layer** (issue #256) is a layer the author split or
|
|
31
|
+
* painted by hand outside the tool — a hand laid over a hip taken out of
|
|
32
|
+
* `handwear`, a loop of hair over a shoulder taken out of `back hair` — and
|
|
33
|
+
* added to a run. It is named `author:<label>` ({@link AUTHOR_PREFIX}), the
|
|
34
|
+
* label the author's own. It is not a See-through tag and carries no role:
|
|
35
|
+
* {@link readTag} never reads it, so no rule that reads roles from tags ever
|
|
36
|
+
* sees it, and every reader that admits a layer reads it through
|
|
37
|
+
* {@link readLayerTag}, which says `group: "author"`. Why a prefix and this
|
|
38
|
+
* one: a single reserved name would let a run hold one such layer (a tag
|
|
39
|
+
* names one layer, `LAYERS_NAME_UNIQUE`, and one layer makes one part); no
|
|
40
|
+
* v3 tag holds a colon, so the prefix cannot be read as a See-through tag or
|
|
41
|
+
* as one of its `-r`/`-l` halves; and `<origin>:<name>` is already how
|
|
42
|
+
* `parts.json` says "not a See-through layer" for a patch (`painting:<name>`).
|
|
43
|
+
* Every reader of a part's `from` splits at the first colon, so
|
|
44
|
+
* `full:author:hand` is the full run's layer `author:hand`.
|
|
29
45
|
*/
|
|
30
46
|
|
|
31
47
|
export const BODY_TAGS = [
|
|
@@ -119,3 +135,73 @@ export function pairOf(name: string): string | null {
|
|
|
119
135
|
if (t === null || t.side === null) return null;
|
|
120
136
|
return `${t.base}-${t.side === 'r' ? 'l' : 'r'}`;
|
|
121
137
|
}
|
|
138
|
+
|
|
139
|
+
/** The prefix of an author-made layer's name (issue #256): `author:<label>`. */
|
|
140
|
+
export const AUTHOR_PREFIX = 'author:';
|
|
141
|
+
|
|
142
|
+
/** An author-made layer's name read: the label after {@link AUTHOR_PREFIX}, and no tag, so no role. */
|
|
143
|
+
export interface AuthorTagReading {
|
|
144
|
+
/** The name as the layer carries it: `author:hand`. */
|
|
145
|
+
name: string;
|
|
146
|
+
/** What follows the prefix: the author's own word, read by nothing as a role. */
|
|
147
|
+
label: string;
|
|
148
|
+
group: 'author';
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** What a layer name reads as: a See-through v3 tag, or an author-made layer's name. */
|
|
152
|
+
export type LayerTagReading = TagReading | AuthorTagReading;
|
|
153
|
+
|
|
154
|
+
/** What an author-made layer's label must be, as a refusal quotes it. */
|
|
155
|
+
export const AUTHOR_LABEL_RULE = 'a label that is not empty, does not start with "." or whitespace, does not end with whitespace, and holds no slash, backslash or colon';
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Whether `label` may follow {@link AUTHOR_PREFIX}. It becomes part of a part
|
|
159
|
+
* name the plan proposes and of a file name the wrapper form may look for
|
|
160
|
+
* (`parts/<name>.png`), so it is held to what a part name may be, and it holds
|
|
161
|
+
* no colon, so the name keeps exactly one.
|
|
162
|
+
*/
|
|
163
|
+
export function authorLabelOk(label: string): boolean {
|
|
164
|
+
return label !== '' && !label.startsWith('.') && label.trim() === label && !/[\\/:]/.test(label);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Read a layer name as an author-made layer's, or `null` when it is not one (no prefix, or a label {@link authorLabelOk} refuses). */
|
|
168
|
+
export function readAuthorTag(name: string): AuthorTagReading | null {
|
|
169
|
+
if (!name.startsWith(AUTHOR_PREFIX)) return null;
|
|
170
|
+
const label = name.slice(AUTHOR_PREFIX.length);
|
|
171
|
+
return authorLabelOk(label) ? { name, label, group: 'author' } : null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Read a layer name as a v3 tag or an author-made layer's name, or `null` when it is neither. */
|
|
175
|
+
export function readLayerTag(name: string): LayerTagReading | null {
|
|
176
|
+
return readTag(name) ?? readAuthorTag(name);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Whether a tag string names an author-made layer. */
|
|
180
|
+
export function isAuthorTag(name: string): boolean {
|
|
181
|
+
return readAuthorTag(name) !== null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Why `name` is no layer tag, as the detail of a `*_TAG_KNOWN` refusal: an
|
|
186
|
+
* `author:` name whose label is refused says what the label must be; any
|
|
187
|
+
* other names the accepted v3 tags and the author form.
|
|
188
|
+
*/
|
|
189
|
+
export function layerTagRequirement(name: string): string {
|
|
190
|
+
if (name.startsWith(AUTHOR_PREFIX)) {
|
|
191
|
+
return `is an author-made layer's name with the label ${JSON.stringify(name.slice(AUTHOR_PREFIX.length))}; ${AUTHOR_LABEL_RULE} is required after "${AUTHOR_PREFIX}"`;
|
|
192
|
+
}
|
|
193
|
+
return `is not a See-through v3 tag; one of ${acceptedTagNames().join(', ')} is required, or "${AUTHOR_PREFIX}<label>" for a layer the author made`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* What every stage says of an author-made layer (issue #256), so it never
|
|
198
|
+
* reads as a See-through layer: `layers` prints it per layer, `assemble` per
|
|
199
|
+
* part, and `--propose-plan` and `propose` carry it in their notes.
|
|
200
|
+
*/
|
|
201
|
+
export const AUTHOR_NO_ROLE =
|
|
202
|
+
'an author-made layer, not a See-through one: no tag, so no role — it rides the bone the config gives it (config.regions.<part>, or a mesh in config.meshes), and its draw position is its place in config.assemble.plan';
|
|
203
|
+
|
|
204
|
+
/** The `layers` line for an author-made layer. */
|
|
205
|
+
export function authorLayerLine(name: string): string {
|
|
206
|
+
return `author-made: layer "${name}" — ${AUTHOR_NO_ROLE}`;
|
|
207
|
+
}
|
package/src/turn.ts
CHANGED
|
@@ -30,6 +30,37 @@
|
|
|
30
30
|
*/
|
|
31
31
|
import { pyRound } from './round.ts';
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* The layers that ride on the face (issue #271): See-through makes no depth
|
|
35
|
+
* of their own for them, so `propose` does not read theirs. The eye group —
|
|
36
|
+
* eyewhite, irides, eyelash, eyebrow — is cut from one depth map predicted for
|
|
37
|
+
* the combined eyes layer (`compose_list` in See-through's
|
|
38
|
+
* `common/utils/inference_utils.py`, commit df019de), which is why
|
|
39
|
+
* `eyewhite-r` and `eyewhite-l` read the same median in every run measured
|
|
40
|
+
* for #251; nose and mouth are clamped by See-through to the face's median
|
|
41
|
+
* minus {@link TURN_RIDER_OFFSET} when they read behind it (the same file,
|
|
42
|
+
* lines 528-531). Their own medians disagreed across the two runs by up to
|
|
43
|
+
* 0.474 (demo `eyebrow-r`, #251's table), and the disagreement swapped their
|
|
44
|
+
* order against the face. Matched on the layer's base tag, either side.
|
|
45
|
+
*/
|
|
46
|
+
export const TURN_RIDER_TAGS: readonly string[] = ['eyewhite', 'irides', 'eyelash', 'eyebrow', 'nose', 'mouth'];
|
|
47
|
+
|
|
48
|
+
/** The tag of the part the riders take their depth from. */
|
|
49
|
+
export const TURN_CARRIER_TAG = 'face';
|
|
50
|
+
|
|
51
|
+
/** See-through's own offset between a clamped face feature and the face (`face − 0.001`, the file named at {@link TURN_RIDER_TAGS}). */
|
|
52
|
+
export const TURN_RIDER_OFFSET = 0.001;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A rider's depth: its carrier's, {@link TURN_RIDER_OFFSET} nearer when the
|
|
56
|
+
* rider is drawn after the carrier (above it) and as much further when it is
|
|
57
|
+
* drawn before (under it), so the draw order the depths imply is the one
|
|
58
|
+
* parts.json holds.
|
|
59
|
+
*/
|
|
60
|
+
export function turnRiderDepth(carrier: number, above: boolean): number {
|
|
61
|
+
return above ? carrier - TURN_RIDER_OFFSET : carrier + TURN_RIDER_OFFSET;
|
|
62
|
+
}
|
|
63
|
+
|
|
33
64
|
/** The control bone the rig stage adds under `root` when the config has `turn`: nothing keys it, and its local x is the dial. */
|
|
34
65
|
export const TURN_BONE = 'turn';
|
|
35
66
|
|