@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/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';
@@ -200,6 +200,16 @@ export const BLINK_PATCH_LEVEL = SEAM_PX_LEVEL;
200
200
  /** The smallest lag the reading resolves, in cycles: phases are written to three places. */
201
201
  export const CHAIN_LAG_MIN_STEP = 0.001;
202
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;
203
213
  /**
204
214
  * `TEXTURE_STRETCH`'s ceiling on max(ratio, 1/ratio) (issue #31): the worse
205
215
  * example's figure (demo, 1.388) squared. The ceiling rule is "twice the worse
@@ -1052,6 +1062,12 @@ export interface SeamFigures {
1052
1062
  mean: number;
1053
1063
  over40: number;
1054
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;
1055
1071
  /** The frame pixels {@link over40} counts, by index into the frame, ascending; each one's max-channel |d| is in {@link countedDiff}. */
1056
1072
  counted: number[];
1057
1073
  countedDiff: number[];
@@ -1135,6 +1151,16 @@ export function frameToRigMap(stage: StageBox, rigH: number, vp: Viewport): Scal
1135
1151
  *
1136
1152
  * `map` is for the controls alone (the `check` suite plants the map without
1137
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.
1138
1164
  */
1139
1165
  export function seamFigures(
1140
1166
  composite: Raster,
@@ -1143,6 +1169,7 @@ export function seamFigures(
1143
1169
  vp: Viewport,
1144
1170
  background: readonly [number, number, number, number],
1145
1171
  map: ScaleTranslate = rigToFrameMap(stage, composite.height, vp),
1172
+ scene = false,
1146
1173
  ): SeamFigures {
1147
1174
  const [W, H] = [composite.width, composite.height];
1148
1175
  const src = newFloatImage(W, H, 3);
@@ -1154,9 +1181,18 @@ export function seamFigures(
1154
1181
  let sum = 0;
1155
1182
  let over40 = 0;
1156
1183
  let over80 = 0;
1184
+ let borderPx = 0;
1185
+ let borderOver40 = 0;
1157
1186
  const counted: number[] = [];
1158
1187
  const countedDiff: number[] = [];
1159
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;
1160
1196
  for (let p = 0; p < n; p++) {
1161
1197
  let m = 0;
1162
1198
  for (let c = 0; c < 3; c++) {
@@ -1164,6 +1200,11 @@ export function seamFigures(
1164
1200
  const d = Math.abs(w - frame.data[p * 4 + c]);
1165
1201
  if (d > m) m = d;
1166
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
+ }
1167
1208
  sum += m;
1168
1209
  if (m > SEAM_PX_LEVEL) {
1169
1210
  over40++;
@@ -1172,7 +1213,7 @@ export function seamFigures(
1172
1213
  }
1173
1214
  if (m > SEAM_PX_LEVEL_HIGH) over80++;
1174
1215
  }
1175
- 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 };
1176
1217
  }
1177
1218
 
1178
1219
  /**
@@ -1580,6 +1621,13 @@ export interface CheckFigures {
1580
1621
  * divided by its square ({@link seamRigPx}). Null with the other three.
1581
1622
  */
1582
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 };
1583
1631
  /**
1584
1632
  * Written only when the seam bar fails, or when it was asked for (`check
1585
1633
  * --seam-pairs`), issue #202: the counted pixels split by the pair of parts
@@ -1887,7 +1935,7 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
1887
1935
  if (full !== null) {
1888
1936
  try {
1889
1937
  breath = breathLine(full, isolated, problems, notPlanted);
1890
- 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 });
1891
1939
  } finally {
1892
1940
  rmSync(isoDir, { recursive: true, force: true });
1893
1941
  }
@@ -1896,7 +1944,7 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
1896
1944
  let stretch: JudgementLine = skip(inp.noIdle ?? '');
1897
1945
  let still: JudgementLine = skip(lacking);
1898
1946
  if (idle !== null) {
1899
- chain = chainLine(inp, problems);
1947
+ chain = chainLine(inp, problems, reqFile === null ? NO_CHAIN_RESTS : { path: reqFile.path, rests: reqFile.rests });
1900
1948
  // One reader for the one geometry file: the stretch reads its meshes, the still regions every slot and the head bone.
1901
1949
  const geometryPath = join(idle.dir, GEOMETRY_FILE);
1902
1950
  const geometry = readGeometry(geometryPath, idle.written);
@@ -1941,7 +1989,7 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
1941
1989
  if (keepStills) cpSync(join(stillDir, 'render'), keptDir, { recursive: true });
1942
1990
  if (inp.parts !== null) {
1943
1991
  const composite = flatComposite(inp.parts, inp.partsDir, stillSet.background);
1944
- 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);
1945
1993
  if (seamPairsAsked || seamFails(round3(seam.mean), seam.over40, round6(stillSet.viewport.scale))) {
1946
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`)) }));
1947
1995
  const split = seamPairRows(seam, placed, inp.parts.rig_size, stillSet.viewport);
@@ -2003,6 +2051,7 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
2003
2051
  seam_px_over_40: seam === null ? null : seam.over40,
2004
2052
  seam_px_over_80: seam === null ? null : seam.over80,
2005
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 } }),
2006
2055
  ...(seamPairs === null ? {} : { seam_pairs: seamPairs }),
2007
2056
  BREATH_VISIBLE: breath,
2008
2057
  BLINK_NO_HOLE: blink,
@@ -2223,6 +2272,8 @@ export function holesLine(parts: PartsFile): ReportedLine {
2223
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' };
2224
2273
  return {
2225
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]) }),
2226
2277
  error_px: r.error_px,
2227
2278
  uncovered_error_px: r.uncovered_error_px,
2228
2279
  hole_count: r.hole_count,
@@ -2256,15 +2307,15 @@ type Isolate = (label: string, ps: readonly PartRecord[]) => FrameSet;
2256
2307
  * before anything is built: each name must be a part parts.json lists
2257
2308
  * (`REQUIREMENTS_RESOLVES`), and one the bar it relaxes reads — a `footwear`
2258
2309
  * part for `not_planted` (`REQUIREMENTS_NOT_PLANTED`), a `handwear` or
2259
- * `bottomwear` part for `not_swinging` and `rises`
2310
+ * `bottomwear` part for `not_swinging`, `rises` and `rests` (issue #245)
2260
2311
  * (`REQUIREMENTS_TIP_EXCEPTION`): an exception that excepts nothing is a miss,
2261
2312
  * refused by name. With no parts.json every name is refused. Whether a `rises`
2262
2313
  * part's chain does rise is read off the idle's geometry by `TIP_OVER_ROOT`.
2263
2314
  */
2264
- 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[] {
2265
2316
  const out: Problem[] = [];
2266
- // 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.
2267
- 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) {
2268
2319
  file[key].forEach((name, i) => {
2269
2320
  const object = `${file.path} field "${key}"[${i}]`;
2270
2321
  if (parts === null) {
@@ -2369,14 +2420,15 @@ function breathLine(inp: FullInputs, isolated: Isolate, problems: Problem[], not
2369
2420
  };
2370
2421
  }
2371
2422
 
2372
- /** The scene file's two exceptions to `TIP_OVER_ROOT` (issue #231), and the file they were read from, which a refusal names. */
2423
+ /** The scene file's exceptions to `TIP_OVER_ROOT` (issues #231, #245), and the file they were read from, which a refusal names. */
2373
2424
  export interface TipExceptions {
2374
2425
  path: string;
2375
2426
  not_swinging: readonly string[];
2376
2427
  rises: readonly string[];
2428
+ rests: readonly string[];
2377
2429
  }
2378
2430
 
2379
- const NO_TIP_EXCEPTIONS: TipExceptions = { path: '', not_swinging: [], rises: [] };
2431
+ const NO_TIP_EXCEPTIONS: TipExceptions = { path: '', not_swinging: [], rises: [], rests: [] };
2380
2432
 
2381
2433
  /**
2382
2434
  * What `TIP_OVER_ROOT` is measured with, written into its line (issue #118):
@@ -2402,6 +2454,12 @@ export const TIP_INSTRUMENT = 'geometry';
2402
2454
  * chain carrying each is measured to rise ({@link chainDirection}; refused by
2403
2455
  * name otherwise); both lists are the line's first figures. Without them the
2404
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.
2405
2463
  */
2406
2464
  function tipLine(inp: FullInputs, geometryPath: string, frames: number, problems: Problem[], excepted: TipExceptions = NO_TIP_EXCEPTIONS): JudgementLine {
2407
2465
  const swing = partsTagged(inp.parts, SWING_TAGS);
@@ -2435,21 +2493,30 @@ function tipLine(inp: FullInputs, geometryPath: string, frames: number, problems
2435
2493
  continue;
2436
2494
  }
2437
2495
  const rises = excepted.rises.includes(p.name);
2496
+ const rests = excepted.rests.includes(p.name);
2438
2497
  const [rootHalf, tipHalf] = rises ? ['lower', 'upper'] : ['upper', 'lower'];
2439
2498
  const art = placedArt(p.x, p.y, readPng(join(inp.partsDir, `${p.name}.png`)));
2440
2499
  const shown = poses.map((f) => f.get(p.name) ?? null);
2441
2500
  const t = halfTravels(art, p, inp.stage, shown, rises ? 'up' : 'down');
2442
2501
  // A part not declared writes the row it wrote before issue #231; a declared one says which half is its tip's.
2443
- const said = rises ? { tip_half: tipHalf } : {};
2502
+ const said = { ...(rests ? { declared: 'rests' } : {}), ...(rises ? { tip_half: tipHalf } : {}) };
2444
2503
  if (t.root === null || t.tip === null) {
2445
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` });
2446
2505
  continue;
2447
2506
  }
2448
2507
  measured++;
2449
2508
  const ratio = t.root > 0 ? round3(t.tip / t.root) : null;
2450
- const ok = ratio === null ? t.tip > 0 : ratio >= TIP_RATIO_FLOOR;
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;
2451
2511
  rows.push({ part: p.name, ...said, root_rig_px: round3(t.root), tip_rig_px: round3(t.tip), ratio });
2452
- 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) {
2453
2520
  failed = true;
2454
2521
  // Issue #262: a root the breath carries is named with its carrier, not blamed on the chain.
2455
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 });
@@ -2468,9 +2535,10 @@ function tipLine(inp: FullInputs, geometryPath: string, frames: number, problems
2468
2535
  const declared = {
2469
2536
  ...(excepted.not_swinging.length === 0 ? {} : { declared_not_swinging: swing.filter((p) => excepted.not_swinging.includes(p.name)).map((p) => p.name) }),
2470
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) }),
2471
2539
  };
2472
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('; ')}` };
2473
- 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 }) };
2474
2542
  }
2475
2543
 
2476
2544
  /**
@@ -2590,6 +2658,33 @@ function breathCarrier(
2590
2658
  * different ways.
2591
2659
  */
2592
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> } {
2593
2688
  const bones = (Array.isArray(rig.bones) ? rig.bones : []).filter(isRecord).filter((b) => typeof b.name === 'string');
2594
2689
  const parent = new Map(bones.map((b) => [String(b.name), typeof b.parent === 'string' ? b.parent : null]));
2595
2690
  const bound = boundBones(rig, part);
@@ -2617,21 +2712,7 @@ export function chainDirection(rig: Record<string, unknown>, motion: Record<stri
2617
2712
  }
2618
2713
  }
2619
2714
  }
2620
- const boundText = [...bound].map((b) => `"${b}"`).join(', ') || 'no bone';
2621
- 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` };
2622
- const read = found.map((c) => {
2623
- const a = origins.get(c[0]);
2624
- const z = origins.get(c[c.length - 1]);
2625
- const label = c.join(' > ');
2626
- 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` };
2627
- const dx = z.x - a.x;
2628
- const dy = z.y - a.y;
2629
- const toward = chainToward(dx, dy);
2630
- return { label, toward, text: `${label} ${toward ?? 'runs as far across as along'} (${round3(dx)}, ${round3(dy)} world px)` };
2631
- });
2632
- const ways = [...new Set(read.map((r) => r.toward))];
2633
- if (ways.length !== 1 || ways[0] === null) return { none: `its chains do not run one way: ${read.map((r) => r.text).join('; ')}` };
2634
- return { toward: ways[0], chains: read.map((r) => r.text) };
2715
+ return { found, bound };
2635
2716
  }
2636
2717
 
2637
2718
  /**
@@ -2945,10 +3026,34 @@ function fmt3(x: number): string {
2945
3026
  return x.toFixed(3);
2946
3027
  }
2947
3028
 
2948
- /** (3) Down every chain the phase lags and the amplitude does not shrink, read off motion.json's rotate tracks. */
2949
- export function chainLine(inp: Pick<CheckInputs, 'rig' | 'motion'>, problems: Problem[]): JudgementLine {
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 {
2950
3048
  const cf = chainFigures(inp.rig, inp.motion);
2951
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(' > ')) };
2952
3057
  if (cf.edges.length === 0 && long.length === 0) {
2953
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`);
2954
3059
  }
@@ -2961,9 +3066,21 @@ export function chainLine(inp: Pick<CheckInputs, 'rig' | 'motion'>, problems: Pr
2961
3066
  }
2962
3067
  }
2963
3068
  for (const c of long) {
3069
+ const rests = resting.get(c[0].bone);
2964
3070
  for (let i = 1; i < c.length; i++) {
2965
3071
  const a = round3(c[i - 1].reading.amp);
2966
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
+ }
2967
3084
  if (b < a) {
2968
3085
  violations.push(`"${c[i].bone}" swings ${fmt3(b)} under "${c[i - 1].bone}"'s ${fmt3(a)}`);
2969
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` });
@@ -2973,6 +3090,7 @@ export function chainLine(inp: Pick<CheckInputs, 'rig' | 'motion'>, problems: Pr
2973
3090
  const minStep = cf.edges.length === 0 ? null : round3(Math.min(...cf.edges.map((e) => e.step)));
2974
3091
  return {
2975
3092
  status: violations.length === 0 ? 'PASS' : 'FAIL',
3093
+ ...said,
2976
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(' ')}`),
2977
3095
  lags: cf.edges.map((e) => `${e.parent} > ${e.child} ${e.step >= 0 ? '+' : ''}${fmt3(e.step)}`),
2978
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, readTag } from './tags.ts';
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
- if (typeof tag !== 'string' || readTag(tag) === null) {
1403
- c.fail('CONFIG_TAG_KNOWN', `${path} tag`, `is ${show(tag)}; a See-through v3 tag is required — one of ${acceptedTagNames().join(', ')}`);
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
- let outline = outlineInRegions(stage.outline, params.regions);
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
  }