@rig-c/core 2.78.0 → 2.79.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/docs/AUTHORING.md CHANGED
@@ -12647,7 +12647,7 @@ verdict on those figures.
12647
12647
  | <a id="CE_COMPILE_ALPHA_CLIP_CONFLICT"></a>`CE_COMPILE_ALPHA_CLIP_CONFLICT` | Names authored fields or convex/inverse flags conflicting with generated inside-clipping geometry. Use one authored/generated form.<br>Exact fixed diagnostic chunks (named values between chunks vary): ⟦: alphaClip conflicts with [⟧<br>⟦vertexCount⟧<br>⟦vertices⟧<br>⟦weights⟧<br>⟦boneIndexing⟧<br>⟦, ⟧<br>⟦], convex=⟧<br>⟦, inverse=⟧<br>⟦; required ordinary inside clipping with generated geometry only. Remove authored geometry/flags.⟧ |
12648
12648
  | <a id="CE_COMPILE_ALPHA_CLIP_DEFORM"></a>`CE_COMPILE_ALPHA_CLIP_DEFORM` | Names animation and source/clip key/frame counts that cannot share the existing projection contract. Give matching explicit supported transform keys in compatible actual frames, with constant world shares where needed, or author the corresponding clip geometry and motion; weighted binding does not copy source deform.<br>Exact fixed diagnostic chunks (named values between chunks vary): ⟦ alphaClip from "⟧<br>⟦"⟧<br>⟦: animation "⟧<br>⟦" has ⟧<br>⟦ source and ⟧<br>⟦ clip keys; source frame=⟧<br>⟦no vertex geometry⟧<br>⟦clip frame=⟧<br>⟦, constant world influence shares=⟧<br>⟦Required matching explicit affine/depth-yaw/pitch transforms in compatible DeformGeometry frames, ⟧<br>⟦with constant shares for world-space transfer. Fix the named frame/keys or author corresponding clip motion.⟧ |
12649
12649
 
12650
- ## 15. Posing in place for a player — the runtime poser (stage 5)
12650
+ ## 15. Posing in place for a player — the runtime poser (stage 9)
12651
12651
 
12652
12652
  rigc poses a model document twice over, on purpose (issue #1591). The **oracle**
12653
12653
  (`src/core/`) is the reference: each pose is a pure function of the document,
@@ -12682,16 +12682,31 @@ exact resolved table identity `(skin, model.slots[attachment.slot].name, placeho
12682
12682
  The display `name` may be shared by different placeholders; it is never a table
12683
12683
  key to guess the source from. These load-time strings do not change the pose path.
12684
12684
 
12685
- | | what stage 5 poses |
12685
+ | | what stage 9 poses |
12686
12686
  | --- | --- |
12687
12687
  | bones | local and world transforms, all five inherit modes, skin-required bones under the view's skin (an inactive bone is zeros, as the oracle poses it) |
12688
12688
  | timelines | `rotate`, `translate(x/y)`, `scale(x/y)`, `shear(x/y)`; a slot's `attachment`, light colour (`rgba`, `rgb`, `alpha`) and dark colour (`rgba2`, `rgb2`); `drawOrder`; ik, transform and path timelines; linear, stepped and Bézier keys, each read from a per-pose key cursor |
12689
- | constraints | physics with its exact clock, reset and parameter timelines, and optional constraint-scoped external translate/rotate; value-mapped sliders with bone/slot/draw-order/deform/physics composition; path constraints across all position, spacing and rotate modes on open or closed, constant-speed or arc-length, weighted or unweighted, deformed curves; one- and two-bone ik; world-space, absolute transform constraints; the document's order, each followed by the bones below what it moved posed again |
12689
+ | constraints | physics with its exact clock, reset and parameter timelines, and optional constraint-scoped external translate/rotate; value-mapped sliders with bone/slot/draw-order/deform/physics composition; path constraints across all position, spacing and rotate modes on open or closed, constant-speed or arc-length, weighted or unweighted, deformed curves; one- and two-bone ik; absolute/additive transform constraints with local or world sources and targets; the document's order, each followed by the bones below what it moved posed again |
12690
12690
  | vertices | regions, meshes, weighted meshes and linked meshes, written into the pose's buffer |
12691
12691
  | deform | weighted and unweighted mesh and path timelines, linear/stepped/Bézier, per-pose key cursors and in-place buffers; linked meshes inherit their source's timelines only with `timelines: true` |
12692
12692
  | skins and events | named/no-skin views with their skin-required bones and constraints; event delivery in key order over `(last, time]`, tail then head across a wrap |
12693
12693
  | time | one animation at full weight from the setup pose, at a time (`apply`) or stepped as a looping track (`step`, the track time wrapped by the duration as `openTrack` wraps it) |
12694
12694
 
12695
+ Clipping draw output is read by `pose.computeClipped()`. It recomputes vertices
12696
+ and overwrites `pose.clipping`'s preallocated `clipSlots`, `drawSlots` and per-slot
12697
+ `outputs`, with `clipCount` and `drawCount` delimiting live rows. `pose.clipping`
12698
+ is null on a model without clipping. Only draws under an active clip appear
12699
+ there; this is not the complete draw list. Each output has vertex/triangle
12700
+ counts, float32 coordinates and UVs, triangle indices, source triangle indices
12701
+ and the cut flag. Slot-end/start order follows `poseClipped`; polygon planning
12702
+ and arithmetic follow `clipShapeOf`, `clipThrough` and `clipTriangles`.
12703
+ Concave/inverse rows follow the core's drawn coverage. The oracle's native
12704
+ triangle list is absent there, so the third dumper refuses that reading as
12705
+ `oracle-absent` rather than relabeling coverage. Self-crossing, unsupported
12706
+ repeated vertices, collapsed polygons and non-finite clipped coordinates or
12707
+ UVs are rejected when the polygon is prepared or clipped, as the core rejects
12708
+ those inputs; no arbitrary-polygon or singular-input coverage is claimed.
12709
+
12695
12710
  Deform keys are expanded once at load. Unweighted keys hold float32 positions,
12696
12711
  `fround(fround(setup) + fround(offset))`; weighted keys hold float32 offsets,
12697
12712
  **one x/y pair per influence**, in vertex then influence order. At a pose,
@@ -12767,9 +12782,41 @@ constraints later in update order. An unsupported timeline in a slider's
12767
12782
  animation refuses the whole model under that timeline's existing feature name,
12768
12783
  so an unselected track cannot hide a construct the slider always applies.
12769
12784
 
12785
+ Transform constraints read each source property from the current local
12786
+ values (`localSource: true`) or world matrix, including earlier constraints'
12787
+ read-back, apply offsets and cross-mappings in declaration order, then clamp and
12788
+ blend by the keyed property mix. `localTarget: true` writes local values and
12789
+ re-poses the changed bones and their descendants in bone order without a world
12790
+ read-back; world targets keep their edited world matrices and read back locals.
12791
+ A zero mix leaves that property untouched. Both local and world targets accept
12792
+ all five inherit modes. For a parented `noScale` or `noScaleOrReflection` world
12793
+ target, read-back derives rotation from the parent's adjugate and the edited
12794
+ world x column, rebuilds that mode's normalised frame, and decomposes the world
12795
+ columns in it. The residual x angle is kept as `shearX`; `noScale` preserves a
12796
+ parent reflection and `noScaleOrReflection` removes it. This is the oracle's
12797
+ `localFromWorld`, `noScaleDirection` and `decompose` order; subsequent local
12798
+ readers and parent edits observe those same locals. Stage-seven controls
12799
+ `RP43`–`RP46` hold both modes to the oracle with omission and allocation plants.
12800
+ With `additive: true`, a local position, rotation or shear adds `v * mix`
12801
+ and a local scale multiplies by `1 + (v - 1) * mix`. A world rotation turns by
12802
+ `v * RAD` wrapped once at the runtime pi before applying mix; world translation
12803
+ adds `v * mix`, and world scale multiplies by `1 + (v - 1) * mix` (a zero-length
12804
+ column is left alone). World additive shear uses `(v + 90) * RAD - 90 * RAD`
12805
+ without wrapping, then adds the mixed angle to the existing y-column angle.
12806
+ That subtraction order is kept exactly, as in the oracle's `applyWorld`;
12807
+ replacing it with `v * RAD` is not bit-equivalent. Source reading, mappings,
12808
+ clamp, keyed mixes and the final union repose retain `solveTransform` order.
12809
+ Stage-nine controls `RP53`–`RP56` compare buffered primitives and default
12810
+ admission to the oracle, require every omission to differ, and measure pose
12811
+ stepping plus clipping with a per-frame object plant. Stage-eight controls
12812
+ `RP47`–`RP52` hold all four source/target forms to the
12813
+ oracle, with an omission plant and the steady-state allocation meter.
12814
+ Stage-six controls `RP38`–`RP42` hold local-source, local-target
12815
+ and both forms to the oracle with omission and allocation plants.
12816
+
12770
12817
  **Everything else is refused at load, by name.** `RuntimeRefusal.missing` lists
12771
12818
  every construct met, each `{ feature, detail, animation }`, and the message
12772
- reads `not posed by the runtime poser (stage 5), N construct(s) missing:
12819
+ reads `not posed by the runtime poser (stage 9), N construct(s) missing:
12773
12820
  <feature>: <detail>; …`. A model-level feature refuses the whole model; an
12774
12821
  animation-level one refuses that animation alone (`model.refused`;
12775
12822
  `model.animation(name)` and `setAnimation(name)` throw its refusal) and every
@@ -12778,10 +12825,8 @@ refused: nothing is posed approximately.
12778
12825
 
12779
12826
  | feature | refuses | what it is |
12780
12827
  | --- | --- | --- |
12781
- | `local-transform`, `additive-transform` | the model | a transform constraint with `localSource` or `localTarget`, or `additive` |
12782
12828
  | `inactive-constrained-bone` | the model | an applied ik, transform or path constraint naming a bone the view leaves inactive (what the oracle writes there depends on the pass before) |
12783
- | `noscale-world-target` | the model | a world-space transform onto a `noScale` or `noScaleOrReflection` bone, whose local values would be read back in that mode |
12784
- | `clipping`, `null-atlas-region` | the model when shown at setup, else the animation that keys it | a clipping attachment; a region whose atlas rectangle the model states as `null` |
12829
+ | `null-atlas-region` | the model when shown at setup, else the animation that keys it | a region whose atlas rectangle the model states as `null` |
12785
12830
  | `skin-conflict` | as above | a placeholder the skins fill differently |
12786
12831
  | `oracle-absent` | the model | a pose the oracle itself leaves out (`constraintsAbsentWhy`, `pathAnimationsWhy`, `steppedPreviousPassWhy`, `sliderDeformsWalkedPathWhy`) |
12787
12832
  | `inherit-timeline`, `sequence-timeline` | the animation | a timeline of that kind |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rig-c/core",
3
- "version": "2.78.0",
3
+ "version": "2.79.0",
4
4
  "description": "AI-authored 2D rigging and animation, verified before it is written. rigc compiles a rig spec into skeleton data in the Spine 4.3 format, gates it with named assertions held to a spine-core round trip's verdicts, and emits nothing that fails. Ships as an agent skill.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,52 @@
1
+ /** Stage-nine buffered draw walk; RuntimePose owns the frame buffers. */
2
+ import type { RuntimeModel } from './model.ts';
3
+ import type { RuntimePose } from './pose.ts';
4
+ import { RuntimeClipper, RuntimeClipBuffer } from './clipping.ts';
5
+ /** Load once from the same model/view as the pose; frame() overwrites arrays and counts. */
6
+ export class RuntimeDrawClipper {
7
+ readonly clipSlots: Int32Array;
8
+ readonly drawSlots: Int32Array;
9
+ readonly outputs: readonly RuntimeClipBuffer[];
10
+ nativeRows = true;
11
+ clipCount = 0;
12
+ drawCount = 0;
13
+ private readonly triangles: readonly (Int32Array | null)[];
14
+ private readonly uvs: readonly (Float64Array | null)[];
15
+ private readonly clipper: RuntimeClipper;
16
+ constructor(readonly model: RuntimeModel) {
17
+ const triangles: Array<Int32Array | null> = [], uvs: Array<Float64Array | null> = [], maxTriangles = new Int32Array(model.slots.length);
18
+ let maxPolygon = 3;
19
+ for (const at of model.attachments) {
20
+ const t = at.clipTopology?.triangles ?? null, uv = at.clipTopology?.uvs ?? null;
21
+ if (at.clip !== undefined) maxPolygon = Math.max(maxPolygon, at.count / 2);
22
+ triangles.push(t); uvs.push(uv); if (t !== null) maxTriangles[at.slot] = Math.max(maxTriangles[at.slot], t.length / 3);
23
+ }
24
+ this.triangles = triangles; this.uvs = uvs; this.clipper = new RuntimeClipper(maxPolygon);
25
+ this.clipSlots = new Int32Array(model.slots.length); this.drawSlots = new Int32Array(model.slots.length);
26
+ this.outputs = Array.from(maxTriangles, n => new RuntimeClipBuffer(n, maxPolygon));
27
+ }
28
+ /** pose.computeVertices() must precede this walk. Omission is a diagnostic plant only. */
29
+ frame(pose: RuntimePose, omit = false): void {
30
+ if (pose.model !== this.model) throw new Error('runtime clip draw: pose differs from the prepared model');
31
+ this.clipCount = 0; this.drawCount = 0; this.nativeRows = true;
32
+ let active = false, end: string | null = null;
33
+ for (let i = 0; i < pose.drawOrder.length; i++) {
34
+ const s = pose.drawOrder[i], slot = this.model.slots[s], shown = pose.shown[s], at = shown < 0 ? null : this.model.attachments[shown];
35
+ if (at?.clip !== undefined) {
36
+ this.clipSlots[this.clipCount++] = s;
37
+ if (active && end === slot.name) active = false;
38
+ if (!omit && !active && this.model.active[at.bone] === 1) {
39
+ this.clipper.prepare(pose.vertices, at.offset, at.count, at.clip.inverse, at.clip.convex); this.nativeRows &&= this.clipper.nativeRows; active = true; end = at.clip.end;
40
+ }
41
+ continue;
42
+ }
43
+ if (active && at !== null) {
44
+ const triangles = this.triangles[shown], uvs = this.uvs[shown];
45
+ if (triangles !== null && uvs !== null) {
46
+ this.clipper.clip(pose.vertices, at.offset, triangles, uvs, this.outputs[s]); this.drawSlots[this.drawCount++] = s;
47
+ }
48
+ }
49
+ if (active && end === slot.name) active = false;
50
+ }
51
+ }
52
+ }
@@ -0,0 +1,223 @@
1
+ /** Buffered counterpart of clipShapeOf/clipThrough/clipTriangles in src/core/clipping.ts.
2
+ * RuntimePose.computeClipped overwrites these buffers for the live draw order.
3
+ * Scratch and outputs are allocated at load; a frame changes counts and array cells only.
4
+ * Concave/inverse output follows the core's drawn geometry, not an unclaimed Spine triangulation.
5
+ */
6
+ export class RuntimeClipBuffer {
7
+ readonly vertices: Float32Array;
8
+ readonly uvs: Float32Array;
9
+ readonly triangles: Int32Array;
10
+ readonly sources: Int32Array;
11
+ vertexCount = 0;
12
+ triangleCount = 0;
13
+ clipped = false;
14
+ constructor(triangles: number, polygonVertices: number) {
15
+ const points = triangles * Math.max(3, polygonVertices * (polygonVertices + 3));
16
+ if (!Number.isSafeInteger(points) || points < 0) throw new Error('runtime clip: output capacity is not a safe non-negative integer');
17
+ this.vertices = new Float32Array(points * 2); this.uvs = new Float32Array(points * 2);
18
+ this.triangles = new Int32Array(points * 3); this.sources = new Int32Array(points);
19
+ }
20
+ }
21
+
22
+ export class RuntimeClipper {
23
+ /** Whether the original world polygon has the oracle native convex triangle-list contract. */
24
+ nativeRows = false;
25
+ private readonly points: Float64Array;
26
+ private readonly ring: Int32Array;
27
+ private readonly work: Int32Array;
28
+ private readonly sorted: Int32Array;
29
+ private readonly hull: Int32Array;
30
+ private readonly pieces: Int32Array;
31
+ private readonly sizes: Int32Array;
32
+ private readonly merged: Int32Array;
33
+ private readonly regions: Float64Array;
34
+ private readonly regionSizes: Int32Array;
35
+ private readonly a: Float64Array;
36
+ private readonly b: Float64Array;
37
+ private readonly outside: Float64Array;
38
+ private readonly tri = new Float64Array(6);
39
+ private readonly uv = new Float64Array(6);
40
+ private regionsCount = 0;
41
+ private inverse = false;
42
+ private cut = false;
43
+ private n = 0;
44
+ constructor(readonly maxPolygonVertices: number) {
45
+ const n = Math.max(3, maxPolygonVertices);
46
+ this.points = new Float64Array(n * 2); this.ring = new Int32Array(n); this.work = new Int32Array(n);
47
+ this.sorted = new Int32Array(n); this.hull = new Int32Array(n * 2); this.pieces = new Int32Array(n * n);
48
+ this.sizes = new Int32Array(n); this.merged = new Int32Array(n * 2);
49
+ this.regions = new Float64Array(n * n * 2); this.regionSizes = new Int32Array(n);
50
+ this.a = new Float64Array((n * 2 + 16) * 2); this.b = new Float64Array(this.a.length); this.outside = new Float64Array(this.a.length);
51
+ }
52
+ private cross(a: number, b: number, c: number): number {
53
+ const p = this.points; return (p[b * 2] - p[a * 2]) * (p[c * 2 + 1] - p[a * 2 + 1]) - (p[b * 2 + 1] - p[a * 2 + 1]) * (p[c * 2] - p[a * 2]);
54
+ }
55
+ private area(indices: Int32Array, n: number): number {
56
+ const p = this.points; let area = 0;
57
+ for (let i = 0; i < n; i++) { const a = indices[i] * 2, b = indices[(i + 1) % n] * 2; area += p[a] * p[b + 1] - p[b] * p[a + 1]; }
58
+ return area;
59
+ }
60
+ private convex(indices: Int32Array, n: number, start = 0): boolean {
61
+ let sign = 0;
62
+ for (let i = 0; i < n; i++) {
63
+ const p = this.points, a = indices[start + i] * 2, b = indices[start + (i + 1) % n] * 2, c = indices[start + (i + 2) % n] * 2;
64
+ // convexWhy takes the second edge from b, unlike cross3 used by triangulation.
65
+ const cross = (p[b] - p[a]) * (p[c + 1] - p[b + 1]) - (p[b + 1] - p[a + 1]) * (p[c] - p[b]);
66
+ if (cross === 0) return false;
67
+ const next = Math.sign(cross); if (sign === 0) sign = next; else if (sign !== next) return false;
68
+ }
69
+ return true;
70
+ }
71
+ private on(a: number, b: number, r: number): boolean {
72
+ const p = this.points; return Math.min(p[a * 2], p[b * 2]) <= p[r * 2] && p[r * 2] <= Math.max(p[a * 2], p[b * 2]) && Math.min(p[a * 2 + 1], p[b * 2 + 1]) <= p[r * 2 + 1] && p[r * 2 + 1] <= Math.max(p[a * 2 + 1], p[b * 2 + 1]);
73
+ }
74
+ private meet(a: number, b: number, c: number, d: number): boolean {
75
+ const d1 = this.cross(c, d, a), d2 = this.cross(c, d, b), d3 = this.cross(a, b, c), d4 = this.cross(a, b, d);
76
+ if (((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0))) return true;
77
+ return d1 === 0 && this.on(c, d, a) || d2 === 0 && this.on(c, d, b) || d3 === 0 && this.on(a, b, c) || d4 === 0 && this.on(a, b, d);
78
+ }
79
+ private region(indices: Int32Array, n: number, r: number, start = 0): void {
80
+ const p = this.points; let area = 0;
81
+ for (let i = 0; i < n; i++) { const a = indices[start + i] * 2, b = indices[start + (i + 1) % n] * 2; area += p[a] * p[b + 1] - p[b] * p[a + 1]; }
82
+ const base = r * this.maxPolygonVertices * 2;
83
+ for (let i = 0; i < n; i++) { const j = indices[start + (area > 0 ? n - 1 - i : i)] * 2; this.regions[base + i * 2] = p[j]; this.regions[base + i * 2 + 1] = p[j + 1]; }
84
+ this.regionSizes[r] = n;
85
+ }
86
+ private makeHull(count: number): number {
87
+ const idx = this.sorted, p = this.points, h = this.hull;
88
+ for (let i = 0; i < count; i++) {
89
+ const v = this.ring[i]; let j = i;
90
+ while (j > 0) { const u = idx[j - 1]; if (p[u * 2] < p[v * 2] || p[u * 2] === p[v * 2] && p[u * 2 + 1] <= p[v * 2 + 1]) break; idx[j] = u; j--; }
91
+ idx[j] = v;
92
+ }
93
+ let used = 0;
94
+ for (let i = 0; i < count; i++) { const v = idx[i]; while (used >= 2 && this.cross(h[used - 2], h[used - 1], v) <= 0) used--; h[used++] = v; }
95
+ used--;
96
+ const lower = used;
97
+ for (let i = count - 1; i >= 0; i--) { const v = idx[i]; while (used >= lower + 2 && this.cross(h[used - 2], h[used - 1], v) <= 0) used--; h[used++] = v; }
98
+ return used - 1;
99
+ }
100
+ private plan(inverse: boolean, convex: boolean): boolean {
101
+ this.nativeRows = false;
102
+ const n = this.n, p = this.points, ring = this.ring;
103
+ let repeats = 0, distinct = 0;
104
+ for (let i = 0; i < n; i++) {
105
+ ring[i] = i;
106
+ for (let j = i + 1; j < n; j++) if (p[i * 2] === p[j * 2] && p[i * 2 + 1] === p[j * 2 + 1]) repeats++;
107
+ const j = (i + 1) % n;
108
+ if (p[i * 2] !== p[j * 2] || p[i * 2 + 1] !== p[j * 2 + 1]) this.work[distinct++] = i;
109
+ }
110
+ if (repeats > 0) {
111
+ if (!inverse && distinct === n - repeats && distinct >= 3 && this.convex(this.work, distinct)) { this.regionsCount = 1; this.inverse = false; this.region(ring, n, 0); return false; }
112
+ throw new Error('runtime clip: polygon has repeated vertices unsupported by clipShapeOf');
113
+ }
114
+ for (let i = 0; i < n; i++) {
115
+ const j = (i + 1) % n, k = (i + 2) % n;
116
+ if (this.cross(i, j, k) === 0 && (p[k * 2] - p[j * 2]) * (p[i * 2] - p[j * 2]) + (p[k * 2 + 1] - p[j * 2 + 1]) * (p[i * 2 + 1] - p[j * 2 + 1]) > 0) throw new Error('runtime clip: neighbouring polygon edges fold back');
117
+ for (let q = i + 2; q < n; q++) if (!(i === 0 && q === n - 1) && this.meet(i, j, q, (q + 1) % n)) throw new Error('runtime clip: polygon edges cross or touch');
118
+ }
119
+ const originalConvex = this.convex(ring, n);
120
+ this.nativeRows = !inverse && originalConvex;
121
+ let count = n;
122
+ for (let changed = true; changed && count > 3;) {
123
+ changed = false;
124
+ for (let i = 0; i < count; i++) if (this.cross(ring[(i + count - 1) % count], ring[i], ring[(i + 1) % count]) === 0) { ring.copyWithin(i, i + 1, count); count--; changed = true; break; }
125
+ }
126
+ if (count === 3 && this.cross(ring[0], ring[1], ring[2]) === 0) throw new Error('runtime clip: every polygon vertex lies on one line');
127
+ this.inverse = inverse;
128
+ if (inverse || !originalConvex && convex) { this.regionsCount = 1; this.region(this.hull, this.makeHull(count), 0); return false; }
129
+ if (originalConvex) { for (let i = 0; i < n; i++) ring[i] = i; this.regionsCount = 1; this.region(ring, n, 0); return false; }
130
+ if (this.area(ring, count) < 0) for (let i = 0; i < count / 2; i++) { const j = count - 1 - i, v = ring[i]; ring[i] = ring[j]; ring[j] = v; }
131
+ this.work.set(ring); let left = count, pieces = 0;
132
+ while (left > 3) {
133
+ let found = false;
134
+ for (let k = 0; k < left; k++) {
135
+ const a = this.work[(k + left - 1) % left], b = this.work[k], c = this.work[(k + 1) % left];
136
+ if (this.cross(a, b, c) <= 0) continue;
137
+ let inside = false;
138
+ for (let j = 0; j < left; j++) { const v = this.work[j]; if (v !== a && v !== b && v !== c && this.cross(a, b, v) >= 0 && this.cross(b, c, v) >= 0 && this.cross(c, a, v) >= 0) { inside = true; break; } }
139
+ if (inside) continue;
140
+ const base = pieces * this.maxPolygonVertices; this.pieces[base] = a; this.pieces[base + 1] = b; this.pieces[base + 2] = c; this.sizes[pieces++] = 3;
141
+ this.work.copyWithin(k, k + 1, left); left--; found = true; break;
142
+ }
143
+ if (!found) throw new Error('runtime clip: no ear left in a polygon held simple');
144
+ }
145
+ for (let i = 0; i < 3; i++) this.pieces[pieces * this.maxPolygonVertices + i] = this.work[i]; this.sizes[pieces++] = 3;
146
+ for (let changed = true; changed;) {
147
+ changed = false;
148
+ search: for (let a = 0; a < pieces; a++) for (let b = a + 1; b < pieces; b++) {
149
+ const an = this.sizes[a], bn = this.sizes[b], ab = a * this.maxPolygonVertices, bb = b * this.maxPolygonVertices;
150
+ for (let i = 0; i < an; i++) {
151
+ const u = this.pieces[ab + i], v = this.pieces[ab + (i + 1) % an]; let j = -1;
152
+ for (let k = 0; k < bn; k++) if (this.pieces[bb + k] === v) { j = k; break; }
153
+ if (j < 0 || this.pieces[bb + (j + 1) % bn] !== u) continue;
154
+ let size = 0;
155
+ for (let k = 0; k <= i; k++) this.merged[size++] = this.pieces[ab + k];
156
+ for (let k = (j + 2) % bn; k !== j; k = (k + 1) % bn) this.merged[size++] = this.pieces[bb + k];
157
+ for (let k = i + 1; k < an; k++) this.merged[size++] = this.pieces[ab + k];
158
+ let strict = true;
159
+ for (let k = 0; k < size; k++) if (this.cross(this.merged[(k + size - 1) % size], this.merged[k], this.merged[(k + 1) % size]) <= 0) { strict = false; break; }
160
+ if (!strict) continue;
161
+ for (let k = 0; k < size; k++) this.pieces[ab + k] = this.merged[k]; this.sizes[a] = size;
162
+ this.pieces.copyWithin(bb, bb + this.maxPolygonVertices, pieces * this.maxPolygonVertices); this.sizes.copyWithin(b, b + 1, pieces); pieces--; changed = true; break search;
163
+ }
164
+ }
165
+ }
166
+ this.regionsCount = pieces;
167
+ for (let i = 0; i < pieces; i++) this.region(this.pieces, this.sizes[i], i, i * this.maxPolygonVertices);
168
+ return pieces > 1;
169
+ }
170
+ /** Plan again after float32 conversion exactly when clipThrough first finds pieces. */
171
+ prepare(polygon: Float64Array, offset: number, count: number, inverse: boolean, convex: boolean): void {
172
+ this.n = count / 2;
173
+ if (!Number.isInteger(this.n) || this.n < 3 || this.n > this.maxPolygonVertices) throw new Error('runtime clip: polygon vertex count exceeds its load-time capacity');
174
+ for (let i = 0; i < count; i++) { const value = polygon[offset + i]; if (!Number.isFinite(value)) throw new Error('runtime clip: polygon contains a non-finite coordinate'); this.points[i] = value; }
175
+ const replan = this.plan(inverse, convex), nativeRows = this.nativeRows;
176
+ if (replan) { for (let i = 0; i < count; i++) this.points[i] = Math.fround(polygon[offset + i]); this.plan(inverse, convex); }
177
+ this.nativeRows = nativeRows;
178
+ }
179
+ private half(input: Float64Array, n: number, px: number, py: number, qx: number, qy: number, out: Float64Array): number {
180
+ let size = 0; this.cut = false; const ex = qx - px, ey = qy - py;
181
+ for (let i = 0; i < n; i++) {
182
+ const j = (i + 1) % n, sx = input[i * 2], sy = input[i * 2 + 1], tx = input[j * 2], ty = input[j * 2 + 1];
183
+ const si = ex * (sy - py) - ey * (sx - px) < 0, ei = ex * (ty - py) - ey * (tx - px) < 0;
184
+ if (!si || !ei) this.cut = true;
185
+ if (si !== ei) { const dx = tx - sx, dy = ty - sy, t = (ex * (sy - py) - ey * (sx - px)) / (ey * dx - ex * dy); if (size * 2 + 2 > out.length) throw new Error('runtime clip: scratch polygon exceeds its load-time capacity'); out[size * 2] = sx + t * dx; out[size * 2 + 1] = sy + t * dy; size++; }
186
+ if (ei) { if (size * 2 + 2 > out.length) throw new Error('runtime clip: scratch polygon exceeds its load-time capacity'); out[size * 2] = tx; out[size * 2 + 1] = ty; size++; }
187
+ }
188
+ return size;
189
+ }
190
+ private emit(points: Float64Array, count: number, cut: boolean, source: number, out: RuntimeClipBuffer): void {
191
+ if (count < 3) return;
192
+ if ((out.vertexCount + count) * 2 > out.vertices.length || out.triangleCount + count - 2 > out.sources.length) throw new Error('runtime clip: output exceeds its load-time capacity');
193
+ const tri = this.tri, uv = this.uv, base = out.vertexCount;
194
+ const d0 = tri[3] - tri[5], d1 = tri[4] - tri[2], d2 = tri[0] - tri[4], d4 = tri[5] - tri[1], d = 1 / (d0 * d2 + d1 * (tri[1] - tri[5]));
195
+ for (let i = 0; i < count; i++) {
196
+ const x = cut ? points[i * 2] : tri[i * 2], y = cut ? points[i * 2 + 1] : tri[i * 2 + 1], at = out.vertexCount++ * 2;
197
+ out.vertices[at] = x; out.vertices[at + 1] = y;
198
+ if (!cut) { out.uvs[at] = uv[i * 2]; out.uvs[at + 1] = uv[i * 2 + 1]; }
199
+ else { const a = (d0 * (x - tri[4]) + d1 * (y - tri[5])) * d, b = (d4 * (x - tri[4]) + d2 * (y - tri[5])) * d, c = 1 - a - b; out.uvs[at] = uv[0] * a + uv[2] * b + uv[4] * c; out.uvs[at + 1] = uv[1] * a + uv[3] * b + uv[5] * c; }
200
+ }
201
+ for (let i = base * 2; i < out.vertexCount * 2; i++) if (!Number.isFinite(out.vertices[i]) || !Number.isFinite(out.uvs[i])) throw new Error(`runtime clip: triangle ${source} produced a non-finite clipped coordinate or UV`);
202
+ for (let i = 1; i + 1 < count; i++) { const at = out.triangleCount * 3; out.triangles[at] = base; out.triangles[at + 1] = base + i; out.triangles[at + 2] = base + i + 1; out.sources[out.triangleCount++] = source; }
203
+ }
204
+ clip(vertices: Float64Array, offset: number, triangles: Int32Array, uvs: Float64Array, out: RuntimeClipBuffer): void {
205
+ out.vertexCount = 0; out.triangleCount = 0; out.clipped = this.inverse;
206
+ for (let k = 0; k < triangles.length; k += 3) {
207
+ for (let j = 0; j < 3; j++) { const n = triangles[k + j] * 2; this.tri[j * 2] = vertices[offset + n]; this.tri[j * 2 + 1] = vertices[offset + n + 1]; this.uv[j * 2] = Math.fround(uvs[n]); this.uv[j * 2 + 1] = Math.fround(uvs[n + 1]); }
208
+ if (!this.inverse) for (let r = 0; r < this.regionsCount; r++) {
209
+ let input = this.a, output = this.b, count = 3, cut = false; for (let i = 0; i < 6; i++) input[i] = this.tri[i];
210
+ const n = this.regionSizes[r], base = r * this.maxPolygonVertices * 2;
211
+ for (let e = 0; e < n && count > 0; e++) { const p = base + e * 2, q = base + ((e + 1) % n) * 2; count = this.half(input, count, this.regions[p], this.regions[p + 1], this.regions[q], this.regions[q + 1], output); cut ||= this.cut; const swap = input; input = output; output = swap; }
212
+ out.clipped ||= cut; this.emit(input, count, cut, k / 3, out);
213
+ } else {
214
+ let input = this.a, output = this.b, count = 3, cutSoFar = false; for (let i = 0; i < 6; i++) input[i] = this.tri[i];
215
+ const n = this.regionSizes[0];
216
+ for (let e = 0; e < n && count > 0; e++) { const p = e * 2, q = ((e + 1) % n) * 2, px = this.regions[p], py = this.regions[p + 1], qx = this.regions[q], qy = this.regions[q + 1];
217
+ const outside = this.half(input, count, qx, qy, px, py, this.outside), cut = this.cut || cutSoFar; this.emit(this.outside, outside, cut, k / 3, out);
218
+ count = this.half(input, count, px, py, qx, qy, output); cutSoFar ||= this.cut; const swap = input; input = output; output = swap;
219
+ }
220
+ }
221
+ }
222
+ }
223
+ }
@@ -21,13 +21,13 @@
21
21
  * corpus through both at the oracle's samples and compares full doubles at
22
22
  * tolerance 0 (*The runtime poser* there). The oracle is not changed for it.
23
23
  *
24
- * ## Stage 5: what it poses, and what it refuses
24
+ * ## Stage 8: what it poses, and what it refuses
25
25
  *
26
26
  * Bones and their five inherit modes; the rotate, translate, scale and shear
27
27
  * timelines; a slot's attachment, light colour (`rgba`, `rgb`, `alpha`) and
28
28
  * dark colour (`rgba2`, `rgb2`) timelines; draw-order timelines (including
29
29
  * slider draw-order application); weighted/unweighted deform timelines; region, mesh, weighted mesh and linked mesh vertices; one- and
30
- * two-bone ik, world-space absolute transforms, path constraints, physics (with optional movement scope) and value-mapped sliders with their
30
+ * two-bone ik, absolute/additive transforms with local/world sources and targets in all five inherit modes, path constraints, physics (with optional movement scope) and value-mapped sliders with their
31
31
  * timelines; skin-required bones and constraints under the view's skin. One
32
32
  * animation at full weight from the setup pose, at a time or stepped as a
33
33
  * looping track. Everything else is refused at load by name with the list of
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The runtime poser's load-time half (issue #1591, stage 5): a skin view of
2
+ * The runtime poser's load-time half (issue #1591, stage 9): a skin view of
3
3
  * the compiled model (`readModel`, then `underSkin` / `underNoSkin` — the
4
4
  * oracle's reader and its views, read and never written) compiled once into
5
5
  * index-resolved bones and slots, a constraint plan, compiled timelines and
@@ -12,21 +12,23 @@
12
12
  * (`RuntimeMissing`), and:
13
13
  *
14
14
  * - **refuses the model** (`RuntimeRefusal`, every missing feature listed)
15
- * when one is a property of the whole model — a transform constraint reading or
16
- * writing local values or adding (`localSource`, `localTarget`,
17
- * `additive`), an ik, transform or path constraint over a bone the view leaves
18
- * inactive, a world-space transform onto a `noScale` or
19
- * `noScaleOrReflection` bone, a clipping attachment or a region with no
15
+ * when one is a property of the whole model — an ik, transform or path constraint
16
+ * over a bone the view leaves inactive, a region with no
20
17
  * atlas rectangle shown at setup, a placeholder the skins fill
21
18
  * differently, or a block the oracle itself leaves out;
22
19
  * - **refuses an animation** (`RuntimeModel.refused`; `animation(name)`
23
20
  * throws the same `RuntimeRefusal`) when the construct is the animation's
24
21
  * — an `inherit` timeline, a sequence timeline, or an
25
- * attachment key showing a clipping attachment or a region with no atlas
22
+ * attachment key showing a region with no atlas
26
23
  * rectangle. Every other animation of the model is posed.
27
24
  *
28
- * `RUNTIME_FEATURES` is the closed list of what can be named. Stage 5 adds draw-order timelines (including slider draw-order application) and dark-colour (`rgba2`, `rgb2`) timelines on top of Stage 4's path constraints and scoped physics movement. Mesh and path deforms, named/no-skin views
29
- * and immutable event deliveries remain covered. `ignore: ['events']` remains an explicit diagnostic opt-out; it is
25
+ * `RUNTIME_FEATURES` is the closed list of what can be named. Stage 9 adds buffered clipping draw output; stage 8 added
26
+ * additive local/world transform constraints. Stage 7 admitted
27
+ * world transform targets in both no-scale inherit modes, using the existing
28
+ * localFromWorld read-back. Stage 6 added local-source and local-target transforms. Draw-order,
29
+ * light/dark colour, mesh and path deforms, named/no-skin views, path constraints,
30
+ * scoped physics and immutable event deliveries remain covered.
31
+ * `ignore: ['events']` remains an explicit diagnostic opt-out; it is
30
32
  * never the default. A merged view with conflicting attachments still refuses
31
33
  * by name, as the oracle itself cannot resolve that view without file order.
32
34
  *
@@ -54,15 +56,11 @@ import type { CoreSliderRecord } from '../core/constraints_slider.ts';
54
56
  import { compileCurve, compileDeformCurve, type RuntimeCurve } from './curves.ts';
55
57
 
56
58
  /** The stage of issue #1591 this poser implements. */
57
- export const RUNTIME_STAGE = 5;
59
+ export const RUNTIME_STAGE = 9;
58
60
 
59
61
  /** Every construct the runtime poser can refuse, by the name its refusals carry. */
60
62
  export const RUNTIME_FEATURES = [
61
- 'local-transform',
62
- 'additive-transform',
63
63
  'inactive-constrained-bone',
64
- 'noscale-world-target',
65
- 'clipping',
66
64
  'null-atlas-region',
67
65
  'skin-conflict',
68
66
  'oracle-absent',
@@ -130,6 +128,12 @@ export interface RuntimePlant {
130
128
  readonly ignorePhysicsScope?: boolean;
131
129
  readonly omitDrawOrder?: boolean;
132
130
  readonly omitDarkColour?: boolean;
131
+ /** Omit local-source/target transforms at load, to prove the stage-six gate. */
132
+ readonly omitLocalTransform?: boolean;
133
+ /** Omit world transforms with parented no-scale targets, to prove stage seven. */
134
+ readonly omitNoScaleWorldTransform?: boolean;
135
+ /** Omit additive transforms at load, to prove stage eight. */
136
+ readonly omitAdditiveTransform?: boolean;
133
137
  }
134
138
 
135
139
  // A bone's local values, seven per bone.
@@ -200,6 +204,10 @@ export const RAD = RUNTIME_PI / 180;
200
204
  * bindings (bone index, float32 coordinates, float32 weight) in their order.
201
205
  */
202
206
  export interface RuntimeAttachment {
207
+ /** Source topology resolved at load for models containing clipping. */
208
+ readonly clipTopology?: { readonly triangles: Int32Array; readonly uvs: Float64Array };
209
+ /** Clip polygon metadata, resolved once with the attachment. */
210
+ readonly clip?: { readonly end: string | null; readonly inverse: boolean; readonly convex: boolean };
203
211
  /** Source timeline identity, including linked-mesh inheritance, resolved once. */
204
212
  readonly timeline: string | null;
205
213
  readonly deformOffset: number;
@@ -248,7 +256,7 @@ export interface RuntimeIk {
248
256
  readonly flags: number;
249
257
  }
250
258
 
251
- /** A world-space, absolute transform constraint as the plan runs it: each source property's offset and its targets'. */
259
+ /** An absolute or additive transform constraint as the plan runs it: each source property's offset and its targets'. */
252
260
  export interface RuntimeTransform {
253
261
  readonly kind: 'transform';
254
262
  readonly name: string;
@@ -268,6 +276,9 @@ export interface RuntimeTransform {
268
276
  readonly offsets: Float64Array;
269
277
  readonly mixes: Float64Array;
270
278
  readonly clamp: boolean;
279
+ readonly localSource: boolean;
280
+ readonly localTarget: boolean;
281
+ readonly additive: boolean;
271
282
  }
272
283
 
273
284
  export interface RuntimePhysics {
@@ -585,20 +596,14 @@ export function compileRuntime(doc: CompiledDocument, options: RuntimeOptions =
585
596
  else if (r.kind === 'path') {
586
597
  constraints.push(compilePath(r, index, applied, bones, indexOf, slotIndexOf, pathPlan.get(index) ?? null));
587
598
  } else {
588
- if (r.localSource || r.localTarget) refusals.note('local-transform', `transform constraint "${r.name}" ${[r.localSource ? 'reads local values (localSource)' : '', r.localTarget ? 'writes local values (localTarget)' : ''].filter((x) => x !== '').join(' and ')}`);
589
- if (r.additive) refusals.note('additive-transform', `transform constraint "${r.name}" is additive`);
590
- const t = compileTransform(r, index, applied, bones, indexOf);
591
- if (applied) {
592
- for (let k = 0; k < bones.length; k++) {
593
- const m = modes[bones[k]];
594
- if (parents[bones[k]] >= 0 && (m === MODE_NO_SCALE || m === MODE_NO_SCALE_OR_REFLECTION)) refusals.note('noscale-world-target', `transform constraint "${r.name}" moves bone "${r.bones[k]}" in world space, and its local values are read back in a noScale mode`);
595
- }
596
- }
599
+ const noScaleWorld = !r.localTarget && bones.some(b => parents[b] >= 0 && (modes[b] === MODE_NO_SCALE || modes[b] === MODE_NO_SCALE_OR_REFLECTION));
600
+ const t = compileTransform(r, index, applied && !(plant.omitLocalTransform && (r.localSource || r.localTarget)) && !(plant.omitNoScaleWorldTransform && noScaleWorld) && !(plant.omitAdditiveTransform && r.additive), bones, indexOf);
597
601
  constraints.push(t);
598
602
  }
599
603
  }
600
604
 
601
605
  // --- slots and the records they show ---
606
+ const hasClipping = doc.skins.some(s => Object.values(s.attachments).some(table => Object.values(table).some(at => at.geometry?.kind === 'clipping')));
602
607
  const attachments: RuntimeAttachment[] = [];
603
608
  let vertexCount = 0;
604
609
  let deformCount = 0;
@@ -626,7 +631,9 @@ export function compileRuntime(doc: CompiledDocument, options: RuntimeOptions =
626
631
  const at = { timeline: timelineIdentity(slot.name, shown), deformOffset: deformCount, slot: si, name: row.name, skin: shown.skin, placeholder: shown.placeholder, path: CORE_REGION_KINDS.has(shown.record.kind) ? row.path : null, bone: indexOf(slot.bone), offset: vertexCount };
627
632
  let made: RuntimeAttachment | null = null;
628
633
  let missing: readonly [RuntimeFeature, string] | null = null;
629
- if (g?.kind === 'clipping') missing = ['clipping', `slot "${slot.name}" shows clipping attachment "${row.name}"`];
634
+ if (g?.kind === 'clipping') {
635
+ made = { ...meshAttachment(at, g.vertices, indexOf, plant.bindingIndex, nb), clip: { end: g.end, inverse: g.inverse, convex: g.convex } };
636
+ }
630
637
  else if (g?.kind === 'region') {
631
638
  const r = g.region;
632
639
  if (r.atlas === null) missing = ['null-atlas-region', `slot "${slot.name}" shows region "${row.name}", whose atlas rectangle the model states as null`];
@@ -667,6 +674,14 @@ export function compileRuntime(doc: CompiledDocument, options: RuntimeOptions =
667
674
  }
668
675
  // A bounding box, a refused record admitted by a plant: the row's name and path, and nothing drawn.
669
676
  if (made === null) made = vertexAttachment(at, DRAW_NONE, new Float64Array(0), 0);
677
+ if (hasClipping) {
678
+ if (g?.kind === 'region') made = { ...made, clipTopology: { triangles: new Int32Array([0, 1, 2, 2, 3, 0]), uvs: new Float64Array([0, 1, 0, 0, 1, 0, 1, 1]) } };
679
+ else if (g?.kind === 'mesh' || g?.kind === 'linkedmesh') {
680
+ const mesh = g.kind === 'mesh' ? g : doc.skins.find(s => s.name === g.skin)?.attachments[g.slot]?.[g.source]?.geometry;
681
+ if (mesh?.kind !== 'mesh') throw new RuntimeInputError('clipping topology: linked source is not a mesh (readModel refuses it first)');
682
+ made = { ...made, clipTopology: { triangles: Int32Array.from(mesh.triangles), uvs: Float64Array.from(mesh.uvs) } };
683
+ }
684
+ }
670
685
  attachments.push(made);
671
686
  vertexCount += made.count;
672
687
  deformCount += made.deformCount;
@@ -882,7 +897,7 @@ function compileTransform(r: CoreTransformRecord, index: number, applied: boolea
882
897
  toProperty: Int32Array.from(toProperty), toOffset: Float64Array.from(toOffset), toScale: Float64Array.from(toScale), toMax: Float64Array.from(toMax),
883
898
  offsets: Float64Array.from(TRANSFORM_PROPERTIES.map((p) => r.offsets[p])),
884
899
  mixes: Float64Array.from(TRANSFORM_PROPERTIES.map((p) => r.mixes[p])),
885
- clamp: r.clamp,
900
+ clamp: r.clamp, localSource: r.localSource, localTarget: r.localTarget, additive: r.additive,
886
901
  };
887
902
  }
888
903
 
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The runtime poser's per-instance half (issue #1591, stage 5): one
2
+ * The runtime poser's per-instance half (issue #1591, stage 9): one
3
3
  * `RuntimePose` per skeleton on screen, every buffer allocated when it is
4
4
  * made and overwritten in place on every pose, so that posing a frame
5
5
  * allocates nothing.
@@ -40,6 +40,7 @@
40
40
  * exactly 0 touching nothing; an inactive bone posed as zeros by the
41
41
  * hierarchy and never posed again by a constraint, nor the bones below it.
42
42
  */
43
+ import { RuntimeDrawClipper } from './clip_draw.ts';
43
44
  import type { CorePhysicsRecord } from '../core/constraints_physics.ts';
44
45
  import { freshPhysicsState, resetPhysicsState, stepRuntimePhysics, type PhysicsState } from './physics.ts';
45
46
  import { COLLAPSED_X_AXIS_SQ, RUNTIME_DEG, RUNTIME_PI } from '../core/world.ts';
@@ -102,6 +103,8 @@ const P_X = 1;
102
103
  const P_Y = 2;
103
104
  const P_SCALE_X = 3;
104
105
  const P_SCALE_Y = 4;
106
+ // `applyLocal` in src/core/constraints.ts: the six transform properties' local fields.
107
+ const PROPERTY_LOCAL = new Int32Array([L_ROTATION, L_X, L_Y, L_SCALE_X, L_SCALE_Y, L_SHEAR_Y]);
105
108
 
106
109
  /** A change brought once into (−180, 180] — −180 goes to 180 (src/core/constraints.ts). */
107
110
  function wrap180(r: number): number {
@@ -136,6 +139,8 @@ export class RuntimePose {
136
139
  readonly drawOrder: Int32Array;
137
140
  /** Every attachment's world vertices at its `offset`, written by `computeVertices` for the attachments shown. */
138
141
  readonly vertices: Float64Array;
142
+ /** Buffered clipping output; null when the compiled model has no clipping attachments. */
143
+ readonly clipping: RuntimeDrawClipper | null;
139
144
  /** Held/interpolated deform values, one pair per binding when weighted. */
140
145
  readonly deform: Float64Array;
141
146
  private readonly deformed: Uint8Array;
@@ -190,6 +195,7 @@ export class RuntimePose {
190
195
 
191
196
  constructor(model: RuntimeModel, options: RuntimePoseOptions = {}) {
192
197
  this.model = model;
198
+ this.clipping = model.attachments.some(at => at.clip !== undefined) ? new RuntimeDrawClipper(model) : null;
193
199
  this.clock = options.clock;
194
200
  this.hasPhysics = model.constraints.some(c => c.kind === 'physics');
195
201
  this.physicsStates = model.constraints.map(() => freshPhysicsState());
@@ -1508,7 +1514,10 @@ export class RuntimePose {
1508
1514
  return true;
1509
1515
  }
1510
1516
 
1511
- /** A world-space, absolute transform (`solveTransform`): each bone's world edited in turn and written back, then the repose. */
1517
+ /** Absolute/additive transform, matching `solveTransform` in src/core/constraints.ts:
1518
+ * read the source per target/property, edit locals or the scratch world,
1519
+ * then repose the union in bone order (`repose` in that module). No read-back for a local target.
1520
+ */
1512
1521
  private solveTransform(c: RuntimeTransform, k: number): void {
1513
1522
  const W = this.world;
1514
1523
  const w = this.w;
@@ -1538,25 +1547,34 @@ export class RuntimePose {
1538
1547
  v = v < lo ? lo : v > hi ? hi : v;
1539
1548
  }
1540
1549
  applied = true;
1541
- applyWorld(w, property, v, mix);
1550
+ if (c.localTarget) {
1551
+ const at = bi * LOCALS + PROPERTY_LOCAL[property];
1552
+ const current = this.locals[at];
1553
+ this.locals[at] = c.additive
1554
+ ? property === P_SCALE_X || property === P_SCALE_Y ? current * (1 + (v - 1) * mix) : current + v * mix
1555
+ : current + (v - current) * mix;
1556
+ } else applyWorld(w, property, v, mix, c.additive);
1542
1557
  }
1543
1558
  }
1544
1559
  if (!applied) continue;
1545
- W[o + W_A] = w[W_A];
1546
- W[o + W_B] = w[W_B];
1547
- W[o + W_C] = w[W_C];
1548
- W[o + W_D] = w[W_D];
1549
- W[o + W_X] = w[W_X];
1550
- W[o + W_Y] = w[W_Y];
1560
+ if (!c.localTarget) {
1561
+ W[o + W_A] = w[W_A];
1562
+ W[o + W_B] = w[W_B];
1563
+ W[o + W_C] = w[W_C];
1564
+ W[o + W_D] = w[W_D];
1565
+ W[o + W_X] = w[W_X];
1566
+ W[o + W_Y] = w[W_Y];
1567
+ }
1551
1568
  this.changed[n] = bi;
1552
1569
  this.inWorld[n] = bi;
1553
1570
  n++;
1554
1571
  }
1555
- if (n > 0) this.repose(n, n);
1572
+ if (n > 0) this.repose(n, c.localTarget ? 0 : n);
1556
1573
  }
1557
1574
 
1558
- /** The source's world value of a property (`sourceValue`, world source). */
1575
+ /** The source's current local or world value (`sourceValue` in src/core/constraints.ts). */
1559
1576
  private sourceValue(c: RuntimeTransform, property: number): number {
1577
+ if (c.localSource) return this.locals[c.source * LOCALS + PROPERTY_LOCAL[property]] + c.offsets[property];
1560
1578
  const W = this.world;
1561
1579
  const s = c.source * WORLDS;
1562
1580
  const o = c.offsets;
@@ -1640,7 +1658,10 @@ export class RuntimePose {
1640
1658
  if (below[b] === 1 && this.inWorldMark[b] === 0) this.poseBone(b, false);
1641
1659
  }
1642
1660
 
1643
- /** A bone's local values read back from its world transform (`localFromWorld`) — `normal`, `onlyTranslation` and `noRotationOrReflection`; a world target in a `noScale` mode is refused at load. */
1661
+ /** A bone's locals read back from its world transform (`localFromWorld` in src/core/constraints.ts).
1662
+ * All five inherit modes: the no-scale forms read rotation first, rebuild
1663
+ * `noScaleDirection`, and retain the x-angle residual as shearX in `decompose`.
1664
+ */
1644
1665
  private localFromWorld(bi: number): void {
1645
1666
  const m = this.model;
1646
1667
  const W = this.world;
@@ -1816,6 +1837,14 @@ export class RuntimePose {
1816
1837
  }
1817
1838
  }
1818
1839
 
1840
+ /** Recompute vertices and overwrite clipping output, without per-frame allocation.
1841
+ * Concave/inverse rows describe the core's drawn coverage, not Spine's native triangle list.
1842
+ */
1843
+ computeClipped(): void {
1844
+ this.computeVertices();
1845
+ this.clipping?.frame(this);
1846
+ }
1847
+
1819
1848
  /** Every shown attachment's world vertices into `vertices` at its offset (`regionCorners`, `worldVertices`). */
1820
1849
  computeVertices(): void {
1821
1850
  const m = this.model;
@@ -1889,11 +1918,11 @@ export class RuntimePose {
1889
1918
  }
1890
1919
  }
1891
1920
 
1892
- /** A world-space, absolute transform of one property on a world transform (`applyWorld`). */
1893
- function applyWorld(w: Float64Array, property: number, v: number, mix: number): void {
1921
+ /** A world-space absolute/additive property, in `applyWorld` order from src/core/constraints.ts. */
1922
+ function applyWorld(w: Float64Array, property: number, v: number, mix: number, additive: boolean): void {
1894
1923
  switch (property) {
1895
1924
  case P_ROTATE: {
1896
- let r = v * RAD - Math.atan2(w[W_C], w[W_A]);
1925
+ let r = additive ? v * RAD : v * RAD - Math.atan2(w[W_C], w[W_A]);
1897
1926
  if (r > RUNTIME_PI) r -= TWO_PI;
1898
1927
  else if (r < -RUNTIME_PI) r += TWO_PI;
1899
1928
  r *= mix;
@@ -1908,10 +1937,10 @@ function applyWorld(w: Float64Array, property: number, v: number, mix: number):
1908
1937
  return;
1909
1938
  }
1910
1939
  case P_X:
1911
- w[W_X] = w[W_X] + (v - w[W_X]) * mix;
1940
+ w[W_X] = additive ? w[W_X] + v * mix : w[W_X] + (v - w[W_X]) * mix;
1912
1941
  return;
1913
1942
  case P_Y:
1914
- w[W_Y] = w[W_Y] + (v - w[W_Y]) * mix;
1943
+ w[W_Y] = additive ? w[W_Y] + v * mix : w[W_Y] + (v - w[W_Y]) * mix;
1915
1944
  return;
1916
1945
  case P_SCALE_X:
1917
1946
  case P_SCALE_Y: {
@@ -1919,7 +1948,7 @@ function applyWorld(w: Float64Array, property: number, v: number, mix: number):
1919
1948
  const q = property === P_SCALE_X ? W_C : W_D;
1920
1949
  const s = Math.sqrt(w[p] * w[p] + w[q] * w[q]);
1921
1950
  if (s === 0) return;
1922
- const k = 1 + ((v - s) * mix) / s;
1951
+ const k = additive ? 1 + (v - 1) * mix : 1 + ((v - s) * mix) / s;
1923
1952
  w[p] *= k;
1924
1953
  w[q] *= k;
1925
1954
  return;
@@ -1927,8 +1956,9 @@ function applyWorld(w: Float64Array, property: number, v: number, mix: number):
1927
1956
  default: {
1928
1957
  const yAngle = Math.atan2(w[W_D], w[W_B]);
1929
1958
  const xAngle = Math.atan2(w[W_C], w[W_A]);
1930
- let r = (v + 90) * RAD - (yAngle - xAngle);
1931
- r = r > RUNTIME_PI ? r - TWO_PI : r < -RUNTIME_PI ? r + TWO_PI : r;
1959
+ // Keep the right-angle subtraction: simplifying it to v * RAD changes bits.
1960
+ let r = (v + 90) * RAD - (additive ? 90 * RAD : yAngle - xAngle);
1961
+ if (!additive) r = r > RUNTIME_PI ? r - TWO_PI : r < -RUNTIME_PI ? r + TWO_PI : r;
1932
1962
  const angle = yAngle + r * mix;
1933
1963
  const s = Math.sqrt(w[W_B] * w[W_B] + w[W_D] * w[W_D]);
1934
1964
  w[W_B] = Math.cos(angle) * s;