rig-c 0.0.0-stage → 2.21.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.
Files changed (213) hide show
  1. package/.claude-plugin/marketplace.json +19 -0
  2. package/.claude-plugin/plugin.json +13 -0
  3. package/LICENSE +30 -0
  4. package/NOTICE.md +145 -0
  5. package/README.md +817 -3
  6. package/bin/rigc.cjs +83 -0
  7. package/cli.ts +61 -0
  8. package/cli_core.ts +46 -0
  9. package/docs/AUTHORING.md +9923 -0
  10. package/docs/FACE.md +1948 -0
  11. package/docs/INGEST.md +1488 -0
  12. package/docs/MOTION.md +1241 -0
  13. package/docs/PROMPTING.md +109 -0
  14. package/docs/RIGGING.md +1441 -0
  15. package/docs/SPEC_COVERAGE.md +357 -0
  16. package/package.json +108 -4
  17. package/skills/rigc/SKILL.md +133 -0
  18. package/skills/rigc-face/SKILL.md +60 -0
  19. package/skills/rigc-ingest/SKILL.md +78 -0
  20. package/skills/rigc-motion/SKILL.md +51 -0
  21. package/skills/rigc-rigging/SKILL.md +49 -0
  22. package/src/areaband.ts +159 -0
  23. package/src/assertions/bodies/a01.ts +23 -0
  24. package/src/assertions/bodies/a02.ts +21 -0
  25. package/src/assertions/bodies/a03.ts +27 -0
  26. package/src/assertions/bodies/a04.ts +40 -0
  27. package/src/assertions/bodies/a05.ts +56 -0
  28. package/src/assertions/bodies/a06.ts +245 -0
  29. package/src/assertions/bodies/a07.ts +68 -0
  30. package/src/assertions/bodies/a08.ts +76 -0
  31. package/src/assertions/bodies/a09.ts +82 -0
  32. package/src/assertions/bodies/a10.ts +116 -0
  33. package/src/assertions/bodies/a11.ts +15 -0
  34. package/src/assertions/bodies/a12.ts +30 -0
  35. package/src/assertions/bodies/a13.ts +51 -0
  36. package/src/assertions/bodies/a14.ts +35 -0
  37. package/src/assertions/bodies/a15.ts +97 -0
  38. package/src/assertions/bodies/a16.ts +24 -0
  39. package/src/assertions/bodies/a17.ts +26 -0
  40. package/src/assertions/bodies/a18.ts +62 -0
  41. package/src/assertions/bodies/a19.ts +404 -0
  42. package/src/assertions/bodies/a20.ts +122 -0
  43. package/src/assertions/bodies/a21.ts +190 -0
  44. package/src/assertions/bodies/a22.ts +39 -0
  45. package/src/assertions/bodies/a23.ts +305 -0
  46. package/src/assertions/bodies/a24.ts +68 -0
  47. package/src/assertions/bodies/a25.ts +39 -0
  48. package/src/assertions/bodies/a26.ts +61 -0
  49. package/src/assertions/bodies/a27.ts +33 -0
  50. package/src/assertions/bodies/a28.ts +70 -0
  51. package/src/assertions/bodies/a29.ts +34 -0
  52. package/src/assertions/bodies/a30.ts +50 -0
  53. package/src/assertions/bodies/a31.ts +61 -0
  54. package/src/assertions/bodies/a32.ts +44 -0
  55. package/src/assertions/bodies/a33.ts +110 -0
  56. package/src/assertions/bodies/a34.ts +133 -0
  57. package/src/assertions/bodies/a35.ts +160 -0
  58. package/src/assertions/bodies/a36.ts +81 -0
  59. package/src/assertions/bodies/a37.ts +77 -0
  60. package/src/assertions/bodies/a38.ts +73 -0
  61. package/src/assertions/bodies/a39.ts +303 -0
  62. package/src/assertions/bodies/a40.ts +128 -0
  63. package/src/assertions/bodies/a42.ts +97 -0
  64. package/src/assertions/bodies/a43.ts +181 -0
  65. package/src/assertions/bodies/a44.ts +23 -0
  66. package/src/assertions/bodies/a45.ts +172 -0
  67. package/src/assertions/bodies/a46.ts +224 -0
  68. package/src/assertions/bodies/a47.ts +126 -0
  69. package/src/assertions/bodies/a48.ts +83 -0
  70. package/src/assertions/bodies/a49.ts +81 -0
  71. package/src/assertions/bodies/a50.ts +97 -0
  72. package/src/assertions/constraint_words.ts +169 -0
  73. package/src/assertions/emitted/index.ts +148 -0
  74. package/src/assertions/facts/animated_bones.ts +30 -0
  75. package/src/assertions/facts/animation_durations.ts +37 -0
  76. package/src/assertions/facts/atlas_pages.ts +19 -0
  77. package/src/assertions/facts/atlas_regions.ts +52 -0
  78. package/src/assertions/facts/bone_timelines.ts +37 -0
  79. package/src/assertions/facts/constraint_targets.ts +56 -0
  80. package/src/assertions/facts/constraints.ts +155 -0
  81. package/src/assertions/facts/deform_survey.ts +27 -0
  82. package/src/assertions/facts/event_keys.ts +55 -0
  83. package/src/assertions/facts/linked_meshes.ts +38 -0
  84. package/src/assertions/facts/mesh_attachments.ts +100 -0
  85. package/src/assertions/facts/region_joins.ts +34 -0
  86. package/src/assertions/facts/sequences.ts +85 -0
  87. package/src/assertions/facts/skeleton_roster.ts +45 -0
  88. package/src/assertions/facts/skin_entries.ts +37 -0
  89. package/src/assertions/facts/skin_members.ts +53 -0
  90. package/src/assertions/facts/slider_composition.ts +78 -0
  91. package/src/assertions/facts/slot_colour.ts +43 -0
  92. package/src/assertions/facts/stage.ts +27 -0
  93. package/src/assertions/facts/stage_box.ts +65 -0
  94. package/src/assertions/facts/stepped_poses.ts +74 -0
  95. package/src/assertions/facts/two_colour.ts +52 -0
  96. package/src/assertions/facts/vertex_polygons.ts +53 -0
  97. package/src/assertions/footprints.ts +367 -0
  98. package/src/assertions/harness.ts +109 -0
  99. package/src/assertions/inward_advance.ts +58 -0
  100. package/src/assertions/kinds.ts +105 -0
  101. package/src/assertions/mesh_kinds.ts +56 -0
  102. package/src/assertions/model/animated_bones.ts +38 -0
  103. package/src/assertions/model/animation_durations.ts +57 -0
  104. package/src/assertions/model/atlas_pages.ts +15 -0
  105. package/src/assertions/model/atlas_regions.ts +76 -0
  106. package/src/assertions/model/bone_timelines.ts +58 -0
  107. package/src/assertions/model/constraint_targets.ts +82 -0
  108. package/src/assertions/model/constraints.ts +233 -0
  109. package/src/assertions/model/declared.ts +125 -0
  110. package/src/assertions/model/deform_survey.ts +24 -0
  111. package/src/assertions/model/event_keys.ts +45 -0
  112. package/src/assertions/model/given.ts +45 -0
  113. package/src/assertions/model/index.ts +398 -0
  114. package/src/assertions/model/linked_meshes.ts +24 -0
  115. package/src/assertions/model/mesh_attachments.ts +119 -0
  116. package/src/assertions/model/parse.ts +146 -0
  117. package/src/assertions/model/region_joins.ts +67 -0
  118. package/src/assertions/model/runtime_timelines.ts +78 -0
  119. package/src/assertions/model/sequences.ts +157 -0
  120. package/src/assertions/model/skeleton_roster.ts +23 -0
  121. package/src/assertions/model/skin_entries.ts +69 -0
  122. package/src/assertions/model/skin_members.ts +64 -0
  123. package/src/assertions/model/slider_composition.ts +193 -0
  124. package/src/assertions/model/slot_colour.ts +81 -0
  125. package/src/assertions/model/stage.ts +28 -0
  126. package/src/assertions/model/stage_box.ts +51 -0
  127. package/src/assertions/model/stepped_poses.ts +105 -0
  128. package/src/assertions/model/two_colour.ts +61 -0
  129. package/src/assertions/model/vertex_polygons.ts +72 -0
  130. package/src/assertions/reasons.ts +129 -0
  131. package/src/assertions/region_lookups.ts +61 -0
  132. package/src/assertions/report.ts +189 -0
  133. package/src/assertions/values.ts +39 -0
  134. package/src/atlas.ts +2870 -0
  135. package/src/ballot.ts +866 -0
  136. package/src/bonedist.ts +643 -0
  137. package/src/chainfit.ts +2752 -0
  138. package/src/chains.ts +170 -0
  139. package/src/check.ts +4303 -0
  140. package/src/checkpics.ts +295 -0
  141. package/src/cli/core_commands.ts +1627 -0
  142. package/src/cli/repack.ts +414 -0
  143. package/src/cli/shared.ts +2776 -0
  144. package/src/cli/spine_commands.ts +820 -0
  145. package/src/compile.ts +9414 -0
  146. package/src/core/additive.ts +458 -0
  147. package/src/core/animation.ts +1050 -0
  148. package/src/core/clipping.ts +696 -0
  149. package/src/core/constraints.ts +1876 -0
  150. package/src/core/constraints_path.ts +964 -0
  151. package/src/core/constraints_physics.ts +881 -0
  152. package/src/core/constraints_slider.ts +635 -0
  153. package/src/core/deform.ts +613 -0
  154. package/src/core/draw_order.ts +125 -0
  155. package/src/core/events.ts +135 -0
  156. package/src/core/hooks.ts +249 -0
  157. package/src/core/index.ts +1400 -0
  158. package/src/core/raw.ts +739 -0
  159. package/src/core/skins.ts +129 -0
  160. package/src/core/uvs.ts +469 -0
  161. package/src/core/vertices.ts +490 -0
  162. package/src/core/walk.ts +197 -0
  163. package/src/core/world.ts +289 -0
  164. package/src/correspondence.ts +15 -0
  165. package/src/deformbuild.ts +60 -0
  166. package/src/deformgen.ts +630 -0
  167. package/src/deformmeasure.ts +732 -0
  168. package/src/deformreport.ts +373 -0
  169. package/src/deformstructure.ts +386 -0
  170. package/src/deformsurvey.ts +2162 -0
  171. package/src/depth.ts +784 -0
  172. package/src/diff.ts +2252 -0
  173. package/src/emit.ts +134 -0
  174. package/src/emit_spine.ts +854 -0
  175. package/src/errors.ts +53 -0
  176. package/src/framing.ts +819 -0
  177. package/src/generation.ts +139 -0
  178. package/src/ingest.ts +2293 -0
  179. package/src/json-position.ts +253 -0
  180. package/src/keyorder.ts +587 -0
  181. package/src/keys.ts +486 -0
  182. package/src/ladder.ts +121 -0
  183. package/src/mesh.ts +2382 -0
  184. package/src/meshcompare.ts +1191 -0
  185. package/src/meshquality.ts +2051 -0
  186. package/src/meshrasters.ts +944 -0
  187. package/src/meshreduce.ts +1444 -0
  188. package/src/model.ts +1245 -0
  189. package/src/motion.ts +809 -0
  190. package/src/nonfinite.ts +54 -0
  191. package/src/package_meta.ts +48 -0
  192. package/src/png.ts +297 -0
  193. package/src/pose.ts +2324 -0
  194. package/src/preview.ts +434 -0
  195. package/src/region_joins.ts +54 -0
  196. package/src/render.ts +1013 -0
  197. package/src/render_core.ts +871 -0
  198. package/src/render_shared.ts +2958 -0
  199. package/src/repack.ts +495 -0
  200. package/src/rig.ts +2941 -0
  201. package/src/slots.ts +892 -0
  202. package/src/spine_side.ts +138 -0
  203. package/src/timelines.ts +837 -0
  204. package/src/trackgen.ts +364 -0
  205. package/src/transform.ts +310 -0
  206. package/src/types.ts +1797 -0
  207. package/src/validate.ts +3875 -0
  208. package/tools/contact.ts +126 -0
  209. package/tools/editor_roundtrip.ts +1641 -0
  210. package/tools/font5x7.ts +101 -0
  211. package/tools/measure_contact_depth.ts +105 -0
  212. package/tools/plate.ts +508 -0
  213. package/tools/png_probe.mjs +72 -0
package/src/slots.ts ADDED
@@ -0,0 +1,892 @@
1
+ /**
2
+ * Slot tracking — which part landed where, and when that cannot be said.
3
+ *
4
+ * ## Two matchers, in order, and a cap on both
5
+ *
6
+ * The cheap matcher labels the reference frame's connected components and asks
7
+ * which one each of the candidate's slots landed on. It is right whenever the
8
+ * parts of a shot are separate blobs, and it has three failure modes that honest
9
+ * ladder runs hit head-on (issues #34 and #37) — all three the same mistake, which
10
+ * is treating a blob as a part:
11
+ *
12
+ * - **Parts that touch label as one component.** Rung 4 is a disc with five chain
13
+ * links hanging off it; they touch in every frame of every animation, so every
14
+ * slot came back "ambiguous" and the run produced **no drift table at all**.
15
+ * - **Nearest-centroid has no notion of "too far to be the same thing".** Rung 5's
16
+ * 4 px ball, on the frames where it rests against the course and has no component
17
+ * of its own, matched the floating girder 47 px away — and the summary line
18
+ * reported **48.3 px of drift for a 4 px ball**, unflagged.
19
+ * - **A blob one part dominates passes for that part.** Rung 2's reference merges
20
+ * the course, the water, the panel and both rings into one component in which the
21
+ * course is 81 % of the ink, so it is 1.24x the course's own and no wider than its
22
+ * box — both merge tests see nothing, and the run reported *"course drift
23
+ * 11.2 px"*, the distance to a five-part centroid (issue #37). `occupantsOf`
24
+ * answers that one with the label map: anything else the candidate drew inside the
25
+ * blob makes the blob's centroid nobody's position.
26
+ *
27
+ * So: components first, and when a component cannot be attributed to one slot, the
28
+ * slot's own rendered quad is **template-matched** against the reference in a
29
+ * window around where the candidate drew it. Touching parts stop being fatal —
30
+ * a link that overlaps its neighbour still correlates against its own pixels.
31
+ *
32
+ * ⛔ **Its own pixels means the ones you can see.** The template is the slot drawn
33
+ * alone and the reference is a composite, so a template pixel the candidate itself
34
+ * draws something over matches nothing at the true offset — and a residual that is
35
+ * there at every offset is not evidence about position. Left in, it *moves the
36
+ * answer*: issue #698 measured four of the seven shipped examples reporting
37
+ * 0.8–2.2 px against frames rendered from themselves, and on every one the score
38
+ * field's own whole-pixel minimum sat off the identity offset because the covered
39
+ * samples' gradient outvoted a shallow basin. `visibleField` drops them, and the
40
+ * minimum comes back to the origin on all seven.
41
+ *
42
+ * ⛔ And both matchers are capped by `searchRadius`: a part may be displaced by
43
+ * about its own size and still be the same part in the picture, and past that the
44
+ * honest report is **no match**. A number that is not a measurement of the slot it
45
+ * is printed beside is worse than a blank, because it is actionable and wrong.
46
+ */
47
+ import { Plate, type RGBA } from '../tools/plate.ts';
48
+ import { backgroundDistance, isContent } from './framing.ts';
49
+ import {
50
+ frameGeometry,
51
+ pageFor,
52
+ projector,
53
+ rasterisePiece,
54
+ type Frame,
55
+ type Footprint,
56
+ type Viewport,
57
+ } from './render_shared.ts';
58
+
59
+ /** Components smaller than this are antialiasing crumbs, not parts. */
60
+ const MIN_COMPONENT_PIXELS = 4;
61
+ /** A second component this close to the nearest makes a component match a guess. */
62
+ const AMBIGUITY_RATIO = 1.25;
63
+ /**
64
+ * How much bigger than the slot a component may be and still be *that slot's*.
65
+ *
66
+ * Above it, the slot is inside something larger — the reference merged it with a
67
+ * neighbour, or drew it behind one — and the component's centroid is the merged
68
+ * blob's, not the part's.
69
+ *
70
+ * ⚠️ Pixel count alone is not enough, and rung 3's transcription is the proof: at
71
+ * `heavy/f0009` the pendulum touches the block, the two label as one 1227 px blob,
72
+ * and the pendulum's own 836 px makes that only 1.47x — under any ratio loose
73
+ * enough to tolerate antialiasing. The blob's **bounding box** gives it away (60 px
74
+ * wide against the slot's 39), so both tests have to pass. Erring towards "merged"
75
+ * is the safe direction: a slot wrongly called merged still gets a drift from the
76
+ * template matcher, where a merged blob wrongly called the slot's own reports the
77
+ * blob's centroid as the part's position.
78
+ */
79
+ const MERGE_RATIO = 1.6;
80
+ /** How much wider or taller than the slot a component may be, as a fraction. */
81
+ const MERGE_MARGIN = 0.1;
82
+ /** ...and never less than this, so antialiasing alone cannot trip it. */
83
+ const MERGE_MARGIN_PIXELS = 2;
84
+ /** Displacement bounds, in frame pixels: never search less, never search more. */
85
+ const MIN_SEARCH_RADIUS = 4;
86
+ const MAX_SEARCH_RADIUS = 32;
87
+ /** Search radius as a fraction of the slot's own long side. */
88
+ const SEARCH_SPAN = 0.75;
89
+ /** How many template pixels a correlation samples, at most. */
90
+ const MAX_SAMPLES = 256;
91
+ /** A rival peak must be at least this far from the winner to count as a rival. */
92
+ const RIVAL_GAP = 3;
93
+ /**
94
+ * How distinctive a correlation peak must be before its offset is reported.
95
+ *
96
+ * ⭐ It rises with the displacement being claimed, and that is the whole point. A
97
+ * peak sitting where the candidate already drew the slot is only confirming a
98
+ * position, so a weak peak is enough; a peak claiming the part is most of a search
99
+ * radius away is claiming something big, and the bar for it is correspondingly
100
+ * high. Rung 4 is the case that fixed the constant: `chain1` — a 9x27 sliver in a
101
+ * chain of near-identical links — correlated 26 px away at confidence 0.16, and
102
+ * that number went straight into the summary line as the run's worst drift. Under
103
+ * this rule it needs 0.60 and comes back as **no match**, which is the honest
104
+ * answer for a repetitive structure.
105
+ */
106
+ const MIN_CONFIDENCE = 0.15;
107
+ /** How much more distinctive a peak must be at a full radius out than at zero. */
108
+ const CONFIDENCE_SLOPE = 0.45;
109
+ /** The residual must be under this fraction of the slot's own contrast. */
110
+ const MAX_RESIDUAL_FRACTION = 0.5;
111
+
112
+ export interface Component {
113
+ pixels: number;
114
+ cx: number;
115
+ cy: number;
116
+ minX: number;
117
+ minY: number;
118
+ maxX: number;
119
+ maxY: number;
120
+ }
121
+
122
+ /**
123
+ * The reference frame's components, and which one each pixel belongs to.
124
+ *
125
+ * The label map is what makes "is anything ELSE inside this blob?" a measurement
126
+ * rather than a guess from bounding boxes — see `occupantsOf`. It is indexed
127
+ * row-major on the frame's own grid, and `-1` is background or a component too
128
+ * small to be a part.
129
+ */
130
+ export interface ComponentField {
131
+ components: Component[];
132
+ labels: Int32Array;
133
+ width: number;
134
+ height: number;
135
+ }
136
+
137
+ /**
138
+ * Connected components of "not the background colour", 8-connected.
139
+ *
140
+ * 8-connected rather than 4: a thin diagonal — a bar, a stick, a shadow's edge —
141
+ * breaks into a dotted line under 4-connectivity, and then one part reads as
142
+ * twenty and every match is ambiguous for a reason that is about the labeller.
143
+ */
144
+ export function componentsOf(plate: Plate, background: RGBA): Component[] {
145
+ return componentField(plate, background).components;
146
+ }
147
+
148
+ /** The same labelling, with the map kept — see `ComponentField`. */
149
+ export function componentField(plate: Plate, background: RGBA): ComponentField {
150
+ const { width, height } = plate;
151
+ const label = new Int32Array(width * height).fill(-1);
152
+ const out: Component[] = [];
153
+ const stack: number[] = [];
154
+ for (let y0 = 0; y0 < height; y0++) {
155
+ for (let x0 = 0; x0 < width; x0++) {
156
+ const seed = y0 * width + x0;
157
+ if (label[seed] !== -1 || !isContent(plate, x0, y0, background)) continue;
158
+ const id = out.length;
159
+ label[seed] = id;
160
+ stack.push(seed);
161
+ let pixels = 0;
162
+ let sx = 0;
163
+ let sy = 0;
164
+ let minX = width;
165
+ let minY = height;
166
+ let maxX = -1;
167
+ let maxY = -1;
168
+ while (stack.length > 0) {
169
+ const at = stack.pop() as number;
170
+ const x = at % width;
171
+ const y = (at - x) / width;
172
+ pixels++;
173
+ sx += x + 0.5;
174
+ sy += y + 0.5;
175
+ if (x < minX) minX = x;
176
+ if (x > maxX) maxX = x;
177
+ if (y < minY) minY = y;
178
+ if (y > maxY) maxY = y;
179
+ for (let dy = -1; dy <= 1; dy++) {
180
+ for (let dx = -1; dx <= 1; dx++) {
181
+ const nx = x + dx;
182
+ const ny = y + dy;
183
+ if (nx < 0 || ny < 0 || nx >= width || ny >= height) continue;
184
+ const n = ny * width + nx;
185
+ if (label[n] !== -1 || !isContent(plate, nx, ny, background)) continue;
186
+ label[n] = id;
187
+ stack.push(n);
188
+ }
189
+ }
190
+ }
191
+ out.push({ pixels, cx: sx / pixels, cy: sy / pixels, minX, minY, maxX: maxX + 1, maxY: maxY + 1 });
192
+ }
193
+ }
194
+ // Crumbs out, biggest first — and the label map carried through the reorder, so
195
+ // a label is always an index into the array the caller is handed. Renumbering
196
+ // rather than sorting the map is what keeps the two from drifting apart.
197
+ const keep = out.map((c, id) => ({ c, id })).filter(({ c }) => c.pixels >= MIN_COMPONENT_PIXELS);
198
+ keep.sort((a, b) => b.c.pixels - a.c.pixels);
199
+ const renumbered = new Int32Array(out.length).fill(-1);
200
+ keep.forEach(({ id }, index) => {
201
+ renumbered[id] = index;
202
+ });
203
+ for (let at = 0; at < label.length; at++) label[at] = label[at] < 0 ? -1 : renumbered[label[at]];
204
+ return { components: keep.map(({ c }) => c), labels: label, width, height };
205
+ }
206
+
207
+ /**
208
+ * Which drawn slots' ink sits inside each component, by centroid.
209
+ *
210
+ * ## Why this exists: a blob one part dominates is still a blob
211
+ *
212
+ * The size and bounding-box tests below catch a merge when the merged neighbour is
213
+ * a material fraction of the blob. They cannot catch the case issue #37 filed:
214
+ * rung 2's reference merges the course, the water, the panel and both rings into
215
+ * one component, and the **course is 81 % of it**, so the blob is only 1.24x the
216
+ * course's own ink and barely wider than the course's own box. It passed every
217
+ * test, and the summary line reported *"course drift 11.2 px"* — the distance from
218
+ * the course's centroid to the centroid of a blob holding four other parts, which
219
+ * is not a measurement of the course at all.
220
+ *
221
+ * One label lookup per drawn slot answers it exactly: if anything else the
222
+ * candidate drew lands on this component's own pixels, the component is more than
223
+ * one part and its centroid is nobody's position. That is the same judgement the
224
+ * two-claimants rule below already makes; what was missing is that a slot which
225
+ * never got as far as *claiming* the blob — because it was refused for being 13x
226
+ * too small, or diverted to the template matcher — still proves the blob is shared.
227
+ */
228
+ function occupantsOf(field: ComponentField, footprints: Map<string, Footprint>): Map<number, string[]> {
229
+ const out = new Map<number, string[]>();
230
+ for (const [slot, foot] of footprints) {
231
+ if (foot.pixels === 0) continue;
232
+ const x = Math.floor(foot.cx);
233
+ const y = Math.floor(foot.cy);
234
+ if (x < 0 || y < 0 || x >= field.width || y >= field.height) continue;
235
+ const label = field.labels[y * field.width + x];
236
+ if (label < 0) continue;
237
+ const seen = out.get(label) ?? [];
238
+ seen.push(slot);
239
+ out.set(label, seen);
240
+ }
241
+ return out;
242
+ }
243
+
244
+ /** How the drift beside a slot was arrived at. `none` means it could not be. */
245
+ export type MatchMethod = 'component' | 'template' | 'none';
246
+
247
+ export interface SlotTrack {
248
+ slot: string;
249
+ candidate: { cx: number; cy: number; width: number; height: number; pixels: number } | null;
250
+ method: MatchMethod;
251
+ /** The reference component this slot was matched to — component matches only. */
252
+ reference: { cx: number; cy: number; width: number; height: number; pixels: number } | null;
253
+ /** Centroid distance in frame pixels, or the correlation offset's length. */
254
+ drift: number | null;
255
+ /** The same displacement with its direction, reference minus candidate. */
256
+ driftX: number | null;
257
+ driftY: number | null;
258
+ /** Bounding-box differences — component matches only, where a bbox is known. */
259
+ widthDrift: number | null;
260
+ heightDrift: number | null;
261
+ /** 0..1 for a template match: how much better the winner is than its best rival. */
262
+ confidence: number | null;
263
+ /** How far the match was allowed to look, in frame pixels. */
264
+ searchRadius: number | null;
265
+ /**
266
+ * The whole-pixel offset the sub-pixel step refined — template matches only.
267
+ *
268
+ * It is the half of the answer that is a *displacement*: `(0, 0)` says the
269
+ * correlation put the part where the candidate drew it and everything in
270
+ * `drift` is the parabola's own residue.
271
+ */
272
+ wholePixel: { dx: number; dy: number } | null;
273
+ /** Set when the drift is not a measurement of this slot, saying why. */
274
+ ambiguity: string | null;
275
+ }
276
+
277
+ /**
278
+ * The most this match could have read, given the winner it found.
279
+ *
280
+ * `hypot(|dx| + SUBPIXEL_CLAMP, |dy| + SUBPIXEL_CLAMP)`, because the sub-pixel
281
+ * step is clamped to half a pixel on each axis. A reader comparing a drift to a
282
+ * floor needs it beside the figure: 0.44 px under a bound of 0.71 px is the
283
+ * instrument at rest, and 0.44 px under a bound of 1.58 px is a part that moved a
284
+ * whole pixel and came most of the way back. `null` for a component match — a
285
+ * distance between two centroids is bounded by nothing but the search radius.
286
+ *
287
+ * 🔒 **A function and not a field**, and the red-first run is why. It was stored
288
+ * on the track first, and `C29`'s plant — one track's whole-pixel winner forced a
289
+ * pixel off the identity — printed *"bounded by 0.71 px — the correlation moved
290
+ * this slot by (1, 0) whole pixel(s)"*: the sentence and the figure beside it came
291
+ * from two fields that could disagree, which is this repository's own antipattern
292
+ * with a drift figure attached to it. One derivation, one place.
293
+ */
294
+ export function driftBound(track: SlotTrack): number | null {
295
+ if (track.method !== 'template' || track.wholePixel === null) return null;
296
+ return Math.hypot(Math.abs(track.wholePixel.dx) + SUBPIXEL_CLAMP, Math.abs(track.wholePixel.dy) + SUBPIXEL_CLAMP);
297
+ }
298
+
299
+ /**
300
+ * How far this slot may be displaced and still be the same thing in the picture.
301
+ *
302
+ * Tied to the slot's own size, because that is what makes the bound mean
303
+ * something: past about its own long side a part no longer overlaps where it was,
304
+ * and a correlation peak out there is another object, not this one moved.
305
+ */
306
+ export function searchRadius(width: number, height: number): number {
307
+ const span = Math.round(Math.max(width, height) * SEARCH_SPAN);
308
+ return Math.max(MIN_SEARCH_RADIUS, Math.min(MAX_SEARCH_RADIUS, span));
309
+ }
310
+
311
+ /** What the template matcher needs to draw one slot on its own. */
312
+ export interface SlotSource {
313
+ frame: Frame;
314
+ pages: Map<string, Plate>;
315
+ viewport: Viewport;
316
+ background: RGBA;
317
+ reference: Plate;
318
+ }
319
+
320
+ // ---------------------------------------------------------------------------
321
+ // the component pass
322
+ // ---------------------------------------------------------------------------
323
+
324
+ interface Pending {
325
+ track: SlotTrack;
326
+ foot: Footprint;
327
+ claimed: Component | null;
328
+ }
329
+
330
+ /**
331
+ * Match each drawn slot to a reference component, then template-match the rest.
332
+ *
333
+ * Returns the tracks in slot-name order, plus how many reference components the
334
+ * candidate accounted for — a component nothing overlaps is something in the shot
335
+ * the candidate has not drawn.
336
+ */
337
+ export function matchSlots(
338
+ footprints: Map<string, Footprint>,
339
+ field: ComponentField,
340
+ source: SlotSource | null,
341
+ ): { tracks: SlotTrack[]; matchedComponents: number } {
342
+ const components = field.components;
343
+ const pending: Pending[] = [];
344
+ const takenBy = new Map<Component, string[]>();
345
+ const occupants = occupantsOf(field, footprints);
346
+ /** Which component each one is, so an occupancy list can be looked up by it. */
347
+ const idOf = new Map<Component, number>();
348
+ components.forEach((component, id) => idOf.set(component, id));
349
+
350
+ for (const [slot, foot] of [...footprints].sort((a, b) => a[0].localeCompare(b[0]))) {
351
+ const track = blankTrack(slot);
352
+ if (foot.pixels === 0) {
353
+ track.ambiguity = 'the candidate draws nothing here — the slot is empty or entirely outside the frame';
354
+ pending.push({ track, foot, claimed: null });
355
+ continue;
356
+ }
357
+ track.candidate = {
358
+ cx: foot.cx,
359
+ cy: foot.cy,
360
+ width: foot.maxX - foot.minX,
361
+ height: foot.maxY - foot.minY,
362
+ pixels: Math.round(foot.pixels),
363
+ };
364
+ const radius = searchRadius(track.candidate.width, track.candidate.height);
365
+ track.searchRadius = radius;
366
+ if (components.length === 0) {
367
+ track.ambiguity = 'the reference frame is empty — nothing to match against';
368
+ pending.push({ track, foot, claimed: null });
369
+ continue;
370
+ }
371
+
372
+ // A component whose box holds this slot's centroid and is not much bigger than
373
+ // the slot is that slot's own blob. One much bigger than the slot is a merge:
374
+ // its centroid is the merged shape's and says nothing about this part.
375
+ const covering = components.filter(
376
+ (c) => foot.cx >= c.minX - 1 && foot.cx <= c.maxX + 1 && foot.cy >= c.minY - 1 && foot.cy <= c.maxY + 1,
377
+ );
378
+ const width = track.candidate.width;
379
+ const height = track.candidate.height;
380
+ const margin = Math.max(MERGE_MARGIN_PIXELS, MERGE_MARGIN * Math.max(width, height));
381
+ const own = covering
382
+ .filter(
383
+ (c) =>
384
+ c.pixels <= foot.pixels * MERGE_RATIO &&
385
+ c.maxX - c.minX <= width + margin &&
386
+ c.maxY - c.minY <= height + margin,
387
+ )
388
+ .sort((a, b) => Math.abs(a.pixels - foot.pixels) - Math.abs(b.pixels - foot.pixels))[0];
389
+
390
+ // Containment plus a compatible size is a far stronger claim than nearest
391
+ // centroid, so it is taken at face value and the runner-up test below — which
392
+ // exists to catch a *guess* — does not apply to it.
393
+ if (own) {
394
+ fillComponentMatch(track, foot, own);
395
+ claim(takenBy, own, slot);
396
+ pending.push({ track, foot, claimed: own });
397
+ continue;
398
+ }
399
+
400
+ if (covering.length > 0) {
401
+ const biggest = covering.sort((a, b) => b.pixels - a.pixels)[0];
402
+ track.ambiguity =
403
+ `this slot is inside a reference component ${(biggest.pixels / Math.max(1, foot.pixels)).toFixed(1)}x its ` +
404
+ `size and ${biggest.maxX - biggest.minX}x${biggest.maxY - biggest.minY} px against its ${width}x${height} — ` +
405
+ 'the reference merged it with something it touches or is drawn behind';
406
+ pending.push({ track, foot, claimed: null });
407
+ continue;
408
+ }
409
+
410
+ // Nothing contains it: the slot landed in open background. Nearest centroid is
411
+ // a guess, so it is bounded by what this slot could plausibly have moved, and
412
+ // a rival about as near makes it a guess between two things.
413
+ const ranked = components
414
+ .map((c) => ({ c, d: Math.hypot(c.cx - foot.cx, c.cy - foot.cy) }))
415
+ .sort((a, b) => a.d - b.d);
416
+ const nearest = ranked[0];
417
+ if (nearest.d > radius) {
418
+ track.ambiguity =
419
+ `the nearest reference component is ${nearest.d.toFixed(1)} px away, past the ${radius} px this ` +
420
+ `${Math.round(Math.max(track.candidate.width, track.candidate.height))} px slot could have moved and still ` +
421
+ 'be itself';
422
+ pending.push({ track, foot, claimed: null });
423
+ continue;
424
+ }
425
+ if (ranked[1] && ranked[1].d <= nearest.d * AMBIGUITY_RATIO) {
426
+ track.ambiguity =
427
+ `two reference components are about equally near (${nearest.d.toFixed(1)} px and ${ranked[1].d.toFixed(1)} px)`;
428
+ pending.push({ track, foot, claimed: null });
429
+ continue;
430
+ }
431
+ fillComponentMatch(track, foot, nearest.c);
432
+ claim(takenBy, nearest.c, slot);
433
+ pending.push({ track, foot, claimed: nearest.c });
434
+ }
435
+
436
+ // A component two slots both claim is one blob the reference merged. Neither
437
+ // claim is a measurement of its slot, so both drop to the template matcher.
438
+ for (const [component, claimants] of takenBy) {
439
+ if (claimants.length < 2) continue;
440
+ for (const entry of pending) {
441
+ if (!claimants.includes(entry.track.slot)) continue;
442
+ const others = claimants.filter((s) => s !== entry.track.slot);
443
+ entry.track.ambiguity =
444
+ `shares one reference component with ${others.map((s) => JSON.stringify(s)).join(', ')} — they touch or ` +
445
+ 'overlap in this frame';
446
+ entry.claimed = component;
447
+ clearMatch(entry.track);
448
+ }
449
+ }
450
+
451
+ // ...and a component ONE slot claimed while other ink of the candidate's sits
452
+ // inside it is the same blob by the other route — the one that reported a
453
+ // dominant part's distance to a five-part blob as that part's drift (#37). The
454
+ // claim goes to the template matcher for the same reason: a centroid shared by
455
+ // several parts is not this part's position.
456
+ for (const entry of pending) {
457
+ if (entry.claimed === null || entry.track.ambiguity !== null) continue;
458
+ const id = idOf.get(entry.claimed);
459
+ if (id === undefined) continue;
460
+ const others = (occupants.get(id) ?? []).filter((slot) => slot !== entry.track.slot);
461
+ if (others.length === 0) continue;
462
+ entry.track.ambiguity =
463
+ `this slot's reference component also holds ${others.map((s) => JSON.stringify(s)).join(', ')} — ` +
464
+ `${entry.claimed.pixels} px of blob against this slot's own ${Math.round(entry.foot.pixels)} px, so its ` +
465
+ "centroid is the merged shape's and not this part's";
466
+ clearMatch(entry.track);
467
+ }
468
+
469
+ // The fallback: anything the components could not attribute, correlated against
470
+ // the pixels of its own that the candidate's composite lets show. The owner mask
471
+ // is a property of the frame rather than of the slot, so it is taken once and
472
+ // only when something is actually going to correlate against it.
473
+ if (source) {
474
+ const wanted = pending.filter(
475
+ (entry) => entry.track.ambiguity !== null && entry.track.candidate !== null && entry.foot.pixels > 0,
476
+ );
477
+ if (wanted.length > 0) {
478
+ const visible = visibleField(source);
479
+ for (const entry of wanted) applyTemplateMatch(entry.track, entry.foot, source, visible);
480
+ }
481
+ }
482
+
483
+ const tracks = pending.map((p) => p.track);
484
+ return { tracks, matchedComponents: countExplained(footprints, components) };
485
+ }
486
+
487
+ function claim(takenBy: Map<Component, string[]>, component: Component, slot: string): void {
488
+ const claimants = takenBy.get(component) ?? [];
489
+ claimants.push(slot);
490
+ takenBy.set(component, claimants);
491
+ }
492
+
493
+ function blankTrack(slot: string): SlotTrack {
494
+ return {
495
+ slot,
496
+ candidate: null,
497
+ method: 'none',
498
+ reference: null,
499
+ drift: null,
500
+ driftX: null,
501
+ driftY: null,
502
+ widthDrift: null,
503
+ heightDrift: null,
504
+ confidence: null,
505
+ searchRadius: null,
506
+ wholePixel: null,
507
+ ambiguity: null,
508
+ };
509
+ }
510
+
511
+ function fillComponentMatch(track: SlotTrack, foot: Footprint, component: Component): void {
512
+ track.method = 'component';
513
+ track.reference = {
514
+ cx: component.cx,
515
+ cy: component.cy,
516
+ width: component.maxX - component.minX,
517
+ height: component.maxY - component.minY,
518
+ pixels: component.pixels,
519
+ };
520
+ track.driftX = component.cx - foot.cx;
521
+ track.driftY = component.cy - foot.cy;
522
+ track.drift = Math.hypot(track.driftX, track.driftY);
523
+ track.widthDrift = foot.maxX - foot.minX - track.reference.width;
524
+ track.heightDrift = foot.maxY - foot.minY - track.reference.height;
525
+ }
526
+
527
+ function clearMatch(track: SlotTrack): void {
528
+ track.method = 'none';
529
+ track.reference = null;
530
+ track.drift = null;
531
+ track.driftX = null;
532
+ track.driftY = null;
533
+ track.widthDrift = null;
534
+ track.heightDrift = null;
535
+ track.wholePixel = null;
536
+ }
537
+
538
+ /**
539
+ * How many reference components the candidate accounts for.
540
+ *
541
+ * Overlap rather than the drift match, and deliberately so: a slot whose drift
542
+ * could not be measured has still *drawn* over that blob, and counting it as
543
+ * unaccounted would report "something in the shot you have not drawn" about a part
544
+ * that is right there. What this number is for is the opposite case — a component
545
+ * no slot reaches at all.
546
+ */
547
+ function countExplained(footprints: Map<string, Footprint>, components: Component[]): number {
548
+ let explained = 0;
549
+ for (const component of components) {
550
+ for (const foot of footprints.values()) {
551
+ if (foot.pixels === 0) continue;
552
+ if (foot.minX >= component.maxX || component.minX >= foot.maxX) continue;
553
+ if (foot.minY >= component.maxY || component.minY >= foot.maxY) continue;
554
+ explained++;
555
+ break;
556
+ }
557
+ }
558
+ return explained;
559
+ }
560
+
561
+ // ---------------------------------------------------------------------------
562
+ // the template pass
563
+ // ---------------------------------------------------------------------------
564
+
565
+ interface Template {
566
+ /** Patch origin in frame pixels. */
567
+ ox: number;
568
+ oy: number;
569
+ width: number;
570
+ height: number;
571
+ /** The slot alone, composited over the background. */
572
+ patch: Plate;
573
+ /** Offsets into the patch that carry the slot's own VISIBLE pixels. */
574
+ samples: Int32Array;
575
+ /** Mean distance from the background over those samples: how visible it is. */
576
+ contrast: number;
577
+ /** How much of the slot's own ink reaches the picture, and how much does not. */
578
+ ink: number;
579
+ covered: number;
580
+ }
581
+
582
+ /**
583
+ * A template to correlate, or why this slot has none.
584
+ *
585
+ * `covered` is a *reported* outcome and not a quiet miss: a slot every pixel of
586
+ * which the candidate draws over has no position to measure, and saying so is a
587
+ * different fact from a correlation that searched and found nothing.
588
+ */
589
+ type TemplateOutcome =
590
+ | { kind: 'template'; template: Template }
591
+ | { kind: 'undrawn' }
592
+ | { kind: 'covered'; ink: number; width: number; height: number };
593
+
594
+ /**
595
+ * Which slot you would SEE at each pixel of the CANDIDATE's own frame.
596
+ *
597
+ * The composite's own rule, last writer wins, over the candidate's pieces in draw
598
+ * order — `frameGeometry`'s owner mask, which already computes exactly this for
599
+ * the chain split. It is entirely candidate-side: what it answers is *which of my
600
+ * template's pixels do I myself cover*, which is a fact about the thing being
601
+ * looked for and never a reading of the reference.
602
+ *
603
+ * ⚠️ So it is one-sided by construction, and that is the honest half to have. A
604
+ * pixel the candidate leaves visible may be covered in the reference, and nothing
605
+ * here can know it; what it removes is the half that is knowable, which is the
606
+ * half that was moving the answer.
607
+ */
608
+ interface VisibleField {
609
+ owner: Int32Array;
610
+ idOf: Map<string, number>;
611
+ width: number;
612
+ height: number;
613
+ }
614
+
615
+ function visibleField(source: SlotSource): VisibleField {
616
+ const idOf = new Map<string, number>();
617
+ for (const piece of source.frame.pieces) if (!idOf.has(piece.slot)) idOf.set(piece.slot, idOf.size);
618
+ const { owner } = frameGeometry(source.frame, source.pages, source.viewport, idOf);
619
+ return {
620
+ owner: owner ?? new Int32Array(source.viewport.width * source.viewport.height).fill(-1),
621
+ idOf,
622
+ width: source.viewport.width,
623
+ height: source.viewport.height,
624
+ };
625
+ }
626
+
627
+ /**
628
+ * The slot on its own, over the background, at the size it drew — sampled only
629
+ * where the candidate's own composite lets it show.
630
+ *
631
+ * Its own pieces and nothing else — the point of the fallback is that the
632
+ * reference merged this part with its neighbours, so the thing being looked for
633
+ * has to be the part rather than the blob.
634
+ *
635
+ * 🚨 And only the pixels of it that reach the picture. A template pixel the
636
+ * candidate draws something over cannot match the reference at the true offset,
637
+ * so it contributes a residual at *every* offset and the correlation is reading
638
+ * the occluder rather than the part — issue #698's whole finding. ⚠️ It is not a
639
+ * flat penalty that cancels: sliding the template moves those samples onto other
640
+ * pixels, and where the visible basin is shallow their gradient is what decides
641
+ * the winner. `contrast` is taken over the same retained samples, so the residual
642
+ * test below compares two figures measured on one population.
643
+ */
644
+ function templateFor(slot: string, foot: Footprint, source: SlotSource, visible: VisibleField): TemplateOutcome {
645
+ const ox = Math.floor(foot.minX);
646
+ const oy = Math.floor(foot.minY);
647
+ const width = Math.ceil(foot.maxX) - ox;
648
+ const height = Math.ceil(foot.maxY) - oy;
649
+ if (width <= 0 || height <= 0) return { kind: 'undrawn' };
650
+ const patch = new Plate(width, height);
651
+ const bg = source.background;
652
+ for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) patch.set(x, y, bg);
653
+ const project = projector(source.viewport);
654
+ const shifted = (wx: number, wy: number): [number, number] => {
655
+ const [px, py] = project(wx, wy);
656
+ return [px - ox, py - oy];
657
+ };
658
+ let drew = false;
659
+ for (const piece of source.frame.pieces) {
660
+ if (piece.slot !== slot) continue;
661
+ rasterisePiece(pageFor(source.pages, piece), piece, shifted, { width, height }, (px, py, r, g, b, a) => {
662
+ patch.blend(px, py, [r, g, b, a]);
663
+ drew = true;
664
+ });
665
+ }
666
+ if (!drew) return { kind: 'undrawn' };
667
+
668
+ const own = visible.idOf.get(slot) ?? -1;
669
+ const hits: number[] = [];
670
+ let ink = 0;
671
+ let sum = 0;
672
+ for (let y = 0; y < height; y++) {
673
+ for (let x = 0; x < width; x++) {
674
+ const d = backgroundDistance(patch, x, y, bg);
675
+ if (d <= 0) continue;
676
+ ink++;
677
+ const fx = ox + x;
678
+ const fy = oy + y;
679
+ // Off the frame counts as covered: a pixel outside the reference compares
680
+ // against the background at every offset, which is the same constant.
681
+ if (fx < 0 || fy < 0 || fx >= visible.width || fy >= visible.height) continue;
682
+ if (visible.owner[fy * visible.width + fx] !== own) continue;
683
+ hits.push(y * width + x);
684
+ sum += d;
685
+ }
686
+ }
687
+ if (ink === 0) return { kind: 'undrawn' };
688
+ if (hits.length === 0) return { kind: 'covered', ink, width, height };
689
+ const stride = Math.max(1, Math.ceil(hits.length / MAX_SAMPLES));
690
+ const samples: number[] = [];
691
+ for (let i = 0; i < hits.length; i += stride) samples.push(hits[i]);
692
+ return {
693
+ kind: 'template',
694
+ template: {
695
+ ox,
696
+ oy,
697
+ width,
698
+ height,
699
+ patch,
700
+ samples: Int32Array.from(samples),
701
+ contrast: sum / hits.length,
702
+ ink,
703
+ covered: ink - hits.length,
704
+ },
705
+ };
706
+ }
707
+
708
+ /** Mean absolute RGB difference between the template and the reference at an offset. */
709
+ function scoreAt(template: Template, source: SlotSource, dx: number, dy: number): number {
710
+ const { patch, samples, width, ox, oy } = template;
711
+ const reference = source.reference;
712
+ const bg = source.background;
713
+ let sum = 0;
714
+ for (let i = 0; i < samples.length; i++) {
715
+ const at = samples[i];
716
+ const x = at % width;
717
+ const y = (at - x) / width;
718
+ const a = patch.get(x, y);
719
+ const rx = ox + x + dx;
720
+ const ry = oy + y + dy;
721
+ const b =
722
+ rx < 0 || ry < 0 || rx >= reference.width || ry >= reference.height ? bg : reference.get(rx, ry);
723
+ sum += (Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]) + Math.abs(a[2] - b[2])) / 3;
724
+ }
725
+ return sum / samples.length;
726
+ }
727
+
728
+ /**
729
+ * The offsets one axis of the coarse sweep visits, in ascending order.
730
+ *
731
+ * ⭐ Anchored on **zero** and symmetric about it, and both halves of that are
732
+ * correctness rather than taste. Zero is the offset that says *the part landed
733
+ * where the candidate drew it*, which is the answer on every frame of a correct
734
+ * rig — so a lattice that does not carry it cannot report a correct rig as
735
+ * correct. And the window this searches is `±radius`, so a lattice reaching one
736
+ * stride further one way than the other is searching a different window from the
737
+ * one the radius names.
738
+ *
739
+ * 🚨 The sweep used to run `for (dx = -radius; dx <= radius; dx += coarse)`,
740
+ * which puts the origin on the lattice only when `coarse` divides `radius`.
741
+ * Issue #678: `gallery/squash` checked against frames rendered from **itself**
742
+ * reported `slot drift worst 3.7 px "ear_r"`. The ear's radius is 26 and its
743
+ * stride 3, so the lattice ran `-26, -23, … -2, 1, 4 …` — the origin missing,
744
+ * and the nearest lattice point below it two whole pixels out. The coarse winner
745
+ * landed at `(1, -2)` scoring 37.33, the halving refinement walked it to
746
+ * `(2, -3)` at 37.28, and the exhaustive field over the same radius has its
747
+ * minimum at `(0, 0)` scoring **35.88**. The refinement cannot recover it: it
748
+ * searches `±1` around the coarse winner, and `dy = -2` is two away from zero.
749
+ */
750
+ function sweepOffsets(radius: number, coarse: number): number[] {
751
+ const out = [0];
752
+ for (let d = coarse; d <= radius; d += coarse) {
753
+ out.unshift(-d);
754
+ out.push(d);
755
+ }
756
+ return out;
757
+ }
758
+
759
+ /**
760
+ * Correlate one slot against the reference inside its own search radius.
761
+ *
762
+ * Multi-resolution: a full sweep at a coarse stride, then halving steps around the
763
+ * winner, then a parabolic refinement for the sub-pixel part. That keeps the cost
764
+ * near-constant in the radius — a big part gets a big window without paying its
765
+ * square — and the coarse sweep doubles as the rival field the confidence is read
766
+ * from.
767
+ *
768
+ * ⚠️ What is left over on an identity run is the **sub-pixel step**, and it
769
+ * cannot be zero: the parabola is fitted through three whole-pixel residuals that
770
+ * antialiasing alone makes slightly uneven, so its vertex sits a fraction off the
771
+ * winner. `parabolic` clamps that to `SUBPIXEL_CLAMP` on each axis, which bounds
772
+ * the identity floor **provided the whole-pixel winner is the identity offset** —
773
+ * and `templateFor` masking the occluded samples is what makes that second half
774
+ * true. `track.wholePixel` publishes the winner and `driftBound` turns it into the
775
+ * figure, so a reader never has to assume it. See `docs/AUTHORING.md` §9.2.
776
+ */
777
+ function applyTemplateMatch(track: SlotTrack, foot: Footprint, source: SlotSource, visible: VisibleField): void {
778
+ if (!track.candidate || track.searchRadius === null) return;
779
+ const outcome = templateFor(track.slot, foot, source, visible);
780
+ if (outcome.kind === 'undrawn') return;
781
+ if (outcome.kind === 'covered') {
782
+ track.method = 'none';
783
+ track.ambiguity =
784
+ `${track.ambiguity ?? 'no component of its own'}; its drift is not measurable — every one of the ` +
785
+ `${outcome.ink} px this ${outcome.width}x${outcome.height} px slot draws is covered by something the ` +
786
+ 'candidate draws over it, so none of its own ink reaches the picture to be correlated against';
787
+ return;
788
+ }
789
+ const template = outcome.template;
790
+ if (template.contrast <= 0) return;
791
+ const radius = track.searchRadius;
792
+ const cache = new Map<number, number>();
793
+ const score = (dx: number, dy: number): number => {
794
+ const key = (dy + MAX_SEARCH_RADIUS * 2) * 1024 + (dx + MAX_SEARCH_RADIUS * 2);
795
+ const seen = cache.get(key);
796
+ if (seen !== undefined) return seen;
797
+ const value = scoreAt(template, source, dx, dy);
798
+ cache.set(key, value);
799
+ return value;
800
+ };
801
+
802
+ const coarse = Math.max(1, Math.round(radius / 8));
803
+ let bestX = 0;
804
+ let bestY = 0;
805
+ let best = Infinity;
806
+ const sweep: Array<{ dx: number; dy: number; s: number }> = [];
807
+ const axis = sweepOffsets(radius, coarse);
808
+ for (const dy of axis) {
809
+ for (const dx of axis) {
810
+ const s = score(dx, dy);
811
+ sweep.push({ dx, dy, s });
812
+ if (s < best) {
813
+ best = s;
814
+ bestX = dx;
815
+ bestY = dy;
816
+ }
817
+ }
818
+ }
819
+ for (let step = coarse; step > 1; ) {
820
+ step = Math.max(1, Math.floor(step / 2));
821
+ for (let dy = bestY - step; dy <= bestY + step; dy += step) {
822
+ for (let dx = bestX - step; dx <= bestX + step; dx += step) {
823
+ if (Math.abs(dx) > radius || Math.abs(dy) > radius) continue;
824
+ const s = score(dx, dy);
825
+ if (s < best) {
826
+ best = s;
827
+ bestX = dx;
828
+ bestY = dy;
829
+ }
830
+ }
831
+ }
832
+ }
833
+
834
+ // A winner nobody else came close to is a located part. A winner its neighbours
835
+ // match just as well is a featureless blob, and no offset is evidence.
836
+ const gap = Math.max(RIVAL_GAP, coarse);
837
+ let rival = Infinity;
838
+ for (const { dx, dy, s } of sweep) {
839
+ if (Math.hypot(dx - bestX, dy - bestY) < gap) continue;
840
+ if (s < rival) rival = s;
841
+ }
842
+ const confidence = Number.isFinite(rival) && rival > 0 ? Math.max(0, Math.min(1, 1 - best / rival)) : 0;
843
+
844
+ const reach = Math.min(1, Math.hypot(bestX, bestY) / radius);
845
+ const required = MIN_CONFIDENCE + reach * CONFIDENCE_SLOPE;
846
+ if (best > template.contrast * MAX_RESIDUAL_FRACTION || confidence < required) {
847
+ track.method = 'none';
848
+ track.confidence = Number.isFinite(confidence) ? confidence : 0;
849
+ track.ambiguity =
850
+ `${track.ambiguity ?? 'no component of its own'}; correlating the slot's own pixels found no match within ` +
851
+ `${radius} px either (best residual ${best.toFixed(1)} against its own ${template.contrast.toFixed(1)} of ` +
852
+ `contrast over the ${template.ink - template.covered} of its ${template.ink} px that reach the picture; ` +
853
+ `confidence ${(Number.isFinite(confidence) ? confidence : 0).toFixed(2)} where ` +
854
+ `${required.toFixed(2)} is needed ${Math.hypot(bestX, bestY).toFixed(1)} px out)`;
855
+ return;
856
+ }
857
+
858
+ const dx = bestX + parabolic(score(bestX - 1, bestY), best, score(bestX + 1, bestY));
859
+ const dy = bestY + parabolic(score(bestX, bestY - 1), best, score(bestX, bestY + 1));
860
+ track.method = 'template';
861
+ track.reference = null;
862
+ track.driftX = dx;
863
+ track.driftY = dy;
864
+ track.drift = Math.hypot(dx, dy);
865
+ track.widthDrift = null;
866
+ track.heightDrift = null;
867
+ track.confidence = confidence;
868
+ track.wholePixel = { dx: bestX, dy: bestY };
869
+ track.ambiguity = null;
870
+ }
871
+
872
+ /**
873
+ * How far off the whole-pixel winner the sub-pixel step may place the answer.
874
+ *
875
+ * Half a pixel on each axis, because past that the neighbouring whole pixel is
876
+ * the better winner and the search would have found it. It is what bounds the
877
+ * instrument's identity floor at `hypot(0.5, 0.5)` px, which `docs/AUTHORING.md`
878
+ * §9.2 states as the floor an author reads a drift against.
879
+ */
880
+ export const SUBPIXEL_CLAMP = 0.5;
881
+
882
+ /** Sub-pixel minimum of the parabola through three samples one pixel apart. */
883
+ function parabolic(before: number, at: number, after: number): number {
884
+ const denominator = before - 2 * at + after;
885
+ if (!Number.isFinite(denominator) || Math.abs(denominator) < 1e-9) return 0;
886
+ return Math.max(-SUBPIXEL_CLAMP, Math.min(SUBPIXEL_CLAMP, (before - after) / (2 * denominator)));
887
+ }
888
+
889
+ /** Is this track's drift a measurement of this slot? */
890
+ export function isAttributable(track: SlotTrack): boolean {
891
+ return track.drift !== null && track.ambiguity === null;
892
+ }