rig-c 2.31.2 → 2.32.1

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.2",
3
+ "version": "2.32.1",
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
@@ -160,6 +160,8 @@ import {
160
160
  type Termination,
161
161
  } from './meshquality.ts';
162
162
  import { artRastersOf, stepRastersOf, type ArtRasters, type StepRasters } from './meshrasters.ts';
163
+ import { areaBand, triangleAreas } from './areaband.ts';
164
+ import { cropToSpineY } from './transform.ts';
163
165
  import {
164
166
  skinningContract,
165
167
  SkinningCarry,
@@ -1262,43 +1264,154 @@ function addVertex(run: Run, p: Pt): number {
1262
1264
  return id;
1263
1265
  }
1264
1266
 
1267
+ /**
1268
+ * The two triangles a split of the edge a–b at the new vertex `m` makes of `tri`, winding kept, or null when `tri`
1269
+ * does not have that edge — the one rule `splitEdge` writes and `degenerateInsertion` reads (issue #1311).
1270
+ */
1271
+ function splitPieces(tri: readonly [number, number, number], a: number, b: number, m: number): [[number, number, number], [number, number, number]] | null {
1272
+ const i = tri.indexOf(a);
1273
+ const j = tri.indexOf(b);
1274
+ if (i === -1 || j === -1) return null;
1275
+ // Rotate so the edge is the triangle's first two corners, in its own winding.
1276
+ const r = (j - i + 3) % 3 === 1 ? i : j;
1277
+ const x = tri[r];
1278
+ const y = tri[(r + 1) % 3];
1279
+ const c = tri[(r + 2) % 3];
1280
+ return [[x, m, c], [m, y, c]];
1281
+ }
1282
+
1265
1283
  /** Split the edge between working ids a and b at `p`: each triangle on it becomes two, winding kept. */
1266
1284
  function splitEdge(run: Run, a: number, b: number, p: Pt): void {
1267
1285
  const m = addVertex(run, p);
1268
1286
  const next: Array<[number, number, number]> = [];
1269
1287
  for (const tri of run.work.triangles) {
1270
- const i = tri.indexOf(a);
1271
- const j = tri.indexOf(b);
1272
- if (i === -1 || j === -1) {
1273
- next.push(tri);
1274
- continue;
1275
- }
1276
- // Rotate so the edge is the triangle's first two corners, in its own winding.
1277
- const r = (j - i + 3) % 3 === 1 ? i : j;
1278
- const x = tri[r];
1279
- const y = tri[(r + 1) % 3];
1280
- const c = tri[(r + 2) % 3];
1281
- next.push([x, m, c], [m, y, c]);
1288
+ const pieces = splitPieces(tri, a, b, m);
1289
+ if (pieces === null) next.push(tri);
1290
+ else next.push(...pieces);
1282
1291
  }
1283
1292
  run.work.triangles = next;
1284
1293
  }
1285
1294
 
1286
- /** Insert `p` into the working triangle that holds it: on an edge, that edge is split; inside, the triangle becomes three. */
1287
- function insertPoint(run: Run, p: Pt): boolean {
1295
+ /** Where `insertPoint` puts `p`: on an edge of the working triangle that holds it, inside that triangle, or nowhere. */
1296
+ type Placement = { kind: 'edge'; a: number; b: number } | { kind: 'inside'; tri: [number, number, number] } | null;
1297
+
1298
+ function placementOf(run: Run, p: Pt): Placement {
1288
1299
  const { work } = run;
1289
1300
  for (const tri of work.triangles) {
1290
1301
  const l = barycentric(p, work.pos[tri[0]], work.pos[tri[1]], work.pos[tri[2]]);
1291
1302
  if (l === null || Math.min(...l) < -1e-9) continue;
1292
1303
  const onEdge = l.findIndex((v) => Math.abs(v) <= 1e-9);
1293
- if (onEdge !== -1) {
1294
- splitEdge(run, tri[(onEdge + 1) % 3], tri[(onEdge + 2) % 3], p);
1295
- return true;
1296
- }
1297
- const m = addVertex(run, p);
1298
- work.triangles = work.triangles.flatMap((t): Array<[number, number, number]> => (t === tri ? [[tri[0], tri[1], m], [tri[1], tri[2], m], [tri[2], tri[0], m]] : [t]));
1299
- return true;
1304
+ if (onEdge !== -1) return { kind: 'edge', a: tri[(onEdge + 1) % 3], b: tri[(onEdge + 2) % 3] };
1305
+ return { kind: 'inside', tri };
1300
1306
  }
1301
- return false;
1307
+ return null;
1308
+ }
1309
+
1310
+ /** The three triangles an insertion of the new vertex `m` inside `tri` makes of it — `insertPoint`'s rule. */
1311
+ function fanPieces(tri: readonly [number, number, number], m: number): Array<[number, number, number]> {
1312
+ return [[tri[0], tri[1], m], [tri[1], tri[2], m], [tri[2], tri[0], m]];
1313
+ }
1314
+
1315
+ /** Insert `p` where `placementOf` put it: on an edge, that edge is split; inside, the triangle becomes three. */
1316
+ function insertPoint(run: Run, p: Pt, at: Exclude<Placement, null>): void {
1317
+ const { work } = run;
1318
+ if (at.kind === 'edge') {
1319
+ splitEdge(run, at.a, at.b, p);
1320
+ return;
1321
+ }
1322
+ const m = addVertex(run, p);
1323
+ work.triangles = work.triangles.flatMap((t): Array<[number, number, number]> => (t === at.tri ? fanPieces(at.tri, m) : [t]));
1324
+ }
1325
+
1326
+ /** The vertex id the degenerate check reads for the vertex an insertion would add, before it exists. */
1327
+ const NEW_VERTEX = -1;
1328
+
1329
+ /** A triangle an insertion would write that `MQ_DEGENERATE` reads as degenerate (issue #1311). */
1330
+ interface DegenerateWrite {
1331
+ /** Working ids, `NEW_VERTEX` for the inserted one, in the triangle's own winding. */
1332
+ corners: [number, number, number];
1333
+ /** The smallest magnitude of its area over the three corners it can be read from, px². */
1334
+ area: number;
1335
+ /** `areaBand` of the mesh the insertion would leave, px². */
1336
+ band: number;
1337
+ }
1338
+
1339
+ /**
1340
+ * Issue #1311: the first triangle an insertion of `p` would write whose area `MQ_DEGENERATE` reads as degenerate, or
1341
+ * null. Read as the row reads it, over the mesh the insertion would leave: every point in the art frame's world (y up,
1342
+ * `cropToSpineY` on the frame's height), every triangle's area by `triangleAreas`, and the band by `areaBand` over all of
1343
+ * them — the larger of 1e-6 of the largest triangle and the float32 noise bound of the coordinates. The row reads a
1344
+ * triangle from its smallest canonical index, which is not known before the mesh is canonicalised, so a fresh triangle
1345
+ * is read from each of its three corners and the smallest magnitude is the one compared: a triangle that any reading
1346
+ * puts within the band is refused. `fresh` uses `NEW_VERTEX` for the inserted vertex; `replaced` are the triangles it
1347
+ * takes the place of.
1348
+ */
1349
+ function degenerateInsertion(run: Run, p: Pt, replaced: ReadonlySet<readonly number[]>, fresh: ReadonlyArray<[number, number, number]>): DegenerateWrite | null {
1350
+ const { work } = run;
1351
+ const height = run.input.art.frame.height;
1352
+ const slot = new Map<number, number>();
1353
+ const world: number[] = [];
1354
+ for (let id = 0; id < work.pos.length; id++) {
1355
+ if (!work.alive[id]) continue;
1356
+ slot.set(id, world.length / 2);
1357
+ world.push(work.pos[id][0], cropToSpineY(work.pos[id][1], height));
1358
+ }
1359
+ slot.set(NEW_VERTEX, world.length / 2);
1360
+ world.push(p[0], cropToSpineY(p[1], height));
1361
+ const flat: number[] = [];
1362
+ for (const tri of work.triangles) if (!replaced.has(tri)) for (const id of tri) flat.push(slot.get(id)!);
1363
+ for (const tri of fresh) for (const id of tri) flat.push(slot.get(id)!);
1364
+ const band = areaBand(triangleAreas(world, flat), world) * (run.plant === 'degenerate-band-widened' ? 1000 : 1);
1365
+ for (const tri of fresh) {
1366
+ const [i, j, k] = tri.map((id) => slot.get(id)!);
1367
+ const area = Math.min(...triangleAreas(world, [i, j, k, j, k, i, k, i, j]).map(Math.abs));
1368
+ if (area <= band) return { corners: tri, area, band };
1369
+ }
1370
+ return null;
1371
+ }
1372
+
1373
+ /**
1374
+ * Issue #1311: the refinement's stop when the insertion its rule chose would write a triangle `MQ_DEGENERATE` refuses —
1375
+ * by name: the target row, the region, the insertion, the triangle's corners with their distance from the region's
1376
+ * polygon, its area and the band required. The refinement writes no triangle the row refuses: a refined source that
1377
+ * failed that row could take no step at all, and the failure would name a triangle rather than the insertion that made
1378
+ * it.
1379
+ */
1380
+ function degenerateStop(run: Run, canon: Canonical, target: MeasureRow, region: RefinementRegion, what: string, p: Pt, found: DegenerateWrite): PhaseEnd {
1381
+ const corner = (id: number): string => {
1382
+ const at = id === NEW_VERTEX ? p : run.work.pos[id];
1383
+ const name = id === NEW_VERTEX ? 'the inserted vertex' : `vertex ${canon.indexOf.get(id)!}`;
1384
+ const where = inClosedPolygon(at, region.polygon) ? 'in the polygon' : `${r6(distanceToBoundary(at, region.polygon))} px from the polygon`;
1385
+ return `${name} (${at[0]}, ${at[1]}), ${where}`;
1386
+ };
1387
+ return {
1388
+ kind: 'stuck',
1389
+ constraint:
1390
+ `${rowName(target)} (${what} at (${p[0]}, ${p[1]}) would write the triangle of ${found.corners.map(corner).join('; ')} — ` +
1391
+ `area ${r6(found.area)} px², which MQ_DEGENERATE reads as degenerate: required an area above the row's band ${r6(found.band)} px² ` +
1392
+ `(the larger of the float32 noise bound of the mesh's coordinates and 1e-6 of its largest triangle); ` +
1393
+ `region "${region.name}" has a ${region.transition} px band, and the refinement writes no triangle the row refuses)`,
1394
+ };
1395
+ }
1396
+
1397
+ /** The triangles a split of a–b replaces, and what it writes in their place with `NEW_VERTEX` inserted. */
1398
+ function splitPlan(run: Run, a: number, b: number): { replaced: Set<readonly number[]>; fresh: Array<[number, number, number]> } {
1399
+ const replaced = new Set<readonly number[]>();
1400
+ const fresh: Array<[number, number, number]> = [];
1401
+ for (const tri of run.work.triangles) {
1402
+ const pieces = splitPieces(tri, a, b, NEW_VERTEX);
1403
+ if (pieces === null) continue;
1404
+ replaced.add(tri);
1405
+ fresh.push(...pieces);
1406
+ }
1407
+ return { replaced, fresh };
1408
+ }
1409
+
1410
+ /** Issue #1311: what a split of a–b at `p` would write that the row refuses, or null — always null under the control's plant. */
1411
+ function degenerateSplit(run: Run, a: number, b: number, p: Pt): DegenerateWrite | null {
1412
+ if (run.plant === 'refinement-writes-degenerate') return null;
1413
+ const { replaced, fresh } = splitPlan(run, a, b);
1414
+ return degenerateInsertion(run, p, replaced, fresh);
1302
1415
  }
1303
1416
 
1304
1417
  type PhaseEnd =
@@ -1346,7 +1459,15 @@ export type ReductionPlant =
1346
1459
  | 'floor-half-a-pixel-short'
1347
1460
  | RetriangulationPlant
1348
1461
  | OrderPlant
1349
- | AmplitudePlant;
1462
+ | AmplitudePlant
1463
+ | RefinementPlant;
1464
+
1465
+ /**
1466
+ * Issue #1311's faults in the refinement: the insertion its rule chose written without asking whether it writes a
1467
+ * triangle `MQ_DEGENERATE` refuses — the refinement as it was before the check; and the check reading the row's band
1468
+ * a thousand times as wide, so it refuses insertions the row would pass.
1469
+ */
1470
+ export type RefinementPlant = 'refinement-writes-degenerate' | 'degenerate-band-widened';
1350
1471
 
1351
1472
  /**
1352
1473
  * Issue #1283's faults in the triangulation post-pass: the pass run on a call that did not opt in; the pass skipped
@@ -1400,7 +1521,9 @@ export type AttemptObserver = (attempt: AttemptRecord) => void;
1400
1521
  * `MQ_TRANSITION` that the refinement can act on passes. One insertion per
1401
1522
  * measurement: the first failing region row in report order has its worst edge
1402
1523
  * split. A band no edge lies in leaves `MQ_TRANSITION` not-measurable (B1's
1403
- * definition) and is not something an insertion is aimed at.
1524
+ * definition) and is not something an insertion is aimed at. An insertion that
1525
+ * would write a triangle `MQ_DEGENERATE` refuses is not made: the refinement
1526
+ * stops there by name (issue #1311, `degenerateStop`).
1404
1527
  */
1405
1528
  function refineRegions(run: Run): PhaseEnd {
1406
1529
  const regions = new Map(run.input.targets.regions.map((r) => [r.name, r]));
@@ -1416,7 +1539,16 @@ function refineRegions(run: Run): PhaseEnd {
1416
1539
  const region = regions.get(target.object.region!)!;
1417
1540
  if (target.state === 'not-measurable') {
1418
1541
  const p: Pt = [r6(region.polygon[0][0]), r6(region.polygon[0][1])];
1419
- if (!insertPoint(run, p)) return { kind: 'stuck', constraint: `${rowName(target)} (the refinement found no triangle holding the region's first vertex)` };
1542
+ const at = placementOf(run, p);
1543
+ if (at === null) return { kind: 'stuck', constraint: `${rowName(target)} (the refinement found no triangle holding the region's first vertex)` };
1544
+ const found =
1545
+ run.plant === 'refinement-writes-degenerate'
1546
+ ? null
1547
+ : at.kind === 'edge'
1548
+ ? degenerateSplit(run, at.a, at.b, p)
1549
+ : degenerateInsertion(run, p, new Set([at.tri]), fanPieces(at.tri, NEW_VERTEX));
1550
+ if (found !== null) return degenerateStop(run, canon, target, region, `inserting region "${region.name}"'s first vertex`, p, found);
1551
+ insertPoint(run, p, at);
1420
1552
  followSkinning(run, 'insertion');
1421
1553
  if (accept(run, 'insertion', [])) return { kind: 'replayed' };
1422
1554
  continue;
@@ -1432,6 +1564,8 @@ function refineRegions(run: Run): PhaseEnd {
1432
1564
  if (inRegionOrBand(run.work.pos[outer], region)) continue;
1433
1565
  const q = outerSplit(run.work.pos[inner], run.work.pos[outer], region);
1434
1566
  if (q === null) continue;
1567
+ const found = degenerateSplit(run, a, b, q);
1568
+ if (found !== null) return degenerateStop(run, canon, target, region, `splitting edge ${ra}–${rb} where it leaves region "${region.name}"'s band`, q, found);
1435
1569
  splitEdge(run, a, b, q);
1436
1570
  followSkinning(run, 'insertion');
1437
1571
  exited = true;
@@ -1462,6 +1596,8 @@ function refineRegions(run: Run): PhaseEnd {
1462
1596
  }
1463
1597
  const p = splitPoint(run.work.pos[a], run.work.pos[b], region);
1464
1598
  if (p === null) return { kind: 'stuck', constraint: `${rowName(target)} (the refinement found no point of edge ${ra}–${rb} strictly between its ends inside region "${region.name}" or its band)` };
1599
+ const found = degenerateSplit(run, a, b, p);
1600
+ if (found !== null) return degenerateStop(run, canon, target, region, `splitting edge ${ra}–${rb} inside region "${region.name}" or its band`, p, found);
1465
1601
  splitEdge(run, a, b, p);
1466
1602
  followSkinning(run, 'insertion');
1467
1603
  if (accept(run, 'insertion', [])) return { kind: 'replayed' };