rig-c 2.20.4 → 2.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rig-c",
3
- "version": "2.20.4",
3
+ "version": "2.21.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": {
@@ -191,8 +191,11 @@ function missing(field: string, found: unknown, required: string): never {
191
191
  }
192
192
 
193
193
  function validateFit(field: string, f: unknown): void {
194
- if (!isObject(f) || !isFiniteNumber(f.minCoverage) || f.minCoverage < 0 || f.minCoverage > 1 || !isFiniteNumber(f.maxOvershoot) || f.maxOvershoot < 0 || !isFiniteNumber(f.maxUndercut) || f.maxUndercut < 0) {
195
- missing(field, f, '{ minCoverage in 0..1, maxOvershoot >= 0, maxUndercut >= 0 }');
194
+ // `null` on maxOvershoot or maxUndercut is a bound declared absent (issue #1254): measured, reported `undeclared`,
195
+ // never refusing a reference or failing a candidate. A field left out is still refused.
196
+ const distance = (v: unknown): boolean => v === null || (isFiniteNumber(v) && v >= 0);
197
+ if (!isObject(f) || !isFiniteNumber(f.minCoverage) || f.minCoverage < 0 || f.minCoverage > 1 || !distance(f.maxOvershoot) || !distance(f.maxUndercut)) {
198
+ missing(field, f, '{ minCoverage in 0..1, maxOvershoot >= 0 or null, maxUndercut >= 0 or null }');
196
199
  }
197
200
  }
198
201
 
@@ -116,14 +116,23 @@ export interface SourceMesh {
116
116
  weights: Array<Array<{ bone: string; weight: number }>> | null;
117
117
  }
118
118
 
119
- /** All distances in the drawing's pixels; converted to texels by `pageScale`. */
119
+ /**
120
+ * All distances in the drawing's pixels; converted to texels by `pageScale`.
121
+ *
122
+ * `maxOvershoot` and `maxUndercut` may be `null` — a bound **declared absent**
123
+ * (issue #1254): the row is measured and reported `undeclared`, with its value
124
+ * and worst sample, and never passes, fails, blocks a step or refuses a source.
125
+ * A field left out (`undefined`) is still refused by name: an omission cannot be
126
+ * told from a caller that forgot, and only the explicit `null` says "measured,
127
+ * not bounded". `minCoverage` has no such form and is always required.
128
+ */
120
129
  export interface ArtFitBounds {
121
130
  /** Share of art pixel centres the triangles cover, 0..1. */
122
131
  minCoverage: number;
123
- /** Furthest a covered pixel may sit outside the filled silhouette, px. */
124
- maxOvershoot: number;
125
- /** Furthest an uncovered art pixel may sit from the covered set, px. */
126
- maxUndercut: number;
132
+ /** Furthest a covered pixel may sit outside the filled silhouette, px; null = declared absent. */
133
+ maxOvershoot: number | null;
134
+ /** Furthest an uncovered art pixel may sit from the covered set, px; null = declared absent. */
135
+ maxUndercut: number | null;
127
136
  }
128
137
 
129
138
  /** What a reduction's RESULT must satisfy (correction 3). Read by `reduceMesh`, stage B2. */
@@ -607,7 +616,7 @@ function validateInput(input: MeshMeasureInput): void {
607
616
  if (!isFiniteNumber(coverage) || coverage < 0 || coverage > 1) refuse('REDUCE_INPUT_MISSING', `${who}: targets.artFit.minCoverage is ${JSON.stringify(coverage)}; required a fraction in 0..1`);
608
617
  for (const field of ['maxOvershoot', 'maxUndercut'] as const) {
609
618
  const v = fit[field];
610
- if (!isFiniteNumber(v) || v < 0) refuse('REDUCE_INPUT_MISSING', `${who}: targets.artFit.${field} is ${JSON.stringify(v)}; required a finite number of px, 0 or more`);
619
+ if (v !== null && (!isFiniteNumber(v) || v < 0)) refuse('REDUCE_INPUT_MISSING', `${who}: targets.artFit.${field} is ${JSON.stringify(v)}; required a finite number of px, 0 or more, or null (declared absent: measured, reported undeclared)`);
611
620
  }
612
621
  }
613
622
  present(who, 'targets.maxBoundaryDeviation', targets.maxBoundaryDeviation, 'a number of px or null');
@@ -1298,8 +1307,8 @@ function measureValidated(input: MeshMeasureInput, rasters: ArtRasters, steps: S
1298
1307
  if (artCount < input.minArtSamples) {
1299
1308
  const why = `attachment ${nameOf(attachment)} has ${artCount} art sample(s) at alpha >= ${threshold}; required at least ${input.minArtSamples} (minArtSamples, P9) — a row over fewer is not a measurement`;
1300
1309
  rows.push(withRaster(unmeasuredRow(spec('MQ_COVERAGE', 'fraction'), 'not-measurable', why, fit !== null), rasterArt(null), grid, artCount === 0 ? 1 : 1 / artCount));
1301
- rows.push(withRaster(unmeasuredRow(spec('MQ_OVERSHOOT', 'px'), 'not-measurable', why, fit !== null), rasterArt(8), grid, pxIncrement));
1302
- rows.push(withRaster(unmeasuredRow(spec('MQ_UNDERCUT', 'px'), 'not-measurable', why, fit !== null), rasterArt(null), grid, pxIncrement));
1310
+ rows.push(withRaster(unmeasuredRow(spec('MQ_OVERSHOOT', 'px'), 'not-measurable', why, fit !== null && fit.maxOvershoot !== null), rasterArt(8), grid, pxIncrement));
1311
+ rows.push(withRaster(unmeasuredRow(spec('MQ_UNDERCUT', 'px'), 'not-measurable', why, fit !== null && fit.maxUndercut !== null), rasterArt(null), grid, pxIncrement));
1303
1312
  rows.push(withRaster(unmeasuredRow(spec('MQ_HOLES', 'count'), 'not-measurable', why, false), rasterArt(8), grid, 1));
1304
1313
  rows.push(withRaster(unmeasuredRow(spec('MQ_ISLANDS', 'count'), 'not-measurable', why, false), rasterArt(4), grid, 1));
1305
1314
  } else {
@@ -1322,8 +1331,8 @@ function measureValidated(input: MeshMeasureInput, rasters: ArtRasters, steps: S
1322
1331
  rows.push(
1323
1332
  withRaster(
1324
1333
  !anyCovered
1325
- ? unmeasuredRow(undercutSpec, 'not-measurable', `attachment ${nameOf(attachment)}: the triangles cover no pixel centre of the ${w}x${h} grid, so no art pixel has a distance to a covered one`, fit !== null)
1326
- : measuredRow(undercutSpec, r6(Math.sqrt(undercutSq) / scale), fit === null ? null : { op: '<=', value: fit.maxUndercut }, missingWorst, true),
1334
+ ? unmeasuredRow(undercutSpec, 'not-measurable', `attachment ${nameOf(attachment)}: the triangles cover no pixel centre of the ${w}x${h} grid, so no art pixel has a distance to a covered one`, fit !== null && fit.maxUndercut !== null)
1335
+ : measuredRow(undercutSpec, r6(Math.sqrt(undercutSq) / scale), fit === null || fit.maxUndercut === null ? null : { op: '<=', value: fit.maxUndercut }, missingWorst, true),
1327
1336
  rasterArt(null),
1328
1337
  grid,
1329
1338
  pxIncrement,
@@ -1335,7 +1344,7 @@ function measureValidated(input: MeshMeasureInput, rasters: ArtRasters, steps: S
1335
1344
  const fillsDiffer = rasters.fills().differ;
1336
1345
  const silhouetteRows = (connectivity: 4 | 8, gated: boolean): void => {
1337
1346
  const { overAt, overSq, holes, holeAt } = reading.silhouette(connectivity);
1338
- const bound = gated && fit !== null ? { op: '<=' as const, value: fit.maxOvershoot } : null;
1347
+ const bound = gated && fit !== null && fit.maxOvershoot !== null ? { op: '<=' as const, value: fit.maxOvershoot } : null;
1339
1348
  rows.push(
1340
1349
  withRaster(
1341
1350
  measuredRow(spec('MQ_OVERSHOOT', 'px'), r6(Math.sqrt(overSq) / scale), bound, overAt === -1 ? NOTHING_WORSE : { at: { pixel: pixelOf(overAt) } }, gated),
package/src/meshreduce.ts CHANGED
@@ -29,7 +29,8 @@
29
29
  * #1240), and as `measureMeshQualityStep` carried from the last step's
30
30
  * measurement (`StepRasters`, issue #1246), each of which returns the same
31
31
  * report — and taken only when every row the caller's
32
- * contract requires is `pass`: the art fit at `targets.artFit`, the boundary
32
+ * contract requires is `pass`: the art fit at `targets.artFit` (an overshoot or
33
+ * undercut bound declared `null` is measured and never required), the boundary
33
34
  * deviation from the source hull at `targets.maxBoundaryDeviation`, the minimum
34
35
  * angle when declared, orientation and degeneracy at 0, and every region's
35
36
  * `MQ_MAX_EDGE` and `MQ_TRANSITION`. Before the measurement, the structural
@@ -216,11 +217,27 @@ class Stop extends Error {
216
217
  function checkFit(who: string, field: string, fit: unknown): void {
217
218
  if (fit === undefined || fit === null || !isObject(fit)) refuse('REDUCE_INPUT_MISSING', `${who}: ${field} is ${JSON.stringify(fit)}; required { minCoverage, maxOvershoot, maxUndercut } — no field has a default`);
218
219
  if (!isFiniteNumber(fit.minCoverage) || fit.minCoverage < 0 || fit.minCoverage > 1) refuse('REDUCE_INPUT_MISSING', `${who}: ${field}.minCoverage is ${JSON.stringify(fit.minCoverage)}; required a fraction in 0..1`);
220
+ // `null` is a bound declared absent (issue #1254): measured, reported `undeclared`, never gating. A field left
221
+ // out is not the same thing and stays refused — `JSON.stringify` names it `undefined` in the message.
219
222
  for (const k of ['maxOvershoot', 'maxUndercut'] as const) {
220
- if (!isFiniteNumber(fit[k]) || (fit[k] as number) < 0) refuse('REDUCE_INPUT_MISSING', `${who}: ${field}.${k} is ${JSON.stringify(fit[k])}; required a finite number of px, 0 or more`);
223
+ const v = fit[k];
224
+ if (v !== null && (!isFiniteNumber(v) || v < 0)) refuse('REDUCE_INPUT_MISSING', `${who}: ${field}.${k} is ${JSON.stringify(v)}; required a finite number of px, 0 or more, or null (declared absent: measured, reported undeclared)`);
221
225
  }
222
226
  }
223
227
 
228
+ /**
229
+ * Whether `fit` declares the bound of an art row absent (issue #1254): the
230
+ * 8-connected `MQ_OVERSHOOT` under `maxOvershoot: null`, `MQ_UNDERCUT` under
231
+ * `maxUndercut: null`. Such a row is `undeclared` when measured and is never
232
+ * required — not for admission, not for a step, not when it could not be
233
+ * measured. Every other row answers false.
234
+ */
235
+ function artBoundAbsent(fit: ArtFitBounds, row: MeasureRow): boolean {
236
+ if (row.code === 'MQ_OVERSHOOT') return fit.maxOvershoot === null;
237
+ if (row.code === 'MQ_UNDERCUT') return fit.maxUndercut === null;
238
+ return false;
239
+ }
240
+
224
241
  /**
225
242
  * The fields a reduction reads and a measurement does not, refused by name. The
226
243
  * art, the mesh's arrays, the regions' shapes and the sample floors are refused
@@ -755,12 +772,14 @@ function rowName(row: MeasureRow): string {
755
772
 
756
773
  /**
757
774
  * The first required row that is not `pass`, in `BLOCKING_ORDER`, or null when
758
- * every one passes. A row is required exactly when its bound is declared; the
775
+ * every one passes. A row is required exactly when its bound is declared — an
776
+ * art bound declared `null` is not (`artBoundAbsent`, issue #1254); the
759
777
  * overshoot row gated is the 8-connected one (P12).
760
778
  */
761
- function firstBlockingRow(report: MeshQualityReport): MeasureRow | null {
779
+ function firstBlockingRow(report: MeshQualityReport, artFit: ArtFitBounds): MeasureRow | null {
762
780
  const rows = report.candidates[0]?.geometry?.rows ?? [];
763
781
  const required = (r: MeasureRow): boolean => {
782
+ if (artBoundAbsent(artFit, r)) return false;
764
783
  if (r.code === 'MQ_OVERSHOOT') return r.art?.connectivity === 8;
765
784
  if (r.code === 'MQ_MIN_ANGLE') return r.bound !== null;
766
785
  if (r.code === 'MQ_HOLES' || r.code === 'MQ_ISLANDS' || r.code === 'MQ_TRACE_DEVIATION' || r.code === 'MQ_FILL_DISTANCE') return false;
@@ -1142,7 +1161,7 @@ function tryRemoval(run: Run, v: number): string | null {
1142
1161
  undo();
1143
1162
  return `outline: ${err.message}`;
1144
1163
  }
1145
- const blocking = firstBlockingRow(measureAgainstTargets(run, canon.mesh, 'candidate'));
1164
+ const blocking = firstBlockingRow(measureAgainstTargets(run, canon.mesh, 'candidate'), run.input.targets.artFit);
1146
1165
  if (blocking !== null) {
1147
1166
  undo();
1148
1167
  return rowName(blocking);
@@ -1250,7 +1269,7 @@ function reduceValidated(input: MeshReductionInput, rasters: ArtRasters, steps:
1250
1269
  }
1251
1270
  for (const code of ['MQ_ORIENTATION', 'MQ_DEGENERATE', 'MQ_COVERAGE', 'MQ_OVERSHOOT', 'MQ_UNDERCUT']) {
1252
1271
  const row = admitRows.find((r) => r.code === code && (code !== 'MQ_OVERSHOOT' || r.art?.connectivity === 8));
1253
- if (row !== undefined && row.state !== 'pass') {
1272
+ if (row !== undefined && row.state !== 'pass' && !artBoundAbsent(input.sourceBounds, row)) {
1254
1273
  const found = row.state === 'fail' ? `${row.value} against ${row.bound!.op} ${row.bound!.value}` : `${row.state} (${row.reason ?? ''})`;
1255
1274
  throw new Stop(
1256
1275
  'invalid-input',
@@ -1294,7 +1313,7 @@ function reduceValidated(input: MeshReductionInput, rasters: ArtRasters, steps:
1294
1313
  const refined = refineRegions(run);
1295
1314
  if (refined.kind === 'budget') return noMesh({ reason: 'budget-exhausted', candidatesTried: run.steps, budget: input.budget.maxCandidates, result: 'none-met-the-targets' });
1296
1315
  const startCanon = canonicalise(work, run.sourceTurn, boneRank);
1297
- const startBlock = firstBlockingRow(measureAgainstTargets(run, startCanon.mesh, 'start'));
1316
+ const startBlock = firstBlockingRow(measureAgainstTargets(run, startCanon.mesh, 'start'), input.targets.artFit);
1298
1317
  let termination: Termination;
1299
1318
  if (startBlock !== null) {
1300
1319
  const constraint = refined.kind === 'stuck' ? refined.constraint : rowName(startBlock);