rig-c 2.31.1 → 2.32.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",
3
- "version": "2.31.1",
3
+ "version": "2.32.0",
4
4
  "description": "AI-authored Spine 2D rigging and animation, verified before it is written. rigc compiles a rig spec into Spine 4.3 skeleton data, gates it with named assertions held to a spine-core round trip's verdicts, and emits nothing that fails. Output imports into the Spine editor. Ships as an agent skill.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -95,7 +95,11 @@ import {
95
95
  type MotionReading,
96
96
  type MotionRowDetail,
97
97
  type MotionSchedule,
98
+ type OverBoundReport,
99
+ type OverBoundRequest,
100
+ type OverBoundRow,
98
101
  type ScheduleUsed,
102
+ type TriangleMaxima,
99
103
  type SourceMesh,
100
104
  type WorstSample,
101
105
  } from './meshquality.ts';
@@ -165,6 +169,17 @@ export interface MotionComparisonInput {
165
169
  * before any build is read, in the measurement's words. Echoed in `effective` when set, `null` included.
166
170
  */
167
171
  motionAmplitude?: MotionAmplitude | null;
172
+ /**
173
+ * Issue #1315: asks each candidate's report for where its motion rows broke — the samples over each declared
174
+ * `MQ_LOCAL_DEFORMATION` bound (the attachment's row and each measured region's), each with the frame of its worst
175
+ * value and the candidate's UV triangle that carries it, at most `maxSamples` per row, worst first, with the count
176
+ * of all of them; and each candidate triangle's worst value of every declared motion row over the selection frames
177
+ * (`triangles`: the over-bound triangles, or `all` with a reading). Nothing else moves: no row, verdict, acceptance
178
+ * or byte of a report without it. Left out, not asked; anything that is not an `OverBoundRequest` in full — `null`
179
+ * included, since asking for nothing is leaving the field out — is refused `COMPARE_INPUT_MISSING` naming the field,
180
+ * before any build is read. Echoed in `effective` when set.
181
+ */
182
+ overBound?: OverBoundRequest;
168
183
  }
169
184
 
170
185
  /**
@@ -173,7 +188,27 @@ export interface MotionComparisonInput {
173
188
  * before the builds are read; `null` echoed for a field left out; and the load counted as a required row, so the
174
189
  * amplitude moves a verdict. `compareMeshesInMotion` plants none.
175
190
  */
176
- export type ComparePlant = 'amplitude-not-carried' | 'null-read-as-left-out' | 'amplitude-unvalidated' | 'echo-when-unset' | 'amplitude-gates-acceptance';
191
+ export type ComparePlant =
192
+ | 'amplitude-not-carried'
193
+ | 'null-read-as-left-out'
194
+ | 'amplitude-unvalidated'
195
+ | 'echo-when-unset'
196
+ | 'amplitude-gates-acceptance'
197
+ | OverBoundPlant;
198
+
199
+ /**
200
+ * Issue #1315's faults in `overBound`, for the `mesh-compare` suite's negative controls: a listed sample's triangle
201
+ * taken from the reference's carrier rather than the candidate's; the samples listed in domain order rather than worst
202
+ * first; the total counted off the listed samples; the per-triangle table read over every frame rather than the
203
+ * selection's; the field echoed when left out; and the field not validated, so `null` and a malformed request pass.
204
+ */
205
+ export type OverBoundPlant =
206
+ | 'over-bound-reference-carrier'
207
+ | 'over-bound-domain-order'
208
+ | 'over-bound-total-is-listed'
209
+ | 'over-bound-table-every-frame'
210
+ | 'over-bound-echo-when-unset'
211
+ | 'over-bound-unvalidated';
177
212
 
178
213
  // ---------------------------------------------------------------------------
179
214
  // fixed tolerances — the tree's own
@@ -286,6 +321,14 @@ function validateInput(input: MotionComparisonInput, plant: ComparePlant | null)
286
321
  throw err;
287
322
  }
288
323
  }
324
+ if (plant !== 'over-bound-unvalidated' && input.overBound !== undefined) {
325
+ // Issue #1315: a request in full, or the field left out. No count is assumed and no table is chosen for the caller.
326
+ const o: unknown = input.overBound;
327
+ const shape = "an OverBoundRequest { maxSamples, triangles }, or the field left out — null asks for nothing, which is what leaving it out says";
328
+ if (!isObject(o)) missing('overBound', o, shape);
329
+ if (!Number.isInteger(o.maxSamples) || (o.maxSamples as number) < 1) missing('overBound.maxSamples', o.maxSamples, 'a whole number >= 1 — the most over-bound samples listed per row; there is no default');
330
+ if (o.triangles !== 'over-bound' && o.triangles !== 'all') missing('overBound.triangles', o.triangles, "'over-bound' or 'all' — which triangles the per-triangle table lists; there is no default");
331
+ }
289
332
  const s = input.schedule;
290
333
  if (s === null) return;
291
334
  if (!isObject(s)) missing('schedule', s, 'a MotionSchedule, or null for no motion');
@@ -915,7 +958,9 @@ function compareMeshesInMotionWith(input: MotionComparisonInput, plant: CompareP
915
958
 
916
959
  // --- motion ---------------------------------------------------------------------------------
917
960
  const schedule = input.schedule;
918
- type Motion = { section: EvidenceSection & { schedule: ScheduleUsed }; perFrame: NonNullable<CandidateReport['perFrame']> } | null;
961
+ type Motion = { section: EvidenceSection & { schedule: ScheduleUsed }; perFrame: NonNullable<CandidateReport['perFrame']>; overBound: OverBoundReport | null } | null;
962
+ // Issue #1315: the request as the caller set it; `?? undefined` only matters under the unvalidated plant.
963
+ const overBound: OverBoundRequest | undefined = input.overBound ?? undefined;
919
964
  let motionOf: (b: Build, isReference: boolean) => Motion = () => null;
920
965
  if (schedule !== null) {
921
966
  const physicsConstraints = reference.doc.constraints.filter((c) => c.kind === 'physics').length;
@@ -958,6 +1003,9 @@ function compareMeshesInMotionWith(input: MotionComparisonInput, plant: CompareP
958
1003
  const posed = isReference ? posedRef : poseBuild(b.doc, order.map((i) => refs[i]), frames, walks);
959
1004
  const built: Built[] = [];
960
1005
  const perFrame: NonNullable<CandidateReport['perFrame']> = [];
1006
+ // Issue #1315: kept for a candidate only, and only when asked — a call without the field does none of this.
1007
+ const ask = isReference ? undefined : overBound;
1008
+ const located: OverBoundReport | null = ask === undefined ? null : { selectionFrames: frames.filter((f) => f.ref.role === 'selection').length, rows: [], triangles: [] };
961
1009
  order.forEach((i, k) => {
962
1010
  const a = input.attachments[i];
963
1011
  const ref = refs[i];
@@ -996,6 +1044,18 @@ function compareMeshesInMotionWith(input: MotionComparisonInput, plant: CompareP
996
1044
  const squash: Reading[] = [];
997
1045
  const inversion: Reading[] = [];
998
1046
  const folds: Array<{ triangle: number; frame: string }> = [];
1047
+ // Issue #1315: each sample's worst distance over every frame and the frame of it (first in walk order); each
1048
+ // triangle's worst reading of each row over the selection frames. Bookkeeping over values computed below anyway.
1049
+ const nTri = mesh.triangles.length / 3;
1050
+ const track = located !== null;
1051
+ const sampleWorst = new Float64Array(track ? samples.length : 0).fill(-1);
1052
+ const sampleFrame = new Int32Array(track ? samples.length : 0).fill(-1);
1053
+ const triLocal = new Float64Array(track ? nTri : 0).fill(-1);
1054
+ const triHi = new Float64Array(track ? nTri : 0).fill(-Infinity);
1055
+ const triLo = new Float64Array(track ? nTri : 0).fill(Infinity);
1056
+ const triStretchRead = new Uint8Array(track ? nTri : 0);
1057
+ const triReversed = new Int32Array(track ? nTri : 0);
1058
+ const triInversionRead = new Uint8Array(track ? nTri : 0);
999
1059
  const setupWorld = posed.setup[k];
1000
1060
  const setupAreas = setupWorld === null ? [] : triangleAreas(setupWorld, mesh.triangles);
1001
1061
  const setupBand = setupWorld === null ? 0 : areaBand(setupAreas, setupWorld);
@@ -1007,6 +1067,7 @@ function compareMeshesInMotionWith(input: MotionComparisonInput, plant: CompareP
1007
1067
  notDrawn.push(f.ref.id);
1008
1068
  return;
1009
1069
  }
1070
+ const selected = track && (f.ref.role === 'selection' || plant === 'over-bound-table-every-frame');
1010
1071
  // Local deformation: the carried points, sample by sample.
1011
1072
  let worst = -1;
1012
1073
  let worstAt = -1;
@@ -1020,6 +1081,16 @@ function compareMeshesInMotionWith(input: MotionComparisonInput, plant: CompareP
1020
1081
  worst = d;
1021
1082
  worstAt = j;
1022
1083
  }
1084
+ if (track) {
1085
+ if (d > sampleWorst[j]) {
1086
+ sampleWorst[j] = d;
1087
+ sampleFrame[j] = fi;
1088
+ }
1089
+ if (selected) {
1090
+ const t = candCarrier[j]!.triangle;
1091
+ if (d > triLocal[t]) triLocal[t] = d;
1092
+ }
1093
+ }
1023
1094
  for (let r = 0; r < a.regions.length; r++) {
1024
1095
  if (inRegion[r][j] && d > regionWorst[r].d) regionWorst[r] = { d, j };
1025
1096
  }
@@ -1046,9 +1117,16 @@ function compareMeshesInMotionWith(input: MotionComparisonInput, plant: CompareP
1046
1117
  if (sv !== null) {
1047
1118
  if (hi === null || sv.max > hi.value) hi = { frame: fi, value: sv.max, at: { triangle: t } };
1048
1119
  if (lo === null || sv.min < lo.value) lo = { frame: fi, value: sv.min, at: { triangle: t } };
1120
+ if (selected) {
1121
+ triStretchRead[t] = 1;
1122
+ if (sv.max > triHi[t]) triHi[t] = sv.max;
1123
+ if (sv.min < triLo[t]) triLo[t] = sv.min;
1124
+ }
1049
1125
  }
1126
+ if (selected) triInversionRead[t] = 1;
1050
1127
  if (Math.abs(after[t]) <= band) continue;
1051
1128
  if (Math.sign(setupAreas[t]) !== Math.sign(after[t])) {
1129
+ if (selected) triReversed[t]++;
1052
1130
  reversed++;
1053
1131
  if (firstReversed === -1) firstReversed = t;
1054
1132
  if (folding.has(ref.slot)) folds.push({ triangle: t, frame: f.ref.id });
@@ -1102,6 +1180,60 @@ function compareMeshesInMotionWith(input: MotionComparisonInput, plant: CompareP
1102
1180
  // A slot `deformMayFold` names: the row holds no bound (A39 exempts it), keeps its count rather than zeroing it, and lists every fold.
1103
1181
  built.push(motionRow(plans.inversion, inversion, frames, schedule, notDrawn, folding.has(ref.slot) ? { folds, degenerateAtSetup } : { degenerateAtSetup }));
1104
1182
  }
1183
+ if (located !== null && ask !== undefined) {
1184
+ // Issue #1315: the samples over the local-deformation bound, per measured row, and the per-triangle tables.
1185
+ const localBound = { op: '<=' as const, value: input.bounds.maxLocalDeformation };
1186
+ const listed = (region: string | null, inside: readonly boolean[] | null): OverBoundRow => {
1187
+ const over: Array<{ j: number; v: number }> = [];
1188
+ for (let j = 0; j < samples.length; j++) {
1189
+ if (sampleFrame[j] < 0 || (inside !== null && !inside[j])) continue;
1190
+ const v = r6(sampleWorst[j]);
1191
+ if (judged(v, localBound) === 'fail') over.push({ j, v });
1192
+ }
1193
+ if (plant !== 'over-bound-domain-order') over.sort((p, q) => q.v - p.v || p.j - q.j);
1194
+ const kept = over.slice(0, ask.maxSamples);
1195
+ return {
1196
+ code: 'MQ_LOCAL_DEFORMATION',
1197
+ attachment: ref,
1198
+ region,
1199
+ bound: localBound,
1200
+ total: plant === 'over-bound-total-is-listed' ? kept.length : over.length,
1201
+ samples: kept.map(({ j, v }) => {
1202
+ const s = samples[j];
1203
+ const c = plant === 'over-bound-reference-carrier' ? refCarrier[j]! : candCarrier[j]!;
1204
+ return {
1205
+ at: s.pixel !== null ? { pixel: s.pixel, uv: s.uv } : { vertex: s.vertex ?? -1, uv: s.uv },
1206
+ frame: frames[sampleFrame[j]].ref,
1207
+ value: v,
1208
+ triangle: c.triangle,
1209
+ corners: [c.corners[0], c.corners[1], c.corners[2]],
1210
+ };
1211
+ }),
1212
+ };
1213
+ };
1214
+ if (art >= a.minArtSamples) located.rows.push(listed(null, null));
1215
+ const measuredRegions = a.regions
1216
+ .map((r, ri) => ({ name: r.name, ri }))
1217
+ .filter(({ ri }) => artIn(inRegion[ri]) >= a.regions[ri].minArtSamples)
1218
+ .sort((p, q) => (p.name < q.name ? -1 : p.name > q.name ? 1 : 0));
1219
+ for (const { name, ri } of measuredRegions) located.rows.push(listed(name, inRegion[ri]));
1220
+ const table = (code: string, bound: { op: '<=' | '>='; value: number }, read: (t: number) => number | null): TriangleMaxima => {
1221
+ const values: Array<{ triangle: number; value: number }> = [];
1222
+ for (let t = 0; t < nTri; t++) {
1223
+ const raw = read(t);
1224
+ if (raw === null) continue;
1225
+ const value = r6(raw);
1226
+ if (ask.triangles === 'all' || judged(value, bound) === 'fail') values.push({ triangle: t, value });
1227
+ }
1228
+ values.sort((p, q) => (bound.op === '<=' ? q.value - p.value : p.value - q.value) || p.triangle - q.triangle);
1229
+ return { code, attachment: ref, bound, values };
1230
+ };
1231
+ // Codes in the rows' order (ROW_ORDER: codes as strings); a row with no declared bound has no table.
1232
+ if (setupWorld !== null && plans.inversion.bound !== null) located.triangles.push(table('MQ_INVERSION', plans.inversion.bound, (t) => (triInversionRead[t] === 1 ? triReversed[t] : null)));
1233
+ if (art >= a.minArtSamples) located.triangles.push(table('MQ_LOCAL_DEFORMATION', localBound, (t) => (triLocal[t] >= 0 ? triLocal[t] : null)));
1234
+ if (setupWorld !== null && plans.squash.bound !== null) located.triangles.push(table('MQ_SQUASH', plans.squash.bound, (t) => (triStretchRead[t] === 1 ? triLo[t] : null)));
1235
+ if (setupWorld !== null && plans.stretch.bound !== null) located.triangles.push(table('MQ_STRETCH', plans.stretch.bound, (t) => (triStretchRead[t] === 1 ? triHi[t] : null)));
1236
+ }
1105
1237
  // P7's opt-in table: each attachment-level row's value at every frame walked, null where none was taken.
1106
1238
  const label = (code: string): string => (order.length > 1 ? `${code}[${nameOf(ref)}]` : code);
1107
1239
  const table: Array<[string, Reading[]]> = [
@@ -1124,17 +1256,18 @@ function compareMeshesInMotionWith(input: MotionComparisonInput, plant: CompareP
1124
1256
  const ref = refs[order[k]];
1125
1257
  grouped.push(...built.filter((x) => x.row.object.attachment === ref).sort((p, q) => ROW_ORDER(p.row, q.row)));
1126
1258
  }
1127
- return { section: { ...sectionOf(grouped), schedule: scheduleUsed }, perFrame };
1259
+ return { section: { ...sectionOf(grouped), schedule: scheduleUsed }, perFrame, overBound: located };
1128
1260
  };
1129
1261
  }
1130
1262
  const referenceMotion = motionOf(reference, true);
1131
1263
  const candidateMotions = candidates.map((c) => motionOf(c, false));
1132
- const reportOf = (b: Build, geometry: { geometry: EvidenceSection | null; counts: MeshCounts | null }, measured: Motion): CandidateReport => {
1264
+ const reportOf = (b: Build, geometry: { geometry: EvidenceSection | null; counts: MeshCounts | null }, measured: Motion, isCandidate: boolean): CandidateReport => {
1133
1265
  const motion = measured === null ? null : measured.section;
1134
1266
  // P6: the geometry verdict pass (every required row measured and passing), and — when motion is required — the motion verdict pass.
1135
1267
  const accepted = geometry.geometry !== null && geometry.geometry.verdict === 'pass' && (!input.motionRequired || (motion !== null && motion.verdict === 'pass'));
1136
1268
  const out: CandidateReport = { id: b.id, counts: geometry.counts, geometry: geometry.geometry, motion, accepted };
1137
1269
  if (input.perFrame && measured !== null) out.perFrame = measured.perFrame;
1270
+ if (isCandidate && overBound !== undefined) out.overBound = measured === null ? null : measured.overBound;
1138
1271
  return out;
1139
1272
  };
1140
1273
  const effective: EffectiveSettings = {
@@ -1163,6 +1296,11 @@ function compareMeshesInMotionWith(input: MotionComparisonInput, plant: CompareP
1163
1296
  schedule,
1164
1297
  budget: null,
1165
1298
  ...(amplitude === undefined ? (plant === 'echo-when-unset' ? { motionAmplitude: null } : {}) : { motionAmplitude: amplitude }),
1299
+ ...(overBound === undefined
1300
+ ? plant === 'over-bound-echo-when-unset'
1301
+ ? { overBound: { maxSamples: 1, triangles: 'over-bound' as const } }
1302
+ : {}
1303
+ : { overBound: { maxSamples: overBound.maxSamples, triangles: overBound.triangles } }),
1166
1304
  };
1167
1305
  return {
1168
1306
  spec: MESH_QUALITY_REPORT_SPEC,
@@ -1171,8 +1309,8 @@ function compareMeshesInMotionWith(input: MotionComparisonInput, plant: CompareP
1171
1309
  poser: schedule === null ? null : { kind: 'core', rigcVersion: readVersion() },
1172
1310
  motionRequired: input.motionRequired,
1173
1311
  sourceCounts: refGeometry.counts,
1174
- reference: reportOf(reference, refGeometry, referenceMotion),
1175
- candidates: candidates.map((c, i) => reportOf(c, candGeometry[i], candidateMotions[i])),
1312
+ reference: reportOf(reference, refGeometry, referenceMotion, false),
1313
+ candidates: candidates.map((c, i) => reportOf(c, candGeometry[i], candidateMotions[i], true)),
1176
1314
  termination: null,
1177
1315
  };
1178
1316
  }
@@ -547,10 +547,69 @@ export interface CandidateReport {
547
547
  accepted: boolean;
548
548
  /** Opt-in (P7): every row's value at every frame. Absent unless asked for. */
549
549
  perFrame?: Array<{ code: string; frame: string; value: number | null }>;
550
+ /**
551
+ * Issue #1315, a comparison's candidate only: present exactly when the input set `overBound` — the samples over each
552
+ * declared motion bound with the candidate triangle that carries them, and each triangle's worst value of each motion
553
+ * row over the selection frames. Null when no motion section was measured (no schedule). Absent otherwise.
554
+ */
555
+ overBound?: OverBoundReport | null;
550
556
  /** A `reduce` whose result exists: what the operation changed. Absent on a `measure` and when no mesh is returned. */
551
557
  changes?: ReductionChanges;
552
558
  }
553
559
 
560
+ /**
561
+ * Issue #1315: what `MotionComparisonInput.overBound` asks for — how many over-bound samples each row lists at most,
562
+ * worst first (a whole number, 1 or more; no default), and which triangles the per-triangle table lists: those over
563
+ * the row's bound, or every triangle that has a reading. Echoed in `effective` when set.
564
+ */
565
+ export interface OverBoundRequest {
566
+ maxSamples: number;
567
+ triangles: 'over-bound' | 'all';
568
+ }
569
+
570
+ /**
571
+ * One sample over a motion bound (issue #1315): the sample by §3's id — an art pixel of the mask grid, or a reference
572
+ * hull vertex — with its UV; the frame its worst value over every frame walked was taken at (the first such frame in
573
+ * walk order), whose `role` says selection, held out or baseline; that value; and the candidate's UV triangle that
574
+ * carries the sample, by index and by its three vertex indices.
575
+ */
576
+ export interface OverBoundSample {
577
+ at: { pixel?: [number, number]; vertex?: number; uv: [number, number] };
578
+ frame: FrameRef;
579
+ value: number;
580
+ triangle: number;
581
+ corners: [number, number, number];
582
+ }
583
+
584
+ /** One sample-taken motion row with a declared bound (issue #1315): every sample over it counted, the worst listed. */
585
+ export interface OverBoundRow {
586
+ code: string;
587
+ attachment: AttachmentRef;
588
+ region: string | null;
589
+ bound: { op: '<=' | '>='; value: number };
590
+ /** Samples whose worst value over every frame walked fails the bound — all of them, whether or not they are listed. */
591
+ total: number;
592
+ /** At most `maxSamples` of them: worst first, then in §3's domain order (art pixels row by row, then hull vertices). */
593
+ samples: OverBoundSample[];
594
+ }
595
+
596
+ /** Each candidate triangle's worst value of one motion row over the selection frames (issue #1315). */
597
+ export interface TriangleMaxima {
598
+ code: string;
599
+ attachment: AttachmentRef;
600
+ bound: { op: '<=' | '>='; value: number };
601
+ /** Worst first, then triangle index; over-bound triangles only, or every triangle with a reading, as asked. */
602
+ values: Array<{ triangle: number; value: number }>;
603
+ }
604
+
605
+ /** Issue #1315's report block on a compared candidate. */
606
+ export interface OverBoundReport {
607
+ /** How many frames the selection named and the schedule walked — the frames `triangles` reads; 0 leaves every table empty. */
608
+ selectionFrames: number;
609
+ rows: OverBoundRow[];
610
+ triangles: TriangleMaxima[];
611
+ }
612
+
554
613
  /**
555
614
  * What a reduction changed, counted rather than described — the figures the
556
615
  * contract says are reported (§6, P18 and P19) and that no geometry row carries.
@@ -635,6 +694,8 @@ export interface EffectiveSettings {
635
694
  removalOrder?: 'deformation-load';
636
695
  /** A measurement's `skinning`, echoed when the input set it — `null` included (issue #1294): the source by id and digest. Absent otherwise. */
637
696
  skinning?: SkinningEcho | null;
697
+ /** A comparison's `overBound`, echoed when the input set it (issue #1315); absent otherwise. */
698
+ overBound?: OverBoundRequest;
638
699
  }
639
700
 
640
701
  export interface MeshCounts {
@@ -2436,6 +2497,7 @@ function effectiveJson(e: EffectiveSettings): Json {
2436
2497
  ...(e.retriangulate === undefined ? {} : { retriangulate: e.retriangulate }),
2437
2498
  ...(e.removalOrder === undefined ? {} : { removalOrder: e.removalOrder }),
2438
2499
  ...(e.skinning === undefined ? {} : { skinning: skinningEchoJson(e.skinning) }),
2500
+ ...(e.overBound === undefined ? {} : { overBound: { maxSamples: e.overBound.maxSamples, triangles: e.overBound.triangles } }),
2439
2501
  };
2440
2502
  }
2441
2503
 
@@ -2557,6 +2619,7 @@ function candidateJson(c: CandidateReport): Json {
2557
2619
  accepted: c.accepted,
2558
2620
  };
2559
2621
  if (c.perFrame !== undefined) out.perFrame = c.perFrame.map((p) => ({ code: p.code, frame: p.frame, value: p.value }));
2622
+ if (c.overBound !== undefined) out.overBound = overBoundJson(c.overBound);
2560
2623
  if (c.changes !== undefined) {
2561
2624
  const k = c.changes;
2562
2625
  out.changes = {
@@ -2585,6 +2648,29 @@ function candidateJson(c: CandidateReport): Json {
2585
2648
  return out;
2586
2649
  }
2587
2650
 
2651
+ function overBoundJson(o: OverBoundReport | null): Json {
2652
+ if (o === null) return null;
2653
+ const boundJson = (b: { op: '<=' | '>='; value: number }): Json => ({ op: b.op, value: b.value });
2654
+ return {
2655
+ selectionFrames: o.selectionFrames,
2656
+ rows: o.rows.map((r) => ({
2657
+ code: r.code,
2658
+ attachment: attachmentJson(r.attachment),
2659
+ region: r.region,
2660
+ bound: boundJson(r.bound),
2661
+ total: r.total,
2662
+ samples: r.samples.map((x) => {
2663
+ const at: { [key: string]: Json } = {};
2664
+ if (x.at.pixel !== undefined) at.pixel = pair(x.at.pixel);
2665
+ if (x.at.vertex !== undefined) at.vertex = x.at.vertex;
2666
+ at.uv = pair(x.at.uv);
2667
+ return { at, frame: frameRefJson(x.frame), value: x.value, triangle: x.triangle, corners: [x.corners[0], x.corners[1], x.corners[2]] };
2668
+ }),
2669
+ })),
2670
+ triangles: o.triangles.map((t) => ({ code: t.code, attachment: attachmentJson(t.attachment), bound: boundJson(t.bound), values: t.values.map((v) => ({ triangle: v.triangle, value: v.value })) })),
2671
+ };
2672
+ }
2673
+
2588
2674
  const notReachedJson = (s: StopNotReached | undefined): { [key: string]: Json } =>
2589
2675
  s === undefined ? {} : { stopAfterAccepted: { requested: s.requested, acceptedSteps: s.acceptedSteps } };
2590
2676
 
package/src/meshreduce.ts CHANGED
@@ -559,18 +559,7 @@ interface Canonical {
559
559
  * 5. bindings strongest first, ties by the bone's position in `boneOrder`.
560
560
  */
561
561
  function canonicalise(work: Work, sourceTurn: number, boneRank: Map<string, number>): Canonical {
562
- const ids: number[] = [];
563
- for (let id = 0; id < work.alive.length; id++) if (work.alive[id]) ids.push(id);
564
- const compact = new Map<number, number>();
565
- ids.forEach((id, i) => compact.set(id, i));
566
- const flat: number[] = [];
567
- for (const tri of work.triangles) for (const id of tri) flat.push(compact.get(id)!);
568
- const outline = traceOutline(ids.length, flat);
569
- let walk = outline.walk.map((i) => ids[i]);
570
- const start = walk.indexOf(Math.min(...walk));
571
- walk = [...walk.slice(start), ...walk.slice(0, start)];
572
- const turn = Math.sign(signedArea(walk.map((id) => [work.pos[id][0], work.pos[id][1]])));
573
- if (turn !== sourceTurn) walk = [walk[0], ...walk.slice(1).reverse()];
562
+ const { ids, walk } = canonicalWalk(work, sourceTurn);
574
563
  const onHull = new Set(walk);
575
564
  const interiorSource = ids.filter((id) => !onHull.has(id) && id < work.nSource);
576
565
  const interiorInserted = ids
@@ -596,6 +585,34 @@ function canonicalise(work: Work, sourceTurn: number, boneRank: Map<string, numb
596
585
  return { mesh, order, indexOf };
597
586
  }
598
587
 
588
+ /**
589
+ * Step 1 of the canonical order alone: the surviving ids, and the outline walked from its smallest id the way the
590
+ * source's own hull listing turns — `canonicalise`'s hull, and the walk the early deviation floor (issue #1309) reads
591
+ * the current outline as. A `MeshError` when the triangles are not one closed outline.
592
+ */
593
+ function canonicalWalk(work: Work, sourceTurn: number): { ids: number[]; walk: number[] } {
594
+ const ids: number[] = [];
595
+ for (let id = 0; id < work.alive.length; id++) if (work.alive[id]) ids.push(id);
596
+ const compact = new Map<number, number>();
597
+ ids.forEach((id, i) => compact.set(id, i));
598
+ const flat: number[] = [];
599
+ for (const tri of work.triangles) for (const id of tri) flat.push(compact.get(id)!);
600
+ const outline = traceOutline(ids.length, flat);
601
+ return { ids, walk: turnedFromSmallest(work, outline.walk.map((i) => ids[i]), sourceTurn) };
602
+ }
603
+
604
+ /**
605
+ * A closed walk of working ids rotated to start at its smallest id and, when its signed area does not turn the way
606
+ * `sourceTurn` does, walked the other way from that id — the one rule `canonicalise` orders its hull by, and the one
607
+ * the early deviation floor turns its predicted outline by (issue #1309), so the two read one polygon.
608
+ */
609
+ function turnedFromSmallest(work: Work, loop: readonly number[], sourceTurn: number): number[] {
610
+ const start = loop.indexOf(Math.min(...loop));
611
+ const walk = [...loop.slice(start), ...loop.slice(0, start)];
612
+ const turn = Math.sign(signedArea(walk.map((id) => [work.pos[id][0], work.pos[id][1]])));
613
+ return turn !== sourceTurn ? [walk[0], ...walk.slice(1).reverse()] : walk;
614
+ }
615
+
599
616
  /** Strongest first, ties by the bone's place in `boneOrder` — the values themselves untouched. */
600
617
  function sortBindings(list: readonly Binding[], boneRank: Map<string, number>): Binding[] {
601
618
  return list.map((b) => ({ bone: b.bone, weight: b.weight })).sort((p, q) => q.weight - p.weight || (boneRank.get(p.bone) ?? 0) - (boneRank.get(q.bone) ?? 0));
@@ -919,6 +936,10 @@ interface Run {
919
936
  sourceEdges: Set<number>;
920
937
  /** Issue #1307's audit of the edge sets, or null. */
921
938
  edgeAudit: EdgeSetAudit | null;
939
+ /** Issue #1309's audit of the early deviation floor against the late one, or null. */
940
+ floorAudit: FloorAudit | null;
941
+ /** The current outline's canonical walk, for the working triangles it was read off (issue #1309); null until read. */
942
+ walk: { triangles: Array<[number, number, number]>; walk: number[] } | null;
922
943
  /** The art's rasters, taken once for the call and read by every measurement in it (issue #1240). */
923
944
  rasters: ArtRasters;
924
945
  /** The step state each removal and insertion is measured through, carried from the last measurement (issue #1246); null measures every step in full. */
@@ -1599,7 +1620,20 @@ function runRemovalOf(work: Work, vertices: readonly number[], audit: EdgeSetAud
1599
1620
  * measurement — a thunk that measures the attempt in full and returns the name the measurement gives, so whatever
1600
1621
  * reads it reads what an unfloored run would have written.
1601
1622
  */
1602
- type Refusal = string | (() => string);
1623
+ type Refusal = string | FloorRefusal;
1624
+
1625
+ /**
1626
+ * A refusal the deviation floor decided (issue #1279), read since issue #1309 before any structure is built for the
1627
+ * attempt. Reading it names the attempt as the full path does — its structural reason when the structure refuses
1628
+ * it, else the rows' — in the state it was tried in. `decidedByFloor` is what the observer reads: whether the path
1629
+ * before #1309, which read the floor after every structural check, would have been decided by it — so an attempt
1630
+ * the structure refuses reads false, as it did. It is computed only when an observer or the floor audit is attached;
1631
+ * with neither it reads true and nothing reads it.
1632
+ */
1633
+ interface FloorRefusal {
1634
+ (): string;
1635
+ decidedByFloor: boolean;
1636
+ }
1603
1637
 
1604
1638
  function refusalText(refusal: Refusal): string {
1605
1639
  return typeof refusal === 'string' ? refusal : refusal();
@@ -1659,7 +1693,7 @@ function removeVertices(run: Run): PhaseEnd {
1659
1693
  run.steps++;
1660
1694
  tried++;
1661
1695
  const block = tryOperation(run, members, false);
1662
- if (run.observe !== null) run.observe({ step: run.steps, kind: 'boundary-run', sourceVertices: [...members], refusedBy: block === null ? null : () => refusalText(block), decidedByFloor: typeof block === 'function', pass, predictedLoad: null });
1696
+ if (run.observe !== null) run.observe({ step: run.steps, kind: 'boundary-run', sourceVertices: [...members], refusedBy: block === null ? null : () => refusalText(block), decidedByFloor: typeof block === 'function' && block.decidedByFloor, pass, predictedLoad: null });
1663
1697
  if (block === null) {
1664
1698
  taken++;
1665
1699
  walk = null;
@@ -1681,7 +1715,7 @@ function removeVertices(run: Run): PhaseEnd {
1681
1715
  run.steps++;
1682
1716
  tried++;
1683
1717
  const block = tryOperation(run, [v], false);
1684
- if (run.observe !== null) run.observe({ step: run.steps, kind: 'removal', sourceVertices: [v], refusedBy: block === null ? null : () => refusalText(block), decidedByFloor: typeof block === 'function', pass, predictedLoad: ranked === null ? null : ranked.load.get(v)! });
1718
+ if (run.observe !== null) run.observe({ step: run.steps, kind: 'removal', sourceVertices: [v], refusedBy: block === null ? null : () => refusalText(block), decidedByFloor: typeof block === 'function' && block.decidedByFloor, pass, predictedLoad: ranked === null ? null : ranked.load.get(v)! });
1685
1719
  if (block === null) {
1686
1720
  taken++;
1687
1721
  if (accept(run, 'removal', [v])) return { kind: 'replayed' };
@@ -1699,6 +1733,34 @@ function removeVertices(run: Run): PhaseEnd {
1699
1733
  }
1700
1734
  }
1701
1735
 
1736
+ /**
1737
+ * Issue #1309: the current outline's canonical walk (`canonicalWalk`), read once per working mesh — the triangles are
1738
+ * replaced, never edited in place, whenever the mesh changes, so the array read off is what the cache is keyed by.
1739
+ */
1740
+ function currentWalk(run: Run): number[] {
1741
+ if (run.walk === null || run.walk.triangles !== run.work.triangles) run.walk = { triangles: run.work.triangles, walk: canonicalWalk(run.work, run.sourceTurn).walk };
1742
+ return run.walk.walk;
1743
+ }
1744
+
1745
+ /**
1746
+ * The deviation floor read before any structure is built for the attempt (issue #1309): `deviationFloor` over the
1747
+ * outline the candidate would have — the current walk less the removed vertices, turned as `canonicalise` turns it
1748
+ * (`turnedFromSmallest`) — rather than over the canonical candidate. Whenever the structure passes, the candidate's
1749
+ * outline is that walk, so the two are the same polygon in the same order and the same number; when it does not, the
1750
+ * attempt is refused whatever the floor reads, and the reader is shown the structure's reason (`floorRefusal`). 0, the
1751
+ * floor of nothing, when no removed vertex is a source-hull vertex (the walk is then not read) or when fewer than three
1752
+ * would remain, which no passing structure leaves. `turned` reports whether the walk was walked the other way.
1753
+ */
1754
+ function earlyFloor(run: Run, vertices: readonly number[]): { floor: number; turned: boolean } {
1755
+ const hull = run.input.source.hull;
1756
+ if (!vertices.some((v) => v < hull)) return { floor: 0, turned: false };
1757
+ const rest = currentWalk(run).filter((id) => !vertices.includes(id));
1758
+ if (rest.length < 3) return { floor: 0, turned: false };
1759
+ const outline = turnedFromSmallest(run.work, rest, run.sourceTurn);
1760
+ const turned = outline.length > 2 && outline[1] !== rest[(rest.indexOf(outline[0]) + 1) % rest.length];
1761
+ return { floor: floorOver(run, outline.map((id) => run.work.pos[id]), vertices), turned };
1762
+ }
1763
+
1702
1764
  /**
1703
1765
  * The deviation floor (issue #1279): the furthest any removed source-hull vertex lies from the candidate's outline.
1704
1766
  * `MQ_BOUNDARY_DEVIATION` is the symmetric Hausdorff distance between that outline and the source hull, and its
@@ -1707,7 +1769,11 @@ function removeVertices(run: Run): PhaseEnd {
1707
1769
  * `distanceToSegment` over the candidate's hull edges, the function and polygon the row reads.
1708
1770
  */
1709
1771
  function deviationFloor(run: Run, mesh: SourceMesh, vertices: readonly number[]): number {
1710
- const poly = mesh.points.slice(0, mesh.hull);
1772
+ return floorOver(run, mesh.points.slice(0, mesh.hull), vertices);
1773
+ }
1774
+
1775
+ /** `deviationFloor` over an outline given as its points in walk order — the one loop both floors read. */
1776
+ function floorOver(run: Run, poly: readonly Pt[], vertices: readonly number[]): number {
1711
1777
  let floor = 0;
1712
1778
  for (const v of vertices) {
1713
1779
  if (v >= run.input.source.hull) continue;
@@ -1748,12 +1814,37 @@ function auditEdgeSet(audit: EdgeSetAudit, edges: Set<number>, triangles: Readon
1748
1814
  }
1749
1815
 
1750
1816
  /**
1751
- * One operation — a single removal, or a boundary run of two or more — tried: null when it was taken (the working
1752
- * mesh then holds it), else what refused it (the working mesh as it was). `full` measures every candidate that
1753
- * reaches the rows; otherwise a candidate whose deviation floor is over the bound by `FLOOR_MARGIN` is refused
1754
- * without the measurement, by a thunk that measures it when its name is read.
1817
+ * Issue #1309's audit, for the `mesh-compare` suite's control and the population run: on every attempt the floor is
1818
+ * read on, the path before the issue is run beside the early floor — every structural check, then the floor read off
1819
+ * the canonical candidate, decided at `FLOOR_MARGIN` — and compared. Where the structure passes, the two floors are
1820
+ * held equal bit for bit and their decisions equal; where the early floor refuses and the structure would have refused
1821
+ * first, the name a reader of the refusal is shown is held to the structure's, and the observer's `decidedByFloor` to
1822
+ * false. A difference is recorded, not thrown. `plant` is the fault applied to the early refusal: its name read off
1823
+ * the floor (`MQ_BOUNDARY_DEVIATION`), never reaching the structural check that would have refused first. With the
1824
+ * audit and no plant, the call's result is the call's without it.
1755
1825
  */
1756
- function tryOperation(run: Run, vertices: readonly number[], full: boolean): Refusal | null {
1826
+ export interface FloorAudit {
1827
+ plant?: 'refusal-named-by-the-floor';
1828
+ /** Attempts the floor was read on. */
1829
+ attempts: number;
1830
+ /** Among them, those whose structure passes — where the early floor and the late one are compared. */
1831
+ compared: number;
1832
+ /** Among those, the ones whose predicted outline `canonicalise` walks the other way from the current walk. */
1833
+ turned: number;
1834
+ /** Attempts the early floor refused, and among them those whose structure refuses them first. */
1835
+ refused: number;
1836
+ refusedStructurally: number;
1837
+ /** Each difference found, named. */
1838
+ differences: string[];
1839
+ }
1840
+
1841
+ /**
1842
+ * The structure of one operation, as every attempt builds it: the removal or run made (or its structural reason),
1843
+ * condition (b) over the edges it adds, the working mesh set to it, the protected edges looked up, and the canonical
1844
+ * candidate. Null-free: either the reason the structure refuses the operation — the working mesh as it was — or the
1845
+ * canonical candidate with the working mesh holding it and the `undo` that restores it.
1846
+ */
1847
+ function structureOf(run: Run, vertices: readonly number[]): string | { canon: Canonical; undo: () => void } {
1757
1848
  const { work, input } = run;
1758
1849
  const step = vertices.length === 1 ? removalOf(work, vertices[0], run.edgeAudit) : runRemovalOf(work, vertices, run.edgeAudit);
1759
1850
  if ('blocked' in step) return step.blocked;
@@ -1785,35 +1876,103 @@ function tryOperation(run: Run, vertices: readonly number[], full: boolean): Ref
1785
1876
  }
1786
1877
  }
1787
1878
  }
1788
- let canon: Canonical;
1789
1879
  try {
1790
- canon = canonicalise(work, run.sourceTurn, run.boneRank);
1880
+ return { canon: canonicalise(work, run.sourceTurn, run.boneRank), undo };
1791
1881
  } catch (err) {
1792
1882
  if (!(err instanceof MeshError)) throw err;
1793
1883
  undo();
1794
1884
  return `outline: ${err.message}`;
1795
1885
  }
1886
+ }
1887
+
1888
+ /**
1889
+ * The refusal the early floor makes (issue #1309), through #1279's thunk: read, it measures the attempt in full in the
1890
+ * state it was tried in and returns that path's name — the structure's reason when the structure refuses it, else the
1891
+ * rows'. `late`, when an observer or the floor audit asked for it, is what the structure and the floor read off the
1892
+ * canonical candidate gave: it decides `decidedByFloor` as the path before the issue decided it, and — with an
1893
+ * observer and no floor audit — its structural reason is the name returned, built once rather than twice (the edge
1894
+ * audit counts every list it builds). Under the floor audit the thunk always runs the full path, which is what the
1895
+ * audit holds equal to the structure's reason.
1896
+ */
1897
+ function floorRefusal(run: Run, vertices: readonly number[], floor: number, limit: number, late: { structural: string } | { floor: number } | null): FloorRefusal {
1898
+ const members = [...vertices];
1899
+ const said = late !== null && 'structural' in late && run.floorAudit === null ? late.structural : null;
1900
+ const refusal = (() => {
1901
+ if (run.floorAudit?.plant === 'refusal-named-by-the-floor') return `MQ_BOUNDARY_DEVIATION: the deviation floor ${r6(floor)} is over ${run.input.targets.maxBoundaryDeviation}`;
1902
+ if (said !== null) return said;
1903
+ const measured = tryOperation(run, members, true);
1904
+ if (measured === null) {
1905
+ // Unreachable while the floor is a floor: the step was refused on a bound its own rows then pass. Loud,
1906
+ // because the run already went on as if it had been refused.
1907
+ throw new Error(`the deviation floor refused removing ${members.join(', ')}, which the rows accept`);
1908
+ }
1909
+ return refusalText(measured);
1910
+ }) as FloorRefusal;
1911
+ refusal.decidedByFloor = late === null || ('floor' in late && late.floor > limit);
1912
+ return refusal;
1913
+ }
1914
+
1915
+ /** The path before issue #1309 for one attempt, read beside the early floor: the structure's reason, or the floor read off the canonical candidate. The working mesh is left as it was. */
1916
+ function lateOf(run: Run, vertices: readonly number[]): { structural: string } | { floor: number } {
1917
+ const built = structureOf(run, vertices);
1918
+ if (typeof built === 'string') return { structural: built };
1919
+ const floor = deviationFloor(run, built.canon.mesh, vertices);
1920
+ built.undo();
1921
+ return { floor };
1922
+ }
1923
+
1924
+ /** The floor audit's comparison where the structure passes: the two floors bit for bit, and the decision at `FLOOR_MARGIN`. */
1925
+ function auditFloors(audit: FloorAudit, run: Run, vertices: readonly number[], early: { floor: number; turned: boolean }, late: number, refusedEarly: boolean): void {
1926
+ audit.compared++;
1927
+ if (early.turned) audit.turned++;
1928
+ const lateRefuses = late > run.input.targets.maxBoundaryDeviation + FLOOR_MARGIN;
1929
+ if (!Object.is(early.floor, late)) audit.differences.push(`removing ${vertices.join(', ')}: the early floor reads ${early.floor} and the floor off the canonical candidate ${late}`);
1930
+ else if (refusedEarly !== lateRefuses) audit.differences.push(`removing ${vertices.join(', ')}: the early floor ${refusedEarly ? 'refused' : 'passed'} at ${early.floor}, which the floor off the canonical candidate ${lateRefuses ? 'refuses' : 'passes'} at the bound ${run.input.targets.maxBoundaryDeviation} + FLOOR_MARGIN`);
1931
+ }
1932
+
1933
+ /**
1934
+ * One operation — a single removal, or a boundary run of two or more — tried: null when it was taken (the working
1935
+ * mesh then holds it), else what refused it (the working mesh as it was). `full` measures every candidate that
1936
+ * reaches the rows; otherwise a candidate whose deviation floor is over the bound by `FLOOR_MARGIN` is refused
1937
+ * without the measurement, by a thunk that measures it when its name is read. Since issue #1309 the floor is read
1938
+ * before the structure is built (`earlyFloor`); an attempt it refuses is not built at all unless an observer or the
1939
+ * floor audit asks what the path before would have decided.
1940
+ */
1941
+ function tryOperation(run: Run, vertices: readonly number[], full: boolean): Refusal | null {
1942
+ const { input } = run;
1943
+ const floored = !full && run.plant !== 'measure-every-candidate' && !(vertices.length > 1 && run.plant === 'run-skips-rows');
1944
+ const audit = run.floorAudit;
1945
+ let early: { floor: number; turned: boolean } | null = null;
1946
+ if (floored) {
1947
+ const limit = input.targets.maxBoundaryDeviation + (run.plant === 'floor-half-a-pixel-short' ? -0.5 : FLOOR_MARGIN);
1948
+ early = earlyFloor(run, vertices);
1949
+ if (audit !== null) audit.attempts++;
1950
+ if (early.floor > limit) {
1951
+ const late = run.observe !== null || audit !== null ? lateOf(run, vertices) : null;
1952
+ const refusal = floorRefusal(run, vertices, early.floor, limit, late);
1953
+ if (audit !== null && late !== null) {
1954
+ audit.refused++;
1955
+ if ('structural' in late) {
1956
+ audit.refusedStructurally++;
1957
+ const said = refusal();
1958
+ if (said !== late.structural) audit.differences.push(`removing ${vertices.join(', ')}: the early refusal reads "${said.slice(0, 80)}" where the structure refuses first with "${late.structural.slice(0, 80)}"`);
1959
+ if (refusal.decidedByFloor) audit.differences.push(`removing ${vertices.join(', ')}: refused by the structure first, yet the observer would read decidedByFloor`);
1960
+ } else {
1961
+ auditFloors(audit, run, vertices, early, late.floor, true);
1962
+ }
1963
+ }
1964
+ return refusal;
1965
+ }
1966
+ }
1967
+ const built = structureOf(run, vertices);
1968
+ if (typeof built === 'string') return built;
1969
+ const { canon, undo } = built;
1970
+ if (audit !== null && early !== null) auditFloors(audit, run, vertices, early, deviationFloor(run, canon.mesh, vertices), false);
1796
1971
  const phase: SkinningInspection['phase'] = vertices.length > 1 ? 'boundary-run' : 'removal';
1797
1972
  if (vertices.length > 1 && run.plant === 'run-skips-rows') {
1798
1973
  followSkinning(run, phase);
1799
1974
  return null;
1800
1975
  }
1801
- if (!full && run.plant !== 'measure-every-candidate') {
1802
- const margin = run.plant === 'floor-half-a-pixel-short' ? -0.5 : FLOOR_MARGIN;
1803
- if (deviationFloor(run, canon.mesh, vertices) > input.targets.maxBoundaryDeviation + margin) {
1804
- undo();
1805
- const members = [...vertices];
1806
- return () => {
1807
- const measured = tryOperation(run, members, true);
1808
- if (measured === null) {
1809
- // Unreachable while the floor is a floor: the step was refused on a bound its own rows then pass. Loud,
1810
- // because the run already went on as if it had been refused.
1811
- throw new Error(`the deviation floor refused removing ${members.join(', ')}, which the rows accept`);
1812
- }
1813
- return refusalText(measured);
1814
- };
1815
- }
1816
- }
1817
1976
  const measured = measureAgainstTargets(run, canon.mesh, 'candidate');
1818
1977
  const blocking =
1819
1978
  firstBlockingRow(measured, run.input.targets.artFit) ??
@@ -2085,12 +2244,13 @@ export function reduceMeshWith(
2085
2244
  observe: AttemptObserver | null = null,
2086
2245
  skinning: SkinningHooks | null = null,
2087
2246
  edgeAudit: EdgeSetAudit | null = null,
2247
+ floorAudit: FloorAudit | null = null,
2088
2248
  ): MeshReductionResult {
2089
2249
  validateReduction(input, plant);
2090
2250
  if (steps !== null && steps.rasters !== rasters) {
2091
2251
  refuse('REDUCE_ART_RASTERS_MISMATCH', `attachment ${nameOf(input.attachment)}: the step rasters were made over another rasters object; required step rasters made over the rasters passed beside them (stepRastersOf(rasters))`);
2092
2252
  }
2093
- return reduceValidated(input, rasters, steps, plant, observe, skinning, edgeAudit);
2253
+ return reduceValidated(input, rasters, steps, plant, observe, skinning, edgeAudit, floorAudit);
2094
2254
  }
2095
2255
 
2096
2256
  /**
@@ -2105,6 +2265,7 @@ function reduceValidated(
2105
2265
  observe: AttemptObserver | null = null,
2106
2266
  hooks: SkinningHooks | null = null,
2107
2267
  edgeAudit: EdgeSetAudit | null = null,
2268
+ floorAudit: FloorAudit | null = null,
2108
2269
  ): MeshReductionResult {
2109
2270
  const who = `attachment ${nameOf(input.attachment)}`;
2110
2271
  const src = input.source;
@@ -2194,6 +2355,8 @@ function reduceValidated(
2194
2355
  ...protectionOf(input, work, sourceEdgeNames),
2195
2356
  sourceEdges,
2196
2357
  edgeAudit,
2358
+ floorAudit,
2359
+ walk: null,
2197
2360
  rasters,
2198
2361
  stepRasters: steps,
2199
2362
  skinning: null,