rig-parts 1.85.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 +36 -0
- package/docs/AUTHORING.md +147 -0
- package/docs/STABILITY.md +15 -0
- package/package.json +2 -2
- package/src/build.ts +3 -2
- package/src/check.ts +31 -6
- package/src/depthmaps.ts +170 -0
- package/src/depthworkflow.ts +239 -0
- package/src/jointcontact.ts +59 -0
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
|
@@ -5120,6 +5120,28 @@ wind (1, 0), then (40, 0) from 0.5 s, reads `TEXTURE_STRETCH` 32.181 (FAIL, `hai
|
|
|
5120
5120
|
triangle 258), two meshes folding under `ANIMATIONS_DO_NOT_FOLD` (FAIL; none without the
|
|
5121
5121
|
flag) and 8 `CLOTH_SETTLES` warnings for 9 (selftest `CK515`).
|
|
5122
5122
|
|
|
5123
|
+
**Joint material contact** (issue #894). Each played `SEAM_OVER_MOTION.rows[]` adds
|
|
5124
|
+
`joint_contact`: `status` (`REPORTED` or `SKIP`), `joints` (declared limb joint
|
|
5125
|
+
labels or a proven body root's bone), and, when observed, the frame counts
|
|
5126
|
+
`joined`, `separated` and `unavailable`, plus `reasons` with each unavailable
|
|
5127
|
+
reason and its observed frame indices. `JOINT_CONTACT` prints one informational
|
|
5128
|
+
line per pair and animation; it is never a bar and changes no PASS, warning or
|
|
5129
|
+
exit status. It counts core 2.84.0's `jointContact.frames` from the same seam
|
|
5130
|
+
render. Core reads edge-connected half-opacity material: initially overlapping
|
|
5131
|
+
single-component pieces are joined when their posed union connects by an edge
|
|
5132
|
+
or overlap, separated when it does not. A diagonal contact is separated. Missing
|
|
5133
|
+
setup overlap, absent drawings, disconnected material or multiple fragments are
|
|
5134
|
+
unavailable with core's reason. A pair without a declared named joint is
|
|
5135
|
+
unavailable even if its arbitrary slot drawings connect. An absent core contact
|
|
5136
|
+
field is unavailable when the frame count is known; absent frame counts or zero
|
|
5137
|
+
samples are SKIP with a reason, never invented joined frames. Unavailable is
|
|
5138
|
+
never added to joined. No pair or animation is a named SKIP in the output.
|
|
5139
|
+
Contact reports material connectivity at core's texel grid, not anatomical
|
|
5140
|
+
intent, sub-cell contact or the absence of local holes. Raw gap, ghost, floor
|
|
5141
|
+
and body-root verdicts keep their existing producer readings. Core 2.83.0's
|
|
5142
|
+
visible-ghost and local-support gap definitions are inherited by the 2.84.0 pin;
|
|
5143
|
+
contact counts do not replace or reinterpret them.
|
|
5144
|
+
|
|
5123
5145
|
**Physics seam environments** (issue #591). Default: wind [1, 0], gravity [0, 1]. Still: wind [0, 0], gravity [0, 1]. In rig-play, environment(null) returns to Default; still air requires an explicit wind [0, 0]. For a physics rig with readable declared pairs and animations, SEAM_OVER_MOTION adds optional `worlds.default` and `worlds.still` readings. They are informational raw gap/ghost cell counts (including cells below the warning floor), never new bars or warnings; neither changes PASS, exit status or the bar count. Each world records its vectors, status and rows; each row names its pair, animation, drawn/banded frames and gap/ghost maximum and worst frame, cell size and world box. Per-frame hits are not embedded in check.json. A rendered world's `dir` is relative to check.json (`seam_motion/default` or `seam_motion/still`): it retains the complete rigc render, including frames.json, frame images and each animation's seam.json with all raw hits. The directories are published with check.json only after the readings are validated; scratch is removed, and an unrendered SKIP has no directory reference. The original reading and any custom `--environment` schedule keep their existing meaning; the two reference worlds are independent of that schedule. A rig with no wind response reports Still as SKIP with its reason; no band is SKIP, never a measured zero. With no readable declared pair or animation no worlds block is written. The same declared pair list is used for both reference worlds: neighbouring limb segments, explicit seam requirements and the proven body-root declarations described below. Thigh/hem pairs are not inferred. Adding reference worlds to a nonphysics reading stays byte-identical, with no extra key, line or render; issue #686 separately adds body-root declarations and their active-reading bar. Reference renders start physics at frame 0 at 12 fps; they do not certify a composed player scene or another sampling rate. Malformed sidecars or renderer failures remain named refusals.
|
|
5124
5146
|
|
|
5125
5147
|
**Steady-wind seam band** (`--wind-band`, issue #590). `check --wind-band <spec.json>` (or `build --wind-band <spec.json>`, forwarded) sweeps steady horizontal wind across a caller-declared grid and reports the contiguous `[x_lo, x_hi]` band in which each declared seam pair holds `gap <= max_gap` and `ghost <= max_ghost` across every animation in `motion.json`. Every field in `{ "spec": "rig-parts-wind-band/1", "min", "max", "step", "fps", "max_gap", "max_ghost", "pairs" }` is required — nothing falls back to `SEAM_OVER_MOTION`'s pairs, one-cell floor, or fps. At each swept step `x = min + k * step`, `check` runs `rigc render --seam <pairs> --fps <fps> --max 16 --environment` with a single-step steady schedule `{ "spec": "rigc-environment/1", "steps": [{ "t": 0, "wind": [x, 0], "gravity": [0, 1] }] }` (`seam.json` cells are in each pair's coarser drawing texels, independent of `--max`). **What it does not measure:** the sweep is steady wind (`wind: [x, 0], gravity: [0, 1]` at `t = 0`), not a time-varying gust (`x ± a` at 0.2 Hz); transient overshoot from a changing wind is not read by a steady sweep, and the band is documented and reported as the steady-wind band (`mode: "steady"`, `gravity: [0, 1]`). It is a reported line (`status: "REPORTED"`), never a bar: it prints one `WIND_BAND:` summary line plus one `WIND_BAND:` line per pair that breaks inside `[min, max]`, and writes `WIND_BAND` in `check.json` before `PASS`.
|
|
@@ -6564,6 +6586,131 @@ and never assign a role, depth, binding or draw order. A part already bound to
|
|
|
6564
6586
|
its actual intended role, then inspect setup, both turn endpoints and their diff;
|
|
6565
6587
|
an explicit role alone does not establish that two drawings remain coincident.
|
|
6566
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
|
+
|
|
6567
6714
|
### Unit depth precedence
|
|
6568
6715
|
|
|
6569
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.
|
|
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": {
|
|
@@ -67,7 +67,7 @@
|
|
|
67
67
|
},
|
|
68
68
|
"dependencies": {
|
|
69
69
|
"ag-psd": "^31.0.2",
|
|
70
|
-
"@rig-c/core": "2.
|
|
70
|
+
"@rig-c/core": "2.84.0"
|
|
71
71
|
},
|
|
72
72
|
"devDependencies": {
|
|
73
73
|
"@esotericsoftware/spine-core": "4.3.13",
|
package/src/build.ts
CHANGED
|
@@ -81,7 +81,7 @@ import {
|
|
|
81
81
|
splitSchedule,
|
|
82
82
|
untestedIntervals,
|
|
83
83
|
} from './autoreplay.ts';
|
|
84
|
-
import { besideIdleLine, checkSummaryLine, inputFileRecord, type InputFileRecord, CLOTH_LINE, type ClothLine, clothWarnText, frameRuns, MOTION_SEAM_LINE, type MotionSeamLine, type MotionSeamPlant, motionSeamWarnText, FOLD_LINE, type FoldsLine, LEVEL_LINE, type LevelLine, LIMB_LINE, type LimbLine, STRESS_LINE, SWEEP_LINE, type SweepLine, buildGateLines, causeLines, environmentLine, IDLE_FPS, IDLE_MAX_PX, maxRgbDiff, REQUIREMENTS_DIR, type CheckReport, type PackLine, DEFAULT_PACK_SHAPE, DEFAULT_PAGE_EDGES, GATE_PROFILE, JUDGEMENT_LINES, type JudgementLine, packedBuildArgs, packedBuildLabel, type PackMode, readCheckEnvironment, readWindBand, type PackShape, type PageEdges, readFrameSet, rigcFailed, rigcIdentity, rigcRanClause, REPORTED_LINES, type ReportedLine, type RigcRunner, runCheck, SEAM_DIR, SEAM_MEAN_BAR, seamPairText, seamRigPxText, SOURCE_LINE, SEAM_PX_BAR, SEAM_PX_LEVEL, SEAM_PX_LEVEL_HIGH, SPINEBOY_YARDSTICK, STILL_DIR, WIND_BAND_LINE, windBandLines } from './check.ts';
|
|
84
|
+
import { besideIdleLine, checkSummaryLine, inputFileRecord, type InputFileRecord, CLOTH_LINE, type ClothLine, clothWarnText, frameRuns, MOTION_SEAM_LINE, type MotionSeamLine, type MotionSeamPlant, motionSeamWarnText, motionJointContactLines, FOLD_LINE, type FoldsLine, LEVEL_LINE, type LevelLine, LIMB_LINE, type LimbLine, STRESS_LINE, SWEEP_LINE, type SweepLine, buildGateLines, causeLines, environmentLine, IDLE_FPS, IDLE_MAX_PX, maxRgbDiff, REQUIREMENTS_DIR, type CheckReport, type PackLine, DEFAULT_PACK_SHAPE, DEFAULT_PAGE_EDGES, GATE_PROFILE, JUDGEMENT_LINES, type JudgementLine, packedBuildArgs, packedBuildLabel, type PackMode, readCheckEnvironment, readWindBand, type PackShape, type PageEdges, readFrameSet, rigcFailed, rigcIdentity, rigcRanClause, REPORTED_LINES, type ReportedLine, type RigcRunner, runCheck, SEAM_DIR, SEAM_MEAN_BAR, seamPairText, seamRigPxText, SOURCE_LINE, SEAM_PX_BAR, SEAM_PX_LEVEL, SEAM_PX_LEVEL_HIGH, SPINEBOY_YARDSTICK, STILL_DIR, WIND_BAND_LINE, windBandLines } from './check.ts';
|
|
85
85
|
import { type Cut, type Extend, type Limb, loadConfigAndAnimations, loadConfigWithBytes, loadEarlyConfig, type Patch, type PlanEntry } from './config.ts';
|
|
86
86
|
import { coreCompileGuideLine, failLine, PartsError, type Problem, refuseIfAny } from './errors.ts';
|
|
87
87
|
import type { MeshQualityReport, MotionSchedule } from '@rig-c/core/mesh';
|
|
@@ -1824,7 +1824,7 @@ export function sweepCheckLines(line: SweepLine, steps: ReadonlyMap<string, { le
|
|
|
1824
1824
|
export function motionSeamLines(line: MotionSeamLine, plant: MotionSeamPlant = {}): string[] {
|
|
1825
1825
|
const unmeasured = (line.roots?.unmeasured ?? []).map(root => `root pair for ${root.limb} (${root.part}, bone "${root.bone}", parent "${root.parent}", candidates ${root.candidates.join('/')}): SKIP — ${root.reason}`);
|
|
1826
1826
|
const rootStatus = line.roots === undefined ? '' : '; body-root gap-open+under-ghost gate ' + line.roots.status + ' (' + line.roots.pairs.length + ' pair(s)); ghost ownership from resolved draw order' + (line.roots.reason === undefined ? '' : ' — ' + line.roots.reason);
|
|
1827
|
-
if (line.worlds === undefined && line.status === 'SKIP' && line.rows.length === 0 && line.pairs.every((p) => p.unread === undefined)) return [`${MOTION_SEAM_LINE}: SKIP — ${String(line.reason)}${unmeasured.length === 0 ? '' : rootStatus}`, ...unmeasured];
|
|
1827
|
+
if (line.worlds === undefined && line.status === 'SKIP' && line.rows.length === 0 && line.pairs.every((p) => p.unread === undefined)) return [`${MOTION_SEAM_LINE}: SKIP — ${String(line.reason)}${unmeasured.length === 0 ? '' : rootStatus}`, ...unmeasured, ...motionJointContactLines(line)];
|
|
1828
1828
|
const read = `${line.pairs.length} declared pair(s) in ${line.animations.length} animation(s) (${line.animations.join(', ')}), read by rigc render --seam at ${line.fps} fps on the build as played`;
|
|
1829
1829
|
const head =
|
|
1830
1830
|
line.status === 'SKIP'
|
|
@@ -1832,6 +1832,7 @@ export function motionSeamLines(line: MotionSeamLine, plant: MotionSeamPlant = {
|
|
|
1832
1832
|
: `${MOTION_SEAM_LINE}: ${line.status} — ${read}: ${line.warnings} warning(s), never a bar${rootStatus}; ${line.floor}`;
|
|
1833
1833
|
return [
|
|
1834
1834
|
head,
|
|
1835
|
+
...motionJointContactLines(line),
|
|
1835
1836
|
...unmeasured,
|
|
1836
1837
|
...line.pairs.filter((p) => p.unread !== undefined).map((p) => `seam "${p.pair}" (${p.declared.join('; ')}): not read — ${String(p.unread)}`),
|
|
1837
1838
|
...line.rows.filter((r) => r.status === 'skip').map((r) => `seam "${r.pair}" in "${r.animation}": SKIP — rigc read no band on any of the ${r.drawn} frame(s) both slots draw, so there is no seam to read`),
|
package/src/check.ts
CHANGED
|
@@ -83,6 +83,7 @@ import { baseAnchorMoves, type AnchorRegion } from './baseanchor.ts';
|
|
|
83
83
|
* the last idle frame is refused unless it sits at t = duration (within
|
|
84
84
|
* `TIME_SLACK`, issue #244).
|
|
85
85
|
*/
|
|
86
|
+
import { jointContactReading, type JointContactReading } from './jointcontact.ts';
|
|
86
87
|
import { createHash } from 'node:crypto';
|
|
87
88
|
import { createRequire } from 'node:module';
|
|
88
89
|
import type { Viewport as RootCoverageViewport } from '@rig-c/core/render-shared';
|
|
@@ -4341,6 +4342,8 @@ export interface MotionSeamPair {
|
|
|
4341
4342
|
a: string;
|
|
4342
4343
|
b: string;
|
|
4343
4344
|
declared: string[];
|
|
4345
|
+
/** Explicit limb joint labels or a proven body root's bone; never inferred for arbitrary seams. */
|
|
4346
|
+
joints?: string[];
|
|
4344
4347
|
/** Why the pair is not read: a slot `rig.json` does not have. */
|
|
4345
4348
|
unread?: string;
|
|
4346
4349
|
/** Issue #686: segment-zero provenance and its immediate named body carrier. */
|
|
@@ -4374,6 +4377,8 @@ export interface MotionSeamSelection {
|
|
|
4374
4377
|
export interface MotionSeamRow {
|
|
4375
4378
|
pair: string;
|
|
4376
4379
|
animation: string;
|
|
4380
|
+
/** Report-only core connectivity, independent of raw counts and verdicts. */
|
|
4381
|
+
joint_contact?: JointContactReading;
|
|
4377
4382
|
/** rigc's: `skip` when no frame has a band to read — nothing measured, not a seam that held. */
|
|
4378
4383
|
status: 'read' | 'skip';
|
|
4379
4384
|
/** Frames both slots draw, and of those the frames read (rigc's `drawn`, `banded`). */
|
|
@@ -4426,10 +4431,12 @@ export function motionSeamPairs(parts: PartsFile, seams: ReadonlyArray<{ name: s
|
|
|
4426
4431
|
export function motionSeamSelection(parts: PartsFile, seams: ReadonlyArray<{ name: string; part: string; neighbour: string }>, rig?: Record<string, unknown>, readSetup?: (candidates: readonly MotionSeamPair[]) => readonly MotionRootSetupRow[]): MotionSeamSelection {
|
|
4427
4432
|
const out: MotionSeamPair[] = [];
|
|
4428
4433
|
const unmeasuredRoots: MotionUnmeasuredRoot[] = [];
|
|
4429
|
-
const add = (a: string, b: string, declared: string): void => {
|
|
4434
|
+
const add = (a: string, b: string, declared: string, joint?: string): void => {
|
|
4430
4435
|
const hit = out.find((p) => (p.a === a && p.b === b) || (p.a === b && p.b === a));
|
|
4431
|
-
if (hit !== undefined)
|
|
4432
|
-
|
|
4436
|
+
if (hit !== undefined) {
|
|
4437
|
+
hit.declared.push(declared);
|
|
4438
|
+
if (joint !== undefined && !hit.joints?.includes(joint)) (hit.joints ??= []).push(joint);
|
|
4439
|
+
} else out.push({ pair: `${a}|${b}`, a, b, declared: [declared], ...(joint === undefined ? {} : { joints: [joint] }) });
|
|
4433
4440
|
};
|
|
4434
4441
|
const limbs: string[] = [];
|
|
4435
4442
|
for (const p of parts.parts) if (p.limb !== undefined && !limbs.includes(p.limb.name)) limbs.push(p.limb.name);
|
|
@@ -4437,7 +4444,7 @@ export function motionSeamSelection(parts: PartsFile, seams: ReadonlyArray<{ nam
|
|
|
4437
4444
|
const segs = parts.parts.filter((p) => p.limb?.name === limb).sort((x, y) => (x.limb as LimbRecord).segment - (y.limb as LimbRecord).segment);
|
|
4438
4445
|
for (let i = 1; i < segs.length; i++) {
|
|
4439
4446
|
const lower = segs[i].limb as LimbRecord;
|
|
4440
|
-
add(segs[i - 1].name, segs[i].name, `config.limbs.${limb} segment ${lower.segment}${lower.joint === undefined ? '' : `, joint "${lower.joint}"`}
|
|
4447
|
+
add(segs[i - 1].name, segs[i].name, `config.limbs.${limb} segment ${lower.segment}${lower.joint === undefined ? '' : `, joint "${lower.joint}"`}`, lower.joint);
|
|
4441
4448
|
}
|
|
4442
4449
|
}
|
|
4443
4450
|
for (const r of seams) add(r.part, r.neighbour, `requirement "${r.name}" (seam)`);
|
|
@@ -4473,7 +4480,7 @@ export function motionSeamSelection(parts: PartsFile, seams: ReadonlyArray<{ nam
|
|
|
4473
4480
|
}
|
|
4474
4481
|
}
|
|
4475
4482
|
for (const root of selected) {
|
|
4476
|
-
add(root.a, root.b, root.declared[0]);
|
|
4483
|
+
add(root.a, root.b, root.declared[0], root.root?.bone);
|
|
4477
4484
|
const pair = out.find(p => (p.a === root.a && p.b === root.b) || (p.a === root.b && p.b === root.a));
|
|
4478
4485
|
if (pair !== undefined) pair.root = root.root;
|
|
4479
4486
|
}
|
|
@@ -4578,7 +4585,7 @@ function motionRootCandidates(parts: PartsFile, rig: Record<string, unknown>): M
|
|
|
4578
4585
|
}
|
|
4579
4586
|
for (const { part: body } of eligible) out.push({ pair: body.name + '|' + root.name, a: body.name, b: root.name,
|
|
4580
4587
|
declared: ['config.limbs.' + limb + ' segment 0, body carrier "' + parent + '" from ' + body.from],
|
|
4581
|
-
root: { limb, bone: l.bone, parent } });
|
|
4588
|
+
joints: [l.bone], root: { limb, bone: l.bone, parent } });
|
|
4582
4589
|
}
|
|
4583
4590
|
refuseIfAny(problems);
|
|
4584
4591
|
return out;
|
|
@@ -4923,11 +4930,14 @@ function readSeamRows(dir: string, animations: readonly string[], fps: number, p
|
|
|
4923
4930
|
}
|
|
4924
4931
|
}
|
|
4925
4932
|
}
|
|
4933
|
+
const contact = setup ? undefined : jointContactReading(p.jointContact, want.joints ?? [], set.written ?? set.sampled, at, problems);
|
|
4934
|
+
if (contact === null) return;
|
|
4926
4935
|
worldRows?.push({ pair: want.pair, animation, status: p.status, drawn: p.drawn, banded: p.banded, gap: kinds.gap, ghost: kinds.ghost });
|
|
4927
4936
|
const opens = [...over.keys()].sort((x, y) => x - y);
|
|
4928
4937
|
rows.push({
|
|
4929
4938
|
pair: want.pair,
|
|
4930
4939
|
animation,
|
|
4940
|
+
...(setup ? {} : { joint_contact: contact }),
|
|
4931
4941
|
status: p.status,
|
|
4932
4942
|
drawn: p.drawn,
|
|
4933
4943
|
banded: p.banded,
|
|
@@ -5081,6 +5091,21 @@ export function enrichMotionSeamPoses(dir: string, rig: Record<string, unknown>,
|
|
|
5081
5091
|
}
|
|
5082
5092
|
}
|
|
5083
5093
|
|
|
5094
|
+
/** One informational line per observed pair/animation; missing readings are named SKIP. */
|
|
5095
|
+
export function motionJointContactLines(line: MotionSeamLine): string[] {
|
|
5096
|
+
if (line.rows.length === 0) return ['JOINT_CONTACT: SKIP — ' + (line.reason ?? 'no sampled pair/animation reading')];
|
|
5097
|
+
const lines = line.rows.map(row => {
|
|
5098
|
+
const c = row.joint_contact;
|
|
5099
|
+
const head = 'JOINT_CONTACT: "' + row.pair + '" in "' + row.animation + '"';
|
|
5100
|
+
if (c === undefined || c.status === 'SKIP') return head + ': SKIP — ' + (c?.reason ?? 'no contact observation');
|
|
5101
|
+
return head + ', joint ' + (c.joints.length === 0 ? 'unavailable' : c.joints.map(n => JSON.stringify(n)).join(', ')) +
|
|
5102
|
+
': joined ' + c.joined + ', separated ' + c.separated + ', unavailable ' + c.unavailable + ' frame(s)' +
|
|
5103
|
+
(c.reasons.length === 0 ? '' : '; ' + c.reasons.map(r => r.reason + (r.frames === undefined ? '' : ' on frames ' + frameRuns(r.frames))).join('; ')) + '; report only';
|
|
5104
|
+
});
|
|
5105
|
+
for (const pair of line.pairs) if (!line.rows.some(row => row.pair === pair.pair)) lines.push('JOINT_CONTACT: "' + pair.pair + '": SKIP — ' + (pair.unread ?? line.reason ?? 'no sampled animation'));
|
|
5106
|
+
return lines;
|
|
5107
|
+
}
|
|
5108
|
+
|
|
5084
5109
|
/** One warning as the console prints it (issue #493's form, the floor's clause after it). */
|
|
5085
5110
|
export function motionSeamWarnText(r: MotionSeamRow): string {
|
|
5086
5111
|
const pose = r.pose_at_worst;
|
package/src/depthmaps.ts
ADDED
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/** Report core's material connectivity; no count here participates in a bar. */
|
|
2
|
+
import { PartsError, type Problem } from './errors.ts';
|
|
3
|
+
|
|
4
|
+
export interface JointContactReading {
|
|
5
|
+
status: 'REPORTED' | 'SKIP';
|
|
6
|
+
joints: string[];
|
|
7
|
+
joined?: number;
|
|
8
|
+
separated?: number;
|
|
9
|
+
unavailable?: number;
|
|
10
|
+
reasons: Array<{ reason: string; frames?: number[] }>;
|
|
11
|
+
reason?: string;
|
|
12
|
+
}
|
|
13
|
+
const record = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
14
|
+
|
|
15
|
+
/** Count only producer observations; invalid input adds a problem and yields no reading when a collector is supplied. */
|
|
16
|
+
export function jointContactReading(raw: unknown, joints: readonly string[], sampled: unknown, object: string, problems?: Problem[]): JointContactReading | null {
|
|
17
|
+
const names = [...new Set(joints)];
|
|
18
|
+
const fail = (detail: string): null => {
|
|
19
|
+
const problem = { code: 'FRAMES_SIDECAR', object, detail: 'jointContact: ' + detail };
|
|
20
|
+
if (problems === undefined) throw new PartsError([problem]);
|
|
21
|
+
problems.push(problem);
|
|
22
|
+
return null;
|
|
23
|
+
};
|
|
24
|
+
const knownCount = typeof sampled === 'number' && Number.isSafeInteger(sampled) && sampled >= 0 ? sampled : undefined;
|
|
25
|
+
if (raw === undefined) {
|
|
26
|
+
const reason = names.length === 0 ? 'pair has no declared named joint' : 'core seam reading has no jointContact.frames';
|
|
27
|
+
return knownCount === undefined || knownCount === 0
|
|
28
|
+
? { status: 'SKIP', joints: names, reasons: [], reason: knownCount === 0 ? 'no sampled frames' : 'sample count unavailable: ' + reason }
|
|
29
|
+
: { status: 'REPORTED', joints: names, joined: 0, separated: 0, unavailable: knownCount, reasons: [{ reason }] };
|
|
30
|
+
}
|
|
31
|
+
if (!record(raw) || !Array.isArray(raw.frames)) return fail('requires { frames: [...] }');
|
|
32
|
+
if (knownCount !== undefined && raw.frames.length !== knownCount) return fail('has ' + raw.frames.length + ' frame(s); frames.json records ' + knownCount);
|
|
33
|
+
const frames: Array<{ index: number; time: number; status: 'joined' | 'separated' | 'unavailable'; reason: string | null }> = [];
|
|
34
|
+
for (const f of raw.frames) {
|
|
35
|
+
if (!record(f) || typeof f.index !== 'number' || !Number.isSafeInteger(f.index) || f.index < 0 ||
|
|
36
|
+
typeof f.time !== 'number' || !Number.isFinite(f.time) || f.time < 0 ||
|
|
37
|
+
(f.status !== 'joined' && f.status !== 'separated' && f.status !== 'unavailable') ||
|
|
38
|
+
(f.status === 'unavailable' ? typeof f.reason !== 'string' || f.reason.trim() === '' : f.reason !== null)) return fail('requires nonnegative index/time, joined/separated with null reason, or unavailable with a nonempty reason');
|
|
39
|
+
const previous = frames.at(-1);
|
|
40
|
+
if (previous !== undefined && (f.index <= previous.index || f.time <= previous.time)) return fail('frame indices and times must increase strictly');
|
|
41
|
+
frames.push({ index: f.index, time: f.time, status: f.status, reason: f.reason as string | null });
|
|
42
|
+
}
|
|
43
|
+
if (frames.length === 0) return { status: 'SKIP', joints: names, reasons: [], reason: 'no sampled frames' };
|
|
44
|
+
const out: JointContactReading = { status: 'REPORTED', joints: names, joined: 0, separated: 0, unavailable: 0, reasons: [] };
|
|
45
|
+
for (const f of frames) {
|
|
46
|
+
const status = names.length === 0 ? 'unavailable' : f.status;
|
|
47
|
+
const reason = names.length === 0 ? 'pair has no declared named joint' : f.reason;
|
|
48
|
+
if (status === 'joined') out.joined = (out.joined ?? 0) + 1;
|
|
49
|
+
else if (status === 'separated') out.separated = (out.separated ?? 0) + 1;
|
|
50
|
+
else {
|
|
51
|
+
out.unavailable = (out.unavailable ?? 0) + 1;
|
|
52
|
+
const why = reason as string;
|
|
53
|
+
let hit = out.reasons.find(r => r.reason === why);
|
|
54
|
+
if (hit === undefined) { hit = { reason: why, frames: [] }; out.reasons.push(hit); }
|
|
55
|
+
hit.frames?.push(f.index);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|