@rig-c/parts 1.16.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/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
+ }
package/src/limbs.ts ADDED
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Limbs cut at their joints (issue #349): the geometry `assemble` cuts a
3
+ * declared limb's layer by, and the cap each lower segment carries past its
4
+ * joint. Pure arithmetic on rig pixels (y down, origin top-left, the space of
5
+ * `parts.json` and of `config.bones`); no clock, no randomness.
6
+ *
7
+ * - **Where a bone is.** A single bone sits at its `at` and ends at its `tip`
8
+ * (no tip: no end); a chain's link `k` sits at `points[k]` and ends at
9
+ * `points[k + 1]`, the last link at `tip` ({@link bonePlaces}).
10
+ * - **Which limb a pixel belongs to.** Every pixel of a part a limb takes
11
+ * goes to one limb: the one whose polyline — its segments' bone origins in
12
+ * order, then the last bone's end when it has one — passes nearest the
13
+ * pixel's centre, by squared Euclidean distance to the polyline's pieces; a
14
+ * tie goes to the limb `config.limbs` declares first ({@link nearestLimb}).
15
+ * Two legs in one `legwear` layer are split this way, with no polygon.
16
+ * - **The joint line.** The joint between segments `k − 1` and `k` is the
17
+ * origin of segment `k`'s bone. Its line runs through that point,
18
+ * perpendicular to the limb's axis there: the sum of the unit directions of
19
+ * the upper bone (its origin to the joint) and the lower bone (the joint to
20
+ * its end, when it has one; the upper's alone when it has none) — on a
21
+ * straight limb the plain perpendicular, on a bent one the bisector
22
+ * ({@link jointLine}). A pixel is past the line when its centre's dot
23
+ * product with the axis, from the joint, is above 0; a centre on the line
24
+ * stays on the upper side.
25
+ * - **Which segment a pixel belongs to.** Among the consecutive segments of a
26
+ * limb that take one part, a pixel starts on the first and moves down one
27
+ * segment for each joint line it is past, in order, stopping at the first it
28
+ * is not past ({@link segmentOf}). A segment whose part is not the previous
29
+ * segment's takes its limb's share of its own part whole: no line is cut
30
+ * between two layers (the shin's legwear and the foot's footwear).
31
+ * - **The limb's width at a joint.** The pixels of the limb's art (alpha above
32
+ * 8, both segments' parts) whose centre lies within half a pixel of the
33
+ * joint line, read along the line: the run of them through the joint — the
34
+ * pixel nearest the joint must lie within 1 px of it, and the run grows while
35
+ * neighbours along the line are at most 1.5 px apart (a diagonal line's
36
+ * centres are up to sqrt(2) apart) — from its first to its last, plus one
37
+ * pixel ({@link limbWidth}). The cap's radius is half of it.
38
+ * - **The cap.** The pixels whose centre lies within the radius of the joint
39
+ * and on the upper side of its line, inside the box
40
+ * {@link capBox} prints: the overlap the lower segment carries so a bend
41
+ * shows no gap. The tool invents none of them: each is the config's cap
42
+ * image's where that image is not transparent, else the layer's where the
43
+ * limb has art there, else missing — counted, and named in the limb line.
44
+ */
45
+ import type { BoneEntry, Limb } from './config.ts';
46
+
47
+ export type Pt = [number, number];
48
+
49
+ /** A bone's origin and, when it has one, its end, in rig px. */
50
+ export interface BonePlace {
51
+ at: Pt;
52
+ end: Pt | null;
53
+ }
54
+
55
+ /** Where every bone `bones` declares sits and ends (module comment). */
56
+ export function bonePlaces(bones: readonly BoneEntry[]): Map<string, BonePlace> {
57
+ const out = new Map<string, BonePlace>();
58
+ for (const b of bones) {
59
+ if ('chain' in b) {
60
+ b.points.forEach((p, k) => out.set(`${b.chain}${k}`, { at: [p[0], p[1]], end: k + 1 < b.points.length ? [b.points[k + 1][0], b.points[k + 1][1]] : [b.tip[0], b.tip[1]] }));
61
+ } else out.set(b.name, { at: [b.at[0], b.at[1]], end: b.tip === undefined ? null : [b.tip[0], b.tip[1]] });
62
+ }
63
+ return out;
64
+ }
65
+
66
+ /** A limb's polyline: its segments' bone origins in order, then the last bone's end when it has one. */
67
+ export function limbPolyline(limb: Limb, places: ReadonlyMap<string, BonePlace>): Pt[] {
68
+ const pts = limb.segments.map((s) => (places.get(s.bone) as BonePlace).at);
69
+ const last = places.get(limb.segments[limb.segments.length - 1].bone) as BonePlace;
70
+ return last.end === null ? pts : [...pts, last.end];
71
+ }
72
+
73
+ /** The squared distance from `(x, y)` to the polyline `pts` (one point: to that point). */
74
+ export function polylineDist2(pts: readonly Pt[], x: number, y: number): number {
75
+ if (pts.length === 1) return (x - pts[0][0]) ** 2 + (y - pts[0][1]) ** 2;
76
+ let best = Infinity;
77
+ for (let i = 0; i + 1 < pts.length; i++) {
78
+ const [ax, ay] = pts[i];
79
+ const dx = pts[i + 1][0] - ax;
80
+ const dy = pts[i + 1][1] - ay;
81
+ const len2 = dx * dx + dy * dy;
82
+ const t = len2 === 0 ? 0 : Math.max(0, Math.min(1, ((x - ax) * dx + (y - ay) * dy) / len2));
83
+ const d = (x - ax - t * dx) ** 2 + (y - ay - t * dy) ** 2;
84
+ if (d < best) best = d;
85
+ }
86
+ return best;
87
+ }
88
+
89
+ /** The index, among `lines`, of the polyline nearest the pixel `(px, py)`'s centre; the first wins a tie. */
90
+ export function nearestLimb(lines: ReadonlyArray<readonly Pt[]>, px: number, py: number): number {
91
+ let best = 0;
92
+ let bestD = Infinity;
93
+ lines.forEach((l, i) => {
94
+ const d = polylineDist2(l, px + 0.5, py + 0.5);
95
+ if (d < bestD) {
96
+ bestD = d;
97
+ best = i;
98
+ }
99
+ });
100
+ return best;
101
+ }
102
+
103
+ /** A joint's line: through `at`, perpendicular to the unit `axis` (pointing from the upper segment to the lower). */
104
+ export interface JointLine {
105
+ at: Pt;
106
+ axis: Pt;
107
+ }
108
+
109
+ /** The unit vector along `(x, y)`, or null when it has no length. */
110
+ function unit(x: number, y: number): Pt | null {
111
+ const l = Math.sqrt(x * x + y * y);
112
+ return l === 0 ? null : [x / l, y / l];
113
+ }
114
+
115
+ /**
116
+ * The joint line between a bone whose origin is `upper` and one whose origin
117
+ * is `at` and whose end is `end` (null: none) — module comment. Null when no
118
+ * axis exists: the upper bone has no length, or the limb folds straight back
119
+ * on itself.
120
+ */
121
+ export function jointLine(upper: Pt, at: Pt, end: Pt | null): JointLine | null {
122
+ const u = unit(at[0] - upper[0], at[1] - upper[1]);
123
+ if (u === null) return null;
124
+ const w = end === null ? null : unit(end[0] - at[0], end[1] - at[1]);
125
+ const a = w === null ? u : unit(u[0] + w[0], u[1] + w[1]);
126
+ return a === null ? null : { at, axis: a };
127
+ }
128
+
129
+ /** Whether the pixel `(px, py)`'s centre lies past the line (on the lower segment's side); on the line is not past. */
130
+ export function pastJoint(line: JointLine, px: number, py: number): boolean {
131
+ return (px + 0.5 - line.at[0]) * line.axis[0] + (py + 0.5 - line.at[1]) * line.axis[1] > 0;
132
+ }
133
+
134
+ /**
135
+ * The segment, among `first .. last` (consecutive segments taking one part),
136
+ * a pixel belongs to: `first`, moved down one for each joint line in order it
137
+ * is past, stopping at the first it is not. `lines[k]` is the line above
138
+ * segment `k`.
139
+ */
140
+ export function segmentOf(lines: ReadonlyArray<JointLine | null>, first: number, last: number, px: number, py: number): number {
141
+ let k = first;
142
+ while (k < last && pastJoint(lines[k + 1] as JointLine, px, py)) k++;
143
+ return k;
144
+ }
145
+
146
+ /**
147
+ * The limb's width at a joint (module comment): `art(x, y)` says whether the
148
+ * limb has art at a pixel; the box `[x0, y0, x1, y1]` (inclusive) bounds the
149
+ * search. Null when no pixel of the run lies within 1 px of the joint.
150
+ */
151
+ export function limbWidth(line: JointLine, art: (x: number, y: number) => boolean, box: readonly [number, number, number, number]): number | null {
152
+ const [ax, ay] = line.axis;
153
+ const ts: number[] = [];
154
+ for (let y = box[1]; y <= box[3]; y++) {
155
+ for (let x = box[0]; x <= box[2]; x++) {
156
+ const dx = x + 0.5 - line.at[0];
157
+ const dy = y + 0.5 - line.at[1];
158
+ if (Math.abs(dx * ax + dy * ay) > 0.5 || !art(x, y)) continue;
159
+ ts.push(-dx * ay + dy * ax);
160
+ }
161
+ }
162
+ if (ts.length === 0) return null;
163
+ ts.sort((a, b) => a - b);
164
+ let near = 0;
165
+ ts.forEach((t, i) => {
166
+ if (Math.abs(t) < Math.abs(ts[near])) near = i;
167
+ });
168
+ if (Math.abs(ts[near]) > 1) return null;
169
+ let lo = near;
170
+ let hi = near;
171
+ while (lo > 0 && ts[lo] - ts[lo - 1] <= 1.5) lo--;
172
+ while (hi + 1 < ts.length && ts[hi + 1] - ts[hi] <= 1.5) hi++;
173
+ return ts[hi] - ts[lo] + 1;
174
+ }
175
+
176
+ /** The box `[x, y, w, h]` (rig px) of the pixels whose centre lies within `radius` of `at`: the cap image's size and place. */
177
+ export function capBox(at: Pt, radius: number): [number, number, number, number] {
178
+ const x0 = Math.ceil(at[0] - radius - 0.5);
179
+ const y0 = Math.ceil(at[1] - radius - 0.5);
180
+ const x1 = Math.floor(at[0] + radius - 0.5);
181
+ const y1 = Math.floor(at[1] + radius - 0.5);
182
+ return [x0, y0, x1 - x0 + 1, y1 - y0 + 1];
183
+ }
184
+
185
+ /** Whether the pixel `(px, py)`'s centre lies within `radius` of `at`. */
186
+ export function inDisc(at: Pt, radius: number, px: number, py: number): boolean {
187
+ return (px + 0.5 - at[0]) ** 2 + (py + 0.5 - at[1]) ** 2 <= radius * radius;
188
+ }