rig-parts 2.3.0 → 2.4.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 +2 -0
- package/docs/AUTHORING.md +291 -6
- package/docs/STABILITY.md +59 -3
- package/package.json +2 -2
- package/src/assemble.ts +124 -30
- package/src/boundarycolour.ts +233 -0
- package/src/build.ts +24 -5
- package/src/check.ts +165 -6
- package/src/config.ts +41 -3
- package/src/editreceipt.ts +194 -0
- package/src/motionseampose.ts +3 -1
- package/src/partsource.ts +91 -0
- package/src/requirements.ts +64 -2
- package/src/rig.ts +25 -1
- package/src/setuppainting.ts +18 -3
package/docs/STABILITY.md
CHANGED
|
@@ -147,11 +147,13 @@ part name, `[i]` an entry of a list):
|
|
|
147
147
|
- **generation.control.skeleton**, the pose file it names when it is no built-in — required: `width`, `height`, `points`
|
|
148
148
|
- **seethrough** — required: `resolution`, `steps`, `seed`, `offload`; optional: `head_box`, `arm_split`
|
|
149
149
|
- **seethrough.arm_split** — required: `order`, and one of `x` or `line` (issue #439)
|
|
150
|
-
- **assemble** — required: `rig_scale`, `plan` (from plain `assemble` on); optional: `seam`, `extend_below_crop`, `patches`, `cuts`, `alternates`, `head_face_underfill`, `yield_refused`, `keep_under_occluders`, `extend_painting`, `take`
|
|
150
|
+
- **assemble** — required: `rig_scale`, `plan` (from plain `assemble` on); optional: `seam`, `extend_below_crop`, `patches`, `cuts`, `alternates`, `head_face_underfill`, `yield_refused`, `keep_under_occluders`, `extend_painting`, `take`, `editor_receipts`
|
|
151
151
|
- **assemble.alternates[i]** — required: `name`; optional: `source`, `full`, `head`, `parts`, `hides`, `head_box`, `cuts`, `skeleton`, `anchor`, `base_anchor`, `drawn`, `tips`, `front`, `layers`, `focus` (issues #454, #465, #543, #652, #664, #666, #719, #780). `focus` is an authored finite `[x, y]` in painting pixels, within its canvas (edges included), refused as CONFIG_SET_FOCUS before assemble output when invalid; only when present, `parts.json` records it with focus_source_size and `sets.json` emits it in setup-world coordinates through the actual rig resize and `src/coords.ts`. Absent focus emits no field or inferred value; changing/removing it requires reassembly
|
|
152
152
|
- **assemble.extend_below_crop[i]** — required: `part`, `run`, `tag`
|
|
153
153
|
- **assemble.extend_painting[i]** — required: `part`, `band`, `max_distance`; optional: `min_held_alpha`, `order` (issues #848, #854, #855; explicit final layer-derived part, half-open rig band and positive four-neighbour path bound; base painting only). The optional integer floor 1–255 admits exact original alpha-over coverage strictly below it; absent or 1 blocks every nonzero holder. Only selected target samples below the floor may take painting RGBA; target samples at/above the floor and every non-target holder remain exact. By default overlapping reachable claims refuse. With `order` on every entry (unique nonnegative safe integers), lower values claim first and their gains block later candidates and paths; array order never breaks ties or supplies missing precedence
|
|
154
154
|
- **assemble.take[i]** — required: `run`, `run_dir`, `st_input`, `tags` (issue #866)
|
|
155
|
+
- **assemble.editor_receipts** — required: `receipts`; optional: `required` (issue #946, additive), `trace_parts` (issue #947, additive)
|
|
156
|
+
- **assemble.editor_receipts.receipts[i]** — required: `file`, `sha256`, `from_sha256`, `editor` (issue #946)
|
|
155
157
|
- **assemble.patches[i]** — required: `name`, `box`, `alpha`, `draw`
|
|
156
158
|
- **assemble.patches[i].draw**, when an object — required: `before`
|
|
157
159
|
- **assemble.cuts[i]** — required: `from`, `into`, `polygon`, `draw`, `overlap`; optional: `under`, `under_fill`
|
|
@@ -240,7 +242,7 @@ every number) are AUTHORING §3's tables, and the loader refuses any other by na
|
|
|
240
242
|
| `parts.json`, `parts/<name>.png` | `assemble`, `build` | one record per part: `from` (`<run>:<tag>` with a See-through v3 tag, `<run>:author:<label>` for a layer the author made — issue #256, read by no role rule — or `painting:<name>` for a patch), `cut_from` on a cut's piece only (the plan part it was cut from, issue #230), `limb` on a limb segment's part only (issue #349: `name`, `segment`, `of`, `bone`, and below a joint `joint`, `range` when the config segment declares one (issue #360), and `cap` — `at`, `width`, `radius`, `box`, `layer_px`, `image_px` with a cap image, `image_layer` after it when that image was a hidden-art layer — issue #359, `<run>:<layer>:hidden` — `missing_px`, `short_px` (`left`, `right`) after it only when `missing_px` is above 0 (issue #557, additive), `excluded_px` after them only on a subset set's limb part whose cap the set exclusion took a pixel of (issue #603, additive: each pixel taken out of the count it was in, so the cap's counts are the written PNG's), then `shape` and `upper_px` under the segment's `cap_shape` — `upper_px` without the shape only when the upper segment gave a pixel (issue #557, additive) — and after them `lower_px`, `lower_kept_px` and `lower_missing_px` under `cap_shape: "ball"` (issue #579, additive), and `edge`, `feather_radius` and `feathered_px` under its `edge`, and `cut_band`, `cut_offset`, `cut_at`, `cut_crossed`, `joint_crossed`, `band_px` and `stroke_luma_max` (when the band has a split) under its `cut_band`, issue #360), the box `x`, `y`, `w`, `h` (rig px), the pixel counts `src/parts.ts` documents (`opaque_px`, `visible_px`, `occluded_px`, `projected_core_px`, `source_px_taken`, `visible_not_projected_px`, `refused_drift_px`, `merged_px` — on a subset set's limb part counted after the set exclusion, issue #603 — `seam_override_px`, and `fringe_pushed_back` when not empty); per-part `provenance` (`unchanged_layer_px`, `hand_px`, `automatic_px`, `edits`, `automatic`, additive, issue #823); optional top-level `motion_seams`, `alternates` and `cut_ownership` (one row per cut, independent of RGBA equality, issue #823); `rig_size`, `scale_rig_per_source`, `ghost_px`, `recomposite`, and `scene` (`figure_alpha_above`, `figure_px`, `figure_mask`) under `config.scene` only, with the figure's mask `figure_rig.png` beside the recomposite (issue #242); `single_run_pad` (`file_size`, `side`, `left`, `top`) last, only for one run whose file is not the `seethrough.resolution` square (issue #366) |
|
|
241
243
|
| `rig/rig.json`, `rig/motion.json`, `rig/images/` | `rig`, `build`, `compose` | rig-c's rig spec and motion spec, `spec` `rigc-rig/1` and `rigc-motion/1`: the format is rig-c's, and this package writes only what rig-c's parser accepts; a bone carries `inherit` only where config.bones declares one, rig-c's mode name as written (issue #348) |
|
|
242
244
|
| `rig/mesh_report.json` | `rig`, `build` | one row per mesh: `part`, `vertices`, `triangles`, `hull`, `bones` (the sorted names the attachment's weights bind in the written `rig.json`, in every mode — since 1.49.1 (issue #536) a rig with `turn` or `stops` names the twins and `<link>_d` bones there, where the row named the segment bones they replace: a fix of a wrong name, not a format change; a rig with neither writes the bytes it wrote), `max_influences`, `mean_influences`, `art_coverage`, then `grid` (a lattice); or `mode` "explicit" with `image_sha256` and `region_sha256` (the verified guards; counts and coverage are measured from the supplied mesh, no legacy generator report is adopted); `mode` "contour" with `params`, `contour`, `regions` (`params.maxEdge` and `contour.outlineCut` only when the mesh declares `contour.maxEdge`; `params.bridges` and `contour.bridges` — `islands`, `units`, `joins`, and `faint` only when an island was left out as faint — only when it declares `contour.bridges`); or `mode` "influence" with `influence` (as declared), `reach` (`farthest`, `at`, `nearest`, `capped`, `widest`) and `lattice` (rig-c's `SegmentsLatticeReport`: `cols`, `rows`, `artCells`, `keptCells`, `passes`, `islands`) (issue #378); or `mode` "auto" with `settings`, `source`, `result`, `residuals`, `worst_residual`, `worst_region`, `motion_amplitude`, `termination`, `weights`, `regions`, `deformation`, `quality_report` (rig-c's `mesh-quality-report/1` document whole), `motion_report`, and, only when they apply, `stray_cleared` (after `source`, when `source.stray` is declared), `skinning_residual` and `replay` (`AutoMeshReport` in `src/rig.ts`); in every mode, `exponent` when the mesh declares one (after `grid`, or after `mode`); `influences`, as declared, when a lattice or contour mesh declares it (after `exponent`, or where `exponent` would be); `stray` and `stray_cleared` when a lattice mesh declares `stray` (after `influences`, or where `influences` would be); `ribs` when a contour or auto mesh declares them (after `exponent`, or after `mode`), and then `contour.ribs` (`source.contour.ribs` in the automatic mode) |
|
|
243
|
-
| `check/check.json` and the rest of `check/` | `check`, `build`, `compose` | the reference's fields in its order (`gate_spine_html_green`, `rigc_entry`, `pack_mode`, `environment` after it under `--environment` only (issue #538, additive: `path` as given, `sha256`, `steps`), `loop_max_diff`, the seam figures — `seam_mean`, `seam_px_over_40`, `seam_px_over_80` and, from issue #216, `seam_frame_scale`, the frame scale the bar divides the count by), `loop_physics` after `loop_max_diff` when the rig declares a physics constraint, `seam_border` after the seam figures when `parts.json` declares a scene (issue #242), `seam_pairs` after the seam figures when the seam bar fails or `--seam-pairs` asks (each row's picture under `check/seam/`; `--keep-stills` leaves the setup-pose still and its `frames.json` under `check/setup_still/`), then the judgement lines (`BREATH_VISIBLE`, `BLINK_NO_HOLE`, `CHAIN_LAG`, `TIP_OVER_ROOT`, `STILL_REGIONS_DARK`, `TEXTURE_STRETCH`), `ANIMATIONS_DO_NOT_FOLD` after them when `motion.json` holds an animation, the idle included — on every rig with an idle since issue #333 (issues #320, #363: `status`, `animations`, `fps`, `physics_left_out`, `folding` — each `animation`, `slot`, `attachment`, `frames`, `worst_frame`, `worst_triangles`, `triangles`, `physics_on`, `physics_off`, `physics_constraints`, `declared`, `line` — `folding_undeclared`, `declared_not_folding`, `bar`), `FEET_STAY_LEVEL` after it when a `footwear` part rides a bone that hangs from another and `motion.json` holds an animation (issue #348: `status`, `reason` on a SKIP only, `animations`, `fps`, `physics_left_out`, `bar_degrees`, `bar`, `feet` — each `part`, `bone`, `hangs_from`, `status`, `frames_posed`, `worst`), `LIMB_ONE_BONE` after it (and after `STRESS_OFF_CHAIN` where that is written) when `parts.json` holds a limb segment's part (issue #349: `status`, `parts` — each `part`, `limb`, `bone`, `status`, `others`), `SEAM_OVER_RANGE` after it when a limb record carries a `range` (issue #360: `status`, `frame_scale`, `physics_left_out`, `bar` — `mean`, `rig_px_over_40`, `level` — and `joints`, each `part`, `limb`, `segment`, `joint`, `bone`, `range`, `at`, `radius`, `under_px`, `status`, `poses` — each `degrees`, `status`, `mean`, `px_over_40`, `px_over_80`, `rig_px_over_40`, and `step_px` (`left`, `right`) only on a joint whose cap record carries `short_px` (issue #557, additive) — and `unread` on a joint not read), `SEAM_OVER_MOTION` after it when `parts.json` holds a limb segment or `--requirements` a `seam` (issue #493 retains existing pair warnings; issue #686 gates GAP on proven body-root pairs only (gap-only; mixed ghost remains INFO until rig-c/core#1555 supplies owners): `status` — `WARN`, `CLEAR`, `SKIP` or `FAIL` — `reason` on a SKIP only, `fps`, `floor`, `pairs` — each `pair`, `a`, `b`, `declared`, optional `root` (`limb`, `bone`, `parent`) on a proven body-root pair, and `unread` on a pair not read — `animations`, `warnings`, `rows` — each `pair`, `animation`, `status`, `drawn`, `banded`, `opens`, `px`, `max_px`, `under_floor`, and only on new-root rows stock `gapOpen` plus UNDER-owned `ghostA`/`ghostB` cells selected by resolved per-frame draw order gate their summed count against the unchanged strict one-cell floor on the original raw kind boxes (their union when both kinds contribute), never narrowed partition boxes, while `gapCovered` and OVER-owned ghost cells are excluded (issue #711); `cells` retains partition counts, UNDER owner and counted total, `worst` retains frame/count/cell/box, and `roots` retains status, mode `gap-open+under-ghost`, ghost_owner `resolved-draw-order`, pairs, failed, reason and optional unmeasured bindings; eligible candidates whose valid setup bands are all zero retain a named unmeasured root and SKIP bar, including when no pair is selected; measured root FAIL wins over SKIP, while measured PASS plus an unmeasured root remains SKIP; every eligible body drawing with stock setup `banded > 0` is selected, including both when a parent is shared; issue #708 resolves a body slot on its exact generated turn child to the same named carrier only after unique identity-bone/control/per-part-transform proof, without changing pair identity, setup selection or the direct-carrier path; setup-only observation preserves the authored motion and default drawing, root-free inputs add no setup compile/render, and no animation remains SKIP rather than a measured zero; existing internal pair warnings and their exits are unchanged; issue #591 adds optional `worlds` on physics rigs with readable declared pairs and animations: `default` and `still`, each `wind`, `gravity`, `status` (`REPORTED` or `SKIP`), `reason` on SKIP, `dir` only when rendered (relative to check.json: `seam_motion/default` or `seam_motion/still`, the complete retained render with frames.json and each animation's raw seam.json), and `rows` — pair, animation, status, drawn, banded, `gap` and `ghost`, each `max_px` and `worst` only; worst carries `index`, `px`, `cell`, `box`. Per-frame hits stay in the raw sidecars. These raw reference readings add no bar or warning and leave the custom environment reading unchanged), `CLOTH_SETTLES` after it when the rig declares a physics constraint and has an idle (issue #501, a warning and not a bar: `status` — `WARN` or `CLEAR` — `fps`, `frames`, `duration`, `floor_rig_px_per_s`, `floor`, `levers`, `warnings`, `rows` — each `tip`, `root`, `links`, `point`, `settles_at`, `max_rig_px_per_s`, `min_rig_px_per_s`), `RECOMPOSITE_HOLES` (its first figure `declared_scene` when `parts.json` declares a scene), `SETUP_POSE_VS_SOURCE`, `WIND_BAND` after it under `--wind-band` only (issue #590, additive: `status` `"REPORTED"`, `spec` `"rig-parts-wind-band/1"`, `path` as given, `sha256`, `steady_wind_only` `true`, `gravity` `[0, 1]`, `min`, `max`, `step`, `steps`, `fps`, `animations`, `max_gap`, `max_ghost`, `holds_from`, `holds_to`, `pairs_holding_all`, and `pairs` — each `pair`, `a`, `b`, `holds_all`, `holds_from`, `holds_to`, `first_break_below`, `first_break_above`, `worst_gap`, `worst_ghost`, `steps`), `skipped`, `config` under `build` only (issue #607, additive: `path` as `--config` was typed, `sha256` of the bytes the build read; `check` and `compose` read no config and write none), a `requirements` block under `--requirements` (or `build`'s `config.requirements`) — `file` first from issue #556, additive: `path` as typed or declared, `sha256`, `keys` — and `PASS`; `check/build/skeleton.json`, `.atlas` and `.png` are the packed artifact rig-c writes. Missing stock partitions or resolved owner are named refusals, never zero. No whole-box exemption or consumer compositor remains; internal warnings and reference raw readings are unchanged. |
|
|
245
|
+
| `check/check.json` and the rest of `check/` | `check`, `build`, `compose` | the reference's fields in its order (`gate_spine_html_green`, `rigc_entry`, `pack_mode`, `environment` after it under `--environment` only (issue #538, additive: `path` as given, `sha256`, `steps`), `loop_max_diff`, the seam figures — `seam_mean`, `seam_px_over_40`, `seam_px_over_80` and, from issue #216, `seam_frame_scale`, the frame scale the bar divides the count by), `loop_physics` after `loop_max_diff` when the rig declares a physics constraint, `seam_border` after the seam figures when `parts.json` declares a scene (issue #242), `seam_pairs` after the seam figures when the seam bar fails or `--seam-pairs` asks (each row's picture under `check/seam/`; `--keep-stills` leaves the setup-pose still and its `frames.json` under `check/setup_still/`), then the judgement lines (`BREATH_VISIBLE`, `BLINK_NO_HOLE`, `CHAIN_LAG`, `TIP_OVER_ROOT`, `STILL_REGIONS_DARK`, `TEXTURE_STRETCH`), `ANIMATIONS_DO_NOT_FOLD` after them when `motion.json` holds an animation, the idle included — on every rig with an idle since issue #333 (issues #320, #363: `status`, `animations`, `fps`, `physics_left_out`, `folding` — each `animation`, `slot`, `attachment`, `frames`, `worst_frame`, `worst_triangles`, `triangles`, `physics_on`, `physics_off`, `physics_constraints`, `declared`, `line` — `folding_undeclared`, `declared_not_folding`, `bar`), `FEET_STAY_LEVEL` after it when a `footwear` part rides a bone that hangs from another and `motion.json` holds an animation (issue #348: `status`, `reason` on a SKIP only, `animations`, `fps`, `physics_left_out`, `bar_degrees`, `bar`, `feet` — each `part`, `bone`, `hangs_from`, `status`, `frames_posed`, `worst`), `LIMB_ONE_BONE` after it (and after `STRESS_OFF_CHAIN` where that is written) when `parts.json` holds a limb segment's part (issue #349: `status`, `parts` — each `part`, `limb`, `bone`, `status`, `others`), `SEAM_OVER_RANGE` after it when a limb record carries a `range` (issue #360: `status`, `frame_scale`, `physics_left_out`, `bar` — `mean`, `rig_px_over_40`, `level` — and `joints`, each `part`, `limb`, `segment`, `joint`, `bone`, `range`, `at`, `radius`, `under_px`, `status`, `poses` — each `degrees`, `status`, `mean`, `px_over_40`, `px_over_80`, `rig_px_over_40`, and `step_px` (`left`, `right`) only on a joint whose cap record carries `short_px` (issue #557, additive) — and `unread` on a joint not read), `SEAM_OVER_MOTION` after it when `parts.json` holds a limb segment or `--requirements` a `seam` (issue #493 retains existing pair warnings; issue #686 gates GAP on proven body-root pairs only (gap-only; mixed ghost remains INFO until rig-c/core#1555 supplies owners): `status` — `WARN`, `CLEAR`, `SKIP` or `FAIL` — `reason` on a SKIP only, `fps`, `floor`, `pairs` — each `pair`, `a`, `b`, `declared`, optional `root` (`limb`, `bone`, `parent`) on a proven body-root pair, and `unread` on a pair not read — `animations`, `warnings`, `rows` — each `pair`, `animation`, `status`, `drawn`, `banded`, `opens`, `px`, `max_px`, `under_floor`, and only on new-root rows stock `gapOpen` plus UNDER-owned `ghostA`/`ghostB` cells selected by resolved per-frame draw order gate their summed count against the unchanged strict one-cell floor on the original raw kind boxes (their union when both kinds contribute), never narrowed partition boxes, while `gapCovered` and OVER-owned ghost cells are excluded (issue #711); `cells` retains partition counts, UNDER owner and counted total, `worst` retains frame/count/cell/box, and `roots` retains status, mode `gap-open+under-ghost`, ghost_owner `resolved-draw-order`, pairs, failed, reason and optional unmeasured bindings; eligible candidates whose valid setup bands are all zero retain a named unmeasured root and SKIP bar, including when no pair is selected; measured root FAIL wins over SKIP, while measured PASS plus an unmeasured root remains SKIP; every eligible body drawing with stock setup `banded > 0` is selected, including both when a parent is shared; issue #708 resolves a body slot on its exact generated turn child to the same named carrier only after unique identity-bone/control/per-part-transform proof, without changing pair identity, setup selection or the direct-carrier path; setup-only observation preserves the authored motion and default drawing, root-free inputs add no setup compile/render, and no animation remains SKIP rather than a measured zero; existing internal pair warnings and their exits are unchanged; issue #941 adds optional `required` only under the requirements file's `seam_over_motion.required: true` — `status` (`PASS` or `FAIL`), `mode` `gap-open`, `bar_px` 0, `pairs` (each `pair`, `class` — `body-root`, `internal-joint` or `explicit` — `frames`, `background_frames`, `max_px`) and `failed` — and with it the key is written even when no pair is declared; issue #591 adds optional `worlds` on physics rigs with readable declared pairs and animations: `default` and `still`, each `wind`, `gravity`, `status` (`REPORTED` or `SKIP`), `reason` on SKIP, `dir` only when rendered (relative to check.json: `seam_motion/default` or `seam_motion/still`, the complete retained render with frames.json and each animation's raw seam.json), and `rows` — pair, animation, status, drawn, banded, `gap` and `ghost`, each `max_px` and `worst` only; worst carries `index`, `px`, `cell`, `box`. Per-frame hits stay in the raw sidecars. These raw reference readings add no bar or warning and leave the custom environment reading unchanged), `CLOTH_SETTLES` after it when the rig declares a physics constraint and has an idle (issue #501, a warning and not a bar: `status` — `WARN` or `CLEAR` — `fps`, `frames`, `duration`, `floor_rig_px_per_s`, `floor`, `levers`, `warnings`, `rows` — each `tip`, `root`, `links`, `point`, `settles_at`, `max_rig_px_per_s`, `min_rig_px_per_s`), `RECOMPOSITE_HOLES` (its first figure `declared_scene` when `parts.json` declares a scene), `SETUP_POSE_VS_SOURCE`, `WIND_BAND` after it under `--wind-band` only (issue #590, additive: `status` `"REPORTED"`, `spec` `"rig-parts-wind-band/1"`, `path` as given, `sha256`, `steady_wind_only` `true`, `gravity` `[0, 1]`, `min`, `max`, `step`, `steps`, `fps`, `animations`, `max_gap`, `max_ghost`, `holds_from`, `holds_to`, `pairs_holding_all`, and `pairs` — each `pair`, `a`, `b`, `holds_all`, `holds_from`, `holds_to`, `first_break_below`, `first_break_above`, `worst_gap`, `worst_ghost`, `steps`), `skipped`, `config` under `build` only (issue #607, additive: `path` as `--config` was typed, `sha256` of the bytes the build read; `check` and `compose` read no config and write none), a `requirements` block under `--requirements` (or `build`'s `config.requirements`) — `file` first from issue #556, additive: `path` as typed or declared, `sha256`, `keys` — and `PASS`; `check/build/skeleton.json`, `.atlas` and `.png` are the packed artifact rig-c writes. Missing stock partitions or resolved owner are named refusals, never zero. No whole-box exemption or consumer compositor remains; internal warnings and reference raw readings are unchanged. |
|
|
244
246
|
| `layers.json`, `<index>_<name>.png` | `layers --export` | the wrapper form `layers` reads (issue #359): `width`, `height`, then `layers` — each `name`, `filename`, `left`, `top`, `right`, `bottom`, `depth_median` (the input's own; `null` for a PSD, the manifest's order then being the draw order) — drawn layers in draw order, then the hidden-art layers; a PNG's name is its index and its layer's name, and the manifest, not the file name, says which layer it is |
|
|
245
247
|
| the template `.psd` | `layers --template` | one empty layer per name `templateLayerNames` (`src/layers.ts`) holds, bottom first; the same canvas writes the same bytes |
|
|
246
248
|
| `proposal.json`, `render/landmarks*.png` | `propose` | the config's four rig sections, `constraints` with `--cloth` (issue #364: a physics constraint per tree link named `<bone>_physics`, a dangling end's `<bone>_translate`, and a skirt swing's transforms `<swing>_<tree>`), `turn` with `--full`/`--head`, `proposed_rig_scale` (issue #354), and `notes` |
|
|
@@ -534,7 +536,37 @@ The separate ordering scalar is unchanged. No-image outputs retain legacy bytes.
|
|
|
534
536
|
The optional `setup_painting` key in `spine-parts-requirements/1` adds a
|
|
535
537
|
`SETUP_MATCHES_PAINTING` measurement when declared. It requires authored
|
|
536
538
|
`rgb_tolerance` and named native-stage boxes. Each optional `max_pixels` is
|
|
537
|
-
that whole region's limit; omission is SKIP, never PASS.
|
|
539
|
+
that whole region's limit; omission is SKIP, never PASS. The optional boolean
|
|
540
|
+
`setup_painting.required` (issue #943, additive) is the author's declaration
|
|
541
|
+
that the check may not SKIP: with `true`, an absent `rgb_tolerance`, `regions`
|
|
542
|
+
or region `max_pixels` is refused by name as `SETUP_PAINTING_REQUIRED_LIMIT`
|
|
543
|
+
when the file is read, before anything is built, and no limit is derived; a
|
|
544
|
+
fully limited declaration measures, prints and writes the same bytes as
|
|
545
|
+
without the field. Absent or `false`, every reading in this section is
|
|
546
|
+
unchanged. The optional `pinholes` key (issue #944, additive) declares
|
|
547
|
+
`assemble`'s pinhole bar: with `{"required": true}`, `assemble` (and `build`'s
|
|
548
|
+
assemble) refuses every pinhole of the base set's flat parts stack by name as
|
|
549
|
+
`ASSEMBLE_PINHOLES` before anything is written — the bar is 0 and takes no
|
|
550
|
+
value. Absent or `false`, nothing is measured, the console's
|
|
551
|
+
`ASSEMBLE_PINHOLES: SKIP` line says so, and no exit code, file or other line
|
|
552
|
+
moves. The optional `seam_over_motion` key (issue #941, additive)
|
|
553
|
+
declares `check`'s background bar: with `{"required": true}`, every declared
|
|
554
|
+
`SEAM_OVER_MOTION` pair — body-root, internal-joint and explicit — is refused
|
|
555
|
+
by name as `CHECK_SEAM_OVER_MOTION_REQUIRED` on any frame of any animation (12
|
|
556
|
+
fps, every frame) with rigc `gapOpen` cells above 0, and a line or pair that
|
|
557
|
+
measured nothing is refused too; `check.json`'s `SEAM_OVER_MOTION` gains
|
|
558
|
+
`required`. The bar is 0 and takes no value. Absent or `false`, nothing is read
|
|
559
|
+
and no exit code, line or byte moves; the existing warnings and the body-root
|
|
560
|
+
bar are unchanged.
|
|
561
|
+
The optional `boundary_colour` key (issue #942, additive) declares `check`'s
|
|
562
|
+
`BOUNDARY_COLOUR_STEP` bar: with `{"max_step_excess": <0-255>}` (an authored
|
|
563
|
+
integer; no default exists), `check` measures every part boundary of the setup
|
|
564
|
+
and every idle frame against the rig's own pieces, writes the
|
|
565
|
+
`BOUNDARY_COLOUR_STEP` block to `check.json`, and refuses each part pair over
|
|
566
|
+
the allowance as `CHECK_BOUNDARY_COLOUR_STEP`, or a reading it cannot take as
|
|
567
|
+
`CHECK_BOUNDARY_COLOUR_UNMEASURED`. Absent, nothing is measured or written,
|
|
568
|
+
the console's `BOUNDARY_COLOUR_STEP: SKIP` line says so, and no exit code,
|
|
569
|
+
file or other line moves. With `--source` but
|
|
538
570
|
no declaration, the console, `check.json` and the report's skipped-bar list now
|
|
539
571
|
say SKIP with the reason and the `--requirements` declaration remedy; exit
|
|
540
572
|
codes stay unchanged. No metric, region or limit is invented. Without source
|
|
@@ -560,6 +592,30 @@ stale evidence rather than reconstructing original art from emitted siblings.
|
|
|
560
592
|
Without the block the API reading is SKIP; CLI stdout and emitted bytes remain unchanged.
|
|
561
593
|
See [Source before scissors](AUTHORING.md#source-before-scissors).
|
|
562
594
|
|
|
595
|
+
### Editor receipts
|
|
596
|
+
|
|
597
|
+
The optional `assemble.editor_receipts` block (issue #946, additive) records
|
|
598
|
+
images edited in an image editor. Absent, no file is read and every output is
|
|
599
|
+
byte-identical. Declared, every receipt's `file` must exist and hash to its
|
|
600
|
+
`sha256` (`ASSEMBLE_EDITOR_RECEIPT_FILE`, `ASSEMBLE_EDITOR_RECEIPT_SHA256`);
|
|
601
|
+
`from_sha256` and `editor` are the author's declaration, held to form and to
|
|
602
|
+
`from_sha256` differing from `sha256` (`CONFIG_EDITOR_RECEIPT`). The optional
|
|
603
|
+
boolean `required` is the author's declaration that no edited image may go
|
|
604
|
+
without one: with `true`, every author-made layer, hidden-art layer, cut
|
|
605
|
+
`under` and limb `cap` the build reads needs a receipt matching its bytes
|
|
606
|
+
(`ASSEMBLE_EDITOR_RECEIPT_MISSING`), refused before anything is assembled.
|
|
607
|
+
Absent or `false`, nothing is demanded; a build whose receipts all verify
|
|
608
|
+
writes the same bytes as without the block. No input accepted before the block
|
|
609
|
+
existed changes its exit code: the refusals fire only on a block that input
|
|
610
|
+
could not have held. The optional boolean `trace_parts` (issue #947, additive)
|
|
611
|
+
is the author's declaration that every part's pixels trace to a layer of the
|
|
612
|
+
split or to a receipted image: with `true`, receipts are demanded as under
|
|
613
|
+
`required`, and every shape the config draws through a part (a cut's
|
|
614
|
+
`polygon`, `seethrough.arm_split`, a limb's `split.line`, a patch's `box`, a
|
|
615
|
+
painting extension's `band`) is refused (`ASSEMBLE_PART_SOURCE`) before
|
|
616
|
+
anything is assembled. Absent or `false`, nothing more is demanded and no byte
|
|
617
|
+
moves.
|
|
618
|
+
|
|
563
619
|
## Provider-map depth stages (#897)
|
|
564
620
|
|
|
565
621
|
The opt-in `depth-prepare` and `depth-apply` commands accept the separate
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rig-parts",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.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": {
|
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
},
|
|
69
69
|
"dependencies": {
|
|
70
70
|
"ag-psd": "^31.0.2",
|
|
71
|
-
"@rig-c/core": "3.1.
|
|
71
|
+
"@rig-c/core": "3.1.1"
|
|
72
72
|
},
|
|
73
73
|
"devDependencies": {
|
|
74
74
|
"@esotericsoftware/spine-core": "4.3.13",
|
package/src/assemble.ts
CHANGED
|
@@ -2595,6 +2595,23 @@ export function visibilityCounts(object: string, part: Raster, visible: Mask, pr
|
|
|
2595
2595
|
* This is NOT in the reference. It is the input of `seamRule: 'silhouette'`.
|
|
2596
2596
|
*/
|
|
2597
2597
|
export function figureSilhouette(srcr: Raster): Mask {
|
|
2598
|
+
const { width: W, height: H } = srcr;
|
|
2599
|
+
const fig = newMask(W, H);
|
|
2600
|
+
fig.data.set(paintedFigure(srcr));
|
|
2601
|
+
const opened = dilate(dilate(erode(erode(fig, 3), 3), 3), 3);
|
|
2602
|
+
return fillHoles(opened);
|
|
2603
|
+
}
|
|
2604
|
+
|
|
2605
|
+
/**
|
|
2606
|
+
* `figureSilhouette`'s own per-pixel test, before its opening and its hole
|
|
2607
|
+
* fill: 1 where the painting (rig-sized) differs from the median colour of its
|
|
2608
|
+
* outer `SILHOUETTE_BORDER`-pixel ring by more than `SILHOUETTE_LIMIT` (max
|
|
2609
|
+
* channel). `figureSilhouette` opens and fills this mask; `measurePinholes`
|
|
2610
|
+
* (issue #944) reads it as it is, so a pixel the painting shows as ground
|
|
2611
|
+
* (between arm and body, between the legs, inside a hair loop) is never
|
|
2612
|
+
* "painted".
|
|
2613
|
+
*/
|
|
2614
|
+
export function paintedFigure(srcr: Raster): Uint8Array {
|
|
2598
2615
|
const { width: W, height: H } = srcr;
|
|
2599
2616
|
const ring: number[][] = [[], [], []];
|
|
2600
2617
|
for (let y = 0; y < H; y++) {
|
|
@@ -2608,13 +2625,12 @@ export function figureSilhouette(srcr: Raster): Mask {
|
|
|
2608
2625
|
const n = s.length;
|
|
2609
2626
|
return n % 2 === 1 ? s[(n - 1) / 2] : (s[n / 2 - 1] + s[n / 2]) / 2;
|
|
2610
2627
|
});
|
|
2611
|
-
const
|
|
2628
|
+
const painted = new Uint8Array(W * H);
|
|
2612
2629
|
for (let p = 0; p < W * H; p++) {
|
|
2613
2630
|
const d = Math.max(...[0, 1, 2].map((c) => Math.abs(srcr.data[p * 4 + c] - median[c])));
|
|
2614
|
-
if (d > SILHOUETTE_LIMIT)
|
|
2631
|
+
if (d > SILHOUETTE_LIMIT) painted[p] = 1;
|
|
2615
2632
|
}
|
|
2616
|
-
|
|
2617
|
-
return fillHoles(opened);
|
|
2633
|
+
return painted;
|
|
2618
2634
|
}
|
|
2619
2635
|
|
|
2620
2636
|
export interface PlacedPart {
|
|
@@ -3071,36 +3087,80 @@ export function uncoveredHoles(can: Raster, srcr: Raster, parts: PlacedPart[], i
|
|
|
3071
3087
|
const all = cc.stats.slice(1).filter((s) => s.area > 0);
|
|
3072
3088
|
all.sort((a, b) => b.area - a.area || a.top - b.top || a.left - b.left);
|
|
3073
3089
|
const listed = all.slice(0, HOLES_LISTED);
|
|
3074
|
-
const holes = listed.map((s): RecompositeHole => {
|
|
3075
|
-
|
|
3076
|
-
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
|
|
3080
|
-
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
3088
|
-
|
|
3089
|
-
|
|
3090
|
-
|
|
3091
|
-
|
|
3092
|
-
|
|
3090
|
+
const holes = listed.map((s): RecompositeHole => ({ px: s.area, x: s.left, y: s.top, w: s.width, h: s.height, borders: holeBorders(parts, cc, s, W, H, COVERED_ALPHA) }));
|
|
3091
|
+
return { count: all.length, holes };
|
|
3092
|
+
}
|
|
3093
|
+
|
|
3094
|
+
/**
|
|
3095
|
+
* The parts bordering one labelled component: every part with a pixel of
|
|
3096
|
+
* alpha above `above` 8-adjacent to one of the component's pixels, each with
|
|
3097
|
+
* the number of such pixels, most first and then in plan order (a pixel two
|
|
3098
|
+
* parts hold counts for both). `uncoveredHoles` reads it at `COVERED_ALPHA`,
|
|
3099
|
+
* `measurePinholes` (issue #944) at 0.
|
|
3100
|
+
*/
|
|
3101
|
+
function holeBorders(parts: readonly PlacedPart[], cc: Components, s: Components['stats'][number], W: number, H: number, above: number): HoleBorder[] {
|
|
3102
|
+
const borders: HoleBorder[] = [];
|
|
3103
|
+
parts.forEach(({ record: p, image }) => {
|
|
3104
|
+
let n = 0;
|
|
3105
|
+
// A part pixel can touch the hole only inside the hole's box grown by one.
|
|
3106
|
+
const x0 = Math.max(p.x, s.left - 1);
|
|
3107
|
+
const x1 = Math.min(p.x + p.w - 1, s.left + s.width);
|
|
3108
|
+
const y0 = Math.max(p.y, s.top - 1);
|
|
3109
|
+
const y1 = Math.min(p.y + p.h - 1, s.top + s.height);
|
|
3110
|
+
for (let y = y0; y <= y1; y++) {
|
|
3111
|
+
for (let x = x0; x <= x1; x++) {
|
|
3112
|
+
if (image.data[((y - p.y) * p.w + (x - p.x)) * 4 + 3] <= above) continue;
|
|
3113
|
+
let touches = false;
|
|
3114
|
+
for (let dy = -1; dy <= 1 && !touches; dy++) {
|
|
3115
|
+
for (let dx = -1; dx <= 1 && !touches; dx++) {
|
|
3116
|
+
const u = x + dx;
|
|
3117
|
+
const v = y + dy;
|
|
3118
|
+
if (u >= 0 && u < W && v >= 0 && v < H && cc.labels[v * W + u] === s.label) touches = true;
|
|
3093
3119
|
}
|
|
3094
|
-
if (touches) n++;
|
|
3095
3120
|
}
|
|
3121
|
+
if (touches) n++;
|
|
3096
3122
|
}
|
|
3097
|
-
|
|
3098
|
-
});
|
|
3099
|
-
// Array.prototype.sort is stable, so equal counts keep plan order.
|
|
3100
|
-
borders.sort((a, b) => b.px - a.px);
|
|
3101
|
-
return { px: s.area, x: s.left, y: s.top, w: s.width, h: s.height, borders };
|
|
3123
|
+
}
|
|
3124
|
+
if (n > 0) borders.push({ part: p.name, px: n });
|
|
3102
3125
|
});
|
|
3103
|
-
|
|
3126
|
+
// Array.prototype.sort is stable, so equal counts keep plan order.
|
|
3127
|
+
borders.sort((a, b) => b.px - a.px);
|
|
3128
|
+
return borders;
|
|
3129
|
+
}
|
|
3130
|
+
|
|
3131
|
+
/**
|
|
3132
|
+
* The pinholes of the flat parts stack at rest (issue #944): every 8-connected
|
|
3133
|
+
* component of the stack's openings — the pixels no part has any alpha at
|
|
3134
|
+
* (alpha 0 on every part) that the union of the parts' alpha above 0, its
|
|
3135
|
+
* holes filled (`fillHoles`), encloses — of which every pixel is painted
|
|
3136
|
+
* (`paintedFigure`: the painting differs from its border ring's median by more
|
|
3137
|
+
* than `SILHOUETTE_LIMIT`, `figureSilhouette`'s test without its opening and
|
|
3138
|
+
* hole fill). An opening with one pixel the painting shows as ground is a
|
|
3139
|
+
* deliberate gap (between arm and body, between the legs, inside a hair loop)
|
|
3140
|
+
* and is not a pinhole. No size threshold: one pixel is a pinhole.
|
|
3141
|
+
*
|
|
3142
|
+
* ⚖️ Invariant: each pinhole is a `RecompositeHole` — its box is its pixels'
|
|
3143
|
+
* bounding box, its `borders` the parts with a pixel of alpha above 0
|
|
3144
|
+
* 8-adjacent to it (`holeBorders`) — and the list is every pinhole, largest
|
|
3145
|
+
* first, then by top row and left column, so the order is a function of the
|
|
3146
|
+
* pixels. The painting is the rig-sized one assemble reads (`sourceInRig`).
|
|
3147
|
+
*/
|
|
3148
|
+
export function measurePinholes(srcr: Raster, parts: readonly PlacedPart[]): RecompositeHole[] {
|
|
3149
|
+
const { width: W, height: H } = srcr;
|
|
3150
|
+
const stack = newMask(W, H);
|
|
3151
|
+
for (const { record: p, image } of parts) {
|
|
3152
|
+
for (let y = 0; y < p.h; y++) for (let x = 0; x < p.w; x++) if (image.data[(y * p.w + x) * 4 + 3] > 0) stack.data[(p.y + y) * W + p.x + x] = 1;
|
|
3153
|
+
}
|
|
3154
|
+
const filled = fillHoles(stack);
|
|
3155
|
+
const openings = newMask(W, H);
|
|
3156
|
+
for (let p = 0; p < W * H; p++) if (filled.data[p] === 1 && stack.data[p] === 0) openings.data[p] = 1;
|
|
3157
|
+
const cc = connectedComponents(openings, 8);
|
|
3158
|
+
const painted = paintedFigure(srcr);
|
|
3159
|
+
const allPainted = new Uint8Array(cc.stats.length).fill(1);
|
|
3160
|
+
for (let p = 0; p < W * H; p++) if (cc.labels[p] > 0 && painted[p] === 0) allPainted[cc.labels[p]] = 0;
|
|
3161
|
+
const pins = cc.stats.slice(1).filter((s) => s.area > 0 && allPainted[s.label] === 1);
|
|
3162
|
+
pins.sort((a, b) => b.area - a.area || a.top - b.top || a.left - b.left);
|
|
3163
|
+
return pins.map((s) => ({ px: s.area, x: s.left, y: s.top, w: s.width, h: s.height, borders: holeBorders(parts, cc, s, W, H, 0) }));
|
|
3104
3164
|
}
|
|
3105
3165
|
|
|
3106
3166
|
/**
|
|
@@ -3224,6 +3284,40 @@ export function figuresLine(f: RecompositeFigures, scene: string | null = null):
|
|
|
3224
3284
|
);
|
|
3225
3285
|
}
|
|
3226
3286
|
|
|
3287
|
+
/** Issue #944: the name of `assemble`'s pinhole bar — its console line and its refusal. */
|
|
3288
|
+
export const PINHOLES_RULE = 'ASSEMBLE_PINHOLES';
|
|
3289
|
+
|
|
3290
|
+
/** Issue #944: what a pinhole is, said once for the line and the refusal. */
|
|
3291
|
+
const PINHOLE_IS = `an 8-connected opening of the flat parts stack — alpha 0 on every part, enclosed by the parts' alpha above 0 — whose every pixel the painting shows as figure, more than ${SILHOUETTE_LIMIT} off its border ring's median (max channel)`;
|
|
3292
|
+
|
|
3293
|
+
/**
|
|
3294
|
+
* Issue #944: one `ASSEMBLE_PINHOLES` refusal per pinhole, largest first — the
|
|
3295
|
+
* pinhole's box, its pixel count and the parts bordering it, against the bar
|
|
3296
|
+
* of 0 the requirements file `requirements` declares required.
|
|
3297
|
+
*/
|
|
3298
|
+
export function pinholeProblems(pinholes: readonly RecompositeHole[], requirements: string): Problem[] {
|
|
3299
|
+
return pinholes.map((h, i) => ({
|
|
3300
|
+
code: PINHOLES_RULE,
|
|
3301
|
+
object: `pinhole ${i + 1} of ${pinholes.length} at ${holeBox(h)}`,
|
|
3302
|
+
detail:
|
|
3303
|
+
`is ${h.px} px ${h.borders.length === 0 ? 'bordering no part' : `between ${h.borders.map((b) => `"${b.part}" ${b.px} px`).join(', ')}`} — ${PINHOLE_IS}; ` +
|
|
3304
|
+
`${requirements} declares pinholes.required: true, so 0 pinholes are required. Cover it from a layer that holds its pixels (AUTHORING, Source before scissors), or drop the declaration and the bar reports SKIP`,
|
|
3305
|
+
}));
|
|
3306
|
+
}
|
|
3307
|
+
|
|
3308
|
+
/**
|
|
3309
|
+
* Issue #944: the `ASSEMBLE_PINHOLES` line under the hole lines. `null` is the
|
|
3310
|
+
* bar not declared — SKIP with the reason and the declaration remedy, never a
|
|
3311
|
+
* pass; an empty list is the declared bar met. A non-empty list never reaches
|
|
3312
|
+
* here: it was refused (`pinholeProblems`). Alternate sets are not measured, and
|
|
3313
|
+
* the PASS line names them.
|
|
3314
|
+
*/
|
|
3315
|
+
export function pinholesLine(pinholes: readonly RecompositeHole[] | null, alternates: readonly string[]): string {
|
|
3316
|
+
if (pinholes === null) return `${PINHOLES_RULE}: SKIP — not measured, not a pass: no requirements file declares pinholes.required: true; declare {"pinholes": {"required": true}} in the --requirements file (config.requirements under build) to refuse every pinhole, ${PINHOLE_IS}`;
|
|
3317
|
+
if (pinholes.length > 0) throw new Error(`pinholesLine: ${pinholes.length} pinhole(s) reached the PASS line; pinholeProblems refuses them`);
|
|
3318
|
+
return `${PINHOLES_RULE}: PASS — 0 pinholes in the base set's flat parts stack, bar 0 (pinholes.required: true)${alternates.length === 0 ? '' : `; alternate set(s) ${alternates.map((a) => `"${a}"`).join(', ')} not measured`}; a pinhole is ${PINHOLE_IS}`;
|
|
3319
|
+
}
|
|
3320
|
+
|
|
3227
3321
|
/** A hole's box as the part lines and refusals print a box: `x,y wxh`. */
|
|
3228
3322
|
export function holeBox(h: RecompositeHole): string {
|
|
3229
3323
|
return `${h.x},${h.y} ${h.w}x${h.h}`;
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Issue #942: the colour step across part boundaries, against the rig's own pieces.
|
|
3
|
+
*
|
|
4
|
+
* The reference is never the painting: a part drawn on top legitimately shows its own colour over the neighbour's
|
|
5
|
+
* line art, so a painting-relative bar refuses correct overlaps (the #942 v1 reading). The reference is the rig's
|
|
6
|
+
* own parts, drawn by the SAME official rasteriser, so bilinear filtering is the same on both sides:
|
|
7
|
+
*
|
|
8
|
+
* - setup frame: each piece's `parts/<name>.png` as a flat quad at its parts.json rectangle (the layer stack
|
|
9
|
+
* recomposited). A texture stretched into its padded UVs, or a notch the mesh or the texture cuts out of a
|
|
10
|
+
* piece, makes the rig's render differ from that flat draw; a correct overlap does not, because both sides draw
|
|
11
|
+
* the top piece.
|
|
12
|
+
* - every idle frame: the posed frame with each piece re-seated on its own parts PNG (padded as the rig image is)
|
|
13
|
+
* through its own art UVs. A pixel then carries the colour its piece-local point has at rest, so a boundary
|
|
14
|
+
* pair's step there is the same boundary's step at rest, tracked through the pieces' own UVs.
|
|
15
|
+
*
|
|
16
|
+
* The readout is, per 4-adjacent boundary pair (two different drawn owners, in either the render or the
|
|
17
|
+
* reference), the excess of the rendered step over the reference step: max(0, s_render - s_reference), with
|
|
18
|
+
* s = the largest RGB difference across the pair, both composited over white. The bar is the largest excess over
|
|
19
|
+
* every pair of every measured frame, against the rig's declared `boundary_colour.max_step_excess`. Nothing is
|
|
20
|
+
* derived from the art: no declaration, no measurement (SKIP, never a pass).
|
|
21
|
+
*/
|
|
22
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
23
|
+
import { join } from 'node:path';
|
|
24
|
+
import { Plate } from '@rig-c/core/plate';
|
|
25
|
+
import type { Frame, Piece } from '@rig-c/core/render-shared';
|
|
26
|
+
import { seamSourceAttachment } from './check.ts';
|
|
27
|
+
import { cropToSpineY } from './coords.ts';
|
|
28
|
+
import type { Problem } from './errors.ts';
|
|
29
|
+
import type { PartsFile } from './parts.ts';
|
|
30
|
+
import { readPng } from './raster/png.ts';
|
|
31
|
+
|
|
32
|
+
export const BOUNDARY_COLOUR_LINE = 'BOUNDARY_COLOUR_STEP';
|
|
33
|
+
export const BOUNDARY_COLOUR_CODE = 'CHECK_BOUNDARY_COLOUR_STEP';
|
|
34
|
+
export const BOUNDARY_COLOUR_UNMEASURED_CODE = 'CHECK_BOUNDARY_COLOUR_UNMEASURED';
|
|
35
|
+
export const UNDECLARED_BOUNDARY_COLOUR_REASON = 'not measured, not a pass: no requirements file declares boundary_colour; declare {"boundary_colour": {"max_step_excess": <0-255>}} in the --requirements file (config.requirements under build) to hold every part boundary of the setup and every idle frame to the rig\'s own pieces';
|
|
36
|
+
|
|
37
|
+
/** The one declaration: the largest step excess (0-255) the rig allows over its own pieces. No default exists. */
|
|
38
|
+
export interface BoundaryColour {
|
|
39
|
+
max_step_excess: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const isObject = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
43
|
+
const freeKey = (k: string, v: unknown): boolean => (k.startsWith('x-') && k.length > 2 && isObject(v)) || ((k === 'note' || k.endsWith('_note')) && typeof v === 'string');
|
|
44
|
+
|
|
45
|
+
/** Shape only: an object holding exactly an authored integer `max_step_excess` from 0 through 255 (and notes). */
|
|
46
|
+
export function boundaryColourProblems(value: unknown, at: string): Problem[] {
|
|
47
|
+
if (!isObject(value)) return [{ code: 'REQUIREMENTS_FIELD', object: at, detail: `is ${JSON.stringify(value)?.slice(0, 40)}; requires {"max_step_excess": <an authored integer 0-255>}` }];
|
|
48
|
+
const problems: Problem[] = [];
|
|
49
|
+
for (const k of Object.keys(value)) if (k !== 'max_step_excess' && !freeKey(k, value[k])) problems.push({ code: 'REQUIREMENTS_FIELD', object: `${at}.${k}`, detail: 'is not a field this block reads (max_step_excess; note or *_note as a string; x-<name> as a record)' });
|
|
50
|
+
const v = value.max_step_excess;
|
|
51
|
+
if (typeof v !== 'number' || !Number.isSafeInteger(v) || v < 0 || v > 255) problems.push({ code: 'REQUIREMENTS_FIELD', object: `${at}.max_step_excess`, detail: `is ${JSON.stringify(v)}; requires an authored integer from 0 through 255 — the largest RGB step a part boundary may add over the rig's own pieces; no default is derived from the art` });
|
|
52
|
+
return problems;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** One boundary pair's worst reading: where, in which frame, and the two steps the excess is the difference of. */
|
|
56
|
+
export interface BoundaryColourReading {
|
|
57
|
+
parts: [string, string];
|
|
58
|
+
frame: string;
|
|
59
|
+
at: [number, number];
|
|
60
|
+
excess: number;
|
|
61
|
+
rendered_step: number;
|
|
62
|
+
reference_step: number;
|
|
63
|
+
}
|
|
64
|
+
export interface BoundaryColourUnmeasured {
|
|
65
|
+
slot: string;
|
|
66
|
+
frame: string;
|
|
67
|
+
reason: string;
|
|
68
|
+
}
|
|
69
|
+
export interface BoundaryColourMeasured {
|
|
70
|
+
status: 'PASS' | 'FAIL';
|
|
71
|
+
instrument: 'rigc renderer, setup and every idle frame';
|
|
72
|
+
reference: 'setup: parts.json flat pieces; idle: each piece re-seated on its own parts PNG through its art UVs';
|
|
73
|
+
max_step_excess: number;
|
|
74
|
+
frames: number;
|
|
75
|
+
boundary_pairs: number;
|
|
76
|
+
worst: BoundaryColourReading | null;
|
|
77
|
+
/** Every part pair over the allowance, its worst reading each, in part-pair order. */
|
|
78
|
+
over: BoundaryColourReading[];
|
|
79
|
+
/** Pieces the reference could not be drawn for; any one makes the line FAIL (never a pass). */
|
|
80
|
+
unmeasured: BoundaryColourUnmeasured[];
|
|
81
|
+
alternates: 'not measured';
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
type Api = typeof import('@rig-c/core/render-shared');
|
|
85
|
+
|
|
86
|
+
export interface BoundaryColourInput {
|
|
87
|
+
build: string;
|
|
88
|
+
stage: { x: number; y: number; width: number; height: number };
|
|
89
|
+
parts: PartsFile;
|
|
90
|
+
partsDir: string;
|
|
91
|
+
imagesDir: string;
|
|
92
|
+
rig: Record<string, unknown>;
|
|
93
|
+
fps: number;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
interface Source { flat: Plate; padded: Plate; rec: { x: number; y: number; w: number; h: number } }
|
|
97
|
+
|
|
98
|
+
function plateOf(r: { width: number; height: number; data: ArrayLike<number> }, pad: number): Plate {
|
|
99
|
+
const p = new Plate(r.width + 2 * pad, r.height + 2 * pad);
|
|
100
|
+
for (let y = 0; y < r.height; y++) for (let x = 0; x < r.width; x++) for (let c = 0; c < 4; c++) p.data[((y + pad) * p.width + x + pad) * 4 + c] = r.data[(y * r.width + x) * 4 + c];
|
|
101
|
+
return p;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Measure the declared bar on a gated build. `problems` gains one CHECK_BOUNDARY_COLOUR_STEP per part pair over the
|
|
106
|
+
* allowance, and one CHECK_BOUNDARY_COLOUR_UNMEASURED per piece the reference cannot be drawn for.
|
|
107
|
+
*/
|
|
108
|
+
export function measureBoundaryColour(api: Api, input: BoundaryColourInput, declared: BoundaryColour, problems: Problem[]): BoundaryColourMeasured {
|
|
109
|
+
const skeleton = join(input.build, 'skeleton.json'), atlas = join(input.build, 'skeleton.atlas');
|
|
110
|
+
const candidate = api.loadCandidate({ skeletonText: readFileSync(skeleton, 'utf8'), atlasText: readFileSync(atlas, 'utf8'), atlasDir: input.build, label: 'boundary colour' }, { skeleton, atlas }, undefined);
|
|
111
|
+
const opts = { geometry: true, texture: true };
|
|
112
|
+
const setup = api.throughPoser(candidate.choice, (p) => api.sampleSetupPose(p, opts)).value[0];
|
|
113
|
+
const idle = api.throughPoser(candidate.choice, (p) => api.sampleAnimation(p, 'idle', input.fps, opts)).value;
|
|
114
|
+
const W = input.stage.width, H = input.stage.height;
|
|
115
|
+
const vp = { minX: input.stage.x, minY: input.stage.y, maxX: input.stage.x + W, maxY: input.stage.y + H, scale: 1, width: W, height: H };
|
|
116
|
+
const project = api.projector(vp);
|
|
117
|
+
const records = new Map(input.parts.parts.map((p) => [p.name, p]));
|
|
118
|
+
const sources = new Map<string, Source | string>();
|
|
119
|
+
const unmeasured: BoundaryColourUnmeasured[] = [];
|
|
120
|
+
const unmeasuredSeen = new Set<string>();
|
|
121
|
+
const sourceOf = (frame: Frame, piece: Piece, label: string): Source | null => {
|
|
122
|
+
const fail = (reason: string): null => {
|
|
123
|
+
const key = `${piece.slot}\u0000${reason}`;
|
|
124
|
+
if (!unmeasuredSeen.has(key)) { unmeasuredSeen.add(key); unmeasured.push({ slot: piece.slot, frame: label, reason }); }
|
|
125
|
+
return null;
|
|
126
|
+
};
|
|
127
|
+
if (piece.kind === 'mesh' && piece.source !== undefined) return fail('the piece is clipped; a clipped piece has no flat reference');
|
|
128
|
+
if (piece.texture === undefined) return fail('the renderer gave no art UVs for the piece');
|
|
129
|
+
const drawn = (frame.attachments ?? []).filter((a) => a.slot === piece.slot);
|
|
130
|
+
if (drawn.length !== 1) return fail(`the frame draws ${drawn.length} attachment(s) on the slot; one is required`);
|
|
131
|
+
// The drawn attachment is resolved by name the way the seam's source mapping is (an entry's explicit `name`, or
|
|
132
|
+
// else its key), so a skin entry renamed under another key still reads its own image.
|
|
133
|
+
let image: string;
|
|
134
|
+
try { image = String(seamSourceAttachment(input.rig, piece.slot, String(drawn[0].attachment)).image); }
|
|
135
|
+
catch (e) { return fail(`the skin resolves no single image drawing for attachment "${String(drawn[0].attachment)}" (${e instanceof Error ? e.message : String(e)})`); }
|
|
136
|
+
let got = sources.get(image);
|
|
137
|
+
if (got === undefined) {
|
|
138
|
+
const name = image.replace(/\.png$/, '');
|
|
139
|
+
const rec = records.get(name);
|
|
140
|
+
const partPng = join(input.partsDir, `${name}.png`), rigPng = join(input.imagesDir, image);
|
|
141
|
+
if (rec === undefined) got = `parts.json holds no record "${name}" for the image "${image}"`;
|
|
142
|
+
else if (!existsSync(partPng) || !existsSync(rigPng)) got = `${existsSync(partPng) ? `the rig image "${image}"` : `the parts image "${name}.png"`} is absent`;
|
|
143
|
+
else {
|
|
144
|
+
const src = readPng(partPng), rigImg = readPng(rigPng);
|
|
145
|
+
const dx = rigImg.width - src.width, dy = rigImg.height - src.height;
|
|
146
|
+
if (src.width !== rec.w || src.height !== rec.h) got = `parts/${name}.png is ${src.width}x${src.height}, its record ${rec.w}x${rec.h}`;
|
|
147
|
+
else if (dx !== dy || dx < 0 || dx % 2 !== 0) got = `the rig image "${image}" is ${rigImg.width}x${rigImg.height}, not parts/${name}.png's ${src.width}x${src.height} with one even pad on every side`;
|
|
148
|
+
else got = { flat: plateOf(src, 0), padded: plateOf(src, dx / 2), rec };
|
|
149
|
+
}
|
|
150
|
+
sources.set(image, got);
|
|
151
|
+
}
|
|
152
|
+
return typeof got === 'string' ? fail(got) : got;
|
|
153
|
+
};
|
|
154
|
+
const own = (frame: Frame, pages: Map<string, Plate>, pieces: Piece[]): { data: Uint8Array; owner: Int32Array } => {
|
|
155
|
+
const plate = api.renderFrame({ ...frame, pieces }, pages, vp, [255, 255, 255, 255]);
|
|
156
|
+
const owner = new Int32Array(W * H).fill(-1);
|
|
157
|
+
pieces.forEach((piece, i) => api.rasterisePiece(api.pageFor(pages, piece), piece, project, vp, (x, y, _r, _g, _b, a) => { if (a > 0) owner[y * W + x] = i; }));
|
|
158
|
+
return { data: plate.data, owner };
|
|
159
|
+
};
|
|
160
|
+
const pairsWorst = new Map<string, BoundaryColourReading>();
|
|
161
|
+
let worst: BoundaryColourReading | null = null;
|
|
162
|
+
let boundaryPairs = 0;
|
|
163
|
+
const readFrame = (frame: Frame, label: string, flat: boolean): void => {
|
|
164
|
+
const pages = new Map<string, Plate>(candidate.pages);
|
|
165
|
+
const ok: boolean[] = [];
|
|
166
|
+
const refPieces = frame.pieces.map((piece, i): Piece => {
|
|
167
|
+
const s = sourceOf(frame, piece, label);
|
|
168
|
+
ok.push(s !== null);
|
|
169
|
+
if (s === null) return piece;
|
|
170
|
+
const page = `boundary-colour:${i}`;
|
|
171
|
+
if (flat) {
|
|
172
|
+
const { x, y, w, h } = s.rec;
|
|
173
|
+
const wx = (rx: number): number => rx + vp.minX, wy = (ry: number): number => vp.minY + cropToSpineY(ry, H);
|
|
174
|
+
pages.set(page, s.flat);
|
|
175
|
+
// rasteriseQuad reads world/uv pairs 0, 1, 2 as (s=1,t=0), the origin, (s=0,t=1).
|
|
176
|
+
return { kind: 'region', slot: piece.slot, page, tint: piece.tint, ...(piece.dark === undefined ? {} : { dark: piece.dark }), world: [wx(x + w), wy(y), wx(x), wy(y), wx(x), wy(y + h), wx(x + w), wy(y + h)], uvs: [1, 0, 0, 0, 0, 1, 1, 1] };
|
|
177
|
+
}
|
|
178
|
+
pages.set(page, s.padded);
|
|
179
|
+
const { uvWindow: _window, ...rest } = piece;
|
|
180
|
+
return { ...rest, page, uvs: (piece.texture as { artUvs: number[] }).artUvs } as Piece;
|
|
181
|
+
});
|
|
182
|
+
if (ok.some((v) => !v)) return;
|
|
183
|
+
const r = own(frame, candidate.pages, frame.pieces), f = own(frame, pages, refPieces);
|
|
184
|
+
const slots = frame.pieces.map((p) => p.slot);
|
|
185
|
+
for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) {
|
|
186
|
+
const p = y * W + x;
|
|
187
|
+
for (const d of [1, W]) {
|
|
188
|
+
if ((d === 1 && x + 1 >= W) || (d === W && y + 1 >= H)) continue;
|
|
189
|
+
const q = p + d;
|
|
190
|
+
let a = r.owner[p], b = r.owner[q];
|
|
191
|
+
if (a < 0 || b < 0 || a === b) { a = f.owner[p]; b = f.owner[q]; if (a < 0 || b < 0 || a === b) continue; }
|
|
192
|
+
boundaryPairs++;
|
|
193
|
+
let sr = 0, sf = 0;
|
|
194
|
+
for (let c = 0; c < 3; c++) { sr = Math.max(sr, Math.abs(r.data[p * 4 + c] - r.data[q * 4 + c])); sf = Math.max(sf, Math.abs(f.data[p * 4 + c] - f.data[q * 4 + c])); }
|
|
195
|
+
const excess = Math.max(0, sr - sf);
|
|
196
|
+
const pair: [string, string] = slots[a] < slots[b] ? [slots[a], slots[b]] : [slots[b], slots[a]];
|
|
197
|
+
const key = pair.join('|');
|
|
198
|
+
const reading: BoundaryColourReading = { parts: pair, frame: label, at: [x, y], excess, rendered_step: sr, reference_step: sf };
|
|
199
|
+
const prev = pairsWorst.get(key);
|
|
200
|
+
if (prev === undefined || excess > prev.excess) pairsWorst.set(key, reading);
|
|
201
|
+
if (worst === null || excess > worst.excess) worst = reading;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
readFrame(setup, 'setup', true);
|
|
206
|
+
for (const frame of idle) readFrame(frame, `idle frame ${frame.index} (t ${+frame.time.toFixed(4)} s)`, false);
|
|
207
|
+
const over = [...pairsWorst.entries()].sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)).map(([, v]) => v).filter((v) => v.excess > declared.max_step_excess);
|
|
208
|
+
for (const u of unmeasured) problems.push({ code: BOUNDARY_COLOUR_UNMEASURED_CODE, object: `slot "${u.slot}" (${u.frame})`, detail: `${u.reason}; the step against the rig's own pieces cannot be taken, and an untaken reading is never a pass — make the slot draw a parts.json piece, or drop boundary_colour from the requirements file and the bar reports SKIP` });
|
|
209
|
+
for (const o of over) problems.push({ code: BOUNDARY_COLOUR_CODE, object: `part pair "${o.parts[0]}|${o.parts[1]}"`, detail: `${o.frame}, stage pixel (${o.at[0]}, ${o.at[1]}): step excess ${o.excess} > declared max_step_excess ${declared.max_step_excess} (rendered step ${o.rendered_step}, the rig's own pieces ${o.reference_step}); a texture stretched into its padded UVs or a notch cut out of a piece shows here — fix the rig image or mesh of that pair (AUTHORING, failure map)` });
|
|
210
|
+
return {
|
|
211
|
+
status: over.length === 0 && unmeasured.length === 0 ? 'PASS' : 'FAIL',
|
|
212
|
+
instrument: 'rigc renderer, setup and every idle frame',
|
|
213
|
+
reference: 'setup: parts.json flat pieces; idle: each piece re-seated on its own parts PNG through its art UVs',
|
|
214
|
+
max_step_excess: declared.max_step_excess,
|
|
215
|
+
frames: 1 + idle.length,
|
|
216
|
+
boundary_pairs: boundaryPairs,
|
|
217
|
+
worst,
|
|
218
|
+
over,
|
|
219
|
+
unmeasured,
|
|
220
|
+
alternates: 'not measured',
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** The lines `check` prints: the undeclared SKIP, or the measured line and one per part pair over the allowance. */
|
|
225
|
+
export function boundaryColourLines(line: BoundaryColourMeasured | null): string[] {
|
|
226
|
+
if (line === null) return [`${BOUNDARY_COLOUR_LINE}: SKIP — ${UNDECLARED_BOUNDARY_COLOUR_REASON}`];
|
|
227
|
+
const w = line.worst;
|
|
228
|
+
return [
|
|
229
|
+
`${BOUNDARY_COLOUR_LINE}: ${line.status} — ${line.boundary_pairs} boundary pair reading(s) over ${line.frames} frame(s) (setup and every idle frame), worst step excess ${w === null ? 'none (no boundary)' : `${w.excess} at part pair "${w.parts.join('|')}" ${w.frame} (${w.at.join(', ')})`}, declared max_step_excess ${line.max_step_excess}; ${line.reference}; alternate sets not measured`,
|
|
230
|
+
...line.over.map((o) => `${BOUNDARY_COLOUR_LINE}: part pair "${o.parts.join('|')}" FAIL — ${o.frame} (${o.at.join(', ')}), step excess ${o.excess} > ${line.max_step_excess} (rendered ${o.rendered_step}, own pieces ${o.reference_step})`),
|
|
231
|
+
...line.unmeasured.map((u) => `${BOUNDARY_COLOUR_LINE}: slot "${u.slot}" NOT MEASURED — ${u.frame}: ${u.reason}`),
|
|
232
|
+
];
|
|
233
|
+
}
|