@rig-c/core 2.78.0 → 2.80.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/README.md +18 -17
- package/docs/AUTHORING.md +73 -7
- package/package.json +1 -1
- package/skills/rigc/SKILL.md +5 -5
- package/src/render.ts +22 -0
- package/src/runtime/clip_draw.ts +52 -0
- package/src/runtime/clipping.ts +223 -0
- package/src/runtime/index.ts +2 -2
- package/src/runtime/model.ts +41 -26
- package/src/runtime/pose.ts +50 -20
- package/tools/editor_roundtrip.ts +87 -3
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
</p>
|
|
4
4
|
|
|
5
5
|
<p align="center">
|
|
6
|
-
<a href="https://www.npmjs.com/package
|
|
6
|
+
<a href="https://www.npmjs.com/package/@rig-c/core"><img src="https://img.shields.io/npm/v/@rig-c/core.svg?style=flat-square&color=FF6B4A" alt="npm version" /></a>
|
|
7
7
|
<a href="https://www.npmjs.com/package/rig-c"><img src="https://img.shields.io/npm/dm/rig-c.svg?style=flat-square&color=A855F7" alt="npm downloads" /></a>
|
|
8
8
|
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-38BDF8.svg?style=flat-square" alt="license" /></a>
|
|
9
9
|
</p>
|
|
@@ -14,9 +14,9 @@ format, with rigc's own compiled model beside it —
|
|
|
14
14
|
gated by a list of named assertions before a byte is written — rigc's own validator in
|
|
15
15
|
the published package, held to a `spine-core` round trip's verdicts in this
|
|
16
16
|
repository's CI. Built so AI agents can author rigs and check their own work; it ships
|
|
17
|
-
as an agent skill.
|
|
18
|
-
|
|
19
|
-
published
|
|
17
|
+
as an agent skill. Its canonical npm package is `@rig-c/core`, under the toolchain's
|
|
18
|
+
scope. `rig-c` is the convenience install name; `spine-rigc` is the legacy install
|
|
19
|
+
name. Both remain published alongside it with the same product files.
|
|
20
20
|
|
|
21
21
|
## What you get
|
|
22
22
|
|
|
@@ -177,22 +177,23 @@ name with exit 2 before upload. Use a published exact `X.Y.Z` (no `v` prefix,
|
|
|
177
177
|
alias, range or prerelease), or retry a failed network fetch. This is repository
|
|
178
178
|
verification tooling, not an installed `rigc` command or a change to CI's matrix.
|
|
179
179
|
|
|
180
|
-
**The npm package is
|
|
180
|
+
**The canonical npm package is `@rig-c/core`; the command it installs is `rigc`.**
|
|
181
|
+
`rig-c` remains a convenience install name. npm
|
|
181
182
|
refuses the name `rigc` as too similar to packages that already exist, so the
|
|
182
|
-
project and the executable keep their name
|
|
183
|
-
hyphen; the repository is [`rig-c/core`](https://github.com/rig-c/core). **`spine-rigc` is the same package**: it is the name
|
|
183
|
+
project and the executable keep their name; the convenience name `rig-c` carries one
|
|
184
|
+
hyphen; the repository is [`rig-c/core`](https://github.com/rig-c/core). **`spine-rigc` is the legacy install name for the same package**: it is the name
|
|
184
185
|
every version up to 2.20.4 shipped under, and it stays published as an alias —
|
|
185
186
|
every version from 2.20.4 on is on both names, with the same files — so a
|
|
186
187
|
project that depends on `spine-rigc` keeps receiving every release. A new
|
|
187
|
-
project installs
|
|
188
|
+
project installs `@rig-c/core`, the canonical package under the toolchain's scope.
|
|
188
189
|
|
|
189
190
|
```bash
|
|
190
|
-
bunx rig-c --help # run it without installing
|
|
191
|
-
bun add -g rig-c # or install the command
|
|
192
|
-
bun add -d rig-c # or pin it in a project
|
|
191
|
+
bunx @rig-c/core --help # run it without installing
|
|
192
|
+
bun add -g @rig-c/core # or install the command
|
|
193
|
+
bun add -d @rig-c/core # or pin it in a project
|
|
193
194
|
```
|
|
194
195
|
|
|
195
|
-
`npx rig-c` works too, as long as Bun is on `PATH` — the executable is a
|
|
196
|
+
`npx @rig-c/core` works too, as long as Bun is on `PATH` — the executable is a
|
|
196
197
|
Bun script, and npm only writes the shim that calls it.
|
|
197
198
|
|
|
198
199
|
Installed, the command is `rigc`. The examples below spell it `bun cli.ts`
|
|
@@ -242,7 +243,7 @@ plugin marketplace:
|
|
|
242
243
|
/plugin install rigc@rigc
|
|
243
244
|
```
|
|
244
245
|
|
|
245
|
-
With the package already installed, `claude --plugin-dir node_modules
|
|
246
|
+
With the package already installed, `claude --plugin-dir node_modules/@rig-c/core`
|
|
246
247
|
loads the same skills without a marketplace. The plugin carries no version of its
|
|
247
248
|
own — `/plugin update` follows `main` commit by commit, and the only version on
|
|
248
249
|
disk stays the one in `package.json`.
|
|
@@ -254,8 +255,8 @@ Codex, Gemini CLI and Antigravity read skills from one directory in the workspac
|
|
|
254
255
|
`node_modules`. With the package installed, one command puts every skill there:
|
|
255
256
|
|
|
256
257
|
```shell
|
|
257
|
-
bun add -d rig-c
|
|
258
|
-
bun rigc skills install # relative links: .agents/skills/rigc -> ../../node_modules
|
|
258
|
+
bun add -d @rig-c/core
|
|
259
|
+
bun rigc skills install # relative links: .agents/skills/rigc -> ../../node_modules/@rig-c/core/skills/rigc
|
|
259
260
|
bun rigc skills install --copy # the folders themselves, for a host that does not follow a link
|
|
260
261
|
```
|
|
261
262
|
|
|
@@ -290,11 +291,11 @@ reference export, so nothing you read in a quickstart is an answer to anything
|
|
|
290
291
|
**1. Install the command.**
|
|
291
292
|
|
|
292
293
|
```bash
|
|
293
|
-
bun add -g rig-c # installs `rigc`
|
|
294
|
+
bun add -g @rig-c/core # installs `rigc`
|
|
294
295
|
```
|
|
295
296
|
|
|
296
297
|
Or skip the install and prefix every command below with `bunx `, e.g.
|
|
297
|
-
`bunx rig-c build …`.
|
|
298
|
+
`bunx @rig-c/core build …`.
|
|
298
299
|
|
|
299
300
|
**2. Make a directory and three plates.** rigc measures PNGs rather than trusting
|
|
300
301
|
a number you typed (R5), so the art has to exist. These three are solid colours a
|
package/docs/AUTHORING.md
CHANGED
|
@@ -11267,6 +11267,27 @@ Everything above is what the editor **does**. This is what it **returns** — wh
|
|
|
11267
11267
|
matters to you for one reason: a construct nobody has carried through the editor
|
|
11268
11268
|
is a construct that might vanish there, and an agent cannot see that it did.
|
|
11269
11269
|
|
|
11270
|
+
`tools/editor_roundtrip.ts` also names a clipping semantic change after export
|
|
11271
|
+
(issue #1630), including under `--exported`. If the export adds `convex: true`
|
|
11272
|
+
where the same skin/slot/attachment in the build did not state true, and the
|
|
11273
|
+
build's authored setup polygon has a reflex vertex, it prints:
|
|
11274
|
+
|
|
11275
|
+
```text
|
|
11276
|
+
FAIL EDITOR_ADDED_CONVEX_ON_CONCAVE_CLIP skin "S" slot "C" attachment "A" end "E": export convex=true, build convex is not true; setup reflex vertices=N; the editor's export clips with the convex hull of the authored concave polygon
|
|
11277
|
+
```
|
|
11278
|
+
|
|
11279
|
+
The setup polygon is read through the runtime with that skin applied, setup
|
|
11280
|
+
bones and constraints posed, and the slot's deform empty, as for the renderer's
|
|
11281
|
+
authored rest geometry. Weighted vertex runs are therefore read as posed points,
|
|
11282
|
+
not mistaken for coordinate pairs; animation-time deformation is not substituted
|
|
11283
|
+
for the authored setup shape. Reflex turns are counted in either winding and
|
|
11284
|
+
straight boundary vertices do not make a convex polygon concave. A convex setup
|
|
11285
|
+
polygon or a build already stating `convex: true` adds no line. The named failure
|
|
11286
|
+
makes the trip exit nonzero **after** the existing per-skin render/check figures
|
|
11287
|
+
are printed; it does not replace those measurements. It changes neither compiler
|
|
11288
|
+
emission nor coordinates, and does not imply that removing the export's flag
|
|
11289
|
+
undoes the editor's other changes.
|
|
11290
|
+
|
|
11270
11291
|
🔬 The source is one run: eight discriminator rigs, each built to isolate a group
|
|
11271
11292
|
of fields, compiled by rigc **0.21.0** (emitting 4.3.13), imported into a licensed
|
|
11272
11293
|
Spine **4.3.26** through the documented CLI and exported back, with the
|
|
@@ -12647,7 +12668,7 @@ verdict on those figures.
|
|
|
12647
12668
|
| <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
12669
|
| <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
12670
|
|
|
12650
|
-
## 15. Posing in place for a player — the runtime poser (stage
|
|
12671
|
+
## 15. Posing in place for a player — the runtime poser (stage 9)
|
|
12651
12672
|
|
|
12652
12673
|
rigc poses a model document twice over, on purpose (issue #1591). The **oracle**
|
|
12653
12674
|
(`src/core/`) is the reference: each pose is a pure function of the document,
|
|
@@ -12682,16 +12703,31 @@ exact resolved table identity `(skin, model.slots[attachment.slot].name, placeho
|
|
|
12682
12703
|
The display `name` may be shared by different placeholders; it is never a table
|
|
12683
12704
|
key to guess the source from. These load-time strings do not change the pose path.
|
|
12684
12705
|
|
|
12685
|
-
| | what stage
|
|
12706
|
+
| | what stage 9 poses |
|
|
12686
12707
|
| --- | --- |
|
|
12687
12708
|
| 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
12709
|
| 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;
|
|
12710
|
+
| 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
12711
|
| vertices | regions, meshes, weighted meshes and linked meshes, written into the pose's buffer |
|
|
12691
12712
|
| 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
12713
|
| 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
12714
|
| 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
12715
|
|
|
12716
|
+
Clipping draw output is read by `pose.computeClipped()`. It recomputes vertices
|
|
12717
|
+
and overwrites `pose.clipping`'s preallocated `clipSlots`, `drawSlots` and per-slot
|
|
12718
|
+
`outputs`, with `clipCount` and `drawCount` delimiting live rows. `pose.clipping`
|
|
12719
|
+
is null on a model without clipping. Only draws under an active clip appear
|
|
12720
|
+
there; this is not the complete draw list. Each output has vertex/triangle
|
|
12721
|
+
counts, float32 coordinates and UVs, triangle indices, source triangle indices
|
|
12722
|
+
and the cut flag. Slot-end/start order follows `poseClipped`; polygon planning
|
|
12723
|
+
and arithmetic follow `clipShapeOf`, `clipThrough` and `clipTriangles`.
|
|
12724
|
+
Concave/inverse rows follow the core's drawn coverage. The oracle's native
|
|
12725
|
+
triangle list is absent there, so the third dumper refuses that reading as
|
|
12726
|
+
`oracle-absent` rather than relabeling coverage. Self-crossing, unsupported
|
|
12727
|
+
repeated vertices, collapsed polygons and non-finite clipped coordinates or
|
|
12728
|
+
UVs are rejected when the polygon is prepared or clipped, as the core rejects
|
|
12729
|
+
those inputs; no arbitrary-polygon or singular-input coverage is claimed.
|
|
12730
|
+
|
|
12695
12731
|
Deform keys are expanded once at load. Unweighted keys hold float32 positions,
|
|
12696
12732
|
`fround(fround(setup) + fround(offset))`; weighted keys hold float32 offsets,
|
|
12697
12733
|
**one x/y pair per influence**, in vertex then influence order. At a pose,
|
|
@@ -12767,9 +12803,41 @@ constraints later in update order. An unsupported timeline in a slider's
|
|
|
12767
12803
|
animation refuses the whole model under that timeline's existing feature name,
|
|
12768
12804
|
so an unselected track cannot hide a construct the slider always applies.
|
|
12769
12805
|
|
|
12806
|
+
Transform constraints read each source property from the current local
|
|
12807
|
+
values (`localSource: true`) or world matrix, including earlier constraints'
|
|
12808
|
+
read-back, apply offsets and cross-mappings in declaration order, then clamp and
|
|
12809
|
+
blend by the keyed property mix. `localTarget: true` writes local values and
|
|
12810
|
+
re-poses the changed bones and their descendants in bone order without a world
|
|
12811
|
+
read-back; world targets keep their edited world matrices and read back locals.
|
|
12812
|
+
A zero mix leaves that property untouched. Both local and world targets accept
|
|
12813
|
+
all five inherit modes. For a parented `noScale` or `noScaleOrReflection` world
|
|
12814
|
+
target, read-back derives rotation from the parent's adjugate and the edited
|
|
12815
|
+
world x column, rebuilds that mode's normalised frame, and decomposes the world
|
|
12816
|
+
columns in it. The residual x angle is kept as `shearX`; `noScale` preserves a
|
|
12817
|
+
parent reflection and `noScaleOrReflection` removes it. This is the oracle's
|
|
12818
|
+
`localFromWorld`, `noScaleDirection` and `decompose` order; subsequent local
|
|
12819
|
+
readers and parent edits observe those same locals. Stage-seven controls
|
|
12820
|
+
`RP43`–`RP46` hold both modes to the oracle with omission and allocation plants.
|
|
12821
|
+
With `additive: true`, a local position, rotation or shear adds `v * mix`
|
|
12822
|
+
and a local scale multiplies by `1 + (v - 1) * mix`. A world rotation turns by
|
|
12823
|
+
`v * RAD` wrapped once at the runtime pi before applying mix; world translation
|
|
12824
|
+
adds `v * mix`, and world scale multiplies by `1 + (v - 1) * mix` (a zero-length
|
|
12825
|
+
column is left alone). World additive shear uses `(v + 90) * RAD - 90 * RAD`
|
|
12826
|
+
without wrapping, then adds the mixed angle to the existing y-column angle.
|
|
12827
|
+
That subtraction order is kept exactly, as in the oracle's `applyWorld`;
|
|
12828
|
+
replacing it with `v * RAD` is not bit-equivalent. Source reading, mappings,
|
|
12829
|
+
clamp, keyed mixes and the final union repose retain `solveTransform` order.
|
|
12830
|
+
Stage-nine controls `RP53`–`RP56` compare buffered primitives and default
|
|
12831
|
+
admission to the oracle, require every omission to differ, and measure pose
|
|
12832
|
+
stepping plus clipping with a per-frame object plant. Stage-eight controls
|
|
12833
|
+
`RP47`–`RP52` hold all four source/target forms to the
|
|
12834
|
+
oracle, with an omission plant and the steady-state allocation meter.
|
|
12835
|
+
Stage-six controls `RP38`–`RP42` hold local-source, local-target
|
|
12836
|
+
and both forms to the oracle with omission and allocation plants.
|
|
12837
|
+
|
|
12770
12838
|
**Everything else is refused at load, by name.** `RuntimeRefusal.missing` lists
|
|
12771
12839
|
every construct met, each `{ feature, detail, animation }`, and the message
|
|
12772
|
-
reads `not posed by the runtime poser (stage
|
|
12840
|
+
reads `not posed by the runtime poser (stage 9), N construct(s) missing:
|
|
12773
12841
|
<feature>: <detail>; …`. A model-level feature refuses the whole model; an
|
|
12774
12842
|
animation-level one refuses that animation alone (`model.refused`;
|
|
12775
12843
|
`model.animation(name)` and `setAnimation(name)` throw its refusal) and every
|
|
@@ -12778,10 +12846,8 @@ refused: nothing is posed approximately.
|
|
|
12778
12846
|
|
|
12779
12847
|
| feature | refuses | what it is |
|
|
12780
12848
|
| --- | --- | --- |
|
|
12781
|
-
| `local-transform`, `additive-transform` | the model | a transform constraint with `localSource` or `localTarget`, or `additive` |
|
|
12782
12849
|
| `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
|
-
| `
|
|
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` |
|
|
12850
|
+
| `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
12851
|
| `skin-conflict` | as above | a placeholder the skins fill differently |
|
|
12786
12852
|
| `oracle-absent` | the model | a pose the oracle itself leaves out (`constraintsAbsentWhy`, `pathAnimationsWhy`, `steppedPreviousPassWhy`, `sliderDeformsWalkedPathWhy`) |
|
|
12787
12853
|
| `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.
|
|
3
|
+
"version": "2.80.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": {
|
package/skills/rigc/SKILL.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: rigc
|
|
3
3
|
description: Author, build and validate Spine 4.3 skeleton data (skeleton.json plus its .atlas) from loose part PNGs with rigc, the rig compiler that verifies its own output with named assertions before writing it. Use for any request to make a Spine rig or Spine animation from PNG parts, or where the source is a Live2D, Unity or video model whose pictures you can render, to run or read rigc build, validate, render, preview, check or vote, or to write or fix a *.rig.json or *.motion.json spec; it says which shipped guide to open for the need at hand. Not for Live2D conversion, cutting an illustration into parts, or real-time face tracking.
|
|
4
4
|
license: MIT
|
|
5
|
-
compatibility: Requires Bun 1.4 or later. The
|
|
5
|
+
compatibility: Requires Bun 1.4 or later. The canonical npm package is @rig-c/core (bunx @rig-c/core, or bun add -d @rig-c/core); the command it installs is rigc. rig-c is the convenience install name; spine-rigc is the legacy install name.
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# rigc — a rig compiler for agents
|
|
@@ -46,8 +46,8 @@ index within that same limit; only legacy uncoded library errors point at `§5.1
|
|
|
46
46
|
## Install
|
|
47
47
|
|
|
48
48
|
```shell
|
|
49
|
-
bunx rig-c --help # run it without installing
|
|
50
|
-
bun add -d rig-c # or pin it in the project; the command is `rigc`
|
|
49
|
+
bunx @rig-c/core --help # run it without installing
|
|
50
|
+
bun add -d @rig-c/core # or pin it in the project; the command is `rigc`
|
|
51
51
|
bun rigc skills install # then link these skills into .agents/skills
|
|
52
52
|
```
|
|
53
53
|
|
|
@@ -145,8 +145,8 @@ spec files field by field, the emission rules, the loop, and the failure map. Th
|
|
|
145
145
|
| a **skeleton.json somebody else authored** — read it, repair it, extend it | [INGEST.md](https://github.com/rig-c/core/blob/main/docs/INGEST.md) | `rigc-ingest` |
|
|
146
146
|
| you are the **person operating** the agent rather than the agent | [PROMPTING.md](https://github.com/rig-c/core/blob/main/docs/PROMPTING.md) | — |
|
|
147
147
|
|
|
148
|
-
Every guide linked here is in the installed package at `node_modules
|
|
148
|
+
Every guide linked here is in the installed package at `node_modules/@rig-c/core/docs/`,
|
|
149
149
|
which is the copy that matches the rigc you run; the links go to the repository's
|
|
150
150
|
`main`. Inside the Claude Code plugin the same files are at `${CLAUDE_PLUGIN_ROOT}/docs/`.
|
|
151
151
|
Formats, the CLI reference and the licence chain:
|
|
152
|
-
[README.md](https://github.com/rig-c/core/blob/main/README.md), installed at `node_modules
|
|
152
|
+
[README.md](https://github.com/rig-c/core/blob/main/README.md), installed at `node_modules/@rig-c/core/README.md`.
|
package/src/render.ts
CHANGED
|
@@ -581,6 +581,28 @@ function setupPosed(data: SkeletonData, skin: string | undefined): Skeleton {
|
|
|
581
581
|
return skeleton;
|
|
582
582
|
}
|
|
583
583
|
|
|
584
|
+
/** A named clip's authored setup polygon under its own skin, with no deform. */
|
|
585
|
+
export function setupClipPolygon(
|
|
586
|
+
data: SkeletonData,
|
|
587
|
+
skin: string,
|
|
588
|
+
slotName: string,
|
|
589
|
+
attachmentName: string,
|
|
590
|
+
): number[] {
|
|
591
|
+
const skeleton = setupPosed(data, skin);
|
|
592
|
+
const index = data.findSlot(slotName)?.index ?? -1;
|
|
593
|
+
const slot = skeleton.slots[index];
|
|
594
|
+
const attachment = data.findSkin(skin)?.getAttachment(index, attachmentName);
|
|
595
|
+
if (slot === undefined || !(attachment instanceof ClippingAttachment)) {
|
|
596
|
+
throw new Error(
|
|
597
|
+
`setup clip skin ${JSON.stringify(skin)} slot ${JSON.stringify(slotName)} attachment ${JSON.stringify(attachmentName)} did not resolve`,
|
|
598
|
+
);
|
|
599
|
+
}
|
|
600
|
+
slot.appliedPose.deform.length = 0;
|
|
601
|
+
const polygon = new Array<number>(attachment.worldVerticesLength).fill(0);
|
|
602
|
+
attachment.computeWorldVertices(skeleton, slot, 0, attachment.worldVerticesLength, polygon, 0, 2);
|
|
603
|
+
return polygon;
|
|
604
|
+
}
|
|
605
|
+
|
|
584
606
|
/**
|
|
585
607
|
* Both posers for the skeleton at `skeletonPath` drawn through the atlas at
|
|
586
608
|
* `atlasPath`, with spine-core's parse of it already in hand (`data`) — the
|
|
@@ -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
|
+
}
|
package/src/runtime/index.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
package/src/runtime/model.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The runtime poser's load-time half (issue #1591, stage
|
|
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 —
|
|
16
|
-
*
|
|
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
|
|
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
|
|
29
|
-
*
|
|
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 =
|
|
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
|
-
/**
|
|
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
|
-
|
|
589
|
-
|
|
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')
|
|
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
|
|
package/src/runtime/pose.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The runtime poser's per-instance half (issue #1591, stage
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
1931
|
-
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;
|
|
@@ -917,6 +917,75 @@ export function skinRoster(
|
|
|
917
917
|
};
|
|
918
918
|
}
|
|
919
919
|
|
|
920
|
+
/** An attachment's declaration key, kept separate from its skin and slot. */
|
|
921
|
+
interface ClipFlagSkin {
|
|
922
|
+
name: string;
|
|
923
|
+
attachments?: Record<string, Record<string, { type?: string; convex?: boolean; end?: string }>>;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
/** Reflex turns of the world's own doubles, winding independent; straight turns are not reflex. */
|
|
927
|
+
function reflexVertices(polygon: readonly number[]): number {
|
|
928
|
+
const n = polygon.length / 2;
|
|
929
|
+
let area2 = 0;
|
|
930
|
+
for (let i = 0; i < n; i++) {
|
|
931
|
+
const j = (i + 1) % n;
|
|
932
|
+
area2 += polygon[2 * i] * polygon[2 * j + 1] - polygon[2 * j] * polygon[2 * i + 1];
|
|
933
|
+
}
|
|
934
|
+
const winding = Math.sign(area2);
|
|
935
|
+
if (winding === 0) return 0; // Degenerate geometry is the validator's refusal.
|
|
936
|
+
let count = 0;
|
|
937
|
+
for (let i = 0; i < n; i++) {
|
|
938
|
+
const j = (i + 1) % n;
|
|
939
|
+
const k = (i + 2) % n;
|
|
940
|
+
const turn =
|
|
941
|
+
(polygon[2 * j] - polygon[2 * i]) * (polygon[2 * k + 1] - polygon[2 * j + 1]) -
|
|
942
|
+
(polygon[2 * j + 1] - polygon[2 * i + 1]) * (polygon[2 * k] - polygon[2 * j]);
|
|
943
|
+
if (turn !== 0 && Math.sign(turn) !== winding) count++;
|
|
944
|
+
}
|
|
945
|
+
return count;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
/**
|
|
949
|
+
* An editor-added convex flag changes a concave clip's meaning (issue #1630).
|
|
950
|
+
* Compare the same skin/slot/attachment, then read the BUILD's posed authored
|
|
951
|
+
* setup polygon. Weighted runs are not points; an animation deform is not the
|
|
952
|
+
* authored setup shape. Empty deform follows the renderer's `restOf` convention.
|
|
953
|
+
* Imports are lazy so the existing no-editor/trial/read refusals need no runtime.
|
|
954
|
+
*/
|
|
955
|
+
async function addedConvexClipFailures(build: string, exported: string, buildDir: string): Promise<string[]> {
|
|
956
|
+
const before = (JSON.parse(readFileSync(build, 'utf8')) as { skins?: ClipFlagSkin[] }).skins;
|
|
957
|
+
const after = (JSON.parse(readFileSync(exported, 'utf8')) as { skins?: ClipFlagSkin[] }).skins;
|
|
958
|
+
const candidates: Array<{ skin: string; slot: string; attachment: string; end: string | null }> = [];
|
|
959
|
+
for (const skin of Array.isArray(after) ? after : []) {
|
|
960
|
+
const originalSkin = (Array.isArray(before) ? before : []).find((s) => s.name === skin.name);
|
|
961
|
+
for (const [slot, attachments] of Object.entries(skin.attachments ?? {})) {
|
|
962
|
+
for (const [attachment, clip] of Object.entries(attachments)) {
|
|
963
|
+
const original = originalSkin?.attachments?.[slot]?.[attachment];
|
|
964
|
+
if (clip.type === 'clipping' && clip.convex === true && original?.type === 'clipping' && original.convex !== true) {
|
|
965
|
+
candidates.push({ skin: skin.name, slot, attachment, end: original.end ?? null });
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
if (candidates.length === 0) return [];
|
|
971
|
+
const { posableFromText, setupClipPolygon } = await import('../src/render.ts');
|
|
972
|
+
const atlas = readdirSync(buildDir).find((name) => name.endsWith('.atlas'))!;
|
|
973
|
+
const { data } = posableFromText(readFileSync(build, 'utf8'), readFileSync(join(buildDir, atlas), 'utf8'), buildDir);
|
|
974
|
+
const lines: string[] = [];
|
|
975
|
+
for (const candidate of candidates) {
|
|
976
|
+
const polygon = setupClipPolygon(data, candidate.skin, candidate.slot, candidate.attachment);
|
|
977
|
+
const reflex = reflexVertices(polygon);
|
|
978
|
+
if (reflex > 0) {
|
|
979
|
+
lines.push(
|
|
980
|
+
` FAIL EDITOR_ADDED_CONVEX_ON_CONCAVE_CLIP skin ${JSON.stringify(candidate.skin)} slot ${JSON.stringify(candidate.slot)} ` +
|
|
981
|
+
`attachment ${JSON.stringify(candidate.attachment)} end ${JSON.stringify(candidate.end)}: export convex=true, build convex is not true; ` +
|
|
982
|
+
`setup reflex vertices=${reflex}; the editor's export clips with the convex hull of the authored concave polygon`,
|
|
983
|
+
);
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
return lines;
|
|
987
|
+
}
|
|
988
|
+
|
|
920
989
|
/**
|
|
921
990
|
* One measure of a `diff --json` report as `matched/total (ratio)`, or `null`
|
|
922
991
|
* when the report or the measure is not there — read off the report for the
|
|
@@ -1185,7 +1254,7 @@ function imagesProbeFault(skeleton: Record<string, unknown>): string | null {
|
|
|
1185
1254
|
return null;
|
|
1186
1255
|
}
|
|
1187
1256
|
|
|
1188
|
-
function main(): void {
|
|
1257
|
+
async function main(): Promise<void> {
|
|
1189
1258
|
const opts = parseArgs(process.argv.slice(2));
|
|
1190
1259
|
const rigc = rigcCommand();
|
|
1191
1260
|
|
|
@@ -1489,6 +1558,21 @@ function main(): void {
|
|
|
1489
1558
|
// console layout for them would break the first time that layout is tidied.
|
|
1490
1559
|
for (const line of diffSummaryLines(diffJson)) emit(` ${line}`);
|
|
1491
1560
|
|
|
1561
|
+
// Report the changed meaning without skipping any later per-skin figures.
|
|
1562
|
+
let clipFailures: string[];
|
|
1563
|
+
try {
|
|
1564
|
+
// An unreadable export has no semantic comparison. Leave its established
|
|
1565
|
+
// gate/read failure intact (including the late-read planted controls).
|
|
1566
|
+
clipFailures = gate.status === 0 ? await addedConvexClipFailures(source, join(cand, 'skeleton.json'), opts.build) : [];
|
|
1567
|
+
} catch (error) {
|
|
1568
|
+
return fail(`could not measure clipping setup geometry: ${error instanceof Error ? error.message : String(error)}`);
|
|
1569
|
+
}
|
|
1570
|
+
if (clipFailures.length > 0) {
|
|
1571
|
+
emit('');
|
|
1572
|
+
emit('## 4.1 clipping semantics');
|
|
1573
|
+
for (const line of clipFailures) emit(line);
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1492
1576
|
// 🔒 The skins come off BOTH files, compared before anything is rendered
|
|
1493
1577
|
// (issue #801). A skin the export dropped is named here as LOST BY THE EXPORT
|
|
1494
1578
|
// and fails the run; enumerating the export's skins alone would quietly stop
|
|
@@ -1627,7 +1711,7 @@ function main(): void {
|
|
|
1627
1711
|
// is the half a single un-skinned check could not say.
|
|
1628
1712
|
// A version line that disagrees with the export is red too (issue #1199): the
|
|
1629
1713
|
// report would otherwise name an editor the trip did not run on, green.
|
|
1630
|
-
process.exit(gate.status === 0 && checksClean && rosterClean && !versionDisagrees ? 0 : 1);
|
|
1714
|
+
process.exit(gate.status === 0 && checksClean && rosterClean && !versionDisagrees && clipFailures.length === 0 ? 0 : 1);
|
|
1631
1715
|
}
|
|
1632
1716
|
|
|
1633
1717
|
// ⭐ Guarded so `shapeOf`, `shapeDiff`, `skinsDeclaredBy` and `skinBlocks` can be
|
|
@@ -1638,4 +1722,4 @@ function main(): void {
|
|
|
1638
1722
|
// reach either would be paying for a round trip to test arithmetic. Run as a
|
|
1639
1723
|
// program this is unchanged: `bun tools/editor_roundtrip.ts …` makes this module
|
|
1640
1724
|
// the entry, so `import.meta.main` is true.
|
|
1641
|
-
if (import.meta.main) main();
|
|
1725
|
+
if (import.meta.main) await main();
|