rig-c 2.24.0 → 2.25.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.24.0",
3
+ "version": "2.25.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": {
package/src/mesh.ts CHANGED
@@ -2036,7 +2036,7 @@ export function meshEdges(vertexCount: number, triangles: readonly number[], hul
2036
2036
  // that used to re-implement distance weighting outside the compiler states the
2037
2037
  // segment list and nothing else.
2038
2038
  //
2039
- // The algorithm is a port of `spine-parts`' lattice mesh and segment weights
2039
+ // The algorithm is a port of `rig-parts`' lattice mesh and segment weights
2040
2040
  // (MIT, same owner). What it does, in order:
2041
2041
  //
2042
2042
  // 1. **Cells.** A square lattice of `cell`-pixel squares over the part; the
@@ -236,13 +236,42 @@ export interface MeshReductionInput {
236
236
  /** Every linked mesh of the source (they inherit the new topology); empty when none. */
237
237
  linkedMeshes: AttachmentRef[];
238
238
  /**
239
- * Replay (issue #1268, §7 mechanism 2): stop after the n-th accepted step — a refinement insertion or a removal
240
- * taken, counted as `ReductionChanges.acceptedAt` counts them — and return the mesh the same call without this field
241
- * had at that step, byte for byte, terminated `replayed-to-accepted-step`. Left out = today's behaviour. A whole
242
- * number, 0 or more: 0 takes no step. A number the run never reaches leaves the run to end as it would have, and
243
- * that termination says so (`stopAfterAccepted` on it).
239
+ * Replay (issue #1268, §7 mechanism 2): stop after the n-th accepted operation — a refinement insertion, a removal
240
+ * or a boundary run taken, one each, counted as `ReductionChanges.acceptedAt` counts them (issue #1279) — and return
241
+ * the mesh the same call without this field had after it, byte for byte, terminated `replayed-to-accepted-step`.
242
+ * Left out = today's behaviour. A whole number, 0 or more: 0 takes no step. A number the run never reaches leaves
243
+ * the run to end as it would have, and that termination says so (`stopAfterAccepted` on it).
244
244
  */
245
245
  stopAfterAccepted?: number;
246
+ /**
247
+ * Boundary runs taken as steps (issue #1279, §8 option (iv)) — opt-in. Left out = no run is tried, and the call is
248
+ * the one it was before the field existed. Set, each removal pass first tries to replace a run of 2 to
249
+ * `maxVertices` consecutive surviving source-hull vertices of the outline with one chord, as one step held to every
250
+ * required row exactly as a single removal is, before that pass's single removals. `maxVertices` is a whole number,
251
+ * 2 or more, and has no default; `null` is refused like any value that is not that object.
252
+ */
253
+ boundaryRuns?: BoundaryRuns;
254
+ }
255
+
256
+ /** Issue #1279: the opt-in to boundary runs — the most consecutive outline vertices one chord may replace. */
257
+ export interface BoundaryRuns {
258
+ /** A whole number, 2 or more: the longest run tried. */
259
+ maxVertices: number;
260
+ }
261
+
262
+ /**
263
+ * Issue #1279: one accepted operation of a reduction, in the order taken — the entry `ReductionChanges.acceptedAt`
264
+ * carries for it and the unit `stopAfterAccepted` counts.
265
+ */
266
+ export interface AcceptedOperation {
267
+ /** The attempt number — `candidatesTried` as it stood when the operation was taken, 1-based. */
268
+ step: number;
269
+ /** A refinement insertion, one source vertex removed, or a boundary run replaced by one chord. */
270
+ kind: 'insertion' | 'removal' | 'boundary-run';
271
+ /** Vertices the operation inserted (an insertion: 1) or removed (a removal: 1; a boundary run: 2 or more). */
272
+ count: number;
273
+ /** The source indices it removed, in outline order for a run — each `null` in `indexMap`; empty for an insertion. */
274
+ sourceVertices: number[];
246
275
  }
247
276
 
248
277
  /**
@@ -411,11 +440,13 @@ export interface ReductionChanges {
411
440
  /** Every linked mesh of the source: each inherits the new topology. */
412
441
  linkedMeshes: AttachmentRef[];
413
442
  /**
414
- * Issue #1268: the attempt number — `candidatesTried` as it stood when the step was taken, 1-based — of every
415
- * accepted step, ascending: each refinement insertion and each removal, so its length is
416
- * `insertedVertices + removedVertices`. `stopAfterAccepted: k` replays to the step at `acceptedAt[k - 1]`.
443
+ * Issue #1268, one entry per accepted operation since issue #1279: each refinement insertion, each removal and each
444
+ * boundary run taken, in order, with the attempt it was taken at (`step`, strictly ascending), its kind, how many
445
+ * vertices it inserted or removed and which source vertices it removed. The counts sum to
446
+ * `insertedVertices + removedVertices`; with no boundary run every count is 1 and the length is that sum.
447
+ * `stopAfterAccepted: k` replays to the operation at `acceptedAt[k - 1]`.
417
448
  */
418
- acceptedAt: number[];
449
+ acceptedAt: AcceptedOperation[];
419
450
  }
420
451
 
421
452
  export interface MeshQualityReport {
@@ -461,6 +492,8 @@ export interface EffectiveSettings {
461
492
  budget: { maxCandidates: number } | null;
462
493
  /** A reduction's `stopAfterAccepted`, echoed when the input set it (issue #1268); absent otherwise. */
463
494
  stopAfterAccepted?: number;
495
+ /** A reduction's `boundaryRuns`, echoed when the input set it (issue #1279); absent otherwise. */
496
+ boundaryRuns?: BoundaryRuns;
464
497
  }
465
498
 
466
499
  export interface MeshCounts {
@@ -934,7 +967,7 @@ function runsAlongOuterBoundary(a: Pt, b: Pt, poly: readonly Pt[], transition: n
934
967
  }
935
968
 
936
969
  /**
937
- * [agreed, spine-parts#126] Is the edge `a`–`b` held by `region` — does the
970
+ * [agreed, rig-parts#126] Is the edge `a`–`b` held by `region` — does the
938
971
  * region's density bound (`MQ_MAX_EDGE` or `MQ_TRANSITION`) apply to it? The
939
972
  * one definition both `measureMeshQuality` and `reduceMesh`'s refinement read.
940
973
  *
@@ -1674,7 +1707,7 @@ interface Edge {
1674
1707
  *
1675
1708
  * Which edges a region holds is `edgeIsHeldByRegion`'s answer and nothing
1676
1709
  * else's: an edge that only touches the band's outer boundary at one point,
1677
- * from outside, is not held (spine-parts#126). One bound per edge, the smallest
1710
+ * from outside, is not held (rig-parts#126). One bound per edge, the smallest
1678
1711
  * applicable anywhere on it (P16): `L0` of every region whose closed polygon it
1679
1712
  * meets, and `L0 + grade·d` of every other region that holds it, `d` its
1680
1713
  * distance from that region. A row's value is the edge whose
@@ -1990,6 +2023,7 @@ function effectiveJson(e: EffectiveSettings): Json {
1990
2023
  schedule: scheduleJson(e.schedule),
1991
2024
  budget: e.budget === null ? null : { maxCandidates: e.budget.maxCandidates },
1992
2025
  ...(e.stopAfterAccepted === undefined ? {} : { stopAfterAccepted: e.stopAfterAccepted }),
2026
+ ...(e.boundaryRuns === undefined ? {} : { boundaryRuns: { maxVertices: e.boundaryRuns.maxVertices } }),
1993
2027
  };
1994
2028
  }
1995
2029
 
@@ -2067,7 +2101,7 @@ function candidateJson(c: CandidateReport): Json {
2067
2101
  deformRemapped: k.deformRemapped.map((d) => ({ animation: d.animation, attachment: attachmentJson(d.attachment), key: d.key, droppedVertices: [...d.droppedVertices] })),
2068
2102
  deformReevaluated: k.deformReevaluated.map((d) => ({ animation: d.animation, attachment: attachmentJson(d.attachment), key: d.key })),
2069
2103
  linkedMeshes: k.linkedMeshes.map(attachmentJson),
2070
- acceptedAt: [...k.acceptedAt],
2104
+ acceptedAt: k.acceptedAt.map((a) => ({ step: a.step, kind: a.kind, count: a.count, sourceVertices: [...a.sourceVertices] })),
2071
2105
  };
2072
2106
  }
2073
2107
  return out;
package/src/meshreduce.ts CHANGED
@@ -17,7 +17,7 @@
17
17
  *
18
18
  * Each is its own function with its own entry and exit, so a control can run
19
19
  * one with the other idle — regions empty, or every source vertex protected —
20
- * which is the shape the consumer asked for (spine-parts#126, comment
20
+ * which is the shape the consumer asked for (rig-parts#126, comment
21
21
  * 6042150608: "separate composed operations … with independently passing
22
22
  * controls for each").
23
23
  *
@@ -48,12 +48,24 @@
48
48
  * blocked the last attempt). Inserted vertices are never candidates: they exist
49
49
  * to meet `L(R)`, and removing one is undoing the refinement.
50
50
  *
51
+ * With `boundaryRuns` (issue #1279, opt-in) each pass first sweeps boundary
52
+ * runs — 2 to `maxVertices` consecutive source-hull vertices of the outline
53
+ * replaced by one chord, as one step held to every row a single removal is —
54
+ * and then the single removals as above (`removeVertices` states the order).
55
+ * Every accepted operation is one `acceptedAt` entry and one unit of
56
+ * `stopAfterAccepted`.
57
+ *
58
+ * A removal whose deviation floor — the furthest removed source-hull vertex
59
+ * from the candidate's outline, which `MQ_BOUNDARY_DEVIATION` can only exceed —
60
+ * is over the bound is refused without the measurement; its name, when read,
61
+ * is the measurement's (`tryOperation`). No result reads differently.
62
+ *
51
63
  * The refinement measures, takes the first failing region row in report order
52
64
  * (code, then region name) and splits that row's worst edge. When an end of
53
65
  * the edge lies outside the region and its band, the split is where the edge
54
66
  * leaves the band, so the piece to that end only touches the band's outer
55
67
  * boundary and `edgeIsHeldByRegion` — the one definition the rows read —
56
- * exempts it (spine-parts#126). Otherwise the split is at the midpoint when
68
+ * exempts it (rig-parts#126). Otherwise the split is at the midpoint when
57
69
  * the midpoint lies in the region or its band, else at the point of the edge
58
70
  * inside them nearest the midpoint. A region no edge meets — one
59
71
  * inside a single triangle — gets the first vertex of its polygon inserted into
@@ -92,6 +104,7 @@ import {
92
104
  edgeIsHeldByRegion,
93
105
  measureMeshQualityStep,
94
106
  measureMeshQualityWith,
107
+ type AcceptedOperation,
95
108
  type AttachmentRef,
96
109
  type ArtFitBounds,
97
110
  type CandidateReport,
@@ -264,6 +277,12 @@ function validateReduction(input: MeshReductionInput): void {
264
277
  if ('stopAfterAccepted' in input && input.stopAfterAccepted !== undefined && (!Number.isInteger(input.stopAfterAccepted) || input.stopAfterAccepted < 0)) {
265
278
  refuse('REDUCE_INPUT_MISSING', `${who}: stopAfterAccepted is ${JSON.stringify(input.stopAfterAccepted)}; required a whole number of accepted steps, 0 or more, or the field left out (issue #1268: replay to that step)`);
266
279
  }
280
+ if ('boundaryRuns' in input && input.boundaryRuns !== undefined) {
281
+ const runs: unknown = input.boundaryRuns;
282
+ if (!isObject(runs) || !Number.isInteger(runs.maxVertices) || (runs.maxVertices as number) < 2) {
283
+ refuse('REDUCE_INPUT_MISSING', `${who}: boundaryRuns is ${JSON.stringify(runs)}; required { maxVertices: a whole number, 2 or more } — the longest run one chord may replace, which has no default — or the field left out (issue #1279: no run is tried)`);
284
+ }
285
+ }
267
286
  const src = input.source;
268
287
  if (!isObject(src) || !Array.isArray(src.points)) refuse('REDUCE_INPUT_MISSING', `${who}: source is not { points, uvs, triangles, hull, weights }`);
269
288
  const n = src.points.length;
@@ -733,10 +752,12 @@ interface Run {
733
752
  sourceHull: Array<[number, number]>;
734
753
  /** Steps tried so far, refinement insertions and removal attempts alike. */
735
754
  steps: number;
736
- /** Issue #1268: `steps` as it stood at each accepted step — every insertion made and every removal taken — in order. */
737
- acceptedAt: number[];
738
- /** The replay fault planted for a control, or null. */
739
- plant: ReplayPlant | null;
755
+ /** Issue #1268, per operation since #1279: every insertion made, removal taken and boundary run taken, in order. */
756
+ acceptedAt: AcceptedOperation[];
757
+ /** The fault planted for a control, or null. */
758
+ plant: ReductionPlant | null;
759
+ /** What a control reads of every removal-phase attempt, or null. */
760
+ observe: AttemptObserver | null;
740
761
  tally: Tally;
741
762
  keyed: Map<number, { animation: string; ref: AttachmentRef; key: number; time: number }>;
742
763
  protectedVertices: Set<number>;
@@ -883,7 +904,7 @@ function offsetExit(inner: Pt, outer: Pt, poly: readonly Pt[], level: number): n
883
904
  }
884
905
 
885
906
  /**
886
- * [agreed, spine-parts#126] A split of `inner`–`outer` where it leaves
907
+ * [agreed, rig-parts#126] A split of `inner`–`outer` where it leaves
887
908
  * `region`'s band, so that the piece to `outer` only touches the band's outer
888
909
  * boundary and is exempt by `edgeIsHeldByRegion` — the one definition the
889
910
  * measurement reads. The split is put half of `BAND_CONTACT_TOLERANCE` inside
@@ -975,8 +996,12 @@ type PhaseEnd =
975
996
  * straight after the step, before the budget is read again, so the replay stops in exactly the state the
976
997
  * unreplayed run held after that step.
977
998
  */
978
- function accept(run: Run): boolean {
979
- run.acceptedAt.push(run.steps);
999
+ function accept(run: Run, kind: AcceptedOperation['kind'], sourceVertices: number[]): boolean {
1000
+ if (kind === 'boundary-run' && run.plant === 'run-split-per-vertex') {
1001
+ for (const v of sourceVertices) run.acceptedAt.push({ step: run.steps, kind: 'removal', count: 1, sourceVertices: [v] });
1002
+ } else {
1003
+ run.acceptedAt.push({ step: run.steps, kind, count: kind === 'insertion' ? 1 : sourceVertices.length, sourceVertices: [...sourceVertices] });
1004
+ }
980
1005
  const stop = run.input.stopAfterAccepted;
981
1006
  if (stop === undefined) return false;
982
1007
  const at = run.plant === 'stop-one-early' ? stop - 1 : run.plant === 'stop-one-late' ? stop + 1 : stop;
@@ -989,6 +1014,29 @@ function accept(run: Run): boolean {
989
1014
  */
990
1015
  export type ReplayPlant = 'stop-one-early' | 'stop-one-late';
991
1016
 
1017
+ /**
1018
+ * Every fault a control plants in a reduction (`reduceMeshWith`'s `plant`); `reduceMesh` plants none. Beside the
1019
+ * replay's two (issue #1268), issue #1279's: boundary runs tried by a call that did not opt in; a run recorded as one
1020
+ * `removal` entry per vertex (the shape before #1279); a run taken without its rows measured; every candidate
1021
+ * measured, with no deviation floor (the path the floor is held equal to); and the floor read half a pixel short
1022
+ * of the bound, so it refuses steps the rows would take.
1023
+ */
1024
+ export type ReductionPlant = ReplayPlant | 'runs-without-opt-in' | 'run-split-per-vertex' | 'run-skips-rows' | 'measure-every-candidate' | 'floor-half-a-pixel-short';
1025
+
1026
+ /** One removal-phase attempt as a control reads it (issue #1279): what was tried, and what refused it or null when taken. */
1027
+ export interface AttemptRecord {
1028
+ step: number;
1029
+ kind: 'removal' | 'boundary-run';
1030
+ sourceVertices: number[];
1031
+ /** Null when taken; else reads the name of what refused it — measuring the attempt in full when the floor decided it. Read it only inside the observer, while the working mesh is the one the attempt was tried on. */
1032
+ refusedBy: (() => string) | null;
1033
+ /** Whether the deviation floor refused it without a measurement. */
1034
+ decidedByFloor: boolean;
1035
+ }
1036
+
1037
+ /** Called once per removal-phase attempt, after it. */
1038
+ export type AttemptObserver = (attempt: AttemptRecord) => void;
1039
+
992
1040
  /**
993
1041
  * Refine the working mesh until every region's `MQ_MAX_EDGE` and
994
1042
  * `MQ_TRANSITION` that the refinement can act on passes. One insertion per
@@ -1011,14 +1059,14 @@ function refineRegions(run: Run): PhaseEnd {
1011
1059
  if (target.state === 'not-measurable') {
1012
1060
  const p: Pt = [r6(region.polygon[0][0]), r6(region.polygon[0][1])];
1013
1061
  if (!insertPoint(run, p)) return { kind: 'stuck', constraint: `${rowName(target)} (the refinement found no triangle holding the region's first vertex)` };
1014
- if (accept(run)) return { kind: 'replayed' };
1062
+ if (accept(run, 'insertion', [])) return { kind: 'replayed' };
1015
1063
  continue;
1016
1064
  }
1017
1065
  const [ra, rb] = target.worst!.at.edge!;
1018
1066
  const a = canon.order[ra];
1019
1067
  const b = canon.order[rb];
1020
1068
  // An end outside the region and its band: split where the edge leaves the band, so the piece to that
1021
- // end only touches the band's outer boundary and is exempt (spine-parts#126) — the same predicate the
1069
+ // end only touches the band's outer boundary and is exempt (rig-parts#126) — the same predicate the
1022
1070
  // measurement reads decides it, so the next measurement agrees.
1023
1071
  let exited = false;
1024
1072
  for (const [inner, outer] of [[b, a], [a, b]] as const) {
@@ -1030,7 +1078,7 @@ function refineRegions(run: Run): PhaseEnd {
1030
1078
  break;
1031
1079
  }
1032
1080
  if (exited) {
1033
- if (accept(run)) return { kind: 'replayed' };
1081
+ if (accept(run, 'insertion', [])) return { kind: 'replayed' };
1034
1082
  continue;
1035
1083
  }
1036
1084
  // An end outside the region and its band further beyond it than the edge's own bound, with no split on
@@ -1045,7 +1093,7 @@ function refineRegions(run: Run): PhaseEnd {
1045
1093
  const why =
1046
1094
  region.transition > 0
1047
1095
  ? 'no point of the edge on the band\'s outer boundary leaves the piece to it exempt, so an edge from inside the region and its band to it stays over the bound'
1048
- : 'with no band, an edge from the region to it touches the authored boundary and stays held (spine-parts#126), so it stays over the bound';
1096
+ : 'with no band, an edge from the region to it touches the authored boundary and stays held (rig-parts#126), so it stays over the bound';
1049
1097
  return {
1050
1098
  kind: 'stuck',
1051
1099
  constraint: `${rowName(target)} (vertex ${end} of edge ${ra}–${rb} lies ${r6(beyond)} px beyond region "${region.name}"'s ${region.transition} px band, further than the edge's bound ${target.bound!.value}: ${why}, and the refinement inserts only inside them — P16)`,
@@ -1055,7 +1103,7 @@ function refineRegions(run: Run): PhaseEnd {
1055
1103
  const p = splitPoint(run.work.pos[a], run.work.pos[b], region);
1056
1104
  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)` };
1057
1105
  splitEdge(run, a, b, p);
1058
- if (accept(run)) return { kind: 'replayed' };
1106
+ if (accept(run, 'insertion', [])) return { kind: 'replayed' };
1059
1107
  }
1060
1108
  }
1061
1109
 
@@ -1131,39 +1179,169 @@ function weightJump(work: Work, a: number, b: number): number {
1131
1179
  return sum;
1132
1180
  }
1133
1181
 
1182
+ /**
1183
+ * Several boundary vertices removed one after another as one operation (issue #1279): the working triangles after
1184
+ * the last and the edges they hold that the triangles before the first did not, in the order they appear — or the
1185
+ * structural reason one of the removals cannot be made. The outline loses the run and gains the chord from the
1186
+ * vertex before it to the vertex after it; nothing is measured in between.
1187
+ */
1188
+ function runRemovalOf(work: Work, vertices: readonly number[]): { triangles: Array<[number, number, number]>; added: Array<[number, number]> } | { blocked: string } {
1189
+ const was = work.triangles;
1190
+ let after: Array<[number, number, number]>;
1191
+ try {
1192
+ for (const v of vertices) {
1193
+ const step = removalOf(work, v);
1194
+ if ('blocked' in step) return step;
1195
+ work.triangles = step.triangles;
1196
+ }
1197
+ after = work.triangles;
1198
+ } finally {
1199
+ work.triangles = was;
1200
+ }
1201
+ const old = new Set<string>();
1202
+ for (const t of was) for (let k = 0; k < 3; k++) old.add(edgeKey(t[k], t[(k + 1) % 3]));
1203
+ const added: Array<[number, number]> = [];
1204
+ const seen = new Set<string>();
1205
+ for (const t of after) {
1206
+ for (let k = 0; k < 3; k++) {
1207
+ const key = edgeKey(t[k], t[(k + 1) % 3]);
1208
+ if (old.has(key) || seen.has(key)) continue;
1209
+ seen.add(key);
1210
+ added.push([t[k], t[(k + 1) % 3]]);
1211
+ }
1212
+ }
1213
+ return { triangles: after, added };
1214
+ }
1215
+
1216
+ /**
1217
+ * Why an attempt was refused: the constraint's name, or — when the deviation floor refused it without a
1218
+ * measurement — a thunk that measures the attempt in full and returns the name the measurement gives, so whatever
1219
+ * reads it reads what an unfloored run would have written.
1220
+ */
1221
+ type Refusal = string | (() => string);
1222
+
1223
+ function refusalText(refusal: Refusal): string {
1224
+ return typeof refusal === 'string' ? refusal : refusal();
1225
+ }
1226
+
1227
+ /**
1228
+ * The surviving source vertices on the current outline, walked the way the source's own hull listing turns (the
1229
+ * canonical order's hull) — the order a boundary run follows.
1230
+ */
1231
+ function outlineWalk(run: Run): number[] {
1232
+ const canon = canonicalise(run.work, run.sourceTurn, run.boneRank);
1233
+ return canon.order.slice(0, canon.mesh.hull);
1234
+ }
1235
+
1134
1236
  /**
1135
1237
  * Remove surviving source vertices in ascending source index, pass after pass,
1136
1238
  * taking a step only when every structural condition and every required row
1137
1239
  * holds after it. Ends when a pass takes no step, or when the budget is spent.
1240
+ *
1241
+ * With `boundaryRuns` (issue #1279) each pass first sweeps the boundary runs:
1242
+ * for each surviving, unprotected source-hull vertex in ascending source index,
1243
+ * the runs starting at it and following the outline the way the source hull is
1244
+ * listed, longest first — `maxVertices` down to 2, never leaving fewer than three
1245
+ * outline vertices, every member a surviving, unprotected source-hull vertex —
1246
+ * each one candidate; the first taken ends that start's sweep, and the sweep
1247
+ * goes on to the next index over the outline the run left. Then the pass's
1248
+ * single removals, exactly as without the field.
1138
1249
  */
1139
1250
  function removeVertices(run: Run): PhaseEnd {
1140
1251
  const { work, input } = run;
1141
- let lastBlock = '';
1252
+ const runs = run.plant === 'runs-without-opt-in' ? { maxVertices: 2 } : input.boundaryRuns;
1253
+ const hull = input.source.hull;
1254
+ let lastBlock: { refusal: Refusal; what: string } | null = null;
1255
+ const lastName = (): string => (lastBlock === null ? '' : `${refusalText(lastBlock.refusal)}, ${lastBlock.what}`);
1142
1256
  for (;;) {
1143
1257
  let taken = 0;
1144
1258
  let tried = 0;
1259
+ if (runs !== undefined) {
1260
+ let walk: number[] | null = null;
1261
+ for (let s = 0; s < hull; s++) {
1262
+ if (!work.alive[s] || run.protectedVertices.has(s)) continue;
1263
+ walk ??= outlineWalk(run);
1264
+ const at = walk.indexOf(s);
1265
+ if (at === -1) continue;
1266
+ for (let r = Math.min(runs.maxVertices, walk.length - 3); r >= 2; r--) {
1267
+ const members: number[] = [];
1268
+ for (let i = 0; i < r; i++) {
1269
+ const v = walk[(at + i) % walk.length];
1270
+ if (v >= hull || !work.alive[v] || run.protectedVertices.has(v)) break;
1271
+ members.push(v);
1272
+ }
1273
+ if (members.length < r) continue;
1274
+ if (run.steps >= input.budget.maxCandidates) return { kind: 'budget' };
1275
+ run.steps++;
1276
+ tried++;
1277
+ const block = tryOperation(run, members, false);
1278
+ if (run.observe !== null) run.observe({ step: run.steps, kind: 'boundary-run', sourceVertices: [...members], refusedBy: block === null ? null : () => refusalText(block), decidedByFloor: typeof block === 'function' });
1279
+ if (block === null) {
1280
+ taken++;
1281
+ walk = null;
1282
+ if (accept(run, 'boundary-run', members)) return { kind: 'replayed' };
1283
+ break;
1284
+ }
1285
+ lastBlock = { refusal: block, what: `removing source vertices ${members.join(', ')} as one boundary run` };
1286
+ }
1287
+ }
1288
+ }
1145
1289
  for (let v = 0; v < work.nSource; v++) {
1146
1290
  if (!work.alive[v] || run.protectedVertices.has(v)) continue;
1147
1291
  if (run.steps >= input.budget.maxCandidates) return { kind: 'budget' };
1148
1292
  run.steps++;
1149
1293
  tried++;
1150
- const block = tryRemoval(run, v);
1294
+ const block = tryOperation(run, [v], false);
1295
+ if (run.observe !== null) run.observe({ step: run.steps, kind: 'removal', sourceVertices: [v], refusedBy: block === null ? null : () => refusalText(block), decidedByFloor: typeof block === 'function' });
1151
1296
  if (block === null) {
1152
1297
  taken++;
1153
- if (accept(run)) return { kind: 'replayed' };
1154
- } else lastBlock = `${block}, removing source vertex ${v}`;
1298
+ if (accept(run, 'removal', [v])) return { kind: 'replayed' };
1299
+ } else lastBlock = { refusal: block, what: `removing source vertex ${v}` };
1155
1300
  }
1156
1301
  if (taken === 0) {
1157
- if (tried === 0) lastBlock = 'protect: every surviving source vertex is protected (protect.hull, protect.vertices, protect.edges, protect.regionBoundaries or a weightJump edge)';
1158
- return { kind: 'stuck', constraint: lastBlock };
1302
+ // A pass that took nothing left the working mesh as it found it, so a refusal the floor decided is measured
1303
+ // now in the state it was decided in.
1304
+ const constraint = tried === 0 ? 'protect: every surviving source vertex is protected (protect.hull, protect.vertices, protect.edges, protect.regionBoundaries or a weightJump edge)' : lastName();
1305
+ return { kind: 'stuck', constraint };
1159
1306
  }
1160
1307
  }
1161
1308
  }
1162
1309
 
1163
- /** One removal: null when it was taken, else the constraint that blocked it. */
1164
- function tryRemoval(run: Run, v: number): string | null {
1310
+ /**
1311
+ * The deviation floor (issue #1279): the furthest any removed source-hull vertex lies from the candidate's outline.
1312
+ * `MQ_BOUNDARY_DEVIATION` is the symmetric Hausdorff distance between that outline and the source hull, and its
1313
+ * backward half evaluates every source-hull vertex's distance to the outline exactly, so the row's value is at least
1314
+ * this — a step whose floor is over the bound fails that row whatever else it measures. The distance is
1315
+ * `distanceToSegment` over the candidate's hull edges, the function and polygon the row reads.
1316
+ */
1317
+ function deviationFloor(run: Run, mesh: SourceMesh, vertices: readonly number[]): number {
1318
+ const poly = mesh.points.slice(0, mesh.hull);
1319
+ let floor = 0;
1320
+ for (const v of vertices) {
1321
+ if (v >= run.input.source.hull) continue;
1322
+ const p = run.work.pos[v];
1323
+ let d = Infinity;
1324
+ for (let i = 0; i < poly.length; i++) d = Math.min(d, distanceToSegment(p, poly[i], poly[(i + 1) % poly.length]));
1325
+ floor = Math.max(floor, d);
1326
+ }
1327
+ return floor;
1328
+ }
1329
+
1330
+ /**
1331
+ * The margin the floor has to clear the bound by before it refuses without measuring: ten units of the `r6` grid
1332
+ * the row's value is put on, far above any difference the order the row walks an edge in can make to a distance.
1333
+ */
1334
+ const FLOOR_MARGIN = 1e-5;
1335
+
1336
+ /**
1337
+ * One operation — a single removal, or a boundary run of two or more — tried: null when it was taken (the working
1338
+ * mesh then holds it), else what refused it (the working mesh as it was). `full` measures every candidate that
1339
+ * reaches the rows; otherwise a candidate whose deviation floor is over the bound by `FLOOR_MARGIN` is refused
1340
+ * without the measurement, by a thunk that measures it when its name is read.
1341
+ */
1342
+ function tryOperation(run: Run, vertices: readonly number[], full: boolean): Refusal | null {
1165
1343
  const { work, input } = run;
1166
- const step = removalOf(work, v);
1344
+ const step = vertices.length === 1 ? removalOf(work, vertices[0]) : runRemovalOf(work, vertices);
1167
1345
  if ('blocked' in step) return step.blocked;
1168
1346
  const jump = input.protect.weightJump;
1169
1347
  if (jump !== null) {
@@ -1175,10 +1353,10 @@ function tryRemoval(run: Run, v: number): string | null {
1175
1353
  }
1176
1354
  const was = { triangles: work.triangles };
1177
1355
  work.triangles = step.triangles;
1178
- work.alive[v] = false;
1356
+ for (const v of vertices) work.alive[v] = false;
1179
1357
  const undo = (): void => {
1180
1358
  work.triangles = was.triangles;
1181
- work.alive[v] = true;
1359
+ for (const v of vertices) work.alive[v] = true;
1182
1360
  };
1183
1361
  const edges = new Set<string>();
1184
1362
  for (const t of work.triangles) for (let k = 0; k < 3; k++) edges.add(edgeKey(t[k], t[(k + 1) % 3]));
@@ -1196,6 +1374,23 @@ function tryRemoval(run: Run, v: number): string | null {
1196
1374
  undo();
1197
1375
  return `outline: ${err.message}`;
1198
1376
  }
1377
+ if (vertices.length > 1 && run.plant === 'run-skips-rows') return null;
1378
+ if (!full && run.plant !== 'measure-every-candidate') {
1379
+ const margin = run.plant === 'floor-half-a-pixel-short' ? -0.5 : FLOOR_MARGIN;
1380
+ if (deviationFloor(run, canon.mesh, vertices) > input.targets.maxBoundaryDeviation + margin) {
1381
+ undo();
1382
+ const members = [...vertices];
1383
+ return () => {
1384
+ const measured = tryOperation(run, members, true);
1385
+ if (measured === null) {
1386
+ // Unreachable while the floor is a floor: the step was refused on a bound its own rows then pass. Loud,
1387
+ // because the run already went on as if it had been refused.
1388
+ throw new Error(`the deviation floor refused removing ${members.join(', ')}, which the rows accept`);
1389
+ }
1390
+ return refusalText(measured);
1391
+ };
1392
+ }
1393
+ }
1199
1394
  const blocking = firstBlockingRow(measureAgainstTargets(run, canon.mesh, 'candidate'), run.input.targets.artFit);
1200
1395
  if (blocking !== null) {
1201
1396
  undo();
@@ -1255,19 +1450,25 @@ export function reduceMesh(input: MeshReductionInput): MeshReductionResult {
1255
1450
  * module with `export *`, and a symbol that is merely exported is not promised
1256
1451
  * (RELEASING.md, *The import surface*).
1257
1452
  */
1258
- export function reduceMeshWith(input: MeshReductionInput, rasters: ArtRasters, steps: StepRasters | null = stepRastersOf(rasters), plant: ReplayPlant | null = null): MeshReductionResult {
1453
+ export function reduceMeshWith(
1454
+ input: MeshReductionInput,
1455
+ rasters: ArtRasters,
1456
+ steps: StepRasters | null = stepRastersOf(rasters),
1457
+ plant: ReductionPlant | null = null,
1458
+ observe: AttemptObserver | null = null,
1459
+ ): MeshReductionResult {
1259
1460
  validateReduction(input);
1260
1461
  if (steps !== null && steps.rasters !== rasters) {
1261
1462
  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))`);
1262
1463
  }
1263
- return reduceValidated(input, rasters, steps, plant);
1464
+ return reduceValidated(input, rasters, steps, plant, observe);
1264
1465
  }
1265
1466
 
1266
1467
  /**
1267
1468
  * The operation, over an input `validateReduction` accepted; the art's rasters are computed at most once, in
1268
1469
  * `rasters`, and every refinement and removal step is measured through `steps` when it is given (issue #1246).
1269
1470
  */
1270
- function reduceValidated(input: MeshReductionInput, rasters: ArtRasters, steps: StepRasters | null, plant: ReplayPlant | null = null): MeshReductionResult {
1471
+ function reduceValidated(input: MeshReductionInput, rasters: ArtRasters, steps: StepRasters | null, plant: ReductionPlant | null = null, observe: AttemptObserver | null = null): MeshReductionResult {
1271
1472
  const who = `attachment ${nameOf(input.attachment)}`;
1272
1473
  const src = input.source;
1273
1474
  const sourceHull: Array<[number, number]> = src.points.slice(0, Math.max(0, src.hull)).map(([x, y]): [number, number] => [x, y]);
@@ -1344,6 +1545,7 @@ function reduceValidated(input: MeshReductionInput, rasters: ArtRasters, steps:
1344
1545
  steps: 0,
1345
1546
  acceptedAt: [],
1346
1547
  plant,
1548
+ observe,
1347
1549
  tally: { sharesDroppedOnGrid: 0, sharesPruned: 0 },
1348
1550
  keyed: keyedSourceVertices(input),
1349
1551
  ...protectionOf(input, work, sourceEdges),
@@ -1453,7 +1655,7 @@ function finish(run: Run, effective: EffectiveSettings, sourceCounts: MeshCounts
1453
1655
  deformRemapped: deform.remapped.map((d) => ({ animation: d.animation, attachment: d.attachment, key: d.key, droppedVertices: d.droppedVertices })),
1454
1656
  deformReevaluated: deform.reevaluated.map((d) => ({ animation: d.animation, attachment: d.attachment, key: d.key })),
1455
1657
  linkedMeshes: input.linkedMeshes,
1456
- acceptedAt: [...run.acceptedAt],
1658
+ acceptedAt: run.acceptedAt.map((a) => ({ step: a.step, kind: a.kind, count: a.count, sourceVertices: [...a.sourceVertices] })),
1457
1659
  };
1458
1660
  const counts = candidate.counts!;
1459
1661
  const mesh: ReducedMesh = {
@@ -1492,5 +1694,6 @@ function effectiveOf(input: MeshReductionInput, measured: EffectiveSettings, sou
1492
1694
  referenceHull: sourceHull,
1493
1695
  budget: { maxCandidates: input.budget.maxCandidates },
1494
1696
  ...(input.stopAfterAccepted === undefined ? {} : { stopAfterAccepted: input.stopAfterAccepted }),
1697
+ ...(input.boundaryRuns === undefined ? {} : { boundaryRuns: { maxVertices: input.boundaryRuns.maxVertices } }),
1495
1698
  };
1496
1699
  }
package/src/transform.ts CHANGED
@@ -68,7 +68,7 @@ import { modeMatrix, RUNTIME_ARITHMETIC, worldTransforms, type CoreWorld, type W
68
68
 
69
69
  /**
70
70
  * What `computeWorldTransforms` reads off a bone — a structural type, so both a
71
- * Spine bone (`SpineBone`, what spine-parts and every skeleton reader hold) and
71
+ * Spine bone (`SpineBone`, what rig-parts and every skeleton reader hold) and
72
72
  * a compiled-model bone (`ModelBone`, what `compile` holds) satisfy it as they
73
73
  * are, without a cast.
74
74
  *