@rig-c/parts 1.25.0 → 1.27.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.25.0",
3
+ "version": "1.27.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": {
@@ -85,7 +85,7 @@ under that.
85
85
  gap See-through left (a skirt split into two legs), fixed in the plan or the layers,
86
86
  not in the rig. Then `rig-parts propose --parts work/rig
87
87
  --source <painting> --out work` → copy `bones`, `meshes`, `regions`, `limbs`,
88
- `motion`, `constraints`, `turn` from `proposal.json` into the config, each it
88
+ `motion`, `constraints`, `turn`, `proposed_rig_scale` from `proposal.json` into the config, each it
89
89
  wrote (`limbs` only with `--keypoints`, `constraints` only with `--cloth`, `turn`
90
90
  only with `--full`/`--head`; a `limbs` block left out is refused at the next step). `assemble` needs the plan and none of
91
91
  those sections; do not stub them to get past it (AUTHORING §4 says what each step
package/src/assemble.ts CHANGED
@@ -1208,7 +1208,7 @@ function keepArtWhole(part: PlacedPart, cleared: Cleared[], holderAt: (q: number
1208
1208
  * hold the pixel after it); and every pixel at alpha >= `CORE_ALPHA`, which
1209
1209
  * is the seam override's.
1210
1210
  */
1211
- export function pushBackFringe(parts: PlacedPart[], srcr: Raster, rule: SeamRule, silhouette: Mask | null): FringePushed[][] {
1211
+ export function pushBackFringe(parts: PlacedPart[], srcr: Raster, rule: SeamRule, silhouette: Mask | null, feathered: ReadonlyArray<Mask | null> | null = null): FringePushed[][] {
1212
1212
  const { width: W } = srcr;
1213
1213
  const { holder, cand } = seamCandidates(parts, srcr, rule, silhouette);
1214
1214
  const at = (i: number, d: number): number => {
@@ -1226,6 +1226,7 @@ export function pushBackFringe(parts: PlacedPart[], srcr: Raster, rule: SeamRule
1226
1226
  // cover itself the loop below has nothing to clear, and the seam override acts on the pixel as before.
1227
1227
  if (cand[d] === 0 || !shows(d)) continue;
1228
1228
  for (let i = holder[d] + 1; i < parts.length; i++) {
1229
+ if (feathered?.[i]?.data[d] === 1) continue;
1229
1230
  const s = at(i, d);
1230
1231
  if (s < 0) continue;
1231
1232
  const img = parts[i].image.data;
@@ -1561,6 +1562,61 @@ export function holeLines(f: RecompositeFigures, scene: string | null = null): s
1561
1562
  ];
1562
1563
  }
1563
1564
 
1565
+ /** One kind of uncovered error pixel (issue #351): total pixel count, 8-connected component count, and largest component box. */
1566
+ export interface HoleKindSummary {
1567
+ px: number;
1568
+ count: number;
1569
+ largest: { px: number; x: number; y: number; w: number; h: number } | null;
1570
+ }
1571
+
1572
+ /**
1573
+ * The partition of uncovered error pixels into two disjoint causes (issue #351):
1574
+ * - `noLayer`: pixels where no See-through layer in either run has `alpha > OPAQUE_ALPHA_ABOVE` (an `assemble.patches` box from the painting is the only repair).
1575
+ * - `betweenLayers`: pixels where at least one See-through layer has `alpha > OPAQUE_ALPHA_ABOVE`, but no placed part covers the pixel at `alpha > COVERED_ALPHA`.
1576
+ */
1577
+ export interface HoleKinds {
1578
+ noLayer: HoleKindSummary;
1579
+ betweenLayers: HoleKindSummary;
1580
+ }
1581
+
1582
+ function summarizeKindMask(mask: Mask): HoleKindSummary {
1583
+ let px = 0;
1584
+ for (let p = 0; p < mask.data.length; p++) px += mask.data[p];
1585
+ if (px === 0) return { px: 0, count: 0, largest: null };
1586
+ const cc = connectedComponents(mask, 8);
1587
+ const all = cc.stats.slice(1).filter((s) => s.area > 0);
1588
+ all.sort((a, b) => b.area - a.area || a.top - b.top || a.left - b.left);
1589
+ const s = all[0];
1590
+ return { px, count: all.length, largest: s === undefined ? null : { px: s.area, x: s.left, y: s.top, w: s.width, h: s.height } };
1591
+ }
1592
+
1593
+ export function holeKinds(err: Uint8Array, covered: Uint8Array, anyLayer: Uint8Array, W: number, H: number): HoleKinds {
1594
+ const noLayerMask = newMask(W, H);
1595
+ const betweenMask = newMask(W, H);
1596
+ for (let p = 0; p < W * H; p++) {
1597
+ if (err[p] !== 1 || covered[p] !== 0) continue;
1598
+ if (anyLayer[p] === 0) noLayerMask.data[p] = 1;
1599
+ else betweenMask.data[p] = 1;
1600
+ }
1601
+ return { noLayer: summarizeKindMask(noLayerMask), betweenLayers: summarizeKindMask(betweenMask) };
1602
+ }
1603
+
1604
+ /**
1605
+ * The summary line under `holeLines` (issue #351): partitions `uncoveredErrorPx`
1606
+ * into pixels where no See-through layer has `alpha > 8` (`noLayer`, repaired
1607
+ * only by `assemble.patches`) and pixels where a layer has `alpha > 8` (`betweenLayers`).
1608
+ */
1609
+ export function holeKindsLine(k: HoleKinds, scene: string | null = null): string {
1610
+ const fmt = (s: HoleKindSummary): string =>
1611
+ s.px === 0 || s.largest === null
1612
+ ? '0 px'
1613
+ : `${s.px} px in ${s.count} component(s) (largest ${s.largest.px} px at ${s.largest.x},${s.largest.y} ${s.largest.w}x${s.largest.h})`;
1614
+ return (
1615
+ `${scene === null ? '' : `${scene}: `}uncovered error by cause: no layer (alpha <= ${OPAQUE_ALPHA_ABOVE} on every layer; only assemble.patches from the painting can cover): ${fmt(k.noLayer)}; ` +
1616
+ `on a layer (alpha > ${OPAQUE_ALPHA_ABOVE} on a layer, below covered alpha ${COVERED_ALPHA} in the parts): ${fmt(k.betweenLayers)}`
1617
+ );
1618
+ }
1619
+
1564
1620
  /** The `parts.json` `recomposite` block: the four figures, the limits they were measured at, and the listed holes. */
1565
1621
  export function recompositeRecord(f: RecompositeFigures): RecompositeRecord {
1566
1622
  return {
@@ -1834,6 +1890,8 @@ interface Built {
1834
1890
  merged: number;
1835
1891
  /** The rig pixels this piece of the layer owns when the layer is cut, null when it is not: the counts are split by it. */
1836
1892
  region: Mask | null;
1893
+ /** The rig pixels on an upper limb segment whose alpha `edge: "feather"` scaled down (issue #444). */
1894
+ feathered?: Mask;
1837
1895
  }
1838
1896
 
1839
1897
  // ---------------------------------------------------------------------------
@@ -2208,6 +2266,9 @@ function cutLimbs(
2208
2266
  let featheredPx = 0;
2209
2267
  let band = 0;
2210
2268
  if (sg.edge === 'feather') {
2269
+ const upperRegion = regions[upAt];
2270
+ const upPiece = pieces[upAt] as Built;
2271
+ const featheredMask = (upPiece.feathered ??= newMask(W, H));
2211
2272
  band = pyRound(Math.max(0, coveredRadius(line.at, radius, (x, y) => x >= 0 && y >= 0 && x < W && y < H && lower.data[(y * W + x) * 4 + 3] === 255)), 3);
2212
2273
  const fb = capBox(line.at, band);
2213
2274
  for (let y = Math.max(0, fb[1]); y < Math.min(H, fb[1] + fb[3]); y++) {
@@ -2221,6 +2282,11 @@ function cutLimbs(
2221
2282
  if (na === ua) continue;
2222
2283
  if (na === 0) upper.data.fill(0, p * 4, p * 4 + 4);
2223
2284
  else upper.data[p * 4 + 3] = na;
2285
+ // Issue #444: a pixel the feather ramp fades to alpha <= OPAQUE_ALPHA_ABOVE is no longer opaque on the
2286
+ // upper piece (its opacity is carried by the lower segment's opaque cap beneath), so it leaves the upper
2287
+ // piece's region and its remaining ramp is marked feathered so pushBackFringe does not clear the fade.
2288
+ if (na <= OPAQUE_ALPHA_ABOVE) upperRegion.data[p] = 0;
2289
+ else featheredMask.data[p] = 1;
2224
2290
  featheredPx++;
2225
2291
  }
2226
2292
  }
@@ -2278,6 +2344,8 @@ export interface AssembleResult {
2278
2344
  limbs: LimbCount[];
2279
2345
  /** The full-run layers the plan does not take from the full run whose uncovered error holds a body (`uncoveredErrorLayers`); empty otherwise. */
2280
2346
  uncovered: UncoveredErrorLayer[];
2347
+ /** The partition of uncovered error pixels into no-layer and on-a-layer components (`holeKinds`, issue #351). */
2348
+ holeKinds: HoleKinds;
2281
2349
  /** The parts whose `DRIFT_LIMIT` refusals hold a body another part holds in the draw order (`driftBodies`), in draw order; empty otherwise. */
2282
2350
  drift: DriftBodies[];
2283
2351
  /** Every hidden-art layer of the runs read (issue #359) and what read it (`hiddenLines`); empty when the runs hold none. */
@@ -2783,7 +2851,8 @@ export function assemble(input: AssembleInput): AssembleResult {
2783
2851
  const whole: PartRecord = { name, from: b.key, x: 0, y: 0, w: W, h: H, opaque_px: 0, projected_core_px: 0, source_px_taken: 0, refused_drift_px: 0, merged_px: 0, seam_override_px: 0 };
2784
2852
  return { record: whole, image: b.r };
2785
2853
  });
2786
- const pushedBy = pushBackFringe(stack, srcr, seamRule, seamRule === 'silhouette' ? silhouette : null);
2854
+ const featheredInOrder: Array<Mask | null> = order.map((d) => ('patch' in d ? null : (pieceAt(d).b.feathered ?? null)));
2855
+ const pushedBy = pushBackFringe(stack, srcr, seamRule, seamRule === 'silhouette' ? silhouette : null, featheredInOrder);
2787
2856
  // Issue #255: a part's drift refusals in a body, and who holds the body in the draw order (the push-back clears
2788
2857
  // only fringe below CORE_ALPHA, and the seam override only recolours, so the holders read here are final).
2789
2858
  const holderAt = new Int32Array(W * H).fill(-1);
@@ -2792,13 +2861,26 @@ export function assemble(input: AssembleInput): AssembleResult {
2792
2861
  });
2793
2862
  const stackNames = stack.map((q) => q.record.name);
2794
2863
  const drift: DriftBodies[] = [];
2795
- order.forEach((d) => {
2864
+ order.forEach((d, orderIdx) => {
2796
2865
  if ('patch' in d) return;
2797
2866
  const { name, b } = pieceAt(d);
2798
2867
  if (b.st.refusedMask === null) return;
2799
2868
  const refused = newMask(W, H);
2800
2869
  for (let p = 0; p < W * H; p++) if (b.st.refusedMask.data[p] === 1 && (b.region === null || b.region.data[p] === 1)) refused.data[p] = 1;
2801
- const found = driftBodies(name, b.key, refused, bodyMinPx(frame), holderAt, stackNames);
2870
+ const behindAt = new Int32Array(W * H).fill(-1);
2871
+ for (let j = 0; j < orderIdx; j++) {
2872
+ const under = stack[j].image.data;
2873
+ for (let p = 0; p < W * H; p++) {
2874
+ if (under[p * 4 + 3] < CORE_ALPHA) continue;
2875
+ const dMax = Math.max(
2876
+ Math.abs(under[p * 4] - srcr.data[p * 4]),
2877
+ Math.abs(under[p * 4 + 1] - srcr.data[p * 4 + 1]),
2878
+ Math.abs(under[p * 4 + 2] - srcr.data[p * 4 + 2]),
2879
+ );
2880
+ if (dMax <= DRIFT_LIMIT) behindAt[p] = j;
2881
+ }
2882
+ }
2883
+ const found = driftBodies(name, b.key, refused, bodyMinPx(frame), holderAt, stackNames, behindAt);
2802
2884
  if (found !== null) drift.push(found);
2803
2885
  });
2804
2886
  const pushedOf: FringePushed[][] = plan.map(() => []);
@@ -2880,11 +2962,16 @@ export function assemble(input: AssembleInput): AssembleResult {
2880
2962
  const inside = input.scene === true ? sceneFigure([...rig.entries()].filter(([k]) => k.startsWith('full:')).map(([, r]) => r), W, H) : null;
2881
2963
  const sceneRecord: SceneRecord | null = inside === null ? null : { figure_alpha_above: OPAQUE_ALPHA_ABOVE, figure_px: inside.reduce((a, b) => a + b, 0), figure_mask: FIGURE_MASK_FILE };
2882
2964
  const figures = measureRecomposite(can, srcr, placed, inside);
2965
+ const covered = coveredMask(placed, W, H);
2966
+ const err = errorMask(can, srcr, inside);
2967
+ const anyLayer = new Uint8Array(W * H);
2968
+ for (const r of rig.values()) for (let p = 0; p < W * H; p++) if (r.data[p * 4 + 3] > OPAQUE_ALPHA_ABOVE) anyLayer[p] = 1;
2969
+ const kinds = holeKinds(err, covered, anyLayer, W, H);
2883
2970
  const fullRig = fullTags.map((tag) => ({ tag, rig: rig.get(`full:${tag}`) as Raster }));
2884
2971
  const uncovered = uncoveredErrorLayers(
2885
2972
  fullRig,
2886
- coveredMask(placed, W, H),
2887
- errorMask(can, srcr, inside),
2973
+ covered,
2974
+ err,
2888
2975
  figures.uncoveredErrorPx,
2889
2976
  plan,
2890
2977
  extend,
@@ -2911,6 +2998,7 @@ export function assemble(input: AssembleInput): AssembleResult {
2911
2998
  cuts: cutCounts,
2912
2999
  limbs: lim.counts,
2913
3000
  uncovered,
3001
+ holeKinds: kinds,
2914
3002
  drift,
2915
3003
  hidden: [...hiddenUses].map(([layer, readBy]) => ({ layer, readBy })),
2916
3004
  seamRule,
@@ -2939,11 +3027,18 @@ export interface DriftBodies {
2939
3027
  * (`seamCandidates`) — with its pixel count, most first, then by name.
2940
3028
  */
2941
3029
  holders: Array<{ part: string; px: number }>;
3030
+ /**
3031
+ * Over every pixel of the bodies, the top-most part drawn behind `part` at
3032
+ * alpha >= `CORE_ALPHA` whose colour is within `DRIFT_LIMIT` of the painting
3033
+ * (issue #351), with its pixel count, most first, then by name.
3034
+ */
3035
+ behind?: Array<{ part: string; px: number }>;
2942
3036
  }
2943
3037
 
2944
3038
  /**
2945
3039
  * A part's refused bodies that another part holds in the draw order (issue
2946
- * #255).
3040
+ * #255) or that a part drawn behind it matches within `DRIFT_LIMIT` of the
3041
+ * painting (issue #351).
2947
3042
  *
2948
3043
  * ⚖️ Invariant: `refused` is the part's projection core pixels whose layer
2949
3044
  * colour is more than `DRIFT_LIMIT` off the painting (`projectSource`'s
@@ -2953,32 +3048,36 @@ export interface DriftBodies {
2953
3048
  * uncovered layer's, and the stage passes the same bar (`bodyMinPx`).
2954
3049
  * `holderAt` is, per rig pixel, the index into `names` of the top-most part
2955
3050
  * at alpha >= `CORE_ALPHA` in draw order (-1: none), as the seam pass reads a
2956
- * holder. The result is non-null exactly when the bodies hold a pixel whose
2957
- * holder is another part: the plan draws that part over the body, which is
2958
- * the signature of "this layer is not the owner here".
2959
- *
2960
- * Not every body: a body the part holds itself, with nothing drawn over it,
2961
- * is See-through's colour for the part's own pixels disagreeing with the
2962
- * painting, and on four of the five public paintings it holds bodies of
2963
- * hundreds to tens of thousands of pixels that no other part touches (the
2964
- * pull request that added this has the figures); a line there would name no
2965
- * owner, which is what the line exists to name.
2966
- */
2967
- export function driftBodies(part: string, from: string, refused: Mask, minPx: number, holderAt: Int32Array, names: readonly string[]): DriftBodies | null {
3051
+ * holder. `behindAt` (optional) is, per rig pixel, the index into `names` of
3052
+ * the top-most part drawn behind `part` at alpha >= `CORE_ALPHA` whose colour
3053
+ * is within `DRIFT_LIMIT` of the painting (-1: none). The result is non-null
3054
+ * exactly when the bodies hold a pixel whose holder is another part or whose
3055
+ * `behindAt` names a part drawn behind `part` that matches the painting.
3056
+ */
3057
+ export function driftBodies(part: string, from: string, refused: Mask, minPx: number, holderAt: Int32Array, names: readonly string[], behindAt?: Int32Array): DriftBodies | null {
2968
3058
  let refusedPx = 0;
2969
3059
  for (let p = 0; p < refused.data.length; p++) refusedPx += refused.data[p];
2970
3060
  if (refusedPx === 0) return null;
2971
3061
  const { bodies, pixels } = bodiesWithPixels(refused, minPx);
2972
3062
  if (bodies.length === 0) return null;
2973
3063
  const count = new Map<string, number>();
3064
+ const behindCount = new Map<string, number>();
2974
3065
  for (let p = 0; p < pixels.data.length; p++) {
2975
- if (pixels.data[p] === 0 || holderAt[p] < 0) continue;
2976
- const n = names[holderAt[p]];
2977
- count.set(n, (count.get(n) ?? 0) + 1);
3066
+ if (pixels.data[p] === 0) continue;
3067
+ if (holderAt[p] >= 0) {
3068
+ const n = names[holderAt[p]];
3069
+ count.set(n, (count.get(n) ?? 0) + 1);
3070
+ }
3071
+ if (behindAt !== undefined && behindAt[p] >= 0) {
3072
+ const b = names[behindAt[p]];
3073
+ if (b !== part) behindCount.set(b, (behindCount.get(b) ?? 0) + 1);
3074
+ }
2978
3075
  }
2979
- if (![...count.keys()].some((n) => n !== part)) return null;
3076
+ const hasFront = [...count.keys()].some((n) => n !== part);
3077
+ if (!hasFront && behindCount.size === 0) return null;
2980
3078
  const holders = [...count.entries()].map(([q, px]) => ({ part: q, px })).sort((a, b) => b.px - a.px || (a.part < b.part ? -1 : a.part > b.part ? 1 : 0));
2981
- return { part, from, refusedPx, minPx, bodies, holders };
3079
+ const behind = [...behindCount.entries()].map(([q, px]) => ({ part: q, px })).sort((a, b) => b.px - a.px || (a.part < b.part ? -1 : a.part > b.part ? 1 : 0));
3080
+ return { part, from, refusedPx, minPx, bodies, holders, ...(behind.length > 0 ? { behind } : {}) };
2982
3081
  }
2983
3082
 
2984
3083
  /**
@@ -2988,15 +3087,22 @@ export function driftBodies(part: string, from: string, refused: Mask, minPx: nu
2988
3087
  * off the painting, max channel), in a body of 196 px at 16,12 14x14 (at
2989
3088
  * least 150 px past a 5x5 opening); by the plan's draw order "face" holds 196
2990
3089
  * px of it (the top-most part at alpha >= 250), drawn in front of "topwear"`.
3090
+ * When `behind` is non-empty (issue #351), names the part(s) drawn behind
3091
+ * `part` whose colour is within `DRIFT_LIMIT` of the painting.
2991
3092
  */
2992
3093
  export function driftLine(d: DriftBodies): string {
2993
3094
  const total = d.bodies.reduce((a, b) => a + b.px, 0);
2994
3095
  const big = d.bodies[0];
2995
3096
  const held = d.bodies.length > 1 ? `${d.bodies.length} bodies of ${total} px, the largest ${big.px} px at ${bodyBox(big)}` : `a body of ${big.px} px at ${bodyBox(big)}`;
2996
3097
  const others = d.holders.filter((h) => h.part !== d.part).map((h) => `"${h.part}"`);
3098
+ const frontClause = others.length > 0 ? `, ${others.join(', ')} drawn in front of "${d.part}"` : '';
3099
+ const behindClause =
3100
+ d.behind !== undefined && d.behind.length > 0
3101
+ ? `; the painting is within ${DRIFT_LIMIT} of ${d.behind.map((h) => `"${h.part}" (${h.px} px at alpha >= ${CORE_ALPHA}, drawn behind "${d.part}")`).join(', ')}`
3102
+ : '';
2997
3103
  return (
2998
3104
  `drift: "${d.part}" refuses ${d.refusedPx} px of ${d.from} (its colour more than ${DRIFT_LIMIT} off the painting, max channel), in ${held} (at least ${fmtPx(d.minPx)} px past a ${BODY_KERNEL}x${BODY_KERNEL} opening); ` +
2999
- `by the plan's draw order ${d.holders.map((h) => `"${h.part}" holds ${h.px} px`).join(', ')} of it (the top-most part at alpha >= ${CORE_ALPHA}), ${others.join(', ')} drawn in front of "${d.part}"`
3105
+ `by the plan's draw order ${d.holders.map((h) => `"${h.part}" holds ${h.px} px`).join(', ')} of it (the top-most part at alpha >= ${CORE_ALPHA})${frontClause}${behindClause}`
3000
3106
  );
3001
3107
  }
3002
3108
 
package/src/build.ts CHANGED
@@ -43,7 +43,7 @@
43
43
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
44
44
  import { dirname, join, resolve } from 'node:path';
45
45
  import { type AnimFrame, EncodeError, encodeApng, encodeIndexedApng, INDEXED_DEFAULTS } from './apng.ts';
46
- import { assemble, type AssembleResult, cutLines, hiddenLines, SINGLE_RUN_FACE_TAGS, driftLine, limbLines, FIGURE_MASK_FILE, figuresLine, holeLines, type ProjectRule, scenePhrase, type SeamRule, stageFields, uncoveredLine } from './assemble.ts';
46
+ import { assemble, type AssembleResult, cutLines, hiddenLines, SINGLE_RUN_FACE_TAGS, driftLine, limbLines, FIGURE_MASK_FILE, figuresLine, holeKindsLine, holeLines, type ProjectRule, scenePhrase, type SeamRule, stageFields, uncoveredLine } from './assemble.ts';
47
47
  import { allocationClause, type Reducer, stageBClause, unboundedClause } from './automesh.ts';
48
48
  import { residualClause } from './autoenvelope.ts';
49
49
  import { type AutoMotionCase, failingRows, idleSchedule, localRow, motionClause, motionDeformation, motionDocument, motionInput, motionRowText, motionStimulus, motionVerdict, noStimulusProblem, type ReplayCandidate, runComparison, sourceSelfFailure, stimulusMotion, withStimulus } from './automotion.ts';
@@ -237,11 +237,12 @@ export function readCaps(configPath: string, limbs: Readonly<Record<string, Limb
237
237
 
238
238
  /** Read, assemble, and write only after every refusal has had its chance. Throws a PartsError on a refusal, having written nothing. */
239
239
  export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs, log: Log): AssembleResult {
240
- const src = readSource(input.source);
241
- const runs = readRuns(input.full, input.head);
242
240
  // The early door: assemble runs before propose has drafted bones, meshes,
243
241
  // regions and motion, so it requires only what it reads (issue #20).
244
- const fields = stageFields(loadEarlyConfig(input.config, 'assemble'), runs.single ? { w: src.width, h: src.height } : null);
242
+ const cfg = loadEarlyConfig(input.config, 'assemble');
243
+ const src = readSource(input.source);
244
+ const runs = readRuns(input.full, input.head);
245
+ const fields = stageFields(cfg, runs.single ? { w: src.width, h: src.height } : null);
245
246
  const under = readUnders(input.config, fields.cuts);
246
247
  const caps = fields.limbs === undefined ? undefined : readCaps(input.config, fields.limbs);
247
248
  const result = assemble({ source: src, full: runs.full, head: runs.head, ...fields, under, ...(caps === undefined ? {} : { caps }), seamRule: input.seam, projectRule: input.project });
@@ -292,6 +293,7 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
292
293
  const scene = result.parts.scene === undefined ? null : scenePhrase(result.parts.scene, W * H);
293
294
  log(figuresLine(result.figures, scene));
294
295
  for (const l of holeLines(result.figures, scene)) log(l);
296
+ if (result.figures.uncoveredErrorPx > 0) log(holeKindsLine(result.holeKinds, scene));
295
297
  for (const u of result.uncovered) log(uncoveredLine(u, scene));
296
298
  for (const d of result.drift) log(driftLine(d));
297
299
  log(`wrote ${outs.partsJson}, ${result.images.length} PNG(s) in ${outs.partsDir}, ${outs.recomposite}, ${outs.errorMap} (uncovered error px red, covered error px blue, the painting grey)${figurePath === null ? '' : `, ${figurePath} (the figure, white, the recomposite was read inside)`}`);
@@ -1088,17 +1090,29 @@ export function judgementLine(name: string, line: JudgementLine | ReportedLine):
1088
1090
  }
1089
1091
 
1090
1092
  /**
1091
- * The fold line (issues #320, #333) as the console prints it: the status, what was
1092
- * read and at what rate, and then each folding mesh on a line of its own,
1093
- * rigc's own words, with the declaration's why where `may_fold` takes it.
1093
+ * The fold line (issues #320, #333, #363) as the console prints it: the status,
1094
+ * what was read and at what rate, and then each folding mesh on a line of its
1095
+ * own, with physics on and off side by side (and its physics constraints named
1096
+ * when only physics folds it), rigc's own words, and the declaration's why
1097
+ * where `may_fold` takes it.
1094
1098
  */
1095
1099
  export function foldsLines(line: FoldsLine): string[] {
1096
- const physics = line.physics_left_out === 0 ? '' : `, its ${line.physics_left_out} physics constraint(s) left out`;
1100
+ const physics = line.physics_left_out === 0 ? '' : `, with its ${line.physics_left_out} physics constraint(s) on and off`;
1097
1101
  const head =
1098
1102
  `${FOLD_LINE}: ${line.status} — ${line.animations.length} animation(s) read by rigc render --folds at ${line.fps} fps${physics} (${line.animations.join(', ')}): ` +
1099
1103
  `${line.folding.length} folding mesh(es), ${line.folding_undeclared} not declared in may_fold (<= ${line.bar})` +
1100
1104
  (line.declared_not_folding.length === 0 ? '' : `; may_fold declares ${line.declared_not_folding.map((d) => `slot "${d.slot}" in "${d.animation}"`).join(', ')}, which do(es) not fold`);
1101
- return [head, ...line.folding.map((r) => ` folds ${r.line}${r.declared === null ? '' : ` — declared in may_fold: ${r.declared}`}`)];
1105
+ return [
1106
+ head,
1107
+ ...line.folding.map((r) => {
1108
+ const side =
1109
+ line.physics_left_out === 0
1110
+ ? ''
1111
+ : ` — physics on: ${r.physics_on === null ? '0 frame(s)' : `${r.physics_on.frames} frame(s), worst frame ${r.physics_on.worst_frame} (${r.physics_on.worst_triangles}/${r.triangles} triangle(s))`}; physics off: ${r.physics_off === null ? '0 frame(s)' : `${r.physics_off.frames} frame(s), worst frame ${r.physics_off.worst_frame} (${r.physics_off.worst_triangles}/${r.triangles} triangle(s))`}` +
1112
+ (r.physics_constraints.length === 0 ? '' : `; physics constraint(s): ${r.physics_constraints.join(', ')}`);
1113
+ return ` folds ${r.line}${side}${r.declared === null ? '' : ` — declared in may_fold: ${r.declared}`}`;
1114
+ }),
1115
+ ];
1102
1116
  }
1103
1117
 
1104
1118
  /**