rig-parts 1.86.0 → 1.87.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/cli.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env bun
2
2
  import { applyTakenLayers } from './src/takelayers.ts';
3
+ import { applyDepth, prepareDepth } from './src/depthworkflow.ts';
3
4
  /**
4
5
  * rig-parts — one painting and its See-through layers in, Spine-ready parts
5
6
  * and rig specs out, verified through rig-c.
@@ -67,6 +68,16 @@ const LATER: ReadonlyArray<[string, string]> = [];
67
68
  const HELP = `rig-parts ${version()} — Spine-ready parts from one painting and its See-through layers
68
69
 
69
70
  usage:
71
+ rig-parts depth-prepare --parts <assembled-dir> --config <depth-recipe.json> --out <empty-dir>
72
+ Write per-part inference PNGs on neutral gray, upscaled and padded as
73
+ declared, plus a source-hashed manifest. No model is bundled or called.
74
+
75
+ rig-parts depth-apply --parts <assembled-dir> --manifest <manifest.json> --config <depth-recipe.json> --rig-config <config.json> --out <empty-dir>
76
+ Register floating-point JSON depth or signed normals, normalize inside
77
+ alpha >=128, apply declared median/relief and group near/zScale/pivot,
78
+ extend past alpha, and write parts, relocated config and depth-report.json.
79
+ Normals require one closed convex island. See AUTHORING for the schemas.
80
+
70
81
  rig-parts layers <dir | layers.json | file.psd>
71
82
  Read a See-through decomposition — the ComfyUI wrapper form (a directory
72
83
  holding layers.json and one PNG per layer) or an upstream .psd — and print
@@ -1077,6 +1088,8 @@ function printLayerTable(set: LayerSet): void {
1077
1088
  * no row; `layers` takes one positional path beside its flags (issue #359).
1078
1089
  */
1079
1090
  const TAKES = {
1091
+ 'depth-prepare': { valued: ['--parts', '--config', '--out'], switches: [] },
1092
+ 'depth-apply': { valued: ['--parts', '--manifest', '--config', '--rig-config', '--out'], switches: [] },
1080
1093
  layers: { valued: ['--export', '--template', '--canvas', '--full', '--source', '--config'], switches: [] },
1081
1094
  sheet: { valued: ['--source', '--layers', '--out', '--cell', '--cols'], switches: [] },
1082
1095
  rig: { valued: ['--config', '--parts', '--out', '--idle-keys', '--page-edges', '--pack-shape'], switches: [] },
@@ -2181,6 +2194,27 @@ function cmdPainting(args: string[]): number {
2181
2194
  }
2182
2195
  }
2183
2196
 
2197
+ function cmdDepthPrepare(args: string[]): number {
2198
+ const f = flags(args, 'depth-prepare', ['--parts', '--config', '--out']);
2199
+ if (typeof f === 'string') return usage(f);
2200
+ try {
2201
+ const result = prepareDepth({ parts: f.get('--parts')!, config: f.get('--config')!, out: f.get('--out')! });
2202
+ console.log(`depth-prepare: ${result.parts.length} part(s); manifest.json binds crop, padding and source/input hashes`);
2203
+ return EXIT_OK;
2204
+ } catch (error) { return printRefusal(error); }
2205
+ }
2206
+
2207
+ function cmdDepthApply(args: string[]): number {
2208
+ const f = flags(args, 'depth-apply', ['--parts', '--manifest', '--config', '--rig-config', '--out']);
2209
+ if (typeof f === 'string') return usage(f);
2210
+ try {
2211
+ const result = applyDepth({ parts: f.get('--parts')!, manifest: f.get('--manifest')!, config: f.get('--config')!, rigConfig: f.get('--rig-config')!, out: f.get('--out')! });
2212
+ for (const row of result.parts as Array<Record<string, unknown>>) console.log(`depth-apply: ${row.part} levels=${row.levels} clip=${row.clipFraction} reliefRigPixels=${row.reliefRigPixels} sign=${row.sign}`);
2213
+ console.log('depth-apply: wrote registered parts, config.json and depth-report.json; run rig for the core gate');
2214
+ return EXIT_OK;
2215
+ } catch (error) { return printRefusal(error); }
2216
+ }
2217
+
2184
2218
  function main(argv: string[]): number | Promise<number> {
2185
2219
  const [command, ...rest] = argv;
2186
2220
  if (command === undefined || command === '--help' || command === '-h' || command === 'help') {
@@ -2192,6 +2226,8 @@ function main(argv: string[]): number | Promise<number> {
2192
2226
  return EXIT_OK;
2193
2227
  }
2194
2228
  if (command === 'layers') return cmdLayers(rest);
2229
+ if (command === 'depth-prepare') return cmdDepthPrepare(rest);
2230
+ if (command === 'depth-apply') return cmdDepthApply(rest);
2195
2231
  if (command === 'sheet') return cmdSheet(rest);
2196
2232
  if (command === 'rig') return cmdRig(rest);
2197
2233
  if (command === 'propose') return cmdPropose(rest);
package/docs/AUTHORING.md CHANGED
@@ -6586,6 +6586,131 @@ and never assign a role, depth, binding or draw order. A part already bound to
6586
6586
  its actual intended role, then inspect setup, both turn endpoints and their diff;
6587
6587
  an explicit role alone does not establish that two drawings remain coincident.
6588
6588
 
6589
+ ### Per-part provider maps (#897)
6590
+
6591
+ `depth-prepare` and `depth-apply` are explicit stages after `assemble`, before
6592
+ `rig`. They carry no model, weights, provider service or downloads. Prepare
6593
+ writes one inference PNG per selected assembled part, composited onto gray 128,
6594
+ with integer LANCZOS upscaling and padding. A `rig-parts-depth-inputs/1` manifest
6595
+ binds the original parts.json hash, part PNG hash, rig crop `[x,y,w,h]`, native
6596
+ and upscaled sizes, padding, recipe hash and inference PNG hash. Map registration
6597
+ is declared, never inferred from a filename or guessed from art.
6598
+
6599
+ ```sh
6600
+ rig-parts depth-prepare --parts assembled --config depth.json --out inference
6601
+ # A provider reads inference/*.png and writes the declared floating-point maps.
6602
+ rig-parts depth-apply --parts assembled --manifest inference/manifest.json --config depth.json --rig-config config.json --out mapped
6603
+ rig-parts rig --parts mapped --config mapped/config.json --out compiled
6604
+ ```
6605
+
6606
+ The separate recipe is not a new field on character config:
6607
+
6608
+ ```json
6609
+ {
6610
+ "spec": "rig-parts-depth/1",
6611
+ "prepare": {"longSide": 1024, "paddingFraction": 0.08, "padding": 4},
6612
+ "groups": {"head": {"near": "white", "zScale": 30, "pivot": 15}},
6613
+ "parts": {
6614
+ "face": {"map": "face.json", "source": "depth", "frame": "part", "group": "head", "median": 21, "relief": 13}
6615
+ },
6616
+ "clear": []
6617
+ }
6618
+ ```
6619
+
6620
+ All displayed numbers are authored example choices, not defaults. `longSide`
6621
+ and additive `padding` are integers; upscale is `max(1,ceil(longSide/max(w,h)))`
6622
+ and pad is `floor(max(upscaledSize)*paddingFraction)+padding`. Each part declares
6623
+ `median` and nonnegative `relief` in rig pixels. Each group (`head` or `body`)
6624
+ declares `near`, positive `zScale` and `pivot`; optional `unitDepths` explicitly
6625
+ replaces that group's rigid depths. `clear` explicitly lists part references to
6626
+ remove, including any small units intended to sample the surface. Unselected
6627
+ parts retain their artwork and depth references. The output config copies the
6628
+ original, sets the declared group pivots/unit depths, and relocates animation,
6629
+ requirements and control-skeleton file paths. Built-in skeleton names remain
6630
+ names. A selected part whose provenance records `depth.kind: "authored-unit"`
6631
+ is refused before output (`DEPTH_AUTHORED_UNIT_CONFLICT`); apply does not invent
6632
+ a transition to a transported scalar reference. Unselected authored units keep
6633
+ their original representation. Roles and draw order stay the author's.
6634
+
6635
+ Maps are UTF-8 JSON `{"spec":"rig-parts-map/1","width":2,"height":2,
6636
+ "channels":1,"data":[0.125,0.25,0.5,0.875]}`: row-major finite numeric values,
6637
+ with channels interleaved for signed XYZ normals (`channels:3`). JSON is open,
6638
+ lossless for binary64 numbers, and requires no numeric-array dependency; an
6639
+ 8-bit preview cannot preserve the raw map's normalization. Paths resolve beside
6640
+ the recipe. `frame:"part"` requires native part dimensions; `"upscaled"` requires
6641
+ the prepared art's enlarged dimensions without padding; `"input"` requires the
6642
+ whole prepared image and crops its declared padding before reduction. Scalar
6643
+ maps use float32 BOX reduction. Normal sources require signed image-axis XYZ
6644
+ (x right, y down), not an RGB normal preview.
6645
+
6646
+ Apply computes `u=(raw-median(raw))/(p95-p5)` inside original alpha >=128,
6647
+ using linear percentiles, then `Z=median+relief*u`. A flat map requires explicit
6648
+ relief 0. Encode `Z/zScale` for near white, `1-Z/zScale` for near black, clip to
6649
+ [0,1], and round to the nearest 8-bit level with even ties. Extend every
6650
+ non-support pixel with the Euclidean nearest support value, with row-major ties.
6651
+ The resulting opaque gray sheet carries no background cliff. The report names
6652
+ levels, clip fraction (Z <=0 or >=zScale on support), declared relief in rig
6653
+ pixels, group pivot, sign, raw median/p5/p95 and source/input/map/sheet hashes.
6654
+ It reports measurements, not a depth-quality pass mark. `rig` still runs Core's
6655
+ gate; apply writes parts and configuration, never a skeleton or a gate verdict.
6656
+ Outputs must be absent or empty; all declared inputs validate before emitting.
6657
+
6658
+ `source:"normals"` is optional and limited to one closed convex part. It refuses
6659
+ multiple four-connected alpha islands both before and after stride2 sampling.
6660
+ At half resolution it integrates adjacent gradients `-nx/max(abs(nz),0.15*norm)`
6661
+ and `-ny/max(abs(nz),0.15*norm)` with step2 trapezoids and matrix-free LSQR
6662
+ (maximum 3000 iterations, atol/btol 1e-6, condition limit 1e8). Cross erosion
6663
+ finds an interior core (max(3,floor(0.12*sqrt(area))) steps) and a three-step rim.
6664
+ The sign makes mean core depth greater than mean rim depth; no reference is
6665
+ read. An empty core or unresolved sign refuses. Before float32 BOX, unsupported
6666
+ integration-grid samples receive the Euclidean nearest supported float height,
6667
+ with row-major ties; supported integrated heights are unchanged. This fill uses
6668
+ the stride2 integration mask itself, not the native mask or encoded gray levels,
6669
+ so unsupported zeros cannot enter a supported native pixel through BOX. BOX
6670
+ then brings the field back to the native grid before the same normalization.
6671
+ Normalization still reads all native alpha>=128 support; a boundary repair can
6672
+ change its percentiles and therefore interior levels too. Iterations and
6673
+ residual are reported. One connected silhouette does not certify convexity or
6674
+ exclude internal occlusion edges; choose scalar depth for strands and collars.
6675
+
6676
+ The [provider trial](https://github.com/rig-c/parts/issues/897) measured Depth
6677
+ Anything V2 Large and Small, DA3MONO-LARGE, DepthPro, Lotus depth/normal G,
6678
+ Marigold depth, and bundled MiDaS/Zoe/LeReS/Metric3D routes. DA3 and Small rows
6679
+ were added after the initial report. The stage2 reproduction uses V2 Large
6680
+ with the Lotus-normal face, not DA3. No model is required by these commands.
6681
+ The trial's role relief/width ratios (face 0.1264803, front hair 0.1018772,
6682
+ back hair 0.0857722, ornament 0.0375868, garment 0.0990473) came from one
6683
+ head-turn demo's p5..p95 depth and part widths. They are measured starting
6684
+ points for declared proportions, not anatomical laws or default calibration.
6685
+ The owner's preference for per-part volume is subjective provenance, not an
6686
+ automated quality conclusion. Model licence posture remains the owner's choice.
6687
+
6688
+ `depth.full.layers` and `depth.head.layers` still accept authored source-grid
6689
+ sheets. They bind the See-through layer before assembly transports/scales/crops
6690
+ it; they do not bind an already assembled part's post-merge image. Use them
6691
+ when a sheet genuinely occupies that source grid. The commands above bind the
6692
+ final assembled grid and preserve the existing `parts.json` reference consumed
6693
+ by `rig`, so their output requires no alternate compiler or dependency pin.
6694
+
6695
+ | rule | refused input | remedy |
6696
+ | --- | --- | --- |
6697
+ | <a id="DEPTH_RECIPE"></a>`DEPTH_RECIPE` | unknown, absent or wrongly typed recipe field, group, source or frame | use the documented explicit recipe schema; no guessed field |
6698
+ | <a id="DEPTH_RECIPE_NUMBER"></a>`DEPTH_RECIPE_NUMBER` | missing/nonfinite median, relief, near-scale pivot or prepare number; invalid range/integer | declare every numeric field in its documented units and range |
6699
+ | <a id="DEPTH_INPUT_PRESENT"></a>`DEPTH_INPUT_PRESENT` | declared JSON input missing or not a file | restore the named file |
6700
+ | <a id="DEPTH_INPUT_JSON"></a>`DEPTH_INPUT_JSON` | malformed numeric or configuration JSON | write valid finite JSON |
6701
+ | <a id="DEPTH_PART_RESOLVES"></a>`DEPTH_PART_RESOLVES` | selected or cleared name absent from parts.json | use the assembled part name |
6702
+ | <a id="DEPTH_OUTPUT_EMPTY"></a>`DEPTH_OUTPUT_EMPTY` | nonempty output or output containing an input | choose a separate absent or empty directory |
6703
+ | <a id="DEPTH_INPUT_BINDING"></a>`DEPTH_INPUT_BINDING` | changed recipe, art, part record, crop, padding or inference PNG | prepare again for these exact inputs |
6704
+ | <a id="DEPTH_MAP_FORMAT"></a>`DEPTH_MAP_FORMAT` | wrong numeric schema, channels, shape, count, unknown field or nonfinite element | provide the declared float JSON map, not a preview |
6705
+ | <a id="DEPTH_MAP_SIZE"></a>`DEPTH_MAP_SIZE` | map dimensions differ from its explicit frame | use the registered part, enlarged art or padded input grid |
6706
+ | <a id="DEPTH_MAP_SUPPORT"></a>`DEPTH_MAP_SUPPORT` | no alpha>=128 support | provide a supported part |
6707
+ | <a id="DEPTH_MAP_SPREAD"></a>`DEPTH_MAP_SPREAD` | p5=p95 with nonzero relief | supply a nonflat map or declare relief 0 |
6708
+ | <a id="DEPTH_NORMAL_ISLANDS"></a>`DEPTH_NORMAL_ISLANDS` | zero/multiple connected components at native or integration resolution | scalar source, or a larger registered normal input that preserves one island |
6709
+ | <a id="DEPTH_NORMAL_VECTOR"></a>`DEPTH_NORMAL_VECTOR` | a sampled support normal is zero | provide nonzero signed XYZ normals |
6710
+ | <a id="DEPTH_NORMAL_CONVEXITY"></a>`DEPTH_NORMAL_CONVEXITY` | core/rim or its convexity sign is unresolved | larger closed convex mask or scalar source |
6711
+ | <a id="DEPTH_GROUP_BINDING"></a>`DEPTH_GROUP_BINDING` | part outside declared turn roles, mixed retained near/zScale, or filename collision | register exact roles and one shared scale/polarity, replace/clear conflicting sheets, use distinct names |
6712
+ | <a id="DEPTH_AUTHORED_UNIT_CONFLICT"></a>`DEPTH_AUTHORED_UNIT_CONFLICT` | a selected part already records `provenance.depth.kind: "authored-unit"`; adding scalar depth would make its two representations conflict | retain the authored unit or supply an explicitly reassembled scalar-compatible input; apply never replaces the provenance automatically |
6713
+
6589
6714
  ### Unit depth precedence
6590
6715
 
6591
6716
  For `"unit"` roles, a transported part's sheet is recorded and validated but is not
package/docs/STABILITY.md CHANGED
@@ -49,6 +49,8 @@ defaults are the help's too, and none is guessed from the art.
49
49
  <!-- stable:commands -->
50
50
  | command | flags |
51
51
  | --- | --- |
52
+ | `depth-prepare` | `--parts`, `--config`, `--out` (issue #897; separate numeric-depth recipe) |
53
+ | `depth-apply` | `--parts`, `--manifest`, `--config`, `--rig-config`, `--out` (issue #897; original character config is read-only) |
52
54
  | `layers` | one positional path: a wrapper directory, a `layers.json` or a `.psd`; with it, optional `--export` (a directory to write the wrapper form into, issue #359); or `--template` with `--canvas` and no path; or a positional head run with `--full`, `--source`, `--config` together for `PLAN_HEAD_GARMENT_OVERLAP` (issue #826) |
53
55
  | `sheet` | `--source`, `--layers` (repeatable), `--out`; optional `--cell`, `--cols` |
54
56
  | `inputs` | `--source`, `--config`, `--out` |
@@ -525,3 +527,16 @@ only for that explicit block; `check --requirements` refuses unavailable or
525
527
  stale evidence rather than reconstructing original art from emitted siblings.
526
528
  Without the block the API reading is SKIP; CLI stdout and emitted bytes remain unchanged.
527
529
  See [Source before scissors](AUTHORING.md#source-before-scissors).
530
+
531
+ ## Provider-map depth stages (#897)
532
+
533
+ The opt-in `depth-prepare` and `depth-apply` commands accept the separate
534
+ `rig-parts-depth/1` recipe and finite floating-point `rig-parts-map/1` JSON.
535
+ Prepared provenance is `rig-parts-depth-inputs/1`; application writes
536
+ `rig-parts-depth-report/1`, standard part depth references and a relocated
537
+ character config. Their schemas, frames, alpha threshold, normalization,
538
+ encoding, convexity sign and refusal remedies are in
539
+ [AUTHORING](AUTHORING.md#per-part-provider-maps-897). No existing build path
540
+ calls these stages; no-config builds retain their existing behavior and bytes.
541
+ Group numbers and unit depths are explicit; no provider or absolute scale is
542
+ inferred. A new depth stage never replaces Core's rig gate.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rig-parts",
3
- "version": "1.86.0",
3
+ "version": "1.87.0",
4
4
  "description": "AI-authored 2D character parts from one anime painting and its See-through layer decomposition: measured parts, weighted-mesh rig specs, verified through rig-c before anything is written. A CLI for agents that cannot see the image.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,170 @@
1
+ /** Provider-independent floating-point maps. No model or reference depth is read here. */
2
+ import { PartsError } from './errors.ts';
3
+ import { connectedComponents } from './raster/components.ts';
4
+ import { extendDepthSilhouette } from './depthsheets.ts';
5
+ import { newRaster, type Raster } from './raster/types.ts';
6
+ import { edgeLength } from './length.ts';
7
+
8
+ export interface NumericMap { spec: 'rig-parts-map/1'; width: number; height: number; channels: 1 | 3; data: number[] }
9
+ export function depthMapFail(code: string, object: string, detail: string): never {
10
+ throw new PartsError([{ code, object, detail }]);
11
+ }
12
+ export function readNumericMap(raw: unknown, at: string): NumericMap {
13
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return depthMapFail('DEPTH_MAP_FORMAT', at, 'requires a rig-parts-map/1 JSON object');
14
+ const r = raw as Record<string, unknown>;
15
+ if (Object.keys(r).some(k => !['spec', 'width', 'height', 'channels', 'data'].includes(k)) || r.spec !== 'rig-parts-map/1' || !Number.isSafeInteger(r.width) || !Number.isSafeInteger(r.height) || Number(r.width) < 1 || Number(r.height) < 1 || (r.channels !== 1 && r.channels !== 3) || !Array.isArray(r.data) || r.data.length !== Number(r.width) * Number(r.height) * r.channels || !r.data.every(x => typeof x === 'number' && Number.isFinite(x))) {
16
+ return depthMapFail('DEPTH_MAP_FORMAT', at, 'requires positive integer width/height, channels 1 or 3 and exactly width*height*channels finite row-major numbers; previews and quantized PNGs are not numeric maps');
17
+ }
18
+ return r as unknown as NumericMap;
19
+ }
20
+
21
+ /** NumPy percentile's linear interpolation on sorted samples, including the even median. */
22
+ export function mapQuantile(sorted: readonly number[], q: number): number {
23
+ const p = (sorted.length - 1) * q, i = Math.floor(p), f = p - i;
24
+ return sorted[i] * (1 - f) + sorted[Math.min(i + 1, sorted.length - 1)] * f;
25
+ }
26
+ export function alphaSupport(art: Raster): Uint8Array {
27
+ return Uint8Array.from({ length: art.width * art.height }, (_, i) => art.data[i * 4 + 3] >= 128 ? 1 : 0);
28
+ }
29
+
30
+ /** Pillow mode-F BOX: discrete box taps, horizontal then vertical float32 passes.
31
+ * Unlike area coverage, BOX gives equal weights to the included pixel centres.
32
+ * Verification compares this operation to an independent Pillow oracle.
33
+ */
34
+ export function floatBox(data: ArrayLike<number>, w: number, h: number, width: number, height: number): Float64Array {
35
+ const pass = (src: ArrayLike<number>, sw: number, sh: number, dw: number): Float32Array => {
36
+ const out = new Float32Array(dw * sh), scale = sw / dw;
37
+ for (let x = 0; x < dw; x++) {
38
+ const center = (x + 0.5) * scale, support = Math.max(1, scale) * 0.5;
39
+ const lo = Math.max(0, Math.trunc(center - support + 0.5)), hi = Math.min(sw, Math.trunc(center + support + 0.5));
40
+ const indices: number[] = [];
41
+ for (let i = lo; i < hi; i++) { const d = (i - center + 0.5) / Math.max(1, scale); if (d > -0.5 && d <= 0.5) indices.push(i); }
42
+ if (indices.length === 0) indices.push(Math.min(sw - 1, Math.max(0, Math.floor(center))));
43
+ for (let y = 0; y < sh; y++) { let v = 0; for (const i of indices) v += src[y * sw + i] / indices.length; out[y * dw + x] = v; }
44
+ }
45
+ return out;
46
+ };
47
+ const horizontal = w === width ? Float32Array.from(data) : pass(data, w, h, width);
48
+ if (h === height) return Float64Array.from(horizontal);
49
+ const transpose = new Float32Array(width * h);
50
+ for (let y = 0; y < h; y++) for (let x = 0; x < width; x++) transpose[x * h + y] = horizontal[y * width + x];
51
+ const vertical = pass(transpose, h, width, height), out = new Float64Array(width * height);
52
+ for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) out[y * width + x] = vertical[x * height + y];
53
+ return out;
54
+ }
55
+
56
+ function norm(v: Float64Array): number { let s = 0; for (const x of v) s += x * x; return Math.sqrt(s); }
57
+ function eroded(mask: Uint8Array, w: number, h: number, count: number): Uint8Array {
58
+ let cur = mask;
59
+ for (let n = 0; n < count; n++) {
60
+ const next = new Uint8Array(cur.length);
61
+ for (let y = 1; y < h - 1; y++) for (let x = 1; x < w - 1; x++) { const i = y * w + x; next[i] = cur[i] && cur[i - 1] && cur[i + 1] && cur[i - w] && cur[i + w] ? 1 : 0; }
62
+ cur = next;
63
+ }
64
+ return cur;
65
+ }
66
+
67
+ /** Matrix-free LSQR over right/down adjacent mask pixels, row order right then down.
68
+ * Paige/Saunders bidiagonalization; atol=btol=1e-6, conlim=1e8, at most 3000
69
+ * iterations. These are algorithm settings, not fitted depth-quality bars.
70
+ */
71
+ function solveEdges(a: Int32Array, b: Int32Array, rhs: Float64Array, size: number): { z: Float64Array; iterations: number; residual: number } {
72
+ const mul = (v: Float64Array, out: Float64Array): void => { for (let i = 0; i < a.length; i++) out[i] = v[b[i]] - v[a[i]]; };
73
+ const trans = (u: Float64Array, out: Float64Array): void => { out.fill(0); for (let i = 0; i < a.length; i++) { out[b[i]] += u[i]; out[a[i]] -= u[i]; } };
74
+ const z = new Float64Array(size), u = rhs.slice(), v = new Float64Array(size);
75
+ let beta = norm(u); const bnorm = beta;
76
+ if (beta === 0) return { z, iterations: 0, residual: 0 };
77
+ for (let i = 0; i < u.length; i++) u[i] /= beta;
78
+ trans(u, v); let alpha = norm(v);
79
+ if (alpha === 0) return { z, iterations: 0, residual: beta };
80
+ for (let i = 0; i < v.length; i++) v[i] /= alpha;
81
+ const w = v.slice(), av = new Float64Array(u.length), atu = new Float64Array(size);
82
+ let rhobar = alpha, phibar = beta, anorm = 0, ddnorm = 0, xnorm = 0, xxnorm = 0, zeta = 0, cs2 = -1, sn2 = 0, iterations = 0, residual = beta;
83
+ for (; iterations < 3000;) {
84
+ iterations++; mul(v, av);
85
+ for (let i = 0; i < u.length; i++) u[i] = av[i] - alpha * u[i];
86
+ beta = norm(u);
87
+ if (beta > 0) {
88
+ for (let i = 0; i < u.length; i++) u[i] /= beta;
89
+ anorm = Math.sqrt(anorm * anorm + alpha * alpha + beta * beta);
90
+ trans(u, atu);
91
+ for (let i = 0; i < v.length; i++) v[i] = atu[i] - beta * v[i];
92
+ alpha = norm(v);
93
+ if (alpha > 0) for (let i = 0; i < v.length; i++) v[i] /= alpha;
94
+ }
95
+ const rho = edgeLength(rhobar, beta), cs = rhobar / rho, sn = beta / rho;
96
+ const theta = sn * alpha; rhobar = -cs * alpha;
97
+ const phi = cs * phibar; phibar = sn * phibar;
98
+ const tau = sn * phi;
99
+ for (let i = 0; i < z.length; i++) { const d = w[i] / rho; z[i] += phi * d; ddnorm += d * d; w[i] = v[i] - theta / rho * w[i]; }
100
+ const delta = sn2 * rho, gambar = -cs2 * rho, right = phi - delta * zeta;
101
+ const zbar = right / gambar; xnorm = Math.sqrt(xxnorm + zbar * zbar);
102
+ const gamma = edgeLength(gambar, theta); cs2 = gambar / gamma; sn2 = theta / gamma; zeta = right / gamma; xxnorm += zeta * zeta;
103
+ residual = Math.abs(phibar);
104
+ const arnorm = alpha * Math.abs(tau), acond = anorm * Math.sqrt(ddnorm);
105
+ if (acond >= 1e8 || arnorm <= 1e-6 * anorm * residual || residual <= 1e-6 * bnorm + 1e-6 * anorm * xnorm) break;
106
+ }
107
+ return { z, iterations, residual };
108
+ }
109
+
110
+ export interface NormalHeight { data: Float64Array; sign: 1 | -1; coreMinusRim: number; iterations: number; residual: number }
111
+ /** Normals are signed XYZ in image axes (x right/y down), not RGB previews.
112
+ * Half-resolution stride, step2 edge trapezoids, cross-erosion core/rim sign.
113
+ * Convexity selects between a height and its negation without an answer key.
114
+ */
115
+ export function integrateNormals(map: NumericMap, art: Raster, at: string): NormalHeight {
116
+ const support = alphaSupport(art);
117
+ const islands = connectedComponents({ width: art.width, height: art.height, data: support }, 4).count - 1;
118
+ if (islands !== 1) return depthMapFail('DEPTH_NORMAL_ISLANDS', at, `has ${islands} four-connected alpha>=128 islands; exactly one closed convex part is required, use scalar depth for occlusion-separated parts`);
119
+ if (map.channels !== 3 || map.width !== art.width || map.height !== art.height) return depthMapFail('DEPTH_MAP_SIZE', at, `normal map is ${map.width}x${map.height}x${map.channels}; registered art ${art.width}x${art.height}x3 is required`);
120
+ const w = Math.ceil(map.width / 2), h = Math.ceil(map.height / 2), mask = new Uint8Array(w * h), px = new Float64Array(w * h), py = new Float64Array(w * h), ids = new Int32Array(w * h).fill(-1);
121
+ let count = 0;
122
+ for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
123
+ const src = 2 * y * map.width + 2 * x, i = y * w + x;
124
+ mask[i] = support[src]; if (!mask[i]) continue;
125
+ ids[i] = count++;
126
+ const nx = map.data[src * 3], ny = map.data[src * 3 + 1], nz = map.data[src * 3 + 2], length = edgeLength(edgeLength(nx, ny), nz);
127
+ if (length < 1e-12 || !Number.isFinite(length)) return depthMapFail('DEPTH_NORMAL_VECTOR', `${at} (${x * 2},${y * 2})`, 'has zero or overflowing length; a finite nonzero representable signed XYZ vector is required');
128
+ const divisor = Math.max(Math.abs(nz), 0.15 * length);
129
+ px[i] = Math.fround(-nx / divisor); py[i] = Math.fround(-ny / divisor);
130
+ }
131
+ const sampledIslands = connectedComponents({ width: w, height: h, data: mask }, 4).count - 1;
132
+ if (sampledIslands !== 1) return depthMapFail('DEPTH_NORMAL_ISLANDS', at, `half-resolution mask has ${sampledIslands} islands; provide a larger registered input so stride2 keeps one island`);
133
+ const aa: number[] = [], bb: number[] = [], rr: number[] = [];
134
+ for (const [dx, dy, g] of [[1, 0, px], [0, 1, py]] as const) {
135
+ for (let y = 0; y < h - dy; y++) for (let x = 0; x < w - dx; x++) {
136
+ const i = y * w + x, j = (y + dy) * w + x + dx;
137
+ if (mask[i] && mask[j]) { aa.push(ids[i]); bb.push(ids[j]); rr.push(Math.fround(g[i] + g[j])); }
138
+ }
139
+ }
140
+ const solve = solveEdges(Int32Array.from(aa), Int32Array.from(bb), Float64Array.from(rr), count);
141
+ const core = eroded(mask, w, h, Math.max(3, Math.trunc(0.12 * Math.sqrt(count)))), inner = eroded(mask, w, h, 3);
142
+ let cv = 0, rv = 0, cn = 0, rn = 0;
143
+ for (let i = 0; i < mask.length; i++) { if (core[i]) { cv += solve.z[ids[i]]; cn++; } if (mask[i] && !inner[i]) { rv += solve.z[ids[i]]; rn++; } }
144
+ const d = cv / cn - rv / rn;
145
+ if (!cn || !rn || !Number.isFinite(d) || Math.abs(d) < 1e-12) return depthMapFail('DEPTH_NORMAL_CONVEXITY', at, `core ${cn}, rim ${rn}, core-minus-rim ${d}; a resolved convex interior is required, use a larger mask or scalar source`);
146
+ const sign = d >= 0 ? 1 : -1, data = new Float64Array(w * h);
147
+ for (let i = 0; i < mask.length; i++) if (mask[i]) data[i] = Math.fround(solve.z[ids[i]] * sign);
148
+ return { data, sign, coreMinusRim: d, iterations: solve.iterations, residual: solve.residual };
149
+ }
150
+
151
+ export interface MapSheetReading { levels: number; clipFraction: number; reliefRigPixels: number; rawMedian: number; rawP5: number; rawP95: number }
152
+ export function mapToSheet(data: ArrayLike<number>, art: Raster, median: number, relief: number, near: 'black' | 'white', zScale: number, at: string): { sheet: Raster; reading: MapSheetReading } {
153
+ const support = alphaSupport(art), values: number[] = [];
154
+ for (let i = 0; i < support.length; i++) if (support[i]) values.push(data[i]);
155
+ if (!values.length) return depthMapFail('DEPTH_MAP_SUPPORT', at, 'has no alpha>=128 pixels; a part with declared support is required');
156
+ values.sort((a, b) => a - b);
157
+ const med = mapQuantile(values, 0.5), lo = mapQuantile(values, 0.05), hi = mapQuantile(values, 0.95), spread = hi - lo;
158
+ if (![med, lo, hi, spread].every(Number.isFinite)) return depthMapFail('DEPTH_MAP_FORMAT', at, 'normalization overflows finite arithmetic; provide finite representable median and percentile spread');
159
+ if (spread === 0 && relief !== 0) return depthMapFail('DEPTH_MAP_SPREAD', at, `p5=p95=${lo} but relief=${relief}; a nonflat map or explicit relief 0 is required`);
160
+ const sheet = newRaster(art.width, art.height), seeds = newRaster(art.width, art.height), levels = new Set<number>(); let clipped = 0;
161
+ for (let i = 0; i < support.length; i++) {
162
+ const z = median + relief * (spread === 0 ? 0 : (data[i] - med) / spread), unit = Math.max(0, Math.min(1, near === 'white' ? z / zScale : 1 - z / zScale));
163
+ if (!Number.isFinite(z) && support[i]) return depthMapFail('DEPTH_MAP_FORMAT', at, `world depth at support pixel ${i} overflows; declare representable median/relief and raw numbers`);
164
+ // Uint8ClampedArray implements ties-to-even, as numpy.round does.
165
+ sheet.data[i * 4] = unit * 255; sheet.data[i * 4 + 1] = sheet.data[i * 4 + 2] = sheet.data[i * 4]; sheet.data[i * 4 + 3] = 255;
166
+ seeds.data[i * 4 + 3] = support[i] ? 255 : 0;
167
+ if (support[i]) { levels.add(sheet.data[i * 4]); if (z <= 0 || z >= zScale) clipped++; }
168
+ }
169
+ return { sheet: extendDepthSilhouette(sheet, seeds), reading: { levels: levels.size, clipFraction: clipped / values.length, reliefRigPixels: relief, rawMedian: med, rawP5: lo, rawP95: hi } };
170
+ }
@@ -0,0 +1,239 @@
1
+ /** Explicit opt-in commands for provider maps on already assembled parts (#897).
2
+ * No existing assembly/build path calls these stages. All inputs validate before
3
+ * the first output is emitted; output directories must be absent or empty.
4
+ */
5
+ import { createHash } from 'node:crypto';
6
+ import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, writeFileSync } from 'node:fs';
7
+ import { basename, dirname, join, relative, resolve, sep } from 'node:path';
8
+ import { loadConfigAndAnimations, parseConfig } from './config.ts';
9
+ import { depthReading, readSourceDepth } from './depthsheets.ts';
10
+ import { alphaSupport, depthMapFail as fail, floatBox, integrateNormals, mapToSheet, readNumericMap, type NumericMap } from './depthmaps.ts';
11
+ import { readParts } from './parts.ts';
12
+ import { readPartSet } from './propose.ts';
13
+ import { decodePngBytes, encodePngBytes } from './raster/png.ts';
14
+ import { resize } from './raster/resize.ts';
15
+ import { connectedComponents } from './raster/components.ts';
16
+ import { isSkeletonName } from './skeleton.ts';
17
+ import { newRaster, type Raster } from './raster/types.ts';
18
+
19
+ type Obj = Record<string, unknown>;
20
+ interface PrepareOptions { longSide: number; paddingFraction: number; padding: number }
21
+ interface Group { median?: never; near: 'black' | 'white'; zScale: number; pivot: number; unitDepths?: Record<string, number> }
22
+ interface PartMap { map: string; source: 'depth' | 'normals'; frame: 'part' | 'upscaled' | 'input'; group: string; median: number; relief: number }
23
+ export interface DepthRecipe { spec: 'rig-parts-depth/1'; prepare: PrepareOptions; groups: Record<string, Group>; parts: Record<string, PartMap>; clear: string[] }
24
+ interface PreparedPart { part: string; crop: number[]; sourceSha256: string; input: string; inputSha256: string; size: number[]; upscaled: number[]; upscale: number; padding: number; inputSize: number[] }
25
+ interface Prepared { spec: 'rig-parts-depth-inputs/1'; partsSha256: string; recipeSha256: string; background: number; alpha: number; parts: PreparedPart[] }
26
+ const hash = (bytes: Uint8Array | string): string => createHash('sha256').update(bytes).digest('hex');
27
+ const text = (raw: unknown): string => JSON.stringify(raw, null, 2) + '\n';
28
+ function object(raw: unknown, at: string): Obj {
29
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return fail('DEPTH_RECIPE', at, 'requires an object with the documented fields');
30
+ return raw as Obj;
31
+ }
32
+ function keys(o: Obj, allowed: readonly string[], at: string): void {
33
+ const unknown = Object.keys(o).filter(k => !allowed.includes(k));
34
+ if (unknown.length) fail('DEPTH_RECIPE', at, `unknown fields ${unknown.join(', ')}; allowed: ${allowed.join(', ')}`);
35
+ }
36
+ function number(raw: unknown, at: string, min: number, inclusive = true): number {
37
+ if (typeof raw !== 'number' || !Number.isFinite(raw) || (inclusive ? raw < min : raw <= min)) return fail('DEPTH_RECIPE_NUMBER', at, `is ${String(raw)}; an explicit finite number ${inclusive ? '>=' : '>'} ${min} is required`);
38
+ return raw;
39
+ }
40
+ function readJson(file: string): unknown {
41
+ if (!existsSync(file) || !statSync(file).isFile()) return fail('DEPTH_INPUT_PRESENT', file, 'is missing or not a file; restore the declared input');
42
+ try { return JSON.parse(readFileSync(file, 'utf8')) as unknown; }
43
+ catch { return fail('DEPTH_INPUT_JSON', file, 'is not valid JSON; finite numeric JSON data is required'); }
44
+ }
45
+ function plain(name: string, at: string): void {
46
+ if (!name || name.startsWith('.') || /[\\/]/.test(name) || name.includes('\0')) fail('DEPTH_RECIPE', at, `name "${name}" is not a plain part filename`);
47
+ }
48
+ export function parseDepthRecipe(raw: unknown): DepthRecipe {
49
+ const o = object(raw, 'depth recipe'); keys(o, ['spec', 'prepare', 'groups', 'parts', 'clear'], 'depth recipe');
50
+ if (o.spec !== 'rig-parts-depth/1') fail('DEPTH_RECIPE', 'spec', 'requires rig-parts-depth/1');
51
+ const p = object(o.prepare, 'prepare'); keys(p, ['longSide', 'paddingFraction', 'padding'], 'prepare');
52
+ const prepare = { longSide: number(p.longSide, 'prepare.longSide', 1), paddingFraction: number(p.paddingFraction, 'prepare.paddingFraction', 0), padding: number(p.padding, 'prepare.padding', 0) };
53
+ if (!Number.isSafeInteger(prepare.longSide) || !Number.isSafeInteger(prepare.padding)) fail('DEPTH_RECIPE_NUMBER', 'prepare', 'longSide and padding must be explicit safe integers');
54
+ const groups: Record<string, Group> = {};
55
+ for (const [name, value] of Object.entries(object(o.groups, 'groups'))) {
56
+ if (!['head', 'body'].includes(name)) fail('DEPTH_RECIPE', `groups.${name}`, 'requires head or body, matching config.turn or config.turn.body');
57
+ const g = object(value, `groups.${name}`); keys(g, ['near', 'zScale', 'pivot', 'unitDepths'], `groups.${name}`);
58
+ if (g.near !== 'white' && g.near !== 'black') fail('DEPTH_RECIPE', `groups.${name}.near`, `is ${String(g.near)}; explicit white or black is required`);
59
+ const unitDepths: Record<string, number> = {};
60
+ if (g.unitDepths !== undefined) for (const [part, v] of Object.entries(object(g.unitDepths, `groups.${name}.unitDepths`))) unitDepths[part] = number(v, `groups.${name}.unitDepths.${part}`, -Infinity);
61
+ groups[name] = { near: g.near, zScale: number(g.zScale, `groups.${name}.zScale`, 0, false), pivot: number(g.pivot, `groups.${name}.pivot`, -Infinity), ...(g.unitDepths === undefined ? {} : { unitDepths }) };
62
+ }
63
+ const parts: Record<string, PartMap> = {};
64
+ for (const [name, value] of Object.entries(object(o.parts, 'parts'))) {
65
+ plain(name, `parts.${name}`); const p = object(value, `parts.${name}`); keys(p, ['map', 'source', 'frame', 'group', 'median', 'relief'], `parts.${name}`);
66
+ if (typeof p.map !== 'string' || !p.map) fail('DEPTH_RECIPE', `parts.${name}.map`, 'requires a declared numeric JSON map filename relative to the recipe');
67
+ if (p.source !== 'depth' && p.source !== 'normals') fail('DEPTH_RECIPE', `parts.${name}.source`, 'requires depth or normals');
68
+ if (p.frame !== 'part' && p.frame !== 'upscaled' && p.frame !== 'input') fail('DEPTH_RECIPE', `parts.${name}.frame`, 'requires part, upscaled or input; registration is never inferred from dimensions');
69
+ if (typeof p.group !== 'string' || !Object.hasOwn(groups, p.group)) fail('DEPTH_RECIPE', `parts.${name}.group`, 'must resolve to a declared group');
70
+ parts[name] = { map: p.map, source: p.source, frame: p.frame, group: p.group, median: number(p.median, `parts.${name}.median`, -Infinity), relief: number(p.relief, `parts.${name}.relief`, 0) };
71
+ }
72
+ if (!Object.keys(parts).length) fail('DEPTH_RECIPE', 'parts', 'requires at least one named part');
73
+ if (!Array.isArray(o.clear) || !o.clear.every(n => typeof n === 'string') || new Set(o.clear).size !== o.clear.length) fail('DEPTH_RECIPE', 'clear', 'requires an explicit array of unique part names, [] when none; no sheet is dropped implicitly');
74
+ for (const name of o.clear as string[]) { plain(name, 'clear'); if (Object.hasOwn(parts, name)) fail('DEPTH_RECIPE', `clear.${name}`, 'also has a new map; choose clear or apply'); }
75
+ return { spec: 'rig-parts-depth/1', prepare, groups, parts, clear: o.clear as string[] };
76
+ }
77
+ function emptyOutput(out: string, inputs: readonly string[]): void {
78
+ const canonical = (p: string): string => existsSync(p) ? realpathSync(p) : resolve(canonical(dirname(resolve(p))), basename(p));
79
+ const target = canonical(out);
80
+ for (const input of inputs) {
81
+ const path = canonical(input), r = relative(target, path), back = relative(path, target);
82
+ if (r === '' || (!r.startsWith(`..${sep}`) && r !== '..' && !r.startsWith(sep))) fail('DEPTH_OUTPUT_EMPTY', out, 'contains or is an input path; use a separate absent or empty output directory');
83
+ if (existsSync(input) && statSync(input).isDirectory() && back !== '..' && !back.startsWith(`..${sep}`) && !back.startsWith(sep)) fail('DEPTH_OUTPUT_EMPTY', out, 'is inside an input directory; choose a separate output so source inputs remain read-only');
84
+ }
85
+ if (existsSync(out) && (!statSync(out).isDirectory() || readdirSync(out).length !== 0)) fail('DEPTH_OUTPUT_EMPTY', out, 'is not an empty directory; choose an absent or empty output, never overwrite inputs or old evidence');
86
+ }
87
+ function preparedParts(partsDir: string, recipe: DepthRecipe): { manifest: Prepared; images: Map<string, Uint8Array> } {
88
+ const set = readPartSet(partsDir), images = new Map<string, Uint8Array>(), parts: PreparedPart[] = [];
89
+ for (const name of [...Object.keys(recipe.parts), ...recipe.clear]) if (!set.recs.some(p => p.name === name)) fail('DEPTH_PART_RESOLVES', name, 'names no assembled part; use exact parts.json names');
90
+ for (const part of set.recs.filter(p => Object.hasOwn(recipe.parts, p.name))) {
91
+ const art = set.images.get(part.name) as Raster, { longSide, paddingFraction, padding } = recipe.prepare;
92
+ const upscale = Math.max(1, Math.ceil(longSide / Math.max(art.width, art.height))), w = art.width * upscale, h = art.height * upscale;
93
+ const pad = Math.floor(Math.max(w, h) * paddingFraction) + padding, big = resize(art, w, h, 'lanczos3'), input = newRaster(w + pad * 2, h + pad * 2);
94
+ for (let i = 0; i < input.width * input.height; i++) input.data.set([128, 128, 128, 255], i * 4);
95
+ for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
96
+ const i = (y * w + x) * 4, j = ((y + pad) * input.width + x + pad) * 4, a = big.data[i + 3];
97
+ for (let c = 0; c < 3; c++) input.data[j + c] = Math.floor((big.data[i + c] * a + 128 * (255 - a) + 127) / 255);
98
+ }
99
+ if (!alphaSupport(art).some(Boolean)) fail('DEPTH_MAP_SUPPORT', part.name, 'has no alpha>=128 pixels; a supported part is required');
100
+ const bytes = encodePngBytes(input), file = `${part.name}.png`; images.set(file, bytes);
101
+ parts.push({ part: part.name, crop: [part.x, part.y, part.w, part.h], sourceSha256: hash(readFileSync(join(partsDir, 'parts', file))), input: file, inputSha256: hash(bytes), size: [art.width, art.height], upscaled: [w, h], upscale, padding: pad, inputSize: [input.width, input.height] });
102
+ }
103
+ return { manifest: { spec: 'rig-parts-depth-inputs/1', partsSha256: hash(readFileSync(join(partsDir, 'parts.json'))), recipeSha256: hash(text(recipe)), background: 128, alpha: 128, parts }, images };
104
+ }
105
+ export function prepareDepth(input: { parts: string; config: string; out: string }): Prepared {
106
+ const recipe = parseDepthRecipe(readJson(input.config));
107
+ emptyOutput(input.out, [input.parts, input.config]);
108
+ const { manifest, images } = preparedParts(input.parts, recipe);
109
+ mkdirSync(input.out, { recursive: true });
110
+ for (const [file, bytes] of images) writeFileSync(join(input.out, file), bytes);
111
+ writeFileSync(join(input.out, 'manifest.json'), text(manifest));
112
+ return manifest;
113
+ }
114
+ function registeredMap(map: NumericMap, p: PreparedPart, frame: PartMap['frame'], at: string): NumericMap {
115
+ const [w, h] = frame === 'part' ? p.size : frame === 'input' ? p.inputSize : p.upscaled;
116
+ if (map.width !== w || map.height !== h) return fail('DEPTH_MAP_SIZE', at, `is ${map.width}x${map.height}; declared ${frame} frame is ${w}x${h}`);
117
+ if (frame !== 'input') return map;
118
+ const data: number[] = [];
119
+ for (let y = 0; y < p.upscaled[1]; y++) for (let x = 0; x < p.upscaled[0]; x++) for (let c = 0; c < map.channels; c++) data.push(map.data[((y + p.padding) * map.width + x + p.padding) * map.channels + c]);
120
+ return { ...map, width: p.upscaled[0], height: p.upscaled[1], data };
121
+ }
122
+
123
+ /** Extend the float field from its OWN stride2 integration support before BOX.
124
+ * Keep every supported height unchanged; Euclidean nearest seeds use row-major
125
+ * ties. A nearest seed must be on the four-neighbour boundary: an interior
126
+ * seed always has a strictly closer axis neighbour toward an unsupported point.
127
+ * No gray encoding or native-art mask is used on this integration grid.
128
+ */
129
+ function extendIntegratedHeight(data: Float64Array, art: Raster): Float64Array {
130
+ const w = Math.ceil(art.width / 2), h = Math.ceil(art.height / 2);
131
+ const support = Uint8Array.from({ length: w * h }, (_, i) => art.data[((2 * Math.floor(i / w)) * art.width + 2 * (i % w)) * 4 + 3] >= 128 ? 1 : 0);
132
+ const boundary: number[] = [];
133
+ for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
134
+ const i = y * w + x;
135
+ if (support[i] && (x === 0 || y === 0 || x === w - 1 || y === h - 1 || !support[i - 1] || !support[i + 1] || !support[i - w] || !support[i + w])) boundary.push(i);
136
+ }
137
+ // integrateNormals already refused empty/disconnected support before this call.
138
+ const filled = data.slice();
139
+ for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
140
+ const i = y * w + x; if (support[i]) continue;
141
+ let distance = Infinity, seed = -1;
142
+ for (const j of boundary) {
143
+ const dx = x - j % w, dy = y - Math.floor(j / w), d = dx * dx + dy * dy;
144
+ if (d < distance) { distance = d; seed = j; }
145
+ }
146
+ filled[i] = data[seed];
147
+ }
148
+ return filled;
149
+ }
150
+ export function applyDepth(input: { parts: string; manifest: string; config: string; rigConfig: string; out: string }): Obj {
151
+ const recipe = parseDepthRecipe(readJson(input.config)), expected = preparedParts(input.parts, recipe);
152
+ const supplied = readJson(input.manifest);
153
+ if (text(supplied) !== text(expected.manifest)) fail('DEPTH_INPUT_BINDING', input.manifest, 'does not match the recipe, parts.json, art hashes, crop or padding; run depth-prepare again for these exact inputs');
154
+ for (const p of expected.manifest.parts) {
155
+ const file = join(dirname(input.manifest), p.input);
156
+ if (!existsSync(file) || hash(readFileSync(file)) !== p.inputSha256) fail('DEPTH_INPUT_BINDING', p.part, 'prepared inference PNG is missing or changed; restore its exact bytes or prepare again');
157
+ }
158
+ emptyOutput(input.out, [input.parts, input.config, input.manifest, input.rigConfig, ...Object.values(recipe.parts).map(p => resolve(dirname(input.config), p.map))]);
159
+ const rawParts = object(readJson(join(input.parts, 'parts.json')), 'parts.json'), file = readParts(join(input.parts, 'parts.json'));
160
+ for (const p of file.parts) if (Object.hasOwn(recipe.parts, p.name) && p.provenance?.depth?.kind === 'authored-unit') fail('DEPTH_AUTHORED_UNIT_CONFLICT', `part "${p.name}"`, 'has provenance.depth.kind "authored-unit"; a transported scalar reference cannot coexist with authored unit depth — retain the unit declaration or supply an explicitly reassembled scalar-compatible input; no automatic transition is made');
161
+ loadConfigAndAnimations(input.rigConfig);
162
+ const raw = object(readJson(input.rigConfig), 'rig config'), turn = object(raw.turn, 'config.turn');
163
+ for (const [name, group] of Object.entries(recipe.groups)) {
164
+ const target = name === 'head' ? turn : object(turn.body, 'config.turn.body'); target.pivot = group.pivot;
165
+ if (group.unitDepths !== undefined) target.unitDepths = group.unitDepths;
166
+ }
167
+ // Keep relative paths portable when relocating the output directory with its inputs.
168
+ const motion = raw.motion as Obj | undefined;
169
+ if (typeof motion?.animations_from === 'string') motion.animations_from = relative(resolve(input.out), resolve(dirname(input.rigConfig), motion.animations_from));
170
+ const control = (raw.generation as Obj | undefined)?.control as Obj | undefined;
171
+ if (typeof control?.skeleton === 'string' && !isSkeletonName(control.skeleton)) control.skeleton = relative(resolve(input.out), resolve(dirname(input.rigConfig), control.skeleton));
172
+ if (typeof raw.requirements === 'string') raw.requirements = relative(resolve(input.out), resolve(dirname(input.rigConfig), raw.requirements));
173
+ parseConfig(raw);
174
+ const writes = new Map<string, Uint8Array>(), rows: Obj[] = [], recs = rawParts.parts as Obj[];
175
+ // Retain auxiliary art (including alternate sets) and diagnostics byte-for-byte.
176
+ // Symlink inputs would obscure ownership and can escape this assembled directory.
177
+ const retained = new Map<string, Uint8Array>();
178
+ const collect = (dir: string, prefix: string): void => {
179
+ for (const ent of readdirSync(dir, { withFileTypes: true })) {
180
+ const path = join(dir, ent.name), key = prefix + ent.name;
181
+ if (ent.isDirectory()) collect(path, key + '/');
182
+ else if (ent.isFile()) retained.set(key, readFileSync(path));
183
+ else fail('DEPTH_INPUT_PRESENT', path, 'is not a regular owned file/directory; supply a self-contained assembled directory without symlinks');
184
+ }
185
+ };
186
+ collect(input.parts, '');
187
+ for (const p of file.parts) {
188
+ const name = p.name, bytes = readFileSync(join(input.parts, 'parts', `${name}.png`)), art = decodePngBytes(bytes, name), rec = recs.find(r => r.name === name) as Obj;
189
+ writes.set(`${name}.png`, bytes);
190
+ const declaration = Object.hasOwn(recipe.parts, name) ? recipe.parts[name] : undefined;
191
+ if (declaration === undefined) {
192
+ if (recipe.clear.includes(name)) { if (p.depth !== undefined) retained.delete(`parts/${p.depth.image}`); delete rec.depth; }
193
+ else if (p.depth !== undefined) { readSourceDepth(join(input.parts, 'parts', p.depth.image), art, null); writes.set(p.depth.image, readFileSync(join(input.parts, 'parts', p.depth.image))); }
194
+ continue;
195
+ }
196
+ const g = recipe.groups[declaration.group], target = declaration.group === 'head' ? turn : object(turn.body, 'config.turn.body');
197
+ if (!Object.hasOwn(object(target.roles, `config.turn.${declaration.group}.roles`), name)) fail('DEPTH_GROUP_BINDING', name, `declared group ${declaration.group} does not name this part in its roles; register it explicitly`);
198
+ const prepared = expected.manifest.parts.find(row => row.part === name) as PreparedPart;
199
+ const mapPath = resolve(dirname(input.config), declaration.map), numeric = readNumericMap(readJson(mapPath), name), mapBytes = readFileSync(mapPath);
200
+ const map = registeredMap(numeric, prepared, declaration.frame, name);
201
+ let values: ArrayLike<number>, normal: ReturnType<typeof integrateNormals> | undefined;
202
+ if (declaration.source === 'normals') {
203
+ if (map.channels !== 3) fail('DEPTH_MAP_FORMAT', name, 'normals require three signed XYZ channels');
204
+ const islands = connectedComponents({ width: art.width, height: art.height, data: alphaSupport(art) }, 4).count - 1;
205
+ if (islands !== 1) fail('DEPTH_NORMAL_ISLANDS', name, `native part has ${islands} alpha>=128 islands; exactly one closed convex part is required`);
206
+ const maskArt = map.width === art.width && map.height === art.height ? art : resize(art, map.width, map.height, 'lanczos3');
207
+ normal = integrateNormals(map, maskArt, name);
208
+ values = floatBox(extendIntegratedHeight(normal.data, maskArt), Math.ceil(map.width / 2), Math.ceil(map.height / 2), art.width, art.height);
209
+ } else {
210
+ if (map.channels !== 1) fail('DEPTH_MAP_FORMAT', name, 'depth requires one floating-point channel');
211
+ values = floatBox(map.data, map.width, map.height, art.width, art.height);
212
+ }
213
+ const { sheet, reading } = mapToSheet(values, art, declaration.median, declaration.relief, g.near, g.zScale, name), image = `${name}_depth.png`, sheetBytes = encodePngBytes(sheet);
214
+ if (file.parts.some(p => `${p.name}.png` === image)) fail('DEPTH_GROUP_BINDING', name, `sheet filename ${image} collides with artwork; use noncolliding part names`);
215
+ writes.set(image, sheetBytes);
216
+ if (p.depth !== undefined) retained.delete(`parts/${p.depth.image}`);
217
+ rec.depth = { image, near: g.near, zScale: g.zScale, sources: [{ from: p.from, image: basename(declaration.map), sha256: hash(mapBytes), crop: [0, 0, art.width, art.height], reading: depthReading(sheet, art, null) }] };
218
+ rows.push({ part: name, group: declaration.group, source: declaration.source, frame: declaration.frame, sourceSha256: prepared.sourceSha256, mapSha256: hash(mapBytes), inputSha256: prepared.inputSha256, sheetSha256: hash(sheetBytes), median: declaration.median, relief: declaration.relief, near: g.near, zScale: g.zScale, pivot: g.pivot, ...reading, sign: normal?.sign ?? 1, ...(normal === undefined ? {} : { integration: { stride: 2, iterations: normal.iterations, residual: normal.residual, coreMinusRim: normal.coreMinusRim, maxIterations: 3000, atol: 1e-6, btol: 1e-6 } }) });
219
+ }
220
+ // A replacement must share near/zScale with every retained part in its actual group.
221
+ for (const [name, g] of Object.entries(recipe.groups)) {
222
+ const target = name === 'head' ? turn : object(turn.body, 'config.turn.body'), roles = object(target.roles, `config.turn.${name}.roles`);
223
+ for (const rec of recs) if (Object.hasOwn(roles, String(rec.name)) && rec.depth !== undefined) {
224
+ const d = rec.depth as Obj;
225
+ if (d.near !== g.near || d.zScale !== g.zScale) fail('DEPTH_GROUP_BINDING', String(rec.name), `retained ${name} sheet near=${String(d.near)}, zScale=${String(d.zScale)}; declared group requires ${g.near}, ${g.zScale}; replace or explicitly clear that sheet`);
226
+ }
227
+ }
228
+ const report = { spec: 'rig-parts-depth-report/1', partsSha256: expected.manifest.partsSha256, recipeSha256: expected.manifest.recipeSha256, manifestSha256: hash(readFileSync(input.manifest)), rigConfigSha256: hash(readFileSync(input.rigConfig)), alpha: 128, normalization: 'median, linear p5..p95', extension: 'nearest alpha>=128, row-major ties', cleared: recipe.clear, parts: rows };
229
+ mkdirSync(join(input.out, 'parts'), { recursive: true });
230
+ for (const [file, bytes] of retained) {
231
+ if (['parts.json', 'config.json', 'depth-report.json'].includes(file)) continue;
232
+ const target = join(input.out, file); mkdirSync(dirname(target), { recursive: true }); writeFileSync(target, bytes);
233
+ }
234
+ for (const [file, bytes] of writes) writeFileSync(join(input.out, 'parts', file), bytes);
235
+ writeFileSync(join(input.out, 'parts.json'), text(rawParts));
236
+ writeFileSync(join(input.out, 'config.json'), text(raw));
237
+ writeFileSync(join(input.out, 'depth-report.json'), text(report));
238
+ return report;
239
+ }