rig-c 2.30.0 → 2.31.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.30.0",
3
+ "version": "2.31.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": {
@@ -17,7 +17,7 @@ import { MeshReductionError } from './mesh.ts';
17
17
  import { areaBand, triangleAreas } from './areaband.ts';
18
18
 
19
19
  /** `pointInTriangle`'s epsilon in `src/mesh.ts`, applied to each barycentric coordinate. */
20
- const CONTAINS = 1e-9;
20
+ export const CONTAINS = 1e-9;
21
21
 
22
22
  function refuse(code: string, message: string): never {
23
23
  throw new MeshReductionError(code, message);
@@ -47,47 +47,121 @@ export function uvCarriers(uvs: readonly number[], triangles: readonly number[],
47
47
  const ys = [uvs[triangles[t * 3] * 2 + 1], uvs[triangles[t * 3 + 1] * 2 + 1], uvs[triangles[t * 3 + 2] * 2 + 1]];
48
48
  boxes.push(Math.min(...xs), Math.min(...ys), Math.max(...xs), Math.max(...ys));
49
49
  }
50
- return samples.map((s) => {
51
- const [px, py] = s.uv;
52
- const hits: Carrier[] = [];
50
+ const u = (v: number): number => uvs[v * 2];
51
+ const w = (v: number): number => uvs[v * 2 + 1];
52
+ // Issue #1295: the samples bucketed on a uniform grid, so each live triangle is tested only against the samples in
53
+ // the cells its box (with the containment slack) overlaps — every (sample, triangle) pair the box test could pass is
54
+ // still tested, by the same `carrierIn`, and each sample's hits are gathered in ascending triangle index, the order
55
+ // a scan of every live triangle per sample gathered them in. The carriers are that scan's, value for value; what
56
+ // changes is that the work no longer grows with samples × triangles.
57
+ const n = samples.length;
58
+ const hits: Array<Carrier[] | null> = new Array(n).fill(null);
59
+ if (n > 0 && live.length > 0) {
60
+ let minX = Infinity;
61
+ let minY = Infinity;
62
+ let maxX = -Infinity;
63
+ let maxY = -Infinity;
64
+ for (const s of samples) {
65
+ minX = Math.min(minX, s.uv[0]);
66
+ minY = Math.min(minY, s.uv[1]);
67
+ maxX = Math.max(maxX, s.uv[0]);
68
+ maxY = Math.max(maxY, s.uv[1]);
69
+ }
70
+ const G = Math.max(1, Math.min(1024, Math.ceil(Math.sqrt(n / 4))));
71
+ const cw = (maxX - minX) / G || 1;
72
+ const ch = (maxY - minY) / G || 1;
73
+ const cellX = (x: number): number => Math.min(G - 1, Math.max(0, Math.floor((x - minX) / cw)));
74
+ const cellY = (y: number): number => Math.min(G - 1, Math.max(0, Math.floor((y - minY) / ch)));
75
+ const start = new Int32Array(G * G + 1);
76
+ const cellOf = new Int32Array(n);
77
+ samples.forEach((s, j) => {
78
+ cellOf[j] = cellY(s.uv[1]) * G + cellX(s.uv[0]);
79
+ start[cellOf[j] + 1]++;
80
+ });
81
+ for (let c = 0; c < G * G; c++) start[c + 1] += start[c];
82
+ const order = new Int32Array(n);
83
+ const fill = start.slice(0, G * G);
84
+ for (let j = 0; j < n; j++) order[fill[cellOf[j]]++] = j;
85
+ const slack = 1e-9;
53
86
  live.forEach((t, k) => {
54
- const slack = 1e-9;
55
- if (px < boxes[k * 4] - slack || py < boxes[k * 4 + 1] - slack || px > boxes[k * 4 + 2] + slack || py > boxes[k * 4 + 3] + slack) return;
56
- const i0 = triangles[t * 3];
57
- const i1 = triangles[t * 3 + 1];
58
- const i2 = triangles[t * 3 + 2];
59
- const ax = uvs[i0 * 2];
60
- const ay = uvs[i0 * 2 + 1];
61
- const bx = uvs[i1 * 2] - ax;
62
- const by = uvs[i1 * 2 + 1] - ay;
63
- const cx = uvs[i2 * 2] - ax;
64
- const cy = uvs[i2 * 2 + 1] - ay;
65
- const qx = px - ax;
66
- const qy = py - ay;
67
- const det = bx * cy - cx * by;
68
- const l1 = (qx * cy - cx * qy) / det;
69
- const l2 = (bx * qy - qx * by) / det;
70
- const l0 = 1 - l1 - l2;
71
- if (l0 < -CONTAINS || l1 < -CONTAINS || l2 < -CONTAINS) return;
72
- hits.push({ triangle: t, corners: [i0, i1, i2], bary: [l0, l1, l2] });
87
+ const [x0, y0, x1, y1] = [boxes[k * 4], boxes[k * 4 + 1], boxes[k * 4 + 2], boxes[k * 4 + 3]];
88
+ const cy0 = cellY(y0 - slack);
89
+ const cy1 = cellY(y1 + slack);
90
+ const cx0 = cellX(x0 - slack);
91
+ const cx1 = cellX(x1 + slack);
92
+ for (let cy = cy0; cy <= cy1; cy++) {
93
+ for (let cx = cx0; cx <= cx1; cx++) {
94
+ const c = cy * G + cx;
95
+ for (let q = start[c]; q < start[c + 1]; q++) {
96
+ const j = order[q];
97
+ const hit = carrierIn(samples[j].uv[0], samples[j].uv[1], x0, y0, x1, y1, triangles[t * 3], triangles[t * 3 + 1], triangles[t * 3 + 2], u, w, t);
98
+ if (hit !== null) (hits[j] ??= []).push(hit);
99
+ }
100
+ }
101
+ }
73
102
  });
74
- if (hits.length === 0) return null;
75
- if (hits.length === 1) return hits[0];
76
- // Several carriers are one hit only across a vertex or an edge they all share: the sample's support — the corners
77
- // it does not sit at zero weight on — is then the same vertex indices in each, and the carried point is theirs.
78
- const support = (c: Carrier): string =>
79
- c.corners
80
- .filter((_v, k) => c.bary[k] > CONTAINS)
81
- .sort((a, b) => a - b)
82
- .join(',');
83
- const first = support(hits[0]);
84
- const shared = first.split(',').length <= 2 && hits.every((c) => support(c) === first);
85
- if (!shared) {
86
- refuse(
87
- 'COMPARE_UV_CARRIER_NOT_UNIQUE',
88
- `${who}: the sample at uv (${px}, ${py}) lies in ${hits.length} UV triangles — ${hits.map((c) => `triangle ${c.triangle} (vertices ${c.corners.join(', ')})`).join(' and ')} — that do not meet there at a shared vertex or edge; required one carrier per sample (correction 4: overlapping or folded UV triangles would carry it by an arbitrary one)`,
89
- );
90
- }
91
- return hits[0];
92
- });
103
+ }
104
+ return samples.map((s, j) => resolveCarriers(s.uv[0], s.uv[1], hits[j] ?? [], who));
105
+ }
106
+
107
+ /**
108
+ * Whether the UV triangle i0, i1, i2 (`t`, its box x0..x1, y0..y1) carries the sample at (px, py), and where: the
109
+ * carrier, or null. The one containment test — `uvCarriers` and the reduction's carried residual (issue #1295) both
110
+ * read it, so a sample is in a triangle by one arithmetic.
111
+ */
112
+ export function carrierIn(
113
+ px: number,
114
+ py: number,
115
+ x0: number,
116
+ y0: number,
117
+ x1: number,
118
+ y1: number,
119
+ i0: number,
120
+ i1: number,
121
+ i2: number,
122
+ u: (v: number) => number,
123
+ v: (v: number) => number,
124
+ t: number,
125
+ ): Carrier | null {
126
+ const slack = 1e-9;
127
+ if (px < x0 - slack || py < y0 - slack || px > x1 + slack || py > y1 + slack) return null;
128
+ const ax = u(i0);
129
+ const ay = v(i0);
130
+ const bx = u(i1) - ax;
131
+ const by = v(i1) - ay;
132
+ const cx = u(i2) - ax;
133
+ const cy = v(i2) - ay;
134
+ const qx = px - ax;
135
+ const qy = py - ay;
136
+ const det = bx * cy - cx * by;
137
+ const l1 = (qx * cy - cx * qy) / det;
138
+ const l2 = (bx * qy - qx * by) / det;
139
+ const l0 = 1 - l1 - l2;
140
+ if (l0 < -CONTAINS || l1 < -CONTAINS || l2 < -CONTAINS) return null;
141
+ return { triangle: t, corners: [i0, i1, i2], bary: [l0, l1, l2] };
142
+ }
143
+
144
+ /**
145
+ * The carrier of a sample from every triangle that contains it: none, the one, or — several — the first when they all
146
+ * meet there at a shared vertex or edge; else `COMPARE_UV_CARRIER_NOT_UNIQUE` (correction 4).
147
+ */
148
+ export function resolveCarriers(px: number, py: number, hits: readonly Carrier[], who: string): Carrier | null {
149
+ if (hits.length === 0) return null;
150
+ if (hits.length === 1) return hits[0];
151
+ // Several carriers are one hit only across a vertex or an edge they all share: the sample's support — the corners
152
+ // it does not sit at zero weight on — is then the same vertex indices in each, and the carried point is theirs.
153
+ const support = (c: Carrier): string =>
154
+ c.corners
155
+ .filter((_v, k) => c.bary[k] > CONTAINS)
156
+ .sort((a, b) => a - b)
157
+ .join(',');
158
+ const first = support(hits[0]);
159
+ const shared = first.split(',').length <= 2 && hits.every((c) => support(c) === first);
160
+ if (!shared) {
161
+ refuse(
162
+ 'COMPARE_UV_CARRIER_NOT_UNIQUE',
163
+ `${who}: the sample at uv (${px}, ${py}) lies in ${hits.length} UV triangles — ${hits.map((c) => `triangle ${c.triangle} (vertices ${c.corners.join(', ')})`).join(' and ')} — that do not meet there at a shared vertex or edge; required one carrier per sample (correction 4: overlapping or folded UV triangles would carry it by an arbitrary one)`,
164
+ );
165
+ }
166
+ return hits[0];
93
167
  }
@@ -71,7 +71,7 @@ import {
71
71
  } from './mesh.ts';
72
72
  import { areaBand, triangleAreas } from './areaband.ts';
73
73
  import { allocationContrast, boundaryNecessityOnce, deformLoad, gradeMax, minAngleP10, type AllocationArt, type AllocationPlant } from './meshallocation.ts';
74
- import { skinningEchoOf, skinningResidual, validateSkinning, type SkinningDetail, type SkinningEcho, type SkinningPlant, type SkinningResidualInput } from './meshskinning.ts';
74
+ import { skinningEchoOf, skinningResidual, validateSkinning, type ReductionSkinning, type SkinningDetail, type SkinningEcho, type SkinningPlant, type SkinningResidualInput } from './meshskinning.ts';
75
75
  import { artRastersOf, type ArtRasters, type CoverageReading, type OutlineMemo, type RegionEdgeReading, type SilhouetteReading, type StepRasters } from './meshrasters.ts';
76
76
  import { cropToSpineY } from './transform.ts';
77
77
 
@@ -147,6 +147,16 @@ export interface ReductionTargets {
147
147
  minAngle?: number;
148
148
  /** Local density requirements — §5. */
149
149
  regions: RefinementRegion[];
150
+ /**
151
+ * Issue #1295 (§7 mechanism 1, the reducer half) — opt-in. Left out = no step reads a residual and the call is the
152
+ * one it was before the field existed, byte for byte. Set, every removal, boundary run and triangulation post-pass is
153
+ * taken only when `MQ_SKINNING_RESIDUAL` of its result against the call's own `source` — the original, never a
154
+ * previous step — is within `maxResidual` and every other required row passes; refinement insertions are not vetoed
155
+ * one by one, and the refined mesh is held to it before any removal. The result's measurement carries the row.
156
+ * `null` declares it absent (no row read by a step, the result's row `not-measurable` saying so); anything else that
157
+ * is not `{ envelope, maxResidual }` in full is refused `REDUCE_INPUT_MISSING` naming its path, before any work.
158
+ */
159
+ skinning?: ReductionSkinning | null;
150
160
  }
151
161
 
152
162
  /** P16/P17: a local density requirement over a closed polygon. */
@@ -2359,6 +2369,11 @@ function targetsJson(t: ReductionTargets | MeasureTargets | null): Json {
2359
2369
  const out: { [key: string]: Json } = { artFit: fitJson(t.artFit), maxBoundaryDeviation: t.maxBoundaryDeviation };
2360
2370
  if (t.minAngle !== undefined) out.minAngle = t.minAngle;
2361
2371
  out.regions = t.regions.map(regionJson);
2372
+ // Issue #1295: a reduction's `targets.skinning`, echoed when the input set it — `null` included — and never otherwise.
2373
+ if ('skinning' in t && t.skinning !== undefined) {
2374
+ const k = t.skinning;
2375
+ out.skinning = k === null ? null : { envelope: { reference: k.envelope.reference, bones: k.envelope.bones.map((b) => ({ bone: b.bone, linear: b.linear, pivot: pair(b.pivot), translation: b.translation })) }, maxResidual: k.maxResidual };
2376
+ }
2362
2377
  return out;
2363
2378
  }
2364
2379