@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/check.ts
CHANGED
|
@@ -73,7 +73,7 @@
|
|
|
73
73
|
*/
|
|
74
74
|
import { copyFileSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
75
75
|
import { basename, delimiter, dirname, join, resolve } from 'node:path';
|
|
76
|
-
import { measureRecomposite, type RecompositeFigures, recompositeRecord, sourceInRig } from './assemble.ts';
|
|
76
|
+
import { measureRecomposite, type RecompositeFigures, recompositeRecord, scenePhrase, sourceInRig } from './assemble.ts';
|
|
77
77
|
import { cropToSpineY } from './coords.ts';
|
|
78
78
|
import { PartsError, type Problem, refuseIfAny } from './errors.ts';
|
|
79
79
|
import { framesInside, IDLE_FPS, TIME_SLACK } from './motion.ts';
|
|
@@ -149,6 +149,7 @@ import { readPng, writePng } from './raster/png.ts';
|
|
|
149
149
|
import { newFloatImage, newRaster, type Raster } from './raster/types.ts';
|
|
150
150
|
import type { BaseTag } from './tags.ts';
|
|
151
151
|
import { type ScaleTranslate, warpAffine } from './raster/warp.ts';
|
|
152
|
+
import { edgeLength } from './length.ts';
|
|
152
153
|
|
|
153
154
|
// ---------------------------------------------------------------------------
|
|
154
155
|
// the bars
|
|
@@ -199,6 +200,16 @@ export const BLINK_PATCH_LEVEL = SEAM_PX_LEVEL;
|
|
|
199
200
|
/** The smallest lag the reading resolves, in cycles: phases are written to three places. */
|
|
200
201
|
export const CHAIN_LAG_MIN_STEP = 0.001;
|
|
201
202
|
export const TIP_RATIO_FLOOR = 1.4725;
|
|
203
|
+
/**
|
|
204
|
+
* Issue #245: the bar a part the scene declares `rests` is held to — its tip
|
|
205
|
+
* half travels no further than its root half, tip / root <= 1, read at the
|
|
206
|
+
* line's precision (three places), and with a root that does not move, a tip
|
|
207
|
+
* that does not either. The declaration's own relation fixes it ("the tip's
|
|
208
|
+
* motion no greater than the root's"), a value the model fixes and not a
|
|
209
|
+
* margin: no example declares `rests`, so the floor-and-ceiling rule above
|
|
210
|
+
* has no figure to halve or double.
|
|
211
|
+
*/
|
|
212
|
+
export const TIP_REST_RATIO_CEILING = 1;
|
|
202
213
|
/**
|
|
203
214
|
* `TEXTURE_STRETCH`'s ceiling on max(ratio, 1/ratio) (issue #31): the worse
|
|
204
215
|
* example's figure (demo, 1.388) squared. The ceiling rule is "twice the worse
|
|
@@ -1051,6 +1062,12 @@ export interface SeamFigures {
|
|
|
1051
1062
|
mean: number;
|
|
1052
1063
|
over40: number;
|
|
1053
1064
|
over80: number;
|
|
1065
|
+
/**
|
|
1066
|
+
* Under a scene (issue #242), the frame pixels on the painting's border that the
|
|
1067
|
+
* figures above leave out ({@link seamFigures}): how many there are, and how many
|
|
1068
|
+
* of them were over {@link SEAM_PX_LEVEL}. Null when the painting is not a scene.
|
|
1069
|
+
*/
|
|
1070
|
+
border: { framePx: number; over40: number } | null;
|
|
1054
1071
|
/** The frame pixels {@link over40} counts, by index into the frame, ascending; each one's max-channel |d| is in {@link countedDiff}. */
|
|
1055
1072
|
counted: number[];
|
|
1056
1073
|
countedDiff: number[];
|
|
@@ -1134,6 +1151,16 @@ export function frameToRigMap(stage: StageBox, rigH: number, vp: Viewport): Scal
|
|
|
1134
1151
|
*
|
|
1135
1152
|
* `map` is for the controls alone (the `check` suite plants the map without
|
|
1136
1153
|
* the centre term, issue #209); every caller in `src/` leaves it to the default.
|
|
1154
|
+
*
|
|
1155
|
+
* `scene` (issue #242; `parts.json`'s `scene` block): the painting is a scene,
|
|
1156
|
+
* and a figure cut by its frame has an edge there that no part meets. A frame
|
|
1157
|
+
* pixel whose bilinear sample of the composite reads the painting's outermost
|
|
1158
|
+
* row or column, or past it — its two-by-two footprint, `floor(u)` and
|
|
1159
|
+
* `floor(u) + 1` with `u = (x - tx) / sx`, reaching column 0 or `W - 1` or
|
|
1160
|
+
* beyond, and the same for rows — is on the painting's border, and is not
|
|
1161
|
+
* counted: not in the mean, not over either level. How many such pixels there
|
|
1162
|
+
* were, and how many of them were over {@link SEAM_PX_LEVEL}, is returned
|
|
1163
|
+
* (`border`) for the line to say.
|
|
1137
1164
|
*/
|
|
1138
1165
|
export function seamFigures(
|
|
1139
1166
|
composite: Raster,
|
|
@@ -1142,6 +1169,7 @@ export function seamFigures(
|
|
|
1142
1169
|
vp: Viewport,
|
|
1143
1170
|
background: readonly [number, number, number, number],
|
|
1144
1171
|
map: ScaleTranslate = rigToFrameMap(stage, composite.height, vp),
|
|
1172
|
+
scene = false,
|
|
1145
1173
|
): SeamFigures {
|
|
1146
1174
|
const [W, H] = [composite.width, composite.height];
|
|
1147
1175
|
const src = newFloatImage(W, H, 3);
|
|
@@ -1153,9 +1181,18 @@ export function seamFigures(
|
|
|
1153
1181
|
let sum = 0;
|
|
1154
1182
|
let over40 = 0;
|
|
1155
1183
|
let over80 = 0;
|
|
1184
|
+
let borderPx = 0;
|
|
1185
|
+
let borderOver40 = 0;
|
|
1156
1186
|
const counted: number[] = [];
|
|
1157
1187
|
const countedDiff: number[] = [];
|
|
1158
1188
|
const n = vp.pixelWidth * vp.pixelHeight;
|
|
1189
|
+
// Issue #242: on a scene, the frame columns and rows whose composite sample reaches the painting's border.
|
|
1190
|
+
const onEdge = (i: number, t: number, s: number, size: number): boolean => {
|
|
1191
|
+
const k = Math.floor((i - t) / s);
|
|
1192
|
+
return k <= 0 || k + 1 >= size - 1;
|
|
1193
|
+
};
|
|
1194
|
+
const edgeCol = scene ? Array.from({ length: vp.pixelWidth }, (_, x) => onEdge(x, map.tx, map.sx, W)) : null;
|
|
1195
|
+
const edgeRow = scene ? Array.from({ length: vp.pixelHeight }, (_, y) => onEdge(y, map.ty, map.sy, H)) : null;
|
|
1159
1196
|
for (let p = 0; p < n; p++) {
|
|
1160
1197
|
let m = 0;
|
|
1161
1198
|
for (let c = 0; c < 3; c++) {
|
|
@@ -1163,6 +1200,11 @@ export function seamFigures(
|
|
|
1163
1200
|
const d = Math.abs(w - frame.data[p * 4 + c]);
|
|
1164
1201
|
if (d > m) m = d;
|
|
1165
1202
|
}
|
|
1203
|
+
if (edgeCol !== null && edgeRow !== null && (edgeCol[p % vp.pixelWidth] || edgeRow[Math.floor(p / vp.pixelWidth)])) {
|
|
1204
|
+
borderPx++;
|
|
1205
|
+
if (m > SEAM_PX_LEVEL) borderOver40++;
|
|
1206
|
+
continue;
|
|
1207
|
+
}
|
|
1166
1208
|
sum += m;
|
|
1167
1209
|
if (m > SEAM_PX_LEVEL) {
|
|
1168
1210
|
over40++;
|
|
@@ -1171,7 +1213,7 @@ export function seamFigures(
|
|
|
1171
1213
|
}
|
|
1172
1214
|
if (m > SEAM_PX_LEVEL_HIGH) over80++;
|
|
1173
1215
|
}
|
|
1174
|
-
return { mean: sum / n, over40, over80, counted, countedDiff, map };
|
|
1216
|
+
return { mean: scene ? (n - borderPx === 0 ? 0 : sum / (n - borderPx)) : sum / n, over40, over80, border: scene ? { framePx: borderPx, over40: borderOver40 } : null, counted, countedDiff, map };
|
|
1175
1217
|
}
|
|
1176
1218
|
|
|
1177
1219
|
/**
|
|
@@ -1579,6 +1621,13 @@ export interface CheckFigures {
|
|
|
1579
1621
|
* divided by its square ({@link seamRigPx}). Null with the other three.
|
|
1580
1622
|
*/
|
|
1581
1623
|
seam_frame_scale: number | null;
|
|
1624
|
+
/**
|
|
1625
|
+
* Written only when `parts.json` declares a scene (issue #242): the frame pixels
|
|
1626
|
+
* on the painting's border that the seam figures above leave out, and how many
|
|
1627
|
+
* of them were over 40 ({@link seamFigures}). A painting that is not a scene
|
|
1628
|
+
* writes the keys it always wrote.
|
|
1629
|
+
*/
|
|
1630
|
+
seam_border?: { frame_px_left_out: number; px_over_40_left_out: number };
|
|
1582
1631
|
/**
|
|
1583
1632
|
* Written only when the seam bar fails, or when it was asked for (`check
|
|
1584
1633
|
* --seam-pairs`), issue #202: the counted pixels split by the pair of parts
|
|
@@ -1886,7 +1935,7 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
|
|
|
1886
1935
|
if (full !== null) {
|
|
1887
1936
|
try {
|
|
1888
1937
|
breath = breathLine(full, isolated, problems, notPlanted);
|
|
1889
|
-
if (idle !== null) tip = tipLine(full, join(idle.dir, GEOMETRY_FILE), idle.written, problems, reqFile === null ? NO_TIP_EXCEPTIONS : { path: reqFile.path, not_swinging: reqFile.not_swinging, rises: reqFile.rises });
|
|
1938
|
+
if (idle !== null) tip = tipLine(full, join(idle.dir, GEOMETRY_FILE), idle.written, problems, reqFile === null ? NO_TIP_EXCEPTIONS : { path: reqFile.path, not_swinging: reqFile.not_swinging, rises: reqFile.rises, rests: reqFile.rests });
|
|
1890
1939
|
} finally {
|
|
1891
1940
|
rmSync(isoDir, { recursive: true, force: true });
|
|
1892
1941
|
}
|
|
@@ -1895,7 +1944,7 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
|
|
|
1895
1944
|
let stretch: JudgementLine = skip(inp.noIdle ?? '');
|
|
1896
1945
|
let still: JudgementLine = skip(lacking);
|
|
1897
1946
|
if (idle !== null) {
|
|
1898
|
-
chain = chainLine(inp, problems);
|
|
1947
|
+
chain = chainLine(inp, problems, reqFile === null ? NO_CHAIN_RESTS : { path: reqFile.path, rests: reqFile.rests });
|
|
1899
1948
|
// One reader for the one geometry file: the stretch reads its meshes, the still regions every slot and the head bone.
|
|
1900
1949
|
const geometryPath = join(idle.dir, GEOMETRY_FILE);
|
|
1901
1950
|
const geometry = readGeometry(geometryPath, idle.written);
|
|
@@ -1940,7 +1989,7 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
|
|
|
1940
1989
|
if (keepStills) cpSync(join(stillDir, 'render'), keptDir, { recursive: true });
|
|
1941
1990
|
if (inp.parts !== null) {
|
|
1942
1991
|
const composite = flatComposite(inp.parts, inp.partsDir, stillSet.background);
|
|
1943
|
-
seam = seamFigures(composite, stillSet.frames[0].image, inp.stage, stillSet.viewport, stillSet.background);
|
|
1992
|
+
seam = seamFigures(composite, stillSet.frames[0].image, inp.stage, stillSet.viewport, stillSet.background, undefined, inp.parts.scene !== undefined);
|
|
1944
1993
|
if (seamPairsAsked || seamFails(round3(seam.mean), seam.over40, round6(stillSet.viewport.scale))) {
|
|
1945
1994
|
const placed = inp.parts.parts.map((p) => ({ name: p.name, from: p.from, x: p.x, y: p.y, image: readPng(join(inp.partsDir, `${p.name}.png`)) }));
|
|
1946
1995
|
const split = seamPairRows(seam, placed, inp.parts.rig_size, stillSet.viewport);
|
|
@@ -2002,6 +2051,7 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
|
|
|
2002
2051
|
seam_px_over_40: seam === null ? null : seam.over40,
|
|
2003
2052
|
seam_px_over_80: seam === null ? null : seam.over80,
|
|
2004
2053
|
seam_frame_scale: seam === null || seamViewport === null ? null : round6(seamViewport.scale),
|
|
2054
|
+
...(seam === null || seam.border === null ? {} : { seam_border: { frame_px_left_out: seam.border.framePx, px_over_40_left_out: seam.border.over40 } }),
|
|
2005
2055
|
...(seamPairs === null ? {} : { seam_pairs: seamPairs }),
|
|
2006
2056
|
BREATH_VISIBLE: breath,
|
|
2007
2057
|
BLINK_NO_HOLE: blink,
|
|
@@ -2222,6 +2272,8 @@ export function holesLine(parts: PartsFile): ReportedLine {
|
|
|
2222
2272
|
if (r === undefined) return { status: 'SKIP', reason: 'parts.json has no "recomposite" block (a parts.json the reference wrote has none); re-run assemble, which writes it' };
|
|
2223
2273
|
return {
|
|
2224
2274
|
status: 'REPORTED',
|
|
2275
|
+
// Issue #242: a scene's figures were read inside the figure, and the line says so first.
|
|
2276
|
+
...(parts.scene === undefined ? {} : { declared_scene: scenePhrase(parts.scene, parts.rig_size[0] * parts.rig_size[1]) }),
|
|
2225
2277
|
error_px: r.error_px,
|
|
2226
2278
|
uncovered_error_px: r.uncovered_error_px,
|
|
2227
2279
|
hole_count: r.hole_count,
|
|
@@ -2255,15 +2307,15 @@ type Isolate = (label: string, ps: readonly PartRecord[]) => FrameSet;
|
|
|
2255
2307
|
* before anything is built: each name must be a part parts.json lists
|
|
2256
2308
|
* (`REQUIREMENTS_RESOLVES`), and one the bar it relaxes reads — a `footwear`
|
|
2257
2309
|
* part for `not_planted` (`REQUIREMENTS_NOT_PLANTED`), a `handwear` or
|
|
2258
|
-
* `bottomwear` part for `not_swinging` and `
|
|
2310
|
+
* `bottomwear` part for `not_swinging`, `rises` and `rests` (issue #245)
|
|
2259
2311
|
* (`REQUIREMENTS_TIP_EXCEPTION`): an exception that excepts nothing is a miss,
|
|
2260
2312
|
* refused by name. With no parts.json every name is refused. Whether a `rises`
|
|
2261
2313
|
* part's chain does rise is read off the idle's geometry by `TIP_OVER_ROOT`.
|
|
2262
2314
|
*/
|
|
2263
|
-
export function exceptionProblems(file: Pick<RequirementsFile, 'not_planted' | 'not_swinging' | 'rises' | 'path'>, parts: PartsFile | null, noParts: string | null): Problem[] {
|
|
2315
|
+
export function exceptionProblems(file: Pick<RequirementsFile, 'not_planted' | 'not_swinging' | 'rises' | 'rests' | 'path'>, parts: PartsFile | null, noParts: string | null): Problem[] {
|
|
2264
2316
|
const out: Problem[] = [];
|
|
2265
|
-
// Issue #231: not_swinging and rises name swinging parts, the ones TIP_OVER_ROOT reads; whether a rises part's chain does rise is read off the idle's geometry, in the line.
|
|
2266
|
-
for (const key of ['not_swinging', 'rises'] as const) {
|
|
2317
|
+
// Issue #231: not_swinging and rises name swinging parts, the ones TIP_OVER_ROOT reads; whether a rises part's chain does rise is read off the idle's geometry, in the line. Issue #245: rests too.
|
|
2318
|
+
for (const key of ['not_swinging', 'rises', 'rests'] as const) {
|
|
2267
2319
|
file[key].forEach((name, i) => {
|
|
2268
2320
|
const object = `${file.path} field "${key}"[${i}]`;
|
|
2269
2321
|
if (parts === null) {
|
|
@@ -2368,14 +2420,15 @@ function breathLine(inp: FullInputs, isolated: Isolate, problems: Problem[], not
|
|
|
2368
2420
|
};
|
|
2369
2421
|
}
|
|
2370
2422
|
|
|
2371
|
-
/** The scene file's
|
|
2423
|
+
/** The scene file's exceptions to `TIP_OVER_ROOT` (issues #231, #245), and the file they were read from, which a refusal names. */
|
|
2372
2424
|
export interface TipExceptions {
|
|
2373
2425
|
path: string;
|
|
2374
2426
|
not_swinging: readonly string[];
|
|
2375
2427
|
rises: readonly string[];
|
|
2428
|
+
rests: readonly string[];
|
|
2376
2429
|
}
|
|
2377
2430
|
|
|
2378
|
-
const NO_TIP_EXCEPTIONS: TipExceptions = { path: '', not_swinging: [], rises: [] };
|
|
2431
|
+
const NO_TIP_EXCEPTIONS: TipExceptions = { path: '', not_swinging: [], rises: [], rests: [] };
|
|
2379
2432
|
|
|
2380
2433
|
/**
|
|
2381
2434
|
* What `TIP_OVER_ROOT` is measured with, written into its line (issue #118):
|
|
@@ -2401,6 +2454,12 @@ export const TIP_INSTRUMENT = 'geometry';
|
|
|
2401
2454
|
* chain carrying each is measured to rise ({@link chainDirection}; refused by
|
|
2402
2455
|
* name otherwise); both lists are the line's first figures. Without them the
|
|
2403
2456
|
* line is the one written before.
|
|
2457
|
+
*
|
|
2458
|
+
* Issue #245: a part the scene declares `rests` — its tip at rest while its
|
|
2459
|
+
* root moves — is measured with the same halves and held to the opposite
|
|
2460
|
+
* relation: tip / root at most {@link TIP_REST_RATIO_CEILING}. Its row says
|
|
2461
|
+
* so first (`declared: "rests"`), the list is the line's first figure after
|
|
2462
|
+
* the other two, and the ceiling follows the floor.
|
|
2404
2463
|
*/
|
|
2405
2464
|
function tipLine(inp: FullInputs, geometryPath: string, frames: number, problems: Problem[], excepted: TipExceptions = NO_TIP_EXCEPTIONS): JudgementLine {
|
|
2406
2465
|
const swing = partsTagged(inp.parts, SWING_TAGS);
|
|
@@ -2434,21 +2493,30 @@ function tipLine(inp: FullInputs, geometryPath: string, frames: number, problems
|
|
|
2434
2493
|
continue;
|
|
2435
2494
|
}
|
|
2436
2495
|
const rises = excepted.rises.includes(p.name);
|
|
2496
|
+
const rests = excepted.rests.includes(p.name);
|
|
2437
2497
|
const [rootHalf, tipHalf] = rises ? ['lower', 'upper'] : ['upper', 'lower'];
|
|
2438
2498
|
const art = placedArt(p.x, p.y, readPng(join(inp.partsDir, `${p.name}.png`)));
|
|
2439
2499
|
const shown = poses.map((f) => f.get(p.name) ?? null);
|
|
2440
2500
|
const t = halfTravels(art, p, inp.stage, shown, rises ? 'up' : 'down');
|
|
2441
2501
|
// A part not declared writes the row it wrote before issue #231; a declared one says which half is its tip's.
|
|
2442
|
-
const said = rises ? { tip_half: tipHalf } : {};
|
|
2502
|
+
const said = { ...(rests ? { declared: 'rests' } : {}), ...(rises ? { tip_half: tipHalf } : {}) };
|
|
2443
2503
|
if (t.root === null || t.tip === null) {
|
|
2444
2504
|
rows.push({ part: p.name, ...said, unmeasured: shown[0] === null ? 'its slot shows no attachment on idle frame 0' : `on idle frame 0 the ${t.root === null ? rootHalf : tipHalf} half of its box holds no art its attachment draws` });
|
|
2445
2505
|
continue;
|
|
2446
2506
|
}
|
|
2447
2507
|
measured++;
|
|
2448
2508
|
const ratio = t.root > 0 ? round3(t.tip / t.root) : null;
|
|
2449
|
-
|
|
2509
|
+
// Issue #245: a resting tip is held to the opposite relation — no further than its root, at the line's precision.
|
|
2510
|
+
const ok = rests ? (ratio === null ? round3(t.tip) === 0 : ratio <= TIP_REST_RATIO_CEILING) : ratio === null ? t.tip > 0 : ratio >= TIP_RATIO_FLOOR;
|
|
2450
2511
|
rows.push({ part: p.name, ...said, root_rig_px: round3(t.root), tip_rig_px: round3(t.tip), ratio });
|
|
2451
|
-
if (!ok) {
|
|
2512
|
+
if (!ok && rests) {
|
|
2513
|
+
failed = true;
|
|
2514
|
+
problems.push({
|
|
2515
|
+
code: 'CHECK_TIP_OVER_ROOT',
|
|
2516
|
+
object: `part "${p.name}", box ${p.x},${p.y} ${p.w}x${p.h} rig px, posed by the idle's geometry, declared rests${rises ? ' and rises' : ''}`,
|
|
2517
|
+
detail: `the art in its ${tipHalf} half travels ${round3(t.tip)} rig px and in its ${rootHalf} half ${round3(t.root)} rig px (ratio ${ratio ?? 'undefined: the root does not move'}); the scene file's rests declares its tip at rest, so a ratio <= ${TIP_REST_RATIO_CEILING} is required — the tip swings: the chain's amplitudes grow toward the tip, or the tip of the mesh is weighted to a bone the idle swings`,
|
|
2518
|
+
});
|
|
2519
|
+
} else if (!ok) {
|
|
2452
2520
|
failed = true;
|
|
2453
2521
|
// Issue #262: a root the breath carries is named with its carrier, not blamed on the chain.
|
|
2454
2522
|
const carry = breathCarrier(inp.rig, inp.motion, p.name, geometryPath, frames, { art, box: p, stage: inp.stage, shown, toward: rises ? 'up' : 'down', root: t.root, tip: t.tip });
|
|
@@ -2467,9 +2535,10 @@ function tipLine(inp: FullInputs, geometryPath: string, frames: number, problems
|
|
|
2467
2535
|
const declared = {
|
|
2468
2536
|
...(excepted.not_swinging.length === 0 ? {} : { declared_not_swinging: swing.filter((p) => excepted.not_swinging.includes(p.name)).map((p) => p.name) }),
|
|
2469
2537
|
...(excepted.rises.length === 0 ? {} : { declared_rises: swing.filter((p) => excepted.rises.includes(p.name)).map((p) => p.name) }),
|
|
2538
|
+
...(excepted.rests.length === 0 ? {} : { declared_rests: swing.filter((p) => excepted.rests.includes(p.name)).map((p) => p.name) }),
|
|
2470
2539
|
};
|
|
2471
2540
|
if (measured === 0) return { status: 'SKIP', reason: `none of ${quoteParts(swing)} could be measured: ${rows.map((r) => `${String(r.part)}: ${String(r.unmeasured)}`).join('; ')}` };
|
|
2472
|
-
return { status: failed ? 'FAIL' : 'PASS', ...declared, instrument: TIP_INSTRUMENT, parts: rows, ratio_floor: TIP_RATIO_FLOOR };
|
|
2541
|
+
return { status: failed ? 'FAIL' : 'PASS', ...declared, instrument: TIP_INSTRUMENT, parts: rows, ratio_floor: TIP_RATIO_FLOOR, ...(excepted.rests.length === 0 ? {} : { rest_ratio_ceiling: TIP_REST_RATIO_CEILING }) };
|
|
2473
2542
|
}
|
|
2474
2543
|
|
|
2475
2544
|
/**
|
|
@@ -2564,7 +2633,7 @@ function breathCarrier(
|
|
|
2564
2633
|
const a0 = added[0];
|
|
2565
2634
|
if (a0 === null || added.some((d) => d === null)) continue;
|
|
2566
2635
|
const delta = (added as Array<[number, number]>).map(([x, y]) => [x - a0[0], y - a0[1]] as const);
|
|
2567
|
-
const breath = Math.max(...delta.map(([x, y]) =>
|
|
2636
|
+
const breath = Math.max(...delta.map(([x, y]) => edgeLength(x, y)));
|
|
2568
2637
|
if (!(breath > 0)) continue;
|
|
2569
2638
|
const share = weights.map((v) => v.filter((w) => atOrUnder(String(w.bone), k)).reduce((s, w) => s + (w.weight as number), 0));
|
|
2570
2639
|
const held = m.shown.map((f, i) => (f === null ? null : { rest: f.rest, triangles: f.triangles, posed: f.posed.map((v, j) => v - share[j >> 1] * delta[i][j & 1]) }));
|
|
@@ -2589,6 +2658,33 @@ function breathCarrier(
|
|
|
2589
2658
|
* different ways.
|
|
2590
2659
|
*/
|
|
2591
2660
|
export function chainDirection(rig: Record<string, unknown>, motion: Record<string, unknown>, part: string, origins: ReadonlyMap<string, { x: number; y: number }>): { toward: Toward; chains: string[] } | { none: string } {
|
|
2661
|
+
const { found, bound } = chainsCarrying(rig, motion, part);
|
|
2662
|
+
const boundText = [...bound].map((b) => `"${b}"`).join(', ') || 'no bone';
|
|
2663
|
+
if (found.length === 0) return { none: `it rides no chain: none of the bones it is bound to (${boundText}) is under a chain of rotate-keyed links` };
|
|
2664
|
+
const read = found.map((c) => {
|
|
2665
|
+
const a = origins.get(c[0]);
|
|
2666
|
+
const z = origins.get(c[c.length - 1]);
|
|
2667
|
+
const label = c.join(' > ');
|
|
2668
|
+
if (a === undefined || z === undefined) return { label, toward: null, text: `${label}: no setup-pose origin for ${a === undefined ? c[0] : c[c.length - 1]} in the idle's geometry` };
|
|
2669
|
+
const dx = z.x - a.x;
|
|
2670
|
+
const dy = z.y - a.y;
|
|
2671
|
+
const toward = chainToward(dx, dy);
|
|
2672
|
+
return { label, toward, text: `${label} ${toward ?? 'runs as far across as along'} (${round3(dx)}, ${round3(dy)} world px)` };
|
|
2673
|
+
});
|
|
2674
|
+
const ways = [...new Set(read.map((r) => r.toward))];
|
|
2675
|
+
if (ways.length !== 1 || ways[0] === null) return { none: `its chains do not run one way: ${read.map((r) => r.text).join('; ')}` };
|
|
2676
|
+
return { toward: ways[0], chains: read.map((r) => r.text) };
|
|
2677
|
+
}
|
|
2678
|
+
|
|
2679
|
+
/**
|
|
2680
|
+
* The chains that carry a part (issues #231, #245): the bones its attachments
|
|
2681
|
+
* are bound to in the default skin ({@link boundBones}); the leaves among them
|
|
2682
|
+
* — those with no other of them below — each walked up the bone tree to the
|
|
2683
|
+
* first link of a chain `CHAIN_LAG` reads ({@link chainFigures}: two or more
|
|
2684
|
+
* rotate-keyed links, each under the one before at one period). Each chain is
|
|
2685
|
+
* its links' names from the root, once, in the order the leaves find them.
|
|
2686
|
+
*/
|
|
2687
|
+
export function chainsCarrying(rig: Record<string, unknown>, motion: Record<string, unknown>, part: string): { found: string[][]; bound: Set<string> } {
|
|
2592
2688
|
const bones = (Array.isArray(rig.bones) ? rig.bones : []).filter(isRecord).filter((b) => typeof b.name === 'string');
|
|
2593
2689
|
const parent = new Map(bones.map((b) => [String(b.name), typeof b.parent === 'string' ? b.parent : null]));
|
|
2594
2690
|
const bound = boundBones(rig, part);
|
|
@@ -2616,21 +2712,7 @@ export function chainDirection(rig: Record<string, unknown>, motion: Record<stri
|
|
|
2616
2712
|
}
|
|
2617
2713
|
}
|
|
2618
2714
|
}
|
|
2619
|
-
|
|
2620
|
-
if (found.length === 0) return { none: `it rides no chain: none of the bones it is bound to (${boundText}) is under a chain of rotate-keyed links` };
|
|
2621
|
-
const read = found.map((c) => {
|
|
2622
|
-
const a = origins.get(c[0]);
|
|
2623
|
-
const z = origins.get(c[c.length - 1]);
|
|
2624
|
-
const label = c.join(' > ');
|
|
2625
|
-
if (a === undefined || z === undefined) return { label, toward: null, text: `${label}: no setup-pose origin for ${a === undefined ? c[0] : c[c.length - 1]} in the idle's geometry` };
|
|
2626
|
-
const dx = z.x - a.x;
|
|
2627
|
-
const dy = z.y - a.y;
|
|
2628
|
-
const toward = chainToward(dx, dy);
|
|
2629
|
-
return { label, toward, text: `${label} ${toward ?? 'runs as far across as along'} (${round3(dx)}, ${round3(dy)} world px)` };
|
|
2630
|
-
});
|
|
2631
|
-
const ways = [...new Set(read.map((r) => r.toward))];
|
|
2632
|
-
if (ways.length !== 1 || ways[0] === null) return { none: `its chains do not run one way: ${read.map((r) => r.text).join('; ')}` };
|
|
2633
|
-
return { toward: ways[0], chains: read.map((r) => r.text) };
|
|
2715
|
+
return { found, bound };
|
|
2634
2716
|
}
|
|
2635
2717
|
|
|
2636
2718
|
/**
|
|
@@ -2944,10 +3026,34 @@ function fmt3(x: number): string {
|
|
|
2944
3026
|
return x.toFixed(3);
|
|
2945
3027
|
}
|
|
2946
3028
|
|
|
2947
|
-
/**
|
|
2948
|
-
export
|
|
3029
|
+
/** The scene file's `rests` parts as `CHAIN_LAG` reads them (issue #245), and the file they were read from. */
|
|
3030
|
+
export interface ChainRests {
|
|
3031
|
+
path: string;
|
|
3032
|
+
rests: readonly string[];
|
|
3033
|
+
}
|
|
3034
|
+
|
|
3035
|
+
const NO_CHAIN_RESTS: ChainRests = { path: '', rests: [] };
|
|
3036
|
+
|
|
3037
|
+
/**
|
|
3038
|
+
* (3) Down every chain the phase lags and the amplitude does not shrink, read off motion.json's rotate tracks.
|
|
3039
|
+
*
|
|
3040
|
+
* Issue #245: a chain that carries a part the scene file declares `rests` ({@link chainsCarrying}) is held to the
|
|
3041
|
+
* opposite amplitude relation — non-increasing toward the tip, the tip moving no more than the root, as
|
|
3042
|
+
* `TIP_OVER_ROOT` holds the part. The lag is not relaxed: the declaration says how far the tip moves, not when.
|
|
3043
|
+
* The declared parts are the line's first figure (`declared_rests`), and the chains they hold, `resting_chains`,
|
|
3044
|
+
* follow it; a declared part no chain carries is named in neither. Without the field the line is the one written
|
|
3045
|
+
* before.
|
|
3046
|
+
*/
|
|
3047
|
+
export function chainLine(inp: Pick<CheckInputs, 'rig' | 'motion'>, problems: Problem[], declared: ChainRests = NO_CHAIN_RESTS): JudgementLine {
|
|
2949
3048
|
const cf = chainFigures(inp.rig, inp.motion);
|
|
2950
3049
|
const long = cf.chains.filter((c) => c.length >= 2);
|
|
3050
|
+
// Issue #245: each resting chain by its first link, with the declared parts that ride it, in the file's order.
|
|
3051
|
+
const resting = new Map<string, string[]>();
|
|
3052
|
+
for (const part of declared.rests) {
|
|
3053
|
+
for (const c of chainsCarrying(inp.rig, inp.motion, part).found) resting.set(c[0], [...(resting.get(c[0]) ?? []), part]);
|
|
3054
|
+
}
|
|
3055
|
+
const restingChains = long.filter((c) => resting.has(c[0].bone));
|
|
3056
|
+
const said = declared.rests.length === 0 ? {} : { declared_rests: [...declared.rests], resting_chains: restingChains.map((c) => c.map((l) => l.bone).join(' > ')) };
|
|
2951
3057
|
if (cf.edges.length === 0 && long.length === 0) {
|
|
2952
3058
|
return skip(`no idle "rotate" track sits under another at the same period${cf.unread.length > 0 ? ` (unread, not sampled sines: ${cf.unread.join(', ')})` : ''}, so there is no chain to lag`);
|
|
2953
3059
|
}
|
|
@@ -2960,9 +3066,21 @@ export function chainLine(inp: Pick<CheckInputs, 'rig' | 'motion'>, problems: Pr
|
|
|
2960
3066
|
}
|
|
2961
3067
|
}
|
|
2962
3068
|
for (const c of long) {
|
|
3069
|
+
const rests = resting.get(c[0].bone);
|
|
2963
3070
|
for (let i = 1; i < c.length; i++) {
|
|
2964
3071
|
const a = round3(c[i - 1].reading.amp);
|
|
2965
3072
|
const b = round3(c[i].reading.amp);
|
|
3073
|
+
if (rests !== undefined) {
|
|
3074
|
+
if (b > a) {
|
|
3075
|
+
violations.push(`"${c[i].bone}" swings ${fmt3(b)} over "${c[i - 1].bone}"'s ${fmt3(a)} in a chain declared resting`);
|
|
3076
|
+
problems.push({
|
|
3077
|
+
code: 'CHECK_CHAIN_LAG',
|
|
3078
|
+
object: `bone "${c[i].bone}" in the chain from "${c[0].bone}", which carries ${rests.map((n) => `"${n}"`).join(', ')}, declared rests by ${declared.path}`,
|
|
3079
|
+
detail: `amplitude ${fmt3(b)} above the link above's ${fmt3(a)}; non-increasing down the chain is required — the scene file's rests declares the tip at rest, so it moves no more than the root ("amps" shrink toward the tip)`,
|
|
3080
|
+
});
|
|
3081
|
+
}
|
|
3082
|
+
continue;
|
|
3083
|
+
}
|
|
2966
3084
|
if (b < a) {
|
|
2967
3085
|
violations.push(`"${c[i].bone}" swings ${fmt3(b)} under "${c[i - 1].bone}"'s ${fmt3(a)}`);
|
|
2968
3086
|
problems.push({ code: 'CHECK_CHAIN_LAG', object: `bone "${c[i].bone}" in the chain from "${c[0].bone}"`, detail: `amplitude ${fmt3(b)} below the link above's ${fmt3(a)}; non-decreasing down the chain is required — "amps" grow toward the tip` });
|
|
@@ -2972,6 +3090,7 @@ export function chainLine(inp: Pick<CheckInputs, 'rig' | 'motion'>, problems: Pr
|
|
|
2972
3090
|
const minStep = cf.edges.length === 0 ? null : round3(Math.min(...cf.edges.map((e) => e.step)));
|
|
2973
3091
|
return {
|
|
2974
3092
|
status: violations.length === 0 ? 'PASS' : 'FAIL',
|
|
3093
|
+
...said,
|
|
2975
3094
|
chains: long.map((c) => `${c.map((l) => l.bone).join(' > ')}: period ${round3(c[0].reading.period)}s, phase ${c.map((l) => fmt3(l.reading.phase)).join(' ')}, amp ${c.map((l) => fmt3(l.reading.amp)).join(' ')}`),
|
|
2976
3095
|
lags: cf.edges.map((e) => `${e.parent} > ${e.child} ${e.step >= 0 ? '+' : ''}${fmt3(e.step)}`),
|
|
2977
3096
|
min_step: minStep,
|
package/src/config.ts
CHANGED
|
@@ -60,6 +60,17 @@
|
|
|
60
60
|
* depth that stays put, both stated — `propose --full/--head` drafts them —
|
|
61
61
|
* from which the rig stage derives every fraction ({@link checkTurn}).
|
|
62
62
|
*
|
|
63
|
+
* 🖼️ **`scene` declares the painting a scene (issue #242).** An optional
|
|
64
|
+
* object, `{}` today: the painting holds the figure and its surroundings (a
|
|
65
|
+
* room, a ground), and See-through's full run holds only the figure. With it,
|
|
66
|
+
* `assemble` reads the recomposite inside the figure and writes the figure's
|
|
67
|
+
* mask, and `check` reads `RECOMPOSITE_HOLES` and the seam that way too, each
|
|
68
|
+
* line saying so first (`src/assemble.ts`, `sceneFigure`). It takes no field
|
|
69
|
+
* because nothing in it is the author's to choose: the figure is derived (the
|
|
70
|
+
* union of the full run's layers), and so is the threshold. A key in it that is
|
|
71
|
+
* not a record or an annotation is refused by name ({@link checkScene}); the
|
|
72
|
+
* object leaves room for a field a later issue gives it.
|
|
73
|
+
*
|
|
63
74
|
* 🔗 **`motion.animations_from` is rig-c's too, verbatim (issue #183).** An
|
|
64
75
|
* optional path, relative to the config file's directory, to a file in
|
|
65
76
|
* rig-c's motion-spec shape whose `animations` are written into `motion.json`
|
|
@@ -79,7 +90,7 @@ import { RIG_SKIN_CONSTRAINT_KEYS, type RigConstraint, type RigSkinConstraintKey
|
|
|
79
90
|
import { GRID, MAX_SIDE } from './contour.ts';
|
|
80
91
|
import { type Problem, refuseIfAny } from './errors.ts';
|
|
81
92
|
import { isSkeletonName, KEYPOINT_NAMES, type KeypointName, type PosePoints, SKELETON_NAMES, type SkeletonName } from './skeleton.ts';
|
|
82
|
-
import { acceptedTagNames,
|
|
93
|
+
import { acceptedTagNames, AUTHOR_LABEL_RULE, AUTHOR_PREFIX, readLayerTag } from './tags.ts';
|
|
83
94
|
import { TURN_BONE, type TurnSpec, turnNearest } from './turn.ts';
|
|
84
95
|
|
|
85
96
|
export type Point = [number, number];
|
|
@@ -301,6 +312,8 @@ export interface ContourSpec {
|
|
|
301
312
|
margin: number;
|
|
302
313
|
/** The background interior spacing, px, above 0. */
|
|
303
314
|
spacing: number;
|
|
315
|
+
/** The longest outline edge, px (issue #265): a longer simplified edge is cut into equal pieces; absent cuts nothing. */
|
|
316
|
+
maxEdge?: number;
|
|
304
317
|
/** The most vertices the mesh may have; absent is no budget. */
|
|
305
318
|
budget?: number;
|
|
306
319
|
/** The largest island, in art pixels, that may be left out of the mesh; absent leaves none out. */
|
|
@@ -612,8 +625,13 @@ export interface CharacterConfig {
|
|
|
612
625
|
constraints?: ConfigConstraint[];
|
|
613
626
|
/** The turn control (issue #251, `src/turn.ts`): absent, no control is added and no byte of any output moves. */
|
|
614
627
|
turn?: TurnSpec;
|
|
628
|
+
/** The painting is a scene (issue #242): absent, no byte of any output moves. */
|
|
629
|
+
scene?: SceneSpec;
|
|
615
630
|
}
|
|
616
631
|
|
|
632
|
+
/** `config.scene` (issue #242): the declaration that the painting is a scene. It holds no field today. */
|
|
633
|
+
export type SceneSpec = Readonly<Record<string, unknown>>;
|
|
634
|
+
|
|
617
635
|
/** The fields of one constraint kind, as rig-c's interface for that kind declares them. */
|
|
618
636
|
type ConstraintField<K extends RigSkinConstraintKey> = Exclude<keyof Extract<RigConstraint, { type: K }>, number | symbol>;
|
|
619
637
|
|
|
@@ -891,9 +909,10 @@ export interface AssembleConfig {
|
|
|
891
909
|
key: string;
|
|
892
910
|
seethrough: SeeThrough;
|
|
893
911
|
assemble: Assemble;
|
|
912
|
+
scene?: SceneSpec;
|
|
894
913
|
}
|
|
895
914
|
|
|
896
|
-
const TOP_KEYS = ['key', 'generation', 'seethrough', 'assemble', 'bones', 'meshes', 'regions', 'motion', 'constraints', 'turn'] as const;
|
|
915
|
+
const TOP_KEYS = ['key', 'generation', 'seethrough', 'assemble', 'bones', 'meshes', 'regions', 'motion', 'constraints', 'turn', 'scene'] as const;
|
|
897
916
|
|
|
898
917
|
/**
|
|
899
918
|
* The one partial entry point. It validates exactly what its door requires —
|
|
@@ -943,6 +962,7 @@ function parseEarlyConfigFrom(raw: Json, door: EarlyDoor, base: string | null):
|
|
|
943
962
|
if (door === 'assemble') {
|
|
944
963
|
if ('seethrough' in t) checkSeeThrough(c, t.seethrough);
|
|
945
964
|
if ('assemble' in t) checkAssemble(c, t.assemble, door);
|
|
965
|
+
if ('scene' in t) checkScene(c, t.scene);
|
|
946
966
|
}
|
|
947
967
|
const pose = base === null ? null : poseBeside(c, t.generation, base);
|
|
948
968
|
refuseIfAny(c.problems);
|
|
@@ -1095,6 +1115,7 @@ function parseConfigFrom(raw: Json, base: string | null): { config: CharacterCon
|
|
|
1095
1115
|
if ('motion' in t) checkMotion(c, t.motion, names.bones, names.chains, 'regions' in t ? t.regions : undefined);
|
|
1096
1116
|
if ('constraints' in t) checkConstraints(c, t.constraints, names.bones, names.parents);
|
|
1097
1117
|
if ('turn' in t) checkTurn(c, t.turn, attachedParts(t.meshes, t.regions), names.bones);
|
|
1118
|
+
if ('scene' in t) checkScene(c, t.scene);
|
|
1098
1119
|
const from = isPlainObject(t.motion) ? t.motion.animations_from : undefined;
|
|
1099
1120
|
const animations = base !== null && typeof from === 'string' && from !== '' ? readAnimationsFrom(c, resolve(base, from)) : null;
|
|
1100
1121
|
if (base !== null) poseBeside(c, t.generation, base);
|
|
@@ -1399,8 +1420,10 @@ function checkSeeThrough(c: Check, v: Json): void {
|
|
|
1399
1420
|
|
|
1400
1421
|
function checkRunTag(c: Check, path: string, run: Json, tag: Json): void {
|
|
1401
1422
|
if (run !== 'full' && run !== 'head') c.fail('CONFIG_FIELD_TYPE', `${path} run`, `is ${show(run)}; "full" or "head" is required`);
|
|
1402
|
-
|
|
1403
|
-
|
|
1423
|
+
// Issue #256: an author-made layer's `author:<label>` is a tag the readers admit, with no role.
|
|
1424
|
+
if (typeof tag !== 'string' || readLayerTag(tag) === null) {
|
|
1425
|
+
const label = typeof tag === 'string' && tag.startsWith(AUTHOR_PREFIX) ? `; after "${AUTHOR_PREFIX}", ${AUTHOR_LABEL_RULE}` : '';
|
|
1426
|
+
c.fail('CONFIG_TAG_KNOWN', `${path} tag`, `is ${show(tag)}; a See-through v3 tag is required — one of ${acceptedTagNames().join(', ')} — or "${AUTHOR_PREFIX}<label>" for a layer the author made${label}`);
|
|
1404
1427
|
}
|
|
1405
1428
|
}
|
|
1406
1429
|
|
|
@@ -1756,7 +1779,7 @@ function checkRibs(c: Check, at: string, m: Record<string, Json>, chains: Map<st
|
|
|
1756
1779
|
|
|
1757
1780
|
/**
|
|
1758
1781
|
* `meshes.<part>.contour` (issue #84). `tolerance`, `margin` and `spacing` are
|
|
1759
|
-
* required — nothing is defaulted; `budget`, `stray` and `regions` are
|
|
1782
|
+
* required — nothing is defaulted; `maxEdge`, `budget`, `stray` and `regions` are
|
|
1760
1783
|
* optional, and an absent one is "none", not a guessed value. What the outline
|
|
1761
1784
|
* itself refuses (a band that folds a polygon, a spacing whose keep radius
|
|
1762
1785
|
* snaps to nothing) is `src/contour.ts`'s, at the rig stage, in its words;
|
|
@@ -1764,13 +1787,14 @@ function checkRibs(c: Check, at: string, m: Record<string, Json>, chains: Map<st
|
|
|
1764
1787
|
* the grid every region number must sit on.
|
|
1765
1788
|
*/
|
|
1766
1789
|
function checkContour(c: Check, at: string, v: Json, bones: Set<string>): void {
|
|
1767
|
-
const o = c.object(at, v, ['tolerance', 'margin', 'spacing'], ['budget', 'stray', 'regions']);
|
|
1790
|
+
const o = c.object(at, v, ['tolerance', 'margin', 'spacing'], ['maxEdge', 'budget', 'stray', 'regions']);
|
|
1768
1791
|
if (o === null) return;
|
|
1769
1792
|
if ('tolerance' in o) c.number(`${at}.tolerance`, o.tolerance, 'non-negative');
|
|
1770
1793
|
if ('margin' in o && c.number(`${at}.margin`, o.margin, 'non-negative') && (o.margin as number) > 0 && (o.margin as number) < 1) {
|
|
1771
1794
|
c.fail('CONFIG_FIELD_TYPE', `${at}.margin`, `is ${o.margin as number}; 0, or 1 px or more, is required — the outline's silhouette grows by the pixels whose centre lies within the margin of an art pixel's centre, and none lies closer than 1 px, so a margin under 1 px would add nothing`);
|
|
1772
1795
|
}
|
|
1773
1796
|
if ('spacing' in o) c.number(`${at}.spacing`, o.spacing, 'positive');
|
|
1797
|
+
if ('maxEdge' in o) c.number(`${at}.maxEdge`, o.maxEdge, 'positive');
|
|
1774
1798
|
if ('budget' in o) c.int(`${at}.budget`, o.budget, 3);
|
|
1775
1799
|
if ('stray' in o) c.int(`${at}.stray`, o.stray, 0);
|
|
1776
1800
|
if (!('regions' in o) || !c.array(`${at}.regions`, o.regions, false)) return;
|
|
@@ -2318,6 +2342,28 @@ function attachedParts(meshes: Json, regions: Json): Set<string> {
|
|
|
2318
2342
|
* - `CONFIG_TURN_BONE_FREE`: `bones` declares a bone named {@link TURN_BONE},
|
|
2319
2343
|
* the control the rig stage adds.
|
|
2320
2344
|
*/
|
|
2345
|
+
/**
|
|
2346
|
+
* `config.scene` (issue #242): an object, and today one with no field — a key
|
|
2347
|
+
* that is not a record (`x-…`) or an annotation (`note`, `…_note`) is
|
|
2348
|
+
* `CONFIG_KEY_KNOWN`, naming the key and saying the object is the whole
|
|
2349
|
+
* declaration. Not `true`: an object is what a later field can be added to
|
|
2350
|
+
* without changing what an existing config means.
|
|
2351
|
+
*/
|
|
2352
|
+
function checkScene(c: Check, v: Json): void {
|
|
2353
|
+
if (!isPlainObject(v)) {
|
|
2354
|
+
c.fail('CONFIG_FIELD_TYPE', 'config.scene', `is ${show(v)}; an object is required — {} declares the painting a scene`);
|
|
2355
|
+
return;
|
|
2356
|
+
}
|
|
2357
|
+
for (const key of Object.keys(v)) {
|
|
2358
|
+
if (isRecordKey(key)) continue;
|
|
2359
|
+
if (key === 'note' || key.endsWith('_note')) {
|
|
2360
|
+
if (typeof v[key] !== 'string') c.fail('CONFIG_FIELD_TYPE', `config.scene.${key}`, `is ${show(v[key])}; an annotation is a string`);
|
|
2361
|
+
continue;
|
|
2362
|
+
}
|
|
2363
|
+
c.fail('CONFIG_KEY_KNOWN', `config.scene.${key}`, `is not a field here; scene takes no field — {} is the whole declaration (the figure and its threshold are derived, not chosen) — ${DOORS}`);
|
|
2364
|
+
}
|
|
2365
|
+
}
|
|
2366
|
+
|
|
2321
2367
|
function checkTurn(c: Check, v: Json, attached: Set<string>, bones: Set<string>): void {
|
|
2322
2368
|
const at = 'config.turn';
|
|
2323
2369
|
const o = c.object(at, v, ['pivot', 'depths'], []);
|
package/src/contour.ts
CHANGED
|
@@ -35,7 +35,9 @@
|
|
|
35
35
|
* pixels, so its trace cannot cross itself; a notch narrower than about
|
|
36
36
|
* twice the margin grows shut. A margin above 0 and below 1 px adds no
|
|
37
37
|
* pixel and is refused by name. This module does not raise a margin by
|
|
38
|
-
* itself: a margin that clips art is refused (`CONTOUR_COVERAGE`).
|
|
38
|
+
* itself: a margin that clips art is refused (`CONTOUR_COVERAGE`). With
|
|
39
|
+
* `maxEdge` declared (issue #265), every simplified edge longer than it is
|
|
40
|
+
* then cut into equal pieces ({@link capOutlineEdges}); undeclared, nothing is.
|
|
39
41
|
* 4. **Interior vertices are declared**, never inferred: see
|
|
40
42
|
* {@link interiorCandidates} for the candidates and {@link KEEP_FRACTION}
|
|
41
43
|
* for the one rule that keeps or drops each. Where the outline passes
|
|
@@ -141,6 +143,7 @@
|
|
|
141
143
|
* automatic mode hands these triangles to rig-c as they are.
|
|
142
144
|
*/
|
|
143
145
|
import { type Problem } from './errors.ts';
|
|
146
|
+
import { edgeLength } from './length.ts';
|
|
144
147
|
import { counterClockwiseInSpineWorld } from './mesh.ts';
|
|
145
148
|
import { connectedComponents, fillHoles, type Mask } from './raster/index.ts';
|
|
146
149
|
import {
|
|
@@ -224,6 +227,12 @@ export interface ContourParams {
|
|
|
224
227
|
margin: number;
|
|
225
228
|
/** The background interior spacing, px, above 0. */
|
|
226
229
|
spacing: number;
|
|
230
|
+
/**
|
|
231
|
+
* The longest outline edge, px (issue #265): every edge of the simplified outline longer than this is cut into
|
|
232
|
+
* `ceil(length / maxEdge)` equal pieces ({@link capOutlineEdges}), before any region or rib touches the outline.
|
|
233
|
+
* Absent, nothing is cut and the mesh is byte for byte the one it was before the field existed; there is no default.
|
|
234
|
+
*/
|
|
235
|
+
maxEdge?: number;
|
|
227
236
|
/** Refuse a mesh with more vertices than this; absent is no budget. */
|
|
228
237
|
budget?: number;
|
|
229
238
|
/**
|
|
@@ -330,6 +339,8 @@ export interface ContourReport {
|
|
|
330
339
|
grownHolePixels: number;
|
|
331
340
|
/** Only when ribs were declared (issue #188): every rib, in placement order, with its vertices. Absent otherwise, so a mesh without ribs reports what it always reported. */
|
|
332
341
|
ribs?: ContourRib[];
|
|
342
|
+
/** Only when `maxEdge` was declared (issue #265): the outline vertices the cap added. Absent otherwise, so a mesh without it reports what it always reported. */
|
|
343
|
+
outlineCut?: number;
|
|
333
344
|
}
|
|
334
345
|
|
|
335
346
|
export interface ContourMesh {
|
|
@@ -382,6 +393,10 @@ function parameterProblems(part: string, mask: AlphaMask, p: ContourParams): Pro
|
|
|
382
393
|
else if (keepRadius(s) > MAX_RADIUS) bad(field, `is ${s}; its keep radius is ${keepRadius(s)} grid units, past the ${MAX_RADIUS} (${MAX_RADIUS / GRID} px) whose square is exact in a double`);
|
|
383
394
|
};
|
|
384
395
|
spacingOk('spacing', p.spacing);
|
|
396
|
+
// issue #265: a cut piece must be longer than one grid unit, so two cut points never snap onto one another.
|
|
397
|
+
if (p.maxEdge !== undefined && (!finite(p.maxEdge) || p.maxEdge * GRID < 2)) {
|
|
398
|
+
bad('maxEdge', `is ${p.maxEdge}; a number of px of at least 2/${GRID} is required — each edge is cut into pieces longer than half of it, and a piece must be longer than one 1/${GRID} px grid unit for its two ends to stay apart once snapped`);
|
|
399
|
+
}
|
|
385
400
|
if (p.budget !== undefined && (!Number.isInteger(p.budget) || p.budget < 3)) bad('budget', `is ${p.budget}; a whole number of vertices, 3 or more, is required`);
|
|
386
401
|
if (p.fitConnectivity !== undefined && p.fitConnectivity !== 4 && p.fitConnectivity !== 8) bad('fitConnectivity', `is ${JSON.stringify(p.fitConnectivity)}; 4 or 8 is required (how the background is flooded when overshoot is measured)`);
|
|
387
402
|
if (p.stray !== undefined && (!Number.isInteger(p.stray) || p.stray < 0)) bad('stray', `is ${p.stray}; a whole number of art pixels, 0 or more, is required (the largest island that may be left out)`);
|
|
@@ -736,6 +751,26 @@ export function outlineInRegions(outline: ReadonlyArray<readonly [number, number
|
|
|
736
751
|
return out;
|
|
737
752
|
}
|
|
738
753
|
|
|
754
|
+
/**
|
|
755
|
+
* An outline with every edge longer than `maxEdge` px cut into `n = ceil(length / maxEdge)` equal pieces (issue
|
|
756
|
+
* #265): between each edge's two ends, the points `a + (b − a)·k/n` for k = 1..n−1, each snapped to the {@link GRID}.
|
|
757
|
+
* Every vertex of the outline is kept, in the same cyclic order and position from index 0, and an edge at or under
|
|
758
|
+
* `maxEdge` gains nothing. The length is {@link edgeLength}'s. The snap moves a cut point off its edge by at most
|
|
759
|
+
* half a grid unit (1/512 px); the mesh's topology and coverage checks read the cut outline as they read any other.
|
|
760
|
+
* The measured reference is `capOutline` in `tools/outline_survey.ts` (issue #265, (G′)), which this states in `src/`.
|
|
761
|
+
*/
|
|
762
|
+
export function capOutlineEdges(outline: ReadonlyArray<readonly [number, number]>, maxEdge: number): Array<[number, number]> {
|
|
763
|
+
const out: Array<[number, number]> = [];
|
|
764
|
+
for (let i = 0; i < outline.length; i++) {
|
|
765
|
+
const [ax, ay] = outline[i];
|
|
766
|
+
const [bx, by] = outline[(i + 1) % outline.length];
|
|
767
|
+
out.push([ax, ay]);
|
|
768
|
+
const n = Math.ceil(edgeLength(bx - ax, by - ay) / maxEdge);
|
|
769
|
+
for (let k = 1; k < n; k++) out.push([snap(ax + ((bx - ax) * k) / n) / GRID, snap(ay + ((by - ay) * k) / n) / GRID]);
|
|
770
|
+
}
|
|
771
|
+
return out;
|
|
772
|
+
}
|
|
773
|
+
|
|
739
774
|
/**
|
|
740
775
|
* The keep rule ({@link KEEP_FRACTION}) over the candidates in order, against
|
|
741
776
|
* an outline in grid units. Exact: inside by the crossing rule on integers,
|
|
@@ -1767,7 +1802,9 @@ export function contourMesh(part: string, mask: AlphaMask, params: ContourParams
|
|
|
1767
1802
|
const stage = contourOutline(part, filled, threshold, tolerance, margin);
|
|
1768
1803
|
if (Array.isArray(stage)) return stage;
|
|
1769
1804
|
const { grown } = stage;
|
|
1770
|
-
|
|
1805
|
+
// issue #265: the cap cuts the simplified outline's long edges first; undeclared, the outline is the stage's own.
|
|
1806
|
+
const capped = params.maxEdge === undefined ? stage.outline : capOutlineEdges(stage.outline, params.maxEdge);
|
|
1807
|
+
let outline = outlineInRegions(capped, params.regions);
|
|
1771
1808
|
// issue #188: the ribs' ends join the outline, and their interior points come first among the interior vertices.
|
|
1772
1809
|
// None declared, nothing here runs and every list below is the one it always was.
|
|
1773
1810
|
let ribs: RibPlacement | null = null;
|
|
@@ -1854,6 +1891,7 @@ export function contourMesh(part: string, mask: AlphaMask, params: ContourParams
|
|
|
1854
1891
|
pinchFilledPixels: grown.pinchFilledPixels,
|
|
1855
1892
|
grownHolePixels: stage.grownHolePixels,
|
|
1856
1893
|
...(ribs === null ? {} : { ribs: ribs.ribs }),
|
|
1894
|
+
...(params.maxEdge === undefined ? {} : { outlineCut: capped.length - stage.outline.length }),
|
|
1857
1895
|
},
|
|
1858
1896
|
};
|
|
1859
1897
|
}
|