@rig-c/parts 1.13.1 → 1.15.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/meshdraft.ts CHANGED
@@ -13,7 +13,9 @@
13
13
  * - the interior grid at spacing S = round(H / {@link FIGURE_GRID_DIVISOR});
14
14
  * - dust left out: an extra 4-connected island of the art (alpha above `ART_ALPHA`) of at most {@link DUST_MAX_PX} px
15
15
  * and at most {@link DUST_MAX_SHARE} of the part's art is dust, and a `stray` equal to the largest such island leaves
16
- * it out. A part with any other extra island holds a real piece, and keeps its lattice ({@link dustOf});
16
+ * it out. A part with any other extra island holds a real piece ({@link dustOf}): issue #326 drafts it per island,
17
+ * `bridges: true`, only when every island is carried by the same bones ({@link islandsJoin}: every vertex of the
18
+ * part's lattice bound to one bone, the same one); otherwise it keeps its lattice, and the note names the bones;
17
19
  * - `r`, `segments`, `exponent`, `rule` and `influences` as the lattice entry states them.
18
20
  *
19
21
  * The constants are the ones issue #265's stages measured (Stage A passed every bar at c = 37 on three examples; T 2
@@ -68,6 +70,7 @@ import { BARS, type CheckReport, type PackShape, type PageEdges, type RigcRunner
68
70
  import { type CharacterConfig, type ContourMeshSpec, type LatticeMeshSpec, loadConfigAndAnimations, type Segment } from './config.ts';
69
71
  import { PartsError, type Problem, problemLine } from './errors.ts';
70
72
  import { IDLE_FPS } from './motion.ts';
73
+ import { type ContourBridges, FAINT_ALPHA } from './contour.ts';
71
74
  import { ART_ALPHA, DUST_MAX_PX, DUST_MAX_SHARE, isDust } from './mesh.ts';
72
75
  import { type PartsFile, readParts } from './parts.ts';
73
76
  import { alphaAbove, connectedComponents } from './raster/index.ts';
@@ -219,10 +222,13 @@ export function dustOf(image: Raster): DustReading {
219
222
  return { stray: extra.length === 0 ? undefined : extra[0], islands, dust: extra };
220
223
  }
221
224
 
222
- /** The candidate for one lattice entry: the outline mesh at the figure's numbers, its weights' fields as the lattice states them. */
223
- export function candidateMesh(lattice: LatticeMeshSpec, rules: Pick<FigureRules, 'tolerance' | 'spacing'>, stray: number | undefined): ContourMeshSpec {
225
+ /**
226
+ * The candidate for one lattice entry: the outline mesh at the figure's numbers, its weights' fields as the lattice
227
+ * states them. `bridges` (issue #326): every real island meshed and joined by bridges ({@link islandsJoin}).
228
+ */
229
+ export function candidateMesh(lattice: LatticeMeshSpec, rules: Pick<FigureRules, 'tolerance' | 'spacing'>, stray: number | undefined, bridges = false): ContourMeshSpec {
224
230
  return {
225
- contour: { tolerance: rules.tolerance, margin: rules.tolerance, spacing: rules.spacing, maxEdge: rules.spacing, ...(stray === undefined ? {} : { stray }) },
231
+ contour: { tolerance: rules.tolerance, margin: rules.tolerance, spacing: rules.spacing, maxEdge: rules.spacing, ...(stray === undefined ? {} : { stray }), ...(bridges ? { bridges: true as const } : {}) },
226
232
  r: lattice.r,
227
233
  segments: lattice.segments,
228
234
  ...(lattice.exponent === undefined ? {} : { exponent: lattice.exponent }),
@@ -231,6 +237,52 @@ export function candidateMesh(lattice: LatticeMeshSpec, rules: Pick<FigureRules,
231
237
  };
232
238
  }
233
239
 
240
+ /** What the same-bones test reads off a part's lattice as the rig stage weighted it (issue #326). */
241
+ export interface CarriedBy {
242
+ /** Every bone a vertex is bound to, sorted. */
243
+ bones: string[];
244
+ /** The lattice's vertices, and how many are bound to more than one bone. */
245
+ vertices: number;
246
+ blended: number;
247
+ }
248
+
249
+ /** The bones a mesh attachment's vertices are bound to ({@link CarriedBy}). */
250
+ export function carriedBy(att: Pick<MeshAttachment, 'weights'>): CarriedBy {
251
+ const bones = new Set<string>();
252
+ let blended = 0;
253
+ for (const w of att.weights) {
254
+ for (const e of w) bones.add(e.bone);
255
+ if (w.length > 1) blended++;
256
+ }
257
+ return { bones: [...bones].sort(), vertices: att.weights.length, blended };
258
+ }
259
+
260
+ /**
261
+ * Issue #326 (HQ's ruling on Stage A): a part whose art holds a second real island is drafted per island, joined by
262
+ * bridges, **only when every island is carried by the same bones**. The test, read off the part's lattice as the rig
263
+ * stage weighted it ({@link carriedBy}): every vertex is bound to one bone, and it is the same bone for every
264
+ * vertex. Then every point of the part — each island and every bridge between them — moves with that one bone, so no
265
+ * island moves against another and no bridge stretches. Anything else keeps the lattice, and the sentence says which
266
+ * bones; no two-bone join is drafted, measured or not (the ruling: nothing measured needs it).
267
+ *
268
+ * `islands` is {@link dustOf}'s reading of the same image. Returns the `stray` the candidate declares (the largest
269
+ * dust island: every real island is larger than any dust island on one part, since dust is at most
270
+ * {@link DUST_MAX_PX} px and a share of at most {@link DUST_MAX_SHARE}) and the real islands' px; or why the lattice
271
+ * is kept. A reading that is not a real piece or a tie for the largest — no art, or one island — is not this path's.
272
+ */
273
+ export function islandsJoin(islands: readonly number[], lattice: Pick<MeshAttachment, 'weights'>): { stray: number | undefined; real: number[]; bone: string } | { keep: string } | null {
274
+ if (islands.length < 2) return null;
275
+ const art = islands.reduce((a, b) => a + b, 0);
276
+ const real = islands.filter((n, i) => i === 0 || n === islands[0] || !isDust(n, art));
277
+ const dust = islands.filter((n, i) => !(i === 0 || n === islands[0] || !isDust(n, art)));
278
+ if (real.length < 2) return null;
279
+ const c = carriedBy(lattice);
280
+ if (c.bones.length === 1 && c.blended === 0) return { stray: dust.length === 0 ? undefined : dust[0], real, bone: c.bones[0] };
281
+ return {
282
+ keep: `its ${real.length} real islands are carried by different bones — the lattice's ${c.vertices} vertices are bound to ${c.bones.map((b) => `"${b}"`).join(', ')}${c.blended > 0 ? `, ${c.blended} of them to more than one` : ''} — and mesh-draft joins islands by bridges only when every vertex is bound to one and the same bone, so that no island moves against another and no bridge between them stretches (issue #326)`,
283
+ };
284
+ }
285
+
234
286
  /** The dense reference's lattice cell, px: Stage C's sample stride, the least whole number keeping a part's art at or under {@link DENSE_MAX_SAMPLES} samples. */
235
287
  export function denseGrid(artPx: number): number {
236
288
  return Math.max(1, Math.ceil(Math.sqrt(artPx / DENSE_MAX_SAMPLES)));
@@ -546,13 +598,30 @@ export interface MeshFigure {
546
598
  overdraw: number;
547
599
  }
548
600
 
601
+ /** A bridged candidate's bridges (issue #326), off the rig stage's mesh report: the silhouettes, each join, and their area over the part's art. */
602
+ export interface BridgeFigure {
603
+ /** The grown silhouettes, numbered by art px: each one's islands' art px. */
604
+ units: number[][];
605
+ joins: Array<{ span: [number, number]; area_px2: number }>;
606
+ /** Σ of the joins' area, px². */
607
+ area_px2: number;
608
+ /** That area over the part's art px: the share of the candidate's overdraw the bridges draw (4 decimals). */
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] }>;
616
+ }
617
+
549
618
  export interface PartRow {
550
619
  part: string;
551
620
  /** What the written config holds for the part. */
552
621
  mode: 'contour' | 'grid';
553
622
  lattice: MeshFigure & { grid: number; art_px: number };
554
623
  /** Null when no candidate was drafted for it (a real piece, or the stage refused it before any reading). */
555
- candidate: (MeshFigure & { mesh: ContourMeshSpec }) | null;
624
+ candidate: (MeshFigure & { mesh: ContourMeshSpec; bridges?: BridgeFigure }) | null;
556
625
  readings: PartReadings | null;
557
626
  gate: GateLine[];
558
627
  note: string;
@@ -566,6 +635,23 @@ export interface MeshDraftResult {
566
635
  written: string[];
567
636
  }
568
637
 
638
+ /** A bridged part's {@link BridgeFigure} off `<rigDir>/mesh_report.json`, or null when the report holds none for it. */
639
+ export function bridgesOf(rigDir: string, part: string, art: number): BridgeFigure | null {
640
+ const path = join(rigDir, 'mesh_report.json');
641
+ if (!existsSync(path)) return null;
642
+ const rows = JSON.parse(readFileSync(path, 'utf8')) as Array<{ part: string; contour?: { bridges?: ContourBridges } }>;
643
+ const b = rows.find((r) => r.part === part)?.contour?.bridges;
644
+ if (b === undefined) return null;
645
+ const area = b.joins.reduce((t, j) => t + j.area, 0);
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
+ };
653
+ }
654
+
569
655
  /** The mesh's area over the art's: the triangles' area in image px over the image's art px. */
570
656
  export function overdrawOf(att: MeshAttachment, art: number): number {
571
657
  let twice = 0;
@@ -660,6 +746,7 @@ export function meshDraftStage(input: MeshDraftInput, rigc: RigcRunner, checkBin
660
746
  // 2. Candidates for every lattice entry; a real piece keeps the lattice.
661
747
  const rows = new Map<string, PartRow>();
662
748
  const candidates = new Map<string, ContourMeshSpec>();
749
+ const joined = new Map<string, { stray: number | undefined; real: number[]; bone: string }>();
663
750
  for (const [part, spec] of Object.entries(cfg.meshes)) {
664
751
  if (!('grid' in spec)) continue;
665
752
  const img = images.get(part);
@@ -673,16 +760,45 @@ export function meshDraftStage(input: MeshDraftInput, rigc: RigcRunner, checkBin
673
760
  }
674
761
  const dust = dustOf(img);
675
762
  if ('keep' in dust) {
676
- row.note = `keeps its lattice: ${dust.keep}`;
763
+ // Issue #326: islands carried by one bone are drafted per island and joined by bridges.
764
+ const join = islandsJoin(dust.islands, att);
765
+ if (join === null || 'keep' in join) {
766
+ row.note = `keeps its lattice: ${dust.keep}${join === null ? '' : `; ${join.keep}`}`;
767
+ continue;
768
+ }
769
+ joined.set(part, join);
770
+ candidates.set(part, candidateMesh(spec, rules, join.stray, true));
677
771
  continue;
678
772
  }
679
773
  candidates.set(part, candidateMesh(spec, rules, dust.stray));
680
774
  }
681
775
  const dustText = (part: string): string => {
776
+ const j = joined.get(part);
777
+ if (j !== undefined) {
778
+ const img = images.get(part) as Raster;
779
+ const dust = (dustOf(img) as { islands: number[] }).islands.slice(j.real.length);
780
+ return `${dust.length === 0 ? 'no dust' : `stray ${j.stray as number} leaves out ${dust.length} dust island(s) of ${dust.join(', ')} px`}; ${bridgeText(row(part))}`;
781
+ }
682
782
  const img = images.get(part);
683
783
  const d = img === undefined ? null : dustOf(img);
684
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`;
685
785
  };
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`;
789
+ const bridgeText = (r: PartRow): string => {
790
+ const j = joined.get(r.part) as { real: number[]; bone: string };
791
+ const b = r.candidate?.bridges;
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)`;
801
+ };
686
802
 
687
803
  // 3. The drafted rig must build: a refusal laid on a part keeps that part's lattice.
688
804
  const keep = (part: string, reading: Reading, text: string): void => {
@@ -784,6 +900,10 @@ export function meshDraftStage(input: MeshDraftInput, rigc: RigcRunner, checkBin
784
900
  const att = attachmentOf(draftRig, part);
785
901
  const img = images.get(part) as Raster;
786
902
  row.candidate = { triangles: att.triangles.length / 3, overdraw: overdrawOf(att, artPixels(img)), mesh: candidates.get(part) as ContourMeshSpec };
903
+ if (joined.has(part)) {
904
+ const b = bridgesOf(draftedRig.rigDir, part, artPixels(img));
905
+ if (b !== null) row.candidate.bridges = b;
906
+ }
787
907
  const readings: PartReadings = { bars: null, idle: idleReading(idle, part), bend: read === null || typeof read === 'string' ? read : { links, ...read }, triangles: { lattice: row.lattice.triangles, candidate: row.candidate.triangles } };
788
908
  row.readings = readings;
789
909
  const v = gateVerdict({ ...readings, bars: [] }, input.moreTriangles === true);
@@ -834,7 +954,8 @@ export function meshDraftStage(input: MeshDraftInput, rigc: RigcRunner, checkBin
834
954
  drafted.set(row.part, { ...cand, note: row.note });
835
955
  } else if (row.note === '') {
836
956
  const failed = row.gate.filter((x) => !x.pass);
837
- 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)}`}`;
838
959
  }
839
960
  }
840
961
  const configText = splicedConfig(text, drafted);
@@ -857,7 +978,7 @@ export function meshDraftStage(input: MeshDraftInput, rigc: RigcRunner, checkBin
857
978
  part: r.part,
858
979
  mode: r.mode,
859
980
  lattice: r.lattice,
860
- candidate: r.candidate === null ? null : { triangles: r.candidate.triangles, overdraw: r.candidate.overdraw },
981
+ candidate: r.candidate === null ? null : { triangles: r.candidate.triangles, overdraw: r.candidate.overdraw, ...(r.candidate.bridges === undefined ? {} : { bridges: r.candidate.bridges }) },
861
982
  // Under --more-triangles only: what accepting a costlier candidate trades, triangles against overdraw.
862
983
  ...(input.moreTriangles === true && r.candidate !== null ? { delta: { triangles: r.candidate.triangles - r.lattice.triangles, overdraw: pyRound(r.candidate.overdraw - r.lattice.overdraw, 4) } } : {}),
863
984
  readings: r.readings,
@@ -873,7 +994,7 @@ export function meshDraftStage(input: MeshDraftInput, rigc: RigcRunner, checkBin
873
994
  writeFileSync(written[0], configText);
874
995
  writeFileSync(written[1], `${JSON.stringify(report, null, 2)}\n`);
875
996
  for (const r of rowsOut) {
876
- const cand = r.candidate === null ? '' : `; candidate ${r.candidate.triangles} triangle(s), overdraw ${r.candidate.overdraw}`;
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('')})`}`;
877
998
  log(` ${r.mode === 'contour' ? 'DRAFTED' : 'LATTICE'} ${r.part}: lattice grid ${r.lattice.grid} ${r.lattice.triangles} triangle(s), overdraw ${r.lattice.overdraw}${cand}`);
878
999
  for (const g of r.gate) log(` ${g.pass ? 'pass' : 'FAIL'} ${g.reading}: ${g.text}`);
879
1000
  if (r.mode === 'grid' && r.gate.length === 0) log(` ${r.note}`);
@@ -179,6 +179,24 @@ export interface RequirementsFile {
179
179
  * Empty when not declared: every swinging part's tip swings.
180
180
  */
181
181
  rests: string[];
182
+ /**
183
+ * Issue #320: the folds the scene means — a slot whose mesh turns a triangle
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.
187
+ * `ANIMATIONS_DO_NOT_FOLD` names each such fold with its why and does not
188
+ * hold it to the bar, and fails an entry whose slot does not fold in that
189
+ * animation, as rig-c refuses an exemption that exempts nothing. Empty when
190
+ * not declared: no fold in a named animation is meant.
191
+ */
192
+ may_fold: MayFold[];
193
+ }
194
+
195
+ /** One declared fold (issue #320): a slot, a named animation, and why the fold is the art's. */
196
+ export interface MayFold {
197
+ slot: string;
198
+ animation: string;
199
+ why: string;
182
200
  }
183
201
 
184
202
  /** What `check --requirements` and `build --requirements` name, said once for the help and the refusals. */
@@ -257,7 +275,7 @@ export function readRequirements(path: string): RequirementsFile {
257
275
  problems.push({ code: 'REQUIREMENTS_FIELD', object, detail });
258
276
  };
259
277
  if (raw.spec !== REQUIREMENTS_SPEC) field(`${at} field "spec"`, `is ${JSON.stringify(raw.spec)}; ${JSON.stringify(REQUIREMENTS_SPEC)} is required`);
260
- for (const k of Object.keys(raw)) if (!['spec', 'fps', 'targets', 'requirements', ...EXCEPTION_LISTS].includes(k) && !freeKey(k, raw[k])) field(`${at} field "${k}"`, 'is not a field this format reads (spec, fps, targets, requirements, not_planted, not_swinging, rises, rests; note or *_note as a string; x-<name> as a record)');
278
+ for (const k of Object.keys(raw)) if (!['spec', 'fps', 'targets', 'requirements', ...EXCEPTION_LISTS, 'may_fold'].includes(k) && !freeKey(k, raw[k])) field(`${at} field "${k}"`, 'is not a field this format reads (spec, fps, targets, requirements, not_planted, not_swinging, rises, rests, may_fold; note or *_note as a string; x-<name> as a record)');
261
279
  let fps = NaN;
262
280
  if (!(finite(raw.fps) && raw.fps > 0)) field(`${at} field "fps"`, `is ${JSON.stringify(raw.fps)}; a positive number of frames per second is required — every named animation is sampled at it, and there is no default`);
263
281
  else fps = raw.fps;
@@ -407,8 +425,31 @@ export function readRequirements(path: string): RequirementsFile {
407
425
  lists.rests.forEach((n, i) => {
408
426
  if (lists.not_swinging.includes(n)) field(`${at} field "rests"[${i}]`, `names "${n}", which "not_swinging" names too; a part declared not swinging is not measured, so there is no tip to hold at rest — leave it in one list`);
409
427
  });
428
+ // Issue #320: may_fold, a non-empty list of { slot, animation, why }, each pair once; which slots and animations they are is read against the rig by resolveRequirements.
429
+ const mayFold: MayFold[] = [];
430
+ if (raw.may_fold !== undefined) {
431
+ if (!Array.isArray(raw.may_fold) || raw.may_fold.length === 0) field(`${at} field "may_fold"`, `is ${JSON.stringify(raw.may_fold)?.slice(0, 60)}; a non-empty list of { "slot", "animation", "why" } is required when the field is given — leave it out to declare that no fold in a named animation is meant`);
432
+ else {
433
+ raw.may_fold.forEach((e, i) => {
434
+ const where = `${at} field "may_fold"[${i}]`;
435
+ if (!isRecord(e)) {
436
+ field(where, `is ${JSON.stringify(e)?.slice(0, 40)}; { "slot": <name>, "animation": <name>, "why": <text> } is required`);
437
+ return;
438
+ }
439
+ const before = problems.length;
440
+ for (const k of Object.keys(e)) if (!['slot', 'animation', 'why'].includes(k) && !freeKey(k, e[k])) field(`${where} field "${k}"`, 'is not a field a may_fold entry reads (slot, animation, why)');
441
+ for (const k of ['slot', 'animation', 'why'] as const) {
442
+ if (typeof e[k] !== 'string' || (e[k] as string).trim().length === 0) field(`${where} field "${k}"`, `is ${JSON.stringify(e[k])}; ${k === 'why' ? 'the reason the fold is the art\'s, in words, is required — an exception without one is a silenced bar' : `a ${k} name is required`}`);
443
+ }
444
+ if (problems.length !== before) return;
445
+ const entry: MayFold = { slot: e.slot as string, animation: e.animation as string, why: e.why as string };
446
+ if (mayFold.some((m) => m.slot === entry.slot && m.animation === entry.animation)) field(where, `declares slot "${entry.slot}" in animation "${entry.animation}" a second time; each pair is declared once`);
447
+ else mayFold.push(entry);
448
+ });
449
+ }
450
+ }
410
451
  refuseIfAny(problems);
411
- return { path: at, fps, targets, requirements, not_planted: lists.not_planted, not_swinging: lists.not_swinging, rises: lists.rises, rests: lists.rests };
452
+ return { path: at, fps, targets, requirements, not_planted: lists.not_planted, not_swinging: lists.not_swinging, rises: lists.rises, rests: lists.rests, may_fold: mayFold };
412
453
  }
413
454
 
414
455
  // ---------------------------------------------------------------------------
@@ -541,6 +582,21 @@ export function resolveRequirements(file: RequirementsFile, rig: Record<string,
541
582
  }
542
583
  }
543
584
 
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.
587
+ file.may_fold.forEach((m, i) => {
588
+ const object = `may_fold[${i}] (slot "${m.slot}", animation "${m.animation}")`;
589
+ if (!(m.animation in anims)) unresolved(object, `names animation "${m.animation}", which motion.json does not declare; it declares ${listed(animNames)}`);
590
+ const slots = (Array.isArray(rig.slots) ? rig.slots : []).filter(isRecord).map((s) => s.name);
591
+ if (!slots.includes(m.slot)) {
592
+ unresolved(object, `names slot "${m.slot}", which rig.json does not declare`);
593
+ return;
594
+ }
595
+ const meshes = meshAttachmentsOf(rig, m.slot);
596
+ if (typeof meshes === 'string') problems.push({ code: 'REQUIREMENTS_RESOLVES', object, detail: meshes });
597
+ else if (meshes.length === 0) problems.push({ code: 'REQUIREMENTS_MAY_FOLD', object, detail: `names slot "${m.slot}", which no skin in rig.json gives a mesh attachment; a region is one quad and cannot fold, so declaring it excepts nothing` });
598
+ });
599
+
544
600
  const root = bones.find((b) => b.name === rootBone);
545
601
  const measured = new Set(file.requirements.map((r) => r.animation));
546
602
  const placed = new Set<string>();
@@ -1009,8 +1065,14 @@ export function followLine(r: FollowRequirement, declared: Poses, released: Pose
1009
1065
  };
1010
1066
  }
1011
1067
 
1012
- /** `stretch`: `TEXTURE_STRETCH`'s per-mesh figure for one mesh over the named animation's frames — its worst max(ratio, 1/ratio) and where. */
1013
- export function stretchRequirementLine(r: StretchRequirement, meshes: readonly MeshRest[], frames: readonly GeometryPose[], p: Poses): RequirementLine {
1068
+ /**
1069
+ * `stretch`: `TEXTURE_STRETCH`'s per-mesh figure for one mesh over the named animation's frames — its worst
1070
+ * max(ratio, 1/ratio) and where. With `heldBy`, the line also carries `held_by` (issue #321): what the worst edge's two
1071
+ * vertices hang on. `check` passes `edgeBonesText` (`src/check.ts`), the function `CHECK_TEXTURE_STRETCH` names them
1072
+ * with since #314, so the two lines cannot drift; it is handed in rather than imported because `src/check.ts` imports
1073
+ * this module.
1074
+ */
1075
+ export function stretchRequirementLine(r: StretchRequirement, meshes: readonly MeshRest[], frames: readonly GeometryPose[], p: Poses, heldBy?: (edge: readonly number[]) => string): RequirementLine {
1014
1076
  const mesh = meshes.find((m) => m.slot === r.slot && m.attachment === r.attachment);
1015
1077
  if (mesh === undefined) return notMeasurable('stretch', r.animation, `geometry.json's rest pose holds no mesh "${r.attachment}" on slot "${r.slot}", so no frame shows it`);
1016
1078
  const [m] = stretchFigures([mesh], frames);
@@ -1032,6 +1094,7 @@ export function stretchRequirementLine(r: StretchRequirement, meshes: readonly M
1032
1094
  triangles: m.triangles,
1033
1095
  severity: round6(severity),
1034
1096
  worst: `triangle ${at.triangle} (vertices ${at.vertices.join(' ')}), edge ${at.edge.join('-')}, ratio ${round6(at.ratio)}`,
1097
+ ...(heldBy === undefined ? {} : { held_by: heldBy(at.edge) }),
1035
1098
  at: fi < 0 ? `frame ${at.frame}` : frameText(p, fi),
1036
1099
  max_ratio: round6(m.max.ratio),
1037
1100
  min_ratio: round6(m.min.ratio),
@@ -1274,7 +1337,7 @@ function failText(r: Requirement, l: RequirementLine): string {
1274
1337
  if (r.kind === 'contact') return `largest distance ${String(l.largest_px)} px at ${String(l.at)}, between ${String(l.of)} and ${String(l.against)}`;
1275
1338
  if (r.kind === 'aim') return `largest angle ${String(l.largest_degrees)} degrees at ${String(l.at)}, between ${String(l.of)} and the line to ${String(l.against)}`;
1276
1339
  if (r.kind === 'range') return `least ${String(l.least_degrees)} degrees at ${String(l.least_at)}, greatest ${String(l.greatest_degrees)} at ${String(l.greatest_at)}`;
1277
- if (r.kind === 'stretch') return `max(ratio, 1/ratio) ${String(l.severity)} at ${String(l.at)}, ${String(l.worst)}`;
1340
+ if (r.kind === 'stretch') return `max(ratio, 1/ratio) ${String(l.severity)} at ${String(l.at)}, ${String(l.worst)}${typeof l.held_by === 'string' ? `; ${l.held_by}` : ''}`;
1278
1341
  if (r.kind === 'seam') return `largest opening ${String(l.largest_px)} px at ${String(l.at)}, ${String(l.pair)}`;
1279
1342
  return `measured fraction ${String(l.fraction_measured)} over ${String(l.frames_counted)} frame(s) that reach the least drive`;
1280
1343
  }
package/src/rig.ts CHANGED
@@ -682,7 +682,7 @@ export interface ContourMeshReport {
682
682
  /** Only when the mesh declared ribs (issue #188): what it declared and what was placed. Absent otherwise, so a mesh without ribs writes the row it always wrote. */
683
683
  ribs?: RibsRow;
684
684
  /** `maxEdge` only when the mesh declared it (issue #265), so a mesh without it writes the row it always wrote. */
685
- params: { tolerance: number; margin: number; spacing: number; maxEdge?: number; budget: number | null; stray: number | null };
685
+ params: { tolerance: number; margin: number; spacing: number; maxEdge?: number; budget: number | null; stray: number | null; bridges?: true };
686
686
  contour: ContourReport;
687
687
  regions: Array<{ name: string; bone: string; reached: number; whole: number }>;
688
688
  /** Only under `rule: "heat"` (issue #161), as the lattice row's. */
@@ -1381,7 +1381,7 @@ export function buildRig(
1381
1381
  : { name: rg.name, shape: 'polygon', points: rg.points.map(([x, y]) => [x - ox, y - oy] as [number, number]), spacing: rg.spacing, band: rg.band },
1382
1382
  );
1383
1383
  const ribs = chainRibs(ribSpec.segments, ribSpec.ribs, ox, oy);
1384
- const cm = contourMesh(p.name, { width: w, height: h, alpha }, { threshold: ART_ALPHA, tolerance: spec.tolerance, margin: spec.margin, spacing: spec.spacing, ...(spec.maxEdge === undefined ? {} : { maxEdge: spec.maxEdge }), budget: spec.budget, stray: spec.stray, regions: inPart, ...(ribs.length === 0 ? {} : { ribs }) });
1384
+ const cm = contourMesh(p.name, { width: w, height: h, alpha }, { threshold: ART_ALPHA, tolerance: spec.tolerance, margin: spec.margin, spacing: spec.spacing, ...(spec.maxEdge === undefined ? {} : { maxEdge: spec.maxEdge }), budget: spec.budget, stray: spec.stray, ...(spec.bridges === undefined ? {} : { bridges: spec.bridges }), regions: inPart, ...(ribs.length === 0 ? {} : { ribs }) });
1385
1385
  if (Array.isArray(cm)) {
1386
1386
  out.push(...cm);
1387
1387
  return null;
@@ -1455,12 +1455,12 @@ 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 }),
1462
1462
  ...(ribSpec.ribs === undefined ? {} : { ribs: ribsRow(ribSpec.ribs, cm.ribs ?? []) }),
1463
- params: { tolerance: spec.tolerance, margin: spec.margin, spacing: spec.spacing, ...(spec.maxEdge === undefined ? {} : { maxEdge: spec.maxEdge }), budget: spec.budget ?? null, stray: spec.stray ?? null },
1463
+ params: { tolerance: spec.tolerance, margin: spec.margin, spacing: spec.spacing, ...(spec.maxEdge === undefined ? {} : { maxEdge: spec.maxEdge }), budget: spec.budget ?? null, stray: spec.stray ?? null, ...(spec.bridges === undefined ? {} : { bridges: spec.bridges }) },
1464
1464
  contour: rep,
1465
1465
  regions: regions.map((rg, k) => ({ name: rg.name, bone: rg.bone, reached: reached[k], whole: whole[k] })),
1466
1466
  ...(heat === null ? {} : { heat: heatRow(heat) }),
package/src/scene.ts CHANGED
@@ -92,7 +92,7 @@
92
92
  import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
93
93
  import { dirname, join, resolve } from 'node:path';
94
94
  import { checkStage, gateThroughRigc, type Log } from './build.ts';
95
- import { ACCEPTABLE_BARS, BARS, barsNotMet, type CheckReport, DEFAULT_PACK_SHAPE, DEFAULT_PAGE_EDGES, type PackMode, requirementsNotPass, type RigcRunner } from './check.ts';
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
98
  import { PartsError, type Problem, problemLine, refuseIfAny } from './errors.ts';
@@ -1421,7 +1421,7 @@ export function composeStage(input: ComposeInput, run: ComposeRunners, log: Log)
1421
1421
  log(`compose: stopped at check; the composed rig's check is PASS${acceptedLine} (the composed rig and scene.json are written; the scene is for looking, not shipping)`);
1422
1422
  return { stoppedAt: 'check', composed, check: report };
1423
1423
  }
1424
- log(`compose: PASS — ${report.bars.measured.length} of ${BARS.length} bar(s) measured; the packed atlas under ${join(input.out, 'check', 'build')} is the artifact`);
1424
+ log(`compose: PASS — ${report.bars.measured.length} of ${report.bars.measured.length + report.bars.skipped.length} bar(s) measured; the packed atlas under ${join(input.out, 'check', 'build')} is the artifact`);
1425
1425
  return { stoppedAt: null, composed, check: report };
1426
1426
  }
1427
1427