@rig-c/parts 1.17.0 → 1.18.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
@@ -43,7 +43,7 @@
43
43
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
44
44
  import { dirname, join, resolve } from 'node:path';
45
45
  import { type AnimFrame, EncodeError, encodeApng, encodeIndexedApng, INDEXED_DEFAULTS } from './apng.ts';
46
- import { assemble, type AssembleResult, cutLines, driftLine, limbLines, FIGURE_MASK_FILE, figuresLine, holeLines, type ProjectRule, scenePhrase, type SeamRule, stageFields, uncoveredLine } from './assemble.ts';
46
+ import { assemble, type AssembleResult, cutLines, hiddenLines, SINGLE_RUN_FACE_TAGS, driftLine, limbLines, FIGURE_MASK_FILE, figuresLine, holeLines, type ProjectRule, scenePhrase, type SeamRule, stageFields, uncoveredLine } from './assemble.ts';
47
47
  import { allocationClause, type Reducer, stageBClause, unboundedClause } from './automesh.ts';
48
48
  import { residualClause } from './autoenvelope.ts';
49
49
  import { type AutoMotionCase, failingRows, idleSchedule, localRow, motionClause, motionDeformation, motionDocument, motionInput, motionRowText, motionStimulus, motionVerdict, noStimulusProblem, type ReplayCandidate, runComparison, sourceSelfFailure, stimulusMotion, withStimulus } from './automotion.ts';
@@ -78,7 +78,7 @@ import { encodeGif } from './gif.ts';
78
78
  import { CONTROL_SUFFIX, withAnimations } from './motion.ts';
79
79
  import type { PaletteError } from './palette.ts';
80
80
  import { type LayerSet, readLayers } from './layers.ts';
81
- import { type PartRecord, readParts, tagOf, writeParts } from './parts.ts';
81
+ import { type PartRecord, readParts, type SingleRunPad, tagOf, writeParts } from './parts.ts';
82
82
  import { encodePngBytes, readPng, writePng } from './raster/png.ts';
83
83
  import type { Raster } from './raster/types.ts';
84
84
  import { readRequirements, type RequirementLine, summaryText } from './requirements.ts';
@@ -106,8 +106,12 @@ export function readSource(path: string): Raster {
106
106
  }
107
107
  }
108
108
 
109
- /** Read both runs, collecting the refusals of both before throwing. */
110
- export function readRuns(full: string, head: string): { full: LayerSet; head: LayerSet } {
109
+ /**
110
+ * Read both runs, collecting the refusals of both before throwing. With no
111
+ * head run (`head` null, issue #359) the full run is read once and stands for
112
+ * both, and `single` says so: the stage then holds it to `checkSingleRun`.
113
+ */
114
+ export function readRuns(full: string, head: string | null): { full: LayerSet; head: LayerSet; single: boolean } {
111
115
  const problems: Problem[] = [];
112
116
  const one = (path: string): LayerSet | null => {
113
117
  try {
@@ -121,15 +125,16 @@ export function readRuns(full: string, head: string): { full: LayerSet; head: La
121
125
  }
122
126
  };
123
127
  const f = one(full);
124
- const h = one(head);
128
+ const h = head === null ? f : one(head);
125
129
  if (problems.length > 0) throw new PartsError(problems);
126
- return { full: f as LayerSet, head: h as LayerSet };
130
+ return { full: f as LayerSet, head: h as LayerSet, single: head === null };
127
131
  }
128
132
 
129
133
  export interface AssembleStageInput {
130
134
  source: string;
131
135
  full: string;
132
- head: string;
136
+ /** The head run, or null for one run that holds the face (issue #359). */
137
+ head: string | null;
133
138
  config: string;
134
139
  seam: SeamRule;
135
140
  project: ProjectRule;
@@ -236,7 +241,7 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
236
241
  const runs = readRuns(input.full, input.head);
237
242
  // The early door: assemble runs before propose has drafted bones, meshes,
238
243
  // regions and motion, so it requires only what it reads (issue #20).
239
- const fields = stageFields(loadEarlyConfig(input.config, 'assemble'));
244
+ const fields = stageFields(loadEarlyConfig(input.config, 'assemble'), runs.single ? { w: src.width, h: src.height } : null);
240
245
  const under = readUnders(input.config, fields.cuts);
241
246
  const caps = fields.limbs === undefined ? undefined : readCaps(input.config, fields.limbs);
242
247
  const result = assemble({ source: src, full: runs.full, head: runs.head, ...fields, under, ...(caps === undefined ? {} : { caps }), seamRule: input.seam, projectRule: input.project });
@@ -254,6 +259,11 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
254
259
  if (figurePath !== null) writePng(figurePath, result.figure as Raster);
255
260
  const [W, H] = result.parts.rig_size;
256
261
  log(`rig-parts assemble: ${result.images.length} part(s) on a ${W}x${H} rig (${result.parts.scale_rig_per_source} rig px per source px), seam rule ${result.seamRule}, projection rule ${result.projectRule}`);
262
+ // Issue #359: one run, said, so a reader of the lines knows no head run was placed.
263
+ if (runs.single) log(` one run: ${input.full} holds the face (${SINGLE_RUN_FACE_TAGS.join(', ')}), so no head run is read; every part comes from the full run`);
264
+ // Issue #366: a file that is not the resolution square was padded to it; said, with where parts.json records it. None for a square file.
265
+ const pad = result.parts.single_run_pad;
266
+ if (pad !== undefined) log(singleRunPadLine(input.full, pad));
257
267
  for (const p of result.parts.parts) {
258
268
  log(
259
269
  ` ${p.name.padEnd(11)} ${p.from.padEnd(16)} ${`${p.w}x${p.h}`.padEnd(9)} @${String(p.x).padStart(4)},${String(p.y).padStart(4)} ` +
@@ -268,6 +278,8 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
268
278
  // Issue #349: one line per part a limb took, and one per joint with its cap's pixels. None without limbs.
269
279
  for (const l of limbLines(result.limbs)) log(l);
270
280
  for (const l of fringeLines(result.parts.parts)) log(l);
281
+ // Issue #359: one line per hidden-art layer and what read it. None without one.
282
+ for (const l of hiddenLines(result.hidden)) log(l);
271
283
  const total = (k: 'opaque_px' | 'visible_px' | 'occluded_px' | 'source_px_taken' | 'visible_not_projected_px'): number => result.parts.parts.reduce((a, p) => a + (p[k] ?? 0), 0);
272
284
  const [op, vis, occ, taken, unproj] = (['opaque_px', 'visible_px', 'occluded_px', 'source_px_taken', 'visible_not_projected_px'] as const).map(total);
273
285
  const pct = (n: number): string => `${((100 * n) / op).toFixed(1)}%`;
@@ -286,6 +298,16 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
286
298
  return result;
287
299
  }
288
300
 
301
+ /**
302
+ * The line `assemble` prints under `one run:` when the file was padded (issue
303
+ * #366): its size, the square and the four margins, and that `parts.json`'s
304
+ * `single_run_pad` records them.
305
+ */
306
+ export function singleRunPadLine(file: string, pad: SingleRunPad): string {
307
+ const [w, h] = pad.file_size;
308
+ return ` padded: ${file} is ${w}x${h}, centred on the ${pad.side}x${pad.side} run square (left ${pad.left}, right ${pad.side - w - pad.left}, top ${pad.top}, bottom ${pad.side - h - pad.top}); parts.json single_run_pad records it, and every part's box is in the file's pixels times scale_rig_per_source`;
309
+ }
310
+
289
311
  /**
290
312
  * The scale the per-part line prints `drift=` with (issue #314): `refused_drift_px` counts the part's projection
291
313
  * candidates (`projected_core_px`, the `src=` column's second figure) refused because See-through's colour sits more
@@ -1421,7 +1443,8 @@ export interface BuildInput {
1421
1443
  config: string;
1422
1444
  source: string;
1423
1445
  full: string;
1424
- head: string;
1446
+ /** The head run, or null for one run that holds the face (issue #359). */
1447
+ head: string | null;
1425
1448
  out: string;
1426
1449
  seam: SeamRule;
1427
1450
  project: ProjectRule;
package/src/inputs.ts CHANGED
@@ -59,7 +59,17 @@ export interface SquarePad {
59
59
  * At most one of the two margins is non-zero.
60
60
  */
61
61
  export function squarePad(w: number, h: number): SquarePad {
62
- const side = Math.max(w, h);
62
+ return padToSquare(w, h, Math.max(w, h));
63
+ }
64
+
65
+ /**
66
+ * The same centring on a square of a given `side` (issue #366): a single run's
67
+ * file padded to the `seethrough.resolution` square. Each margin floors, so an
68
+ * odd remainder goes to the right or the bottom, exactly as {@link squarePad};
69
+ * both margins are non-zero when both sides are below `side`. The caller has
70
+ * refused a side above `side` — padding cannot shrink a canvas.
71
+ */
72
+ export function padToSquare(w: number, h: number, side: number): SquarePad {
63
73
  return { side, left: Math.floor((side - w) / 2), top: Math.floor((side - h) / 2) };
64
74
  }
65
75
 
package/src/layers.ts CHANGED
@@ -31,13 +31,13 @@
31
31
  * (`src/tags.ts`, `AUTHOR_PREFIX`): read with `group` "author", it has no
32
32
  * role, and its place in the draw order is read like any other layer's.
33
33
  */
34
- import { existsSync, readFileSync, statSync } from 'node:fs';
34
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
35
35
  import { basename, dirname, join } from 'node:path';
36
- import { initializeCanvas, type Layer as PsdLayer, readPsd } from 'ag-psd';
36
+ import { initializeCanvas, type Layer as PsdLayer, readPsd, writePsd } from 'ag-psd';
37
37
  import { type Problem, refuseIfAny } from './errors.ts';
38
- import { decodePngBytes } from './raster/png.ts';
38
+ import { decodePngBytes, encodePngBytes } from './raster/png.ts';
39
39
  import type { Raster } from './raster/types.ts';
40
- import { type LayerTagReading, layerTagRequirement, readLayerTag } from './tags.ts';
40
+ import { BODY_TAGS, HEAD_TAGS, HIDDEN_NAME_RULE, type LayerTagReading, layerTagRequirement, readHiddenTag, readLayerTag, SPLIT_TAGS } from './tags.ts';
41
41
 
42
42
  /** Alpha strictly above this counts as painted. The reference uses the same cut for its ghost clean-up and its sheets. */
43
43
  export const OPAQUE_ALPHA_ABOVE = 8;
@@ -69,6 +69,26 @@ export interface Layer {
69
69
  opaquePx: number;
70
70
  }
71
71
 
72
+ /**
73
+ * A hidden-art layer (issue #359, `HIDDEN_SUFFIX` in `src/tags.ts`): the art
74
+ * of the layer `of` where something covers it. Never drawn and never a part,
75
+ * so it carries no tag reading, no depth and no draw order.
76
+ */
77
+ export interface HiddenLayer {
78
+ /** `<of>:hidden`. */
79
+ name: string;
80
+ /** The drawn layer of the same input it belongs to. */
81
+ of: string;
82
+ /** The file the pixels came from, or null for a PSD layer. */
83
+ file: string | null;
84
+ pixels: Raster;
85
+ left: number;
86
+ top: number;
87
+ right: number;
88
+ bottom: number;
89
+ opaquePx: number;
90
+ }
91
+
72
92
  export interface LayerSet {
73
93
  form: 'wrapper' | 'psd';
74
94
  /** The manifest or PSD that was read. */
@@ -76,6 +96,8 @@ export interface LayerSet {
76
96
  canvas: { w: number; h: number };
77
97
  /** In draw order, back to front. */
78
98
  layers: Layer[];
99
+ /** The hidden-art layers (issue #359), in the order the input lists them; empty when it holds none. */
100
+ hidden: HiddenLayer[];
79
101
  }
80
102
 
81
103
  function opaque(r: Raster): number {
@@ -101,6 +123,15 @@ const MANIFEST_REQUIRED = ['layers', 'width', 'height'] as const;
101
123
  const LAYER_KEYS = ['name', 'filename', 'left', 'top', 'right', 'bottom', 'depth_median', 'depth_filename'] as const;
102
124
  const LAYER_REQUIRED = ['name', 'filename', 'left', 'top', 'right', 'bottom', 'depth_median'] as const;
103
125
 
126
+ /**
127
+ * What the wrapper form's `depth_median` may be, as a refusal quotes it. A
128
+ * See-through run writes a number; an input with no depth — a PSD exported by
129
+ * `layers --export` (issue #359) — writes `null` for every drawn layer, and
130
+ * its draw order is then the manifest's order, back to front, exactly as a
131
+ * PSD's is its stacking order. A depth is never invented for it.
132
+ */
133
+ const DEPTH_RULE = 'a finite number (See-through\'s depth, larger = further back), or null on every drawn layer of an input that carries no depth (the manifest\'s order is then the draw order, back to front)';
134
+
104
135
  /**
105
136
  * Read the wrapper form from a directory holding `layers.json`, or from the
106
137
  * manifest path itself.
@@ -166,6 +197,7 @@ export function readWrapperLayers(path: string): LayerSet {
166
197
  if (entries.length === 0) refuse('LAYERS_FIELD_PRESENT', `${manifestPath} field "layers"`, 'is empty; at least one layer is required');
167
198
 
168
199
  const read: Array<Omit<Layer, 'drawOrder'> & { index: number }> = [];
200
+ const hidden: HiddenLayer[] = [];
169
201
  const seen = new Map<string, number>();
170
202
  entries.forEach((entry, index) => {
171
203
  const at = `${basename(manifestPath)} layers[${index}]`;
@@ -202,8 +234,9 @@ export function readWrapperLayers(path: string): LayerSet {
202
234
  return;
203
235
  }
204
236
  const name = e.name;
205
- const tag = readLayerTag(name);
206
- if (tag === null) {
237
+ const hiddenOf = readHiddenTag(name);
238
+ const tag = hiddenOf === null ? readLayerTag(name) : null;
239
+ if (tag === null && hiddenOf === null) {
207
240
  refuse('LAYERS_TAG_KNOWN', `layer "${name}"`, layerTagRequirement(name));
208
241
  ok = false;
209
242
  }
@@ -213,15 +246,20 @@ export function readWrapperLayers(path: string): LayerSet {
213
246
  ok = false;
214
247
  }
215
248
  }
216
- if (typeof e.depth_median !== 'number' || !Number.isFinite(e.depth_median)) {
217
- refuse('LAYERS_FIELD_PRESENT', `layer "${name}" field "depth_median"`, `is ${describe(e.depth_median)}; a finite number is required`);
249
+ if (hiddenOf !== null) {
250
+ if (e.depth_median !== null) {
251
+ refuse('LAYERS_HIDDEN_NO_DEPTH', `layer "${name}" field "depth_median"`, `is ${describe(e.depth_median)}; a hidden-art layer is never drawn, so it has no depth and null is required`);
252
+ ok = false;
253
+ }
254
+ } else if (e.depth_median !== null && (typeof e.depth_median !== 'number' || !Number.isFinite(e.depth_median))) {
255
+ refuse('LAYERS_FIELD_PRESENT', `layer "${name}" field "depth_median"`, `is ${describe(e.depth_median)}; ${DEPTH_RULE} is required`);
218
256
  ok = false;
219
257
  }
220
258
  if (typeof e.filename !== 'string' || e.filename === '') {
221
259
  refuse('LAYERS_FIELD_PRESENT', `layer "${name}" field "filename"`, `is ${describe(e.filename)}; a file name is required`);
222
260
  ok = false;
223
261
  }
224
- if (!ok || tag === null) return;
262
+ if (!ok || (tag === null && hiddenOf === null)) return;
225
263
  const left = e.left as number;
226
264
  const top = e.top as number;
227
265
  const right = e.right as number;
@@ -261,31 +299,53 @@ export function readWrapperLayers(path: string): LayerSet {
261
299
  );
262
300
  return;
263
301
  }
302
+ if (hiddenOf !== null) {
303
+ hidden.push({ name, of: hiddenOf.of, file: present[0], pixels, left, top, right, bottom, opaquePx: opaque(pixels) });
304
+ return;
305
+ }
264
306
  read.push({
265
307
  name,
266
- tag,
308
+ tag: tag as LayerTagReading,
267
309
  file: present[0],
268
310
  pixels,
269
311
  left,
270
312
  top,
271
313
  right,
272
314
  bottom,
273
- depth: e.depth_median as number,
315
+ depth: e.depth_median as number | null,
274
316
  opaquePx: opaque(pixels),
275
317
  index,
276
318
  });
277
319
  });
320
+ // Issue #359: depth on every drawn layer, or on none — a mix has no one order to draw by.
321
+ const withDepth = read.filter((l) => l.depth !== null);
322
+ if (withDepth.length > 0 && withDepth.length < read.length) {
323
+ const none = read.filter((l) => l.depth === null).map((l) => `"${l.name}"`);
324
+ refuse('LAYERS_DEPTH_ALL_OR_NONE', manifestPath, `${withDepth.length} drawn layer(s) carry a depth_median and ${none.length} carry null (${none.join(', ')}); a number on every drawn layer, or null on every one, is required — a mix has no one draw order`);
325
+ }
326
+ checkHiddenOf(hidden, read.map((l) => l.name), (l) => `layer "${l}"`, refuse);
278
327
  refuseIfAny(problems);
279
- // Stable: Array.prototype.sort is stable, so equal depths keep manifest order.
280
- const ordered = [...read].sort((a, b) => (b.depth as number) - (a.depth as number));
328
+ // Stable: Array.prototype.sort is stable, so equal depths keep manifest order — and with no depth at all (every
329
+ // depth null), the manifest's own order is the draw order.
330
+ const ordered = withDepth.length === 0 ? read : [...read].sort((a, b) => (b.depth as number) - (a.depth as number));
281
331
  return {
282
332
  form: 'wrapper',
283
333
  source: manifestPath,
284
334
  canvas: { w: W, h: H },
285
335
  layers: ordered.map(({ index: _index, ...rest }, drawOrder) => ({ ...rest, drawOrder })),
336
+ hidden,
286
337
  };
287
338
  }
288
339
 
340
+ /** Every hidden-art layer must belong to a drawn layer the same input holds (issue #359): `LAYERS_HIDDEN_OF_PRESENT`. */
341
+ function checkHiddenOf(hidden: readonly HiddenLayer[], drawn: readonly string[], label: (name: string) => string, refuse: (code: string, object: string, detail: string) => void): void {
342
+ for (const h of hidden) {
343
+ if (!drawn.includes(h.of)) {
344
+ refuse('LAYERS_HIDDEN_OF_PRESENT', label(h.name), `is the hidden art of "${h.of}", and the input holds no drawn layer of that name — it holds ${drawn.length === 0 ? 'none' : drawn.map((n) => `"${n}"`).join(', ')}; ${HIDDEN_NAME_RULE} is required`);
345
+ }
346
+ }
347
+ }
348
+
289
349
  // ---------------------------------------------------------------------------
290
350
  // the PSD form
291
351
  // ---------------------------------------------------------------------------
@@ -349,7 +409,9 @@ export function readPsdLayers(path: string): LayerSet {
349
409
  const walk = (children: PsdLayer[] | undefined, trail: string): void => {
350
410
  for (const layer of children ?? []) {
351
411
  const where = `${trail}${layer.name ?? '(unnamed)'}`;
352
- if (layer.hidden) refuse('PSD_LAYER_PLAIN', `PSD layer "${where}"`, 'is hidden; every layer of a decomposition is drawn');
412
+ // A hidden-art layer (issue #359) is never drawn, so it may be switched off in the file; every other layer is drawn.
413
+ const art = layer.children === undefined && readHiddenTag(layer.name ?? '') !== null;
414
+ if (layer.hidden && !art) refuse('PSD_LAYER_PLAIN', `PSD layer "${where}"`, 'is hidden; every layer of a decomposition is drawn (only a hidden-art layer, "<layer>:hidden", may be switched off)');
353
415
  if (layer.opacity !== undefined && layer.opacity !== 1) {
354
416
  refuse('PSD_LAYER_PLAIN', `PSD layer "${where}"`, `has opacity ${layer.opacity}; 1 is required`);
355
417
  }
@@ -372,10 +434,12 @@ export function readPsdLayers(path: string): LayerSet {
372
434
  if (leaves.length === 0) refuse('PSD_HAS_LAYERS', path, 'holds no pixel layer; one per See-through tag is required');
373
435
  const seen = new Set<string>();
374
436
  const layers: Layer[] = [];
437
+ const hidden: HiddenLayer[] = [];
375
438
  leaves.forEach((layer) => {
376
439
  const name = layer.name ?? '';
377
- const tag = readLayerTag(name);
378
- if (tag === null) {
440
+ const hiddenOf = readHiddenTag(name);
441
+ const tag = hiddenOf === null ? readLayerTag(name) : null;
442
+ if (tag === null && hiddenOf === null) {
379
443
  refuse('LAYERS_TAG_KNOWN', `PSD layer "${name}"`, layerTagRequirement(name));
380
444
  return;
381
445
  }
@@ -406,9 +470,13 @@ export function readPsdLayers(path: string): LayerSet {
406
470
  return;
407
471
  }
408
472
  const pixels: Raster = { width: img.width, height: img.height, data: new Uint8ClampedArray(img.data) };
473
+ if (hiddenOf !== null) {
474
+ hidden.push({ name, of: hiddenOf.of, file: null, pixels, left, top, right, bottom, opaquePx: opaque(pixels) });
475
+ return;
476
+ }
409
477
  layers.push({
410
478
  name,
411
- tag,
479
+ tag: tag as LayerTagReading,
412
480
  file: null,
413
481
  pixels,
414
482
  left,
@@ -420,8 +488,9 @@ export function readPsdLayers(path: string): LayerSet {
420
488
  opaquePx: opaque(pixels),
421
489
  });
422
490
  });
491
+ checkHiddenOf(hidden, layers.map((l) => l.name), (l) => `PSD layer "${l}"`, refuse);
423
492
  refuseIfAny(problems);
424
- return { form: 'psd', source: path, canvas: { w: W, h: H }, layers };
493
+ return { form: 'psd', source: path, canvas: { w: W, h: H }, layers, hidden };
425
494
  }
426
495
 
427
496
  /** Read whichever form `path` is: a `.psd` file, or a wrapper directory / `layers.json`. */
@@ -440,6 +509,135 @@ export function readLayers(path: string): LayerSet {
440
509
  throw new Error('unreachable');
441
510
  }
442
511
 
512
+ // ---------------------------------------------------------------------------
513
+ // the export and the template (issue #359)
514
+ // ---------------------------------------------------------------------------
515
+
516
+ /**
517
+ * The file a layer is exported to: its index in the manifest, zero-padded to
518
+ * the width of the last index (at least two digits), then its name with every
519
+ * character other than a letter, a digit, `-` and `_` written `_`. The index
520
+ * makes the name unique (`author:a b` and `author:a_b` would otherwise meet),
521
+ * and the manifest, not the file name, says which layer a file is.
522
+ */
523
+ export function exportFileName(index: number, count: number, name: string): string {
524
+ const width = Math.max(2, String(Math.max(0, count - 1)).length);
525
+ return `${String(index).padStart(width, '0')}_${name.replace(/[^A-Za-z0-9_-]/g, '_')}.png`;
526
+ }
527
+
528
+ /** The wrapper manifest `exportLayers` writes, as text: fixed key order, two-space indent, a trailing newline. */
529
+ export function exportManifestText(set: LayerSet): string {
530
+ const all: Array<{ name: string; left: number; top: number; right: number; bottom: number; depth: number | null }> = [
531
+ ...set.layers.map((l) => ({ name: l.name, left: l.left, top: l.top, right: l.right, bottom: l.bottom, depth: l.depth })),
532
+ ...set.hidden.map((h) => ({ name: h.name, left: h.left, top: h.top, right: h.right, bottom: h.bottom, depth: null })),
533
+ ];
534
+ const manifest = {
535
+ width: set.canvas.w,
536
+ height: set.canvas.h,
537
+ layers: all.map((l, i) => ({ name: l.name, filename: exportFileName(i, all.length, l.name), left: l.left, top: l.top, right: l.right, bottom: l.bottom, depth_median: l.depth })),
538
+ };
539
+ return `${JSON.stringify(manifest, null, 2)}\n`;
540
+ }
541
+
542
+ /**
543
+ * `layers --export <dir> <input>` (issue #359): write a set as the wrapper
544
+ * form — `layers.json` beside one RGBA PNG per layer — so an agent can correct
545
+ * the parse with ordinary file operations and hand the directory back to any
546
+ * command that reads layers. Drawn layers are listed in draw order, back to
547
+ * front, then the hidden-art layers in their own order; `depth_median` is the
548
+ * input's own, so a PSD's is `null` on every layer and the manifest's order is
549
+ * its draw order ({@link readWrapperLayers}). Reading the directory back gives
550
+ * the same layers, boxes, pixels and order as the input's reading (selftest
551
+ * `LW` round trip).
552
+ *
553
+ * Refuses a `dir` that exists and is not an empty directory
554
+ * (`LAYERS_EXPORT_DIR_EMPTY`): a file left there by an earlier export — a
555
+ * `parts/<name>.png` the reader also looks for — could be read in place of
556
+ * the one written now. Writes nothing until that has passed.
557
+ */
558
+ export function exportLayers(set: LayerSet, dir: string): { manifest: string; files: string[] } {
559
+ if (existsSync(dir) && (!statSync(dir).isDirectory() || readdirSync(dir).length > 0)) {
560
+ refuseIfAny([
561
+ {
562
+ code: 'LAYERS_EXPORT_DIR_EMPTY',
563
+ object: dir,
564
+ detail: statSync(dir).isDirectory() ? `holds ${readdirSync(dir).length} entr${readdirSync(dir).length === 1 ? 'y' : 'ies'}; a directory that does not exist yet, or an empty one, is required — a file left there could be read in place of the one written now` : 'is a file; a directory that does not exist yet, or an empty one, is required',
565
+ },
566
+ ]);
567
+ }
568
+ const text = exportManifestText(set);
569
+ const entries = (JSON.parse(text) as { layers: Array<{ filename: string }> }).layers;
570
+ const pixels = [...set.layers.map((l) => l.pixels), ...set.hidden.map((h) => h.pixels)];
571
+ mkdirSync(dir, { recursive: true });
572
+ const files = entries.map((e, i) => {
573
+ const path = join(dir, e.filename);
574
+ writeFileSync(path, encodePngBytes(pixels[i]));
575
+ return path;
576
+ });
577
+ const manifest = join(dir, 'layers.json');
578
+ writeFileSync(manifest, text);
579
+ return { manifest, files };
580
+ }
581
+
582
+ /**
583
+ * Where two readings of one decomposition differ, or null when they are the
584
+ * same: the canvas, then each drawn layer in draw order — name, box, depth,
585
+ * pixels byte for byte — then each hidden-art layer. The first difference is
586
+ * named. What the export's round trip is held to (issue #359); the source
587
+ * path, the form and each layer's file are where it was read from, not what
588
+ * it holds, and are not compared.
589
+ */
590
+ export function layerSetDiff(a: LayerSet, b: LayerSet): string | null {
591
+ if (a.canvas.w !== b.canvas.w || a.canvas.h !== b.canvas.h) return `canvas ${a.canvas.w}x${a.canvas.h} against ${b.canvas.w}x${b.canvas.h}`;
592
+ const box = (l: { left: number; top: number; right: number; bottom: number }): string => `${l.left},${l.top},${l.right},${l.bottom}`;
593
+ const same = (x: Raster, y: Raster): boolean => x.width === y.width && x.height === y.height && Buffer.from(x.data).equals(Buffer.from(y.data));
594
+ if (a.layers.length !== b.layers.length) return `${a.layers.length} drawn layer(s) against ${b.layers.length}`;
595
+ for (let i = 0; i < a.layers.length; i++) {
596
+ const x = a.layers[i];
597
+ const y = b.layers[i];
598
+ if (x.name !== y.name) return `draw order ${i} is "${x.name}" against "${y.name}"`;
599
+ if (box(x) !== box(y)) return `layer "${x.name}" box ${box(x)} against ${box(y)}`;
600
+ if (x.depth !== y.depth) return `layer "${x.name}" depth ${String(x.depth)} against ${String(y.depth)}`;
601
+ if (!same(x.pixels, y.pixels)) return `layer "${x.name}" pixels differ`;
602
+ }
603
+ if (a.hidden.length !== b.hidden.length) return `${a.hidden.length} hidden-art layer(s) against ${b.hidden.length}`;
604
+ for (let i = 0; i < a.hidden.length; i++) {
605
+ const x = a.hidden[i];
606
+ const y = b.hidden[i];
607
+ if (x.name !== y.name || box(x) !== box(y) || !same(x.pixels, y.pixels)) return `hidden-art layer ${i} "${x.name}" against "${y.name}" (name, box or pixels)`;
608
+ }
609
+ return null;
610
+ }
611
+
612
+ /**
613
+ * The layer names `layers --template` writes, bottom first (issue #359): every
614
+ * v3 tag (`src/tags.ts`, 24) less `head` — upstream never emits it as a layer
615
+ * and no stage reads a role from it — with each of the six split tags written
616
+ * as its `-r` and `-l` halves, because the rig reads the halves (the eye bones
617
+ * come from `eyewhite-r` and `eyewhite-l`): 24 − 1 − 6 + 12 = 29 names. The
618
+ * order is a starting stack — back hair, the body, the face, front hair — and
619
+ * not a rule: the file's own order is the draw order.
620
+ */
621
+ export function templateLayerNames(): string[] {
622
+ const expand = (t: string): string[] => ((SPLIT_TAGS as readonly string[]).includes(t) ? [`${t}-r`, `${t}-l`] : [t]);
623
+ const body = (BODY_TAGS as readonly string[]).filter((t) => t !== 'head' && t !== 'back hair' && t !== 'front hair');
624
+ return ['back hair', ...body.flatMap(expand), ...(HEAD_TAGS as readonly string[]).flatMap(expand), 'front hair'];
625
+ }
626
+
627
+ /**
628
+ * `layers --template <out.psd> --canvas <W>x<H>`: a PSD of {@link templateLayerNames}'s
629
+ * layers, each empty, on a `W x H` canvas, written by the same library the
630
+ * reader uses. The same canvas writes the same bytes. The reader refuses an
631
+ * empty layer (`PSD_LAYER_HAS_PIXELS`), so the template read back as it is
632
+ * names every layer once: an artist paints into the layers the figure has and
633
+ * deletes the rest.
634
+ */
635
+ export function templatePsdBytes(w: number, h: number): Uint8Array {
636
+ stubCanvas();
637
+ const doc = { width: w, height: h, children: templateLayerNames().map((name) => ({ name })) };
638
+ return new Uint8Array(writePsd(doc, { noBackground: true }));
639
+ }
640
+
443
641
  // ---------------------------------------------------------------------------
444
642
  // plausibility: three figures per layer, and the rules `--propose-plan` drops by
445
643
  // ---------------------------------------------------------------------------
@@ -601,3 +799,18 @@ export function ruleSummary(rule: PlausibilityRule): string {
601
799
  }
602
800
  return `at most ${PLAUSIBLE_AREA_RATIO_MAX}x the rest of the figure`;
603
801
  }
802
+
803
+ /** The `layers` line for a hidden-art layer (issue #359): whose art it is, its box and pixels, and that it is never drawn. */
804
+ export function hiddenLayerLine(h: HiddenLayer): string {
805
+ return `hidden art: layer "${h.name}" — "${h.of}"'s art where it is covered, box ${h.left},${h.top} to ${h.right},${h.bottom}, ${h.opaquePx} opaque px; never drawn and never a part: assemble reads it as the under of a cut and the cap of a limb segment that take "${h.of}"'s part`;
806
+ }
807
+
808
+ /**
809
+ * The line `layers` prints for an input with no depth (issue #359): a PSD, or
810
+ * a wrapper export of one. The one reading of depth in this package is the
811
+ * turn draft (`propose --full/--head`), and it SKIPs such a run — naming it —
812
+ * rather than drafting a turn from depths nobody measured.
813
+ */
814
+ export function depthSkipLine(set: LayerSet): string {
815
+ return `SKIP depth: ${set.source} carries no depth_median (${set.form === 'psd' ? 'a PSD carries none' : 'null on every layer'}); the draw order is the file's own, and the turn draft (propose --full/--head) skips this run`;
816
+ }