@rig-c/parts 1.14.0 → 1.16.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/docs/MIGRATION.md CHANGED
@@ -148,3 +148,65 @@ nothing else.
148
148
  What to do: install with `npm i -D @rig-c/parts` (or `npm install -g @rig-c/parts`);
149
149
  command lines do not change. A dependant that names `rig-parts` keeps receiving
150
150
  every release under it (#222).
151
+
152
+ ## After 1.14.0: the idle is read for folds
153
+
154
+ | | 1.14.0 | after 1.14.0 |
155
+ | --- | --- | --- |
156
+ | `check.json`'s `ANIMATIONS_DO_NOT_FOLD` | written only when `motion.json` holds an animation beside the idle; its `animations` lists those | written whenever `motion.json` holds an animation, a rig of the idle alone included; its `animations` lists every one read, the idle first (#333) |
157
+ | the console's fold line | `N animation(s) beside the idle read by rigc render --folds …` | `N animation(s) read by rigc render --folds …`, the idle counted and named |
158
+ | the summary of a rig with an idle and no other animation | `… of 9 bar(s) measured …` | `… of 10 bar(s) measured …` |
159
+ | `may_fold` naming `"animation": "idle"` | refused, `REQUIREMENTS_MAY_FOLD` (the bar did not read the idle) | accepted like any animation name; a fold it names is listed with its why and not held, and one that does not fold fails the bar |
160
+
161
+ The reading is the one #320 made for the other animations — one `rigc render --folds` at
162
+ 12 fps over the build with its physics constraints left out — and now takes the idle in
163
+ the same run. A rig whose idle turns a mesh triangle over against the setup pose, which
164
+ `check` passed before, fails it, naming the idle and quoting rigc's line. On the four
165
+ public examples and the trailer no idle folds [measured for #333, rig-c 2.47.1], so no
166
+ verdict moved; every example's `expected/check.json` moved by the key and nothing else.
167
+ `reference-demo`, an idle alone, gains the whole key after `TEXTURE_STRETCH`:
168
+
169
+ ```diff
170
+ @@ -259,6 +259,18 @@
171
+ "severity": 1.074
172
+ }
173
+ ]
174
+ + },
175
+ + "ANIMATIONS_DO_NOT_FOLD": {
176
+ + "status": "PASS",
177
+ + "animations": [
178
+ + "idle"
179
+ + ],
180
+ + "fps": 12,
181
+ + "physics_left_out": 0,
182
+ + "folding": [],
183
+ + "folding_undeclared": 0,
184
+ + "declared_not_folding": [],
185
+ + "bar": 0
186
+ },
187
+ "RECOMPOSITE_HOLES": {
188
+ ```
189
+
190
+ and `demo`, `scarf` and `reference-sample` gain `"idle"` as the first entry of its
191
+ `animations`.
192
+
193
+ What to do: nothing for a rig whose idle does not fold. A consumer that reads
194
+ `check.json` finds the key on every rig with an animation. An idle that folds on purpose
195
+ is declared, `"may_fold": [{ "slot": …, "animation": "idle", "why": … }]`, in the
196
+ `--requirements` file.
197
+
198
+ ## After 1.14.0: faint islands under `contour.bridges`
199
+
200
+ `contour.bridges` shipped in 1.14.0 meshing every island past `stray` and bridging it.
201
+ The patch after it reads islands in a fixed order (issue #326, HQ's ruling): dust first
202
+ (`stray`), then **faint** islands — every pixel's alpha under 64 — left out and named,
203
+ then every visible island bridged whatever its bridge costs. No field was added and
204
+ no declared form changed.
205
+
206
+ | change | what moved | measured |
207
+ | --- | --- | --- |
208
+ | #326, faint islands left out | only a part that declares `contour.bridges` and holds a faint island (not the largest): its mesh, its `mesh_report.json` row (`contour.bridges.faint`, and `art_coverage`, which counts those pixels as art not covered) and, from `mesh-draft`, its written entry's note and `mesh_draft.json`'s `candidate.bridges.faint`. A part without a faint island writes the bytes 1.14.0 wrote | demo `earring` (the only such part a `mesh-draft` of the public examples drafts): its 8, 2 and 1 px specks, brightest alpha 17, 17 and 11, are left out — 33 → 26 triangles, overdraw 4.82 → 1.94; the idle rendered at scale 1.01 differs from the build of #335 (the change 1.14.0 carries) by at most 9 of 255 levels on at most 38 px where the specks were (4 frames; elsewhere 158 px differ by 1 level, as they do with the earring slot hidden), and `check`'s seam bar reads 0 px over 40 on both |
209
+
210
+ What to do: nothing for a config without `contour.bridges`. A part that declares it and
211
+ holds a faint island no longer draws that island; if those pixels matter, keep the
212
+ lattice for that part.
package/docs/STABILITY.md CHANGED
@@ -35,8 +35,9 @@ Exit codes are part of the interface: **0** the command did what it says, **1**
35
35
  refused its input (every reason a `FAIL` line), **2** a usage error or a command
36
36
  this version does not implement. No command is unimplemented at 1.0.0 (`LATER`
37
37
  in `cli.ts` is empty). A refusal line has the shape
38
- ` FAIL <RULE>: <object> — <value found>; <value required>`: the rule name is a
39
- stable identifier, and the detail after it is prose that may be reworded. A command
38
+ ` FAIL <RULE>: <object> — <value found>; <value required> (see rig-parts guide <RULE>)`:
39
+ the rule name is a stable identifier, the pointer at the end is fixed text, and the
40
+ detail between them is prose that may be reworded. A command
40
41
  also answers to the alias `spine-parts` as long as the install keeps it
41
42
  ([MIGRATION.md](MIGRATION.md)). `-h`, `help` and `-v` are the short forms of
42
43
  `--help` and `--version`. The flags' meanings are `rig-parts --help`'s; their
@@ -60,6 +61,7 @@ defaults are the help's too, and none is guessed from the art.
60
61
  | `build` | `--config`, `--source`, `--full`, `--head`, `--out`; optional `--seam`, `--project`, `--page-edges`, `--pack-shape`, `--loop`, `--lead-in`, `--requirements`, `--idle-keys`, `--seam-pairs`, `--keep-stills` |
61
62
  | `compose` | `--scene`, `--out`; optional `--requirements`, `--accept` |
62
63
  | `mesh-draft` | `--config`, `--parts`, `--out`; optional `--requirements`, `--idle-keys` (ctl, direct), `--page-edges` (pot, free), `--pack-shape` (rect, polygon), `--more-triangles` |
64
+ | `guide` | one positional argument: a rule name, `§<n>`, `§<n>.<m>` or `§<n>.<m>.<k>` |
63
65
  | (none) | `--version`, `--help` |
64
66
  <!-- /stable:commands -->
65
67
 
@@ -171,11 +173,11 @@ every number) are AUTHORING §3's tables, and the loader refuses any other by na
171
173
  | --- | --- | --- |
172
174
  | `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), 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`, `seam_override_px`, and `fringe_pushed_back` when not empty); `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) |
173
175
  | `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 |
174
- | `rig/mesh_report.json` | `rig`, `build` | one row per mesh: `part`, `vertices`, `triangles`, `hull`, `bones`, `max_influences`, `mean_influences`, `art_coverage`, then `grid` (a lattice); `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` — only when it declares `contour.bridges`); 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) |
175
- | `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`, `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 beside the idle (issue #320: `status`, `animations`, `fps`, `physics_left_out`, `folding`, `folding_undeclared`, `declared_not_folding`, `bar`), `RECOMPOSITE_HOLES` (its first figure `declared_scene` when `parts.json` declares a scene), `SETUP_POSE_VS_SOURCE`, `skipped`, a `requirements` block under `--requirements`, and `PASS`; `check/build/skeleton.json`, `.atlas` and `.png` are the packed artifact rig-c writes |
176
+ | `rig/mesh_report.json` | `rig`, `build` | one row per mesh: `part`, `vertices`, `triangles`, `hull`, `bones`, `max_influences`, `mean_influences`, `art_coverage`, then `grid` (a lattice); `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` "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) |
177
+ | `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`, `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 (issue #320: `status`, `animations`, `fps`, `physics_left_out`, `folding`, `folding_undeclared`, `declared_not_folding`, `bar`), `RECOMPOSITE_HOLES` (its first figure `declared_scene` when `parts.json` declares a scene), `SETUP_POSE_VS_SOURCE`, `skipped`, a `requirements` block under `--requirements`, and `PASS`; `check/build/skeleton.json`, `.atlas` and `.png` are the packed artifact rig-c writes |
176
178
  | `proposal.json`, `render/landmarks*.png` | `propose` | the config's four rig sections, `turn` with `--full`/`--head`, and `notes` |
177
179
  | `basis.json` | `propose` | spec `spine-parts-basis/1` |
178
- | `config.json`, `mesh_draft.json` | `mesh-draft` | `config.json` is the config read, byte for byte, but for each drafted part's `meshes` entry; `mesh_draft.json` is spec `rig-parts-mesh-draft/1`: `rules`, `lattice_check`, `draft_check`, `figure_overdraw`, and per lattice part `part`, `mode`, `lattice`, `candidate` (with `bridges` — `units`, `joins` of `span` and `area_px2`, `area_px2`, `overdraw` — only when the candidate is a bridged one, issue #326), `delta` (under `--more-triangles` only), `readings`, `gate` (each line's `reading`, `pass`, `text`, `margin`), `note`; and `not_compared` (issue #307), only when the config holds an `auto` or `contour` entry: per such entry, in config order, `part`, `mode` and `note` — the part was neither drafted nor compared, and a config of lattices alone writes no such key |
180
+ | `config.json`, `mesh_draft.json` | `mesh-draft` | `config.json` is the config read, byte for byte, but for each drafted part's `meshes` entry; `mesh_draft.json` is spec `rig-parts-mesh-draft/1`: `rules`, `lattice_check`, `draft_check`, `figure_overdraw`, and per lattice part `part`, `mode`, `lattice`, `candidate` (with `bridges` — `units`, `joins` of `span` and `area_px2`, `area_px2`, `overdraw`, and `faint` only when an island was left out as faint — only when the candidate is a bridged one, issue #326), `delta` (under `--more-triangles` only), `readings`, `gate` (each line's `reading`, `pass`, `text`, `margin`), `note`; and `not_compared` (issue #307), only when the config holds an `auto` or `contour` entry: per such entry, in config order, `part`, `mode` and `note` — the part was neither drafted nor compared, and a config of lattices alone writes no such key |
179
181
  | the scene report | `compose` | spec `spine-parts-scene-report/1` |
180
182
  | `idle.png`, `idle-indexed.png`, `idle.gif` | `loop`, `build --loop` | APNG and GIF encodings of the rendered idle |
181
183
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rig-c/parts",
3
- "version": "1.14.0",
3
+ "version": "1.16.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": {
@@ -13,6 +13,13 @@ rig-c, which compiles, gates, packs and renders. You cannot see the painting,
13
13
  the layers or the rig. The printed lines, `docs/AUTHORING.md` and the files each
14
14
  stage writes are the whole interface.
15
15
 
16
+ **A FAIL line ends with the command that prints its row of the guide:**
17
+ `FAIL RULE: object — detail (see rig-parts guide RULE)`. Run that command before
18
+ anything else. It prints the one row of `docs/AUTHORING.md` that maps the rule to
19
+ what has to change, read from the installed file. `rig-parts guide §<n>` prints a
20
+ section, `§<n>.<m>` the m-th `###` under it and `§<n>.<m>.<k>` the k-th `####`
21
+ under that.
22
+
16
23
  ## Non-negotiables
17
24
 
18
25
  - **rig-c's gate is never bypassed.** rig-parts writes no Spine data
@@ -26,7 +33,7 @@ stage writes are the whole interface.
26
33
  because the tool refused.
27
34
  - **The refusal is the instruction.** `FAIL RULE: object — detail` names the object,
28
35
  the value found and the value required; AUTHORING §6 maps every rule to the field
29
- or input that has to change.
36
+ or input that has to change, and `rig-parts guide RULE` prints that row.
30
37
  - **Green is not "looks right".** `check` measures the gates, the seam, the loop and
31
38
  six judgement lines (`BREATH_VISIBLE`, `BLINK_NO_HOLE`, `CHAIN_LAG`,
32
39
  `TIP_OVER_ROOT`, `STILL_REGIONS_DARK` — its face half in the head's own frame —
package/src/build.ts CHANGED
@@ -70,9 +70,9 @@ import {
70
70
  splitSchedule,
71
71
  untestedIntervals,
72
72
  } from './autoreplay.ts';
73
- import { besideIdleLine, FOLD_LINE, type FoldsLine, buildGateLines, causeLines, IDLE_FPS, IDLE_MAX_PX, maxRgbDiff, REQUIREMENTS_DIR, type CheckReport, type PackLine, DEFAULT_PACK_SHAPE, DEFAULT_PAGE_EDGES, JUDGEMENT_LINES, type JudgementLine, packedBuildArgs, packedBuildLabel, type PackMode, type PackShape, type PageEdges, readFrameSet, rigcFailed, REPORTED_LINES, type ReportedLine, type RigcRunner, runCheck, SEAM_DIR, SEAM_MEAN_BAR, seamPairText, seamRigPxText, SOURCE_LINE, SEAM_PX_BAR, SEAM_PX_LEVEL, SEAM_PX_LEVEL_HIGH, SPINEBOY_YARDSTICK, STILL_DIR } from './check.ts';
73
+ import { besideIdleLine, FOLD_LINE, type FoldsLine, buildGateLines, causeLines, IDLE_FPS, IDLE_MAX_PX, maxRgbDiff, REQUIREMENTS_DIR, type CheckReport, type PackLine, DEFAULT_PACK_SHAPE, DEFAULT_PAGE_EDGES, JUDGEMENT_LINES, type JudgementLine, packedBuildArgs, packedBuildLabel, type PackMode, type PackShape, type PageEdges, readFrameSet, rigcFailed, rigcIdentity, rigcRanClause, REPORTED_LINES, type ReportedLine, type RigcRunner, runCheck, SEAM_DIR, SEAM_MEAN_BAR, seamPairText, seamRigPxText, SOURCE_LINE, SEAM_PX_BAR, SEAM_PX_LEVEL, SEAM_PX_LEVEL_HIGH, SPINEBOY_YARDSTICK, STILL_DIR } from './check.ts';
74
74
  import { type Cut, loadConfig, loadConfigAndAnimations, loadEarlyConfig } from './config.ts';
75
- import { PartsError, type Problem, problemLine, refuseIfAny } from './errors.ts';
75
+ import { failLine, PartsError, type Problem, refuseIfAny } from './errors.ts';
76
76
  import type { MeshQualityReport, MotionSchedule } from 'rig-c/mesh';
77
77
  import { encodeGif } from './gif.ts';
78
78
  import { CONTROL_SUFFIX, withAnimations } from './motion.ts';
@@ -277,6 +277,8 @@ export interface GateRun {
277
277
  * {@link causeLines} says name why.
278
278
  */
279
279
  lines: string[];
280
+ /** Which rigc ran it, when the runner said ({@link RigcCall.ran}; issue #337). */
281
+ ran?: string;
280
282
  }
281
283
 
282
284
  /**
@@ -295,7 +297,7 @@ function gateRun(label: string, rigc: RigcRunner, args: string[]): GateRun {
295
297
  const r = rigc(args);
296
298
  const lines = r.out.split('\n').filter((l) => /^ {2}FAIL {2}/.test(l) || /assertions: \d+ measured/.test(l) || /^rigc compile error/.test(l) || DECLARED_SKIP.test(l) || HERE_LINE.test(l));
297
299
  const failed = lines.some((l) => /^ {2}FAIL {2}/.test(l) || /^rigc compile error/.test(l));
298
- return { label, status: r.status, lines: r.status !== 0 && !failed ? [...lines, ...causeLines(r.out)] : lines };
300
+ return { label, status: r.status, lines: r.status !== 0 && !failed ? [...lines, ...causeLines(r.out)] : lines, ...(r.ran === undefined ? {} : { ran: r.ran }) };
299
301
  }
300
302
 
301
303
  /**
@@ -490,7 +492,7 @@ export function motionGates(
490
492
  {
491
493
  code: 'AUTO_MESH_MOTION_INPUT',
492
494
  object: c.object,
493
- detail: `the candidate with the declared stimulus (the rig as written, its motion carrying the stimulus animations) ${candidate.status !== 0 ? `is refused by rigc's gate, exit ${candidate.status}${candidate.lines.length > 0 ? `: ${candidate.lines.map((l) => l.trim()).join(' | ')}` : ''}` : `built green and wrote no ${RIGC_MODEL_DOCUMENT}`}; a candidate that is not gated is not evidence, and nothing is built in the part's place`,
495
+ detail: `the candidate with the declared stimulus (the rig as written, its motion carrying the stimulus animations) ${candidate.status !== 0 ? `is refused by rigc's gate, exit ${candidate.status}${candidate.lines.length > 0 ? `: ${candidate.lines.map((l) => l.trim()).join(' | ')}` : ''}${rigcRanClause(candidate)}` : `built green and wrote no ${RIGC_MODEL_DOCUMENT}`}; a candidate that is not gated is not evidence, and nothing is built in the part's place`,
494
496
  },
495
497
  ],
496
498
  notCompared: 'the stimulus candidate build is red',
@@ -520,7 +522,7 @@ export function motionGates(
520
522
  {
521
523
  code: 'AUTO_MESH_MOTION_INPUT',
522
524
  object: c.object,
523
- detail: `the reference (the rig with "${c.part}" as its unreduced source mesh) ${gate.status !== 0 ? `is refused by rigc's gate, exit ${gate.status}${gate.lines.length > 0 ? `: ${gate.lines.map((l) => l.trim()).join(' | ')}` : ''}` : `built green and wrote no ${RIGC_MODEL_DOCUMENT}`}; a reference that is not gated is not evidence, and nothing is built in the part's place`,
525
+ detail: `the reference (the rig with "${c.part}" as its unreduced source mesh) ${gate.status !== 0 ? `is refused by rigc's gate, exit ${gate.status}${gate.lines.length > 0 ? `: ${gate.lines.map((l) => l.trim()).join(' | ')}` : ''}${rigcRanClause(gate)}` : `built green and wrote no ${RIGC_MODEL_DOCUMENT}`}; a reference that is not gated is not evidence, and nothing is built in the part's place`,
524
526
  },
525
527
  ],
526
528
  notCompared: 'the reference build is red',
@@ -642,7 +644,7 @@ export function replaySearch(
642
644
  fatal = {
643
645
  code: 'AUTO_MESH_MOTION_INPUT',
644
646
  object: c.object,
645
- detail: `the replay to accepted step ${step} (the rig with "${c.part}" as that step's mesh) ${gate.status !== 0 ? `is refused by rigc's gate, exit ${gate.status}${gate.lines.length > 0 ? `: ${gate.lines.map((l) => l.trim()).join(' | ')}` : ''}` : `built green and wrote no ${RIGC_MODEL_DOCUMENT}`}; a candidate that is not gated is not evidence, and nothing is built in the part's place`,
647
+ detail: `the replay to accepted step ${step} (the rig with "${c.part}" as that step's mesh) ${gate.status !== 0 ? `is refused by rigc's gate, exit ${gate.status}${gate.lines.length > 0 ? `: ${gate.lines.map((l) => l.trim()).join(' | ')}` : ''}${rigcRanClause(gate)}` : `built green and wrote no ${RIGC_MODEL_DOCUMENT}`}; a candidate that is not gated is not evidence, and nothing is built in the part's place`,
646
648
  };
647
649
  return refused(`rigc exit ${gate.status}`);
648
650
  }
@@ -897,7 +899,7 @@ export function rigStage(input: RigStageInput, rigc: RigcRunner, scratch: string
897
899
  const problems: Problem[] = red.map((g) => ({
898
900
  code: 'RIG_RIGC_GREEN',
899
901
  object: `rigc ${g.label}`,
900
- detail: `exited ${g.status}${g.lines.length > 0 ? `: ${g.lines.map((l) => l.trim()).join(' | ')}` : ', and it printed nothing'}; ${ctlRemedies(g.lines, rig.controls, input.command ?? 'rig').map((s) => `${s}; `).join('')}exit 0 is required before anything is written, and nothing was`,
902
+ detail: `exited ${g.status}${g.lines.length > 0 ? `: ${g.lines.map((l) => l.trim()).join(' | ')}` : ', and it printed nothing'}${rigcRanClause(g)}; ${ctlRemedies(g.lines, rig.controls, input.command ?? 'rig').map((s) => `${s}; `).join('')}exit 0 is required before anything is written, and nothing was`,
901
903
  }));
902
904
  throw new PartsError(problems);
903
905
  }
@@ -1031,14 +1033,14 @@ export function judgementLine(name: string, line: JudgementLine | ReportedLine):
1031
1033
  }
1032
1034
 
1033
1035
  /**
1034
- * The fold line (issue #320) as the console prints it: the status, what was
1036
+ * The fold line (issues #320, #333) as the console prints it: the status, what was
1035
1037
  * read and at what rate, and then each folding mesh on a line of its own,
1036
1038
  * rigc's own words, with the declaration's why where `may_fold` takes it.
1037
1039
  */
1038
1040
  export function foldsLines(line: FoldsLine): string[] {
1039
1041
  const physics = line.physics_left_out === 0 ? '' : `, its ${line.physics_left_out} physics constraint(s) left out`;
1040
1042
  const head =
1041
- `${FOLD_LINE}: ${line.status} — ${line.animations.length} animation(s) beside the idle read by rigc render --folds at ${line.fps} fps${physics} (${line.animations.join(', ')}): ` +
1043
+ `${FOLD_LINE}: ${line.status} — ${line.animations.length} animation(s) read by rigc render --folds at ${line.fps} fps${physics} (${line.animations.join(', ')}): ` +
1042
1044
  `${line.folding.length} folding mesh(es), ${line.folding_undeclared} not declared in may_fold (<= ${line.bar})` +
1043
1045
  (line.declared_not_folding.length === 0 ? '' : `; may_fold declares ${line.declared_not_folding.map((d) => `slot "${d.slot}" in "${d.animation}"`).join(', ')}, which do(es) not fold`);
1044
1046
  return [head, ...line.folding.map((r) => ` folds ${r.line}${r.declared === null ? '' : ` — declared in may_fold: ${r.declared}`}`)];
@@ -1060,7 +1062,7 @@ export function checkStage(input: CheckStageInput, rigc: RigcRunner, bin: string
1060
1062
  log(`rig-parts check: ${input.rig} -> ${input.out}`);
1061
1063
  const versionLines = v.out.trim().split('\n');
1062
1064
  const entryLine = versionLines.find((l) => l.startsWith('entry:'));
1063
- log(` rigc ${versionLines[0]} at ${bin}${entryLine === undefined ? '' : `; ${entryLine}`}`);
1065
+ log(` ${rigcIdentity(bin, v)}${entryLine === undefined ? '' : `; ${entryLine}`}`);
1064
1066
  const mode: PackMode = { pageEdges: input.pageEdges ?? DEFAULT_PAGE_EDGES, packShape: input.packShape ?? DEFAULT_PACK_SHAPE };
1065
1067
  const r = runCheck(input.rig, input.out, rigc, input.parts, mode, input.source, input.requirements, input.seamPairs === true, input.keepStills === true);
1066
1068
  log(` gate spine-html (rigc ${packedBuildLabel(mode)}), verbatim:`);
@@ -1093,7 +1095,7 @@ export function checkStage(input: CheckStageInput, rigc: RigcRunner, bin: string
1093
1095
  }
1094
1096
  log(` gate: spine-html ${fig.gate_spine_html_green ? 'green' : 'RED'} (${fig.rigc_entry.entry}${fig.rigc_entry.spine_core === null ? ', rigc\'s own validator' : `, the spine-core ${fig.rigc_entry.spine_core} round trip`})`);
1095
1097
  log(` wrote ${r.written.map((w) => join(input.out, w)).join(', ')}, ${join(input.out, 'build')}/${r.idle === null ? '' : `, ${join(input.out, 'idle_frames')}/`}${r.requirements === null ? '' : `, ${join(input.out, REQUIREMENTS_DIR)}/`}${fig.seam_pairs === undefined ? '' : `, ${join(input.out, SEAM_DIR)}/`}${existsSync(join(input.out, STILL_DIR)) ? `, ${join(input.out, STILL_DIR)}/` : ''}`);
1096
- for (const p of r.problems) log(` FAIL ${problemLine(p)}`);
1098
+ for (const p of r.problems) log(failLine(p));
1097
1099
  const reqNotPass = r.requirements === null ? 0 : r.requirements.summary.declared - r.requirements.summary.pass;
1098
1100
  const notMet = r.requirements === null ? `${r.problems.length} bar(s) not met` : `${r.problems.length - reqNotPass} bar(s) not met, ${reqNotPass} declared requirement(s) not PASS`;
1099
1101
  log(`${fig.PASS ? 'check: PASS' : `check: FAIL — ${notMet}`}; ${barsSummary(r)}`);
@@ -1464,7 +1466,7 @@ export function build(input: BuildInput, run: BuildRunners, log: Log): BuildResu
1464
1466
  const refused = (stage: BuildStage, err: unknown, check: CheckReport | null = null): BuildResult => {
1465
1467
  if (!(err instanceof PartsError)) throw err;
1466
1468
  const say = prefixed(stage);
1467
- for (const p of err.problems) say(` FAIL ${problemLine(p)}`);
1469
+ for (const p of err.problems) say(failLine(p));
1468
1470
  say(`refused: ${err.problems.length} problem(s)`);
1469
1471
  log(`build: stopped at ${stage}; no later stage ran`);
1470
1472
  return { stoppedAt: stage, check, artifact: [] };
package/src/check.ts CHANGED
@@ -56,9 +56,9 @@
56
56
  * uncovered holes `assemble` recorded in `parts.json`'s `recomposite` block,
57
57
  * copied into `check.json` because the seam cannot see them — it compares the
58
58
  * setup pose with the flat stack of parts, and a pixel no part holds is
59
- * missing from both. When `motion.json` holds an animation beside the idle,
60
- * one more bar, `ANIMATIONS_DO_NOT_FOLD` (issue #320): rigc's own
61
- * `render --folds` over every animation, physics constraints left out
59
+ * missing from both. When `motion.json` holds an animation, one more bar,
60
+ * `ANIMATIONS_DO_NOT_FOLD` (issues #320, #333): rigc's own `render --folds`
61
+ * over every animation, the idle included, physics constraints left out
62
62
  * ({@link FOLD_LINE}). The part-alone renders, the shut-eye still, the keyed
63
63
  * build and the fold render are scratch (`_isolated/`, `_still/`, `_loop/`,
64
64
  * `_folds/`), removed before the stage returns.
@@ -317,10 +317,58 @@ export interface RigcCall {
317
317
  status: number;
318
318
  /** stdout then stderr. */
319
319
  out: string;
320
+ /**
321
+ * Which rigc ran the call, as {@link rigcIdentity} words it (`rigc <version> at <path>`), from a runner that knows
322
+ * its binary ({@link rigcRunnerAt}): set on every call that exited non-zero and on every `--version` call, so a
323
+ * refusal quoting the call names the binary and its version (issue #337). Absent from a runner that does not know.
324
+ */
325
+ ran?: string;
320
326
  }
321
327
 
322
328
  export type RigcRunner = (args: readonly string[]) => RigcCall;
323
329
 
330
+ /** The process primitive {@link rigcRunnerAt} is given: `src/` spawns nothing, so the caller's spawn is injected. */
331
+ export type RigcSpawn = (bin: string, args: readonly string[]) => RigcCall;
332
+
333
+ /**
334
+ * A rigc binary as what it says it is: `rigc <version> at <path>`, the version being the first line `--version`
335
+ * printed — the line `check` prints before it builds (`checkStage`). A `--version` run that exited non-zero or
336
+ * printed nothing is said so, with the path, rather than read as a version.
337
+ */
338
+ export function rigcIdentity(bin: string, version: RigcCall): string {
339
+ const first = version.out.trim().split('\n')[0] ?? '';
340
+ if (version.status === 0 && first !== '') return `rigc ${first} at ${bin}`;
341
+ return `rigc (\`--version\` exited ${version.status}${first === '' ? ' and printed nothing' : `, printing ${JSON.stringify(first)}`}) at ${bin}`;
342
+ }
343
+
344
+ /** The clause a refusal quoting a rigc call ends with: which rigc ran it ({@link RigcCall.ran}), or nothing when the runner did not say. */
345
+ export function rigcRanClause(call: { ran?: string }): string {
346
+ return call.ran === undefined ? '' : `; ${call.ran} ran it`;
347
+ }
348
+
349
+ /**
350
+ * The runner every stage is given (issue #337): `bin` spawned through `spawn`, each call that exited non-zero, and
351
+ * each `--version` call, carrying {@link RigcCall.ran}. The identity is read once per runner, off its first
352
+ * `--version` call or, when a call fails before any, off one `--version` run then — so a green run spawns nothing
353
+ * extra, and a failure names the binary and version that produced it rather than leaving the reader to guess
354
+ * which rigc a path resolved to.
355
+ */
356
+ export function rigcRunnerAt(bin: string, spawn: RigcSpawn): RigcRunner {
357
+ let identity: string | null = null;
358
+ return (args) => {
359
+ const call = spawn(bin, args);
360
+ const version = args.length === 1 && args[0] === '--version';
361
+ if (version) {
362
+ const own = rigcIdentity(bin, call);
363
+ identity ??= own;
364
+ return { ...call, ran: own };
365
+ }
366
+ if (call.status === 0) return call;
367
+ identity ??= rigcIdentity(bin, spawn(bin, ['--version']));
368
+ return { ...call, ran: identity };
369
+ };
370
+ }
371
+
324
372
  /** A line in which a rigc run that stopped says why: Bun's own `error: …`, rigc's `rigc <command>: …` refusals, a `usage: …` line. */
325
373
  const CAUSE = /^(error: |rigc[\w -]*: |usage: )/;
326
374
 
@@ -396,6 +444,27 @@ export function readRigcEntry(versionOut: string): RigcEntryRecord {
396
444
  throw new Error('unreachable');
397
445
  }
398
446
 
447
+ /**
448
+ * This package's own rigc: `node_modules/.bin/rigc` under `root` and nowhere else — no directory above it and no
449
+ * PATH, which {@link findRigc} walks (issue #337). The selftest's suites run rigc through it from the package root,
450
+ * so a fixture in a temp directory, or a tree nested inside another checkout (a harness worktree under
451
+ * `.claude/worktrees/`) whose own install is missing, cannot reach another tree's rigc. A miss is refused naming
452
+ * the one path looked at.
453
+ */
454
+ export function packageRigc(root: string): string {
455
+ const bin = join(resolve(root), 'node_modules', '.bin', 'rigc');
456
+ if (!existsSync(bin)) {
457
+ refuseIfAny([
458
+ {
459
+ code: 'CHECK_RIGC_PACKAGE',
460
+ object: bin,
461
+ detail: `does not exist; this package's own rigc is required here and no other is looked for — not in a directory above ${resolve(root)}, not on PATH (\`bun install\` puts it there)`,
462
+ },
463
+ ]);
464
+ }
465
+ return bin;
466
+ }
467
+
399
468
  /**
400
469
  * Where the `rigc` binary is: `node_modules/.bin/rigc` in `from` or any
401
470
  * directory above it (a clone has it beside the package; an install has it in
@@ -1646,7 +1715,7 @@ export interface CheckFigures {
1646
1715
  TIP_OVER_ROOT: JudgementLine;
1647
1716
  STILL_REGIONS_DARK: JudgementLine;
1648
1717
  TEXTURE_STRETCH: JudgementLine;
1649
- /** Written only when motion.json holds an animation beside the idle (issue #320): {@link FoldsLine}. */
1718
+ /** Written whenever motion.json holds an animation, the idle included (issues #320, #333): {@link FoldsLine}. */
1650
1719
  [FOLD_LINE]?: FoldsLine;
1651
1720
  RECOMPOSITE_HOLES: ReportedLine;
1652
1721
  /** Written only under `--source` (issue #77): the setup pose against the painting, REPORTED. */
@@ -1672,8 +1741,9 @@ export interface RequirementsBlock {
1672
1741
  }
1673
1742
 
1674
1743
  /**
1675
- * The bar over the animations beside the idle (issue #320): no mesh triangle
1676
- * turned over against the setup pose on any frame, on rig-c's own reading —
1744
+ * The bar over every animation, the idle included (issues #320, #333): no mesh
1745
+ * triangle turned over against the setup pose on any frame, on rig-c's own
1746
+ * reading —
1677
1747
  * `rigc render --folds`, one run over every animation the build holds — and
1678
1748
  * not a reading of this package's. A fold the art means is declared in the
1679
1749
  * requirements file's `may_fold`, by slot and animation with its why, and is
@@ -1689,8 +1759,12 @@ export interface RequirementsBlock {
1689
1759
  * not tracked]. The keys and weights fold at every rate or at none that the
1690
1760
  * sampling resolves; the rate a consumer steps physics at is the scene's.
1691
1761
  *
1692
- * The idle is not read here: written only when the motion holds an animation
1693
- * beside it, so a rig of the idle alone writes and prints what it did.
1762
+ * The idle is read like any other animation (issue #333): `TEXTURE_STRETCH`
1763
+ * reads how far the idle's triangles stretch, and a triangle that turns inside
1764
+ * out is this bar's. Written whenever the motion holds an animation, so a rig
1765
+ * of the idle alone writes it too; on the public examples and the trailer no
1766
+ * idle folds, keys only [measured for issue #333, rigc 2.47.1, a scratch run,
1767
+ * not tracked].
1694
1768
  */
1695
1769
  export const FOLD_LINE = 'ANIMATIONS_DO_NOT_FOLD';
1696
1770
 
@@ -1777,7 +1851,7 @@ export function besideIdleLine(names: readonly string[]): string | null {
1777
1851
  }
1778
1852
 
1779
1853
  // ---------------------------------------------------------------------------
1780
- // the animations beside the idle, read for folds (issue #320)
1854
+ // every animation, the idle included, read for folds (issues #320, #333)
1781
1855
  // ---------------------------------------------------------------------------
1782
1856
 
1783
1857
  /**
@@ -1792,7 +1866,7 @@ export const FOLD_RENDER_MAX = 16;
1792
1866
  /** The fold render, under `--out`; removed before the stage returns. */
1793
1867
  const FOLD_SCRATCH = '_folds';
1794
1868
 
1795
- /** One mesh that folds in one animation beside the idle: rigc's figures, the declaration that takes it if any, and rigc's own line. */
1869
+ /** One mesh that folds in one animation: rigc's figures, the declaration that takes it if any, and rigc's own line. */
1796
1870
  export interface FoldRow {
1797
1871
  animation: string;
1798
1872
  slot: string;
@@ -1811,10 +1885,10 @@ export interface FoldRow {
1811
1885
  line: string;
1812
1886
  }
1813
1887
 
1814
- /** `check.json`'s `ANIMATIONS_DO_NOT_FOLD`: written only when the motion holds an animation beside the idle. */
1888
+ /** `check.json`'s `ANIMATIONS_DO_NOT_FOLD`: written whenever the motion holds an animation, the idle included. */
1815
1889
  export interface FoldsLine {
1816
1890
  status: 'PASS' | 'FAIL';
1817
- /** Every animation beside the idle, in motion.json's order: each one read. */
1891
+ /** Every animation, the idle included, in motion.json's order: each one read. */
1818
1892
  animations: string[];
1819
1893
  fps: number;
1820
1894
  /** How many physics constraints the read build left out ({@link withoutPhysics}); 0 when the rig has none. */
@@ -1957,8 +2031,9 @@ export function rigcFailed(what: string, call: RigcCall, lines: readonly string[
1957
2031
  const named = lines.filter((l) => l.includes('FAIL') || l.includes('compile error'));
1958
2032
  const quoted = named.length > 0 ? named.map((l) => l.trim()) : causeLines(call.out);
1959
2033
  const object = `\`rigc ${what}\``;
1960
- if (quoted.length === 0) return [{ code: 'CHECK_RIGC_GREEN', object, detail: `exit ${call.status}, and it printed nothing` }];
1961
- return quoted.map((l) => ({ code: 'CHECK_RIGC_GREEN', object, detail: `exit ${call.status}: ${l}` }));
2034
+ const ran = rigcRanClause(call);
2035
+ if (quoted.length === 0) return [{ code: 'CHECK_RIGC_GREEN', object, detail: `exit ${call.status}, and it printed nothing${ran}` }];
2036
+ return quoted.map((l) => ({ code: 'CHECK_RIGC_GREEN', object, detail: `exit ${call.status}: ${l}${ran}` }));
1962
2037
  }
1963
2038
 
1964
2039
  /**
@@ -2065,7 +2140,7 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
2065
2140
  let loopSet: FrameSet | null = null;
2066
2141
  let loopPhysics: LoopPhysics[] | null = null;
2067
2142
  // The keyed build (issues #183, #320): with a physics constraint, the same rig and motion with every one left out,
2068
- // built once the same way, which the loop and the folds of the animations beside the idle are both read from.
2143
+ // built once the same way, which the loop and the folds of every animation are both read from.
2069
2144
  const stripped = withoutPhysics(inp.rig, inp.motion);
2070
2145
  let keyed: string | null = null;
2071
2146
  const keyedBuild = (what: string): string => {
@@ -2080,7 +2155,8 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
2080
2155
  keyed = join(loopDir, 'build');
2081
2156
  return keyed;
2082
2157
  };
2083
- const named = isRecord(inp.motion.animations) ? Object.keys(inp.motion.animations).filter((n) => n !== 'idle') : [];
2158
+ const animationNames = isRecord(inp.motion.animations) ? Object.keys(inp.motion.animations) : [];
2159
+ const named = animationNames.filter((n) => n !== 'idle');
2084
2160
  let folds: FoldsLine | null = null;
2085
2161
  const foldProblems: Problem[] = [];
2086
2162
  try {
@@ -2133,15 +2209,15 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
2133
2209
  written.push('motion_heat.png');
2134
2210
  }
2135
2211
 
2136
- // 3a. the animations beside the idle, read for folds by rigc (issue #320) — only when there is one; at the idle's
2137
- // rate whatever --requirements says, so a requirements file moves only what it declares (CK78), from the keyed build
2138
- if (named.length > 0) {
2212
+ // 3a. every animation, the idle included, read for folds by rigc (issues #320, #333) — only when there is one; at
2213
+ // the idle's rate whatever --requirements says, so a requirements file moves only what it declares (CK78), from the keyed build
2214
+ if (animationNames.length > 0) {
2139
2215
  const fps = IDLE_FPS;
2140
2216
  try {
2141
- const fr = rigc(['render', '--candidate', keyedBuild('the animations beside the idle with their physics constraint(s) left out'), '--folds', '--fps', String(fps), '--max', String(FOLD_RENDER_MAX), '--out', foldDir]);
2217
+ const fr = rigc(['render', '--candidate', keyedBuild('the animations with their physics constraint(s) left out'), '--folds', '--fps', String(fps), '--max', String(FOLD_RENDER_MAX), '--out', foldDir]);
2142
2218
  if (fr.status !== 0) refuseIfAny(rigcFailed(`render --folds --fps ${fps}${stripped === null ? '' : ' (physics constraint(s) left out)'}`, fr, fr.out.split('\n').filter((l) => l.includes('FAIL') || l.includes('rigc:'))));
2143
- const readings = readFoldSets(foldDir, named, fps, fr.out);
2144
- folds = foldsLine(named, fps, stripped === null ? 0 : stripped.physics.length, readings, reqFile === null ? [] : reqFile.may_fold, reqFile === null ? null : reqFile.path, foldProblems);
2219
+ const readings = readFoldSets(foldDir, animationNames, fps, fr.out);
2220
+ folds = foldsLine(animationNames, fps, stripped === null ? 0 : stripped.physics.length, readings, reqFile === null ? [] : reqFile.may_fold, reqFile === null ? null : reqFile.path, foldProblems);
2145
2221
  } finally {
2146
2222
  rmSync(foldDir, { recursive: true, force: true });
2147
2223
  }
@@ -2334,7 +2410,7 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
2334
2410
  const l = figures[n];
2335
2411
  tally(n, l.status === 'SKIP' ? l.reason : null);
2336
2412
  }
2337
- // Issue #320: the fold bar is counted only where it is written, so a rig of the idle alone counts what it counted.
2413
+ // Issues #320, #333: the fold bar is counted where it is written — on every rig whose motion holds an animation.
2338
2414
  if (folds !== null) tally(FOLD_LINE, null);
2339
2415
  return {
2340
2416
  figures,
@@ -3511,7 +3587,7 @@ export function requireRigcVersion(rigc: RigcRunner): string {
3511
3587
  {
3512
3588
  code: 'CHECK_RIGC_VERSION',
3513
3589
  object: '`rigc --version`',
3514
- detail: `${got === null ? `exit ${v.status}, printed ${JSON.stringify(v.out.trim().split('\n')[0] ?? '')} — no version` : `is ${got.join('.')}`}; rig-c ${RIGC_GEOMETRY_VERSION} or later is required — its \`render --geometry\` writes the skinned mesh vertices TEXTURE_STRETCH is measured from (\`bun install\` puts this package's own rig-c at node_modules/.bin/rigc)`,
3590
+ detail: `${got === null ? `exit ${v.status}, printed ${JSON.stringify(v.out.trim().split('\n')[0] ?? '')} — no version` : `is ${got.join('.')}`}${rigcRanClause(v)}; rig-c ${RIGC_GEOMETRY_VERSION} or later is required — its \`render --geometry\` writes the skinned mesh vertices TEXTURE_STRETCH is measured from (\`bun install\` puts this package's own rig-c at node_modules/.bin/rigc)`,
3515
3591
  },
3516
3592
  ]);
3517
3593
  }
package/src/contour.ts CHANGED
@@ -380,8 +380,31 @@ export interface ContourBridges {
380
380
  units: number[][];
381
381
  /** One per unit but unit 0, in the order they were joined. */
382
382
  joins: ContourBridge[];
383
+ /**
384
+ * Only when one was left out (HQ's ruling on #326): each faint island — every pixel's alpha under
385
+ * {@link FAINT_ALPHA} — largest first. Its pixels stay in the image and are not meshed, so not drawn, as `stray`
386
+ * leaves dust; `artPixels` does not count them.
387
+ */
388
+ faint?: ContourFaintIsland[];
389
+ }
390
+
391
+ /** A faint island a bridged mesh left out (issue #326): its art px, its brightest pixel's alpha, and its box in part-image px `[x, y, w, h]`. */
392
+ export interface ContourFaintIsland {
393
+ art: number;
394
+ maxAlpha: number;
395
+ box: [number, number, number, number];
383
396
  }
384
397
 
398
+ /**
399
+ * Issue #326, HQ's ruling: under `bridges`, an island whose every pixel has alpha below this is faint, left out and
400
+ * named; any island with one pixel at or above it is visible and bridged, whatever the bridge costs. 64/255 is a
401
+ * quarter of full opacity — a faint pixel moves the composite by at most 63/255 of the colour beneath it — and it
402
+ * sits below the 128 that assemble and `check` read as covered (`FRONT_ALPHA`, `COVERED_ALPHA`, check's coverage),
403
+ * so nothing those readings count as drawn is left out. Read over what a bridged draft reaches on the public figures
404
+ * (the four examples and rig-c/trailer 6e2de45), the faint islands peak at 11–17 and the visible ones at 255.
405
+ */
406
+ export const FAINT_ALPHA = 64;
407
+
385
408
  export interface ContourMesh {
386
409
  /** Part-image px, y down, every value a multiple of 1/{@link GRID}; the first `hull` are the outline, in walk order. */
387
410
  vertices: Array<[number, number]>;
@@ -2010,8 +2033,11 @@ interface BridgeUnit {
2010
2033
  * The bridged mesh (issue #326, Stage B; `bridges: true`). Stage A (`docs/evidence/island-draft.md`) measured the
2011
2034
  * join this builds by hand: rig-c reads one closed outline per mesh, and a bridge is what a join costs.
2012
2035
  *
2013
- * 1. **Islands.** Every 4-connected island of the art is kept, but an island that is not the largest (nor tied
2014
- * with it) and holds `stray` art px or fewer, which is left out as the one-island rule leaves it out.
2036
+ * 1. **Islands**, in this order. Dust first: an island that is not the largest (nor tied with it) and holds `stray`
2037
+ * art px or fewer is left out as the one-island rule leaves it out. Then faint islands: one that is not the
2038
+ * largest and whose every pixel has alpha under {@link FAINT_ALPHA} is left out the same way and named in
2039
+ * `bridges.faint`. Every other island is kept, and step 3 bridges it whatever its bridge costs — overdraw is
2040
+ * reported (`joins[].area`), never a reason to drop art.
2015
2041
  * 2. **Silhouettes, in the trace.** The kept islands with their holes filled grow together by the margin
2016
2042
  * ({@link growSilhouette}, its pinch fill included), and each 4-connected piece of the grown set is one unit,
2017
2043
  * traced on its own. Islands closer than about twice the margin grow into one unit and are traced as one
@@ -2044,17 +2070,26 @@ function bridgedMesh(part: string, mask: AlphaMask, params: ContourParams, comp:
2044
2070
  for (let l = 1; l <= n; l++) largest = Math.max(largest, comp.stats[l].area);
2045
2071
  const leave: number[] = [];
2046
2072
  for (let l = 1; l <= n; l++) if (comp.stats[l].area !== largest && params.stray !== undefined && comp.stats[l].area <= params.stray) leave.push(l);
2073
+ // Issue #326 (HQ's ruling): after dust, a faint island — every pixel's alpha under FAINT_ALPHA — is left out and
2074
+ // named; every other island is bridged whatever its bridge costs. The largest island (or a tie for it) is never left out.
2075
+ const peak = new Array<number>(n + 1).fill(0);
2076
+ for (let i = 0; i < comp.labels.length; i++) if (comp.labels[i] > 0) peak[comp.labels[i]] = Math.max(peak[comp.labels[i]], mask.alpha[i]);
2077
+ const faint: number[] = [];
2078
+ for (let l = 1; l <= n; l++) if (comp.stats[l].area !== largest && !leave.includes(l) && peak[l] < FAINT_ALPHA) faint.push(l);
2047
2079
  let meshed = mask;
2048
2080
  let strayPixels = 0;
2049
- if (leave.length > 0) {
2050
- const out = new Set(leave);
2081
+ if (leave.length > 0 || faint.length > 0) {
2082
+ const out = new Set([...leave, ...faint]);
2051
2083
  const alpha = new Uint8Array(mask.alpha);
2052
2084
  for (let i = 0; i < alpha.length; i++) if (out.has(comp.labels[i])) alpha[i] = 0;
2053
2085
  meshed = { width: w, height: h, alpha };
2054
2086
  for (const l of leave) strayPixels += comp.stats[l].area;
2055
2087
  }
2056
2088
  const keptIslands = new Set<number>();
2057
- for (let l = 1; l <= n; l++) if (!leave.includes(l)) keptIslands.add(l);
2089
+ for (let l = 1; l <= n; l++) if (!leave.includes(l) && !faint.includes(l)) keptIslands.add(l);
2090
+ const left: ContourFaintIsland[] = faint
2091
+ .map((l) => ({ art: comp.stats[l].area, maxAlpha: peak[l], box: [comp.stats[l].left, comp.stats[l].top, comp.stats[l].width, comp.stats[l].height] as [number, number, number, number] }))
2092
+ .sort((a, b) => b.art - a.art || a.box[1] - b.box[1] || a.box[0] - b.box[0]);
2058
2093
 
2059
2094
  const island = artMask(meshed, threshold);
2060
2095
  const filled = fillHoles(island);
@@ -2269,6 +2304,7 @@ function bridgedMesh(part: string, mask: AlphaMask, params: ContourParams, comp:
2269
2304
  islands: [...keptIslands].map((l) => comp.stats[l].area).sort((a, b) => b - a),
2270
2305
  units: units.map((u) => u.islands),
2271
2306
  joins: joins.map((j, i) => ({ ...j, vertices: quads[i].map(at) as [number, number, number, number] })),
2307
+ ...(left.length === 0 ? {} : { faint: left }),
2272
2308
  },
2273
2309
  },
2274
2310
  };
package/src/errors.ts CHANGED
@@ -23,6 +23,22 @@ export function problemLine(p: Problem): string {
23
23
  return `${p.code}: ${p.object} — ${p.detail}`;
24
24
  }
25
25
 
26
+ /**
27
+ * The end of every FAIL line: the command that prints the guide's row for its
28
+ * rule (issue #343). This is the one place that writes it, so every line has the
29
+ * same form, and rig-c's lines have the same form with its own command. A line
30
+ * that quotes a rigc failure keeps rigc's pointer inside the quote, and this one
31
+ * goes after it.
32
+ */
33
+ export function guidePointer(code: string): string {
34
+ return ` (see rig-parts guide ${code})`;
35
+ }
36
+
37
+ /** A refusal as it is printed: ` FAIL RULE: object — detail (see rig-parts guide RULE)`. */
38
+ export function failLine(p: Problem): string {
39
+ return ` FAIL ${problemLine(p)}${guidePointer(p.code)}`;
40
+ }
41
+
26
42
  export class PartsError extends Error {
27
43
  readonly problems: readonly Problem[];
28
44