rig-parts 1.84.0 → 1.85.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 +5 -2
- package/docs/AUTHORING.md +154 -0
- package/docs/STABILITY.md +25 -1
- package/package.json +1 -1
- package/src/assemble.ts +42 -3
- package/src/build.ts +16 -1
- package/src/check.ts +54 -4
- package/src/cutmaterial.ts +285 -0
- package/src/cutmaterialio.ts +63 -0
- package/src/requirements.ts +10 -2
- package/src/setuppainting.ts +126 -0
package/cli.ts
CHANGED
|
@@ -723,6 +723,8 @@ usage:
|
|
|
723
723
|
rig-parts assemble --source <painting.png> --full <dir|psd> [--head <dir|psd>]
|
|
724
724
|
--config <config.json> --out <dir> [--seam near-white|silhouette]
|
|
725
725
|
[--project core|visible] [--alt <name>=<source>:<full>[:<head>]]
|
|
726
|
+
[--requirements <file.json>]
|
|
727
|
+
With cut_material declared, records original source edges bound to emitted parts for check.
|
|
726
728
|
Merge the full-body and head-crop See-through runs into rig-space parts:
|
|
727
729
|
<out>/rig/parts/<name>.png (each cropped to its alpha box), <out>/rig/parts.json,
|
|
728
730
|
<out>/render/recomposite_rig.png and <out>/render/${ERROR_MAP_FILE} (the error
|
|
@@ -1082,7 +1084,7 @@ const TAKES = {
|
|
|
1082
1084
|
compare: { valued: ['--left', '--right', '--map'], switches: [] },
|
|
1083
1085
|
check: { valued: ['--rig', '--out', '--parts', '--source', '--page-edges', '--pack-shape', '--requirements', '--set', '--environment', '--wind-band'], switches: ['--seam-pairs', '--keep-stills', '--all-sets', '--no-extremes'] },
|
|
1084
1086
|
loop: { valued: ['--frames', '--out'], switches: ['--palette'] },
|
|
1085
|
-
assemble: { valued: ['--source', '--full', '--head', '--config', '--out', '--seam', '--project', '--alt', '--write-config'], switches: ['--propose-plan'] },
|
|
1087
|
+
assemble: { valued: ['--source', '--full', '--head', '--config', '--out', '--seam', '--project', '--alt', '--write-config', '--requirements'], switches: ['--propose-plan'] },
|
|
1086
1088
|
'comfy seethrough': { valued: ['--image', '--out', '--resolution', '--steps', '--seed', '--prefix', '--host', '--wait', '--timeout', '--poll', '--config', '--depth-run'], switches: ['--offload', '--no-offload', '--lama', '--nf4'] },
|
|
1087
1089
|
'comfy paint': { valued: ['--config', '--out', '--seeds', '--seed0', '--host', '--wait', '--timeout', '--poll'], switches: [] },
|
|
1088
1090
|
inputs: { valued: ['--source', '--config', '--out'], switches: [] },
|
|
@@ -1707,6 +1709,7 @@ function cmdAssemble(args: string[]): number {
|
|
|
1707
1709
|
}
|
|
1708
1710
|
// --head is optional (issue #359): without it the full run is the one run, and it must hold the face.
|
|
1709
1711
|
for (const f of ['--source', '--full', '--config']) if (!flags.has(f)) return usage(`assemble needs ${f}`);
|
|
1712
|
+
if (propose && flags.has('--requirements')) return usage('--requirements belongs to assemble output, not --propose-plan');
|
|
1710
1713
|
if (propose && (flags.has('--out') || flags.has('--seam') || flags.has('--project') || alts.length > 0)) return usage('--propose-plan prints to the console; it takes none of --out, --seam, --project, --alt');
|
|
1711
1714
|
if (!propose && flags.has('--write-config')) return usage('--write-config belongs to assemble --propose-plan');
|
|
1712
1715
|
if (!propose && !flags.has('--out')) return usage('assemble needs --out <dir>');
|
|
@@ -1739,7 +1742,7 @@ function cmdAssemble(args: string[]): number {
|
|
|
1739
1742
|
}
|
|
1740
1743
|
const out = flags.get('--out') as string;
|
|
1741
1744
|
assembleStage(
|
|
1742
|
-
{ source, full, head, config, seam: seam as SeamRule, project: project as ProjectRule, ...(alts.length === 0 ? {} : { alts }) },
|
|
1745
|
+
{ source, full, head, config, ...(flags.has('--requirements') ? { requirements: flags.get('--requirements') as string } : {}), seam: seam as SeamRule, project: project as ProjectRule, ...(alts.length === 0 ? {} : { alts }) },
|
|
1743
1746
|
{ partsJson: join(out, 'rig', 'parts.json'), partsDir: join(out, 'rig', 'parts'), recomposite: join(out, 'render', 'recomposite_rig.png'), errorMap: join(out, 'render', ERROR_MAP_FILE) },
|
|
1744
1747
|
console.log,
|
|
1745
1748
|
);
|
package/docs/AUTHORING.md
CHANGED
|
@@ -4067,6 +4067,93 @@ consumer acceptance work; assembly provenance does not establish motion quality.
|
|
|
4067
4067
|
|
|
4068
4068
|
<a id="source-before-scissors-issue-819"></a>
|
|
4069
4069
|
|
|
4070
|
+
A hand polygon ending at a joint supplies no material for that joint's turn
|
|
4071
|
+
(issue #888). An `overlap` keeps pixels on the **parent**; it does not grow
|
|
4072
|
+
the cut piece. No source for material past the joint means stop and name the
|
|
4073
|
+
tool gap, following the source ladder below; do not hide a tear with crops or
|
|
4074
|
+
under-pieces. With an explicit `cut_material` declaration, `assemble` prints
|
|
4075
|
+
each original polygon edge's length through
|
|
4076
|
+
opaque source art (`alpha > 8`) and contiguous material past it on the piece.
|
|
4077
|
+
The same reading covers a limb's original structural joint line and the
|
|
4078
|
+
lower segment together with its generated hybrid band, when present.
|
|
4079
|
+
Assembly readings are SKIP-class observations; only check judges the declared bend.
|
|
4080
|
+
Without a declaration the API reading is SKIP and the CLI keeps its existing output.
|
|
4081
|
+
Limb source intersections use the final segmentation ownership map separately
|
|
4082
|
+
for each adjacent source; another limb's island is excluded before capture.
|
|
4083
|
+
|
|
4084
|
+
To enforce a bend, add `cut_material` to the existing
|
|
4085
|
+
`spine-parts-requirements/1` file (the other fields remain required):
|
|
4086
|
+
|
|
4087
|
+
```json
|
|
4088
|
+
{
|
|
4089
|
+
"spec": "spine-parts-requirements/1", "fps": 12, "requirements": [],
|
|
4090
|
+
"cut_material": [{
|
|
4091
|
+
"part": "hand", "joint": "wrist", "at": [8, 12], "axis": [0, 1],
|
|
4092
|
+
"reach_px": 3, "bend_degrees": [-30, 30]
|
|
4093
|
+
}]
|
|
4094
|
+
}
|
|
4095
|
+
```
|
|
4096
|
+
|
|
4097
|
+
These are hand-computable fixture coordinates, not recommended rig values.
|
|
4098
|
+
`at` is an independently supplied joint centre in stage pixels (y down);
|
|
4099
|
+
`axis` identifies an original opaque cut inward within reach, checked against
|
|
4100
|
+
the source evidence. Every polygon edge retains its own canonical inward
|
|
4101
|
+
normal; a declaration matching none of the eligible edges is refused.
|
|
4102
|
+
Correspondence accepts exact perpendicularity to the original edge direction
|
|
4103
|
+
(zero dot product), or exact collinearity with its stored canonical inward
|
|
4104
|
+
(zero cross product); the inward cross-product sign must also agree.
|
|
4105
|
+
Positive scalar multiples such as `[1,1]` and `[2,2]` are equivalent; rounded
|
|
4106
|
+
unit components are never compared. Orientation arithmetic must be finite and
|
|
4107
|
+
the direction magnitude representable, otherwise the declaration is refused.
|
|
4108
|
+
`reach_px` is the author's explicit joint reach, and `bend_degrees` is the
|
|
4109
|
+
continuous interval in stage coordinates, positive clockwise, inside
|
|
4110
|
+
`[-180, 180]`. Nothing is inferred from a name or an animation. A limb's joint
|
|
4111
|
+
name and centre must match its recorded lower segment; segment0 and a
|
|
4112
|
+
standalone generated band have no lower structural cut to read.
|
|
4113
|
+
|
|
4114
|
+
Run `assemble --requirements <file>` (or `build --requirements <file>`)
|
|
4115
|
+
first, then `check --requirements <file>` against that assembly. Only an
|
|
4116
|
+
explicit `cut_material` declaration writes `cut_material_sources.json`
|
|
4117
|
+
beside `parts.json`: original edge endpoints, inward direction, opaque
|
|
4118
|
+
source intervals split at pixel-cell boundaries (ordered, nonoverlapping
|
|
4119
|
+
`opaque_t` spans inside `[0,1]` on their actual edge), source labels and
|
|
4120
|
+
transformed RGBA digests including placement and size, bound to the emitted `parts.json` and every base PNG by SHA-256.
|
|
4121
|
+
`check` also verifies the complete source payload digest, the interval census
|
|
4122
|
+
and canonical perpendiculars. Listed source edges must contain positive
|
|
4123
|
+
opaque intervals; an empty array cannot hide an edge beside another one.
|
|
4124
|
+
`check` never reconstructs discarded source pixels or polygons from siblings.
|
|
4125
|
+
Missing or stale evidence is unavailable, not zero support. Assemble again to
|
|
4126
|
+
refresh it. Alternate-set source evidence is not interchangeable with the base
|
|
4127
|
+
assembly; this instrument currently reads the base parts only.
|
|
4128
|
+
|
|
4129
|
+
The gate clips every original opaque edge interval to the declared reach.
|
|
4130
|
+
Let its tangent be `(-ny, nx)` for inward normal `n`. A point relative to the
|
|
4131
|
+
joint has **both** coordinates `(t, d)`, where `d` is the actual cut plane's
|
|
4132
|
+
normal offset. Inverse rotation gives `(t*cos(theta) - d*sin(theta),
|
|
4133
|
+
t*sin(theta) + d*cos(theta))`. Required outward material, measured from the
|
|
4134
|
+
actual cut plane, is `max(0, d*(1-cos(theta)) - t*sin(theta))`; the normal offset
|
|
4135
|
+
is never dropped or projected onto the joint plane.
|
|
4136
|
+
|
|
4137
|
+
The entire actual line segment is tested against every relevant transparent
|
|
4138
|
+
pixel cell's outward normal shadow. Segment/polygon incidence can change only
|
|
4139
|
+
when a rotating endpoint crosses an edge, the rotating source line crosses a
|
|
4140
|
+
vertex, or edges become parallel. All critical angles are solved analytically;
|
|
4141
|
+
interval interiors and boundary angles cover the whole declared continuous
|
|
4142
|
+
range, including a fixed angle. A strict intersection supplies a witness and
|
|
4143
|
+
its contiguous depth, ending at the first transparent cell. Midpoint rays are
|
|
4144
|
+
additional diagnostics, never the coverage proof. No angular sampling rate or
|
|
4145
|
+
calibrated tolerance is used. Controls plant a between-ray hole and translated
|
|
4146
|
+
and oblique cut edges whose inverse rotations are computed by hand.
|
|
4147
|
+
|
|
4148
|
+
`CHECK_CUT_JOINT_MATERIAL` names part, joint, original edge length, length
|
|
4149
|
+
within reach and found/required depth on the same deficient ray. The interval
|
|
4150
|
+
and pixel-cell geometry are measured; subpixel texture filtering, another
|
|
4151
|
+
motion, material outside the declared reach and consumer compositing remain
|
|
4152
|
+
outside this gate. An absent source crossing or unrepresentable arithmetic is
|
|
4153
|
+
`CHECK_CUT_MATERIAL_MEASURED`, never PASS. Without a declaration the API reading
|
|
4154
|
+
is SKIP; the CLI preserves its existing stdout and adds no sidecar, `parts.json` field,
|
|
4155
|
+
`check.json` field or counted bar to an undeclared build.
|
|
4156
|
+
|
|
4070
4157
|
**Scissors are for structure, never for repair.** `cuts`, `patches` and
|
|
4071
4158
|
`author:<label>` layers separate something that must move independently and
|
|
4072
4159
|
that See-through has no layer for: a joint, an accessory, or a rear piece that
|
|
@@ -4934,6 +5021,9 @@ documents do not ship). Each is an input this package built; none is an author's
|
|
|
4934
5021
|
| <a id="CHECK_INPUT_PRESENT"></a><a id="CHECK_INPUT_IS_JSON"></a><a id="CHECK_PART_PNG_PRESENT"></a><a id="CHECK_PART_PNG_MATCHES_BOX"></a><a id="CHECK_PART_SLOT_PRESENT"></a><a id="CHECK_RIG_STAGE_PRESENT"></a><a id="CHECK_RIG_STAGE_IS_THE_CANVAS"></a><a id="CHECK_RIG_ROOT_BONE"></a><a id="CHECK_IDLE_PRESENT"></a>`CHECK_INPUT_PRESENT`, `CHECK_INPUT_IS_JSON`, `CHECK_PART_PNG_PRESENT`, `CHECK_PART_PNG_MATCHES_BOX`, `CHECK_PART_SLOT_PRESENT`, `CHECK_RIG_STAGE_PRESENT`, `CHECK_RIG_STAGE_IS_THE_CANVAS`, `CHECK_RIG_ROOT_BONE`, `CHECK_IDLE_PRESENT` | the rig directory is incomplete or disagrees with `parts.json` (`CHECK_PART_SLOT_PRESENT`: a part with no slot of its own name, which the judgement lines render it by; `CHECK_INPUT_PRESENT` on `--set` / `--all-sets`: no `parts.json`, no `alternates`, or the named set is not in `alternates`). `motion.json` is required even with no animation in it, because `rigc build` takes `--motion` (a spec with `"animations": {}` builds green); `CHECK_IDLE_PRESENT` is an `idle` that is there with no positive `duration` — an absent `idle` is measured around (§7). `CHECK_INPUT_PRESENT` on `…/parts.json`: `--parts` named a directory without `parts.json` — most often `parts/` itself — or, without `--parts`, the rig directory has none while the directory above it does (build's layout, `<out>/rig` under `<out>/parts.json`). The detail says it: "--parts names the directory holding parts.json and parts/, not parts/ itself (after build, that is build's --out, whose rig is <out>/rig); without --parts it is --rig", and names the parent when the parent holds `parts.json`. With neither (no `--parts`, and no `parts.json` beside `rig.json` or above it), the rig is measured without parts and nothing is refused (§7) | re-run rig (`build` does both); for `…/parts.json`, `--parts` — the directory above `parts/` |
|
|
4935
5022
|
| <a id="CHECK_SOURCE_SIZE"></a>`CHECK_SOURCE_SIZE` | the painting `--source` names is not this rig's: no single `rig_scale` takes its width and height to the stage's (assemble makes the canvas `trunc(w × rig_scale)` x `trunc(h × rig_scale)`), or, with `parts.json` read, its `scale_rig_per_source` does not; or the stage is not whole pixels. Refused before anything is built | the painting the rig was made from — the stage's own size, or the painting `assemble` was given |
|
|
4936
5023
|
| <a id="CHECK_SOURCE_PNG"></a>`CHECK_SOURCE_PNG` | `--source` names a file that does not read as a PNG | a PNG |
|
|
5024
|
+
| <a id="CHECK_SETUP_MATCHES_PAINTING"></a>`CHECK_SETUP_MATCHES_PAINTING` | region, slot, failing-pixel box, slot pixelcount, complete region pixelcount and declared limit | compare the source painting beside the actual setup; follow [Source before scissors](#source-before-scissors), then rerun with the same authored limit |
|
|
5025
|
+
| <a id="CHECK_SETUP_PAINTING_SOURCE"></a>`CHECK_SETUP_PAINTING_SOURCE` | a declaration without a source painting | supply `--source`; the assembled composite is not an independent reference |
|
|
5026
|
+
| <a id="CHECK_SETUP_PAINTING_MEASURED"></a>`CHECK_SETUP_PAINTING_MEASURED` | missing native setup/ownership or unsupported clipping | supply observable textured setup geometry; an unavailable instrument never passes |
|
|
4937
5027
|
| <a id="CHECK_SOURCE_GRID"></a>`CHECK_SOURCE_GRID` | the setup-pose still and its black-tinted twin (the coverage `SETUP_POSE_VS_SOURCE` reads) did not come back on one grid over one opaque background | a rigc problem; report it |
|
|
4938
5028
|
| <a id="CHECK_RIGC_VERSION"></a>`CHECK_RIGC_VERSION` | `rigc --version` is below 1.4.0, or prints no version: its `render` has no `--geometry`, which `TEXTURE_STRETCH` reads. Refused before anything is built | `bun install` (this package depends on rig-c ^2.20.4), or put a newer `rigc` first on `PATH` |
|
|
4939
5029
|
| <a id="CHECK_RIGC_ENTRY"></a><a id="CHECK_RIGC_ENTRY_READS"></a>`CHECK_RIGC_ENTRY`, `CHECK_RIGC_ENTRY_READS` | `rigc --version` names no entry (a rigc below 2.0.0), or names one in neither launcher form (`entry: cli.ts — @esotericsoftware/spine-core <v> present`, `entry: cli_core.ts — @esotericsoftware/spine-core absent — …`); `check.json`'s `rigc_entry` records the one that gated the build | `bun install` |
|
|
@@ -4987,6 +5077,8 @@ documents do not ship). Each is an input this package built; none is an author's
|
|
|
4987
5077
|
| <a id="REQUIREMENTS_TARGET"></a>`REQUIREMENTS_TARGET` | a scene target places one bone twice for one animation, places it for an animation no requirement measures, has a key outside the animation's `[0, duration]`, or sits in a group the animation translates | one placement per bone and animation, inside it, for an animation a requirement reads |
|
|
4988
5078
|
| <a id="REQUIREMENTS_SELF_TARGET"></a>`REQUIREMENTS_SELF_TARGET` | a `contact` whose `target` is its own `bone` and `point` (0 apart by construction), or an `aim` whose `target` is a point of the aimed bone itself — its tip lies on its own axis (0 degrees by construction), its origin gives no line at all (issue #540): a PASS would say nothing. Refused before anything is built, beside the file's other refusals; a bone's tip against its own origin is a contact like any other (it reads the bone's length), and a stage target is never one | a different bone (or a stage point, for an aim), or for a contact the bone's other point |
|
|
4989
5079
|
| <a id="CHECK_REQUIREMENTS_FRAMES"></a>`CHECK_REQUIREMENTS_FRAMES` | a requirement render did not write every frame rigc sampled, or the as-declared, released and full renders of a follow sampled different frames | a rigc problem; report it |
|
|
5080
|
+
| <a id="CHECK_CUT_JOINT_MATERIAL"></a>`CHECK_CUT_JOINT_MATERIAL` | A declared `cut_material` source ray within reach needs more depth during its continuous bend interval than its final piece supplies; the message names part, joint, original opaque edge length, found and required material | follow [Source before scissors](#source-before-scissors); obtain source material past the joint, or explicitly declare the consumer's smaller required bend; no crop or under-piece repair |
|
|
5081
|
+
| <a id="CHECK_CUT_MATERIAL_MEASURED"></a>`CHECK_CUT_MATERIAL_MEASURED` | Original source evidence is missing, malformed or stale, a part/joint correspondence is unresolved, the inward axis disagrees, or no opaque crossing lies within reach | rerun `assemble --requirements <file>` or `build --requirements <file>` to bind original source readings to the actual emitted parts; correct an independently declared correspondence/reach; never infer original art from emitted siblings |
|
|
4990
5082
|
| <a id="CHECK_REQUIREMENT_MET"></a>`CHECK_REQUIREMENT_MET` | a declared requirement measured outside its bar; the figure, its frame and the bar are quoted | the rig or its motion (§7, *Declared requirements*) — or the bar, if the scene asks less |
|
|
4991
5083
|
| <a id="CHECK_REQUIREMENT_MEASURABLE"></a>`CHECK_REQUIREMENT_MEASURABLE` | a declared requirement is NOT MEASURABLE: what it asks is undefined in the frames, and the reason is quoted. Not a pass | the declaration (a bone with a length, a target off the bone's origin, a least drive the constraint reaches) or the rig |
|
|
4992
5084
|
| <a id="REQUIREMENTS_MAY_FOLD"></a>`REQUIREMENTS_MAY_FOLD` | a `may_fold` entry names a slot with no mesh attachment — a region is one quad and cannot fold (issue #320). Refused before anything is built. Before issue #333 an entry naming the idle was refused here too; the bar reads the idle now, and `idle` is an animation name like any other | a mesh slot |
|
|
@@ -5549,6 +5641,7 @@ Under `--source <painting.png>`, a second reported line, also never a FAIL
|
|
|
5549
5641
|
| line | reports | bar | `reference-demo` | `reference-sample` | SKIP when |
|
|
5550
5642
|
| --- | --- | --- | --- | --- | --- |
|
|
5551
5643
|
| <a id="SETUP_POSE_VS_SOURCE"></a>`SETUP_POSE_VS_SOURCE` | the setup pose against the painting, by `assemble`'s own `measureRecomposite` over the stage's pixels — `mean_abs` and `within_share` (mean channel, within 8), `error_px` (max channel over 40), `uncovered_error_px` (of those, where the pose has alpha 128 or less), `hole_count` and the largest hole's box — with `painting_px`, `stage_px`, `render_px` and `render_scale` beside them. The painting is resampled onto the stage as assemble resamples it (lanczos3, alpha dropped). rigc renders onto an opaque grey, so the pose over white and its alpha are read off a second still of the same pose with every slot tinted black; both are carried onto the stage with the seam's map turned round (bilinear) | none — status `REPORTED`; assemble applies no bar to the figures it measures, so none is derived here | 3.146 / 93.43 % / 12,399 / 1,732, 518 holes, the largest 61 px at 351,157 6x20 (assemble's recomposite: 2.391 / 95.81 % / 11,050 / 1,564, 453) | 1.095 / 97.30 % / 5,348 / 1,239, 313 holes, the largest 88 px at 479,416 6x29 (assemble's: 0.835 / 97.95 % / 4,512 / 1,185, 259) | not written without `--source` |
|
|
5644
|
+
| <a id="SETUP_MATCHES_PAINTING"></a>`SETUP_MATCHES_PAINTING` | declared region count, native box, limit and actual top contributor rows | PASS/FAIL against authored limits; no limit is SKIP; undeclared regions are not judged |
|
|
5552
5645
|
|
|
5553
5646
|
The figures are assemble's by construction, not a second definition beside them;
|
|
5554
5647
|
what differs is what they are taken of — the pose rigc draws, not the flat stack —
|
|
@@ -6084,8 +6177,69 @@ Unknown fields are refused.
|
|
|
6084
6177
|
|
|
6085
6178
|
| line | measures | bar | SKIP |
|
|
6086
6179
|
| --- | --- | --- | --- |
|
|
6180
|
+
| <a id="CUT_JOINT_MATERIAL"></a>`CUT_JOINT_MATERIAL` | Original source edge intervals versus final opaque pixel-cell material over each explicit `cut_material` joint reach and continuous bend interval; full two-coordinate swept-segment coverage plus contiguous normal support | every required source interval has material through its inverse-rotation sweep; a hole between diagnostic rays also fails | no declaration: API SKIP, unchanged CLI stdout, no JSON key or counted bar; missing/stale source evidence or no crossing within reach: named unavailable refusal |
|
|
6087
6181
|
| <a id="ART_JOINT_DISTANCE"></a>`ART_JOINT_DISTANCE` | Each independently measured art joint versus its explicitly named bone's renderer setup origin, in stage pixels; printed and recorded per joint | distance <= the declared `fixed_skeleton.within_px`, with no rounding slack | no limit declared: every row and the block retain their distances as SKIP, never a pass; no declaration: no line |
|
|
6088
6182
|
|
|
6183
|
+
### Setup against the painting
|
|
6184
|
+
|
|
6185
|
+
`check --source <painting> --requirements <file>` can declare
|
|
6186
|
+
`setup_painting` in the existing `spine-parts-requirements/1` file:
|
|
6187
|
+
|
|
6188
|
+
```json
|
|
6189
|
+
{
|
|
6190
|
+
"spec": "spine-parts-requirements/1",
|
|
6191
|
+
"fps": 12,
|
|
6192
|
+
"requirements": [],
|
|
6193
|
+
"setup_painting": {
|
|
6194
|
+
"rgb_tolerance": 60,
|
|
6195
|
+
"regions": [
|
|
6196
|
+
{ "name": "torso", "box": [10, 20, 40, 60], "max_pixels": 0 }
|
|
6197
|
+
]
|
|
6198
|
+
}
|
|
6199
|
+
}
|
|
6200
|
+
```
|
|
6201
|
+
|
|
6202
|
+
These are authored limits, not defaults or calibrated figures. A region's
|
|
6203
|
+
`box` is `[x, y, width, height]` in the declared stage's native pixel grid,
|
|
6204
|
+
x right and y down, with exclusive far edges. Regions must fit the stage.
|
|
6205
|
+
`rgb_tolerance` is an integer from 0 through 255. Count a pixel once when its
|
|
6206
|
+
maximum absolute RGB-channel difference is **strictly greater** than that
|
|
6207
|
+
value. The region passes when its total count is at most its authored
|
|
6208
|
+
nonnegative integer `max_pixels`. Without that limit the region reports
|
|
6209
|
+
**SKIP**, including a zero-count region. An undeclared region has no verdict.
|
|
6210
|
+
Without `setup_painting`, existing output bytes and rendering remain unchanged.
|
|
6211
|
+
|
|
6212
|
+
The observation is the actual renderer's setup pose, without animation or
|
|
6213
|
+
physics advancement, from the already gated setup candidate. Its viewport is
|
|
6214
|
+
the declared stage, scale 1; no fitted alignment or flat part composite is
|
|
6215
|
+
substituted. Source PNG readers preserve straight RGBA. This optional check
|
|
6216
|
+
first composites the painting over opaque white at its **original source
|
|
6217
|
+
resolution**, rounding each RGB channel to RGBA8, then passes that opaque
|
|
6218
|
+
image through the existing `sourceInRig` Lanczos3 resampling onto the stage.
|
|
6219
|
+
The rendered setup is composited over white on the native stage grid. This
|
|
6220
|
+
ordering excludes hidden RGB from both resampling taps and figure detection;
|
|
6221
|
+
the global `sourceInRig` and legacy checks retain their existing RGB semantics.
|
|
6222
|
+
The figure domain is the union of rendered
|
|
6223
|
+
alpha above zero and the painting's existing `figureSilhouette`: border-median
|
|
6224
|
+
background difference, morphological opening and hole filling. This includes
|
|
6225
|
+
missing output over source figure and extra rendered support. It is not an
|
|
6226
|
+
independent segmentation oracle for arbitrary backgrounds.
|
|
6227
|
+
|
|
6228
|
+
`SETUP_MATCHES_PAINTING` reports each region's box, pixel count and limit.
|
|
6229
|
+
Diagnostic slot rows partition that count and report their failing-pixel box.
|
|
6230
|
+
The slot is the **last actual textured drawing with sampled alpha above zero**
|
|
6231
|
+
at that pixel, measured by rig-c's rasterizer, or explicit `(uncovered)`.
|
|
6232
|
+
For translucent stacks this names the top contributor, not the exclusive
|
|
6233
|
+
material owner or the cause of the difference. Overlapping region declarations
|
|
6234
|
+
are measured independently. Clipped drawings whose ownership cannot be observed
|
|
6235
|
+
are refused rather than attributed by a part's bounding box.
|
|
6236
|
+
|
|
6237
|
+
Invalid limits, repeated names, unknown fields and out-of-stage boxes use the existing `REQUIREMENTS_FIELD` refusal; no value is guessed.
|
|
6238
|
+
|
|
6239
|
+
This optional declaration is independent of motion requirements and
|
|
6240
|
+
`fixed_skeleton`; it does not replace their schemas or validation. The named
|
|
6241
|
+
refusals are in the check/build rule table and the observation in the line table.
|
|
6242
|
+
|
|
6089
6243
|
## 8. What one character costs
|
|
6090
6244
|
|
|
6091
6245
|
| step | reference-sample | reference-demo | source |
|
package/docs/STABILITY.md
CHANGED
|
@@ -54,7 +54,7 @@ defaults are the help's too, and none is guessed from the art.
|
|
|
54
54
|
| `inputs` | `--source`, `--config`, `--out` |
|
|
55
55
|
| `comfy` | `paint`: `--config`, `--out`; optional `--seeds`, `--seed0`, `--host`, `--wait`, `--timeout`, `--poll` |
|
|
56
56
|
| `comfy` | `seethrough`: `--image`, `--out`; optional `--resolution`, `--steps`, `--seed`, `--prefix`, `--offload`, `--no-offload`, `--lama`, `--nf4`, `--host`, `--wait`, `--timeout`, `--poll`, `--config` (See-through settings and optional depth block), `--depth-run` (`full` or `head`, required with the block) |
|
|
57
|
-
| `assemble` | `--propose-plan` with `--source`, `--full`, `--config`, optional `--head`, `--write-config` (must name `--config` itself, issue #356); or `--source`, `--full`, `--config`, `--out`, optional `--head` (without it, one run: issue #359), `--seam` (near-white, silhouette), `--project` (core, visible), `--alt` (repeatable `<name>=<source>:<full>[:<head>]`, issue #454) |
|
|
57
|
+
| `assemble` | `--propose-plan` with `--source`, `--full`, `--config`, optional `--head`, `--write-config` (must name `--config` itself, issue #356); or `--source`, `--full`, `--config`, `--out`, optional `--head` (without it, one run: issue #359), `--seam` (near-white, silhouette), `--project` (core, visible), `--alt` (repeatable `<name>=<source>:<full>[:<head>]`, issue #454), `--requirements` (source-bound cut-material evidence only when the file declares `cut_material`, issue #888) |
|
|
58
58
|
| `propose` | `--head-box` with `--full`, `--canvas`; optional `--no-back-hair`, `--write-config` (existing config, issue #356) |
|
|
59
59
|
| `propose` | `--parts`, `--source`, `--out`; optional `--compare` or `--from-config` (one of them), `--keypoints`, `--person` and `--wrist-snap` (each with `--keypoints`), `--full`, `--head` (the turn control's depths; not with `--from-config`), `--grid-rule` (`figure` or `part`, the drafted lattice's spacing; not with `--from-config`), `--cloth` (a config whose `cloth` block is the recipe, issue #364; not with `--from-config`), `--write-config` (existing config for an ordinary proposal, issue #356; a new file with `--cloth`, issue #537; not with `--from-config` — additive: without it nothing `propose` writes changes), `--from` (the config the recipe is changed from, only with `--write-config`, issue #571 — additive: without it nothing `--write-config` writes changes), `--only-missing` (only with `--parts` and `--write-config`, not with `--cloth` or `--from-config`, issue #671 — additive: without it nothing `--write-config` writes changes) |
|
|
60
60
|
| `propose` | `--parts`, `--source`, `--out`, `--full`, `--head`, `--write-config` together: the [neck draft](AUTHORING.md#a-neck-that-moves-on-its-own) for an existing config with `turn.body` and one neck plan row; reassembles under `<out>/rig`, preserves the sampled turn and drafts a rigid neck unit (issue #826) |
|
|
@@ -501,3 +501,27 @@ no role or depth and changes no emitted file bytes.
|
|
|
501
501
|
The unit-depth precedence and its primary-surface sampling are documented in
|
|
502
502
|
[AUTHORING](AUTHORING.md#unit-depth-precedence).
|
|
503
503
|
The separate ordering scalar is unchanged. No-image outputs retain legacy bytes.
|
|
504
|
+
|
|
505
|
+
### Declared setup-painting regions
|
|
506
|
+
|
|
507
|
+
The optional `setup_painting` key in `spine-parts-requirements/1` adds a
|
|
508
|
+
`SETUP_MATCHES_PAINTING` observation only when declared. It requires authored
|
|
509
|
+
`rgb_tolerance` and named native-stage boxes. Each optional `max_pixels` is
|
|
510
|
+
that whole region's limit; omission is SKIP, never PASS. Existing files without
|
|
511
|
+
the key retain their output bytes and renderer calls. Other requirement fields
|
|
512
|
+
remain independent. The [authoring contract](AUTHORING.md#setup-against-the-painting)
|
|
513
|
+
defines white compositing, the figure domain, strict RGB comparison, top
|
|
514
|
+
contributor attribution and named measurement refusals. The check observes the
|
|
515
|
+
actual renderer setup at scale 1; it neither fits the art nor duplicates core
|
|
516
|
+
validation. Only this declared check composites source RGBA over white at its
|
|
517
|
+
original resolution **before** existing Lanczos3 stage resampling. Setup RGBA
|
|
518
|
+
is composited on the native stage grid. Source readers preserve alpha; the
|
|
519
|
+
global RGB-only source helper and checks without this declaration are unchanged.
|
|
520
|
+
|
|
521
|
+
The optional `cut_material` block in `--requirements` declares a part, joint,
|
|
522
|
+
independent stage-pixel centre, inward axis, reach and continuous bend interval.
|
|
523
|
+
`assemble --requirements` and `build --requirements` write source-edge evidence
|
|
524
|
+
only for that explicit block; `check --requirements` refuses unavailable or
|
|
525
|
+
stale evidence rather than reconstructing original art from emitted siblings.
|
|
526
|
+
Without the block the API reading is SKIP; CLI stdout and emitted bytes remain unchanged.
|
|
527
|
+
See [Source before scissors](AUTHORING.md#source-before-scissors).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rig-parts",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.85.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": {
|
package/src/assemble.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { edgeLength } from './length.ts';
|
|
2
|
+
import { materialSource, type MaterialSource, materialRows, polygonMaterialEdges, type MaterialRow, type MaterialEdge, type PlacedArt } from './cutmaterial.ts';
|
|
1
3
|
import { unionTakenSources } from './takesource.ts';
|
|
2
4
|
import { authoredUnitDepths, type AuthoredUnitDepth } from './authoredunit.ts';
|
|
3
5
|
import { paintingExtensionMasks, paintingExtensionProblems, type PaintingExtension } from './paintingextension.ts';
|
|
@@ -3934,10 +3936,10 @@ function cutLimbs(
|
|
|
3934
3936
|
readHidden: (layer: string, by: string) => void = () => undefined,
|
|
3935
3937
|
tips?: Record<string, [number, number]>,
|
|
3936
3938
|
sourceGroups: ReadonlyMap<string, string> = new Map(),
|
|
3937
|
-
): { parts: LimbPart[]; pieces: Built[]; counts: LimbCount[]; capFrom: Array<CapFrom | null
|
|
3939
|
+
): { parts: LimbPart[]; pieces: Built[]; counts: LimbCount[]; capFrom: Array<CapFrom | null>; material: MaterialRow[]; sources: MaterialSource[] } {
|
|
3938
3940
|
const logical = (source: string): string => sourceGroups.get(source) ?? source;
|
|
3939
3941
|
const names = Object.keys(limbs);
|
|
3940
|
-
if (names.length === 0) return { parts: [], pieces: [], counts: [], capFrom: [] };
|
|
3942
|
+
if (names.length === 0) return { parts: [], pieces: [], counts: [], capFrom: [], material: [], sources: [] };
|
|
3941
3943
|
const problems: Problem[] = [];
|
|
3942
3944
|
const places = bonePlaces(bones, tips);
|
|
3943
3945
|
const parts: LimbPart[] = [];
|
|
@@ -4413,7 +4415,30 @@ function cutLimbs(
|
|
|
4413
4415
|
});
|
|
4414
4416
|
}
|
|
4415
4417
|
refuseIfAny(problems);
|
|
4416
|
-
|
|
4418
|
+
const material: MaterialRow[] = [];
|
|
4419
|
+
const sourceReadings: MaterialSource[] = [];
|
|
4420
|
+
for (const l of names) limbs[l].segments.forEach((sg, k) => {
|
|
4421
|
+
if (k === 0) return;
|
|
4422
|
+
const line = (lines.get(l) as Array<JointLine | null>)[k];
|
|
4423
|
+
if (line === null) return;
|
|
4424
|
+
const lo = parts.findIndex(p => p.part === sg.part);
|
|
4425
|
+
const source = [...new Set([limbs[l].segments[k - 1].from, sg.from])].flatMap(name => {
|
|
4426
|
+
const b = builtOf(name);
|
|
4427
|
+
if (b === null) return [];
|
|
4428
|
+
// Match segmentation and cap source ownership, after crossing/hand reassignment.
|
|
4429
|
+
// Evidence must never borrow another limb's island or mutate its source raster.
|
|
4430
|
+
const image = { ...b.r, data: b.r.data.slice() };
|
|
4431
|
+
const owned = owners.get(name), li = names.indexOf(l);
|
|
4432
|
+
for (let p = 0; p < W * H; p++) if (owned?.[p] !== li) image.data.fill(0, p * 4, p * 4 + 4);
|
|
4433
|
+
return [{ x: 0, y: 0, image }];
|
|
4434
|
+
});
|
|
4435
|
+
const own = [lo, ...(sg.hybrid === undefined ? [] : [parts.findIndex(p => p.part === sg.hybrid?.part)])].filter(i => i >= 0).map(i => ({ x: 0, y: 0, image: (pieces[i] as Built).r }));
|
|
4436
|
+
const t: [number, number] = [-line.axis[1], line.axis[0]], r = edgeLength(W, H);
|
|
4437
|
+
const edge: MaterialEdge = { a: [line.at[0] - r * t[0], line.at[1] - r * t[1]], b: [line.at[0] + r * t[0], line.at[1] + r * t[1]], inward: line.axis };
|
|
4438
|
+
sourceReadings.push(materialSource(sg.part, [...new Set([limbs[l].segments[k - 1].from, sg.from])], [edge], source));
|
|
4439
|
+
material.push(...materialRows(sg.part, undefined, [edge], source, own).map(row => ({ ...row, joint: String(sg.joint) })));
|
|
4440
|
+
});
|
|
4441
|
+
return { parts, pieces: pieces as Built[], counts, capFrom, material, sources: sourceReadings };
|
|
4417
4442
|
}
|
|
4418
4443
|
|
|
4419
4444
|
// ---------------------------------------------------------------------------
|
|
@@ -4470,6 +4495,9 @@ export interface AssembleResult {
|
|
|
4470
4495
|
figures: RecompositeFigures;
|
|
4471
4496
|
/** What each `assemble.cuts` entry took, counted when it was made (`cutLines`); empty without cuts. */
|
|
4472
4497
|
cuts: CutCount[];
|
|
4498
|
+
/** Console-only source-edge support (#888); never serialized into parts.json. */
|
|
4499
|
+
cutMaterial: MaterialRow[];
|
|
4500
|
+
materialSources: MaterialSource[];
|
|
4473
4501
|
/** What each `config.limbs` limb cut, per part it took and per joint (`limbLines`); empty without limbs (issue #349). */
|
|
4474
4502
|
limbs: LimbCount[];
|
|
4475
4503
|
/** The full-run layers the plan does not take from the full run whose uncovered error holds a body (`uncoveredErrorLayers`); empty otherwise. */
|
|
@@ -5016,11 +5044,14 @@ export function assemble(input: AssembleInput): AssembleResult {
|
|
|
5016
5044
|
const mergedIn = (b: Built, region: Mask): number => count(b.trace.lastSel ?? null, region) + count(b.trace.lastRing ?? null, region);
|
|
5017
5045
|
const pieces: Array<Built | null> = cuts.map(() => null);
|
|
5018
5046
|
const cutCounts: CutCount[] = [];
|
|
5047
|
+
const cutMaterial: MaterialRow[] = [];
|
|
5048
|
+
const materialSources: MaterialSource[] = [];
|
|
5019
5049
|
plan.forEach(([name], pi) => {
|
|
5020
5050
|
const mine = cuts.map((q, i) => i).filter((i) => cuts[i].from === name);
|
|
5021
5051
|
const b = built[pi];
|
|
5022
5052
|
if (mine.length === 0 || b === null) return;
|
|
5023
5053
|
const before = opaqueOf(b.r);
|
|
5054
|
+
const sourceBeforeCuts: PlacedArt = { x: 0, y: 0, image: { ...b.r, data: b.r.data.slice() } };
|
|
5024
5055
|
const rest = newMask(W, H);
|
|
5025
5056
|
rest.data.fill(1);
|
|
5026
5057
|
// The base's own pixels: `rest`, and every cut's band, which it keeps as well as the piece.
|
|
@@ -5120,6 +5151,8 @@ export function assemble(input: AssembleInput): AssembleResult {
|
|
|
5120
5151
|
if (cuts[i].under_fill === 'colour') c.underFill = 'colour';
|
|
5121
5152
|
}
|
|
5122
5153
|
cutCounts.push(c);
|
|
5154
|
+
const piece = pieces[i];
|
|
5155
|
+
if (piece !== null) { materialSources.push(materialSource(cuts[i].into, [b.key], polygonMaterialEdges(cuts[i].polygon), [sourceBeforeCuts])); cutMaterial.push(...materialRows(cuts[i].into, undefined, polygonMaterialEdges(cuts[i].polygon), [sourceBeforeCuts], [{ x: 0, y: 0, image: piece.r }])); }
|
|
5123
5156
|
});
|
|
5124
5157
|
});
|
|
5125
5158
|
// Parent partition counts and under use the removed parent art above. Only
|
|
@@ -5131,6 +5164,10 @@ export function assemble(input: AssembleInput): AssembleResult {
|
|
|
5131
5164
|
if (opaqueOf(r) === 0) empty.push({ code: 'ASSEMBLE_CUT_PIXELS', object: `config.assemble.cuts[${i}] (part "${cuts[i].into}")`, detail: `takes 0 opaque pixels from selected ${source.key}; draw its polygon over that layer's art` });
|
|
5132
5165
|
const writers = (source.writers as Int32Array).slice();
|
|
5133
5166
|
pieces[i] = { ...source, r, writers, region: m, merged: mergedIn(source, m) };
|
|
5167
|
+
for (let j = materialSources.length - 1; j >= 0; j--) if (materialSources[j].part === cuts[i].into) materialSources.splice(j, 1);
|
|
5168
|
+
materialSources.push(materialSource(cuts[i].into, [source.key], polygonMaterialEdges(cuts[i].polygon), [{ x: 0, y: 0, image: source.r }]));
|
|
5169
|
+
for (let j = cutMaterial.length - 1; j >= 0; j--) if (cutMaterial[j].part === cuts[i].into) cutMaterial.splice(j, 1);
|
|
5170
|
+
cutMaterial.push(...materialRows(cuts[i].into, undefined, polygonMaterialEdges(cuts[i].polygon), [{ x: 0, y: 0, image: source.r }], [{ x: 0, y: 0, image: r }]));
|
|
5134
5171
|
});
|
|
5135
5172
|
refuseIfAny(empty);
|
|
5136
5173
|
// In cut order, as the config lists them.
|
|
@@ -5638,6 +5675,8 @@ export function assemble(input: AssembleInput): AssembleResult {
|
|
|
5638
5675
|
figure: inside === null ? null : figureImage(inside, W, H),
|
|
5639
5676
|
figures,
|
|
5640
5677
|
cuts: cutCounts,
|
|
5678
|
+
cutMaterial: [...cutMaterial, ...lim.material],
|
|
5679
|
+
materialSources: [...materialSources, ...lim.sources],
|
|
5641
5680
|
limbs: lim.counts,
|
|
5642
5681
|
uncovered,
|
|
5643
5682
|
holeKinds: kinds,
|
package/src/build.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { SETUP_PAINTING_LINE, setupPaintingLines } from './setuppainting.ts';
|
|
2
|
+
import { writeMaterialSources } from './cutmaterialio.ts';
|
|
3
|
+
import { CUT_MATERIAL_LINE, CUT_MATERIAL_SOURCES, materialLines } from './cutmaterial.ts';
|
|
1
4
|
import { ART_JOINT_LINE, artJointLines } from './artjoints.ts';
|
|
2
5
|
import { applyTakenLayers } from './takelayers.ts';
|
|
3
6
|
import { ownershipLines, provenanceLines } from './provenance.ts';
|
|
@@ -154,6 +157,8 @@ export interface AlternateCliInput {
|
|
|
154
157
|
}
|
|
155
158
|
|
|
156
159
|
export interface AssembleStageInput {
|
|
160
|
+
/** Write source-edge evidence only when cut_material is explicitly declared. */
|
|
161
|
+
requirements?: string;
|
|
157
162
|
source: string;
|
|
158
163
|
full: string;
|
|
159
164
|
/** The head run, or null for one run that holds the face (issue #359). */
|
|
@@ -318,6 +323,7 @@ export function frontOrdered<T extends { record: { name: string } }>(items: read
|
|
|
318
323
|
|
|
319
324
|
/** Read, assemble, and write only after every refusal has had its chance. Throws a PartsError on a refusal, having written nothing. */
|
|
320
325
|
export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs, log: Log): AssembleResult {
|
|
326
|
+
const materialAsked = input.requirements === undefined ? false : readRequirements(input.requirements).cut_material !== undefined;
|
|
321
327
|
// The early door: assemble runs before propose has drafted bones, meshes,
|
|
322
328
|
// regions and motion, so it requires only what it reads (issue #20).
|
|
323
329
|
const cfg = loadEarlyConfig(input.config, 'assemble');
|
|
@@ -772,6 +778,8 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
|
|
|
772
778
|
for (const b of altBuilt) {
|
|
773
779
|
for (const { name, image } of b.images) writePng(join(outs.partsDir, `${name}.png`), image);
|
|
774
780
|
}
|
|
781
|
+
if (materialAsked) writeMaterialSources(outs.partsJson, outs.partsDir, result.parts, result.materialSources);
|
|
782
|
+
else rmSync(join(dirname(outs.partsJson), CUT_MATERIAL_SOURCES), { force: true });
|
|
775
783
|
writePng(outs.recomposite, result.recomposite);
|
|
776
784
|
writePng(outs.errorMap, result.errorMap);
|
|
777
785
|
// Issue #242: under config.scene, the figure the recomposite was read inside, beside the recomposite.
|
|
@@ -799,6 +807,7 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
|
|
|
799
807
|
for (const l of authorPartLines(result.parts.parts)) log(l);
|
|
800
808
|
// One line per plan part an assemble.cuts entry cut, before the fringe lines: the cut is made first. None without cuts.
|
|
801
809
|
for (const l of cutLines(result.cuts)) log(l);
|
|
810
|
+
if (materialAsked) for (const line of materialLines({ status: 'SKIP', rows: result.cutMaterial, reason: 'assemble source readings only; check --requirements judges declared joint material' })) log(` ${line}`);
|
|
802
811
|
// Issue #349: one line per part a limb took, and one per joint with its cap's pixels. None without limbs.
|
|
803
812
|
for (const l of limbLines(result.limbs)) log(l);
|
|
804
813
|
for (const l of fringeLines(result.parts.parts)) log(l);
|
|
@@ -1942,8 +1951,12 @@ export function checkStage(input: CheckStageInput, rigc: RigcRunner, bin: string
|
|
|
1942
1951
|
if (vsSource !== undefined) log(` ${judgementLine(SOURCE_LINE, vsSource)}`);
|
|
1943
1952
|
const windBand = fig[WIND_BAND_LINE];
|
|
1944
1953
|
if (windBand !== undefined) for (const l of windBandLines(windBand)) log(` ${l}`);
|
|
1954
|
+
const cutMaterial = fig[CUT_MATERIAL_LINE];
|
|
1955
|
+
if (cutMaterial !== undefined) for (const line of materialLines(cutMaterial)) log(` ${line}`);
|
|
1945
1956
|
const artJoints = fig[ART_JOINT_LINE];
|
|
1946
1957
|
if (artJoints !== undefined) for (const line of artJointLines(artJoints)) log(` ${line}`);
|
|
1958
|
+
const setupPainting = fig[SETUP_PAINTING_LINE];
|
|
1959
|
+
if (setupPainting !== undefined) for (const line of setupPaintingLines(setupPainting)) log(` ${line}`);
|
|
1947
1960
|
if (r.requirements !== null) {
|
|
1948
1961
|
for (const [name, line] of Object.entries(r.requirements.lines)) log(` ${requirementText(name, line)}`);
|
|
1949
1962
|
log(` ${summaryText(r.requirements.summary)}`);
|
|
@@ -2388,6 +2401,8 @@ export function build(input: BuildInput, run: BuildRunners, log: Log): BuildResu
|
|
|
2388
2401
|
const out = input.out;
|
|
2389
2402
|
mkdirSync(out, { recursive: true });
|
|
2390
2403
|
for (const p of BUILD_OWNS) rmSync(join(out, p), { recursive: true, force: true });
|
|
2404
|
+
// Optional source evidence is cleared separately; BUILD_OWNS is the legacy complete output tree.
|
|
2405
|
+
rmSync(join(out, CUT_MATERIAL_SOURCES), { force: true });
|
|
2391
2406
|
// Issue #242: the figure mask is written only under config.scene; one left by an earlier scene build is not this build's.
|
|
2392
2407
|
rmSync(join(out, FIGURE_MASK_FILE), { force: true });
|
|
2393
2408
|
// The idle-keys value is named only when it is not the default, so a build without the flag, or with the default, prints the line it printed before issue #95.
|
|
@@ -2413,7 +2428,7 @@ export function build(input: BuildInput, run: BuildRunners, log: Log): BuildResu
|
|
|
2413
2428
|
// And the steady-wind band spec (issue #590), as check reads it.
|
|
2414
2429
|
if (input.windBand !== undefined) readWindBand(input.windBand);
|
|
2415
2430
|
stages.assemble(
|
|
2416
|
-
{ source: input.source, full: input.full, head: input.head, config: input.config, seam: input.seam, project: input.project, ...(input.alts === undefined ? {} : { alts: input.alts }) },
|
|
2431
|
+
{ source: input.source, full: input.full, head: input.head, config: input.config, seam: input.seam, project: input.project, ...(req === null ? {} : { requirements: req.read }), ...(input.alts === undefined ? {} : { alts: input.alts }) },
|
|
2417
2432
|
{ partsJson: join(out, 'parts.json'), partsDir: join(out, 'parts'), recomposite: join(out, 'recomposite_rig.png'), errorMap: join(out, ERROR_MAP_FILE) },
|
|
2418
2433
|
prefixed('assemble'),
|
|
2419
2434
|
);
|
package/src/check.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { SETUP_PAINTING_LINE, setupPaintingBounds, setupPaintingLine, type SetupPainting, type SetupPaintingLine } from './setuppainting.ts';
|
|
2
|
+
import { readMaterialSources } from './cutmaterialio.ts';
|
|
3
|
+
import { CUT_MATERIAL_LINE, checkJointMaterial, type MaterialLine } from './cutmaterial.ts';
|
|
1
4
|
import { ART_JOINT_LINE, artJointLine, type ArtJointLine } from './artjoints.ts';
|
|
2
5
|
import { baseAnchorMoves, type AnchorRegion } from './baseanchor.ts';
|
|
3
6
|
/**
|
|
@@ -85,7 +88,7 @@ import { createRequire } from 'node:module';
|
|
|
85
88
|
import type { Viewport as RootCoverageViewport } from '@rig-c/core/render-shared';
|
|
86
89
|
import { copyFileSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
87
90
|
import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
88
|
-
import { measureRecomposite, type RecompositeFigures, recompositeRecord, scenePhrase, sourceInRig } from './assemble.ts';
|
|
91
|
+
import { measureRecomposite, type RecompositeFigures, recompositeRecord, scenePhrase, sourceInRig, figureSilhouette } from './assemble.ts';
|
|
89
92
|
import { type BoneTransform, computeExactFrameTransforms, cropToSpineY, normaliseDegrees, toBoneLocal } from './coords.ts';
|
|
90
93
|
import { capBox, inDisc } from './limbs.ts';
|
|
91
94
|
import { motionSeamProblems } from './motionseams.ts';
|
|
@@ -1931,6 +1934,35 @@ function officialRenderer(): OfficialRenderer {
|
|
|
1931
1934
|
return require('@rig-c/core/render-shared') as OfficialRenderer;
|
|
1932
1935
|
}
|
|
1933
1936
|
|
|
1937
|
+
/** Observe the actual setup on the declared stage grid. No frame-zero motion,
|
|
1938
|
+
* fitted alignment, flat recomposite or duplicate core validation. */
|
|
1939
|
+
export function measureSetupPainting(build: string, stage: StageBox, source: Raster, declared: SetupPainting, problems: Problem[]): SetupPaintingLine {
|
|
1940
|
+
const api = officialRenderer();
|
|
1941
|
+
const skeleton = join(build, 'skeleton.json'), atlas = join(build, 'skeleton.atlas');
|
|
1942
|
+
const candidate = api.loadCandidate({ skeletonText: readFileSync(skeleton, 'utf8'), atlasText: readFileSync(atlas, 'utf8'), atlasDir: build, label: 'setup versus painting' }, { skeleton, atlas }, undefined);
|
|
1943
|
+
const pose = api.throughPoser(candidate.choice, p => api.sampleSetupPose(p, { geometry: true, texture: true })).value[0];
|
|
1944
|
+
if (pose === undefined || pose.pieces.some(p => 'source' in p)) {
|
|
1945
|
+
refuseIfAny([{ code: 'CHECK_SETUP_PAINTING_MEASURED', object: SETUP_PAINTING_LINE, detail: 'actual textured setup pieces are unavailable or include clipping; no guessed ownership is substituted' }]);
|
|
1946
|
+
}
|
|
1947
|
+
const W = stage.width, H = stage.height;
|
|
1948
|
+
if (!Number.isSafeInteger(W) || !Number.isSafeInteger(H) || W <= 0 || H <= 0) refuseIfAny([{ code: 'CHECK_SETUP_PAINTING_MEASURED', object: SETUP_PAINTING_LINE, detail: 'requires positive integer stage width/height for a native source-grid observation' }]);
|
|
1949
|
+
const vp = { minX: stage.x, minY: stage.y, maxX: stage.x + W, maxY: stage.y + H, scale: 1, width: W, height: H };
|
|
1950
|
+
const project = api.projector(vp), owners = new Int32Array(W * H).fill(-1);
|
|
1951
|
+
pose.pieces.forEach((piece, i) => api.rasterisePiece(api.pageFor(candidate.pages, piece), piece, project, vp, (x, y, _r, _g, _b, a) => { if (a > 0) owners[y * W + x] = i; }));
|
|
1952
|
+
// sourceInRig intentionally reads opaque RGB, discarding source alpha before
|
|
1953
|
+
// resizing. This optional comparison declares source-over white instead:
|
|
1954
|
+
// flatten at ORIGINAL source resolution first, so hidden RGB cannot reach
|
|
1955
|
+
// Lanczos taps or the figure silhouette. Do not change the legacy helper.
|
|
1956
|
+
const whiteSource = newRaster(source.width, source.height);
|
|
1957
|
+
for (let p = 0; p < source.width * source.height; p++) {
|
|
1958
|
+
for (let c = 0; c < 3; c++) whiteSource.data[p * 4 + c] = Math.round(source.data[p * 4 + c] * source.data[p * 4 + 3] / 255 + 255 * (1 - source.data[p * 4 + 3] / 255));
|
|
1959
|
+
whiteSource.data[p * 4 + 3] = 255;
|
|
1960
|
+
}
|
|
1961
|
+
const painting = sourceInRig(whiteSource, W, H);
|
|
1962
|
+
const setup = api.renderFrame(pose, candidate.pages, vp, [0, 0, 0, 0]);
|
|
1963
|
+
return setupPaintingLine(declared, painting, { width: W, height: H, data: new Uint8ClampedArray(setup.data) }, figureSilhouette(painting).data, owners, pose.pieces.map(p => p.slot), problems);
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1934
1966
|
/** One decoder/poser lifetime per state; retain only finite bounds and identities, never atlas pages or rasters. */
|
|
1935
1967
|
export function seamStateBounds(build: string, state: string): SeamStateBounds {
|
|
1936
1968
|
try {
|
|
@@ -2697,6 +2729,9 @@ export interface CheckFigures {
|
|
|
2697
2729
|
requirements?: RequirementsBlock;
|
|
2698
2730
|
/** Issue #880: only when requirements declares fixed_skeleton. */
|
|
2699
2731
|
[ART_JOINT_LINE]?: ArtJointLine;
|
|
2732
|
+
/** Only when setup_painting is explicitly declared. */
|
|
2733
|
+
[SETUP_PAINTING_LINE]?: SetupPaintingLine;
|
|
2734
|
+
[CUT_MATERIAL_LINE]?: MaterialLine;
|
|
2700
2735
|
PASS: boolean;
|
|
2701
2736
|
}
|
|
2702
2737
|
|
|
@@ -2943,7 +2978,7 @@ export function barsNotMet(fig: Readonly<Record<string, unknown>>): string[] {
|
|
|
2943
2978
|
isRecord(fig.sets) &&
|
|
2944
2979
|
Object.values(fig.sets).some((s) => isRecord(s) && typeof s.seam_mean === 'number' && typeof s.seam_px_over_40 === 'number' && seamFails(s.seam_mean, s.seam_px_over_40, typeof s.seam_frame_scale === 'number' ? s.seam_frame_scale : 1));
|
|
2945
2980
|
if ((typeof fig.seam_mean === 'number' && typeof fig.seam_px_over_40 === 'number' && seamFails(fig.seam_mean, fig.seam_px_over_40, scale)) || setFailed) out.push('seam');
|
|
2946
|
-
for (const n of [...JUDGEMENT_LINES, FOLD_LINE, LEVEL_LINE, STRESS_LINE, LIMB_LINE, SWEEP_LINE, ART_JOINT_LINE]) {
|
|
2981
|
+
for (const n of [...JUDGEMENT_LINES, FOLD_LINE, LEVEL_LINE, STRESS_LINE, LIMB_LINE, SWEEP_LINE, ART_JOINT_LINE, CUT_MATERIAL_LINE]) {
|
|
2947
2982
|
const l = fig[n];
|
|
2948
2983
|
if (typeof l === 'object' && l !== null && (l as { status?: unknown }).status === 'FAIL') out.push(n);
|
|
2949
2984
|
}
|
|
@@ -3052,7 +3087,7 @@ export function checkStatuses(fig: CheckFigures): Record<string, CheckStatus> {
|
|
|
3052
3087
|
const bar = (name: string, measured: boolean): CheckStatus => (!measured ? 'SKIP' : notMet.includes(name) ? 'FAIL' : 'PASS');
|
|
3053
3088
|
const out: Record<string, CheckStatus> = { gate: bar('gate', true), loop: bar('loop', fig.loop_max_diff !== null), seam: bar('seam', fig.seam_mean !== null) };
|
|
3054
3089
|
for (const n of JUDGEMENT_LINES) out[n] = fig[n].status;
|
|
3055
|
-
for (const n of [FOLD_LINE, LEVEL_LINE, STRESS_LINE, LIMB_LINE, SWEEP_LINE, MOTION_SEAM_LINE, CLOTH_LINE, ...REPORTED_LINES, SOURCE_LINE, WIND_BAND_LINE, ART_JOINT_LINE] as const) {
|
|
3090
|
+
for (const n of [FOLD_LINE, LEVEL_LINE, STRESS_LINE, LIMB_LINE, SWEEP_LINE, MOTION_SEAM_LINE, CLOTH_LINE, ...REPORTED_LINES, SOURCE_LINE, WIND_BAND_LINE, ART_JOINT_LINE, CUT_MATERIAL_LINE, SETUP_PAINTING_LINE] as const) {
|
|
3056
3091
|
const l = fig[n];
|
|
3057
3092
|
if (l !== undefined) out[n] = l.status;
|
|
3058
3093
|
}
|
|
@@ -5862,6 +5897,10 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
|
|
|
5862
5897
|
const file = reqFile as RequirementsFile;
|
|
5863
5898
|
const ready = inp;
|
|
5864
5899
|
take(() => resolveRequirements(file, ready.rig, ready.motion, ready.rootBone));
|
|
5900
|
+
if (file.setup_painting !== undefined) take(() => {
|
|
5901
|
+
refuseIfAny(setupPaintingBounds(file.setup_painting as SetupPainting, ready.stage.width, ready.stage.height));
|
|
5902
|
+
if (ready.source === null) refuseIfAny([{ code: 'CHECK_SETUP_PAINTING_SOURCE', object: 'requirements.setup_painting', detail: 'requires --source naming the painting; the assembled composite is not an independent source painting' }]);
|
|
5903
|
+
});
|
|
5865
5904
|
take(() => refuseIfAny(exceptionProblems(file, ready.parts, ready.noParts)));
|
|
5866
5905
|
// A seam's pairs are read off parts.json's art (issue #111); both resolvers' problems are named in one refusal.
|
|
5867
5906
|
if (file.requirements.some((r) => r.kind === 'seam')) {
|
|
@@ -5879,6 +5918,11 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
|
|
|
5879
5918
|
if (env !== null && !(isRecord(inp.motion.animations) && Object.keys(inp.motion.animations).length > 0)) {
|
|
5880
5919
|
throw environmentRefused(env.path, `${inp.motionPath} holds no animation, so check renders no frame a schedule could pose — the setup-pose stills take none; rigc render reads a schedule only over an animation's frames`);
|
|
5881
5920
|
}
|
|
5921
|
+
// Read source support before rendering; no output/sidecar for undeclared builds.
|
|
5922
|
+
if (reqFile?.cut_material !== undefined && (set !== undefined || allSets)) refuseIfAny([{code: 'CHECK_CUT_MATERIAL_MEASURED', object: 'cut_material', detail: 'source evidence binds base assembly only; run check on its base parts without --set/--all-sets; alternate-set coverage requires its own original source instrument'}]);
|
|
5923
|
+
const cutMaterialProblems: Problem[] = [];
|
|
5924
|
+
const cutMaterial = reqFile?.cut_material === undefined ? null : checkJointMaterial(reqFile.cut_material, inp.parts, name => readPng(join(inp.partsDir, `${name}.png`)), cutMaterialProblems, inp.parts === null ? undefined : readMaterialSources(dirname(inp.partsDir), inp.parts));
|
|
5925
|
+
// A measured material failure is a check bar; unavailable data is a refusal.
|
|
5882
5926
|
const rigcEntry = readRigcEntry(requireRigcVersion(rigc));
|
|
5883
5927
|
/**
|
|
5884
5928
|
* Issue #538: a render whose frames a bar or a warning reads, with the physics on — posed under the schedule when
|
|
@@ -6407,6 +6451,7 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
|
|
|
6407
6451
|
const setHints = new Map<string, string>();
|
|
6408
6452
|
const fixedSkeleton = reqFile?.fixed_skeleton;
|
|
6409
6453
|
let artJoints: ArtJointLine | null = null;
|
|
6454
|
+
let setupPainting: SetupPaintingLine | null = null;
|
|
6410
6455
|
if (inp.parts !== null || inp.source !== null || fixedSkeleton !== undefined) {
|
|
6411
6456
|
try {
|
|
6412
6457
|
mkdirSync(stillDir, { recursive: true });
|
|
@@ -6434,6 +6479,7 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
|
|
|
6434
6479
|
const sr = rigc(['render', '--candidate', join(stillDir, 'build'), '--animation', 'still', '--fps', String(STILL_FPS), '--max', String(maxSide), ...(fixedSkeleton === undefined ? [] : ['--geometry']), '--out', join(stillDir, 'render')]);
|
|
6435
6480
|
if (sr.status !== 0) refuseIfAny(rigcFailed('render (the setup-pose still)', sr, sr.out.split('\n').filter((l) => l.includes('FAIL'))));
|
|
6436
6481
|
const stillSet = readFrameSet(join(stillDir, 'render'));
|
|
6482
|
+
if (reqFile?.setup_painting !== undefined && inp.source !== null) setupPainting = measureSetupPainting(join(stillDir, 'build'), inp.stage, inp.source.painting, reqFile.setup_painting, problems);
|
|
6437
6483
|
if (fixedSkeleton !== undefined) {
|
|
6438
6484
|
const geometryPath = join(stillSet.dir, GEOMETRY_FILE);
|
|
6439
6485
|
const geometry = readGeometry(geometryPath, stillSet.written);
|
|
@@ -6636,6 +6682,8 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
|
|
|
6636
6682
|
...(Object.keys(setsFigures).length === 0 ? {} : { sets: setsFigures }),
|
|
6637
6683
|
...(config === undefined ? {} : { config }),
|
|
6638
6684
|
...(artJoints === null ? {} : { [ART_JOINT_LINE]: artJoints }),
|
|
6685
|
+
...(setupPainting === null ? {} : { [SETUP_PAINTING_LINE]: setupPainting }),
|
|
6686
|
+
...(cutMaterial === null ? {} : { [CUT_MATERIAL_LINE]: cutMaterial }),
|
|
6639
6687
|
...(reqBlock === null ? {} : { requirements: reqBlock }),
|
|
6640
6688
|
PASS: false,
|
|
6641
6689
|
};
|
|
@@ -6679,7 +6727,7 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
|
|
|
6679
6727
|
const rank = (q: Problem): number => JUDGEMENT_LINES.findIndex((n) => q.code === `CHECK_${n}`);
|
|
6680
6728
|
problems.sort((a, b) => rank(a) - rank(b));
|
|
6681
6729
|
problems.unshift(...barProblems);
|
|
6682
|
-
problems.push(...foldProblems, ...levelProblems, ...stressProblems, ...limbProblems, ...sweepProblems, ...motionRootProblems, ...reqProblems);
|
|
6730
|
+
problems.push(...cutMaterialProblems, ...foldProblems, ...levelProblems, ...stressProblems, ...limbProblems, ...sweepProblems, ...motionRootProblems, ...reqProblems);
|
|
6683
6731
|
figures.PASS = problems.length === 0;
|
|
6684
6732
|
const publishCheck = (): void => write('check.json', `${JSON.stringify(figures, null, 1)}\n`);
|
|
6685
6733
|
if (motionSeam.worlds === undefined) publishCheck();
|
|
@@ -6699,7 +6747,9 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
|
|
|
6699
6747
|
}
|
|
6700
6748
|
// Issues #320, #333: the fold bar is counted where it is written — on every rig whose motion holds an animation.
|
|
6701
6749
|
if (folds !== null) tally(FOLD_LINE, null);
|
|
6750
|
+
if (cutMaterial !== null) tally(CUT_MATERIAL_LINE, null);
|
|
6702
6751
|
if (artJoints !== null) tally(ART_JOINT_LINE, artJoints.status === 'SKIP' ? (artJoints.reason ?? 'no declared limit') : null);
|
|
6752
|
+
if (setupPainting !== null) tally(SETUP_PAINTING_LINE, setupPainting.status === 'SKIP' ? (setupPainting.reason ?? 'no declared limit') : null);
|
|
6703
6753
|
// Issue #348: the level bar where it is written; a line whose every foot no frame posed says SKIP and is not measured.
|
|
6704
6754
|
if (level !== null) tally(LEVEL_LINE, level.status === 'SKIP' ? (level.reason ?? '') : null);
|
|
6705
6755
|
// Issue #347: the stress bar is counted where it is written — on every rig with a handwear part and an idle.
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { edgeLength } from './length.ts';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
/** Source support at declared joints (#888). Geometry is in stage pixels, y down.
|
|
4
|
+
* No scene, motion, material or threshold is inferred. This measures pixel-cell
|
|
5
|
+
* support on normal rays, not rendered seam visibility or a continuous surface.
|
|
6
|
+
*/
|
|
7
|
+
import { type Raster } from './raster/types.ts';
|
|
8
|
+
import { OPAQUE_ALPHA_ABOVE } from './layers.ts';
|
|
9
|
+
import { refuseIfAny, type Problem } from './errors.ts';
|
|
10
|
+
import type { PartsFile } from './parts.ts';
|
|
11
|
+
export type Point = [number, number];
|
|
12
|
+
export interface JointMaterial {
|
|
13
|
+
part: string;
|
|
14
|
+
joint: string;
|
|
15
|
+
at: Point;
|
|
16
|
+
/** Points into the piece; need not be unit length. */
|
|
17
|
+
axis: Point;
|
|
18
|
+
reach_px: number;
|
|
19
|
+
/** Continuous interval in stage coordinates (positive clockwise). */
|
|
20
|
+
bend_degrees: Point;
|
|
21
|
+
}
|
|
22
|
+
export interface MaterialEdge { a: Point; b: Point; inward: Point; opaque_t?: Array<[number, number]>; opaque_length_px?: number; opaque_intervals?: number }
|
|
23
|
+
export interface MaterialSource { part: string; from: string[]; source_sha256: string[]; edges: MaterialEdge[] }
|
|
24
|
+
export const CUT_MATERIAL_SOURCES = 'cut_material_sources.json';
|
|
25
|
+
export const CUT_MATERIAL_SPEC = 'rig-parts-cut-material-sources/1';
|
|
26
|
+
export interface PlacedArt { x: number; y: number; image: Raster }
|
|
27
|
+
export interface MaterialRow {
|
|
28
|
+
part: string; joint: string; edge: [Point, Point]; edge_length_px: number; within_reach_px: number;
|
|
29
|
+
found_px: number; required_px: number; rays: number;
|
|
30
|
+
status: 'PASS' | 'FAIL' | 'SKIP'; reason?: string;
|
|
31
|
+
}
|
|
32
|
+
export const CUT_MATERIAL_LINE = 'CUT_JOINT_MATERIAL';
|
|
33
|
+
export interface MaterialLine { status: 'PASS' | 'FAIL' | 'SKIP'; rows: MaterialRow[]; reason?: string }
|
|
34
|
+
const obj = (x: unknown): x is Record<string, unknown> => typeof x === 'object' && x !== null && !Array.isArray(x);
|
|
35
|
+
const num = (x: unknown): x is number => typeof x === 'number' && Number.isFinite(x);
|
|
36
|
+
const pair = (x: unknown): x is Point => Array.isArray(x) && x.length === 2 && x.every(num);
|
|
37
|
+
export function jointMaterialProblems(raw: unknown, at: string): Problem[] {
|
|
38
|
+
const out: Problem[] = [];
|
|
39
|
+
const fail = (object: string, detail: string): void => { out.push({code:'REQUIREMENTS_FIELD', object, detail}); };
|
|
40
|
+
if (!Array.isArray(raw) || raw.length === 0) return [{code:'REQUIREMENTS_FIELD',object:at,detail:'requires a nonempty list of {part, joint, at, axis, reach_px, bend_degrees}; omit cut_material when undeclared'}];
|
|
41
|
+
const seen = new Set<string>();
|
|
42
|
+
raw.forEach((r, i) => {
|
|
43
|
+
const where = `${at}[${i}]`;
|
|
44
|
+
if (!obj(r)) { fail(where,'requires an object'); return; }
|
|
45
|
+
for (const k of Object.keys(r)) if (!['part','joint','at','axis','reach_px','bend_degrees'].includes(k)) fail(`${where}.${k}`,'is unknown; no guessed fields are read');
|
|
46
|
+
for (const k of ['part','joint']) if (typeof r[k] !== 'string' || r[k].trim() === '') fail(`${where}.${k}`,'requires a nonempty name');
|
|
47
|
+
for (const k of ['at','axis','bend_degrees']) if (!pair(r[k])) fail(`${where}.${k}`,'requires two finite numbers');
|
|
48
|
+
if (pair(r.axis) && (edgeLength(...r.axis) === 0 || !Number.isFinite(edgeLength(...r.axis)))) fail(`${where}.axis`,'requires a nonzero inward direction');
|
|
49
|
+
if (!num(r.reach_px) || r.reach_px <= 0) fail(`${where}.reach_px`,'requires a finite positive stage-pixel reach');
|
|
50
|
+
if (pair(r.bend_degrees) && (r.bend_degrees[0] > r.bend_degrees[1] || r.bend_degrees[0] < -180 || r.bend_degrees[1] > 180)) fail(`${where}.bend_degrees`,'requires an ordered interval inside [-180,180], in stage coordinates');
|
|
51
|
+
const key = JSON.stringify([r.part,r.joint]);
|
|
52
|
+
if (seen.has(key)) fail(where,'declares this part and joint twice');
|
|
53
|
+
seen.add(key);
|
|
54
|
+
});
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
const point = (a: Point, b: Point, t: number): Point => [a[0]+(b[0]-a[0])*t,a[1]+(b[1]-a[1])*t];
|
|
58
|
+
const unit = (p: Point): Point => { const d = edgeLength(...p); return [p[0]/d,p[1]/d]; };
|
|
59
|
+
const dot = (a: Point, b: Point): number => a[0]*b[0]+a[1]*b[1];
|
|
60
|
+
export function opaqueAt(arts: readonly PlacedArt[], x: number, y: number): boolean {
|
|
61
|
+
return arts.some(a => { const ix = Math.floor(x-a.x), iy = Math.floor(y-a.y); return ix >= 0 && iy >= 0 && ix < a.image.width && iy < a.image.height && a.image.data[(iy*a.image.width+ix)*4+3] > OPAQUE_ALPHA_ABOVE; });
|
|
62
|
+
}
|
|
63
|
+
/** Exact parameter breaks at pixel-cell boundaries, no spatial step or epsilon. */
|
|
64
|
+
function breaks(a: Point, b: Point, arts: readonly PlacedArt[]): number[] {
|
|
65
|
+
const ts = new Set([0,1]);
|
|
66
|
+
for (const r of arts) for (const k of [0,1] as const) {
|
|
67
|
+
const lo = k === 0 ? r.x : r.y, size = k === 0 ? r.image.width : r.image.height;
|
|
68
|
+
if (a[k] === b[k]) continue;
|
|
69
|
+
const start = Math.max(lo,Math.ceil(Math.min(a[k],b[k]))), end = Math.min(lo+size,Math.floor(Math.max(a[k],b[k])));
|
|
70
|
+
for (let v = start; v <= end; v++) { const t = (v-a[k])/(b[k]-a[k]); if (t > 0 && t < 1) ts.add(t); }
|
|
71
|
+
}
|
|
72
|
+
return [...ts].sort((x,y)=>x-y);
|
|
73
|
+
}
|
|
74
|
+
/** On a source pixel boundary the art must exist on both sides of the cut. */
|
|
75
|
+
function across(arts: readonly PlacedArt[], p: Point, n: Point): boolean {
|
|
76
|
+
const cell = (direction: number, k: 0|1): number => Number.isInteger(p[k]) && direction < 0 ? p[k]-0.5 : p[k];
|
|
77
|
+
return opaqueAt(arts,cell(n[0],0),cell(n[1],1)) && opaqueAt(arts,cell(-n[0],0),cell(-n[1],1));
|
|
78
|
+
}
|
|
79
|
+
export function polygonMaterialEdges(poly: readonly Point[]): MaterialEdge[] {
|
|
80
|
+
const area = poly.reduce((s,a,i)=>{ const b=poly[(i+1)%poly.length]; return s+a[0]*b[1]-a[1]*b[0]; },0);
|
|
81
|
+
return poly.flatMap((a,i)=>{const b=poly[(i+1)%poly.length],d=edgeLength(b[0]-a[0],b[1]-a[1]);return d===0?[]:[{a,b,inward:area>0?[-(b[1]-a[1])/d,(b[0]-a[0])/d] as Point:[(b[1]-a[1])/d,-(b[0]-a[0])/d] as Point}];});
|
|
82
|
+
}
|
|
83
|
+
/** Contiguous opaque cells from the joint plane outward. A hole ends support. */
|
|
84
|
+
function support(arts: readonly PlacedArt[], start: Point, n: Point): number {
|
|
85
|
+
const limit = arts.reduce((m,a)=>Math.max(m,edgeLength(a.x-start[0],a.y-start[1])+a.image.width+a.image.height),0);
|
|
86
|
+
if (limit === 0) return 0;
|
|
87
|
+
const end: Point = [start[0]-n[0]*limit,start[1]-n[1]*limit], ts=breaks(start,end,arts);
|
|
88
|
+
for(let i=0;i+1<ts.length;i++){ const p=point(start,end,(ts[i]+ts[i+1])/2);if(!opaqueAt(arts,...p))return ts[i]*limit; }
|
|
89
|
+
return limit;
|
|
90
|
+
}
|
|
91
|
+
/** max outward displacement of t*(-ny,nx), retained as a scalar instrument. */
|
|
92
|
+
export function bendDepth(t: number, degrees: Point): number {
|
|
93
|
+
const angles = [...degrees];
|
|
94
|
+
for (const a of [-90,90]) if (a >= degrees[0] && a <= degrees[1]) angles.push(a);
|
|
95
|
+
return Math.max(0,...angles.map(a=>t*Math.sin(a*Math.PI/180)));
|
|
96
|
+
}
|
|
97
|
+
/** Exact quadrant values avoid inventing support for sin(pi)'s roundoff. */
|
|
98
|
+
function trig(angle: number): [number, number] {
|
|
99
|
+
const q = angle / (Math.PI / 2);
|
|
100
|
+
if (Number.isInteger(q)) {
|
|
101
|
+
const k = ((q % 4) + 4) % 4;
|
|
102
|
+
return [[1,0],[0,1],[-1,0],[0,-1]][k] as [number, number];
|
|
103
|
+
}
|
|
104
|
+
return [Math.cos(angle), Math.sin(angle)];
|
|
105
|
+
}
|
|
106
|
+
/** Clip a convex polygon by a closed half-plane. No geometric tolerance. */
|
|
107
|
+
function clip(poly:Point[],inside:(p:Point)=>number):Point[]{
|
|
108
|
+
const out:Point[]=[];
|
|
109
|
+
for(let i=0;i<poly.length;i++){
|
|
110
|
+
const a=poly[i],b=poly[(i+1)%poly.length],fa=inside(a),fb=inside(b);
|
|
111
|
+
if(fa>=0)out.push(a);
|
|
112
|
+
if((fa<0&&fb>0)||(fa>0&&fb<0))out.push(point(a,b,fa/(fa-fb)));
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
function polygonArea(poly:Point[]):number{return Math.abs(poly.reduce((s,a,i)=>{const b=poly[(i+1)%poly.length];return s+a[0]*b[1]-a[1]*b[0];},0))/2;}
|
|
117
|
+
function convexHull(points:Point[]):Point[]{
|
|
118
|
+
const sorted=[...points].sort((a,b)=>a[0]-b[0]||a[1]-b[1]);
|
|
119
|
+
const turn=(a:Point,b:Point,c:Point):number=>(b[0]-a[0])*(c[1]-a[1])-(b[1]-a[1])*(c[0]-a[0]);
|
|
120
|
+
const half=(ps:Point[]):Point[]=>{const out:Point[]=[];for(const p of ps){while(out.length>=2&&turn(out[out.length-2],out[out.length-1],p)<=0)out.pop();out.push(p);}return out;};
|
|
121
|
+
return [...half(sorted).slice(0,-1),...half(sorted.reverse()).slice(0,-1)];
|
|
122
|
+
}
|
|
123
|
+
/** Empty exterior is four rectangles, so a distant declared joint never
|
|
124
|
+
* turns empty space into an unbounded pixel walk. Interior cells remain exact. */
|
|
125
|
+
function emptyRectangles(at:Point,radius:number,arts:readonly PlacedArt[]):Array<[number,number,number,number]>{
|
|
126
|
+
const x0=Math.floor(at[0]-radius),x1=Math.ceil(at[0]+radius),y0=Math.floor(at[1]-radius),y1=Math.ceil(at[1]+radius);
|
|
127
|
+
if(arts.length===0)return [[x0,y0,x1,y1]];
|
|
128
|
+
const left=Math.max(x0,Math.min(...arts.map(a=>a.x))),right=Math.min(x1,Math.max(...arts.map(a=>a.x+a.image.width))),top=Math.max(y0,Math.min(...arts.map(a=>a.y))),bottom=Math.min(y1,Math.max(...arts.map(a=>a.y+a.image.height)));
|
|
129
|
+
if(left>=right||top>=bottom)return [[x0,y0,x1,y1]];
|
|
130
|
+
const out:Array<[number,number,number,number]>=[];
|
|
131
|
+
for(const rect of [[x0,y0,x1,top],[x0,bottom,x1,y1],[x0,top,left,bottom],[right,top,x1,bottom]] as Array<[number,number,number,number]>)if(rect[0]<rect[2]&&rect[1]<rect[3])out.push(rect);
|
|
132
|
+
for(let y=top;y<bottom;y++)for(let x=left;x<right;x++)if(!opaqueAt(arts,x+0.5,y+0.5))out.push([x,y,x+1,y+1]);
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
/** The inverse rotation of a full point, with no joint-plane projection. */
|
|
136
|
+
function rotated(p:Point,angle:number):Point{const [c,s]=trig(angle);return [p[0]*c+p[1]*s,p[1]*c-p[0]*s];}
|
|
137
|
+
const cross=(a:Point,b:Point):number=>a[0]*b[1]-a[1]*b[0];
|
|
138
|
+
/**
|
|
139
|
+
* Segment-vs-convex-polygon intersection throughout an angle interval. Changes
|
|
140
|
+
* of incidence occur only when an endpoint crosses a polygon edge, the source
|
|
141
|
+
* line crosses a vertex, or an edge becomes parallel. All are sinusoid roots.
|
|
142
|
+
* One angle inside each resulting interval has the same intersection topology.
|
|
143
|
+
*/
|
|
144
|
+
function sweptSegmentWitness(a:Point,b:Point,poly:Point[],degrees:Point):Point|null{
|
|
145
|
+
if(poly.length<3||polygonArea(poly)===0)return null;
|
|
146
|
+
const lo=degrees[0]*Math.PI/180,hi=degrees[1]*Math.PI/180,angles=new Set<number>([lo,hi]);
|
|
147
|
+
const add=(angle:number):void=>{if(angle>=lo&&angle<=hi)angles.add(angle);};
|
|
148
|
+
const roots=(A:number,B:number,C:number):void=>{
|
|
149
|
+
const radius=edgeLength(A,B);if(radius===0||Math.abs(C)>radius)return;
|
|
150
|
+
const phase=Math.atan2(B,A),offset=Math.acos(C/radius);
|
|
151
|
+
for(const k of [-1,0,1]){add(phase+offset+k*2*Math.PI);add(phase-offset+k*2*Math.PI);}
|
|
152
|
+
};
|
|
153
|
+
const delta:Point=[b[0]-a[0],b[1]-a[1]];
|
|
154
|
+
for(let i=0;i<poly.length;i++){
|
|
155
|
+
const p=poly[i],q=poly[(i+1)%poly.length],edge:Point=[q[0]-p[0],q[1]-p[1]],normal:Point=[-edge[1],edge[0]],C=dot(normal,p);
|
|
156
|
+
for(const endpoint of [a,b])roots(dot(normal,endpoint),normal[0]*endpoint[1]-normal[1]*endpoint[0],C);
|
|
157
|
+
roots(cross(delta,p),dot(delta,p),cross(delta,a));
|
|
158
|
+
roots(cross(delta,edge),dot(delta,edge),0);
|
|
159
|
+
}
|
|
160
|
+
const sorted=[...angles].sort((a,b)=>a-b),tests=[...sorted,...sorted.slice(1).map((v,i)=>(v+sorted[i])/2)];
|
|
161
|
+
for(const angle of tests){
|
|
162
|
+
const x=rotated(a,angle),y=rotated(b,angle);let low=0,high=1;
|
|
163
|
+
for(let i=0;i<poly.length;i++){
|
|
164
|
+
const p=poly[i],q=poly[(i+1)%poly.length],edge:Point=[q[0]-p[0],q[1]-p[1]],fa=cross(edge,[x[0]-p[0],x[1]-p[1]]),fb=cross(edge,[y[0]-p[0],y[1]-p[1]]);
|
|
165
|
+
if(fa<0&&fb<0){low=1;high=0;break;}
|
|
166
|
+
if(fa<0)low=Math.max(low,fa/(fa-fb));
|
|
167
|
+
if(fb<0)high=Math.min(high,fa/(fa-fb));
|
|
168
|
+
}
|
|
169
|
+
if(high<=low)continue;
|
|
170
|
+
const witness=point(x,y,(low+high)/2);
|
|
171
|
+
if(poly.every((p,i)=>{const q=poly[(i+1)%poly.length];return cross([q[0]-p[0],q[1]-p[1]],[witness[0]-p[0],witness[1]-p[1]])>0;}))return witness;
|
|
172
|
+
}
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
/** Entire actual edge segment, including normal offset from the joint. */
|
|
176
|
+
function sweptHole(a:Point,b:Point,n:Point,joint:JointMaterial,piece:readonly PlacedArt[]):{found:number;required:number;at:Point}|null{
|
|
177
|
+
const A:Point=[a[0]-joint.at[0],a[1]-joint.at[1]],B:Point=[b[0]-joint.at[0],b[1]-joint.at[1]],offset=dot(n,A),radius=Math.max(edgeLength(...A),edgeLength(...B));
|
|
178
|
+
// A normal ray starts on the actual edge plane, not the joint plane.
|
|
179
|
+
const bound=radius+Math.abs(offset);
|
|
180
|
+
for(const [x,y,right,bottom] of emptyRectangles(joint.at,bound,piece)){
|
|
181
|
+
const cell:Point[]=[[x-joint.at[0],y-joint.at[1]],[right-joint.at[0],y-joint.at[1]],[right-joint.at[0],bottom-joint.at[1]],[x-joint.at[0],bottom-joint.at[1]]];
|
|
182
|
+
const outward=clip(cell,p=>offset-dot(n,p));if(polygonArea(outward)===0)continue;
|
|
183
|
+
const distance=radius+Math.max(...outward.map(p=>edgeLength(...p)));
|
|
184
|
+
const shadow=convexHull([...outward,...outward.map<Point>(p=>[p[0]-n[0]*distance,p[1]-n[1]*distance])]);
|
|
185
|
+
const q=sweptSegmentWitness(A,B,shadow,joint.bend_degrees);if(q===null)continue;
|
|
186
|
+
const required=offset-dot(n,q);if(required<=0)continue;
|
|
187
|
+
const at:Point=[q[0]+joint.at[0],q[1]+joint.at[1]],start:Point=[at[0]+n[0]*required,at[1]+n[1]*required],found=support(piece,start,n);
|
|
188
|
+
if(found<required)return {found,required,at};
|
|
189
|
+
refuseIfAny([{code:'CHECK_CUT_MATERIAL_MEASURED',object:'swept source interval',detail:'a strict transparent-cell witness disagrees with its normal support reading; no PASS is inferred'}]);
|
|
190
|
+
}
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
export function materialRows(part: string, joint: JointMaterial | undefined, edges: readonly MaterialEdge[], source: readonly PlacedArt[], piece: readonly PlacedArt[]): MaterialRow[] {
|
|
194
|
+
return edges.map(edge=>{
|
|
195
|
+
const n=unit(edge.inward), d:Point=[edge.b[0]-edge.a[0],edge.b[1]-edge.a[1]], length=edgeLength(...d);
|
|
196
|
+
let lo=0,hi=1;
|
|
197
|
+
if(joint!==undefined){
|
|
198
|
+
const q:Point=[edge.a[0]-joint.at[0],edge.a[1]-joint.at[1]], A=dot(d,d), B=2*dot(q,d), C=dot(q,q)-joint.reach_px**2, disc=B*B-4*A*C;
|
|
199
|
+
if(A===0||disc<0){lo=1;hi=0;}else{lo=Math.max(0,(-B-Math.sqrt(disc))/(2*A));hi=Math.min(1,(-B+Math.sqrt(disc))/(2*A));}
|
|
200
|
+
}
|
|
201
|
+
if(!Number.isFinite(lo)||!Number.isFinite(hi)||!Number.isFinite(length))refuseIfAny([{code:'CHECK_CUT_MATERIAL_MEASURED',object:`part "${part}"`,detail:'joint/edge/reach arithmetic is not finite; supply representable measured stage coordinates and reach'}]);
|
|
202
|
+
let crossed=0,found=Infinity,required=0,rays=0,fail=false,margin=Infinity;
|
|
203
|
+
const spans=edge.opaque_t ?? opaqueParameters(edge,source);
|
|
204
|
+
const intervals=spans.map(([a,b]):[Point,Point]=>[point(edge.a,edge.b,a),point(edge.a,edge.b,b)]);
|
|
205
|
+
for(const interval of intervals){
|
|
206
|
+
// Preserve the source pixel-cell partition; clipping changes its endpoints only.
|
|
207
|
+
const param=(p:Point):number=>Math.abs(d[0])>=Math.abs(d[1])?(p[0]-edge.a[0])/d[0]:(p[1]-edge.a[1])/d[1];
|
|
208
|
+
const from=Math.max(lo,param(interval[0])),to=Math.min(hi,param(interval[1]));
|
|
209
|
+
if(to<=from)continue;
|
|
210
|
+
if(joint!==undefined){
|
|
211
|
+
const hole=sweptHole(point(edge.a,edge.b,from),point(edge.a,edge.b,to),n,joint,piece);
|
|
212
|
+
if(hole!==null){fail=true;if(hole.found-hole.required<margin){margin=hole.found-hole.required;found=hole.found;required=hole.required;}}
|
|
213
|
+
}
|
|
214
|
+
const p=point(edge.a,edge.b,(from+to)/2);
|
|
215
|
+
crossed+=(to-from)*length;rays++;
|
|
216
|
+
const at=joint?.at??p,tangent:Point=[-n[1],n[0]],t=dot([p[0]-at[0],p[1]-at[1]],tangent);
|
|
217
|
+
const offset=dot([p[0]-at[0],p[1]-at[1]],n);
|
|
218
|
+
const angles=joint===undefined?[0]:[...joint.bend_degrees].map(a=>a*Math.PI/180);
|
|
219
|
+
for(const angle of angles){
|
|
220
|
+
const [cos,sin]=trig(angle),s=t*cos-offset*sin,start:Point=[at[0]+s*tangent[0]+offset*n[0],at[1]+s*tangent[1]+offset*n[1]];
|
|
221
|
+
const r=joint===undefined?0:Math.max(0,offset*(1-cos)-t*sin),f=support(piece,start,n);
|
|
222
|
+
if(!Number.isFinite(f)||!Number.isFinite(r))refuseIfAny([{code:'CHECK_CUT_MATERIAL_MEASURED',object:`part "${part}"`,detail:'material support arithmetic is not finite; no PASS is inferred'}]);
|
|
223
|
+
if(f-r<margin || f-r===margin && r>required){margin=f-r;found=f;required=r;}
|
|
224
|
+
if(f<r)fail=true;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return {part,joint:joint?.joint??'(undeclared)',edge:[edge.a,edge.b],edge_length_px:intervals.reduce((s,[a,b])=>s+edgeLength(b[0]-a[0],b[1]-a[1]),0),within_reach_px:crossed,found_px:found===Infinity?0:found,required_px:required,rays,
|
|
228
|
+
status:joint===undefined||rays===0?'SKIP':fail?'FAIL':'PASS',...(joint===undefined?{reason:'no declared cut_material requirement; measured source support is not a pass'}:rays===0?{reason:'no opaque source crossing within declared joint reach'}:{})};
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
/** Original source intersections, represented as ordered bounded parameters. */
|
|
232
|
+
function opaqueParameters(edge:MaterialEdge,source:readonly PlacedArt[]):Array<[number,number]>{
|
|
233
|
+
const ts=breaks(edge.a,edge.b,source),out:Array<[number,number]>=[];
|
|
234
|
+
for(let i=0;i+1<ts.length;i++)if(across(source,point(edge.a,edge.b,(ts[i]+ts[i+1])/2),edge.inward))out.push([ts[i],ts[i+1]]);
|
|
235
|
+
return out;
|
|
236
|
+
}
|
|
237
|
+
/** Canonical unit perpendicular; the sign is the explicitly captured inward side. */
|
|
238
|
+
export function canonicalInward(a:Point,b:Point,inward:Point):Point{
|
|
239
|
+
const d:Point=[b[0]-a[0],b[1]-a[1]],length=edgeLength(...d),normal:Point=[-d[1]/length,d[0]/length];
|
|
240
|
+
return dot(normal,inward)>0?normal:[-normal[0],-normal[1]];
|
|
241
|
+
}
|
|
242
|
+
export function materialSource(part:string,from:string[],edges:readonly MaterialEdge[],source:readonly PlacedArt[]):MaterialSource{
|
|
243
|
+
return {part,from,source_sha256:source.map(s=>createHash('sha256').update(JSON.stringify([s.x,s.y,s.image.width,s.image.height])).update(s.image.data).digest('hex')),edges:edges.map(e=>{
|
|
244
|
+
const inward=canonicalInward(e.a,e.b,e.inward),opaque_t=opaqueParameters({...e,inward},source),length=edgeLength(e.b[0]-e.a[0],e.b[1]-e.a[1]);
|
|
245
|
+
return {a:e.a,b:e.b,inward,opaque_t,opaque_intervals:opaque_t.length,opaque_length_px:opaque_t.reduce((s,[a,b])=>s+(b-a)*length,0)};
|
|
246
|
+
}).filter(e=>e.opaque_t.length>0)};
|
|
247
|
+
}
|
|
248
|
+
export function checkJointMaterial(declarations: readonly JointMaterial[] | undefined, parts: PartsFile | null, image: (name:string)=>Raster, problems:Problem[],sources?:readonly MaterialSource[]): MaterialLine {
|
|
249
|
+
if(declarations===undefined)return {status:'SKIP',rows:[],reason:'no declared cut_material requirement'};
|
|
250
|
+
refuseIfAny(jointMaterialProblems(declarations,'cut_material'));
|
|
251
|
+
if(parts===null)refuseIfAny([{code:'CHECK_CUT_MATERIAL_MEASURED',object:'cut_material',detail:'requires parts.json and its emitted PNGs; missing art is not zero support'}]);
|
|
252
|
+
const rows:MaterialRow[]=[];
|
|
253
|
+
const records=(parts as PartsFile).parts;
|
|
254
|
+
const art=(name:string):PlacedArt=>{const p=records.find(p=>p.name===name);if(p===undefined)throw new Error(`missing resolved part ${name}`);return {x:p.x,y:p.y,image:image(name)};};
|
|
255
|
+
for(const req of declarations){
|
|
256
|
+
const p=records.find(p=>p.name===req.part),object=`part "${req.part}", joint "${req.joint}"`;
|
|
257
|
+
if(p===undefined||p.cut_from===undefined&&p.limb===undefined)refuseIfAny([{code:'CHECK_CUT_MATERIAL_MEASURED',object,detail:'requires a named cut_from or limb segment provenance; nearest parts and inferred joints are not read'}]);
|
|
258
|
+
const rec=p!,limb=rec.limb;
|
|
259
|
+
const original=sources?.find(s=>s.part===req.part);
|
|
260
|
+
if(original===undefined)refuseIfAny([{code:'CHECK_CUT_MATERIAL_MEASURED',object,detail:'original source edge readings are unavailable; emitted siblings cannot reconstruct discarded/overlapping source art — rerun assemble --requirements <file> (or build --requirements <file>) to write the source-bound sidecar'}]);
|
|
261
|
+
let piece=[art(rec.name)];
|
|
262
|
+
if(limb!==undefined){
|
|
263
|
+
if(limb.segment===0||limb.band!==undefined)refuseIfAny([{code:'CHECK_CUT_MATERIAL_MEASURED',object,detail:'requires a lower limb segment, not segment0 or the generated band'}]);
|
|
264
|
+
const geometry=limb.cap??limb.hybrid;
|
|
265
|
+
if(limb.joint!==req.joint||geometry===undefined||geometry.at[0]!==req.at[0]||geometry.at[1]!==req.at[1])refuseIfAny([{code:'CHECK_CUT_MATERIAL_MEASURED',object,detail:'declared joint name/at must equal the recorded lower joint centre; correct the explicit correspondence'}]);
|
|
266
|
+
if(limb.hybrid!==undefined)piece=[...piece,art(limb.hybrid.part)];
|
|
267
|
+
}
|
|
268
|
+
const candidates=(original as MaterialSource).edges.filter(e=>(e.opaque_t??[]).some(([lo,hi])=>{
|
|
269
|
+
const a=point(e.a,e.b,lo),b=point(e.a,e.b,hi),d:Point=[b[0]-a[0],b[1]-a[1]],q:Point=[req.at[0]-a[0],req.at[1]-a[1]],t=Math.max(0,Math.min(1,dot(q,d)/dot(d,d))),closest=point(a,b,t);return edgeLength(closest[0]-req.at[0],closest[1]-req.at[1])<=req.reach_px;
|
|
270
|
+
}));
|
|
271
|
+
if(candidates.length>0&&!candidates.some(e=>{
|
|
272
|
+
const d:Point=[e.b[0]-e.a[0],e.b[1]-e.a[1]],perpendicular=dot(d,req.axis),side=cross(d,req.axis),captured=cross(d,e.inward),sameCanonical=cross(e.inward,req.axis);
|
|
273
|
+
if(!Number.isFinite(perpendicular)||!Number.isFinite(side)||!Number.isFinite(captured)||!Number.isFinite(sameCanonical))refuseIfAny([{code:'CHECK_CUT_MATERIAL_MEASURED',object,detail:'edge/axis orientation arithmetic is not finite; supply a representable scalar multiple of the measured inward direction'}]);
|
|
274
|
+
return (perpendicular===0||sameCanonical===0)&&side!==0&&Math.sign(side)===Math.sign(captured);
|
|
275
|
+
}))refuseIfAny([{code:'CHECK_CUT_MATERIAL_MEASURED',object,detail:'declared axis matches no original opaque edge inward within reach; correct the explicit edge/joint correspondence'}]);
|
|
276
|
+
const read=materialRows(req.part,req,(original as MaterialSource).edges,[],piece);
|
|
277
|
+
if(read.every(r=>r.rays===0))refuseIfAny([{code:'CHECK_CUT_MATERIAL_MEASURED',object,detail:'no opaque cut interface within declared reach; correct the explicit joint correspondence/reach or supply source material; no PASS is invented'}]);
|
|
278
|
+
for(const r of read.filter(r=>r.rays>0)){
|
|
279
|
+
rows.push(r);
|
|
280
|
+
if(r.status==='FAIL')problems.push({code:'CHECK_CUT_JOINT_MATERIAL',object,detail:`edge [${r.edge.map(p=>p.join(',')).join(' -> ')}], opaque edge length ${r.edge_length_px} px (${r.within_reach_px} px within reach): material found ${r.found_px} px, required ${r.required_px} px by bend [${req.bend_degrees.join(', ')}] degrees within reach ${req.reach_px} px; source art past the joint is required — follow AUTHORING Source before scissors; no crop or under-piece repair`});
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return {status:rows.some(r=>r.status==='FAIL')?'FAIL':'PASS',rows};
|
|
284
|
+
}
|
|
285
|
+
export function materialLines(line:MaterialLine):string[]{return [`${CUT_MATERIAL_LINE}: ${line.status}${line.reason===undefined?'':` — ${line.reason}`}`,...line.rows.map(r=>`${CUT_MATERIAL_LINE}: part "${r.part}", joint "${r.joint}", edge [${r.edge.map(p=>p.join(',')).join(' -> ')}]: ${r.status}; opaque edge length ${r.edge_length_px} px; material found ${r.found_px} px, required ${r.required_px} px (${r.rays} source rays; continuous inverse-rotation arc)`)];}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/** Optional source-bound assembly evidence, absent on undeclared builds. */
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { edgeLength } from './length.ts';
|
|
6
|
+
import { canonicalInward, CUT_MATERIAL_SOURCES, CUT_MATERIAL_SPEC, type MaterialSource, type Point } from './cutmaterial.ts';
|
|
7
|
+
import type { PartsFile } from './parts.ts';
|
|
8
|
+
import { refuseIfAny } from './errors.ts';
|
|
9
|
+
const sha=(data:Uint8Array|string):string=>createHash('sha256').update(data).digest('hex');
|
|
10
|
+
const hash=(path:string):string=>sha(readFileSync(path));
|
|
11
|
+
const record=(v:unknown):v is Record<string,unknown>=>typeof v==='object'&&v!==null&&!Array.isArray(v);
|
|
12
|
+
const digest=(v:unknown):v is string=>typeof v==='string'&&/^[a-f0-9]{64}$/.test(v);
|
|
13
|
+
const point=(v:unknown):v is Point=>Array.isArray(v)&&v.length===2&&v.every(x=>typeof x==='number'&&Number.isFinite(x));
|
|
14
|
+
export function writeMaterialSources(partsPath:string,partsDir:string,parts:PartsFile,sources:readonly MaterialSource[]):void{
|
|
15
|
+
const emitted=sources.filter(s=>parts.parts.some(p=>p.name===s.part));
|
|
16
|
+
writeFileSync(join(dirname(partsPath),CUT_MATERIAL_SOURCES),JSON.stringify({
|
|
17
|
+
spec:CUT_MATERIAL_SPEC,parts_sha256:hash(partsPath),
|
|
18
|
+
images:parts.parts.map(p=>({part:p.name,sha256:hash(join(partsDir,`${p.name}.png`))})),
|
|
19
|
+
sources_sha256:sha(JSON.stringify(emitted)),sources:emitted,
|
|
20
|
+
},null,2)+'\n');
|
|
21
|
+
}
|
|
22
|
+
export function readMaterialSources(home:string,parts:PartsFile):MaterialSource[]{
|
|
23
|
+
const path=join(home,CUT_MATERIAL_SOURCES);
|
|
24
|
+
const fail=(detail:string):never=>{refuseIfAny([{code:'CHECK_CUT_MATERIAL_MEASURED',object:path,detail:`${detail}; rerun assemble --requirements <file> or build --requirements <file>; emitted siblings are not original source evidence`}]);throw new Error('unreachable');};
|
|
25
|
+
if(!existsSync(path))return fail('original source-edge sidecar is unavailable');
|
|
26
|
+
let raw:unknown;
|
|
27
|
+
try{raw=JSON.parse(readFileSync(path,'utf8'));}catch{return fail('source-edge sidecar is not JSON');}
|
|
28
|
+
if(!record(raw)||raw.spec!==CUT_MATERIAL_SPEC||!Array.isArray(raw.images)||!Array.isArray(raw.sources))return fail(`requires ${CUT_MATERIAL_SPEC}, images and sources`);
|
|
29
|
+
if(Object.keys(raw).some(k=>!['spec','parts_sha256','images','sources_sha256','sources'].includes(k)))return fail('source sidecar has an unknown field');
|
|
30
|
+
if(raw.parts_sha256!==hash(join(home,'parts.json')))return fail('source-edge sidecar does not bind these parts.json bytes');
|
|
31
|
+
if(!digest(raw.sources_sha256)||raw.sources_sha256!==sha(JSON.stringify(raw.sources)))return fail('original source-edge payload digest differs; intervals/edges must not be removed or rewritten');
|
|
32
|
+
if(raw.images.length!==parts.parts.length)return fail('image bindings do not cover every emitted base part');
|
|
33
|
+
const seen=new Set<string>();
|
|
34
|
+
for(const binding of raw.images){
|
|
35
|
+
if(!record(binding)||typeof binding.part!=='string'||!digest(binding.sha256)||seen.has(binding.part)||!parts.parts.some(p=>p.name===binding.part))return fail('image bindings require each named base part exactly once and its SHA-256');
|
|
36
|
+
if(Object.keys(binding).some(k=>!['part','sha256'].includes(k)))return fail('image binding has an unknown field');
|
|
37
|
+
seen.add(binding.part);
|
|
38
|
+
const file=join(home,'parts',`${binding.part}.png`);
|
|
39
|
+
if(!existsSync(file)||hash(file)!==binding.sha256)return fail(`source-edge binding for part "${binding.part}" differs from its emitted PNG`);
|
|
40
|
+
}
|
|
41
|
+
const sources=new Set<string>();
|
|
42
|
+
for(const s of raw.sources){
|
|
43
|
+
if(!record(s)||typeof s.part!=='string'||sources.has(s.part)||!parts.parts.some(p=>p.name===s.part)||!Array.isArray(s.from)||s.from.length===0||!s.from.every(f=>typeof f==='string'&&f.length>0)||!Array.isArray(s.source_sha256)||s.source_sha256.length!==s.from.length||!s.source_sha256.every(digest)||!Array.isArray(s.edges))return fail('source readings require unique emitted parts, source labels/digests and edges');
|
|
44
|
+
if(Object.keys(s).some(k=>!['part','from','source_sha256','edges'].includes(k)))return fail('source reading has an unknown field');
|
|
45
|
+
sources.add(s.part);
|
|
46
|
+
for(const edge of s.edges){
|
|
47
|
+
if(!record(edge)||!point(edge.a)||!point(edge.b)||!point(edge.inward)||!Array.isArray(edge.opaque_t))return fail('edge readings require finite a, b, inward and bounded original opaque_t parameters');
|
|
48
|
+
if(Object.keys(edge).some(k=>!['a','b','inward','opaque_t','opaque_intervals','opaque_length_px'].includes(k)))return fail('edge reading has an unknown field; absolute opaque endpoints are not this format');
|
|
49
|
+
const a=edge.a,b=edge.b,n=edge.inward,length=edgeLength(b[0]-a[0],b[1]-a[1]);
|
|
50
|
+
if(!Number.isFinite(length)||length===0||edgeLength(...n)===0)return fail('source edge or its inward direction has zero/unrepresentable length');
|
|
51
|
+
const canonical=canonicalInward(a,b,n);
|
|
52
|
+
if(n[0]!==canonical[0]||n[1]!==canonical[1])return fail('inward must be the canonical unit perpendicular to the actual edge; no oblique inward is supported');
|
|
53
|
+
if(edge.opaque_t.length===0)return fail('listed source edges must have opaque intervals; an empty list may not hide an originally crossed edge');
|
|
54
|
+
let previous=0,total=0;
|
|
55
|
+
for(const span of edge.opaque_t){
|
|
56
|
+
if(!point(span)||span[0]<0||span[1]>1||span[0]>=span[1]||span[0]<previous)return fail('opaque_t intervals must be ordered, nonoverlapping, positive, and inside [0,1] on this actual edge');
|
|
57
|
+
previous=span[1];total+=(span[1]-span[0])*length;
|
|
58
|
+
}
|
|
59
|
+
if(edge.opaque_intervals!==edge.opaque_t.length||edge.opaque_length_px!==total)return fail('opaque interval count/length disagree with the original source-edge census');
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return raw.sources as unknown as MaterialSource[];
|
|
63
|
+
}
|
package/src/requirements.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { setupPaintingProblems, type SetupPainting } from './setuppainting.ts';
|
|
2
|
+
import { jointMaterialProblems, type JointMaterial } from './cutmaterial.ts';
|
|
1
3
|
import { fixedSkeletonProblems, type FixedSkeleton } from './artjoints.ts';
|
|
2
4
|
/**
|
|
3
5
|
* What a scene requires of a rig's motion, declared in a file `check` is given
|
|
@@ -193,6 +195,10 @@ export interface RequirementsFile {
|
|
|
193
195
|
may_fold: MayFold[];
|
|
194
196
|
/** Issue #880: an explicit fixed-skeleton art comparison, absent on other rigs. */
|
|
195
197
|
fixed_skeleton?: FixedSkeleton;
|
|
198
|
+
/** Issue #887: authored source-painting limits over native renderer setup. */
|
|
199
|
+
setup_painting?: SetupPainting;
|
|
200
|
+
/** Explicit source-support declarations (#888), absent otherwise. */
|
|
201
|
+
cut_material?: JointMaterial[];
|
|
196
202
|
}
|
|
197
203
|
|
|
198
204
|
/** One declared fold (issue #320): a slot, a named animation, and why the fold is the art's. */
|
|
@@ -283,7 +289,9 @@ export function readRequirements(path: string): RequirementsFile {
|
|
|
283
289
|
problems.push({ code: 'REQUIREMENTS_SELF_TARGET', object: `${label} field "target"`, detail });
|
|
284
290
|
};
|
|
285
291
|
if (raw.spec !== REQUIREMENTS_SPEC) field(`${at} field "spec"`, `is ${JSON.stringify(raw.spec)}; ${JSON.stringify(REQUIREMENTS_SPEC)} is required`);
|
|
286
|
-
for (const k of Object.keys(raw)) if (!['spec', 'fps', 'targets', 'requirements', ...EXCEPTION_LISTS, 'may_fold', 'fixed_skeleton'].includes(k) && !freeKey(k, raw[k])) field(`${at} field "${k}"`, 'is not a field this format reads (spec, fps, targets, requirements, not_planted, not_swinging, rises, rests, may_fold, fixed_skeleton; note or *_note as a string; x-<name> as a record)');
|
|
292
|
+
for (const k of Object.keys(raw)) if (!['spec', 'fps', 'targets', 'requirements', ...EXCEPTION_LISTS, 'may_fold', 'fixed_skeleton', 'cut_material', 'setup_painting'].includes(k) && !freeKey(k, raw[k])) field(`${at} field "${k}"`, 'is not a field this format reads (spec, fps, targets, requirements, not_planted, not_swinging, rises, rests, may_fold, fixed_skeleton, cut_material, setup_painting; note or *_note as a string; x-<name> as a record)');
|
|
293
|
+
if ('setup_painting' in raw) problems.push(...setupPaintingProblems(raw.setup_painting, `${at} setup_painting`));
|
|
294
|
+
if ('cut_material' in raw) problems.push(...jointMaterialProblems(raw.cut_material, `${at} cut_material`));
|
|
287
295
|
if ('fixed_skeleton' in raw) problems.push(...fixedSkeletonProblems(raw.fixed_skeleton, `${at} fixed_skeleton`));
|
|
288
296
|
let fps = NaN;
|
|
289
297
|
if (!(finite(raw.fps) && raw.fps > 0)) field(`${at} field "fps"`, `is ${JSON.stringify(raw.fps)}; a positive number of frames per second is required — every named animation is sampled at it, and there is no default`);
|
|
@@ -468,7 +476,7 @@ export function readRequirements(path: string): RequirementsFile {
|
|
|
468
476
|
}
|
|
469
477
|
}
|
|
470
478
|
refuseIfAny(problems);
|
|
471
|
-
return { path: at, fps, targets, requirements, not_planted: lists.not_planted, not_swinging: lists.not_swinging, rises: lists.rises, rests: lists.rests, may_fold: mayFold, ...(raw.fixed_skeleton === undefined ? {} : { fixed_skeleton: raw.fixed_skeleton as FixedSkeleton }) };
|
|
479
|
+
return { path: at, fps, targets, requirements, not_planted: lists.not_planted, not_swinging: lists.not_swinging, rises: lists.rises, rests: lists.rests, may_fold: mayFold, ...(raw.fixed_skeleton === undefined ? {} : { fixed_skeleton: raw.fixed_skeleton as FixedSkeleton }), ...(raw.cut_material === undefined ? {} : { cut_material: raw.cut_material as JointMaterial[] }), ...(raw.setup_painting === undefined ? {} : { setup_painting: raw.setup_painting as SetupPainting }) };
|
|
472
480
|
}
|
|
473
481
|
|
|
474
482
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/** Authored source-painting limits over actual renderer setup pixels (#887). */
|
|
2
|
+
import { refuseIfAny, type Problem } from './errors.ts';
|
|
3
|
+
import type { Raster } from './raster/types.ts';
|
|
4
|
+
|
|
5
|
+
export const SETUP_PAINTING_LINE = 'SETUP_MATCHES_PAINTING';
|
|
6
|
+
export interface SetupPaintingRegion {
|
|
7
|
+
name: string;
|
|
8
|
+
/** Stage pixels, x right/y down: [x, y, width, height], exclusive far edge. */
|
|
9
|
+
box: [number, number, number, number];
|
|
10
|
+
/** Maximum count over the entire named region. Absent means SKIP. */
|
|
11
|
+
max_pixels?: number;
|
|
12
|
+
}
|
|
13
|
+
export interface SetupPainting {
|
|
14
|
+
/** A pixel counts when its maximum RGB difference is strictly greater. */
|
|
15
|
+
rgb_tolerance: number;
|
|
16
|
+
regions: SetupPaintingRegion[];
|
|
17
|
+
}
|
|
18
|
+
const object = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
19
|
+
const integer = (v: unknown): v is number => typeof v === 'number' && Number.isSafeInteger(v);
|
|
20
|
+
const free = (k: string, v: unknown): boolean => (k.startsWith('x-') && k.length > 2) || ((k === 'note' || k.endsWith('_note')) && typeof v === 'string');
|
|
21
|
+
|
|
22
|
+
/** Shape only: no default limit, inferred box, art alignment or slot name. */
|
|
23
|
+
export function setupPaintingProblems(value: unknown, at: string): Problem[] {
|
|
24
|
+
const problems: Problem[] = [];
|
|
25
|
+
const fail = (where: string, detail: string): void => { problems.push({ code: 'REQUIREMENTS_FIELD', object: where, detail }); };
|
|
26
|
+
const keys = (v: Record<string, unknown>, allowed: string[], where: string): void => {
|
|
27
|
+
for (const k of Object.keys(v)) if (!allowed.includes(k) && !free(k, v[k])) fail(`${where}.${k}`, `is unknown; fields are ${allowed.join(', ')}`);
|
|
28
|
+
};
|
|
29
|
+
if (!object(value)) return [{ code: 'REQUIREMENTS_FIELD', object: at, detail: 'requires {rgb_tolerance, regions: [{name, box, max_pixels?}]}' }];
|
|
30
|
+
keys(value, ['rgb_tolerance', 'regions'], at);
|
|
31
|
+
if (!integer(value.rgb_tolerance) || value.rgb_tolerance < 0 || value.rgb_tolerance > 255) fail(`${at}.rgb_tolerance`, 'requires an authored integer from 0 through 255; difference strictly above it counts');
|
|
32
|
+
if (!Array.isArray(value.regions) || value.regions.length === 0) fail(`${at}.regions`, 'requires a nonempty list of named source-grid regions');
|
|
33
|
+
else {
|
|
34
|
+
const names = new Set<string>();
|
|
35
|
+
value.regions.forEach((r, i) => {
|
|
36
|
+
const where = `${at}.regions[${i}]`;
|
|
37
|
+
if (!object(r)) { fail(where, 'requires {name, box, max_pixels?}'); return; }
|
|
38
|
+
keys(r, ['name', 'box', 'max_pixels'], where);
|
|
39
|
+
if (typeof r.name !== 'string' || r.name.trim() === '') fail(`${where}.name`, 'requires a nonempty region name');
|
|
40
|
+
else { if (names.has(r.name)) fail(`${where}.name`, `declares region "${r.name}" twice`); names.add(r.name); }
|
|
41
|
+
if (!Array.isArray(r.box) || r.box.length !== 4 || !r.box.every(integer) || r.box[0] < 0 || r.box[1] < 0 || r.box[2] <= 0 || r.box[3] <= 0 || !Number.isSafeInteger(r.box[0] + r.box[2]) || !Number.isSafeInteger(r.box[1] + r.box[3])) fail(`${where}.box`, 'requires nonnegative integer x/y and positive integer width/height in stage pixels, with finite integer far edges');
|
|
42
|
+
if ('max_pixels' in r && (!integer(r.max_pixels) || r.max_pixels < 0)) fail(`${where}.max_pixels`, 'requires a nonnegative authored integer pixel count, or omission for SKIP');
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
return problems;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function setupPaintingBounds(value: SetupPainting, width: number, height: number): Problem[] {
|
|
49
|
+
return value.regions.flatMap(r => r.box[0] + r.box[2] > width || r.box[1] + r.box[3] > height
|
|
50
|
+
? [{ code: 'REQUIREMENTS_FIELD', object: `setup_painting region "${r.name}" box`, detail: `[${r.box.join(', ')}] exceeds the declared ${width}x${height} stage; regions use its native pixel grid, not a fitted render` }] : []);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface SetupPaintingSlot {
|
|
54
|
+
slot: string;
|
|
55
|
+
/** Bounding box of failing pixel centres, in stage-grid pixels. */
|
|
56
|
+
box: [number, number, number, number];
|
|
57
|
+
pixel_count: number;
|
|
58
|
+
}
|
|
59
|
+
export interface SetupPaintingReading extends SetupPaintingRegion {
|
|
60
|
+
status: 'PASS' | 'FAIL' | 'SKIP';
|
|
61
|
+
pixel_count: number;
|
|
62
|
+
limit: number | null;
|
|
63
|
+
slots: SetupPaintingSlot[];
|
|
64
|
+
}
|
|
65
|
+
export interface SetupPaintingLine {
|
|
66
|
+
status: 'PASS' | 'FAIL' | 'SKIP';
|
|
67
|
+
instrument: 'rigc renderer setup textured pieces';
|
|
68
|
+
registration: 'declared stage grid, scale 1, no fitting';
|
|
69
|
+
compositing: 'source-over white, RGBA8';
|
|
70
|
+
figure: 'source figureSilhouette union rendered alpha > 0';
|
|
71
|
+
ownership: 'last actual drawn piece with sampled alpha > 0; uncovered explicit';
|
|
72
|
+
rgb_tolerance: number;
|
|
73
|
+
regions: SetupPaintingReading[];
|
|
74
|
+
reason?: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Pixel metric only. Caller supplies the actual renderer's textured setup and
|
|
78
|
+
* its per-pixel drawn-piece owners on precisely the source grid. RGB under
|
|
79
|
+
* zero alpha is ignored: both observations are source-over white first.
|
|
80
|
+
* Region totals count each pixel once, even with translucent contributors.
|
|
81
|
+
*/
|
|
82
|
+
export function setupPaintingLine(
|
|
83
|
+
declared: SetupPainting, source: Raster, setup: Raster, figure: Uint8Array,
|
|
84
|
+
owners: Int32Array, slots: readonly string[], problems: Problem[],
|
|
85
|
+
): SetupPaintingLine {
|
|
86
|
+
refuseIfAny(setupPaintingProblems(declared, 'setup_painting'));
|
|
87
|
+
refuseIfAny(setupPaintingBounds(declared, source.width, source.height));
|
|
88
|
+
const W = source.width, H = source.height, n = W * H;
|
|
89
|
+
if (setup.width !== W || setup.height !== H || figure.length !== n || owners.length !== n || source.data.length !== n * 4 || setup.data.length !== n * 4 || owners.some(i => i < -1 || i >= slots.length)) {
|
|
90
|
+
refuseIfAny([{ code: 'CHECK_SETUP_PAINTING_MEASURED', object: SETUP_PAINTING_LINE, detail: 'requires actual setup pixels, figure domain and renderer owners on the declared native source grid; no fitted registration or guessed owner is substituted' }]);
|
|
91
|
+
}
|
|
92
|
+
const regions = declared.regions.map<SetupPaintingReading>(r => {
|
|
93
|
+
const rows = new Map<string, { pixels: number; x0: number; y0: number; x1: number; y1: number }>();
|
|
94
|
+
let count = 0;
|
|
95
|
+
for (let y = r.box[1]; y < r.box[1] + r.box[3]; y++) for (let x = r.box[0]; x < r.box[0] + r.box[2]; x++) {
|
|
96
|
+
const p = y * W + x, q = p * 4;
|
|
97
|
+
if (figure[p] === 0 && setup.data[q + 3] === 0) continue;
|
|
98
|
+
let delta = 0;
|
|
99
|
+
for (let c = 0; c < 3; c++) {
|
|
100
|
+
const white = (image: Raster): number => Math.round(image.data[q + c] * image.data[q + 3] / 255 + 255 * (1 - image.data[q + 3] / 255));
|
|
101
|
+
delta = Math.max(delta, Math.abs(white(source) - white(setup)));
|
|
102
|
+
}
|
|
103
|
+
if (delta <= declared.rgb_tolerance) continue;
|
|
104
|
+
count++;
|
|
105
|
+
const slot = owners[p] < 0 ? '(uncovered)' : slots[owners[p]], row = rows.get(slot);
|
|
106
|
+
if (row === undefined) rows.set(slot, { pixels: 1, x0: x, y0: y, x1: x + 1, y1: y + 1 });
|
|
107
|
+
else { row.pixels++; row.x0 = Math.min(row.x0, x); row.y0 = Math.min(row.y0, y); row.x1 = Math.max(row.x1, x + 1); row.y1 = Math.max(row.y1, y + 1); }
|
|
108
|
+
}
|
|
109
|
+
const status = r.max_pixels === undefined ? 'SKIP' : count > r.max_pixels ? 'FAIL' : 'PASS';
|
|
110
|
+
const result: SetupPaintingReading = { ...r, status, pixel_count: count, limit: r.max_pixels ?? null,
|
|
111
|
+
slots: [...rows].map(([slot, row]) => ({ slot, box: [row.x0, row.y0, row.x1 - row.x0, row.y1 - row.y0], pixel_count: row.pixels })) };
|
|
112
|
+
if (status === 'FAIL') {
|
|
113
|
+
for (const row of result.slots) problems.push({ code: 'CHECK_SETUP_MATCHES_PAINTING', object: `region "${r.name}" slot "${row.slot}"`, detail: `box [${row.box.join(', ')}], pixelcount ${row.pixel_count}; region pixelcount ${count} > declared limit ${r.max_pixels}, RGB tolerance ${declared.rgb_tolerance}; compare the actual setup to the source painting on the declared stage grid` });
|
|
114
|
+
}
|
|
115
|
+
return result;
|
|
116
|
+
});
|
|
117
|
+
const noLimit = regions.some(r => r.status === 'SKIP');
|
|
118
|
+
return { status: regions.some(r => r.status === 'FAIL') ? 'FAIL' : noLimit ? 'SKIP' : 'PASS', instrument: 'rigc renderer setup textured pieces', registration: 'declared stage grid, scale 1, no fitting', compositing: 'source-over white, RGBA8', figure: 'source figureSilhouette union rendered alpha > 0', ownership: 'last actual drawn piece with sampled alpha > 0; uncovered explicit', rgb_tolerance: declared.rgb_tolerance, regions,
|
|
119
|
+
...(noLimit ? { reason: 'a region has no declared max_pixels; it is measured as SKIP, never PASS' } : {}) };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function setupPaintingLines(line: SetupPaintingLine): string[] {
|
|
123
|
+
return [`${SETUP_PAINTING_LINE}: ${line.status}${line.reason === undefined ? '' : ` — ${line.reason}`}; ${line.registration}; ${line.compositing}`,
|
|
124
|
+
...line.regions.flatMap(r => [`${SETUP_PAINTING_LINE}: region "${r.name}" ${r.status} — box [${r.box.join(', ')}], pixelcount ${r.pixel_count}, limit ${r.limit ?? 'undeclared (SKIP)'}, RGB tolerance ${line.rgb_tolerance}`,
|
|
125
|
+
...r.slots.map(s => `${SETUP_PAINTING_LINE}: slot "${s.slot}" region "${r.name}" — box [${s.box.join(', ')}], pixelcount ${s.pixel_count}; region total ${r.pixel_count}, limit ${r.limit ?? 'undeclared (SKIP)'}`)])];
|
|
126
|
+
}
|