@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/src/guide.ts ADDED
@@ -0,0 +1,255 @@
1
+ /**
2
+ * `rig-parts guide`: a slice of `docs/AUTHORING.md`, read at the moment of the
3
+ * call (issue #343).
4
+ *
5
+ * The guide is one file, and it is the only copy. An agent building a rig
6
+ * reads a failure line, `FAIL RULE: object — detail (see rig-parts guide
7
+ * RULE)`, and needs the row that maps RULE to what has to change. It should
8
+ * not need the other 330 KB around that row. Every function here takes the
9
+ * guide's text and returns lines cut from it. Nothing is generated or kept
10
+ * apart from the file, so there is nothing to drift.
11
+ *
12
+ * Four readings, each defined on the text alone:
13
+ *
14
+ * - **A ledger table** is a Markdown table, outside a code fence, whose header's
15
+ * first cell is exactly `rule` (a refusal or a warning) or `line` (a line
16
+ * `check` prints). Other tables are not read.
17
+ * - **A row's names** are the backticked tokens in its first cell that are
18
+ * rule names ({@link RULE_NAME}). A wildcard such as `PARTS_*` names
19
+ * nothing. Each name sits on exactly one row in the whole guide; the
20
+ * selftest's `TY16` holds that against every name the source can print.
21
+ * - **A row's anchors** are the `<a id="NAME"></a>` tags at the start of its
22
+ * first cell, one per name. github.com keeps them as `user-content-<name>`,
23
+ * and its script lowercases a link's fragment before it looks one up, so
24
+ * `AUTHORING.md#NAME` lands on the row.
25
+ * - **`§n`** is the `## n.` heading and everything up to the next `##`.
26
+ * **`§n.m`** is the m-th `###` under it, and **`§n.m.k`** the k-th `####`
27
+ * under that, each counted at read time, up to the next heading of its level
28
+ * or above. The guide's headings carry no number. `guide §n` and `guide
29
+ * §n.m` print the numbers they counted beside each child's size.
30
+ *
31
+ * A refusal and the bar it enforces are two names, each with its own row:
32
+ * `CHECK_<LINE>` in a `rule` table, `<LINE>` in a `line` table. `guide` prints
33
+ * the asked name's row first, then `paired with <OTHER>:` and the other one's.
34
+ *
35
+ * The printed form is the one HQ settled for both packages on issue #343:
36
+ * `docs/AUTHORING.md §<addr>, line <N> — under "<heading>"` over each row;
37
+ * `docs/AUTHORING.md §<addr> "<title>", lines <a>–<b>, <X.X> KB` over a
38
+ * section, its children listed beneath in rig-c's columns; an unknown name or
39
+ * section one coded FAIL line on stderr, with rig-c's three-name ranking.
40
+ */
41
+ import type { Problem } from './errors.ts';
42
+
43
+ /** The guide's path inside the package, beside `cli.ts`. */
44
+ export const GUIDE_DOC = 'docs/AUTHORING.md';
45
+
46
+ /** A rule name: upper-case words joined by underscores (`USAGE` and `CHECK_LOOP_CLOSES` alike). */
47
+ export const RULE_NAME = /^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)*$/;
48
+
49
+ /** How many of the nearest names an unknown name's refusal lists (the form shared with rig-c). */
50
+ export const NEAREST_LISTED = 3;
51
+
52
+ /** The two header words that make a table a ledger table, and what its rows are. */
53
+ export const LEDGER_HEADS = ['rule', 'line'] as const;
54
+ export type LedgerKind = (typeof LEDGER_HEADS)[number];
55
+
56
+ /** A `##` section, a `###` or a `####`: its number, its title, its lines (1-based, end exclusive) and its size. */
57
+ export interface GuideSection {
58
+ /** `"6"` for `## 6.`, `"6.3"` for the third `###` under it, `"3.6.1"` for the first `####` under §3.6. */
59
+ number: string;
60
+ /** The heading's text as written (a `##` keeps its `n. `). */
61
+ title: string;
62
+ start: number;
63
+ end: number;
64
+ /** UTF-8 bytes from the heading's line to the next heading of its level or above, each line with its newline. */
65
+ bytes: number;
66
+ }
67
+
68
+ /** One ledger row. */
69
+ export interface LedgerRow {
70
+ kind: LedgerKind;
71
+ /** The row's line, 1-based. */
72
+ line: number;
73
+ /** The table's header line and its separator line, as written. */
74
+ header: [string, string];
75
+ /** The row as written. */
76
+ text: string;
77
+ /** The rule names in the first cell, in the order written. */
78
+ names: string[];
79
+ /** The ids of the `<a id="…"></a>` tags at the start of the first cell, in order. */
80
+ anchors: string[];
81
+ /** The deepest numbered section the row sits in (`§n`, `§n.m` or `§n.m.k`). */
82
+ section: GuideSection | null;
83
+ /** The nearest heading above the row, its text as written. */
84
+ heading: string;
85
+ }
86
+
87
+ function utf8Bytes(s: string): number {
88
+ return new TextEncoder().encode(s).length;
89
+ }
90
+
91
+ /** Each line, with whether it sits inside a code fence (a fence's own lines count as inside). */
92
+ function fenced(lines: readonly string[]): boolean[] {
93
+ let inside = false;
94
+ return lines.map((l) => {
95
+ if (l.startsWith('```')) {
96
+ inside = !inside;
97
+ return true;
98
+ }
99
+ return inside;
100
+ });
101
+ }
102
+
103
+ /** Every `## n.` section, every `###` under one, and every `####` under those, numbered as {@link GuideSection} says. */
104
+ export function guideSections(text: string): GuideSection[] {
105
+ const lines = text.split('\n');
106
+ const inFence = fenced(lines);
107
+ const heads: Array<{ level: number; title: string; line: number }> = [];
108
+ lines.forEach((l, i) => {
109
+ const h = inFence[i] ? null : /^(#{1,6}) (.*)$/.exec(l);
110
+ if (h !== null) heads.push({ level: h[1].length, title: h[2], line: i + 1 });
111
+ });
112
+ const out: GuideSection[] = [];
113
+ const counts = [0, 0, 0];
114
+ let chapter: string | null = null;
115
+ heads.forEach((h, k) => {
116
+ if (h.level <= 2) {
117
+ chapter = h.level === 2 ? (/^(\d+)\. /.exec(h.title)?.[1] ?? null) : null;
118
+ counts.fill(0);
119
+ } else if (h.level === 3) {
120
+ counts[1]++;
121
+ counts[2] = 0;
122
+ } else if (h.level === 4) counts[2]++;
123
+ if (chapter === null || h.level > 4 || (h.level === 4 && counts[1] === 0)) return;
124
+ const number = h.level === 2 ? chapter : h.level === 3 ? `${chapter}.${counts[1]}` : `${chapter}.${counts[1]}.${counts[2]}`;
125
+ const next = heads.slice(k + 1).find((x) => x.level <= h.level);
126
+ const end = next === undefined ? lines.length + 1 : next.line;
127
+ const title = h.title;
128
+ const body = lines.slice(h.line - 1, end - 1).join('\n');
129
+ out.push({ number, title, start: h.line, end, bytes: utf8Bytes(body) + (next === undefined ? 0 : 1) });
130
+ });
131
+ return out;
132
+ }
133
+
134
+ /** The ledger rows, in the order written. */
135
+ export function ledgerRows(text: string): LedgerRow[] {
136
+ const lines = text.split('\n');
137
+ const inFence = fenced(lines);
138
+ const sections = guideSections(text);
139
+ const rows: LedgerRow[] = [];
140
+ let heading = '';
141
+ let header: [string, string] | null = null;
142
+ let kind: LedgerKind | null = null;
143
+ let inTable = false;
144
+ lines.forEach((l, i) => {
145
+ if (!inFence[i] && /^#{1,6} /.test(l)) heading = l.replace(/^#{1,6} /, '');
146
+ if (inFence[i] || !l.startsWith('|')) {
147
+ inTable = false;
148
+ return;
149
+ }
150
+ const first = (l.split('|')[1] ?? '').trim();
151
+ if (!inTable) {
152
+ inTable = true;
153
+ kind = (LEDGER_HEADS as readonly string[]).includes(first) ? (first as LedgerKind) : null;
154
+ header = [l, lines[i + 1] ?? ''];
155
+ return;
156
+ }
157
+ if (kind === null || header === null || /^:?-+:?$/.test(first)) return;
158
+ const names = [...first.matchAll(/`([^`]+)`/g)].map((x) => x[1]).filter((t) => RULE_NAME.test(t));
159
+ if (names.length === 0) return;
160
+ const lead = /^((?:<a id="[^"]*"><\/a>)*)/.exec(first)?.[1] ?? '';
161
+ const anchors = [...lead.matchAll(/<a id="([^"]*)"><\/a>/g)].map((x) => x[1]);
162
+ const line = i + 1;
163
+ const holding = sections.filter((s) => s.start <= line && line < s.end).sort((a, b) => b.number.split('.').length - a.number.split('.').length);
164
+ rows.push({ kind, line, header, text: l, names, anchors, section: holding[0] ?? null, heading });
165
+ });
166
+ return rows;
167
+ }
168
+
169
+ function editDistance(a: string, b: string): number {
170
+ let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
171
+ for (let i = 1; i <= a.length; i++) {
172
+ const cur = [i];
173
+ for (let j = 1; j <= b.length; j++) cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
174
+ prev = cur;
175
+ }
176
+ return prev[b.length];
177
+ }
178
+
179
+ /**
180
+ * The {@link NEAREST_LISTED} names of `known` nearest `asked`, case aside — rig-c's ranking, the one the shared form
181
+ * fixes: a name `asked` is the start of (or, from three characters, a part of) first; then by edit distance less the
182
+ * prefix the two share; then by name. A list, never a substitute.
183
+ */
184
+ export function nearestNames(known: readonly string[], asked: string): string[] {
185
+ const key = asked.toUpperCase();
186
+ const shared = (name: string): number => {
187
+ let k = 0;
188
+ while (k < key.length && k < name.length && key[k] === name[k]) k++;
189
+ return k;
190
+ };
191
+ const score = (name: string): number => {
192
+ const up = name.toUpperCase();
193
+ return up.startsWith(key) || (key.length >= 3 && up.includes(key)) ? -Infinity : editDistance(key, up) - shared(up);
194
+ };
195
+ return [...new Set(known)]
196
+ .map((name) => ({ name, d: score(name) }))
197
+ .sort((a, b) => a.d - b.d || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
198
+ .slice(0, NEAREST_LISTED)
199
+ .map((x) => x.name);
200
+ }
201
+
202
+ /** A size as the shared form prints it: KB to one decimal. */
203
+ export function kb(bytes: number): string {
204
+ return `${(bytes / 1024).toFixed(1)} KB`;
205
+ }
206
+
207
+ /** One row's block: where it is, then the row as written. */
208
+ function rowBlock(row: LedgerRow): string[] {
209
+ return [`${GUIDE_DOC}${row.section === null ? '' : ` §${row.section.number}`}, line ${row.line} — under "${row.heading}"`, row.text];
210
+ }
211
+
212
+ /** The other half of a refusal–bar pair: `CHECK_<LINE>` and `<LINE>`, each when the guide has its row. */
213
+ function pairOf(name: string, rows: readonly LedgerRow[]): { row: LedgerRow; name: string } | null {
214
+ const other = name.startsWith('CHECK_') ? name.slice('CHECK_'.length) : `CHECK_${name}`;
215
+ const want: LedgerKind = name.startsWith('CHECK_') ? 'line' : 'rule';
216
+ const row = rows.find((r) => r.kind === want && r.names.includes(other));
217
+ return row === undefined ? null : { row, name: other };
218
+ }
219
+
220
+ /** What `guide` answers: the lines to print, or the refusal. */
221
+ export type GuideAnswer = { lines: string[] } | { problem: Problem };
222
+
223
+ /** `guide <RULE>`: the row naming it, then `paired with <OTHER>:` and its pair's; or `GUIDE_RULE_KNOWN` with the nearest names. */
224
+ export function guideRule(text: string, name: string): GuideAnswer {
225
+ const rows = ledgerRows(text);
226
+ const own = rows.find((r) => r.names.includes(name));
227
+ if (own === undefined) {
228
+ return { problem: { code: 'GUIDE_RULE_KNOWN', object: `"${name}"`, detail: `no row in ${GUIDE_DOC} names it; the nearest: ${nearestNames(rows.flatMap((r) => r.names), name).join(', ')}` } };
229
+ }
230
+ const pair = pairOf(name, rows);
231
+ return { lines: [...rowBlock(own), ...(pair === null ? [] : [`paired with ${pair.name}:`, ...rowBlock(pair.row)])] };
232
+ }
233
+
234
+ /** `guide §n`, `§n.m` or `§n.m.k`: a line naming the slice, its children's listing when it has children, then the slice. */
235
+ export function guideSection(text: string, ask: string): GuideAnswer {
236
+ const sections = guideSections(text);
237
+ const depth = (s: GuideSection): number => s.number.split('.').length;
238
+ const childrenOf = (n: string | null): GuideSection[] => sections.filter((s) => (n === null ? depth(s) === 1 : s.number.startsWith(`${n}.`) && depth(s) === n.split('.').length + 1));
239
+ const m = /^§(\d+(?:\.\d+){0,2})$/.exec(ask);
240
+ const hit = m === null ? undefined : sections.find((s) => s.number === m[1]);
241
+ if (hit === undefined) {
242
+ // The deepest address the asked one shares, and what it holds: never a section put in its place.
243
+ const asked = m === null ? [] : m[1].split('.');
244
+ let parent: GuideSection | undefined;
245
+ for (let d = asked.length - 1; d >= 1 && parent === undefined; d--) parent = sections.find((s) => s.number === asked.slice(0, d).join('.'));
246
+ const kids = childrenOf(parent?.number ?? null);
247
+ const list = kids.length === 0 ? 'none' : kids.map((s) => `§${s.number}`).join(', ');
248
+ const deepest = parent === undefined ? `the guide itself, ${GUIDE_DOC}` : `§${parent.number} "${parent.title}"`;
249
+ return { problem: { code: 'GUIDE_SECTION_KNOWN', object: `"${ask}"`, detail: `no such section; the deepest that exists is ${deepest}, whose sections are: ${list}` } };
250
+ }
251
+ const lines = text.split('\n');
252
+ const head = [`${GUIDE_DOC} §${hit.number} "${hit.title}", lines ${hit.start}–${hit.end - 1}, ${kb(hit.bytes)}`];
253
+ for (const s of childrenOf(hit.number)) head.push(`§${s.number.padEnd(6)} ${kb(s.bytes).padStart(9)} ${s.title}`);
254
+ return { lines: [...head, '', lines.slice(hit.start - 1, hit.end - 1).join('\n').replace(/\n+$/, '')] };
255
+ }
package/src/meshdraft.ts CHANGED
@@ -70,7 +70,7 @@ import { BARS, type CheckReport, type PackShape, type PageEdges, type RigcRunner
70
70
  import { type CharacterConfig, type ContourMeshSpec, type LatticeMeshSpec, loadConfigAndAnimations, type Segment } from './config.ts';
71
71
  import { PartsError, type Problem, problemLine } from './errors.ts';
72
72
  import { IDLE_FPS } from './motion.ts';
73
- import type { ContourBridges } from './contour.ts';
73
+ import { type ContourBridges, FAINT_ALPHA } from './contour.ts';
74
74
  import { ART_ALPHA, DUST_MAX_PX, DUST_MAX_SHARE, isDust } from './mesh.ts';
75
75
  import { type PartsFile, readParts } from './parts.ts';
76
76
  import { alphaAbove, connectedComponents } from './raster/index.ts';
@@ -607,6 +607,12 @@ export interface BridgeFigure {
607
607
  area_px2: number;
608
608
  /** That area over the part's art px: the share of the candidate's overdraw the bridges draw (4 decimals). */
609
609
  overdraw: number;
610
+ /**
611
+ * Only when one was left out (HQ's ruling on #326): each faint island — every pixel's alpha under `FAINT_ALPHA`
612
+ * (`src/contour.ts`) — with its art px, its brightest pixel's alpha and its box in part-image px. Not meshed, so not
613
+ * drawn.
614
+ */
615
+ faint?: Array<{ art_px: number; max_alpha: number; box: [number, number, number, number] }>;
610
616
  }
611
617
 
612
618
  export interface PartRow {
@@ -637,7 +643,13 @@ export function bridgesOf(rigDir: string, part: string, art: number): BridgeFigu
637
643
  const b = rows.find((r) => r.part === part)?.contour?.bridges;
638
644
  if (b === undefined) return null;
639
645
  const area = b.joins.reduce((t, j) => t + j.area, 0);
640
- return { units: b.units, joins: b.joins.map((j) => ({ span: j.span, area_px2: j.area })), area_px2: area, overdraw: art === 0 ? 0 : pyRound(area / art, 4) };
646
+ return {
647
+ units: b.units,
648
+ joins: b.joins.map((j) => ({ span: j.span, area_px2: j.area })),
649
+ area_px2: area,
650
+ overdraw: art === 0 ? 0 : pyRound(area / art, 4),
651
+ ...(b.faint === undefined ? {} : { faint: b.faint.map((f) => ({ art_px: f.art, max_alpha: f.maxAlpha, box: f.box })) }),
652
+ };
641
653
  }
642
654
 
643
655
  /** The mesh's area over the art's: the triangles' area in image px over the image's art px. */
@@ -772,11 +784,20 @@ export function meshDraftStage(input: MeshDraftInput, rigc: RigcRunner, checkBin
772
784
  return d === null || 'keep' in d || d.dust.length === 0 ? 'no dust' : `stray ${d.stray as number} leaves out ${d.dust.length} dust island(s) of ${d.dust.join(', ')} px`;
773
785
  };
774
786
  const row = (part: string): PartRow => rows.get(part) as PartRow;
787
+ const faintText = (faint: NonNullable<BridgeFigure['faint']>): string =>
788
+ `LEFT OUT as faint, every pixel under alpha ${FAINT_ALPHA}: ${faint.map((f) => `${f.art_px} px (brightest alpha ${f.max_alpha}, at part-image px ${f.box[0]},${f.box[1]} ${f.box[2]}x${f.box[3]})`).join('; ')} — not meshed, so not drawn; if those pixels matter to you, keep the lattice for this part`;
775
789
  const bridgeText = (r: PartRow): string => {
776
790
  const j = joined.get(r.part) as { real: number[]; bone: string };
777
791
  const b = r.candidate?.bridges;
778
- const joins = b === undefined ? 'its bridges were not read' : `${b.joins.length} bridge(s) of ${b.joins.map((x) => `${x.span[0]} and ${x.span[1]} px`).join('; ')} span, ${b.area_px2} px² over no art (overdraw ${b.overdraw} of the candidate's ${r.candidate?.overdraw})`;
779
- return `its ${j.real.length} real islands (${j.real.join(', ')} px) traced as ${b === undefined ? 'its' : b.units.length} grown silhouette(s) and joined into one outline by ${joins}, every vertex bound to the one bone "${j.bone}" (issue #326)`;
792
+ const joins =
793
+ b === undefined
794
+ ? 'its bridges were not read'
795
+ : b.joins.length === 0
796
+ ? 'no bridge'
797
+ : `${b.joins.length} bridge(s) of ${b.joins.map((x) => `${x.span[0]} and ${x.span[1]} px`).join('; ')} span, ${b.area_px2} px² over no art (overdraw ${b.overdraw} of the candidate's ${r.candidate?.overdraw}; overdraw is reported, never a reason to leave art out)`;
798
+ const faint = b?.faint ?? [];
799
+ const visible = j.real.length - faint.length;
800
+ return `its ${j.real.length} real islands (${j.real.join(', ')} px)${faint.length === 0 ? '' : `: ${faintText(faint)}; the ${visible} visible`} traced as ${b === undefined ? 'its' : b.units.length} grown silhouette(s) and joined into one outline by ${joins}, every vertex bound to the one bone "${j.bone}" (issue #326)`;
780
801
  };
781
802
 
782
803
  // 3. The drafted rig must build: a refusal laid on a part keeps that part's lattice.
@@ -933,7 +954,8 @@ export function meshDraftStage(input: MeshDraftInput, rigc: RigcRunner, checkBin
933
954
  drafted.set(row.part, { ...cand, note: row.note });
934
955
  } else if (row.note === '') {
935
956
  const failed = row.gate.filter((x) => !x.pass);
936
- row.note = `keeps its lattice at grid ${row.lattice.grid}: ${failed.length === 0 ? 'no candidate was read' : failed.map((x) => `${x.reading} — ${x.text}`).join('; ')}`;
957
+ const faint = row.candidate?.bridges?.faint ?? [];
958
+ row.note = `keeps its lattice at grid ${row.lattice.grid}: ${failed.length === 0 ? 'no candidate was read' : failed.map((x) => `${x.reading} — ${x.text}`).join('; ')}${faint.length === 0 ? '' : `; on the bridged candidate those readings read: ${faintText(faint)}`}`;
937
959
  }
938
960
  }
939
961
  const configText = splicedConfig(text, drafted);
@@ -972,7 +994,7 @@ export function meshDraftStage(input: MeshDraftInput, rigc: RigcRunner, checkBin
972
994
  writeFileSync(written[0], configText);
973
995
  writeFileSync(written[1], `${JSON.stringify(report, null, 2)}\n`);
974
996
  for (const r of rowsOut) {
975
- const cand = r.candidate === null ? '' : `; candidate ${r.candidate.triangles} triangle(s), overdraw ${r.candidate.overdraw}${r.candidate.bridges === undefined ? '' : ` (${r.candidate.bridges.joins.length} bridge(s), ${r.candidate.bridges.area_px2} px² over no art: overdraw ${r.candidate.bridges.overdraw} of it)`}`;
997
+ const cand = r.candidate === null ? '' : `; candidate ${r.candidate.triangles} triangle(s), overdraw ${r.candidate.overdraw}${r.candidate.bridges === undefined ? '' : ` (${r.candidate.bridges.joins.length} bridge(s), ${r.candidate.bridges.area_px2} px² over no art: overdraw ${r.candidate.bridges.overdraw} of it${(r.candidate.bridges.faint ?? []).map((f) => `; LEFT OUT faint ${f.art_px} px, brightest alpha ${f.max_alpha} < ${FAINT_ALPHA}`).join('')})`}`;
976
998
  log(` ${r.mode === 'contour' ? 'DRAFTED' : 'LATTICE'} ${r.part}: lattice grid ${r.lattice.grid} ${r.lattice.triangles} triangle(s), overdraw ${r.lattice.overdraw}${cand}`);
977
999
  for (const g of r.gate) log(` ${g.pass ? 'pass' : 'FAIL'} ${g.reading}: ${g.text}`);
978
1000
  if (r.mode === 'grid' && r.gate.length === 0) log(` ${r.note}`);
@@ -181,8 +181,9 @@ export interface RequirementsFile {
181
181
  rests: string[];
182
182
  /**
183
183
  * Issue #320: the folds the scene means — a slot whose mesh turns a triangle
184
- * over in a named animation on purpose (a sleeve hidden behind the body as
185
- * the arm crosses it), each by slot and animation with the author's why.
184
+ * over in an animation, the idle included (issue #333), on purpose (a sleeve
185
+ * hidden behind the body as the arm crosses it), each by slot and animation
186
+ * with the author's why.
186
187
  * `ANIMATIONS_DO_NOT_FOLD` names each such fold with its why and does not
187
188
  * hold it to the bar, and fails an entry whose slot does not fold in that
188
189
  * animation, as rig-c refuses an exemption that exempts nothing. Empty when
@@ -581,11 +582,11 @@ export function resolveRequirements(file: RequirementsFile, rig: Record<string,
581
582
  }
582
583
  }
583
584
 
584
- // Issue #320: each declared fold names a slot that holds a mesh and an animation beside the idle, the ones ANIMATIONS_DO_NOT_FOLD reads.
585
+ // Issues #320, #333: each declared fold names a slot that holds a mesh and an animation motion.json declares — the idle
586
+ // included, since ANIMATIONS_DO_NOT_FOLD reads every animation.
585
587
  file.may_fold.forEach((m, i) => {
586
588
  const object = `may_fold[${i}] (slot "${m.slot}", animation "${m.animation}")`;
587
- if (m.animation === 'idle') problems.push({ code: 'REQUIREMENTS_MAY_FOLD', object, detail: 'names the idle, which ANIMATIONS_DO_NOT_FOLD does not read — it reads the animations beside the idle, so declaring it excepts nothing' });
588
- else if (!(m.animation in anims)) unresolved(object, `names animation "${m.animation}", which motion.json does not declare; it declares ${listed(animNames)}`);
589
+ if (!(m.animation in anims)) unresolved(object, `names animation "${m.animation}", which motion.json does not declare; it declares ${listed(animNames)}`);
589
590
  const slots = (Array.isArray(rig.slots) ? rig.slots : []).filter(isRecord).map((s) => s.name);
590
591
  if (!slots.includes(m.slot)) {
591
592
  unresolved(object, `names slot "${m.slot}", which rig.json does not declare`);
package/src/rig.ts CHANGED
@@ -1455,7 +1455,7 @@ export function buildRig(
1455
1455
  bones: [...bones].sort(),
1456
1456
  max_influences: maxInfl,
1457
1457
  mean_influences: pyRound(infl / cm.vertices.length, 2),
1458
- art_coverage: pyRound(rep.coveredArtPixels / (rep.artPixels + rep.strayPixels), 5),
1458
+ art_coverage: pyRound(rep.coveredArtPixels / (rep.artPixels + rep.strayPixels + (rep.bridges?.faint ?? []).reduce((t, f) => t + f.art, 0)), 5),
1459
1459
  mode: 'contour',
1460
1460
  ...(exponent === undefined ? {} : { exponent }),
1461
1461
  ...(declared === undefined ? {} : { influences: declared }),
package/src/scene.ts CHANGED
@@ -95,7 +95,7 @@ import { checkStage, gateThroughRigc, type Log } from './build.ts';
95
95
  import { ACCEPTABLE_BARS, barsNotMet, type CheckReport, DEFAULT_PACK_SHAPE, DEFAULT_PAGE_EDGES, type PackMode, requirementsNotPass, type RigcRunner } from './check.ts';
96
96
  import { CONSTRAINT_BONE_FIELDS, ROOT_BONE } from './config.ts';
97
97
  import { cropToSpineY } from './coords.ts';
98
- import { PartsError, type Problem, problemLine, refuseIfAny } from './errors.ts';
98
+ import { failLine, PartsError, type Problem, refuseIfAny } from './errors.ts';
99
99
  import type { MotionSpec } from './motion.ts';
100
100
  import { readPng } from './raster/png.ts';
101
101
  import { readRequirements, type RequirementsFile } from './requirements.ts';
@@ -1353,7 +1353,7 @@ export function composeStage(input: ComposeInput, run: ComposeRunners, log: Log)
1353
1353
  const refused = (stage: ComposeStage, err: unknown, composed: ComposedScene | null = null): ComposeResult => {
1354
1354
  if (!(err instanceof PartsError)) throw err;
1355
1355
  const p = prefix(stage);
1356
- for (const x of err.problems) p(` FAIL ${problemLine(x)}`);
1356
+ for (const x of err.problems) p(failLine(x));
1357
1357
  p(`refused: ${err.problems.length} problem(s)`);
1358
1358
  log(`compose: stopped at ${stage}; nothing ${stage === 'compose' ? 'was written' : 'after the check was written'}`);
1359
1359
  return { stoppedAt: stage, composed, check: null };