@rig-c/parts 1.11.0 → 1.13.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rig-c/parts",
3
- "version": "1.11.0",
3
+ "version": "1.13.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": {
@@ -60,7 +60,7 @@
60
60
  },
61
61
  "dependencies": {
62
62
  "ag-psd": "^31.0.2",
63
- "rig-c": "^2.40.0"
63
+ "rig-c": "^2.47.1"
64
64
  },
65
65
  "devDependencies": {
66
66
  "@esotericsoftware/spine-core": "4.3.13",
@@ -82,10 +82,12 @@ stage writes are the whole interface.
82
82
  keeps under the piece's edge; 1 px closed the seam on scarf) — and give the piece its own
83
83
  `meshes` entry; the `cut:` line gives the split. At overlap 0 a cut through
84
84
  opaque art leaves a seam `check` reads (AUTHORING §5).
85
- A seated or reclining figure: add `--keypoints <file>` (body-18 joints in
86
- painting px, each observed, occluded or missing; AUTHORING §3, *A posed
87
- figure*) so the neck, hip, chest and sleeves follow the pose and LINT reads
88
- the joints, not screen y. Never write a joint you did not get from an
85
+ A figure whose joints are not where the screen-y rules put them — reclining,
86
+ an arm raised, seated in a skirt that does not start at the hip: add
87
+ `--keypoints <file>` (body-18 joints in painting px, each observed, occluded
88
+ or missing; AUTHORING §3, *A posed figure*) so the neck, hip, chest and
89
+ sleeves follow the pose and LINT reads the joints, not screen y. A clean
90
+ LINT does not show a seated hip is right; the overlay does. Never write a joint you did not get from an
89
91
  estimator or the painting: say `missing` and let the rule stand in.
90
92
  6. Correct against `render/landmarks.png` and `note:` lines; a
91
93
  `coverage … not checked` line is a bone no check read (the line says why),
@@ -109,7 +111,8 @@ stage writes are the whole interface.
109
111
  8. Read `out/check/check.json` — the six judgement lines the same way as the seam
110
112
  and the loop: a FAIL names the field to change (AUTHORING §6);
111
113
  `TEXTURE_STRETCH` names the worst mesh triangle (slot, triangle, vertices, edge,
112
- frame), so quote it even on a PASS. Quote
114
+ frame), so quote it even on a PASS; its FAIL line also names the bones the
115
+ edge's two vertices are weighted to and the idle chains they hang on. Quote
113
116
  `RECOMPOSITE_HOLES` (uncovered px, hole count, the largest hole's box) in the
114
117
  report whatever `PASS` says. The artifact is the three paths the build prints
115
118
  last: `check/build/skeleton.json`, `skeleton.atlas` and the packed page. Report
package/src/assemble.ts CHANGED
@@ -2654,6 +2654,14 @@ function fmt0(v: number): string {
2654
2654
  * no role. One both runs hold is proposed once, from the full run, and a note
2655
2655
  * says how to take the head run's too. No public example holds one, so no
2656
2656
  * proposal of theirs moves.
2657
+ *
2658
+ * ⚖️ This port's too (issue #308): every tag a layer of either run holds that
2659
+ * no plan row or extend entry takes is named once, with why, run by run
2660
+ * (`unplannedNotes`), after the uncovered bodies and before the plausibility
2661
+ * drops. The plan is not changed. [observed] On the four public examples the
2662
+ * plan, the extend entries and every earlier note are byte for byte as
2663
+ * before; each gains one such note per tag left out (demo 6, scarf 9,
2664
+ * reference-demo 7, reference-sample 9).
2657
2665
  */
2658
2666
  export function proposePlan(full: LayerSet, head: LayerSet, g: Geometry, minPx = PROPOSE_MIN_PX): PlanProposal {
2659
2667
  const frame = checkGeometry(g, { full, head });
@@ -2778,8 +2786,83 @@ export function proposePlan(full: LayerSet, head: LayerSet, g: Geometry, minPx =
2778
2786
  why,
2779
2787
  );
2780
2788
  const bodyNotes = left.map((u) => uncoveredNote(u, plan, minBody));
2781
- // The reference's notes, then the uncovered bodies, then the plausibility drops — a drop note is last, as it was.
2782
- return { plan, extend_below_crop: extend, notes: [...notes, ...bodyNotes, ...dropNotes] };
2789
+ const unplanned = unplannedNotes(runs, plan, extend, noted, figures, minPx, (run, tag) => (find(run, tag)?.opaqueRunPx ?? 0) * (run === 'head' ? headArea : fullArea), new Set(left.map((u) => u.tag)));
2790
+ // The reference's notes, then the uncovered bodies, then the layers in no row (issue #308), then the plausibility drops — a drop note is last, as it was.
2791
+ return { plan, extend_below_crop: extend, notes: [...notes, ...bodyNotes, ...unplanned, ...dropNotes] };
2792
+ }
2793
+
2794
+ /** What every `--propose-plan` note on a layer in no row says its pixel figures are. */
2795
+ export const UNPLANNED_PX = 'px: run pixels of alpha above 8 after ghost clean-up';
2796
+
2797
+ /**
2798
+ * One note per tag that a layer of either run holds and no plan row or
2799
+ * extend entry takes (issue #308), in the full run's draw order and then the
2800
+ * head run's, saying for each run why: it holds no such layer; the proposer
2801
+ * takes the tag from the other run only (`PROPOSE_HEAD_TAGS`); its layer is
2802
+ * empty, its opaque count below the floor (`PROPOSE_MIN_PX`); it crosses a
2803
+ * plausibility rule (that layer's own note, which follows, names its figures);
2804
+ * or, for a full-run `headwear` or `earwear`, the head run's was not below 0.8
2805
+ * of it in rig px. A tag every holding run of which a plausibility note
2806
+ * already names gets no second note. `nose`, which the proposer never plans
2807
+ * (the reference's rule), gets a note of its own: the counts, and the row that
2808
+ * lets `propose` read the head's pitch, the only reading in this package made
2809
+ * off a part tagged `nose` (`headFrameNotes`, `src/propose.ts`). The plan is
2810
+ * not changed.
2811
+ */
2812
+ function unplannedNotes(
2813
+ runs: Record<Run, RunLayer[]>,
2814
+ plan: readonly PlanEntry[],
2815
+ extend: readonly Extend[],
2816
+ noted: ReadonlySet<string>,
2817
+ figures: Record<Run, LayerFigures[]>,
2818
+ minPx: number,
2819
+ rigPx: (run: Run, tag: string) => number,
2820
+ bodies: ReadonlySet<string>,
2821
+ ): string[] {
2822
+ const inRow = new Set<string>([...plan.map((p) => p[2]), ...extend.map((e) => e.tag)]);
2823
+ const find = (run: Run, tag: string): RunLayer | undefined => runs[run].find((l) => l.tag === tag);
2824
+ const floor = minPx === PROPOSE_MIN_PX ? `the proposer's floor of ${minPx} (PROPOSE_MIN_PX, src/assemble.ts)` : `the floor of ${minPx} this proposal was given`;
2825
+ const out: string[] = [];
2826
+ const seen = new Set<string>();
2827
+ for (const { tag } of [...runs.full, ...runs.head]) {
2828
+ if (seen.has(tag) || inRow.has(tag)) continue;
2829
+ seen.add(tag);
2830
+ const held = (['full', 'head'] as const).filter((r) => find(r, tag) !== undefined);
2831
+ const body = bodies.has(tag) ? "; the full run's layer holds a body no planned part covers, named in its note above" : '';
2832
+ if (tag === 'nose') {
2833
+ const counts = (['full', 'head'] as const).map((r) => `${r} run ${find(r, tag)?.opaqueRunPx ?? 'no layer'}${find(r, tag) === undefined ? '' : ' px'}`).join(', ');
2834
+ const best = (['head', 'full'] as const).reduce((a, b) => ((find(b, tag)?.opaqueRunPx ?? 0) > (find(a, tag)?.opaqueRunPx ?? 0) ? b : a));
2835
+ const most = find(best, tag)?.opaqueRunPx ?? 0;
2836
+ const after = plan.some((p) => p[2] === 'face') ? ` after the row that takes "face"` : '';
2837
+ const how =
2838
+ most === 0
2839
+ ? 'neither run\'s layer holds a pixel, so there is no nose part to add'
2840
+ : `propose reads the head's pitch (where the nose sits between the eye midpoint and the mouth) only off a part tagged nose, and without one prints "not read: no part tagged nose"; to have it read, add ["nose", "${best}", "nose"] to assemble.plan${after} (the ${best} run's layer holds the more of it) and run assemble and propose again`;
2841
+ out.push(`nose: in no plan row — the proposer never plans nose (the reference implementation's rule, which this port keeps); ${counts} (${UNPLANNED_PX}); ${how}${body}`);
2842
+ continue;
2843
+ }
2844
+ if (held.every((r) => noted.has(`${r}:${tag}`))) continue;
2845
+ const reason = (run: Run): string => {
2846
+ const l = find(run, tag);
2847
+ if (l === undefined) return `${run} run: no layer`;
2848
+ const px = `${run} run: ${l.opaqueRunPx} px`;
2849
+ const headTag = PROPOSE_HEAD_TAGS.includes(tag);
2850
+ const fallback = tag === 'headwear' || tag === 'earwear';
2851
+ if (run === 'full' && headTag && !fallback) return `${px}, but the proposer takes ${tag} from the head run only (PROPOSE_HEAD_TAGS, src/assemble.ts)`;
2852
+ if (run === 'head' && !headTag && readAuthorTag(tag) === null) return `${px}, but the proposer takes ${tag} from the full run only (it is not one of PROPOSE_HEAD_TAGS, src/assemble.ts)`;
2853
+ if (l.opaqueRunPx < minPx) return `${px}, empty: below ${floor}`;
2854
+ if (noted.has(`${run}:${tag}`)) {
2855
+ const f = figures[run].find((x) => x.name === tag) as LayerFigures;
2856
+ return `${px}, crosses ${implausibleRules(f).join(', ')} (named with its figures in the note below)`;
2857
+ }
2858
+ if (run === 'full' && fallback) {
2859
+ return `${px}, but the proposer takes ${tag} from the full run only when the head run's is below 0.8 of it in rig px (head run ${fmt0(rigPx('head', tag))}, full run ${fmt0(rigPx('full', tag))})`;
2860
+ }
2861
+ throw new Error(`unplannedNotes: no rule left ${run}:${tag} out of the plan, yet no row takes it`);
2862
+ };
2863
+ out.push(`${tag}: in no plan row — ${(['full', 'head'] as const).map(reason).join('; ')} (${UNPLANNED_PX})${body}`);
2864
+ }
2865
+ return out;
2783
2866
  }
2784
2867
 
2785
2868
  // ---------------------------------------------------------------------------
package/src/build.ts CHANGED
@@ -227,7 +227,7 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
227
227
  log(
228
228
  ` ${p.name.padEnd(11)} ${p.from.padEnd(16)} ${`${p.w}x${p.h}`.padEnd(9)} @${String(p.x).padStart(4)},${String(p.y).padStart(4)} ` +
229
229
  `op=${String(p.opaque_px).padStart(6)} vis=${String(p.visible_px).padStart(6)} src=${String(p.source_px_taken).padStart(6)}/${String(p.projected_core_px).padStart(6)} ` +
230
- `unproj=${String(p.visible_not_projected_px).padStart(5)} drift=${String(p.refused_drift_px).padStart(5)} merged=${p.merged_px} seam=${p.seam_override_px}`,
230
+ `unproj=${String(p.visible_not_projected_px).padStart(5)} drift=${String(p.refused_drift_px).padStart(5)} ${driftShare(p.refused_drift_px, p.projected_core_px)} merged=${p.merged_px} seam=${p.seam_override_px}`,
231
231
  );
232
232
  }
233
233
  // Issue #256: one line per part from an author-made layer, so it never reads as See-through's. None without one.
@@ -253,6 +253,16 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
253
253
  return result;
254
254
  }
255
255
 
256
+ /**
257
+ * The scale the per-part line prints `drift=` with (issue #314): `refused_drift_px` counts the part's projection
258
+ * candidates (`projected_core_px`, the `src=` column's second figure) refused because See-through's colour sits more
259
+ * than the drift limit off the painting's, so it is read against them — `(<p>% of core)`, one decimal; a part with no
260
+ * candidate has nothing to refuse and says so. No bar reads either figure (AUTHORING §5, `assemble`).
261
+ */
262
+ export function driftShare(refused: number, core: number): string {
263
+ return core === 0 ? '(no core)' : `(${((100 * refused) / core).toFixed(1)}% of core)`;
264
+ }
265
+
256
266
  // ---------------------------------------------------------------------------
257
267
  // rig
258
268
  // ---------------------------------------------------------------------------
@@ -834,7 +844,11 @@ export function rigStage(input: RigStageInput, rigc: RigcRunner, scratch: string
834
844
  const stray = c.strayIslands === 0 ? '' : ` left out ${c.strayIslands} island(s), ${c.strayPixels} px`;
835
845
  const regions = m.regions.map((rg) => ` region ${rg.name}->${rg.bone} reaches ${rg.reached} (${rg.whole} whole)`).join('');
836
846
  log(`${head}contour tol=${m.params.tolerance} margin=${m.params.margin} spacing=${m.params.spacing}${m.params.maxEdge === undefined ? '' : ` maxEdge=${m.params.maxEdge} (outline +${c.outlineCut ?? 0})`} ${infl} overshoot ${c.overshoot} min angle ${c.smallestAngle.value}${stray}${regions}`);
837
- } else log(`${head}grid=${m.grid} ${infl} loop passes ${rig.loopPasses[m.part]}`);
847
+ } else {
848
+ // issue #303: only beside a declared stray, so a lattice line without one reads as it always did.
849
+ const stray = m.stray_cleared === undefined ? '' : ` stray ${m.stray} left out ${m.stray_cleared.islands} island(s), ${m.stray_cleared.pixels} of ${m.stray_cleared.art_pixels} art px`;
850
+ log(`${head}grid=${m.grid} ${infl} loop passes ${rig.loopPasses[m.part]}${stray}`);
851
+ }
838
852
  }
839
853
  const regions = rig.rig.slots.length - rig.meshReport.length;
840
854
  const tracks = rig.motion.animations.idle.tracks;
package/src/check.ts CHANGED
@@ -1948,7 +1948,7 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
1948
1948
  // One reader for the one geometry file: the stretch reads its meshes, the still regions every slot and the head bone.
1949
1949
  const geometryPath = join(idle.dir, GEOMETRY_FILE);
1950
1950
  const geometry = readGeometry(geometryPath, idle.written);
1951
- stretch = stretchLine(geometry, geometryPath, problems);
1951
+ stretch = stretchLine(geometry, geometryPath, problems, inp);
1952
1952
  if (full !== null) still = stillLine(full, idle, geometry, geometryPath, problems, notPlanted);
1953
1953
  }
1954
1954
 
@@ -3300,15 +3300,76 @@ function stretchAtText(s: { triangle: number; vertices: readonly number[]; edge:
3300
3300
  return `triangle ${s.triangle} (vertices ${s.vertices.join(' ')}), edge ${s.edge.join('-')}, idle frame ${s.frame}`;
3301
3301
  }
3302
3302
 
3303
+ /**
3304
+ * Issue #314: what a failing edge's two vertices hang on, read off rig.json and motion.json — so the cause reads off
3305
+ * the line rather than off a hand decoding of the weight runs. Each vertex's bones from the default skin's weights,
3306
+ * the largest two by weight (equal weights in rig.json's order), with how many it has; then every bone named, grouped
3307
+ * by the idle chain it hangs on — the nearest link at or above it of a chain `CHAIN_LAG` reads
3308
+ * ({@link chainFigures}: two or more rotate-keyed links at one period), each chain named once, by its first link — and
3309
+ * the bones on none. A mesh with no weights rides its slot's bone, and an attachment the skin does not hold, or a
3310
+ * vertex its weights do not reach, is said rather than guessed.
3311
+ */
3312
+ export function edgeBonesText(rig: Record<string, unknown>, motion: Record<string, unknown>, slot: string, attachment: string, edge: readonly number[]): string {
3313
+ const skins = isRecord(rig.skins) ? rig.skins : {};
3314
+ const skin = isRecord(skins.default) ? skins.default : {};
3315
+ const onSlot = isRecord(skin[slot]) ? skin[slot] : {};
3316
+ const att = onSlot[attachment];
3317
+ if (!isRecord(att)) return `its weights are not read: rig.json's default skin holds no attachment "${attachment}" on slot "${slot}"`;
3318
+ if (!Array.isArray(att.weights)) {
3319
+ const sl = (Array.isArray(rig.slots) ? rig.slots : []).filter(isRecord).find((x) => x.name === slot);
3320
+ return `the mesh carries no weights, so vertex ${edge.join(' and vertex ')} ride slot "${slot}"'s bone ${sl !== undefined && typeof sl.bone === 'string' ? `"${sl.bone}"` : '(rig.json names none)'}`;
3321
+ }
3322
+ const weights = att.weights;
3323
+ const missing = edge.filter((v) => !Array.isArray(weights[v]));
3324
+ if (missing.length > 0) return `its weights are not read: rig.json's mesh "${attachment}" weights ${weights.length} vertices, and vertex ${missing.join(' and ')} is not among them`;
3325
+ const named: string[] = [];
3326
+ const parts = edge.map((v, i) => {
3327
+ const list = (weights[v] as unknown[]).filter(isRecord).filter((w) => typeof w.bone === 'string' && typeof w.weight === 'number') as Array<{ bone: string; weight: number }>;
3328
+ const top = [...list].sort((a, b) => b.weight - a.weight).slice(0, 2);
3329
+ for (const w of top) if (!named.includes(w.bone)) named.push(w.bone);
3330
+ const shown = top.map((w) => `"${w.bone}" ${w.weight}`).join(', ');
3331
+ return `${i === 0 ? `vertex ${v} is weighted to` : `vertex ${v} to`} ${shown}${list.length > 2 ? ` (the largest 2 of ${list.length})` : ''}`;
3332
+ });
3333
+ const bones = (Array.isArray(rig.bones) ? rig.bones : []).filter(isRecord);
3334
+ const parent = new Map(bones.filter((b) => typeof b.name === 'string').map((b) => [String(b.name), typeof b.parent === 'string' ? b.parent : null]));
3335
+ const firstLink = new Map<string, string>();
3336
+ for (const c of chainFigures(rig, motion).chains) if (c.length >= 2) for (const l of c) firstLink.set(l.bone, c[0].bone);
3337
+ const carries = new Map<string, string[]>();
3338
+ const none: string[] = [];
3339
+ for (const b of named) {
3340
+ let chain: string | null = null;
3341
+ const seen = new Set<string>();
3342
+ for (let x: string | null = b; x !== null && !seen.has(x); x = parent.get(x) ?? null) {
3343
+ const f = firstLink.get(x);
3344
+ if (f !== undefined) {
3345
+ chain = f;
3346
+ break;
3347
+ }
3348
+ seen.add(x);
3349
+ }
3350
+ if (chain === null) none.push(b);
3351
+ else carries.set(chain, [...(carries.get(chain) ?? []), b]);
3352
+ }
3353
+ const quoted = (l: readonly string[]): string => l.map((n) => `"${n}"`).join(', ');
3354
+ const chains =
3355
+ carries.size === 0
3356
+ ? 'none of these bones hangs on a chain of the idle'
3357
+ : `on the idle's chains, ${[...carries].map(([c, l]) => `the chain from "${c}" carries ${quoted(l)}`).join(' and ')}${none.length === 0 ? '' : `, and ${quoted(none)} ${none.length === 1 ? 'hangs' : 'hang'} on none`}`;
3358
+ return `${parts.join(' and ')}; ${chains}`;
3359
+ }
3360
+
3303
3361
  /**
3304
3362
  * (6) No visible texture stretch: for every mesh triangle, each edge's length
3305
3363
  * in each idle frame over its rest length (the setup pose's bones, no deform —
3306
3364
  * which on both examples is the art's own proportions, the attachment's `uvs`
3307
3365
  * times its size, to 2.4e-5). The figure is the rig's worst max(ratio,
3308
3366
  * 1/ratio); each mesh's largest and smallest ratio, and where, are the detail.
3309
- * SKIP when the render wrote no geometry file or the rig draws no mesh.
3367
+ * SKIP when the render wrote no geometry file or the rig draws no mesh. With
3368
+ * `bound`, the rig and motion `check` read, a failing edge's line also names the
3369
+ * bones its two vertices hang on and their chains ({@link edgeBonesText}, issue
3370
+ * #314).
3310
3371
  */
3311
- export function stretchLine(geo: IdleGeometry | null, path: string, problems: Problem[]): JudgementLine {
3372
+ export function stretchLine(geo: IdleGeometry | null, path: string, problems: Problem[], bound?: Pick<CheckInputs, 'rig' | 'motion'>): JudgementLine {
3312
3373
  if (geo === null) return skip(`${path} does not exist; \`rigc render --geometry\` (rig-c ${RIGC_GEOMETRY_VERSION} or later) writes it, and the skinned mesh vertices are read from it`);
3313
3374
  if (geo.meshes.length === 0) return skip('the rig draws no mesh attachment (every slot is a region), so there is no triangle to stretch');
3314
3375
  const figures = stretchFigures(geo.meshes, geo.frames);
@@ -3343,7 +3404,7 @@ export function stretchLine(geo: IdleGeometry | null, path: string, problems: Pr
3343
3404
  problems.push({
3344
3405
  code: 'CHECK_TEXTURE_STRETCH',
3345
3406
  object: `mesh "${m.slot}" ${stretchAtText(at)}`,
3346
- detail: `the edge is ${round3(at.ratio)} times its rest length (max(ratio, 1/ratio) ${severity}); <= ${TEXTURE_STRETCH_CEILING} is required — the texture on it is ${at.ratio >= 1 ? 'stretched' : 'squeezed'}: the bones this mesh is weighted to move apart (amplitudes too large down a chain), or a vertex blends bones that move against each other`,
3407
+ detail: `the edge is ${round3(at.ratio)} times its rest length (max(ratio, 1/ratio) ${severity}); <= ${TEXTURE_STRETCH_CEILING} is required${bound === undefined ? '' : `; ${edgeBonesText(bound.rig, bound.motion, m.slot, m.attachment, at.edge)}`} — the texture on it is ${at.ratio >= 1 ? 'stretched' : 'squeezed'}: the bones this mesh is weighted to move apart (amplitudes too large down a chain), or a vertex blends bones that move against each other`,
3347
3408
  });
3348
3409
  }
3349
3410
  }
package/src/config.ts CHANGED
@@ -278,6 +278,11 @@ export interface LatticeMeshSpec {
278
278
  rule?: WeightRule;
279
279
  /** The cap and the floor's form (issue #284); absent is 4 and the cut at 0.03, the rule as it always was. */
280
280
  influences?: MeshInfluences;
281
+ /**
282
+ * The largest island, in art pixels, that may be left out of the lattice (issue #303) — the contour mode's
283
+ * `contour.stray`, with the same meaning (`latticeStray`, `src/mesh.ts`); absent leaves none out.
284
+ */
285
+ stray?: number;
281
286
  }
282
287
 
283
288
  export interface ContourMeshSpec {
@@ -1665,7 +1670,7 @@ function checkMeshes(c: Check, v: Json, bones: Set<string>, chains: Map<string,
1665
1670
  const at = `config.meshes.${part}`;
1666
1671
  // The known keys keep their old order (grid, r, segments) with contour after them, and `r` and `segments` are
1667
1672
  // required in the place and words the object check uses, so a lattice mesh's refusals read as they did.
1668
- const m = c.object(at, spec, [], ['grid', 'r', 'segments', 'contour', 'auto', 'exponent', 'rule', 'ribs', 'influences']);
1673
+ const m = c.object(at, spec, [], ['grid', 'r', 'segments', 'contour', 'auto', 'exponent', 'rule', 'ribs', 'influences', 'stray']);
1669
1674
  if (m === null) continue;
1670
1675
  for (const key of ['r', 'segments']) if (!(key in m)) c.fail('CONFIG_FIELD_PRESENT', `${at}.${key}`, 'is absent and required');
1671
1676
  const modes = ['grid', 'contour', 'auto'].filter((k) => k in m);
@@ -1707,6 +1712,7 @@ function checkMeshes(c: Check, v: Json, bones: Set<string>, chains: Map<string,
1707
1712
  }
1708
1713
  if ('ribs' in m) checkRibs(c, `${at}.ribs`, m, chains);
1709
1714
  if ('influences' in m) checkMeshInfluences(c, `${at}.influences`, m);
1715
+ if ('stray' in m) checkLatticeStray(c, `${at}.stray`, m);
1710
1716
  if ('auto' in m && typeof m.auto === 'object' && m.auto !== null && !Array.isArray(m.auto) && 'stimulus' in m.auto) {
1711
1717
  checkStimulus(c, `${at}.auto.stimulus`, (m.auto as Record<string, Json>).stimulus, m, chains);
1712
1718
  }
@@ -1775,6 +1781,21 @@ function checkMeshInfluences(c: Check, at: string, m: Record<string, Json>): voi
1775
1781
  }
1776
1782
  }
1777
1783
 
1784
+ /**
1785
+ * `meshes.<part>.stray` (issue #303): the lattice's largest island, in art pixels, that may be left out — a whole
1786
+ * number, 0 or more, as the contour mode's `contour.stray` is. The contour and automatic modes already state it in
1787
+ * their own place (`contour.stray`, `auto.source.stray`), so beside either it is refused by name
1788
+ * (`CONFIG_STRAY_MODE`) rather than read by nothing.
1789
+ */
1790
+ function checkLatticeStray(c: Check, at: string, m: Record<string, Json>): void {
1791
+ const elsewhere = 'contour' in m ? ['a contour mesh (contour)', 'contour.stray'] : 'auto' in m ? ['an automatic mesh (auto)', 'auto.source.stray'] : null;
1792
+ if (elsewhere !== null && !('grid' in m)) {
1793
+ c.fail('CONFIG_STRAY_MODE', at, `is ${show(m.stray)} on ${elsewhere[0]}; that mode states the largest island it may leave out in ${elsewhere[1]}, which its trace reads — declare it there, or remove stray`);
1794
+ return;
1795
+ }
1796
+ c.int(at, m.stray, 0);
1797
+ }
1798
+
1778
1799
  /**
1779
1800
  * `meshes.<part>.ribs` (issue #188): an object of chain name → `{ stations }`.
1780
1801
  * Each key must be a chain this mesh's `segments` name (a rib is placed along
package/src/heat.ts CHANGED
@@ -44,10 +44,19 @@
44
44
  * silhouette, or one whose every reached pixel is nearer another bone. A
45
45
  * bone named in `segments` is a bone the author said may pull the layer;
46
46
  * giving it 0 everywhere would be a silent answer to a question the
47
- * config asked.
47
+ * config asked. The rule is chosen per mesh (`meshes.<part>.rule`), so the
48
+ * refusal names the third remedy too (issue #303): this part keeps the
49
+ * distance rule — the one an author whose bone anchors the part from
50
+ * outside its art (a torso bone on a neck) can take without moving a bone
51
+ * another part placed or dropping the anchor.
48
52
  * - `RIG_HEAT_ISLAND_SOURCE`: a 4-connected island of the silhouette that no
49
53
  * bone has a source in. With no fixed temperature the equation has no
50
- * answer there; nothing nearest is borrowed.
54
+ * answer there; nothing nearest is borrowed. When the caller can say the
55
+ * island is dust that its `stray` would leave out ({@link HeatDust}: the
56
+ * lattice, issue #303) the refusal names that field and the figure that
57
+ * leaves the island out instead of a segment nobody would place on it; an
58
+ * island above the dust line keeps the segment remedy, a real piece being
59
+ * a real authoring question.
51
60
  * - `RIG_HEAT_CONVERGED`: above.
52
61
  *
53
62
  * Two runs on the same input write the same bytes: the claims, the sweep
@@ -55,6 +64,8 @@
55
64
  */
56
65
  import type { Point } from './config.ts';
57
66
  import { artMask, growSilhouette } from './contour.ts';
67
+ import { DUST_MAX_PX, DUST_MAX_SHARE, isDust } from './mesh.ts';
68
+ import { pyRound } from './round.ts';
58
69
  import type { Problem } from './errors.ts';
59
70
  import { connectedComponents, fillHoles, type Mask } from './raster/index.ts';
60
71
  import { capAndFloor, type Influence, type InfluenceLimits, type Segment, segmentDistance } from './weights.ts';
@@ -120,11 +131,34 @@ export interface HeatRow {
120
131
 
121
132
  const fmt = (v: number): string => String(Math.round(v * 1000) / 1000);
122
133
 
134
+ /**
135
+ * What a mesh's `stray` can say about a silhouette island no bone reaches (issue #303), handed in by the mode that has
136
+ * the field (the lattice): the field's path, its declared figure (null when absent), and the part's art islands it
137
+ * reads — the art px (`isDust`'s share is of them) and the largest island's area, which `stray` never leaves out. It
138
+ * is read only when an island is refused, so a build that solves never computes it.
139
+ */
140
+ export interface HeatDust {
141
+ field: string;
142
+ declared: number | null;
143
+ artPixels: number;
144
+ largest: number;
145
+ }
146
+
147
+ /** The refusal's remedy for an island of `area` px no bone reaches: the stray field when the island is dust it would leave out, else a segment. */
148
+ function islandRemedy(area: number, dust: HeatDust | null): string {
149
+ if (dust !== null && isDust(area, dust.artPixels) && area < dust.largest) {
150
+ const now = dust.declared === null ? 'it is absent' : `it is ${dust.declared}`;
151
+ return `the island is dust — at most ${DUST_MAX_PX} px and at most ${pyRound(DUST_MAX_SHARE * 100, 6)} % of the part's ${dust.artPixels} art px, mesh-draft's dust line — which no bone is meant to sit on: declare ${dust.field} at ${area} or more (${now}), which leaves an island that small out of the mesh and the silhouette heat solves over`;
152
+ }
153
+ return `under rule "heat" every island needs a segment within ${HEAT_SOURCE_BAND} px of one of its pixel centres — nothing is borrowed from the nearest island`;
154
+ }
155
+
123
156
  /**
124
157
  * Solve bone heat over `sil` (part-image pixels) for `segs` (rig px; the image is the rig less `(ox, oy)`).
125
- * `object` names the mesh in every refusal. Every problem found is returned at once.
158
+ * `object` names the mesh in every refusal. Every problem found is returned at once. `dust`, when the mesh's mode has
159
+ * a `stray` field, is read only to word an island refusal ({@link HeatDust}).
126
160
  */
127
- export function boneHeat(object: string, sil: Mask, segs: readonly Segment[], ox: number, oy: number, limits: HeatLimits = HEAT_LIMITS): HeatField | Problem[] {
161
+ export function boneHeat(object: string, sil: Mask, segs: readonly Segment[], ox: number, oy: number, limits: HeatLimits = HEAT_LIMITS, dust: (() => HeatDust) | null = null): HeatField | Problem[] {
128
162
  const { width: w, height: h, data } = sil;
129
163
  const n = w * h;
130
164
  const bones: string[] = [];
@@ -166,19 +200,21 @@ export function boneHeat(object: string, sil: Mask, segs: readonly Segment[], ox
166
200
  problems.push({
167
201
  code: 'RIG_HEAT_BONE_SOURCE',
168
202
  object,
169
- detail: `bone "${bone}" — its segment ${where} (rig px) ${why}, so it holds no source pixel; under rule "heat" every bound bone needs at least one: a silhouette pixel (${silPixels} px) whose centre lies within ${HEAT_SOURCE_BAND} px of the segment and nearer it than any other bound bone's. A bone with no source is not weighted 0 — move its segment onto the art, or take the bone out of segments`,
203
+ detail: `bone "${bone}" — its segment ${where} (rig px) ${why}, so it holds no source pixel; under rule "heat" every bound bone needs at least one: a silhouette pixel (${silPixels} px) whose centre lies within ${HEAT_SOURCE_BAND} px of the segment and nearer it than any other bound bone's. A bone with no source is not weighted 0 — move its segment onto the art, take the bone out of segments, or keep this part on the distance rule (rule "distance", or no rule, on this mesh: the rule is chosen per mesh)`,
170
204
  });
171
205
  });
172
206
  const cc = connectedComponents(sil, 4);
173
207
  const sourced = new Uint8Array(cc.count);
174
208
  for (let i = 0; i < n; i++) if (owner[i] >= 0) sourced[cc.labels[i]] = 1;
209
+ let dustRead: HeatDust | null = null;
175
210
  for (let c = 1; c < cc.count; c++) {
176
211
  if (sourced[c]) continue;
177
212
  const st = cc.stats[c];
213
+ if (dust !== null && dustRead === null) dustRead = dust();
178
214
  problems.push({
179
215
  code: 'RIG_HEAT_ISLAND_SOURCE',
180
216
  object,
181
- detail: `the silhouette's 4-connected island of ${st.area} px at rig (${st.left + ox}, ${st.top + oy}) holds no bound bone's source pixel, so the heat equation has no fixed temperature there and no answer; under rule "heat" every island needs a segment within ${HEAT_SOURCE_BAND} px of one of its pixel centres — nothing is borrowed from the nearest island`,
217
+ detail: `the silhouette's 4-connected island of ${st.area} px at rig (${st.left + ox}, ${st.top + oy}) holds no bound bone's source pixel, so the heat equation has no fixed temperature there and no answer; ${islandRemedy(st.area, dustRead)}`,
182
218
  });
183
219
  }
184
220
  if (problems.length > 0) return problems;
package/src/mesh.ts CHANGED
@@ -360,3 +360,64 @@ export function latticeMesh(part: string, art: Mask, grid: number, maxPasses: nu
360
360
  }
361
361
  return mesh;
362
362
  }
363
+
364
+ /**
365
+ * Dust (issue #265, Stage B's rule; moved here from `src/meshdraft.ts` by issue #303, so the rig stage's heat refusal
366
+ * and `mesh-draft` read one line): an extra 4-connected island of the art of at most this many art px …
367
+ */
368
+ export const DUST_MAX_PX = 4;
369
+
370
+ /** … and at most this share of the part's art px. */
371
+ export const DUST_MAX_SHARE = 0.001;
372
+
373
+ /** Whether an island of `area` art px is dust in a part of `artPixels` art px ({@link DUST_MAX_PX}, {@link DUST_MAX_SHARE}). */
374
+ export function isDust(area: number, artPixels: number): boolean {
375
+ return area <= DUST_MAX_PX && area <= DUST_MAX_SHARE * artPixels;
376
+ }
377
+
378
+ /** What a declared `meshes.<part>.stray` left out of a lattice's art (issue #303). */
379
+ export interface LatticeStray {
380
+ /** The art the lattice is built over: `art` itself when nothing was left out. */
381
+ art: Mask;
382
+ /** Islands left out, and their art px. */
383
+ islands: number;
384
+ pixels: number;
385
+ /** The art px of the part before anything was left out. */
386
+ artPixels: number;
387
+ /** The area of the largest island; an island this large is never left out. */
388
+ largest: number;
389
+ }
390
+
391
+ /**
392
+ * The lattice's `stray` (issue #303), the contour mode's field with the same meaning — the largest island, in art
393
+ * pixels, that may be left out of the mesh — read over the same islands: the 4-connected islands of the art (alpha
394
+ * above {@link ART_ALPHA}). Every island of `stray` art px or fewer is cleared from the art the lattice and its heat
395
+ * silhouette are built over, except an island as large as the largest, which is never left out (two equal largest
396
+ * pieces both stay: leaving one would be a choice the declaration does not make). Unlike the contour mode, an island
397
+ * above the figure is not refused: the lattice bridges every island it keeps (step 2 above), so it is meshed as it
398
+ * always was. The part's image is not touched; `artCoverage` is read over all the art, the islands left out included.
399
+ */
400
+ export function latticeStray(art: Mask, stray: number): LatticeStray {
401
+ const comp = connectedComponents(art, 4);
402
+ let largest = 0;
403
+ let artPixels = 0;
404
+ for (let l = 1; l < comp.count; l++) {
405
+ largest = Math.max(largest, comp.stats[l].area);
406
+ artPixels += comp.stats[l].area;
407
+ }
408
+ const out = new Uint8Array(comp.count);
409
+ let islands = 0;
410
+ let pixels = 0;
411
+ for (let l = 1; l < comp.count; l++) {
412
+ const a = comp.stats[l].area;
413
+ if (a <= stray && a < largest) {
414
+ out[l] = 1;
415
+ islands++;
416
+ pixels += a;
417
+ }
418
+ }
419
+ if (islands === 0) return { art, islands, pixels, artPixels, largest };
420
+ const data = new Uint8Array(art.data);
421
+ for (let i = 0; i < data.length; i++) if (out[comp.labels[i]] === 1) data[i] = 0;
422
+ return { art: { width: art.width, height: art.height, data }, islands, pixels, artPixels, largest };
423
+ }
package/src/meshdraft.ts CHANGED
@@ -68,7 +68,7 @@ import { BARS, type CheckReport, type PackShape, type PageEdges, type RigcRunner
68
68
  import { type CharacterConfig, type ContourMeshSpec, type LatticeMeshSpec, loadConfigAndAnimations, type Segment } from './config.ts';
69
69
  import { PartsError, type Problem, problemLine } from './errors.ts';
70
70
  import { IDLE_FPS } from './motion.ts';
71
- import { ART_ALPHA } from './mesh.ts';
71
+ import { ART_ALPHA, DUST_MAX_PX, DUST_MAX_SHARE, isDust } from './mesh.ts';
72
72
  import { type PartsFile, readParts } from './parts.ts';
73
73
  import { alphaAbove, connectedComponents } from './raster/index.ts';
74
74
  import { readPng } from './raster/png.ts';
@@ -90,11 +90,11 @@ export const TOLERANCE_FIGURE_DIVISOR = 554;
90
90
  /** The heights of the three figures the two divisors were measured on, rig px (demo, the trailer, scarf). */
91
91
  export const CALIBRATION_HEIGHTS: readonly number[] = [1106, 1108, 1185];
92
92
 
93
- /** Dust: an extra island of at most this many art px (Stage B: 1–2 px dots were the measured case) … */
94
- export const DUST_MAX_PX = 4;
95
-
96
- /** … and at most this share of the part's art px. */
97
- export const DUST_MAX_SHARE = 0.001;
93
+ /**
94
+ * Dust (Stage B: 1–2 px dots were the measured case): `src/mesh.ts` holds the line since issue #303, which the rig
95
+ * stage's heat refusal reads too; re-exported here, where it was first written.
96
+ */
97
+ export { DUST_MAX_PX, DUST_MAX_SHARE } from './mesh.ts';
98
98
 
99
99
  /** The bend set's turn per link, degrees: the fallback Stage C stated, a physics constraint's reach not being derivable from it. */
100
100
  export const BEND_DEGREES = 15;
@@ -118,6 +118,33 @@ export const MORE_TRIANGLES_FLAG = '--more-triangles';
118
118
  */
119
119
  export const RELATIVE_SENTENCE = 'the gate is a judgment relative to this config\'s lattice, not an absolute quality: when the lattice coarsens, the reference moves with it';
120
120
 
121
+ /**
122
+ * What `mesh-draft` says of a part whose config entry is not a lattice (`contour` or `auto`, issue #307): it drafts
123
+ * lattice entries only, so such a part was neither drafted nor compared. Said because the answer to "which mode comes
124
+ * out smaller" is not the one the names suggest: on one figure (rig-c/trailer 45becfb) an `auto` reduction held to a
125
+ * large declared bend kept two to three times the triangles of the outline this command drafts (AUTHORING §3, *Which
126
+ * mode comes out smaller*).
127
+ */
128
+ export const NOT_COMPARED_SENTENCE = 'mesh-draft drafts lattice entries only, so it drafted nothing for this part and compared it with nothing; whether a lattice, or the outline mesh-draft drafts over one, comes out smaller is not known from this run — build the part once on a lattice, run mesh-draft, and compare the rig stage\'s mesh lines before keeping this entry';
129
+
130
+ /** A config entry `mesh-draft` did not draft because it is not a lattice (issue #307), in config order. */
131
+ export interface NotCompared {
132
+ part: string;
133
+ mode: 'contour' | 'auto';
134
+ note: string;
135
+ }
136
+
137
+ /** The entries of `meshes` that are not lattices, in config order, each with its note ({@link NOT_COMPARED_SENTENCE}). */
138
+ export function notCompared(meshes: CharacterConfig['meshes']): NotCompared[] {
139
+ const out: NotCompared[] = [];
140
+ for (const [part, spec] of Object.entries(meshes)) {
141
+ if ('grid' in spec) continue;
142
+ const mode = 'auto' in spec ? 'auto' : 'contour';
143
+ out.push({ part, mode, note: `its entry is "${mode}", not a lattice: ${NOT_COMPARED_SENTENCE}` });
144
+ }
145
+ return out;
146
+ }
147
+
121
148
  /** The five readings, in the order the gate reads them. */
122
149
  export const READINGS = ['bars', 'idle', 'bend inverted', 'bend worst', 'triangles'] as const;
123
150
  export type Reading = (typeof READINGS)[number];
@@ -182,7 +209,7 @@ export function dustOf(image: Raster): DustReading {
182
209
  if (islands.length === 0) return { keep: `its image has no pixel with alpha above ${ART_ALPHA}`, islands };
183
210
  if (islands.length > 1 && islands[0] === islands[1]) return { keep: `its art is ${islands.length} islands (${islands.join(', ')} px) of which two share the largest count, so no one island is the largest`, islands };
184
211
  const extra = islands.slice(1);
185
- const pieces = extra.filter((n) => !(n <= DUST_MAX_PX && n <= DUST_MAX_SHARE * art));
212
+ const pieces = extra.filter((n) => !isDust(n, art));
186
213
  if (pieces.length > 0) {
187
214
  return {
188
215
  keep: `its art is ${islands.length} islands (${islands.join(', ')} px); ${pieces.length} beside the largest ${pieces.length === 1 ? 'is a real piece' : 'are real pieces'}, not dust (at most ${DUST_MAX_PX} px and at most ${pyRound(DUST_MAX_SHARE * 100, 6)} % of the part's ${art} art px), and the contour mode meshes one island`,
@@ -534,6 +561,8 @@ export interface PartRow {
534
561
  export interface MeshDraftResult {
535
562
  rules: FigureRules;
536
563
  rows: PartRow[];
564
+ /** The entries not drafted because they are not lattices (issue #307), in config order. */
565
+ notCompared: NotCompared[];
537
566
  written: string[];
538
567
  }
539
568
 
@@ -810,6 +839,7 @@ export function meshDraftStage(input: MeshDraftInput, rigc: RigcRunner, checkBin
810
839
  }
811
840
  const configText = splicedConfig(text, drafted);
812
841
  const rowsOut = [...rows.values()];
842
+ const others = notCompared(cfg.meshes);
813
843
  const report = {
814
844
  spec: MESH_DRAFT_SPEC,
815
845
  rules: {
@@ -834,6 +864,9 @@ export function meshDraftStage(input: MeshDraftInput, rigc: RigcRunner, checkBin
834
864
  gate: r.gate,
835
865
  note: r.note,
836
866
  })),
867
+ // Issue #307: present only when the config holds an entry that is not a lattice, so a config of lattices alone
868
+ // writes the bytes it wrote before the key existed.
869
+ ...(others.length === 0 ? {} : { not_compared: others }),
837
870
  };
838
871
  mkdirSync(input.out, { recursive: true });
839
872
  const written = [join(input.out, 'config.json'), join(input.out, MESH_DRAFT_REPORT)];
@@ -845,14 +878,15 @@ export function meshDraftStage(input: MeshDraftInput, rigc: RigcRunner, checkBin
845
878
  for (const g of r.gate) log(` ${g.pass ? 'pass' : 'FAIL'} ${g.reading}: ${g.text}`);
846
879
  if (r.mode === 'grid' && r.gate.length === 0) log(` ${r.note}`);
847
880
  }
881
+ for (const o of others) log(` NOT COMPARED ${o.part}: ${o.note}`);
848
882
  const nDrafted = rowsOut.filter((r) => r.mode === 'contour').length;
849
883
  const tri = (pick: (r: PartRow) => number): number => rowsOut.reduce((a, r) => a + pick(r), 0);
850
- log(`rig-parts mesh-draft: ${nDrafted} of ${rowsOut.length} lattice part(s) drafted; their triangles ${tri((r) => r.lattice.triangles)} -> ${tri((r) => (r.mode === 'contour' && r.candidate !== null ? r.candidate.triangles : r.lattice.triangles))}, the figure's overdraw over them ${report.figure_overdraw.lattice} -> ${report.figure_overdraw.written}${input.moreTriangles === true ? ` (${MORE_TRIANGLES_FLAG}: triangles reported, not a line)` : ''}; the drafted config's check: ${final.report.figures.PASS ? 'PASS' : 'FAIL'}`);
884
+ log(`rig-parts mesh-draft: ${nDrafted} of ${rowsOut.length} lattice part(s) drafted; their triangles ${tri((r) => r.lattice.triangles)} -> ${tri((r) => (r.mode === 'contour' && r.candidate !== null ? r.candidate.triangles : r.lattice.triangles))}, the figure's overdraw over them ${report.figure_overdraw.lattice} -> ${report.figure_overdraw.written}${input.moreTriangles === true ? ` (${MORE_TRIANGLES_FLAG}: triangles reported, not a line)` : ''}${others.length === 0 ? '' : `; ${others.length} part(s) not a lattice, not drafted and not compared: ${others.map((o) => `${o.part} (${o.mode})`).join(', ')}`}; the drafted config's check: ${final.report.figures.PASS ? 'PASS' : 'FAIL'}`);
851
885
  if (resolve(configDir) !== resolve(input.out)) {
852
886
  for (const r of relativePaths(raw)) log(` note: ${r.at} is "${r.value}", relative to the config's directory; the written config keeps the text, so it resolves against ${input.out} — copy the file beside the written config, or the written config beside it`);
853
887
  }
854
888
  log(`rig-parts mesh-draft: wrote ${written.join(' and ')}`);
855
- return { rules, rows: rowsOut, written };
889
+ return { rules, rows: rowsOut, notCompared: others, written };
856
890
  }
857
891
 
858
892
  /**