rig-parts 2.2.0 → 2.4.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/build.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { DEFAULT_SEAM_RULE } from './seamrule.ts';
2
2
  import { SETUP_PAINTING_LINE, setupPaintingLines } from './setuppainting.ts';
3
+ import { boundaryColourLines } from './boundarycolour.ts';
3
4
  import { writeMaterialSources } from './cutmaterialio.ts';
4
5
  import { CUT_MATERIAL_LINE, CUT_MATERIAL_SOURCES, materialLines } from './cutmaterial.ts';
5
6
  import { ART_JOINT_LINE, artJointLines } from './artjoints.ts';
@@ -55,7 +56,7 @@ import { createHash } from 'node:crypto';
55
56
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
56
57
  import { dirname, join, resolve } from 'node:path';
57
58
  import { type AnimFrame, EncodeError, encodeApng, encodeIndexedApng, INDEXED_DEFAULTS } from './apng.ts';
58
- import { assemble, type AssembleResult, cutLines, type ExcludedCount, excludedLines, setExclusions, islandLines, setIslandSources, type IslandCount, hiddenLines, SINGLE_RUN_FACE_TAGS, singleRunHeadBox, softOcclusionLine, driftLine, limbLines, FIGURE_MASK_FILE, figuresLine, holeKindsLine, holeLines, measureRecomposite, type ProjectRule, recomposite, recompositeRecord, scenePhrase, type SeamRule, sourceInRig, stageFields, uncoveredLine } from './assemble.ts';
59
+ import { assemble, type AssembleResult, cutLines, type ExcludedCount, excludedLines, setExclusions, islandLines, setIslandSources, type IslandCount, hiddenLines, SINGLE_RUN_FACE_TAGS, singleRunHeadBox, softOcclusionLine, driftLine, limbLines, FIGURE_MASK_FILE, figuresLine, holeKindsLine, holeLines, measurePinholes, measureRecomposite, pinholeProblems, pinholesLine, type ProjectRule, recomposite, recompositeRecord, scenePhrase, type SeamRule, sourceInRig, stageFields, uncoveredLine } from './assemble.ts';
59
60
  import { allocationClause, type Reducer, stageBClause, unboundedClause } from './automesh.ts';
60
61
  import { residualClause } from './autoenvelope.ts';
61
62
  import { type AutoMotionCase, failingRows, idleSchedule, localRow, motionClause, motionDeformation, motionDocument, motionInput, motionRowText, motionStimulus, motionVerdict, noStimulusProblem, prepareComparison, type ReplayCandidate, runComparison, runPreparedComparison, sourceSelfFailure, stimulusMotion, withStimulus } from './automotion.ts';
@@ -82,9 +83,11 @@ import {
82
83
  splitSchedule,
83
84
  untestedIntervals,
84
85
  } from './autoreplay.ts';
85
- import { besideIdleLine, checkSummaryLine, inputFileRecord, type InputFileRecord, CLOTH_LINE, type ClothLine, clothWarnText, figureOpeningLines, frameRuns, MOTION_SEAM_LINE, type MotionSeamLine, type MotionSeamPlant, motionSeamWarnText, motionJointContactLines, FOLD_LINE, type FoldsLine, LEVEL_LINE, type LevelLine, LIMB_LINE, type LimbLine, STRESS_LINE, SWEEP_LINE, type SweepLine, buildGateLines, causeLines, environmentLine, IDLE_FPS, IDLE_MAX_PX, maxRgbDiff, REQUIREMENTS_DIR, type CheckReport, type PackLine, DEFAULT_PACK_SHAPE, DEFAULT_PAGE_EDGES, GATE_PROFILE, JUDGEMENT_LINES, type JudgementLine, packedBuildArgs, packedBuildLabel, type PackMode, readCheckEnvironment, readWindBand, type PackShape, type PageEdges, readFrameSet, rigcFailed, rigcIdentity, rigcRanClause, REPORTED_LINES, type ReportedLine, type RigcRunner, runCheck, SEAM_DIR, SEAM_MEAN_BAR, seamPairText, seamRigPxText, SOURCE_LINE, SEAM_PX_BAR, SEAM_PX_LEVEL, SEAM_PX_LEVEL_HIGH, SPINEBOY_YARDSTICK, STILL_DIR, WIND_BAND_LINE, windBandLines } from './check.ts';
86
+ import { besideIdleLine, checkSummaryLine, inputFileRecord, type InputFileRecord, CLOTH_LINE, type ClothLine, clothWarnText, figureOpeningLines, frameRuns, MOTION_SEAM_LINE, type MotionSeamLine, type MotionSeamPlant, motionSeamRequiredLine, motionSeamWarnText, motionJointContactLines, FOLD_LINE, type FoldsLine, LEVEL_LINE, type LevelLine, LIMB_LINE, type LimbLine, STRESS_LINE, SWEEP_LINE, type SweepLine, buildGateLines, causeLines, environmentLine, IDLE_FPS, IDLE_MAX_PX, maxRgbDiff, REQUIREMENTS_DIR, type CheckReport, type PackLine, DEFAULT_PACK_SHAPE, DEFAULT_PAGE_EDGES, GATE_PROFILE, JUDGEMENT_LINES, type JudgementLine, packedBuildArgs, packedBuildLabel, type PackMode, readCheckEnvironment, readWindBand, type PackShape, type PageEdges, readFrameSet, rigcFailed, rigcIdentity, rigcRanClause, REPORTED_LINES, type ReportedLine, type RigcRunner, runCheck, SEAM_DIR, SEAM_MEAN_BAR, seamPairText, seamRigPxText, SOURCE_LINE, SEAM_PX_BAR, SEAM_PX_LEVEL, SEAM_PX_LEVEL_HIGH, SPINEBOY_YARDSTICK, STILL_DIR, WIND_BAND_LINE, windBandLines } from './check.ts';
86
87
  import { type Cut, type Extend, type Limb, loadConfigAndAnimations, loadConfigWithBytes, loadEarlyConfig, type Patch, type PlanEntry } from './config.ts';
87
88
  import { coreCompileGuideLine, failLine, PartsError, type Problem, refuseIfAny } from './errors.ts';
89
+ import { editedConfigImages, editedRunImages, missingReceipts, readEditorReceipts } from './editreceipt.ts';
90
+ import { untracedPartSources } from './partsource.ts';
88
91
  import type { MeshQualityReport, MotionSchedule } from '@rig-c/core/mesh';
89
92
  import { encodeGif } from './gif.ts';
90
93
  import { CONTROL_SUFFIX, withAnimations } from './motion.ts';
@@ -98,7 +101,7 @@ import { type Mask, newMask } from './raster/types.ts';
98
101
  import { type OtherLimbs, splitLines, capOwnershipLines, type SplitCount } from './assemble.ts';
99
102
  import type { BoneEntry } from './config.ts';
100
103
  import { OPAQUE_ALPHA_ABOVE } from './layers.ts';
101
- import { readRequirements, type RequirementLine, summaryText } from './requirements.ts';
104
+ import { pinholesRequired, readRequirements, type RequirementLine, summaryText } from './requirements.ts';
102
105
  import { type AutoMeshReport, buildRig, DEFAULT_IDLE_KEYS, type FaceAxis, type IdleKeys, type MeshReport, type RigCommand, rigJsonText, type RigOutput, type RigSpec, SETS_FILE, type StrayCleared, type TurnRow } from './rig.ts';
103
106
  import { KEYPOINT_NAMES, type KeypointName, type PosePoints } from './skeleton.ts';
104
107
  import { AUTHOR_NO_ROLE, isAuthorTag } from './tags.ts';
@@ -324,13 +327,21 @@ export function frontOrdered<T extends { record: { name: string } }>(items: read
324
327
 
325
328
  /** Read, assemble, and write only after every refusal has had its chance. Throws a PartsError on a refusal, having written nothing. */
326
329
  export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs, log: Log): AssembleResult {
327
- const materialAsked = input.requirements === undefined ? false : readRequirements(input.requirements).cut_material !== undefined;
330
+ const requirementsFile = input.requirements === undefined ? null : readRequirements(input.requirements);
331
+ const materialAsked = requirementsFile?.cut_material !== undefined;
332
+ // Issue #944: the pinhole bar is measured only when the requirements file declares it required.
333
+ const pinholesAsked = requirementsFile !== null && pinholesRequired(requirementsFile);
328
334
  // The early door: assemble runs before propose has drafted bones, meshes,
329
335
  // regions and motion, so it requires only what it reads (issue #20).
330
336
  const cfg = loadEarlyConfig(input.config, 'assemble');
331
337
  const seam = input.seam ?? cfg.assemble.seam ?? DEFAULT_SEAM_RULE;
332
338
  const src = readSource(input.source);
333
- const runs = applyTakenLayers(readRuns(input.full, input.head), src, cfg, input.config);
339
+ const read = readRuns(input.full, input.head);
340
+ // Issue #946: every declared receipt is held to its file, and under `required` every edited image needs one, before anything is assembled.
341
+ const receipts = readEditorReceipts(cfg.assemble.editor_receipts, input.config);
342
+ // Issue #947: under `trace_parts`, a shape drawn in the config through a part's pixels is refused with them.
343
+ refuseIfAny([...receipts.problems, ...missingReceipts(receipts, [...editedRunImages(read), ...editedConfigImages(input.config, cfg.assemble.cuts ?? [], cfg.limbs)]), ...untracedPartSources(cfg)]);
344
+ const runs = applyTakenLayers(read, src, cfg, input.config);
334
345
  for (const note of runs.notes) log(`note: ${note}`);
335
346
  const fields = stageFields(cfg, runs.single ? { w: src.width, h: src.height } : null);
336
347
  const under = readUnders(input.config, fields.cuts);
@@ -489,6 +500,7 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
489
500
  if (invalidFocus.length > 0) { altProblems.push(...invalidFocus); continue; }
490
501
  }
491
502
  const altRuns = readRuns(alt.full, alt.head ?? null);
503
+ altProblems.push(...missingReceipts(receipts, [...editedRunImages(altRuns, `alternate set "${alt.name}" `), ...editedConfigImages(input.config, alt.cuts ?? [], undefined, `${alt.at}.cuts`)]));
492
504
  const hidesSet = new Set(alt.hides ?? []);
493
505
  const declaredSlots = alt.parts !== undefined ? alt.parts.filter((p) => !hidesSet.has(p)) : baseSlots.filter((p) => !hidesSet.has(p));
494
506
  const coveredSet = new Set(declaredSlots);
@@ -759,6 +771,10 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
759
771
  assetIndices.set(file, index);
760
772
  }
761
773
  });
774
+ // Issue #944: under pinholes.required, every pinhole of the base set's flat parts stack is refused by name, with the
775
+ // alternate sets' refusals, before anything is written.
776
+ const pinholes = pinholesAsked ? measurePinholes(sourceInRig(src, result.parts.rig_size[0], result.parts.rig_size[1]), result.images) : null;
777
+ if (pinholes !== null) altProblems.push(...pinholeProblems(pinholes, input.requirements as string));
762
778
  refuseIfAny(altProblems);
763
779
  if (altBuilt.length > 0) {
764
780
  result.parts.alternates = altBuilt.map((b) => b.record);
@@ -829,6 +845,7 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
829
845
  for (const l of holeLines(result.figures, scene)) log(l);
830
846
  if (result.figures.uncoveredErrorPx > 0) log(holeKindsLine(result.holeKinds, scene));
831
847
  for (const u of result.uncovered) log(uncoveredLine(u, scene));
848
+ log(pinholesLine(pinholes, altBuilt.map((b) => b.record.name)));
832
849
  for (const d of result.drift) log(driftLine(d));
833
850
  for (const d of result.softOcclusion ?? []) log(softOcclusionLine(d));
834
851
  // Issue #588: per subset set, one line per limb part cut from a layer of more than one island, then (issue #587) one
@@ -1943,6 +1960,7 @@ export function checkStage(input: CheckStageInput, rigc: RigcRunner, bin: string
1943
1960
  const sweep = fig[SWEEP_LINE];
1944
1961
  if (sweep !== undefined) for (const [i, l] of sweepCheckLines(sweep, r.sweepSteps).entries()) log(` ${i === 0 ? '' : ' '}${l}`);
1945
1962
  for (const [i, l] of motionSeamLines(r.motionSeam).entries()) log(` ${i === 0 ? '' : ' '}${l}`);
1963
+ if (r.motionSeam.required !== undefined) log(` ${motionSeamRequiredLine(r.motionSeam.required)}`);
1946
1964
  for (const [i, l] of figureOpeningLines(r.figureOpenings).entries()) log(` ${i === 0 ? '' : ' '}${l}`);
1947
1965
  for (const [i, l] of clothLines(r.cloth).entries()) log(` ${i === 0 ? '' : ' '}${l}`);
1948
1966
  for (const name of REPORTED_LINES) log(` ${judgementLine(name, fig[name])}`);
@@ -1961,6 +1979,7 @@ export function checkStage(input: CheckStageInput, rigc: RigcRunner, bin: string
1961
1979
  if (artJoints !== undefined) for (const line of artJointLines(artJoints)) log(` ${line}`);
1962
1980
  const setupPainting = fig[SETUP_PAINTING_LINE];
1963
1981
  if (setupPainting !== undefined) for (const line of setupPaintingLines(setupPainting)) log(` ${line}`);
1982
+ for (const line of boundaryColourLines(r.boundaryColour)) log(` ${line}`);
1964
1983
  if (r.requirements !== null) {
1965
1984
  for (const [name, line] of Object.entries(r.requirements.lines)) log(` ${requirementText(name, line)}`);
1966
1985
  log(` ${summaryText(r.requirements.summary)}`);
package/src/check.ts CHANGED
@@ -2,6 +2,7 @@ import { SETUP_PAINTING_LINE, UNDECLARED_SETUP_PAINTING_REASON, setupPaintingBou
2
2
  import { readMaterialSources } from './cutmaterialio.ts';
3
3
  import { CUT_MATERIAL_LINE, checkJointMaterial, type MaterialLine } from './cutmaterial.ts';
4
4
  import { ART_JOINT_LINE, artJointLine, type ArtJointLine } from './artjoints.ts';
5
+ import { BOUNDARY_COLOUR_LINE, BOUNDARY_COLOUR_UNMEASURED_CODE, measureBoundaryColour, type BoundaryColourMeasured } from './boundarycolour.ts';
5
6
  import { baseAnchorMoves, type AnchorRegion } from './baseanchor.ts';
6
7
  /**
7
8
  * The check stage: build a rig through rig-c, gate it under both
@@ -164,6 +165,7 @@ import {
164
165
  resolveRequirements,
165
166
  rigFacts,
166
167
  placedArt,
168
+ seamOverMotionRequired,
167
169
  type PosedAttachment,
168
170
  resolveSeams,
169
171
  type SeamFrame,
@@ -1927,7 +1929,7 @@ export interface SeamStateBounds {
1927
1929
  * different API. Importing check, or constructing a root-free reader, loads none.
1928
1930
  */
1929
1931
  type OfficialRenderer = typeof import('@rig-c/core/render-shared');
1930
- function officialRenderer(): OfficialRenderer {
1932
+ export function officialRenderer(): OfficialRenderer {
1931
1933
  const require = createRequire(import.meta.url);
1932
1934
  const coreRoot = dirname(require.resolve('@rig-c/core/package.json'));
1933
1935
  let runtimePresent = false;
@@ -2736,6 +2738,8 @@ export interface CheckFigures {
2736
2738
  /** Under --source: the declared observation, or an explicit undeclared SKIP. */
2737
2739
  [SETUP_PAINTING_LINE]?: SetupPaintingObservation;
2738
2740
  [CUT_MATERIAL_LINE]?: MaterialLine;
2741
+ /** Issue #942: only when requirements declares boundary_colour; undeclared writes no key and prints a SKIP. */
2742
+ [BOUNDARY_COLOUR_LINE]?: BoundaryColourMeasured;
2739
2743
  PASS: boolean;
2740
2744
  }
2741
2745
 
@@ -2982,12 +2986,12 @@ export function barsNotMet(fig: Readonly<Record<string, unknown>>): string[] {
2982
2986
  isRecord(fig.sets) &&
2983
2987
  Object.values(fig.sets).some((s) => isRecord(s) && typeof s.seam_mean === 'number' && typeof s.seam_px_over_40 === 'number' && seamFails(s.seam_mean, s.seam_px_over_40, typeof s.seam_frame_scale === 'number' ? s.seam_frame_scale : 1));
2984
2988
  if ((typeof fig.seam_mean === 'number' && typeof fig.seam_px_over_40 === 'number' && seamFails(fig.seam_mean, fig.seam_px_over_40, scale)) || setFailed) out.push('seam');
2985
- for (const n of [...JUDGEMENT_LINES, FOLD_LINE, LEVEL_LINE, STRESS_LINE, LIMB_LINE, SWEEP_LINE, ART_JOINT_LINE, CUT_MATERIAL_LINE]) {
2989
+ for (const n of [...JUDGEMENT_LINES, FOLD_LINE, LEVEL_LINE, STRESS_LINE, LIMB_LINE, SWEEP_LINE, ART_JOINT_LINE, CUT_MATERIAL_LINE, BOUNDARY_COLOUR_LINE]) {
2986
2990
  const l = fig[n];
2987
2991
  if (typeof l === 'object' && l !== null && (l as { status?: unknown }).status === 'FAIL') out.push(n);
2988
2992
  }
2989
2993
  const motion = fig[MOTION_SEAM_LINE];
2990
- if (isRecord(motion) && isRecord(motion.roots) && motion.roots.status === 'FAIL') out.push(MOTION_SEAM_LINE);
2994
+ if (isRecord(motion) && ((isRecord(motion.roots) && motion.roots.status === 'FAIL') || (isRecord(motion.required) && motion.required.status === 'FAIL'))) out.push(MOTION_SEAM_LINE);
2991
2995
  return out;
2992
2996
  }
2993
2997
 
@@ -3033,6 +3037,8 @@ export interface CheckReport {
3033
3037
  motionSeam: MotionSeamLine;
3034
3038
  /** Issue #933: openings inside the figure over motion on undeclared setup-overlapping layer pairs — a warning, never a bar. */
3035
3039
  figureOpenings: FigureOpeningReading;
3040
+ /** Issue #942: the boundary colour-step reading; null when undeclared (the SKIP line is printed, nothing written). */
3041
+ boundaryColour: BoundaryColourMeasured | null;
3036
3042
  /** `CLOTH_SETTLES` (issue #501): a warning, never a bar — in `check.json` only when the rig declares a physics constraint and has an idle; its SKIP is printed either way. */
3037
3043
  cloth: ClothLine;
3038
3044
  /** `WIND_BAND` (issue #590): the steady-wind band each declared seam pair holds, written under `--wind-band` only. */
@@ -3093,7 +3099,7 @@ export function checkStatuses(fig: CheckFigures): Record<string, CheckStatus> {
3093
3099
  const bar = (name: string, measured: boolean): CheckStatus => (!measured ? 'SKIP' : notMet.includes(name) ? 'FAIL' : 'PASS');
3094
3100
  const out: Record<string, CheckStatus> = { gate: bar('gate', true), loop: bar('loop', fig.loop_max_diff !== null), seam: bar('seam', fig.seam_mean !== null) };
3095
3101
  for (const n of JUDGEMENT_LINES) out[n] = fig[n].status;
3096
- for (const n of [FOLD_LINE, LEVEL_LINE, STRESS_LINE, LIMB_LINE, SWEEP_LINE, MOTION_SEAM_LINE, CLOTH_LINE, ...REPORTED_LINES, 'figure_openings', SOURCE_LINE, WIND_BAND_LINE, ART_JOINT_LINE, CUT_MATERIAL_LINE, SETUP_PAINTING_LINE] as const) {
3102
+ for (const n of [FOLD_LINE, LEVEL_LINE, STRESS_LINE, LIMB_LINE, SWEEP_LINE, MOTION_SEAM_LINE, CLOTH_LINE, ...REPORTED_LINES, 'figure_openings', SOURCE_LINE, WIND_BAND_LINE, ART_JOINT_LINE, CUT_MATERIAL_LINE, SETUP_PAINTING_LINE, BOUNDARY_COLOUR_LINE] as const) {
3097
3103
  const l = fig[n];
3098
3104
  if (l !== undefined) out[n] = l.status;
3099
3105
  }
@@ -4420,6 +4426,139 @@ export interface MotionSeamLine {
4420
4426
  worlds?: MotionSeamWorlds;
4421
4427
  /** Selected body-root pairs and named zero-band roots; existing internal seams remain warnings. */
4422
4428
  roots?: { status: 'PASS' | 'FAIL' | 'SKIP'; mode: 'gap-open+under-ghost'; ghost_owner: 'resolved-draw-order'; pairs: string[]; failed: number; reason?: string; unmeasured?: MotionUnmeasuredRoot[] };
4429
+ /** Issue #941: written only under the requirements file's `seam_over_motion.required: true` ({@link motionSeamRequired}). */
4430
+ required?: MotionSeamRequired;
4431
+ }
4432
+
4433
+ /** Issue #941: the refusal's code — background on a frame of a declared pair, or a pair or line not measured, under `seam_over_motion.required`. */
4434
+ export const MOTION_SEAM_REQUIRED_CODE = 'CHECK_SEAM_OVER_MOTION_REQUIRED';
4435
+
4436
+ /** The class a declared pair is named by in the required bar: a proven body root, a limb joint below segment 0, or a declaration (`seam` requirement, `motion_seams`). */
4437
+ export type MotionSeamClass = 'body-root' | 'internal-joint' | 'explicit';
4438
+
4439
+ /** Issue #941: one pair in one animation as the background bar read it — every frame of the set, by rigc's `gapOpen`. */
4440
+ export interface MotionBackgroundRow {
4441
+ pair: string;
4442
+ animation: string;
4443
+ /** Frames in the set: every frame `i / fps`, `i = 0..round(duration * fps)` (frames.json `sampled`, stride 1). */
4444
+ frames: number;
4445
+ status: 'read' | 'skip';
4446
+ drawn: number;
4447
+ banded: number;
4448
+ /** rigc's `gapOpen` hits with a cell: background inside the carried setup-overlap band, alpha 0 in the whole skeleton composite. */
4449
+ hits: Array<MotionSeamHit & { time: number }>;
4450
+ }
4451
+
4452
+ /** Issue #941: `SEAM_OVER_MOTION`'s `required` block. The bar is 0 background cells on every frame read; it never SKIPs. */
4453
+ export interface MotionSeamRequired {
4454
+ status: 'PASS' | 'FAIL';
4455
+ mode: 'gap-open';
4456
+ bar_px: 0;
4457
+ /** Every declared pair, its class, and the frames read over every animation. */
4458
+ pairs: Array<{ pair: string; class: MotionSeamClass; frames: number; background_frames: number; max_px: number }>;
4459
+ /** How many refusals the bar added. */
4460
+ failed: number;
4461
+ }
4462
+
4463
+ /** A declared pair's class: a proven root first, then a limb joint, otherwise its explicit declaration. */
4464
+ export function motionSeamClass(p: MotionSeamPair): MotionSeamClass {
4465
+ if (p.root !== undefined) return 'body-root';
4466
+ return p.declared.some((d) => d.startsWith('config.limbs.')) ? 'internal-joint' : 'explicit';
4467
+ }
4468
+
4469
+ /**
4470
+ * Issue #941: every pair's `gapOpen` over every frame of one `rigc render --seam` run, beside {@link readMotionSeams}
4471
+ * (which keeps its own reading). Refuses (`FRAMES_SIDECAR`), every problem at once, a set that is not every frame at
4472
+ * the rate asked (stride 1, written = sampled), a file that is not the animation's or the rate's, a pair out of the
4473
+ * flag's order, and a `gapOpen` of the wrong shape — unavailable is never a measured zero.
4474
+ */
4475
+ export function readMotionSeamBackground(dir: string, animations: readonly string[], fps: number, pairs: readonly MotionSeamPair[]): MotionBackgroundRow[] {
4476
+ const problems: Problem[] = [];
4477
+ const bad = (object: string, detail: string): void => { problems.push({ code: 'FRAMES_SIDECAR', object, detail }); };
4478
+ const sidecar = readJson(join(dir, 'frames.json'), problems);
4479
+ const sets = sidecar !== null && Array.isArray(sidecar.sets) ? sidecar.sets.filter(isRecord) : [];
4480
+ const rows: MotionBackgroundRow[] = [];
4481
+ for (const animation of animations) {
4482
+ const set = sets.find((x) => x.animation === animation);
4483
+ if (set === undefined || typeof set.dir !== 'string') { bad(join(dir, 'frames.json'), `names no frame set for animation ${JSON.stringify(animation)}; \`rigc render --seam\` over the build writes one per animation`); continue; }
4484
+ if (set.fps !== fps || set.stride !== 1 || typeof set.sampled !== 'number' || set.written !== set.sampled || !Number.isSafeInteger(set.sampled) || set.sampled < 1) {
4485
+ bad(join(dir, 'frames.json') + ' set ' + JSON.stringify(animation), `is fps ${JSON.stringify(set.fps)}, sampled ${JSON.stringify(set.sampled)}, written ${JSON.stringify(set.written)}, stride ${JSON.stringify(set.stride)}; every frame at ${fps} fps (stride 1, written = sampled) is required — a sample is not every frame`);
4486
+ continue;
4487
+ }
4488
+ const path = join(dir, set.dir, SEAM_READING_FILE);
4489
+ const file = readJson(path, problems);
4490
+ if (file === null) continue;
4491
+ if (file.spec !== SEAM_READING_SPEC || file.animation !== animation || file.fps !== fps) { bad(path, `is ${JSON.stringify(file.spec)} for ${JSON.stringify(file.animation)} at ${JSON.stringify(file.fps)} fps; "${SEAM_READING_SPEC}" for ${JSON.stringify(animation)} at ${fps} fps is required`); continue; }
4492
+ const read = Array.isArray(file.pairs) ? file.pairs : [];
4493
+ if (read.length !== pairs.length) bad(path, `holds ${read.length} pair(s); the ${pairs.length} asked for, in the flag's order, are required`);
4494
+ pairs.forEach((want, i) => {
4495
+ const p = read[i];
4496
+ const at = `${path} pairs[${i}]`;
4497
+ if (!isRecord(p) || p.a !== want.a || p.b !== want.b) { bad(at, `is ${JSON.stringify(isRecord(p) ? `${String(p.a)}|${String(p.b)}` : p)}; "${want.pair}", the flag's entry ${i}, is required`); return; }
4498
+ const open = p.gapOpen;
4499
+ const frames = isRecord(open) && Array.isArray(open.frames) ? open.frames : null;
4500
+ const hits = frames === null ? [] : frames.flatMap((h) => isRecord(h) && Number.isSafeInteger(h.index) && typeof h.index === 'number' && h.index >= 0 && h.index < (set.sampled as number) &&
4501
+ Number.isSafeInteger(h.px) && typeof h.px === 'number' && h.px > 0 && typeof h.cell === 'number' && h.cell > 0 && typeof h.time === 'number' && Number.isFinite(h.time) &&
4502
+ Array.isArray(h.box) && h.box.length === 4 && h.box.every((v) => typeof v === 'number' && Number.isFinite(v))
4503
+ ? [{ index: h.index, px: h.px, cell: h.cell, time: h.time, box: [h.box[0], h.box[1], h.box[2], h.box[3]] as [number, number, number, number] }] : []);
4504
+ if ((p.status !== 'read' && p.status !== 'skip') || typeof p.drawn !== 'number' || typeof p.banded !== 'number' || frames === null || hits.length !== frames.length || new Set(hits.map((h) => h.index)).size !== hits.length) {
4505
+ bad(`${at} gapOpen`, 'requires { frames: [{ index, time, px > 0, cell, box }…] } on every pair, one hit per frame, as rigc-seam/1 writes it from rig-c 2.69.0 — unavailable is not zero background');
4506
+ return;
4507
+ }
4508
+ rows.push({ pair: want.pair, animation, frames: set.sampled as number, status: p.status, drawn: p.drawn, banded: p.banded, hits });
4509
+ });
4510
+ }
4511
+ refuseIfAny(problems);
4512
+ return rows;
4513
+ }
4514
+
4515
+ /**
4516
+ * Issue #941: the bar `seam_over_motion.required: true` declares — 0 background cells (rigc's `gapOpen`: gap cells
4517
+ * inside the carried setup-overlap band where the whole skeleton composite has alpha 0) on every frame of every
4518
+ * animation, for every declared pair: body-root, internal-joint and explicit. No floor, no sample. A line or a pair
4519
+ * that measured nothing is refused too, since a SKIP is never a pass. Problems are pushed, one per pair and
4520
+ * animation (with its worst frame) and one per pair or line not measured; the block is returned.
4521
+ */
4522
+ export function motionSeamRequired(line: MotionSeamLine, rows: readonly MotionBackgroundRow[], requirements: string, problems: Problem[]): MotionSeamRequired {
4523
+ const before = problems.length;
4524
+ const why = `${requirements} declares seam_over_motion.required: true, so 0 background px is required on every frame of every declared seam pair`;
4525
+ const refuse = (object: string, detail: string): void => { problems.push({ code: MOTION_SEAM_REQUIRED_CODE, object, detail }); };
4526
+ if (line.status === 'SKIP' && line.rows.length === 0 && line.pairs.every((p) => p.unread === undefined)) {
4527
+ refuse(MOTION_SEAM_LINE, `is SKIP — ${String(line.reason)}; nothing was measured, which is not a pass: ${why}. Declare the pairs (config.limbs, config.motion_seams, a --requirements seam) and an animation, or drop the declaration`);
4528
+ }
4529
+ const out: MotionSeamRequired['pairs'] = [];
4530
+ for (const p of line.pairs) {
4531
+ const cls = motionSeamClass(p);
4532
+ const label = `${cls} seam "${p.pair}"`;
4533
+ let frames = 0;
4534
+ let open = 0;
4535
+ let max = 0;
4536
+ if (p.unread !== undefined) refuse(label, `is not read — ${p.unread}; ${why}`);
4537
+ else for (const animation of line.animations) {
4538
+ const r = rows.find((x) => x.pair === p.pair && x.animation === animation);
4539
+ if (r === undefined || r.status !== 'read' || r.banded === 0) {
4540
+ refuse(`${label} in "${animation}"`, `${r === undefined ? 'has no returned reading' : `rigc read no band on any of the ${r.drawn} frame(s) both slots draw (status ${r.status})`}; not measured is not a pass: ${why}`);
4541
+ continue;
4542
+ }
4543
+ frames += r.frames;
4544
+ open += r.hits.length;
4545
+ if (r.hits.length === 0) continue;
4546
+ const worst = r.hits.reduce((b, h) => (h.px > b.px || (h.px === b.px && h.index < b.index) ? h : b));
4547
+ max = Math.max(max, worst.px);
4548
+ refuse(`${label} in "${animation}" at frame ${worst.index} (t = ${worst.time}s)`,
4549
+ `background on ${r.hits.length} of ${r.frames} frame(s) read ${frameRuns(r.hits.map((h) => h.index))}: found ${worst.px} px (rigc gapOpen cells of ${worst.cell} world units, world box [${worst.box.join(', ')}]) on the worst frame, 0 required; ${why}`);
4550
+ }
4551
+ out.push({ pair: p.pair, class: cls, frames, background_frames: open, max_px: max });
4552
+ }
4553
+ const failed = problems.length - before;
4554
+ return { status: failed === 0 ? 'PASS' : 'FAIL', mode: 'gap-open', bar_px: 0, pairs: out, failed };
4555
+ }
4556
+
4557
+ /** Issue #941: the console line for the required block. */
4558
+ export function motionSeamRequiredLine(r: MotionSeamRequired): string {
4559
+ const frames = r.pairs.reduce((n, p) => n + p.frames, 0);
4560
+ const classes = (['body-root', 'internal-joint', 'explicit'] as const).map((c) => `${r.pairs.filter((p) => p.class === c).length} ${c}`).join(', ');
4561
+ return `${MOTION_SEAM_LINE} required: ${r.status} — ${r.pairs.length} declared pair(s) (${classes}), ${frames} pair-frame(s) read, every frame of every animation: ${r.failed === 0 ? '0 background px on every frame' : `${r.failed} refusal(s)`}, bar 0 px (rigc gapOpen, no floor; seam_over_motion.required: true)`;
4423
4562
  }
4424
4563
 
4425
4564
  /**
@@ -6285,6 +6424,11 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
6285
6424
  refuseIfAny(setupPaintingBounds(file.setup_painting as SetupPainting, ready.stage.width, ready.stage.height));
6286
6425
  if (ready.source === null) refuseIfAny([{ code: 'CHECK_SETUP_PAINTING_SOURCE', object: 'requirements.setup_painting', detail: 'requires --source naming the painting; the assembled composite is not an independent source painting' }]);
6287
6426
  });
6427
+ // Issue #942: a declared boundary_colour reads parts.json's pieces and the idle; without either it cannot be taken.
6428
+ if (file.boundary_colour !== undefined) take(() => refuseIfAny([
6429
+ ...(ready.parts === null ? [{ code: BOUNDARY_COLOUR_UNMEASURED_CODE, object: 'requirements.boundary_colour', detail: `requires parts.json and its parts/ PNGs, the rig's own pieces the step is measured against: ${ready.noParts ?? 'none was read'}; an untaken reading is never a pass` }] : []),
6430
+ ...(ready.idleDuration === null ? [{ code: BOUNDARY_COLOUR_UNMEASURED_CODE, object: 'requirements.boundary_colour', detail: `requires an idle animation, whose every frame is measured: ${ready.noIdle ?? 'none was read'}; an untaken reading is never a pass` }] : []),
6431
+ ]));
6288
6432
  take(() => refuseIfAny(exceptionProblems(file, ready.parts, ready.noParts)));
6289
6433
  // A seam's pairs are read off parts.json's art (issue #111); both resolvers' problems are named in one refusal.
6290
6434
  if (file.requirements.some((r) => r.kind === 'seam')) {
@@ -6710,6 +6854,9 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
6710
6854
  // Read the build as played at the idle's rate; unavailable data is named SKIP.
6711
6855
  let motionSeam: MotionSeamLine;
6712
6856
  const motionRootProblems: Problem[] = [];
6857
+ // Issue #941: under seam_over_motion.required, the same render's gapOpen on every frame of every declared pair.
6858
+ const seamRequired = seamOverMotionRequired(reqFile);
6859
+ let backgroundRows: MotionBackgroundRow[] = [];
6713
6860
  {
6714
6861
  const fps = IDLE_FPS;
6715
6862
  const seamReqs = reqFile === null || seams === null ? [] : reqFile.requirements.flatMap((r) => (r.kind === 'seam' && seams?.has(r.name) === true ? [{ name: r.name, part: r.part, neighbour: r.neighbour }] : []));
@@ -6773,6 +6920,7 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
6773
6920
  if (r.status !== 0) refuseIfAny(rigcFailed(`render --seam ${list} --fps ${fps}`, r, r.out.split('\n').filter((l) => l.includes('FAIL') || l.includes('rigc:'))));
6774
6921
  const activeWorldRows: MotionSeamWorldRow[] = [];
6775
6922
  motionSeam = motionSeamLine(pairs, animationNames, fps, readMotionSeams(activeDest, animationNames, fps, read, {}, stripped !== null && env === null ? activeWorldRows : undefined, hasRoots ? motionRootDrawOrderReader(activeDest) : undefined), undefined, motionRootProblems, unmeasuredRoots);
6923
+ if (seamRequired) backgroundRows = readMotionSeamBackground(activeDest, animationNames, fps, read);
6776
6924
  enrichMotionSeamPoses(activeDest, inp.rig, inp.parts, pairs, motionSeam.rows);
6777
6925
  if (stripped !== null) {
6778
6926
  const response = environmentResponse(readFileSync(join(buildDir, 'skeleton.json'), 'utf8'));
@@ -6800,6 +6948,7 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
6800
6948
  }
6801
6949
  }
6802
6950
  }
6951
+ if (seamRequired && reqFile !== null) motionSeam.required = motionSeamRequired(motionSeam, backgroundRows, reqFile.path, motionRootProblems);
6803
6952
  const declaredPairKeys = new Set(motionSeam.pairs.map((p) => [p.a, p.b].sort().join('|')));
6804
6953
  const figureOpenings: FigureOpeningReading =
6805
6954
  inp.parts === null
@@ -7031,6 +7180,11 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
7031
7180
  }
7032
7181
  }
7033
7182
 
7183
+ // Issue #942: the boundary colour step against the rig's own pieces, on the gated build, only when declared.
7184
+ const boundaryDeclared = reqFile?.boundary_colour;
7185
+ const boundaryColour = boundaryDeclared === undefined || inp.parts === null || inp.idleDuration === null ? null
7186
+ : measureBoundaryColour(officialRenderer(), { build: buildDir, stage: inp.stage, parts: inp.parts, partsDir: inp.partsDir, imagesDir: typeof inp.rig.images === 'string' ? resolve(inp.rigDir, inp.rig.images) : join(inp.rigDir, 'images'), rig: inp.rig, fps: IDLE_FPS }, boundaryDeclared, problems);
7187
+
7034
7188
  // 5. the scene's declared requirements (issue #93), only under --requirements
7035
7189
  const reqProblems: Problem[] = [];
7036
7190
  const reqBlock = reqFile === null || reqRecord === null ? null : { file: reqRecord, ...measureRequirements(inp, reqFile, buildDir, out, rigc, reqProblems, seams ?? new Map(), measured) };
@@ -7063,7 +7217,7 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
7063
7217
  ...(stress === null ? {} : { [STRESS_LINE]: stress }),
7064
7218
  ...(limbs === null ? {} : { [LIMB_LINE]: limbs }),
7065
7219
  ...(sweep === null ? {} : { [SWEEP_LINE]: sweep }),
7066
- ...(motionSeam.pairs.length === 0 && motionSeam.roots === undefined ? {} : { [MOTION_SEAM_LINE]: motionSeam }),
7220
+ ...(motionSeam.pairs.length === 0 && motionSeam.roots === undefined && motionSeam.required === undefined ? {} : { [MOTION_SEAM_LINE]: motionSeam }),
7067
7221
  ...(stripped === null || idle === null ? {} : { [CLOTH_LINE]: cloth }),
7068
7222
  RECOMPOSITE_HOLES: inp.parts === null ? { status: 'SKIP', reason: inp.noParts ?? '' } : holesLine(inp.parts),
7069
7223
  ...(figureOpenings.status === 'SKIP' ? {} : { figure_openings: figureOpenings }),
@@ -7074,6 +7228,7 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
7074
7228
  ...(config === undefined ? {} : { config }),
7075
7229
  ...(artJoints === null ? {} : { [ART_JOINT_LINE]: artJoints }),
7076
7230
  ...(setupPainting === null ? {} : { [SETUP_PAINTING_LINE]: setupPainting }),
7231
+ ...(boundaryColour === null ? {} : { [BOUNDARY_COLOUR_LINE]: boundaryColour }),
7077
7232
  ...(cutMaterial === null ? {} : { [CUT_MATERIAL_LINE]: cutMaterial }),
7078
7233
  ...(reqBlock === null ? {} : { requirements: reqBlock }),
7079
7234
  PASS: false,
@@ -7141,6 +7296,7 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
7141
7296
  if (cutMaterial !== null) tally(CUT_MATERIAL_LINE, null);
7142
7297
  if (artJoints !== null) tally(ART_JOINT_LINE, artJoints.status === 'SKIP' ? (artJoints.reason ?? 'no declared limit') : null);
7143
7298
  if (setupPainting !== null) tally(SETUP_PAINTING_LINE, setupPainting.status === 'SKIP' ? (setupPainting.reason ?? 'no declared limit') : null);
7299
+ if (boundaryColour !== null) tally(BOUNDARY_COLOUR_LINE, null);
7144
7300
  // Issue #348: the level bar where it is written; a line whose every foot no frame posed says SKIP and is not measured.
7145
7301
  if (level !== null) tally(LEVEL_LINE, level.status === 'SKIP' ? (level.reason ?? '') : null);
7146
7302
  // Issue #347: the stress bar is counted where it is written — on every rig with a handwear part and an idle.
@@ -7149,7 +7305,9 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
7149
7305
  if (limbs !== null) tally(LIMB_LINE, null);
7150
7306
  // Issue #360: the range bar where it is written; a joint it cannot read fails, so it always measures.
7151
7307
  if (sweep !== null) tally(SWEEP_LINE, null);
7152
- if (motionSeam.roots !== undefined) tally(MOTION_SEAM_LINE, motionSeam.roots.status === 'SKIP' ? (motionSeam.roots.reason ?? 'no body-root reading') : null);
7308
+ // Issue #941: the required bar never SKIPs (an unmeasured line or pair is its refusal), so it counts the line measured.
7309
+ if (motionSeam.required !== undefined) tally(MOTION_SEAM_LINE, null);
7310
+ else if (motionSeam.roots !== undefined) tally(MOTION_SEAM_LINE, motionSeam.roots.status === 'SKIP' ? (motionSeam.roots.reason ?? 'no body-root reading') : null);
7153
7311
  return {
7154
7312
  figures,
7155
7313
  gateHtml,
@@ -7167,6 +7325,7 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
7167
7325
  motionSeam,
7168
7326
  figureOpenings,
7169
7327
  cloth,
7328
+ boundaryColour,
7170
7329
  ...(sweepSteps.size === 0 ? {} : { sweepSteps }),
7171
7330
  ...(windBandRead === null ? {} : { windBand: windBandRead }),
7172
7331
  };
package/src/config.ts CHANGED
@@ -100,6 +100,7 @@ import { GRID, MAX_SIDE } from './contour.ts';
100
100
  import { type Problem, refuseIfAny } from './errors.ts';
101
101
  import { edgeLength } from './length.ts';
102
102
  import { setFocusProblems } from './setfocus.ts';
103
+ import { type EditorReceipts, SHA256_PATTERN, SHA256_RULE } from './editreceipt.ts';
103
104
  import { isSkeletonName, KEYPOINT_NAMES, type KeypointName, type PosePoints, scaledPoints, SKELETON_NAMES, type SkeletonName } from './skeleton.ts';
104
105
  import { acceptedTagNames, AUTHOR_LABEL_RULE, AUTHOR_PREFIX, readLayerTag } from './tags.ts';
105
106
  import { BODYSHIFT_BONE, BODY_REAR_SHIFT_BONE, BODY_YAW_DIAL_BONE, EAR_DEPTH_RANGE, FACESHIFT_BONE, FACE_DEFORM_FRACTIONS, FACE_DEFORM_RANGES, FACE_RATED_PITCH_BAND, FACE_RATED_YAW_BAND, type FaceDeformKey, PITCH_DIAL_BONE, REAR_SHIFT_BONE, TURN_BONE, TURN_ROLES, type TurnRole, type TurnSpec, YAW_DIAL_BONE, turnNearest, turnSurfaceBone, turnUnitBone } from './turn.ts';
@@ -305,6 +306,8 @@ export interface Assemble {
305
306
  seam?: SeamRule;
306
307
  /** Verified named replacements from another See-through run (#866). */
307
308
  take?: TakeLayers[];
309
+ /** Issue #946: receipts for images edited in an image editor; absent, no file is read and no byte moves. */
310
+ editor_receipts?: EditorReceipts;
308
311
  /** Rig pixels per source pixel. */
309
312
  rig_scale: number;
310
313
  plan: PlanEntry[];
@@ -1305,8 +1308,6 @@ export interface CharacterConfig {
1305
1308
  cloth?: ClothRecipes;
1306
1309
  /** The `assemble.rig_scale` (`parts.json`'s `scale_rig_per_source`) `propose` drafted the rig sections at (issue #354): absent on a hand-authored config, held equal to `assemble.rig_scale` when present. */
1307
1310
  proposed_rig_scale?: number;
1308
- /** Alternate painting sets (issue #454): absent, no alternate sets are assembled or emitted. */
1309
- alternates?: AlternateSetConfig[];
1310
1311
  /**
1311
1312
  * The requirements file the build's check reads (issue #556), a path relative
1312
1313
  * to the config's directory: `build` reads it when no `--requirements` is
@@ -1625,7 +1626,7 @@ export interface EarlyConfig {
1625
1626
  depth?: DepthConfig;
1626
1627
  key: string;
1627
1628
  seethrough: SeeThrough;
1628
- assemble: { rig_scale: number; seam?: SeamRule; take?: TakeLayers[]; head_face_underfill?: boolean; yield_refused?: boolean; keep_under_occluders?: string[]; extend_painting?: PaintingExtension[] };
1629
+ assemble: { rig_scale: number; seam?: SeamRule; take?: TakeLayers[]; editor_receipts?: EditorReceipts; head_face_underfill?: boolean; yield_refused?: boolean; keep_under_occluders?: string[]; extend_painting?: PaintingExtension[] };
1629
1630
  }
1630
1631
 
1631
1632
  /** What plain `assemble` reads: the early config plus the plan `--propose-plan` printed. */
@@ -1649,7 +1650,14 @@ export interface AssembleConfig {
1649
1650
  proposed_rig_scale?: number;
1650
1651
  }
1651
1652
 
1652
- const TOP_KEYS = ['key', 'generation', 'seethrough', 'assemble', 'limbs', 'bones', 'meshes', 'regions', 'motion', 'constraints', 'stops', 'turn', 'scene', 'cloth', 'proposed_rig_scale', 'requirements', 'motion_seams', 'depth', 'waist'] as const;
1653
+ /**
1654
+ * The top-level sections `parseConfig` accepts in `config.json` (issue #948),
1655
+ * exported as a runtime tuple so a consumer does not read `src/config.ts` as text.
1656
+ */
1657
+ export const CONFIG_SECTIONS = ['key', 'generation', 'seethrough', 'assemble', 'limbs', 'bones', 'meshes', 'regions', 'motion', 'constraints', 'stops', 'turn', 'scene', 'cloth', 'proposed_rig_scale', 'requirements', 'motion_seams', 'depth', 'waist'] as const;
1658
+ export type ConfigSection = (typeof CONFIG_SECTIONS)[number];
1659
+
1660
+ const TOP_KEYS = CONFIG_SECTIONS;
1653
1661
 
1654
1662
  /**
1655
1663
  * The one partial entry point. It validates exactly what its door requires —
@@ -1694,7 +1702,7 @@ function parseEarlyConfigFrom(raw: Json, door: EarlyDoor, base: string | null):
1694
1702
  if (door === 'layers') {
1695
1703
  if ('seethrough' in t) checkSeeThrough(c, t.seethrough);
1696
1704
  if ('assemble' in t) {
1697
- const a = c.object('config.assemble', t.assemble, sectionRequired(door, 'assemble'), ['plan', 'extend_below_crop', 'patches', 'cuts', 'alternates', 'head_face_underfill', 'yield_refused', 'keep_under_occluders', 'extend_painting', 'take', 'seam']);
1705
+ const a = c.object('config.assemble', t.assemble, sectionRequired(door, 'assemble'), ['plan', 'extend_below_crop', 'patches', 'cuts', 'alternates', 'head_face_underfill', 'yield_refused', 'keep_under_occluders', 'extend_painting', 'take', 'seam', 'editor_receipts']);
1698
1706
  if (a !== null) {
1699
1707
  checkSeam(c, a);
1700
1708
  if ('rig_scale' in a) c.number('config.assemble.rig_scale', a.rig_scale, 'positive');
@@ -1704,6 +1712,7 @@ function parseEarlyConfigFrom(raw: Json, door: EarlyDoor, base: string | null):
1704
1712
  checkKeepUnderOccluders(c, a, null);
1705
1713
  if ('extend_painting' in a) checkPaintingExtensions(c, a.extend_painting);
1706
1714
  if ('take' in a) checkTakeLayers(c, a.take);
1715
+ if ('editor_receipts' in a) checkEditorReceipts(c, a.editor_receipts);
1707
1716
  if ('yield_refused' in a && typeof a.yield_refused !== 'boolean') {
1708
1717
  c.fail('CONFIG_FIELD_TYPE', 'config.assemble.yield_refused', `is ${show(a.yield_refused)}; true or false is required`);
1709
1718
  }
@@ -2461,6 +2470,39 @@ function checkTakeLayers(c: Check, value: Json): void {
2461
2470
  });
2462
2471
  }
2463
2472
 
2473
+ /**
2474
+ * `config.assemble.editor_receipts` (issue #946): `{receipts, required?, trace_parts?}` (`trace_parts`, issue #947).
2475
+ * Shape refusals are the loader's own; a receipt whose `from_sha256` equals
2476
+ * its `sha256` (no edit), or a `file` receipted twice, is `CONFIG_EDITOR_RECEIPT`.
2477
+ * Whether the file exists and hashes to `sha256` is read at assemble
2478
+ * (`src/editreceipt.ts`), where the file is.
2479
+ */
2480
+ function checkEditorReceipts(c: Check, value: Json): void {
2481
+ const at = 'config.assemble.editor_receipts';
2482
+ const o = c.object(at, value, ['receipts'], ['required', 'trace_parts']);
2483
+ if (o === null) return;
2484
+ for (const k of ['required', 'trace_parts'] as const) {
2485
+ if (k in o && typeof o[k] !== 'boolean') c.fail('CONFIG_FIELD_TYPE', `${at}.${k}`, `is ${show(o[k])}; true or false is required`);
2486
+ }
2487
+ if (!('receipts' in o) || !c.array(`${at}.receipts`, o.receipts, false)) return;
2488
+ const seen = new Map<string, number>();
2489
+ (o.receipts as Json[]).forEach((v, i) => {
2490
+ const p = `${at}.receipts[${i}]`;
2491
+ const r = c.object(p, v, ['file', 'sha256', 'from_sha256', 'editor'], []);
2492
+ if (r === null) return;
2493
+ if ('file' in r && c.string(`${p}.file`, r.file)) {
2494
+ const file = r.file as string;
2495
+ if (seen.has(file)) c.fail('CONFIG_EDITOR_RECEIPT', `${p}.file`, `names "${file}" again (first at receipts[${seen.get(file)}]); one receipt per file is required`);
2496
+ else seen.set(file, i);
2497
+ }
2498
+ for (const k of ['sha256', 'from_sha256'] as const) {
2499
+ if (k in r && (typeof r[k] !== 'string' || !SHA256_PATTERN.test(r[k] as string))) c.fail('CONFIG_FIELD_TYPE', `${p}.${k}`, `is ${show(r[k])}; ${SHA256_RULE} is required`);
2500
+ }
2501
+ if ('editor' in r) c.string(`${p}.editor`, r.editor);
2502
+ if (typeof r.sha256 === 'string' && r.sha256 === r.from_sha256) c.fail('CONFIG_EDITOR_RECEIPT', `${p}.from_sha256`, `is ${r.from_sha256}, the receipt's own sha256; the bytes the edit started from are required, and an edit that left the bytes unchanged is not an edit`);
2503
+ });
2504
+ }
2505
+
2464
2506
  function checkSeam(c: Check, a: Record<string, Json>): void {
2465
2507
  if ('seam' in a && !(SEAM_RULES as readonly unknown[]).includes(a.seam)) {
2466
2508
  c.fail('CONFIG_FIELD_TYPE', 'config.assemble.seam', `is ${show(a.seam)}; one of ${SEAM_RULES.join(', ')} is required`);
@@ -2469,7 +2511,7 @@ function checkSeam(c: Check, a: Record<string, Json>): void {
2469
2511
 
2470
2512
  function checkAssemble(c: Check, v: Json, door: 'assemble' | 'full'): { parts: string[]; patches: string[]; cuts: string[] } {
2471
2513
  const required = sectionRequired(door, 'assemble');
2472
- const a = c.object('config.assemble', v, required.filter((k) => k !== 'plan'), ['plan', 'extend_below_crop', 'patches', 'cuts', 'alternates', 'head_face_underfill', 'yield_refused', 'keep_under_occluders', 'extend_painting', 'take', 'seam']);
2514
+ const a = c.object('config.assemble', v, required.filter((k) => k !== 'plan'), ['plan', 'extend_below_crop', 'patches', 'cuts', 'alternates', 'head_face_underfill', 'yield_refused', 'keep_under_occluders', 'extend_painting', 'take', 'seam', 'editor_receipts']);
2473
2515
  if (a === null) return { parts: [], patches: [], cuts: [] };
2474
2516
  checkSeam(c, a);
2475
2517
  if (required.includes('plan') && !('plan' in a)) {
@@ -2507,6 +2549,7 @@ function checkAssemble(c: Check, v: Json, door: 'assemble' | 'full'): { parts: s
2507
2549
  checkKeepUnderOccluders(c, a, parts);
2508
2550
  if ('extend_painting' in a) checkPaintingExtensions(c, a.extend_painting);
2509
2551
  if ('take' in a) checkTakeLayers(c, a.take);
2552
+ if ('editor_receipts' in a) checkEditorReceipts(c, a.editor_receipts);
2510
2553
  if ('extend_below_crop' in a && c.array(`${p}.extend_below_crop`, a.extend_below_crop, false)) {
2511
2554
  (a.extend_below_crop as Json[]).forEach((entry, i) => {
2512
2555
  const at = `${p}.extend_below_crop[${i}]`;