@rig-c/parts 1.13.0 → 1.14.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 } 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,24 @@ 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
+
549
612
  export interface PartRow {
550
613
  part: string;
551
614
  /** What the written config holds for the part. */
552
615
  mode: 'contour' | 'grid';
553
616
  lattice: MeshFigure & { grid: number; art_px: number };
554
617
  /** 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;
618
+ candidate: (MeshFigure & { mesh: ContourMeshSpec; bridges?: BridgeFigure }) | null;
556
619
  readings: PartReadings | null;
557
620
  gate: GateLine[];
558
621
  note: string;
@@ -566,6 +629,17 @@ export interface MeshDraftResult {
566
629
  written: string[];
567
630
  }
568
631
 
632
+ /** A bridged part's {@link BridgeFigure} off `<rigDir>/mesh_report.json`, or null when the report holds none for it. */
633
+ export function bridgesOf(rigDir: string, part: string, art: number): BridgeFigure | null {
634
+ const path = join(rigDir, 'mesh_report.json');
635
+ if (!existsSync(path)) return null;
636
+ const rows = JSON.parse(readFileSync(path, 'utf8')) as Array<{ part: string; contour?: { bridges?: ContourBridges } }>;
637
+ const b = rows.find((r) => r.part === part)?.contour?.bridges;
638
+ if (b === undefined) return null;
639
+ 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) };
641
+ }
642
+
569
643
  /** The mesh's area over the art's: the triangles' area in image px over the image's art px. */
570
644
  export function overdrawOf(att: MeshAttachment, art: number): number {
571
645
  let twice = 0;
@@ -660,6 +734,7 @@ export function meshDraftStage(input: MeshDraftInput, rigc: RigcRunner, checkBin
660
734
  // 2. Candidates for every lattice entry; a real piece keeps the lattice.
661
735
  const rows = new Map<string, PartRow>();
662
736
  const candidates = new Map<string, ContourMeshSpec>();
737
+ const joined = new Map<string, { stray: number | undefined; real: number[]; bone: string }>();
663
738
  for (const [part, spec] of Object.entries(cfg.meshes)) {
664
739
  if (!('grid' in spec)) continue;
665
740
  const img = images.get(part);
@@ -673,16 +748,36 @@ export function meshDraftStage(input: MeshDraftInput, rigc: RigcRunner, checkBin
673
748
  }
674
749
  const dust = dustOf(img);
675
750
  if ('keep' in dust) {
676
- row.note = `keeps its lattice: ${dust.keep}`;
751
+ // Issue #326: islands carried by one bone are drafted per island and joined by bridges.
752
+ const join = islandsJoin(dust.islands, att);
753
+ if (join === null || 'keep' in join) {
754
+ row.note = `keeps its lattice: ${dust.keep}${join === null ? '' : `; ${join.keep}`}`;
755
+ continue;
756
+ }
757
+ joined.set(part, join);
758
+ candidates.set(part, candidateMesh(spec, rules, join.stray, true));
677
759
  continue;
678
760
  }
679
761
  candidates.set(part, candidateMesh(spec, rules, dust.stray));
680
762
  }
681
763
  const dustText = (part: string): string => {
764
+ const j = joined.get(part);
765
+ if (j !== undefined) {
766
+ const img = images.get(part) as Raster;
767
+ const dust = (dustOf(img) as { islands: number[] }).islands.slice(j.real.length);
768
+ 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))}`;
769
+ }
682
770
  const img = images.get(part);
683
771
  const d = img === undefined ? null : dustOf(img);
684
772
  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
773
  };
774
+ const row = (part: string): PartRow => rows.get(part) as PartRow;
775
+ const bridgeText = (r: PartRow): string => {
776
+ const j = joined.get(r.part) as { real: number[]; bone: string };
777
+ 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)`;
780
+ };
686
781
 
687
782
  // 3. The drafted rig must build: a refusal laid on a part keeps that part's lattice.
688
783
  const keep = (part: string, reading: Reading, text: string): void => {
@@ -784,6 +879,10 @@ export function meshDraftStage(input: MeshDraftInput, rigc: RigcRunner, checkBin
784
879
  const att = attachmentOf(draftRig, part);
785
880
  const img = images.get(part) as Raster;
786
881
  row.candidate = { triangles: att.triangles.length / 3, overdraw: overdrawOf(att, artPixels(img)), mesh: candidates.get(part) as ContourMeshSpec };
882
+ if (joined.has(part)) {
883
+ const b = bridgesOf(draftedRig.rigDir, part, artPixels(img));
884
+ if (b !== null) row.candidate.bridges = b;
885
+ }
787
886
  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
887
  row.readings = readings;
789
888
  const v = gateVerdict({ ...readings, bars: [] }, input.moreTriangles === true);
@@ -857,7 +956,7 @@ export function meshDraftStage(input: MeshDraftInput, rigc: RigcRunner, checkBin
857
956
  part: r.part,
858
957
  mode: r.mode,
859
958
  lattice: r.lattice,
860
- candidate: r.candidate === null ? null : { triangles: r.candidate.triangles, overdraw: r.candidate.overdraw },
959
+ candidate: r.candidate === null ? null : { triangles: r.candidate.triangles, overdraw: r.candidate.overdraw, ...(r.candidate.bridges === undefined ? {} : { bridges: r.candidate.bridges }) },
861
960
  // Under --more-triangles only: what accepting a costlier candidate trades, triangles against overdraw.
862
961
  ...(input.moreTriangles === true && r.candidate !== null ? { delta: { triangles: r.candidate.triangles - r.lattice.triangles, overdraw: pyRound(r.candidate.overdraw - r.lattice.overdraw, 4) } } : {}),
863
962
  readings: r.readings,
@@ -873,7 +972,7 @@ export function meshDraftStage(input: MeshDraftInput, rigc: RigcRunner, checkBin
873
972
  writeFileSync(written[0], configText);
874
973
  writeFileSync(written[1], `${JSON.stringify(report, null, 2)}\n`);
875
974
  for (const r of rowsOut) {
876
- const cand = r.candidate === null ? '' : `; candidate ${r.candidate.triangles} triangle(s), overdraw ${r.candidate.overdraw}`;
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)`}`;
877
976
  log(` ${r.mode === 'contour' ? 'DRAFTED' : 'LATTICE'} ${r.part}: lattice grid ${r.lattice.grid} ${r.lattice.triangles} triangle(s), overdraw ${r.lattice.overdraw}${cand}`);
878
977
  for (const g of r.gate) log(` ${g.pass ? 'pass' : 'FAIL'} ${g.reading}: ${g.text}`);
879
978
  if (r.mode === 'grid' && r.gate.length === 0) log(` ${r.note}`);
@@ -179,6 +179,23 @@ 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 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.
186
+ * `ANIMATIONS_DO_NOT_FOLD` names each such fold with its why and does not
187
+ * hold it to the bar, and fails an entry whose slot does not fold in that
188
+ * animation, as rig-c refuses an exemption that exempts nothing. Empty when
189
+ * not declared: no fold in a named animation is meant.
190
+ */
191
+ may_fold: MayFold[];
192
+ }
193
+
194
+ /** One declared fold (issue #320): a slot, a named animation, and why the fold is the art's. */
195
+ export interface MayFold {
196
+ slot: string;
197
+ animation: string;
198
+ why: string;
182
199
  }
183
200
 
184
201
  /** What `check --requirements` and `build --requirements` name, said once for the help and the refusals. */
@@ -257,7 +274,7 @@ export function readRequirements(path: string): RequirementsFile {
257
274
  problems.push({ code: 'REQUIREMENTS_FIELD', object, detail });
258
275
  };
259
276
  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)');
277
+ 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
278
  let fps = NaN;
262
279
  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
280
  else fps = raw.fps;
@@ -407,8 +424,31 @@ export function readRequirements(path: string): RequirementsFile {
407
424
  lists.rests.forEach((n, i) => {
408
425
  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
426
  });
427
+ // 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.
428
+ const mayFold: MayFold[] = [];
429
+ if (raw.may_fold !== undefined) {
430
+ 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`);
431
+ else {
432
+ raw.may_fold.forEach((e, i) => {
433
+ const where = `${at} field "may_fold"[${i}]`;
434
+ if (!isRecord(e)) {
435
+ field(where, `is ${JSON.stringify(e)?.slice(0, 40)}; { "slot": <name>, "animation": <name>, "why": <text> } is required`);
436
+ return;
437
+ }
438
+ const before = problems.length;
439
+ 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)');
440
+ for (const k of ['slot', 'animation', 'why'] as const) {
441
+ 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`}`);
442
+ }
443
+ if (problems.length !== before) return;
444
+ const entry: MayFold = { slot: e.slot as string, animation: e.animation as string, why: e.why as string };
445
+ 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`);
446
+ else mayFold.push(entry);
447
+ });
448
+ }
449
+ }
410
450
  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 };
451
+ 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
452
  }
413
453
 
414
454
  // ---------------------------------------------------------------------------
@@ -541,6 +581,21 @@ export function resolveRequirements(file: RequirementsFile, rig: Record<string,
541
581
  }
542
582
  }
543
583
 
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
+ file.may_fold.forEach((m, i) => {
586
+ 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
+ const slots = (Array.isArray(rig.slots) ? rig.slots : []).filter(isRecord).map((s) => s.name);
590
+ if (!slots.includes(m.slot)) {
591
+ unresolved(object, `names slot "${m.slot}", which rig.json does not declare`);
592
+ return;
593
+ }
594
+ const meshes = meshAttachmentsOf(rig, m.slot);
595
+ if (typeof meshes === 'string') problems.push({ code: 'REQUIREMENTS_RESOLVES', object, detail: meshes });
596
+ 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` });
597
+ });
598
+
544
599
  const root = bones.find((b) => b.name === rootBone);
545
600
  const measured = new Set(file.requirements.map((r) => r.animation));
546
601
  const placed = new Set<string>();
@@ -1009,8 +1064,14 @@ export function followLine(r: FollowRequirement, declared: Poses, released: Pose
1009
1064
  };
1010
1065
  }
1011
1066
 
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 {
1067
+ /**
1068
+ * `stretch`: `TEXTURE_STRETCH`'s per-mesh figure for one mesh over the named animation's frames — its worst
1069
+ * max(ratio, 1/ratio) and where. With `heldBy`, the line also carries `held_by` (issue #321): what the worst edge's two
1070
+ * vertices hang on. `check` passes `edgeBonesText` (`src/check.ts`), the function `CHECK_TEXTURE_STRETCH` names them
1071
+ * with since #314, so the two lines cannot drift; it is handed in rather than imported because `src/check.ts` imports
1072
+ * this module.
1073
+ */
1074
+ export function stretchRequirementLine(r: StretchRequirement, meshes: readonly MeshRest[], frames: readonly GeometryPose[], p: Poses, heldBy?: (edge: readonly number[]) => string): RequirementLine {
1014
1075
  const mesh = meshes.find((m) => m.slot === r.slot && m.attachment === r.attachment);
1015
1076
  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
1077
  const [m] = stretchFigures([mesh], frames);
@@ -1032,6 +1093,7 @@ export function stretchRequirementLine(r: StretchRequirement, meshes: readonly M
1032
1093
  triangles: m.triangles,
1033
1094
  severity: round6(severity),
1034
1095
  worst: `triangle ${at.triangle} (vertices ${at.vertices.join(' ')}), edge ${at.edge.join('-')}, ratio ${round6(at.ratio)}`,
1096
+ ...(heldBy === undefined ? {} : { held_by: heldBy(at.edge) }),
1035
1097
  at: fi < 0 ? `frame ${at.frame}` : frameText(p, fi),
1036
1098
  max_ratio: round6(m.max.ratio),
1037
1099
  min_ratio: round6(m.min.ratio),
@@ -1274,7 +1336,7 @@ function failText(r: Requirement, l: RequirementLine): string {
1274
1336
  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
1337
  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
1338
  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)}`;
1339
+ 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
1340
  if (r.kind === 'seam') return `largest opening ${String(l.largest_px)} px at ${String(l.at)}, ${String(l.pair)}`;
1279
1341
  return `measured fraction ${String(l.fraction_measured)} over ${String(l.frames_counted)} frame(s) that reach the least drive`;
1280
1342
  }
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;
@@ -1460,7 +1460,7 @@ export function buildRig(
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