@rig-c/parts 1.17.0 → 1.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -3
- package/cli.ts +173 -18
- package/docs/AUTHORING.md +178 -6
- package/docs/MIGRATION.md +73 -0
- package/docs/PAINTING.md +327 -0
- package/docs/STABILITY.md +7 -4
- package/package.json +2 -1
- package/skills/rig-parts/SKILL.md +29 -1
- package/src/assemble.ts +324 -25
- package/src/build.ts +32 -9
- package/src/inputs.ts +11 -1
- package/src/layers.ts +231 -18
- package/src/paintcheck.ts +277 -0
- package/src/parts.ts +81 -5
- package/src/propose.ts +11 -1
- package/src/tags.ts +47 -0
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
|
-
|
|
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
|
|
206
|
-
|
|
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 (
|
|
217
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
378
|
-
|
|
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
|
+
}
|