@rig-c/parts 1.25.0 → 1.26.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/assemble.ts CHANGED
@@ -1561,6 +1561,61 @@ export function holeLines(f: RecompositeFigures, scene: string | null = null): s
1561
1561
  ];
1562
1562
  }
1563
1563
 
1564
+ /** One kind of uncovered error pixel (issue #351): total pixel count, 8-connected component count, and largest component box. */
1565
+ export interface HoleKindSummary {
1566
+ px: number;
1567
+ count: number;
1568
+ largest: { px: number; x: number; y: number; w: number; h: number } | null;
1569
+ }
1570
+
1571
+ /**
1572
+ * The partition of uncovered error pixels into two disjoint causes (issue #351):
1573
+ * - `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).
1574
+ * - `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`.
1575
+ */
1576
+ export interface HoleKinds {
1577
+ noLayer: HoleKindSummary;
1578
+ betweenLayers: HoleKindSummary;
1579
+ }
1580
+
1581
+ function summarizeKindMask(mask: Mask): HoleKindSummary {
1582
+ let px = 0;
1583
+ for (let p = 0; p < mask.data.length; p++) px += mask.data[p];
1584
+ if (px === 0) return { px: 0, count: 0, largest: null };
1585
+ const cc = connectedComponents(mask, 8);
1586
+ const all = cc.stats.slice(1).filter((s) => s.area > 0);
1587
+ all.sort((a, b) => b.area - a.area || a.top - b.top || a.left - b.left);
1588
+ const s = all[0];
1589
+ return { px, count: all.length, largest: s === undefined ? null : { px: s.area, x: s.left, y: s.top, w: s.width, h: s.height } };
1590
+ }
1591
+
1592
+ export function holeKinds(err: Uint8Array, covered: Uint8Array, anyLayer: Uint8Array, W: number, H: number): HoleKinds {
1593
+ const noLayerMask = newMask(W, H);
1594
+ const betweenMask = newMask(W, H);
1595
+ for (let p = 0; p < W * H; p++) {
1596
+ if (err[p] !== 1 || covered[p] !== 0) continue;
1597
+ if (anyLayer[p] === 0) noLayerMask.data[p] = 1;
1598
+ else betweenMask.data[p] = 1;
1599
+ }
1600
+ return { noLayer: summarizeKindMask(noLayerMask), betweenLayers: summarizeKindMask(betweenMask) };
1601
+ }
1602
+
1603
+ /**
1604
+ * The summary line under `holeLines` (issue #351): partitions `uncoveredErrorPx`
1605
+ * into pixels where no See-through layer has `alpha > 8` (`noLayer`, repaired
1606
+ * only by `assemble.patches`) and pixels where a layer has `alpha > 8` (`betweenLayers`).
1607
+ */
1608
+ export function holeKindsLine(k: HoleKinds, scene: string | null = null): string {
1609
+ const fmt = (s: HoleKindSummary): string =>
1610
+ s.px === 0 || s.largest === null
1611
+ ? '0 px'
1612
+ : `${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})`;
1613
+ return (
1614
+ `${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)}; ` +
1615
+ `on a layer (alpha > ${OPAQUE_ALPHA_ABOVE} on a layer, below covered alpha ${COVERED_ALPHA} in the parts): ${fmt(k.betweenLayers)}`
1616
+ );
1617
+ }
1618
+
1564
1619
  /** The `parts.json` `recomposite` block: the four figures, the limits they were measured at, and the listed holes. */
1565
1620
  export function recompositeRecord(f: RecompositeFigures): RecompositeRecord {
1566
1621
  return {
@@ -2278,6 +2333,8 @@ export interface AssembleResult {
2278
2333
  limbs: LimbCount[];
2279
2334
  /** The full-run layers the plan does not take from the full run whose uncovered error holds a body (`uncoveredErrorLayers`); empty otherwise. */
2280
2335
  uncovered: UncoveredErrorLayer[];
2336
+ /** The partition of uncovered error pixels into no-layer and on-a-layer components (`holeKinds`, issue #351). */
2337
+ holeKinds: HoleKinds;
2281
2338
  /** The parts whose `DRIFT_LIMIT` refusals hold a body another part holds in the draw order (`driftBodies`), in draw order; empty otherwise. */
2282
2339
  drift: DriftBodies[];
2283
2340
  /** Every hidden-art layer of the runs read (issue #359) and what read it (`hiddenLines`); empty when the runs hold none. */
@@ -2792,13 +2849,26 @@ export function assemble(input: AssembleInput): AssembleResult {
2792
2849
  });
2793
2850
  const stackNames = stack.map((q) => q.record.name);
2794
2851
  const drift: DriftBodies[] = [];
2795
- order.forEach((d) => {
2852
+ order.forEach((d, orderIdx) => {
2796
2853
  if ('patch' in d) return;
2797
2854
  const { name, b } = pieceAt(d);
2798
2855
  if (b.st.refusedMask === null) return;
2799
2856
  const refused = newMask(W, H);
2800
2857
  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);
2858
+ const behindAt = new Int32Array(W * H).fill(-1);
2859
+ for (let j = 0; j < orderIdx; j++) {
2860
+ const under = stack[j].image.data;
2861
+ for (let p = 0; p < W * H; p++) {
2862
+ if (under[p * 4 + 3] < CORE_ALPHA) continue;
2863
+ const dMax = Math.max(
2864
+ Math.abs(under[p * 4] - srcr.data[p * 4]),
2865
+ Math.abs(under[p * 4 + 1] - srcr.data[p * 4 + 1]),
2866
+ Math.abs(under[p * 4 + 2] - srcr.data[p * 4 + 2]),
2867
+ );
2868
+ if (dMax <= DRIFT_LIMIT) behindAt[p] = j;
2869
+ }
2870
+ }
2871
+ const found = driftBodies(name, b.key, refused, bodyMinPx(frame), holderAt, stackNames, behindAt);
2802
2872
  if (found !== null) drift.push(found);
2803
2873
  });
2804
2874
  const pushedOf: FringePushed[][] = plan.map(() => []);
@@ -2880,11 +2950,16 @@ export function assemble(input: AssembleInput): AssembleResult {
2880
2950
  const inside = input.scene === true ? sceneFigure([...rig.entries()].filter(([k]) => k.startsWith('full:')).map(([, r]) => r), W, H) : null;
2881
2951
  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
2952
  const figures = measureRecomposite(can, srcr, placed, inside);
2953
+ const covered = coveredMask(placed, W, H);
2954
+ const err = errorMask(can, srcr, inside);
2955
+ const anyLayer = new Uint8Array(W * H);
2956
+ 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;
2957
+ const kinds = holeKinds(err, covered, anyLayer, W, H);
2883
2958
  const fullRig = fullTags.map((tag) => ({ tag, rig: rig.get(`full:${tag}`) as Raster }));
2884
2959
  const uncovered = uncoveredErrorLayers(
2885
2960
  fullRig,
2886
- coveredMask(placed, W, H),
2887
- errorMask(can, srcr, inside),
2961
+ covered,
2962
+ err,
2888
2963
  figures.uncoveredErrorPx,
2889
2964
  plan,
2890
2965
  extend,
@@ -2911,6 +2986,7 @@ export function assemble(input: AssembleInput): AssembleResult {
2911
2986
  cuts: cutCounts,
2912
2987
  limbs: lim.counts,
2913
2988
  uncovered,
2989
+ holeKinds: kinds,
2914
2990
  drift,
2915
2991
  hidden: [...hiddenUses].map(([layer, readBy]) => ({ layer, readBy })),
2916
2992
  seamRule,
@@ -2939,11 +3015,18 @@ export interface DriftBodies {
2939
3015
  * (`seamCandidates`) — with its pixel count, most first, then by name.
2940
3016
  */
2941
3017
  holders: Array<{ part: string; px: number }>;
3018
+ /**
3019
+ * Over every pixel of the bodies, the top-most part drawn behind `part` at
3020
+ * alpha >= `CORE_ALPHA` whose colour is within `DRIFT_LIMIT` of the painting
3021
+ * (issue #351), with its pixel count, most first, then by name.
3022
+ */
3023
+ behind?: Array<{ part: string; px: number }>;
2942
3024
  }
2943
3025
 
2944
3026
  /**
2945
3027
  * A part's refused bodies that another part holds in the draw order (issue
2946
- * #255).
3028
+ * #255) or that a part drawn behind it matches within `DRIFT_LIMIT` of the
3029
+ * painting (issue #351).
2947
3030
  *
2948
3031
  * ⚖️ Invariant: `refused` is the part's projection core pixels whose layer
2949
3032
  * colour is more than `DRIFT_LIMIT` off the painting (`projectSource`'s
@@ -2953,32 +3036,36 @@ export interface DriftBodies {
2953
3036
  * uncovered layer's, and the stage passes the same bar (`bodyMinPx`).
2954
3037
  * `holderAt` is, per rig pixel, the index into `names` of the top-most part
2955
3038
  * 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 {
3039
+ * holder. `behindAt` (optional) is, per rig pixel, the index into `names` of
3040
+ * the top-most part drawn behind `part` at alpha >= `CORE_ALPHA` whose colour
3041
+ * is within `DRIFT_LIMIT` of the painting (-1: none). The result is non-null
3042
+ * exactly when the bodies hold a pixel whose holder is another part or whose
3043
+ * `behindAt` names a part drawn behind `part` that matches the painting.
3044
+ */
3045
+ export function driftBodies(part: string, from: string, refused: Mask, minPx: number, holderAt: Int32Array, names: readonly string[], behindAt?: Int32Array): DriftBodies | null {
2968
3046
  let refusedPx = 0;
2969
3047
  for (let p = 0; p < refused.data.length; p++) refusedPx += refused.data[p];
2970
3048
  if (refusedPx === 0) return null;
2971
3049
  const { bodies, pixels } = bodiesWithPixels(refused, minPx);
2972
3050
  if (bodies.length === 0) return null;
2973
3051
  const count = new Map<string, number>();
3052
+ const behindCount = new Map<string, number>();
2974
3053
  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);
3054
+ if (pixels.data[p] === 0) continue;
3055
+ if (holderAt[p] >= 0) {
3056
+ const n = names[holderAt[p]];
3057
+ count.set(n, (count.get(n) ?? 0) + 1);
3058
+ }
3059
+ if (behindAt !== undefined && behindAt[p] >= 0) {
3060
+ const b = names[behindAt[p]];
3061
+ if (b !== part) behindCount.set(b, (behindCount.get(b) ?? 0) + 1);
3062
+ }
2978
3063
  }
2979
- if (![...count.keys()].some((n) => n !== part)) return null;
3064
+ const hasFront = [...count.keys()].some((n) => n !== part);
3065
+ if (!hasFront && behindCount.size === 0) return null;
2980
3066
  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 };
3067
+ 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));
3068
+ return { part, from, refusedPx, minPx, bodies, holders, ...(behind.length > 0 ? { behind } : {}) };
2982
3069
  }
2983
3070
 
2984
3071
  /**
@@ -2988,15 +3075,22 @@ export function driftBodies(part: string, from: string, refused: Mask, minPx: nu
2988
3075
  * off the painting, max channel), in a body of 196 px at 16,12 14x14 (at
2989
3076
  * least 150 px past a 5x5 opening); by the plan's draw order "face" holds 196
2990
3077
  * px of it (the top-most part at alpha >= 250), drawn in front of "topwear"`.
3078
+ * When `behind` is non-empty (issue #351), names the part(s) drawn behind
3079
+ * `part` whose colour is within `DRIFT_LIMIT` of the painting.
2991
3080
  */
2992
3081
  export function driftLine(d: DriftBodies): string {
2993
3082
  const total = d.bodies.reduce((a, b) => a + b.px, 0);
2994
3083
  const big = d.bodies[0];
2995
3084
  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
3085
  const others = d.holders.filter((h) => h.part !== d.part).map((h) => `"${h.part}"`);
3086
+ const frontClause = others.length > 0 ? `, ${others.join(', ')} drawn in front of "${d.part}"` : '';
3087
+ const behindClause =
3088
+ d.behind !== undefined && d.behind.length > 0
3089
+ ? `; 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(', ')}`
3090
+ : '';
2997
3091
  return (
2998
3092
  `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}"`
3093
+ `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
3094
  );
3001
3095
  }
3002
3096
 
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
  /**