spine-rigc 2.18.0 → 2.19.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 +1 -1
- package/src/mesh.ts +3 -0
- package/src/meshquality.ts +216 -19
- package/src/meshreduce.ts +1380 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spine-rigc",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.19.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": {
|
package/src/mesh.ts
CHANGED
|
@@ -49,7 +49,10 @@ import type { ModelBinding, ModelVertices } from './model.ts';
|
|
|
49
49
|
// `spine-rigc/mesh` names. That module imports helpers from this one, so the
|
|
50
50
|
// two form an import cycle: safe only because neither reads the other's
|
|
51
51
|
// bindings while it is being evaluated — every use is inside a function.
|
|
52
|
+
// The reduction (`reduceMesh`, stage B2 of #1224) sits in the same cycle, by
|
|
53
|
+
// the same rule.
|
|
52
54
|
export * from './meshquality.ts';
|
|
55
|
+
export * from './meshreduce.ts';
|
|
53
56
|
|
|
54
57
|
export interface MeshSpecInput {
|
|
55
58
|
/** Polygon in part-local pixels, y down, in manifest order. */
|
package/src/meshquality.ts
CHANGED
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
* attachment draws, and the bounds the caller declared, measured into one
|
|
7
7
|
* `mesh-quality-report/1` document (`writeMeshQualityReport` writes its text).
|
|
8
8
|
* Every type the contract declares is declared here too, including the ones
|
|
9
|
-
* only the reduction (`reduceMesh`, stage B2) and the
|
|
10
|
-
* (stage C)
|
|
11
|
-
* than restating them.
|
|
9
|
+
* only the reduction (`reduceMesh`, stage B2, in `src/meshreduce.ts`) and the
|
|
10
|
+
* motion comparison (stage C) fill, so that those stages build on one set of
|
|
11
|
+
* types rather than restating them.
|
|
12
12
|
*
|
|
13
13
|
* ## What it is for
|
|
14
14
|
*
|
|
@@ -181,7 +181,28 @@ export interface InfluenceLimits {
|
|
|
181
181
|
minWeight: number;
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
-
/**
|
|
184
|
+
/**
|
|
185
|
+
* One deform key of a timeline the reduced attachment is keyed under (§6, P18),
|
|
186
|
+
* in the form the compiler emits it: a `vertices` run is the emitted `offset`
|
|
187
|
+
* (an index into the deform array — two numbers per vertex unweighted, two per
|
|
188
|
+
* influence weighted, AUTHORING §4.11) and the numbers copied in from there; a
|
|
189
|
+
* `transform` key is a model the compile evaluates over the attachment's own
|
|
190
|
+
* geometry (§4.11.1), so it has no run to remap; a `setup` key carries no run.
|
|
191
|
+
*/
|
|
192
|
+
export type DeformKeyInput =
|
|
193
|
+
| { time: number; kind: 'setup' }
|
|
194
|
+
| { time: number; kind: 'vertices'; offset: number; vertices: number[] }
|
|
195
|
+
| { time: number; kind: 'transform' };
|
|
196
|
+
|
|
197
|
+
/** One deform timeline: the animation, the attachment it is keyed on, and its keys in order. */
|
|
198
|
+
export interface DeformTimelineInput {
|
|
199
|
+
animation: string;
|
|
200
|
+
/** The reduced attachment itself, or one of `MeshReductionInput.linkedMeshes`. */
|
|
201
|
+
attachment: AttachmentRef;
|
|
202
|
+
keys: DeformKeyInput[];
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Everything a reduction reads (stage B2, `reduceMesh` in `src/meshreduce.ts`). No field has a default inside the operation. */
|
|
185
206
|
export interface MeshReductionInput {
|
|
186
207
|
attachment: AttachmentRef;
|
|
187
208
|
art: ArtInput;
|
|
@@ -198,8 +219,16 @@ export interface MeshReductionInput {
|
|
|
198
219
|
boneOrder: string[] | null;
|
|
199
220
|
/** P5: the preset the caller expanded, if any, echoed and never read. */
|
|
200
221
|
preset: { name: string; version: string } | null;
|
|
201
|
-
/** Work bound — *Termination reasons*. */
|
|
222
|
+
/** Work bound — *Termination reasons*. Every refinement insertion and every removal attempted counts one. */
|
|
202
223
|
budget: { maxCandidates: number };
|
|
224
|
+
/** P9: the fewest art samples the attachment's raster rows are taken over. A whole number >= 1. */
|
|
225
|
+
minArtSamples: number;
|
|
226
|
+
/** P9: one floor per region, by region name — every region named once. */
|
|
227
|
+
regionArtSamples: Array<{ region: string; minArtSamples: number }>;
|
|
228
|
+
/** P18: every deform timeline keyed on the attachment or on one of its linked meshes; empty when none. */
|
|
229
|
+
deform: DeformTimelineInput[];
|
|
230
|
+
/** Every linked mesh of the source (they inherit the new topology); empty when none. */
|
|
231
|
+
linkedMeshes: AttachmentRef[];
|
|
203
232
|
}
|
|
204
233
|
|
|
205
234
|
/**
|
|
@@ -341,6 +370,29 @@ export interface CandidateReport {
|
|
|
341
370
|
accepted: boolean;
|
|
342
371
|
/** Opt-in (P7): every row's value at every frame. Absent unless asked for. */
|
|
343
372
|
perFrame?: Array<{ code: string; frame: string; value: number | null }>;
|
|
373
|
+
/** A `reduce` whose result exists: what the operation changed. Absent on a `measure` and when no mesh is returned. */
|
|
374
|
+
changes?: ReductionChanges;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* What a reduction changed, counted rather than described — the figures the
|
|
379
|
+
* contract says are reported (§6, P18 and P19) and that no geometry row carries.
|
|
380
|
+
*/
|
|
381
|
+
export interface ReductionChanges {
|
|
382
|
+
/** Source vertices the reduction removed. */
|
|
383
|
+
removedVertices: number;
|
|
384
|
+
/** Vertices the refinement inserted (§5). */
|
|
385
|
+
insertedVertices: number;
|
|
386
|
+
/** P19: positive interpolated shares that are 0 on the 6-decimal weight grid, dropped rather than written as 0. */
|
|
387
|
+
sharesDroppedOnGrid: number;
|
|
388
|
+
/** Interpolated shares pruned by `InfluenceLimits` — over `maxInfluences`, or under a nonzero `minWeight`. */
|
|
389
|
+
sharesPruned: number;
|
|
390
|
+
/** P18: `vertices` keys remapped, each with the source vertices whose offsets were dropped. */
|
|
391
|
+
deformRemapped: Array<{ animation: string; attachment: AttachmentRef; key: number; droppedVertices: number[] }>;
|
|
392
|
+
/** P18: `transform` keys, re-evaluated over the new geometry at compile rather than remapped. */
|
|
393
|
+
deformReevaluated: Array<{ animation: string; attachment: AttachmentRef; key: number }>;
|
|
394
|
+
/** Every linked mesh of the source: each inherits the new topology. */
|
|
395
|
+
linkedMeshes: AttachmentRef[];
|
|
344
396
|
}
|
|
345
397
|
|
|
346
398
|
export interface MeshQualityReport {
|
|
@@ -421,6 +473,20 @@ const HAUSDORFF_TOLERANCE = 1e-9;
|
|
|
421
473
|
/** The predicate epsilon `segmentsMeet` and `prunePolygon` use (`src/mesh.ts`), for "on the boundary". */
|
|
422
474
|
const ON_BOUNDARY = 1e-9;
|
|
423
475
|
|
|
476
|
+
/**
|
|
477
|
+
* How near a region's band's outer boundary, in drawing px, an edge's nearest
|
|
478
|
+
* point has to come to count as touching it rather than entering the band —
|
|
479
|
+
* the precision of "a single point on the outer boundary" in
|
|
480
|
+
* `edgeIsHeldByRegion`, ten units of the `r6` grid. The refinement puts the
|
|
481
|
+
* vertex it splits an edge at half of this inside the outer boundary and then
|
|
482
|
+
* on the grid, which moves it by at most √2 × 5e-7 px, so the piece beyond it
|
|
483
|
+
* lands inside this tolerance from either side.
|
|
484
|
+
*/
|
|
485
|
+
export const BAND_CONTACT_TOLERANCE = 1e-5;
|
|
486
|
+
|
|
487
|
+
/** Halvings of the parameter interval in the convex searches along an edge (`edgeIsHeldByRegion`): 2^-60 of an edge is below a double's resolution of it. */
|
|
488
|
+
const EDGE_SEARCH_STEPS = 60;
|
|
489
|
+
|
|
424
490
|
// ---------------------------------------------------------------------------
|
|
425
491
|
// refusals
|
|
426
492
|
// ---------------------------------------------------------------------------
|
|
@@ -656,6 +722,110 @@ function segmentToPolygon(a: Pt, b: Pt, poly: readonly Pt[]): number {
|
|
|
656
722
|
return best;
|
|
657
723
|
}
|
|
658
724
|
|
|
725
|
+
/**
|
|
726
|
+
* The parameter in `[0, 1]` along `a`–`b` nearest which `c`–`d` lies — the
|
|
727
|
+
* minimum of the distance from `a + t(b − a)` to the segment `c`–`d`, which is
|
|
728
|
+
* convex in `t`, found by golden-section search. A flat minimum (parallel
|
|
729
|
+
* segments) returns some point of it; the callers never read one there.
|
|
730
|
+
*/
|
|
731
|
+
function nearestParameter(a: Pt, b: Pt, c: Pt, d: Pt): number {
|
|
732
|
+
const at = (t: number): number => distanceToSegment([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t], c, d);
|
|
733
|
+
const g = (Math.sqrt(5) - 1) / 2;
|
|
734
|
+
let lo = 0;
|
|
735
|
+
let hi = 1;
|
|
736
|
+
for (let i = 0; i < EDGE_SEARCH_STEPS; i++) {
|
|
737
|
+
const p = hi - g * (hi - lo);
|
|
738
|
+
const q = lo + g * (hi - lo);
|
|
739
|
+
if (at(p) <= at(q)) hi = q;
|
|
740
|
+
else lo = p;
|
|
741
|
+
}
|
|
742
|
+
return (lo + hi) / 2;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/**
|
|
746
|
+
* Does `a`–`b` run along the outer boundary of a band of width `transition`
|
|
747
|
+
* for a positive length — some polygon edge it overlaps in projection by more
|
|
748
|
+
* than `BAND_CONTACT_TOLERANCE`, at that edge's perpendicular distance
|
|
749
|
+
* `transition` (within the tolerance) at both ends of the overlap, so at every
|
|
750
|
+
* point between them? The caller has already found the edge's nearest approach
|
|
751
|
+
* to the polygon within the tolerance of `transition`.
|
|
752
|
+
*/
|
|
753
|
+
function runsAlongOuterBoundary(a: Pt, b: Pt, poly: readonly Pt[], transition: number): boolean {
|
|
754
|
+
const length = Math.hypot(b[0] - a[0], b[1] - a[1]);
|
|
755
|
+
const n = poly.length;
|
|
756
|
+
for (let i = 0; i < n; i++) {
|
|
757
|
+
const c = poly[i];
|
|
758
|
+
const e = poly[(i + 1) % n];
|
|
759
|
+
const side = Math.hypot(e[0] - c[0], e[1] - c[1]);
|
|
760
|
+
if (side === 0) continue;
|
|
761
|
+
const ux = (e[0] - c[0]) / side;
|
|
762
|
+
const uy = (e[1] - c[1]) / side;
|
|
763
|
+
const sa = (a[0] - c[0]) * ux + (a[1] - c[1]) * uy;
|
|
764
|
+
const sb = (b[0] - c[0]) * ux + (b[1] - c[1]) * uy;
|
|
765
|
+
let t0 = 0;
|
|
766
|
+
let t1 = 1;
|
|
767
|
+
if (sa === sb) {
|
|
768
|
+
if (sa < 0 || sa > side) continue;
|
|
769
|
+
} else {
|
|
770
|
+
const p = (0 - sa) / (sb - sa);
|
|
771
|
+
const q = (side - sa) / (sb - sa);
|
|
772
|
+
t0 = Math.max(0, Math.min(p, q));
|
|
773
|
+
t1 = Math.min(1, Math.max(p, q));
|
|
774
|
+
}
|
|
775
|
+
if ((t1 - t0) * length <= BAND_CONTACT_TOLERANCE) continue;
|
|
776
|
+
const off = (t: number): number => Math.abs(ux * (a[1] + (b[1] - a[1]) * t - c[1]) - uy * (a[0] + (b[0] - a[0]) * t - c[0]));
|
|
777
|
+
if (Math.abs(off(t0) - transition) <= BAND_CONTACT_TOLERANCE && Math.abs(off(t1) - transition) <= BAND_CONTACT_TOLERANCE) return true;
|
|
778
|
+
}
|
|
779
|
+
return false;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/**
|
|
783
|
+
* [agreed, spine-parts#126] Is the edge `a`–`b` held by `region` — does the
|
|
784
|
+
* region's density bound (`MQ_MAX_EDGE` or `MQ_TRANSITION`) apply to it? The
|
|
785
|
+
* one definition both `measureMeshQuality` and `reduceMesh`'s refinement read.
|
|
786
|
+
*
|
|
787
|
+
* The region's active domain is its closed polygon and, when `transition > 0`,
|
|
788
|
+
* the band of that width outside it. An edge is held when it meets that domain,
|
|
789
|
+
* except for one case: its intersection with the domain is a **single point on
|
|
790
|
+
* the band's outer boundary** and the rest of the edge lies outside — an edge
|
|
791
|
+
* that only touches the band from outside is exempt.
|
|
792
|
+
*
|
|
793
|
+
* - An edge that meets the closed polygon is held, whatever the band.
|
|
794
|
+
* - With `transition: 0` there is no outer band, and a contact with the
|
|
795
|
+
* authored boundary is held under the closed-region rule — never exempt.
|
|
796
|
+
* - A positive-length intersection is held, including a segment lying along
|
|
797
|
+
* the band's outer boundary.
|
|
798
|
+
* - Two separate touches of the outer boundary are two points, not one, and
|
|
799
|
+
* the edge is held.
|
|
800
|
+
*
|
|
801
|
+
* "On the outer boundary" is within `BAND_CONTACT_TOLERANCE`. Each region is
|
|
802
|
+
* read on its own: an exemption from one never removes another's bound.
|
|
803
|
+
*/
|
|
804
|
+
export function edgeIsHeldByRegion(a: readonly [number, number], b: readonly [number, number], region: RefinementRegion): boolean {
|
|
805
|
+
const poly: readonly Pt[] = region.polygon;
|
|
806
|
+
if (segmentMeetsPolygon(a, b, poly)) return true;
|
|
807
|
+
const transition = region.transition;
|
|
808
|
+
if (!(transition > 0)) return false;
|
|
809
|
+
const d = segmentToPolygon(a, b, poly);
|
|
810
|
+
if (d > transition) return false;
|
|
811
|
+
if (d < transition - BAND_CONTACT_TOLERANCE) return true;
|
|
812
|
+
if (runsAlongOuterBoundary(a, b, poly, transition)) return true;
|
|
813
|
+
// Where on the edge each polygon side is touched: one point, or several apart.
|
|
814
|
+
const length = Math.hypot(b[0] - a[0], b[1] - a[1]);
|
|
815
|
+
let first = Infinity;
|
|
816
|
+
let last = -Infinity;
|
|
817
|
+
const n = poly.length;
|
|
818
|
+
for (let i = 0; i < n; i++) {
|
|
819
|
+
const c = poly[i];
|
|
820
|
+
const e = poly[(i + 1) % n];
|
|
821
|
+
if (segmentDistance(a, b, c, e) > transition) continue;
|
|
822
|
+
const t = nearestParameter(a, b, c, e);
|
|
823
|
+
first = Math.min(first, t);
|
|
824
|
+
last = Math.max(last, t);
|
|
825
|
+
}
|
|
826
|
+
return (last - first) * length > BAND_CONTACT_TOLERANCE;
|
|
827
|
+
}
|
|
828
|
+
|
|
659
829
|
/** Distance from a point to a closed polyline (the polygon's boundary), and the edge that realises it. */
|
|
660
830
|
function pointToBoundary(p: Pt, poly: readonly Pt[]): { d: number; edge: number } {
|
|
661
831
|
let d = Infinity;
|
|
@@ -1149,9 +1319,12 @@ interface Edge {
|
|
|
1149
1319
|
* graded band, only where the band has width) and `MQ_FILL_DISTANCE` (`h(R)`,
|
|
1150
1320
|
* sampled, informational).
|
|
1151
1321
|
*
|
|
1152
|
-
*
|
|
1153
|
-
*
|
|
1154
|
-
*
|
|
1322
|
+
* Which edges a region holds is `edgeIsHeldByRegion`'s answer and nothing
|
|
1323
|
+
* else's: an edge that only touches the band's outer boundary at one point,
|
|
1324
|
+
* from outside, is not held (spine-parts#126). One bound per edge, the smallest
|
|
1325
|
+
* applicable anywhere on it (P16): `L0` of every region whose closed polygon it
|
|
1326
|
+
* meets, and `L0 + grade·d` of every other region that holds it, `d` its
|
|
1327
|
+
* distance from that region. A row's value is the edge whose
|
|
1155
1328
|
* length most exceeds its own bound — so the row fails exactly when some edge
|
|
1156
1329
|
* it covers is over — and the row names that edge and that bound.
|
|
1157
1330
|
*/
|
|
@@ -1190,15 +1363,29 @@ function regionRows(input: MeshMeasureInput, outline: MeshOutline, hullPolygon:
|
|
|
1190
1363
|
else refusals.set(region.name, `${refusal.code}: ${refusal.message}`);
|
|
1191
1364
|
}
|
|
1192
1365
|
|
|
1193
|
-
/**
|
|
1366
|
+
/**
|
|
1367
|
+
* Per measurable region, per edge (in `edges` order): the edge's distance
|
|
1368
|
+
* from the closed polygon when the region holds it (`edgeIsHeldByRegion`,
|
|
1369
|
+
* the one definition the refinement reads too), null when it does not.
|
|
1370
|
+
*/
|
|
1371
|
+
const holds = new Map<RefinementRegion, Array<number | null>>();
|
|
1372
|
+
for (const region of live) {
|
|
1373
|
+
holds.set(
|
|
1374
|
+
region,
|
|
1375
|
+
edges.map((e) => (edgeIsHeldByRegion(points[e.a], points[e.b], region) ? segmentToPolygon(points[e.a], points[e.b], region.polygon) : null)),
|
|
1376
|
+
);
|
|
1377
|
+
}
|
|
1378
|
+
const edgeIndex = new Map(edges.map((e, i) => [e, i]));
|
|
1379
|
+
|
|
1380
|
+
/** Every bound that applies to an edge, from every measurable region that holds it, and the smallest. */
|
|
1194
1381
|
const boundOf = (e: Edge): number | null => {
|
|
1195
1382
|
let bound: number | null = null;
|
|
1383
|
+
const i = edgeIndex.get(e)!;
|
|
1196
1384
|
for (const region of live) {
|
|
1197
|
-
const d =
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
if (here !== null && (bound === null || here < bound)) bound = here;
|
|
1385
|
+
const d = holds.get(region)![i];
|
|
1386
|
+
if (d === null) continue;
|
|
1387
|
+
const here = d === 0 ? region.maxEdgeLength : r6(region.maxEdgeLength + region.grade * d);
|
|
1388
|
+
if (bound === null || here < bound) bound = here;
|
|
1202
1389
|
}
|
|
1203
1390
|
return bound;
|
|
1204
1391
|
};
|
|
@@ -1228,7 +1415,8 @@ function regionRows(input: MeshMeasureInput, outline: MeshOutline, hullPolygon:
|
|
|
1228
1415
|
continue;
|
|
1229
1416
|
}
|
|
1230
1417
|
const poly: Pt[] = region.polygon;
|
|
1231
|
-
const
|
|
1418
|
+
const held = holds.get(region)!;
|
|
1419
|
+
const inside = edges.filter((_, i) => held[i] === 0);
|
|
1232
1420
|
const maxSpec = spec('MQ_MAX_EDGE', 'px', region.name);
|
|
1233
1421
|
const inWorst = worstOf(inside);
|
|
1234
1422
|
if (inWorst === null) {
|
|
@@ -1237,10 +1425,7 @@ function regionRows(input: MeshMeasureInput, outline: MeshOutline, hullPolygon:
|
|
|
1237
1425
|
out.push(measuredRow(maxSpec, r6(inWorst.edge.length), { op: '<=', value: inWorst.bound }, { at: { edge: [inWorst.edge.a, inWorst.edge.b] } }, true));
|
|
1238
1426
|
}
|
|
1239
1427
|
if (region.transition > 0) {
|
|
1240
|
-
const banded = edges.filter((
|
|
1241
|
-
const d = segmentToPolygon(points[e.a], points[e.b], poly);
|
|
1242
|
-
return d > 0 && d <= region.transition;
|
|
1243
|
-
});
|
|
1428
|
+
const banded = edges.filter((_, i) => held[i] !== null && held[i]! > 0);
|
|
1244
1429
|
const bandWorst = worstOf(banded);
|
|
1245
1430
|
const transitionSpec = spec('MQ_TRANSITION', 'px', region.name);
|
|
1246
1431
|
if (bandWorst === null) {
|
|
@@ -1470,6 +1655,18 @@ function candidateJson(c: CandidateReport): Json {
|
|
|
1470
1655
|
accepted: c.accepted,
|
|
1471
1656
|
};
|
|
1472
1657
|
if (c.perFrame !== undefined) out.perFrame = c.perFrame.map((p) => ({ code: p.code, frame: p.frame, value: p.value }));
|
|
1658
|
+
if (c.changes !== undefined) {
|
|
1659
|
+
const k = c.changes;
|
|
1660
|
+
out.changes = {
|
|
1661
|
+
removedVertices: k.removedVertices,
|
|
1662
|
+
insertedVertices: k.insertedVertices,
|
|
1663
|
+
sharesDroppedOnGrid: k.sharesDroppedOnGrid,
|
|
1664
|
+
sharesPruned: k.sharesPruned,
|
|
1665
|
+
deformRemapped: k.deformRemapped.map((d) => ({ animation: d.animation, attachment: attachmentJson(d.attachment), key: d.key, droppedVertices: [...d.droppedVertices] })),
|
|
1666
|
+
deformReevaluated: k.deformReevaluated.map((d) => ({ animation: d.animation, attachment: attachmentJson(d.attachment), key: d.key })),
|
|
1667
|
+
linkedMeshes: k.linkedMeshes.map(attachmentJson),
|
|
1668
|
+
};
|
|
1669
|
+
}
|
|
1473
1670
|
return out;
|
|
1474
1671
|
}
|
|
1475
1672
|
|