@rig-c/core 2.65.1 → 2.66.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 +98 -10
- package/package.json +1 -1
- package/src/cli/shared.ts +6 -2
- package/src/compile.ts +49 -12
- package/src/core/clipping.ts +9 -5
- package/src/core/deform.ts +136 -53
- package/src/core/entry.ts +4 -3
- package/src/core/index.ts +10 -7
- package/src/core/raw.ts +112 -18
- package/src/core/vertices.ts +123 -11
- package/src/pose.ts +74 -13
package/docs/AUTHORING.md
CHANGED
|
@@ -299,9 +299,12 @@ and it never counts a slot whose bone the posed skin leaves unposed (a `"skin":
|
|
|
299
299
|
bone the skin does not name, or any bone below one, §3.4.8), which draws no pixel;
|
|
300
300
|
when every drawn slot is such a slot, the render is refused by name (§3.4). Three commands take `--out`: a directory for `render`
|
|
301
301
|
(default `render/`), the `.html` file for `preview` (default `preview.html`) and
|
|
302
|
-
for `vote` (default `ballot.html`). Under `render`'s `--out` each
|
|
303
|
-
directory named by the rate (the `--fps` row): `<anim>/` at the default,
|
|
304
|
-
`<anim>@<fps>fps/` at any other — `--fps 24` writes `<anim>@24fps/`, and the default 12 writes `<anim
|
|
302
|
+
for `vote` (default `ballot.html`). Under `render`'s `--out` each render set is a
|
|
303
|
+
directory named by the rate (the `--fps` row): one per animation, `<anim>/` at the default,
|
|
304
|
+
`<anim>@<fps>fps/` at any other — `--fps 24` writes `<anim>@24fps/`, and the default 12 writes `<anim>/`;
|
|
305
|
+
a rig with no animation writes one setup-pose set, `setup/` at the default and
|
|
306
|
+
`setup@<fps>fps/` at any other — `--fps 24` writes `setup@24fps/` — which `frames.json`
|
|
307
|
+
records with `animation: null`. The line `render` prints for
|
|
305
308
|
each set ends in the directory it wrote, and `frames.json` beside the sets names
|
|
306
309
|
each under `sets[].dir` — read the folder from there, not from the animation's name.
|
|
307
310
|
|
|
@@ -5000,7 +5003,10 @@ consumer's, decided by dressing the skeleton rather than by the animation.
|
|
|
5000
5003
|
the keys before it, says so. Generate each cycle without its closing key and
|
|
5001
5004
|
keep that key on the last cycle only: a looping player takes `time % duration`
|
|
5002
5005
|
and nothing interpolates across the wrap, so a track that stops short of its
|
|
5003
|
-
period holds its last key until it (issue #1506).
|
|
5006
|
+
period holds its last key until it (issue #1506). Two times written within two
|
|
5007
|
+
float32 steps of each other — `1` and `1.00000005` — can be stored as one float,
|
|
5008
|
+
and that refusal says so and names the step rather than calling it a cycle
|
|
5009
|
+
(issue #1525).
|
|
5004
5010
|
- **Key times are emitted as float32s like every other number, and never stored
|
|
5005
5011
|
LATER than you wrote them.** Every emitted number is the shortest decimal naming
|
|
5006
5012
|
its float32 — the text the editor writes, and the precision the runtime keeps,
|
|
@@ -7221,7 +7227,7 @@ retain the minimal inputs and report that code rather than inventing author data
|
|
|
7221
7227
|
| <a id="CE_COMPILE_CURVE_VALUE_NONFINITE"></a>`CE_COMPILE_CURVE_VALUE_NONFINITE` | “<value> (t=<value>): raw curve holds a non-finite value <value>” | Supply finite numbers in the raw curve array. |
|
|
7222
7228
|
| <a id="CE_COMPILE_VALUE_TIMELINE_PROPERTY_UNKNOWN"></a>`CE_COMPILE_VALUE_TIMELINE_PROPERTY_UNKNOWN` | “animation "<value>": <value> "<value>" has no property "<value>"” | Use a property present on the named target's timeline family. |
|
|
7223
7229
|
| <a id="CE_COMPILE_TIMELINE_KEYS_EMPTY"></a>`CE_COMPILE_TIMELINE_KEYS_EMPTY` | “<value>: no keys”<br>Also: “animation "<value>" deform <value>/<value>/<value>”<br>“animation "<value>" events”<br>“animation "<value>" drawOrder”<br>“animation "<value>" slot "<value>" <value>”<br>“animation "<value>" <value> constraint "<value>"”<br>Here the values named in the message include: “default”. | Supply at least one key in the timeline. |
|
|
7224
|
-
| <a id="CE_COMPILE_KEY_TIMES_NOT_INCREASING"></a>`CE_COMPILE_KEY_TIMES_NOT_INCREASING` | “<value>: key times must strictly increase — key <value> at t=<value> does not come after key <value> at t=<value> (keys counted from 0)”, and when the two keys share a time and the keys after them repeat the spacing of the keys before them: “. The keys from t=<value> repeat every <value>s, so key <value> closes one cycle and key <value> opens the next at the same time: a phrase that repeats every <value>s ends one key before its period. Generate each cycle without its closing key and keep that key on the last cycle only — a looping player wraps from it to t=0, and a track that stops short holds its last key until the wrap”<br>Also: “animation "<value>" deform <value>/<value>/<value>”<br>“animation "<value>" drawOrder”<br>“animation "<value>" slot "<value>" <value>”<br>“animation "<value>" <value> constraint "<value>"”<br>Here the values named in the message include: “default”. | Order keys by strictly increasing time; the message names both keys of the pair. A phrase generated cycle by cycle with each cycle written from its start to its end repeats every boundary key — drop each cycle's closing key and keep it on the last cycle only (§4.5). |
|
|
7230
|
+
| <a id="CE_COMPILE_KEY_TIMES_NOT_INCREASING"></a>`CE_COMPILE_KEY_TIMES_NOT_INCREASING` | “<value>: key times must strictly increase — key <value> at t=<value> does not come after key <value> at t=<value> (keys counted from 0)”, and when the two keys share a time and the keys after them repeat the spacing of the keys before them: “. The keys from t=<value> repeat every <value>s, so key <value> closes one cycle and key <value> opens the next at the same time: a phrase that repeats every <value>s ends one key before its period. Generate each cycle without its closing key and keep that key on the last cycle only — a looping player wraps from it to t=0, and a track that stops short holds its last key until the wrap”; and when the two keys were written at different times that are stored at one float32, or in the wrong order: “. The two were written <value>s apart, within two float32 steps of each other (one step at t=<value> is <value>s), and a key's time is stored as a float32 — the float a written value already names when it is that float's shortest decimal (which can sit above it, as 0.1's does), otherwise the float just below the time written: both are stored at t=<value>” or “key <value> is stored at t=<value>, before key <value> at t=<value>”, then “. Separate them by at least <value>s”<br>Also: “animation "<value>" deform <value>/<value>/<value>”<br>“animation "<value>" drawOrder”<br>“animation "<value>" slot "<value>" <value>”<br>“animation "<value>" <value> constraint "<value>"”<br>Here the values named in the message include: “default”. | Order keys by strictly increasing time; the message names both keys of the pair. A phrase generated cycle by cycle with each cycle written from its start to its end repeats every boundary key — drop each cycle's closing key and keep it on the last cycle only (§4.5). Two times written closer than two float32 steps are stored as one key time — the message says so and names the step; move them apart. |
|
|
7225
7231
|
| <a id="CE_COMPILE_STEPPED_TIMELINE_EASING"></a>`CE_COMPILE_STEPPED_TIMELINE_EASING` | “<value> key at t=<value> carries [an easing ("<value>") / a curve], and this timeline is stepped by the format — its reader builds no curve, so the mode changes AT the key and holds until the next one. Remove it”<br>Also: “animation "<value>" <value> "<value>" <value>” | Remove easing/curve from the stepped mode timeline. |
|
|
7226
7232
|
| <a id="CE_COMPILE_TIMELINE_MODE_UNKNOWN"></a>`CE_COMPILE_TIMELINE_MODE_UNKNOWN` | “<value> key at t=<value> names mode <value>; <value>”<br>“known: <value> — the runtime folds the case of the first letter and of nothing else”<br>Also: “animation "<value>" <value> "<value>" <value>”<br>“, ”<br>Here the values named in the message include: “noScaleOrReflection”, “normal”, “onlyTranslation”, “noRotationOrReflection”. | Use a mode named in the timeline's allowed mode list. |
|
|
7227
7233
|
| <a id="CE_COMPILE_VALUELESS_TIMELINE_VALUE"></a>`CE_COMPILE_VALUELESS_TIMELINE_VALUE` | “<value>: this timeline takes no value; use null”<br>Also: “animation "<value>" <value> "<value>" <value>” | Use null for a timeline that takes no value. |
|
|
@@ -11372,16 +11378,20 @@ ambiguous one ends with a sentence saying which reading its figures support:
|
|
|
11372
11378
|
Read the figures, not only the verdict. *Below* says plainness alone explains the
|
|
11373
11379
|
failure: the cut may be right, and `pose` cannot tell. *Above* says it does not,
|
|
11374
11380
|
so look at the cut, the `--scale`/`--rotation` window, or — for an ambiguity — at
|
|
11375
|
-
whether the frame really holds the part twice. 🔀 When **every**
|
|
11376
|
-
the margin
|
|
11377
|
-
|
|
11381
|
+
whether the frame really holds the part twice. 🔀 When **every** other place inside
|
|
11382
|
+
the margin holds the part at the best's scale — the two bars the sentence prints
|
|
11383
|
+
beside the figures it measured — the reading says the frame does hold it more than once and
|
|
11378
11384
|
names MOTION.md §2.2's unordered set instead of a window: no `--scale` window
|
|
11379
11385
|
separates two copies of one size, and a `--rotation` window that excludes one place
|
|
11380
11386
|
*chooses* the answer — both files then place on the other copy, one of them off its
|
|
11381
11387
|
truth and printed clean. Both bars sit at the geometric middle of the gap measured
|
|
11382
11388
|
between such repeats and every other ambiguity on the floor's grid (the constants
|
|
11383
11389
|
`REPEAT_SCALE_RATIO` and `REPEAT_DISTANCE_SPANS` in `src/pose.ts` carry the figures);
|
|
11384
|
-
any other ambiguity above the floor keeps the window sentence.
|
|
11390
|
+
any other ambiguity above the floor keeps the window sentence. A *place* is counted
|
|
11391
|
+
once: placements whose centres are closer than the distance bar to a better-scoring
|
|
11392
|
+
one — a quarter- or half-turn fit the search keeps at a copy's own centre, or a
|
|
11393
|
+
second size read at one spot — are that one's place, and the sentence says how
|
|
11394
|
+
many it set aside that way. ⚠️ Above the floor is **not** a
|
|
11385
11395
|
promise that `pose` will place the part (see the rates below).
|
|
11386
11396
|
|
|
11387
11397
|
**The texture figure.** `legibility.texture` is the mean colour step between
|
|
@@ -11529,7 +11539,9 @@ input bytes, and `PO32` holds it to taking the quarter-rung escape and reading t
|
|
|
11529
11539
|
picked from. `PO34` holds two copies of one part 0.6 span apart to both coming back — and is
|
|
11530
11540
|
seen red on the quarter-span grid releases before 1.4.0 searched on, which put them on
|
|
11531
11541
|
one placement — and `PO35` holds the two readings of an ambiguity above the floor to
|
|
11532
|
-
their own shapes, each with a forged alternate that moves it to the other
|
|
11542
|
+
their own shapes, each with a forged alternate that moves it to the other — and twins
|
|
11543
|
+
a half-turn maps onto themselves to the route, which the window takes back when the
|
|
11544
|
+
grouping into places is planted out.
|
|
11533
11545
|
|
|
11534
11546
|
## 12. Reading the half of that picture `pose` refuses — `rigc chainfit`
|
|
11535
11547
|
|
|
@@ -12033,3 +12045,79 @@ for `declared` and the run says so on a line of its own.
|
|
|
12033
12045
|
`@rig-c/play` or `rigc preview`.
|
|
12034
12046
|
- **Not a second poser.** Everything it draws is a pose `render` would have drawn at
|
|
12035
12047
|
the same frame; where it disagrees with `render --geometry`, it is wrong (`IN04`).
|
|
12048
|
+
|
|
12049
|
+
## 14. Read a named mesh at setup
|
|
12050
|
+
|
|
12051
|
+
`rig-c/core` promises `readSetupMesh(view, target)` and the types
|
|
12052
|
+
`SetupMeshTarget` and `SetupMesh` (issue #1538). It reads any named mesh or linked
|
|
12053
|
+
mesh, including one the setup pose does not select, without a player, animation
|
|
12054
|
+
step or change to the document's setup selection.
|
|
12055
|
+
|
|
12056
|
+
```ts
|
|
12057
|
+
import { readModel, underNoSkin, readSetupMesh } from 'rig-c/core';
|
|
12058
|
+
import type { SetupMeshTarget, SetupMesh } from 'rig-c/core';
|
|
12059
|
+
|
|
12060
|
+
const view = underNoSkin(readModel(modelText));
|
|
12061
|
+
const target: SetupMeshTarget = { slot: 'flag', attachment: 'alternate' };
|
|
12062
|
+
const mesh: SetupMesh = readSetupMesh(view, target);
|
|
12063
|
+
// mesh.vertices: full-double world x,y pairs, y up; no crop/camera conversion.
|
|
12064
|
+
```
|
|
12065
|
+
|
|
12066
|
+
| field | contract |
|
|
12067
|
+
| --- | --- |
|
|
12068
|
+
| `target.slot`, `target.attachment` | exact slot name and attachment **table key**; a record's display name, path or atlas region is not an alias |
|
|
12069
|
+
| `target.skin` omitted | retain the supplied view, including `readModel`'s `all`; use `underNoSkin` for a fresh skeleton's view |
|
|
12070
|
+
| `target.skin: null` | no skin set: default-table fallback, no skin-required bones or constraints activated |
|
|
12071
|
+
| `target.skin: name` | the named view, with default-table fallback; only the named skin's activation lists apply |
|
|
12072
|
+
| `target.skin: 'all'` | all activation lists; a uniquely filled key resolves, identical definitions keep the existing resolver's first supplying skin, differing definitions are refused because the model does not carry the Spine file's merge order |
|
|
12073
|
+
| `mesh.slot`, `mesh.attachment`, `mesh.skin` | requested slot and table key, and the table's supplying skin (which can be the default fallback); a linked mesh retains its own identity |
|
|
12074
|
+
| `mesh.vertices` | fresh mutable `number[]`, flat x,y in world units; stored coordinates and weights use the poser's float32 reading and influence order, without normalization |
|
|
12075
|
+
| `mesh.triangles`, `mesh.hull` | fresh mutable triangle indices; hull is the number of leading hull **vertices**, or `null` when absent |
|
|
12076
|
+
|
|
12077
|
+
Definition equality ignores object property insertion order at every depth,
|
|
12078
|
+
including weighted binding records. Array positions, object key presence and
|
|
12079
|
+
leaf values remain significant; leaves use `Object.is`, so positive and negative
|
|
12080
|
+
zero are distinct when retained in a model. Reordering object keys alone is not
|
|
12081
|
+
a different definition. A genuine value, shape or array-order difference still
|
|
12082
|
+
produces the same named all-skin refusal; no merge order is inferred.
|
|
12083
|
+
|
|
12084
|
+
The setup matrices come from the raw setup poser's existing constraint path,
|
|
12085
|
+
including setup sliders and reset physics. A selected mesh is bit-identical to
|
|
12086
|
+
`poseRawSetup(view)`'s geometry, including any setup-slider deform. An unselected
|
|
12087
|
+
mesh uses its stored vertices through those same matrices: the API never switches
|
|
12088
|
+
an attachment to ask what a slider or path constraint would do after a switch.
|
|
12089
|
+
A linked mesh borrows its source vertices, triangles and hull; unweighted
|
|
12090
|
+
vertices use the queried slot's bone, weighted vertices their named bindings.
|
|
12091
|
+
Its `timelines` flag controls source-deform inheritance only when selected, as
|
|
12092
|
+
in the existing setup poser. An inactive bone keeps the existing zero world
|
|
12093
|
+
matrix; an attachment read does not activate it.
|
|
12094
|
+
|
|
12095
|
+
Every result is detached: changing its object or arrays changes no document,
|
|
12096
|
+
pose, cached topology or subsequent read. Missing slots, attachments and skins,
|
|
12097
|
+
and region, clipping, path or bounding-box targets, throw `CoreInputError`
|
|
12098
|
+
naming the requested identity and the problem. Examples include
|
|
12099
|
+
`setup mesh slot "s" attachment "missing" under skin null: no such attachment`
|
|
12100
|
+
and `…: region is not a mesh`. Ambiguous all-skin definitions name their skins
|
|
12101
|
+
and say `choose a named skin`; unknown skin retains `underSkin`'s existing
|
|
12102
|
+
`--skin "missing": no such skin` refusal. Existing setup/non-finite refusals
|
|
12103
|
+
propagate; the read does not manufacture a partial result when setup is absent.
|
|
12104
|
+
|
|
12105
|
+
`S1538` controls exercise literal coordinates, detached data, linked meshes,
|
|
12106
|
+
skin lookup and activation, setup-slider deform, and the official runtime's
|
|
12107
|
+
`computeWorldVertices` using ordinary JS output arrays at tolerance 0. The
|
|
12108
|
+
runtime stores local mesh coordinates and weights in float32, but an output
|
|
12109
|
+
`Float32Array` would add a second rounding that this API does not perform.
|
|
12110
|
+
|
|
12111
|
+
**Measured nonselected slider distinction.** In spine-core 4.3.13,
|
|
12112
|
+
`computeWorldVertices(skeleton, slot, …)` over an unselected mesh still reads
|
|
12113
|
+
the **selected slot's deform array**, even for a linked mesh with
|
|
12114
|
+
`timelines: false`. `S1538_09` measures a slider at time 0.5 interpolating
|
|
12115
|
+
local offsets `(1,2)` and `(3,4)` to `(2,3)`, with a selected source mesh,
|
|
12116
|
+
a linked mesh inheriting its timeline, and an independent linked mesh.
|
|
12117
|
+
All three selected reads are exact. Four of the six nonselected calls to the
|
|
12118
|
+
official method reuse the selected deform; their maximum absolute difference
|
|
12119
|
+
from this API is `2.9999999999999982` world units. The other two nonselected
|
|
12120
|
+
calls, with the independent linked mesh selected, agree exactly. The API's
|
|
12121
|
+
nonselected output is the stored source geometry through the unchanged setup
|
|
12122
|
+
matrices; it does not borrow a different selection's deform. The control
|
|
12123
|
+
checks both results and the source of the difference, without an epsilon.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rig-c/core",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.66.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/src/cli/shared.ts
CHANGED
|
@@ -59,6 +59,7 @@ import {
|
|
|
59
59
|
FRAMES_SIDECAR,
|
|
60
60
|
type PoserName,
|
|
61
61
|
PROTOCOL_FPS,
|
|
62
|
+
SETUP_POSE_DIR,
|
|
62
63
|
UnframeablePoseError,
|
|
63
64
|
} from '../render_shared.ts';
|
|
64
65
|
import { BallotError } from '../ballot.ts';
|
|
@@ -1758,9 +1759,12 @@ export const COMMANDS: CommandDoc[] = [
|
|
|
1758
1759
|
out: {
|
|
1759
1760
|
value: '<dir>',
|
|
1760
1761
|
meaning:
|
|
1761
|
-
'directory to write the frame series into (default `render/`). Each
|
|
1762
|
+
'directory to write the frame series into (default `render/`). Each render set is a directory under it, named by the rate (see --fps): one per animation, ' +
|
|
1762
1763
|
`\`${frameSetDir('<anim>', PROTOCOL_FPS)}/\` at the default, \`${frameSetDir('<anim>', 24).split('24').join('<fps>')}/\` at any other — ` +
|
|
1763
|
-
`\`--fps 24\` writes \`${frameSetDir('<anim>', 24)}/\`, and the default ${PROTOCOL_FPS} writes \`${frameSetDir('<anim>', PROTOCOL_FPS)}
|
|
1764
|
+
`\`--fps 24\` writes \`${frameSetDir('<anim>', 24)}/\`, and the default ${PROTOCOL_FPS} writes \`${frameSetDir('<anim>', PROTOCOL_FPS)}/\`; ` +
|
|
1765
|
+
`a rig with no animation writes one setup-pose set, \`${frameSetDir(SETUP_POSE_DIR, PROTOCOL_FPS)}/\` at the default and ` +
|
|
1766
|
+
`\`${frameSetDir(SETUP_POSE_DIR, 24).split('24').join('<fps>')}/\` at any other — \`--fps 24\` writes \`${frameSetDir(SETUP_POSE_DIR, 24)}/\` — ` +
|
|
1767
|
+
`which ${FRAMES_SIDECAR} records with \`animation: null\`. ` +
|
|
1764
1768
|
`The line printed for each set ends in the directory it wrote, and ${FRAMES_SIDECAR} beside the sets names each under \`sets[].dir\``,
|
|
1765
1769
|
},
|
|
1766
1770
|
fps: {
|
package/src/compile.ts
CHANGED
|
@@ -1170,15 +1170,44 @@ function checkKeyTime(where: string, time: number, authored: number, duration: n
|
|
|
1170
1170
|
* ⚠️ The repetition is compared in authored seconds within one float32 step of
|
|
1171
1171
|
* the later key (`float32Step`) — `lag`/`stagger` shift every key of a track by
|
|
1172
1172
|
* the same amount, so the spacing is the same before and after them.
|
|
1173
|
+
*
|
|
1174
|
+
* 🔒 **Every key after the pair is held to its periodic position, not a window
|
|
1175
|
+
* of them** (issue #1525). The cycle is the `i` keys before the opening key —
|
|
1176
|
+
* key 0 through the closing key `i − 1` — and the key `j` places after the
|
|
1177
|
+
* opening one must sit at `keys[j mod i].t + period × (1 + floor(j / i))`. A
|
|
1178
|
+
* window of `min(i − 1, keys.length − 1 − i)` keys read a phrase of two keys,
|
|
1179
|
+
* a duplicate, then one key on the spacing and one off it — the shape
|
|
1180
|
+
* `0, a, a, 2a, 2a + b` with `b` not `a` — as a cycle, because the key off the
|
|
1181
|
+
* spacing sat past the window. The exam's shape — four cycles of five keys,
|
|
1182
|
+
* each written from its start to its end, so the boundary key repeats at keys
|
|
1183
|
+
* 4/5, 9/10 and 14/15 — still reads as one: the duplicates further on are the
|
|
1184
|
+
* same rule at `j mod i = 0`.
|
|
1185
|
+
*
|
|
1186
|
+
* 🔒 **The cycle sentence needs the two keys to share an AUTHORED time** (issue
|
|
1187
|
+
* #1525), not a stored one: `keyTime` puts a key on the largest float32 not
|
|
1188
|
+
* above its time, so two times the author wrote apart can be stored as the
|
|
1189
|
+
* same float, or the later one below the earlier. That pair is not a boundary
|
|
1190
|
+
* the author repeated, so it gets the clause that says what did happen — the
|
|
1191
|
+
* two authored values, the stored ones, and the float32 step there — and never
|
|
1192
|
+
* the cycle sentence. "Within two steps" rather than one is measured, not
|
|
1193
|
+
* rounded: a time that names its own float (`0.2`) is stored at that float,
|
|
1194
|
+
* which can sit up to half a step above it, so a later time up to one and a
|
|
1195
|
+
* half steps after it is stored at the same float.
|
|
1173
1196
|
*/
|
|
1174
|
-
function refuseKeyOrder(
|
|
1197
|
+
function refuseKeyOrder(
|
|
1198
|
+
where: string,
|
|
1199
|
+
keys: ReadonlyArray<{ t: number }>,
|
|
1200
|
+
i: number,
|
|
1201
|
+
stored: number,
|
|
1202
|
+
storedBefore: number,
|
|
1203
|
+
): never {
|
|
1175
1204
|
const at = keys[i].t;
|
|
1176
1205
|
const before = keys[i - 1].t;
|
|
1177
1206
|
const period = at - keys[0].t;
|
|
1178
|
-
|
|
1179
|
-
let
|
|
1180
|
-
|
|
1181
|
-
if (Math.abs(keys[i + j].t -
|
|
1207
|
+
let repeats = at === before && period > 0 && i + 1 < keys.length;
|
|
1208
|
+
for (let j = 1; repeats && i + j < keys.length; j++) {
|
|
1209
|
+
const expected = keys[j % i].t + period * (1 + Math.floor(j / i));
|
|
1210
|
+
if (Math.abs(keys[i + j].t - expected) > float32Step(keys[i + j].t)) repeats = false;
|
|
1182
1211
|
}
|
|
1183
1212
|
const boundary = repeats
|
|
1184
1213
|
? `. The keys from t=${keys[0].t} repeat every ${period}s, so key ${i - 1} closes one cycle and key ${i} opens ` +
|
|
@@ -1186,9 +1215,17 @@ function refuseKeyOrder(where: string, keys: ReadonlyArray<{ t: number }>, i: nu
|
|
|
1186
1215
|
'each cycle without its closing key and keep that key on the last cycle only — a looping player wraps from ' +
|
|
1187
1216
|
'it to t=0, and a track that stops short holds its last key until the wrap'
|
|
1188
1217
|
: '';
|
|
1218
|
+
const step = float32Step(stored);
|
|
1219
|
+
const quantised =
|
|
1220
|
+
at > before
|
|
1221
|
+
? `. The two were written ${at - before}s apart, within two float32 steps of each other (one step at ` +
|
|
1222
|
+
`t=${stored} is ${step}s), and a key's time is stored as a float32 — the float a written value already names when it is that float's shortest decimal (which can sit above it, as 0.1's does), otherwise the float just below the time written: ` +
|
|
1223
|
+
(stored === storedBefore ? `both are stored at t=${stored}` : `key ${i} is stored at t=${stored}, before key ${i - 1} at t=${storedBefore}`) +
|
|
1224
|
+
`. Separate them by at least ${2 * step}s`
|
|
1225
|
+
: '';
|
|
1189
1226
|
throw new CompileError(
|
|
1190
1227
|
`${where}: key times must strictly increase — key ${i} at t=${at} does not come after key ${i - 1} at ` +
|
|
1191
|
-
`t=${before} (keys counted from 0)${boundary}`,
|
|
1228
|
+
`t=${before} (keys counted from 0)${boundary}${quantised}`,
|
|
1192
1229
|
'CE_COMPILE_KEY_TIMES_NOT_INCREASING',
|
|
1193
1230
|
);
|
|
1194
1231
|
}
|
|
@@ -8151,7 +8188,7 @@ function compileValueTrack(
|
|
|
8151
8188
|
const key = track.keys[i];
|
|
8152
8189
|
const next = track.keys[i + 1];
|
|
8153
8190
|
const time = keyTime(key.t + shift);
|
|
8154
|
-
if (i > 0 && time <= (out[i - 1].time as number)) refuseKeyOrder(where, track.keys, i, time
|
|
8191
|
+
if (i > 0 && time <= (out[i - 1].time as number)) refuseKeyOrder(where, track.keys, i, time, out[i - 1].time as number);
|
|
8155
8192
|
checkKeyTime(where, time, key.t, duration);
|
|
8156
8193
|
// A named-value timeline (`inherit`): the key's `v` is one name, stepped by
|
|
8157
8194
|
// the format. The name is resolved rather than copied, and what is WRITTEN
|
|
@@ -8280,7 +8317,7 @@ function compileDrawOrder(
|
|
|
8280
8317
|
for (let i = 0; i < keys.length; i++) {
|
|
8281
8318
|
const key = keys[i];
|
|
8282
8319
|
const time = keyTime(key.t);
|
|
8283
|
-
if (i > 0 && time <= (out[i - 1].time as number)) refuseKeyOrder(where, keys, i, time
|
|
8320
|
+
if (i > 0 && time <= (out[i - 1].time as number)) refuseKeyOrder(where, keys, i, time, out[i - 1].time as number);
|
|
8284
8321
|
checkKeyTime(where, time, key.t, duration);
|
|
8285
8322
|
// No offsets = "back to the setup draw order", which is the parser's own
|
|
8286
8323
|
// encoding for it. An empty array means the same thing and is written the
|
|
@@ -8519,7 +8556,7 @@ function compileConstraintTrack(
|
|
|
8519
8556
|
const key = keys[i];
|
|
8520
8557
|
const next = keys[i + 1];
|
|
8521
8558
|
const time = keyTime(key.t);
|
|
8522
|
-
if (i > 0 && time <= (out[i - 1].time as number)) refuseKeyOrder(where, keys, i, time
|
|
8559
|
+
if (i > 0 && time <= (out[i - 1].time as number)) refuseKeyOrder(where, keys, i, time, out[i - 1].time as number);
|
|
8523
8560
|
checkKeyTime(where, time, key.t, duration);
|
|
8524
8561
|
|
|
8525
8562
|
const entry: ModelKey = { time };
|
|
@@ -8945,7 +8982,7 @@ function compileSequenceTrack(
|
|
|
8945
8982
|
for (let i = 0; i < track.keys.length; i++) {
|
|
8946
8983
|
const key = track.keys[i];
|
|
8947
8984
|
const time = keyTime(key.t);
|
|
8948
|
-
if (i > 0 && time <= (out[i - 1].time as number)) refuseKeyOrder(where, track.keys, i, time
|
|
8985
|
+
if (i > 0 && time <= (out[i - 1].time as number)) refuseKeyOrder(where, track.keys, i, time, out[i - 1].time as number);
|
|
8949
8986
|
checkKeyTime(where, time, key.t, duration);
|
|
8950
8987
|
if (key.index !== undefined && key.index >= series.count) {
|
|
8951
8988
|
throw new CompileError(
|
|
@@ -8983,7 +9020,7 @@ function compileDeformTrack(
|
|
|
8983
9020
|
for (let i = 0; i < keys.length; i++) {
|
|
8984
9021
|
const key = keys[i];
|
|
8985
9022
|
const time = keyTime(key.t);
|
|
8986
|
-
if (i > 0 && time <= (out[i - 1].time as number)) refuseKeyOrder(where, keys, i, time
|
|
9023
|
+
if (i > 0 && time <= (out[i - 1].time as number)) refuseKeyOrder(where, keys, i, time, out[i - 1].time as number);
|
|
8987
9024
|
checkKeyTime(where, time, key.t, duration);
|
|
8988
9025
|
// -- a key that states a MODEL rather than a run (issue #294) ------------
|
|
8989
9026
|
//
|
|
@@ -9695,7 +9732,7 @@ function compileTrack(
|
|
|
9695
9732
|
const key = track.keys[i];
|
|
9696
9733
|
const next = track.keys[i + 1];
|
|
9697
9734
|
const time = keyTime(key.t + shift);
|
|
9698
|
-
if (i > 0 && time <= (out[i - 1].time as number)) refuseKeyOrder(where, track.keys, i, time
|
|
9735
|
+
if (i > 0 && time <= (out[i - 1].time as number)) refuseKeyOrder(where, track.keys, i, time, out[i - 1].time as number);
|
|
9699
9736
|
checkKeyTime(where, time, key.t, duration);
|
|
9700
9737
|
|
|
9701
9738
|
if (shape === 'attachment') {
|
package/src/core/clipping.ts
CHANGED
|
@@ -297,13 +297,17 @@ export type TriangleClipper = (polygon: readonly number[], vertices: readonly nu
|
|
|
297
297
|
/** One step of the draw walk: a slot, what its bone's activity is, and what it shows as the walk reads it. */
|
|
298
298
|
export type DrawStep =
|
|
299
299
|
| { slot: string; kind: 'clip'; attachment: string; active: boolean; end: string | null; polygon: number[]; inverse: boolean; convex: boolean }
|
|
300
|
-
| { slot: string; kind: 'draw'; attachment: string; vertices: number[]; triangles: number[]; uvs: number[] }
|
|
300
|
+
| { slot: string; kind: 'draw'; attachment: string; vertices: readonly number[]; triangles: readonly number[]; uvs: readonly number[] }
|
|
301
301
|
| { slot: string; kind: 'none' };
|
|
302
302
|
|
|
303
|
-
/**
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
303
|
+
/**
|
|
304
|
+
* The local UVs the oracle hands a region's four corners to the clipper with, in the corner order (bottom-left, upper-left, upper-right, bottom-right).
|
|
305
|
+
* Frozen (issue #1530): every drawn region of every pose hands out this one array (`rawDrawn` in `./raw.ts`), so a write to it would reach every
|
|
306
|
+
* frame a consumer holds — frozen, the write throws instead.
|
|
307
|
+
*/
|
|
308
|
+
export const REGION_UVS: readonly number[] = Object.freeze([0, 1, 0, 0, 1, 0, 1, 1]);
|
|
309
|
+
/** A region's two triangles over its four corners — the runtime's quad triangulation (`src/render.ts`'s `QUAD_TRIANGLES`). Frozen, as `REGION_UVS` is and for its reason. */
|
|
310
|
+
export const REGION_TRIANGLES: readonly number[] = Object.freeze([0, 1, 2, 2, 3, 0]);
|
|
307
311
|
|
|
308
312
|
/** `clipThrough`'s shape, replaceable by the render suite's plants (`RC08`'s dropped region). */
|
|
309
313
|
export type ShapeClipper = (shape: ClipShape, vertices: readonly number[], triangles: readonly number[], uvs: readonly number[]) => ClipResult | null;
|
package/src/core/deform.ts
CHANGED
|
@@ -180,9 +180,9 @@
|
|
|
180
180
|
*/
|
|
181
181
|
import type { ModelSlot, ModelVertices } from '../model.ts';
|
|
182
182
|
import { bezierPolyline, keyIndexAt, type CoreAnimationTimelines, type CoreCurve } from './animation.ts';
|
|
183
|
-
import { activeBones, shownRow, type CompiledDocument, type CoreShown, type CoreSkin, type ShownResolution } from './index.ts';
|
|
183
|
+
import { activeBones, shownAttachment, shownRow, type CompiledDocument, type CoreShown, type CoreSkin, type ShownResolution } from './index.ts';
|
|
184
184
|
import { fillingSkins, slotTimelinesApply, type SlotTimelineGate } from './skins.ts';
|
|
185
|
-
import type {
|
|
185
|
+
import type { ShownGeometry } from './vertices.ts';
|
|
186
186
|
|
|
187
187
|
/** The far end the deform curve's recurrence runs to — the header's measurement. */
|
|
188
188
|
export const DEFORM_CURVE_END = 0.99999999;
|
|
@@ -515,6 +515,111 @@ export function timelineIdentity(slot: string, shown: CoreShown): string | null
|
|
|
515
515
|
|
|
516
516
|
const nameOf = (a: CoreAttachmentTimeline): string => `${a.skin}/${a.slot}/${a.attachment}`;
|
|
517
517
|
|
|
518
|
+
/** The part of an attachment state that depends only on the document view and placeholder. */
|
|
519
|
+
interface AttachmentIdentity {
|
|
520
|
+
readonly entry: ShownGeometry;
|
|
521
|
+
readonly identity: string | null;
|
|
522
|
+
readonly vertices: ModelVertices | null;
|
|
523
|
+
readonly count: number | undefined;
|
|
524
|
+
readonly fillers: readonly string[];
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** Like shownAttachment's cache, scoped to the view; a new skin view resolves its own records. */
|
|
528
|
+
const identitiesOfView = new WeakMap<CompiledDocument, Map<string, Map<string, AttachmentIdentity | null>>>();
|
|
529
|
+
|
|
530
|
+
function readIdentity(doc: CompiledDocument, resolve: (doc: CompiledDocument, slot: ModelSlot) => ShownResolution, slot: ModelSlot, placeholder: string): AttachmentIdentity | null {
|
|
531
|
+
const s = resolve(doc, { ...slot, setup: placeholder });
|
|
532
|
+
if (s === null || 'conflict' in s || s.record.geometry === undefined) return null;
|
|
533
|
+
const g = s.record.geometry;
|
|
534
|
+
const source = g.kind === 'linkedmesh' ? doc.skins.find((k) => k.name === g.skin)?.attachments[g.slot]?.[g.source] : s.record;
|
|
535
|
+
const sg = source?.geometry;
|
|
536
|
+
return Object.freeze({
|
|
537
|
+
entry: Object.freeze({ slot: slot.name, bone: slot.bone, name: shownRow(s).name, placeholder: s.placeholder, skin: s.skin, geometry: g }),
|
|
538
|
+
identity: timelineIdentity(slot.name, s),
|
|
539
|
+
vertices: sg !== undefined && sg.kind !== 'region' && sg.kind !== 'linkedmesh' ? sg.vertices : null,
|
|
540
|
+
count: source?.sequenceCount,
|
|
541
|
+
fillers: Object.freeze(fillingSkins(doc, slot.name, s.placeholder).map((k) => `"${k}"`)),
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function identityOf(doc: CompiledDocument, resolve: (doc: CompiledDocument, slot: ModelSlot) => ShownResolution, slot: ModelSlot, placeholder: string | null): AttachmentIdentity | null {
|
|
546
|
+
if (placeholder === null) return null;
|
|
547
|
+
// A planted resolver may read changing state outside the document. Only the document's own reader is immutable.
|
|
548
|
+
if (resolve !== shownAttachment) return readIdentity(doc, resolve, slot, placeholder);
|
|
549
|
+
let slots = identitiesOfView.get(doc);
|
|
550
|
+
if (slots === undefined) identitiesOfView.set(doc, slots = new Map());
|
|
551
|
+
let placeholders = slots.get(slot.name);
|
|
552
|
+
if (placeholders === undefined) slots.set(slot.name, placeholders = new Map());
|
|
553
|
+
if (placeholders.has(placeholder)) return placeholders.get(placeholder) as AttachmentIdentity | null;
|
|
554
|
+
const identity = readIdentity(doc, resolve, slot, placeholder);
|
|
555
|
+
placeholders.set(placeholder, identity);
|
|
556
|
+
return identity;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** Index only immutable timeline identity. Times, mixes and the values they compute are never cached. */
|
|
560
|
+
const attachmentsByIdentity = new WeakMap<readonly CoreAttachmentTimeline[], ReadonlyMap<string, readonly CoreAttachmentTimeline[]>>();
|
|
561
|
+
type AttachmentSlots = AttachmentApplication['timelines']['slots'];
|
|
562
|
+
type AttachmentSwitch = NonNullable<AttachmentSlots[number]['timelines'][number]['keys']>;
|
|
563
|
+
const switchesBySlot = new WeakMap<AttachmentSlots, ReadonlyMap<string, readonly AttachmentSwitch[]>>();
|
|
564
|
+
|
|
565
|
+
function matchingAttachments(timelines: readonly CoreAttachmentTimeline[], identity: string): readonly CoreAttachmentTimeline[] | undefined {
|
|
566
|
+
let index = attachmentsByIdentity.get(timelines);
|
|
567
|
+
if (index === undefined) {
|
|
568
|
+
const rows = new Map<string, CoreAttachmentTimeline[]>();
|
|
569
|
+
for (const a of timelines) {
|
|
570
|
+
const name = nameOf(a);
|
|
571
|
+
const found = rows.get(name);
|
|
572
|
+
if (found === undefined) rows.set(name, [a]);
|
|
573
|
+
else found.push(a);
|
|
574
|
+
}
|
|
575
|
+
attachmentsByIdentity.set(timelines, index = rows);
|
|
576
|
+
}
|
|
577
|
+
return index.get(identity);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function slotSwitches(slots: AttachmentSlots, slot: string): readonly AttachmentSwitch[] | undefined {
|
|
581
|
+
let index = switchesBySlot.get(slots);
|
|
582
|
+
if (index === undefined) {
|
|
583
|
+
const rows = new Map<string, AttachmentSwitch[]>();
|
|
584
|
+
for (const target of slots) {
|
|
585
|
+
for (const tl of target.timelines) {
|
|
586
|
+
if (tl.kind !== 'attachment' || tl.keys === undefined) continue;
|
|
587
|
+
const found = rows.get(target.name);
|
|
588
|
+
if (found === undefined) rows.set(target.name, [tl.keys]);
|
|
589
|
+
else found.push(tl.keys);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
switchesBySlot.set(slots, index = rows);
|
|
593
|
+
}
|
|
594
|
+
return index.get(slot);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/** Allocated only when a matching timeline or a mixed deform actually has per-pose state. */
|
|
598
|
+
interface AttachmentValues {
|
|
599
|
+
deform: number[] | null;
|
|
600
|
+
frame: number | null;
|
|
601
|
+
seen: string[];
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function applyAttachment(now: AttachmentIdentity | null, values: AttachmentValues | null, timelines: readonly CoreAttachmentTimeline[], t: number, blend: AttachmentApplication | null, evalDeform: DeformEvaluator, blendOf: DeformBlender, evalFrame: SequenceEvaluator): AttachmentValues | null {
|
|
605
|
+
if (now === null || now.identity === null) return values;
|
|
606
|
+
const matching = matchingAttachments(timelines, now.identity);
|
|
607
|
+
if (matching === undefined) return values;
|
|
608
|
+
values ??= { deform: null, frame: null, seen: [] };
|
|
609
|
+
if (!values.seen.includes(now.identity)) values.seen.push(now.identity);
|
|
610
|
+
for (const a of matching) {
|
|
611
|
+
if (a.deform !== null && now.vertices !== null) {
|
|
612
|
+
const target = evalDeform(now.vertices, a.deform, t);
|
|
613
|
+
if (target !== null) values.deform = blend === null ? target : blendOf(now.vertices, values.deform, target, blend.alpha, blend.additive);
|
|
614
|
+
}
|
|
615
|
+
if (a.sequence !== null && now.count !== undefined) {
|
|
616
|
+
const f = evalFrame(a.sequence, now.count, t);
|
|
617
|
+
if (f !== null) values.frame = f;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
return values;
|
|
621
|
+
}
|
|
622
|
+
|
|
518
623
|
/**
|
|
519
624
|
* Every slot's shown geometry at one pose — the setup's (`sample` null) or a
|
|
520
625
|
* sample's — with the deform array and the series' frame its attachment
|
|
@@ -528,6 +633,11 @@ const nameOf = (a: CoreAttachmentTimeline): string => `${a.skin}/${a.slot}/${a.a
|
|
|
528
633
|
* differently is left to the slots' own absence. `why` names what the core
|
|
529
634
|
* does not pose: a timeline moving a record whose placeholder several skins
|
|
530
635
|
* fill (the header's ⚠️).
|
|
636
|
+
*
|
|
637
|
+
* Identity is kept per document view, slot and placeholder, and timelines
|
|
638
|
+
* indexed by their record identity. A slot with no per-pose values reuses its
|
|
639
|
+
* frozen entry; deform arrays and sequence frames are evaluated afresh.
|
|
640
|
+
* Sliders select identities in application order, never a cached posed value.
|
|
531
641
|
*/
|
|
532
642
|
export function attachmentStates(
|
|
533
643
|
doc: CompiledDocument,
|
|
@@ -557,66 +667,39 @@ export function attachmentStates(
|
|
|
557
667
|
const live = gate(doc, slot, active);
|
|
558
668
|
let placeholder = placeholders.get(slot.name) ?? null;
|
|
559
669
|
const base = live ? mixed?.get(slot.name) : undefined;
|
|
560
|
-
let
|
|
561
|
-
let
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
const apply = (timelines: readonly CoreAttachmentTimeline[], t: number, blend: { alpha: number; additive: boolean } | null): void => {
|
|
575
|
-
const now = state();
|
|
576
|
-
if (now === null || now.identity === null) return;
|
|
577
|
-
for (const a of timelines) {
|
|
578
|
-
if (nameOf(a) !== now.identity) continue;
|
|
579
|
-
if (!seen.includes(now.identity)) seen.push(now.identity);
|
|
580
|
-
if (a.deform !== null && now.vertices !== null) {
|
|
581
|
-
const target = evalDeform(now.vertices, a.deform, t);
|
|
582
|
-
if (target !== null) deform = blend === null ? target : blendOf(now.vertices, deform, target, blend.alpha, blend.additive);
|
|
583
|
-
}
|
|
584
|
-
if (a.sequence !== null && now.count !== undefined) {
|
|
585
|
-
const f = evalFrame(a.sequence, now.count, t);
|
|
586
|
-
if (f !== null) frame = f;
|
|
587
|
-
}
|
|
588
|
-
}
|
|
589
|
-
};
|
|
590
|
-
if (sample !== null && live) apply(sample.timelines.attachments, sample.t, null);
|
|
591
|
-
for (const app of live ? sliders : []) {
|
|
592
|
-
// The slider's attachment key first, as the slot timelines are (`applySliderSlots` in `./constraints_slider.ts`); a switch to another placeholder clears what the timelines set.
|
|
593
|
-
for (const target of app.timelines.slots) {
|
|
594
|
-
if (target.name !== slot.name) continue;
|
|
595
|
-
for (const tl of target.timelines) {
|
|
596
|
-
if (tl.kind !== 'attachment' || tl.keys === undefined) continue;
|
|
597
|
-
const i = keyIndexAt(tl.keys, app.at);
|
|
598
|
-
if (i < 0) continue;
|
|
599
|
-
const next = tl.keys[i].name ?? null;
|
|
600
|
-
if (next !== placeholder) {
|
|
601
|
-
deform = null;
|
|
602
|
-
frame = null;
|
|
670
|
+
let values: AttachmentValues | null = base === undefined || (base.deform === null && base.seen.length === 0) ? null : { deform: base.deform, frame: null, seen: [...base.seen] };
|
|
671
|
+
let now = identityOf(doc, resolve, slot, placeholder);
|
|
672
|
+
if (sample !== null && live) values = applyAttachment(now, values, sample.timelines.attachments, sample.t, null, evalDeform, blendOf, evalFrame);
|
|
673
|
+
if (live) for (const app of sliders) {
|
|
674
|
+
// Each slider switches first, clearing only the per-pose values; then its attachment timelines apply to the new identity.
|
|
675
|
+
const switches = slotSwitches(app.timelines.slots, slot.name);
|
|
676
|
+
if (switches !== undefined) for (const keys of switches) {
|
|
677
|
+
const i = keyIndexAt(keys, app.at);
|
|
678
|
+
if (i < 0) continue;
|
|
679
|
+
const next = keys[i].name ?? null;
|
|
680
|
+
if (next !== placeholder) {
|
|
681
|
+
if (values !== null) {
|
|
682
|
+
values.deform = null;
|
|
683
|
+
values.frame = null;
|
|
603
684
|
}
|
|
604
685
|
placeholder = next;
|
|
686
|
+
now = identityOf(doc, resolve, slot, placeholder);
|
|
605
687
|
}
|
|
606
688
|
}
|
|
607
|
-
|
|
689
|
+
values = applyAttachment(now, values, app.timelines.attachments, app.at, app, evalDeform, blendOf, evalFrame);
|
|
608
690
|
}
|
|
609
|
-
const now = state();
|
|
610
691
|
if (now === null) continue;
|
|
611
|
-
|
|
692
|
+
// The cached entry is frozen. A pose with a deform/frame owns its wrapper and values; later poses cannot write into it.
|
|
693
|
+
const entry = values === null || (values.deform === null && values.frame === null) ? now.entry : { ...now.entry };
|
|
612
694
|
shown.push(entry);
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
why.push(`slot "${slot.name}" shows placeholder "${now.s.placeholder}", which skins ${fillers.join(', ')} fill, and ${seen.map((x) => `"${x}"`).join(', ')} key(s) it — which record --skin all shows, and so whether the timeline moves it, is the Spine file's skin order, not the model's`);
|
|
695
|
+
if (values !== null && values.seen.length > 0 && now.fillers.length > 1) {
|
|
696
|
+
why.push(`slot "${slot.name}" shows placeholder "${now.entry.placeholder}", which skins ${now.fillers.join(', ')} fill, and ${values.seen.map((x) => `"${x}"`).join(', ')} key(s) it — which record --skin all shows, and so whether the timeline moves it, is the Spine file's skin order, not the model's`);
|
|
616
697
|
continue;
|
|
617
698
|
}
|
|
618
|
-
if (
|
|
619
|
-
|
|
699
|
+
if (values !== null) {
|
|
700
|
+
if (values.deform !== null) entry.deform = values.deform;
|
|
701
|
+
if (values.frame !== null) entry.frame = values.frame;
|
|
702
|
+
}
|
|
620
703
|
}
|
|
621
704
|
return { shown, why };
|
|
622
705
|
}
|
package/src/core/entry.ts
CHANGED
|
@@ -24,14 +24,15 @@
|
|
|
24
24
|
* a Bézier segment as, so that a fitter holds its tolerance to the poser's
|
|
25
25
|
* own evaluation rather than to a mirror of it, issue #1417 — re-exported,
|
|
26
26
|
* never copied, because a copy is the drift the request exists to remove;
|
|
27
|
-
* `CA12` holds the identity
|
|
27
|
+
* `CA12` holds the identity; `readSetupMesh`, issue #1538, the named setup
|
|
28
|
+
* geometry read requested by the player, with `SetupMesh` and `SetupMeshTarget`).
|
|
28
29
|
* The list is `scripts/install_smoke.ts`'s `OBSERVED_SYMBOLS` rows for
|
|
29
30
|
* `./core`, and a symbol added here and not there is not promised.
|
|
30
31
|
*/
|
|
31
32
|
export { readModel, underSkin, underNoSkin, CoreInputError, CORE_DOCUMENT_SPEC, CORE_DOCUMENT_SPECS, CORE_BLEND_MODES, activeBones } from './index.ts';
|
|
32
33
|
export type { CompiledDocument, CoreSlotRow, CoreBlendMode } from './index.ts';
|
|
33
|
-
export { poseRawSetup, poseRawAnimation, poseRawAnimationEach, loopedTime, setupBounds, physicsClockStep, PHYSICS_CLOCK_LEAD } from './raw.ts';
|
|
34
|
-
export type { RawPose, RawBone, RawDrawn, RawClip, RawClipped, RawEvent, RawReset } from './raw.ts';
|
|
34
|
+
export { readSetupMesh, poseRawSetup, poseRawAnimation, poseRawAnimationEach, loopedTime, setupBounds, physicsClockStep, PHYSICS_CLOCK_LEAD } from './raw.ts';
|
|
35
|
+
export type { SetupMesh, SetupMeshTarget, RawPose, RawBone, RawDrawn, RawClip, RawClipped, RawEvent, RawReset } from './raw.ts';
|
|
35
36
|
export { poseWalkSetup, poseLoopingWalk } from './walk.ts';
|
|
36
37
|
export { openTrack } from './track.ts';
|
|
37
38
|
export type { LiveTrack, TrackOptions, AdjustableLocals, BoneLocals, LocalAdjust, PhysicsEnvironment } from './track.ts';
|
package/src/core/index.ts
CHANGED
|
@@ -176,7 +176,7 @@ import type { ModelAtlasRect, ModelBone, ModelEditorOrder, ModelPage, ModelPageR
|
|
|
176
176
|
import { worldTransforms, type CoreWorld } from './world.ts';
|
|
177
177
|
import { readAnimationTimelines, type CoreAnimationTimelines } from './animation.ts';
|
|
178
178
|
import { readEventDefs, type CoreEventDef } from './events.ts';
|
|
179
|
-
import { poseGeometry, readGeometry, type CoreAttachmentRow, type CoreClipRow, type CoreGeometry, type DrawWalk, type RegionPoser, type ShownGeometry, type VertexPoser } from './vertices.ts';
|
|
179
|
+
import { poseGeometry, readGeometry, type CoreAttachmentRow, type CoreClipRow, type CoreGeometry, type DrawWalk, type MeshArraysOf, type RegionPoser, type ShownGeometry, type VertexPoser } from './vertices.ts';
|
|
180
180
|
import type { CoreClippedRow, ShapeClipper, TriangleClipper } from './clipping.ts';
|
|
181
181
|
import { applyConstraints, constraintsAbsentWhy, readConstraintRecord, readConstraintTimelines, solverRules, type ConstraintPlant, type CoreConstraintRecord, type CoreConstraintTimelines, type SolverRules } from './constraints.ts';
|
|
182
182
|
import { readPathRecord } from './constraints_path.ts';
|
|
@@ -1077,6 +1077,8 @@ export interface CorePlant {
|
|
|
1077
1077
|
region?: RegionPoser;
|
|
1078
1078
|
/** A vertex array's world positions (`worldVertices` in `./vertices.ts`). */
|
|
1079
1079
|
vertices?: VertexPoser;
|
|
1080
|
+
/** A drawn mesh's read-only UVs and triangles (`meshArraysOf` in `./vertices.ts`) — the raw entry's `drawn`; nothing but a control passes one. */
|
|
1081
|
+
meshArrays?: MeshArraysOf;
|
|
1080
1082
|
/** The ik, transform and path constraints as posed, rewritten before they are applied (`./constraints.ts`). */
|
|
1081
1083
|
constraints?: ConstraintPlant;
|
|
1082
1084
|
/** Issue #979's solver rules, one planted back to the reading before it (`RUNTIME_SOLVER_RULES` in `./constraints.ts`). */
|
|
@@ -1194,10 +1196,11 @@ const shownOfView = new WeakMap<CompiledDocument, Map<string, Map<string, ShownR
|
|
|
1194
1196
|
* The answer reads the view's skins and the slot's name and placeholder only,
|
|
1195
1197
|
* so it is resolved once per view, slot and placeholder and kept (issue
|
|
1196
1198
|
* #1134: every sample of a walk resolved every slot again). Read-only to every
|
|
1197
|
-
* caller; a view built anew resolves its own.
|
|
1199
|
+
* caller; a view built anew resolves its own. An explicit placeholder reads
|
|
1200
|
+
* another table key without changing the slot (issue #1538).
|
|
1198
1201
|
*/
|
|
1199
|
-
export function shownAttachment(doc: CompiledDocument, slot: ModelSlot): ShownResolution {
|
|
1200
|
-
if (
|
|
1202
|
+
export function shownAttachment(doc: CompiledDocument, slot: ModelSlot, placeholder: string | null = slot.setup): ShownResolution {
|
|
1203
|
+
if (placeholder === null) return null;
|
|
1201
1204
|
let bySlot = shownOfView.get(doc);
|
|
1202
1205
|
if (bySlot === undefined) {
|
|
1203
1206
|
bySlot = new Map();
|
|
@@ -1208,9 +1211,9 @@ export function shownAttachment(doc: CompiledDocument, slot: ModelSlot): ShownRe
|
|
|
1208
1211
|
byPlaceholder = new Map();
|
|
1209
1212
|
bySlot.set(slot.name, byPlaceholder);
|
|
1210
1213
|
}
|
|
1211
|
-
if (byPlaceholder.has(
|
|
1212
|
-
const resolved = resolveShown(doc, slot.name,
|
|
1213
|
-
byPlaceholder.set(
|
|
1214
|
+
if (byPlaceholder.has(placeholder)) return byPlaceholder.get(placeholder) as ShownResolution;
|
|
1215
|
+
const resolved = resolveShown(doc, slot.name, placeholder);
|
|
1216
|
+
byPlaceholder.set(placeholder, resolved);
|
|
1214
1217
|
return resolved;
|
|
1215
1218
|
}
|
|
1216
1219
|
|
package/src/core/raw.ts
CHANGED
|
@@ -113,7 +113,7 @@
|
|
|
113
113
|
* naming why (`CoreInputError`): a renderer handed a pose with a block
|
|
114
114
|
* missing would draw a different picture in silence.
|
|
115
115
|
*/
|
|
116
|
-
import { activeBones, CoreInputError, drawWalkOf, poseSetup, rawNumber, readColour, shownAttachment, sourceOfDoc, underNoSkin, type CompiledDocument, type CorePlant, type CoreSlotRow } from './index.ts';
|
|
116
|
+
import { activeBones, CoreInputError, drawWalkOf, poseSetup, rawNumber, readColour, shownAttachment, sourceOfDoc, underNoSkin, underSkin, type CompiledDocument, type CorePlant, type CoreSlotRow } from './index.ts';
|
|
117
117
|
import { posedBoneWorld, posedBoneWorldAlone, posedSlots, type CoreAnimationTimelines, type LocalAdjust, type TimelinePlant } from './animation.ts';
|
|
118
118
|
import { constraintsAbsentWhy, pathAnimationsWhy } from './constraints.ts';
|
|
119
119
|
import { freshStepContext, rotatePhysics, steppedPreviousPassWhy, translatePhysics, DEFAULT_PHYSICS_ENVIRONMENT, type PhysicsEnvironment, type PhysicsStepContext } from './constraints_physics.ts';
|
|
@@ -121,8 +121,9 @@ import { attachmentStates } from './deform.ts';
|
|
|
121
121
|
import { drawOrderAt, type CoreDrawOrderKey } from './draw_order.ts';
|
|
122
122
|
import { eventsFired, type CoreEventRow } from './events.ts';
|
|
123
123
|
import { REGION_TRIANGLES, REGION_UVS, type CoreClippedRow, type ShapeClipper } from './clipping.ts';
|
|
124
|
-
import { poseGeometry, type CoreAttachmentRow, type CoreClipRow, type ShownGeometry } from './vertices.ts';
|
|
124
|
+
import { meshArraysOf, poseGeometry, worldVertices, type CoreAttachmentRow, type CoreClipRow, type ShownGeometry } from './vertices.ts';
|
|
125
125
|
import { RUNTIME_DEG, type CoreWorld } from './world.ts';
|
|
126
|
+
import { fillingSkins } from './skins.ts';
|
|
126
127
|
import type { SliderApplication } from './constraints_slider.ts';
|
|
127
128
|
|
|
128
129
|
/** One bone, posed: the world transform and the runtime getters' four readings (the header's `bones`). */
|
|
@@ -148,8 +149,10 @@ export interface RawDrawn {
|
|
|
148
149
|
attachment: string;
|
|
149
150
|
kind: 'region' | 'mesh';
|
|
150
151
|
vertices: number[];
|
|
151
|
-
|
|
152
|
-
|
|
152
|
+
/** Frozen, and the same array on every pose of the document view (issue #1530): `REGION_UVS` for a region, `meshArraysOf` for a mesh. */
|
|
153
|
+
uvs: readonly number[];
|
|
154
|
+
/** Frozen and identity-stable, as `uvs` is. */
|
|
155
|
+
triangles: readonly number[];
|
|
153
156
|
hull: number | null;
|
|
154
157
|
sequenceIndex: number;
|
|
155
158
|
colour: [number, number, number, number];
|
|
@@ -363,11 +366,11 @@ function drawnGeometry(doc: CompiledDocument, shown: readonly ShownGeometry[], w
|
|
|
363
366
|
* maps it as before where it does, so A10 reads the same values either way.
|
|
364
367
|
* What makes the row's array safe to keep past the step — A10 collects an
|
|
365
368
|
* animation's 120 frames before it scans one — is that it is the pose's own:
|
|
366
|
-
* `poseGeometry` (`./vertices.ts`)
|
|
367
|
-
*
|
|
368
|
-
*
|
|
369
|
-
* suite's `CO45` plants a
|
|
370
|
-
* the retained frames red.
|
|
369
|
+
* `poseGeometry` (`./vertices.ts`) hands every row the corners or the skinned
|
|
370
|
+
* vertices the poser just computed, a new array per attachment per pose, or a
|
|
371
|
+
* rounded copy of it (`roundedRow`, issue #1530), held by nothing but the row.
|
|
372
|
+
* The core suite's `CO45` plants a scan that hands every pose the same array
|
|
373
|
+
* and `CO59` a poser that does, and both read the retained frames red.
|
|
371
374
|
*/
|
|
372
375
|
function scanDrawn(doc: CompiledDocument, shown: readonly ShownGeometry[], world: ReadonlyMap<string, CoreWorld>, order: readonly string[], plant: CorePlant, mode: WalkMode, scanPlant: ScanPlant = {}): ScanDrawn[] {
|
|
373
376
|
const { rows } = drawnGeometry(doc, shown, world, order, plant, mode);
|
|
@@ -385,6 +388,23 @@ function scanDrawn(doc: CompiledDocument, shown: readonly ShownGeometry[], world
|
|
|
385
388
|
return drawn;
|
|
386
389
|
}
|
|
387
390
|
|
|
391
|
+
/**
|
|
392
|
+
* A row's numbers as the pose hands them on (issue #1530): the row's own
|
|
393
|
+
* array wherever it holds no `null` — `numberOf` is then the identity on
|
|
394
|
+
* every one of them, under either mode — and mapped through `numberOf` as
|
|
395
|
+
* before where it does, so a raw walk refuses the same value by the same
|
|
396
|
+
* name and a kept one turns it into NaN. The row's array is the pose's own:
|
|
397
|
+
* `poseGeometry` (`./vertices.ts`, `roundedRow`) hands each row the array
|
|
398
|
+
* the poser just built or a fresh rounded copy of it, and `poseClipped` maps
|
|
399
|
+
* every clipped row into a new array; nothing else holds one, so a frame a
|
|
400
|
+
* consumer keeps is not written by the next pose. The core suite's
|
|
401
|
+
* retained-frame control plants a poser that keeps one buffer per slot and
|
|
402
|
+
* reads the kept frames red.
|
|
403
|
+
*/
|
|
404
|
+
function numbersOf(values: Array<number | null>, what: string, mode: WalkMode): number[] {
|
|
405
|
+
return allNumbers(values) ? values : values.map((v) => numberOf(v, what, mode));
|
|
406
|
+
}
|
|
407
|
+
|
|
388
408
|
/** Whether a row's vertices hold no `null` — what `numberOf` would map — so the row's own array can be handed on as numbers. */
|
|
389
409
|
function allNumbers(values: Array<number | null>): values is number[] {
|
|
390
410
|
for (const v of values) if (v === null) return false;
|
|
@@ -417,24 +437,23 @@ function rawDrawn(doc: CompiledDocument, shown: readonly ShownGeometry[], world:
|
|
|
417
437
|
if (row === undefined || row[0] !== s.slot) throw new CoreInputError(`slot "${s.slot}": the attachment rows are not in the shown records' order`);
|
|
418
438
|
const record = doc.skins.find((x) => x.name === s.skin)?.attachments[s.slot]?.[s.placeholder];
|
|
419
439
|
const colour = record?.color === undefined ? ([1, 1, 1, 1] as [number, number, number, number]) : readColour(record.color);
|
|
420
|
-
let uvs: number[];
|
|
421
|
-
let triangles: number[];
|
|
440
|
+
let uvs: readonly number[];
|
|
441
|
+
let triangles: readonly number[];
|
|
422
442
|
let hull: number | null = null;
|
|
423
443
|
if (g.kind === 'region') {
|
|
424
|
-
uvs =
|
|
425
|
-
triangles =
|
|
444
|
+
uvs = REGION_UVS;
|
|
445
|
+
triangles = REGION_TRIANGLES;
|
|
426
446
|
} else {
|
|
427
447
|
const source = g.kind === 'mesh' ? g : doc.skins.find((x) => x.name === g.skin)?.attachments[g.slot]?.[g.source]?.geometry;
|
|
428
448
|
if (source === undefined || source.kind !== 'mesh') throw new CoreInputError(`slot "${s.slot}": the linked mesh's source carries no mesh`);
|
|
429
|
-
uvs =
|
|
430
|
-
triangles = [...source.triangles];
|
|
449
|
+
({ uvs, triangles } = (plant.meshArrays ?? meshArraysOf)(source));
|
|
431
450
|
hull = source.hull ?? null;
|
|
432
451
|
}
|
|
433
452
|
const sequenceIndex = s.frame ?? record?.sequenceSetup ?? 0;
|
|
434
|
-
drawn.push({ slot: s.slot, attachment: row[1], kind: row[2], vertices: row[3]
|
|
453
|
+
drawn.push({ slot: s.slot, attachment: row[1], kind: row[2], vertices: numbersOf(row[3], `vertex of slot "${s.slot}"`, mode), uvs, triangles, hull, sequenceIndex, colour });
|
|
435
454
|
}
|
|
436
|
-
const clips = geometry.clips.map((c): RawClip => [c[0], c[1], c[2], c[3]
|
|
437
|
-
const clipped = geometry.drawnClipped.map((c): RawClipped => [c[0], c[1], c[2], c[3]
|
|
455
|
+
const clips = geometry.clips.map((c): RawClip => [c[0], c[1], c[2], numbersOf(c[3], `clip vertex of slot "${c[0]}"`, mode)]);
|
|
456
|
+
const clipped = geometry.drawnClipped.map((c): RawClipped => [c[0], c[1], c[2], numbersOf(c[3], `clipped vertex of slot "${c[0]}"`, mode), numbersOf(c[4], `clipped uv of slot "${c[0]}"`, mode), c[5]]);
|
|
438
457
|
return { drawn, clips, clipped };
|
|
439
458
|
}
|
|
440
459
|
|
|
@@ -447,6 +466,81 @@ export function poseRawSetup(doc: CompiledDocument, plant: CorePlant = {}): RawP
|
|
|
447
466
|
return setupPoseIn(doc, plant, RAW_MODE);
|
|
448
467
|
}
|
|
449
468
|
|
|
469
|
+
/** A table key to read, with an optional skin-view override; omitted skin keeps the supplied view. */
|
|
470
|
+
export interface SetupMeshTarget {
|
|
471
|
+
readonly slot: string;
|
|
472
|
+
readonly attachment: string;
|
|
473
|
+
/** A named skin (with default fallback), `all`, or `null` for no skin set. */
|
|
474
|
+
readonly skin?: string | null;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** A detached named mesh in setup world units, y up; hull counts vertices, not coordinates. */
|
|
478
|
+
export interface SetupMesh {
|
|
479
|
+
slot: string;
|
|
480
|
+
/** The requested attachment table key, even when the record states another display name. */
|
|
481
|
+
attachment: string;
|
|
482
|
+
/** The skin whose table supplied the requested record, including a fallback. */
|
|
483
|
+
skin: string;
|
|
484
|
+
vertices: number[];
|
|
485
|
+
triangles: number[];
|
|
486
|
+
hull: number | null;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** Model definitions by value: object key order is irrelevant, array positions and signed zero are not. */
|
|
490
|
+
function sameSetupMeshDefinition(a: unknown, b: unknown): boolean {
|
|
491
|
+
if (Object.is(a, b)) return true;
|
|
492
|
+
if (typeof a !== 'object' || a === null || typeof b !== 'object' || b === null) return false;
|
|
493
|
+
if (Array.isArray(a) !== Array.isArray(b)) return false;
|
|
494
|
+
if (Array.isArray(a) && Array.isArray(b) && a.length !== b.length) return false;
|
|
495
|
+
const left = a as Record<string, unknown>;
|
|
496
|
+
const right = b as Record<string, unknown>;
|
|
497
|
+
const keys = Object.keys(left);
|
|
498
|
+
return keys.length === Object.keys(right).length && keys.every((key) =>
|
|
499
|
+
Object.prototype.hasOwnProperty.call(right, key) && sameSetupMeshDefinition(left[key], right[key]));
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Read a named mesh without changing the setup selection or playing anything
|
|
504
|
+
* (issue #1538). Setup constraints and reset physics use the raw poser's own
|
|
505
|
+
* path. A selected mesh includes its setup-slider deform; an unselected mesh
|
|
506
|
+
* reads its stored geometry through those same matrices, with no hypothetical
|
|
507
|
+
* attachment switch or deform. Linked meshes use their source topology and
|
|
508
|
+
* bindings, and the queried slot's bone for unweighted vertices; `timelines`
|
|
509
|
+
* affects only a selected mesh's setup-slider deform, as in `poseRawSetup`.
|
|
510
|
+
*
|
|
511
|
+
* Skin resolution is the supplied view's unless explicitly overridden. Under
|
|
512
|
+
* `all`, differing definitions of the requested mesh are refused: the model
|
|
513
|
+
* does not carry the file order that would choose one. Object property order
|
|
514
|
+
* does not distinguish definitions; array positions and Object.is leaf values
|
|
515
|
+
* do. Identical definitions keep the resolver's first supplying skin. All arrays are fresh.
|
|
516
|
+
*/
|
|
517
|
+
export function readSetupMesh(doc: CompiledDocument, target: SetupMeshTarget): SetupMesh {
|
|
518
|
+
const view = target.skin === undefined ? doc : target.skin === null ? underNoSkin(doc) : underSkin(doc, target.skin);
|
|
519
|
+
const where = `setup mesh slot ${JSON.stringify(target.slot)} attachment ${JSON.stringify(target.attachment)} under skin ${JSON.stringify(view.skin)}`;
|
|
520
|
+
const slot = view.slots.find((s) => s.name === target.slot);
|
|
521
|
+
if (slot === undefined) throw new CoreInputError(`${where}: no such slot`);
|
|
522
|
+
const resolved = shownAttachment(view, slot, target.attachment);
|
|
523
|
+
if (resolved === null) throw new CoreInputError(`${where}: no such attachment`);
|
|
524
|
+
const fills = fillingSkins(view, slot.name, target.attachment);
|
|
525
|
+
const definitions = fills.map((name) => view.skins.find((k) => k.name === name)?.attachments[slot.name]?.[target.attachment]);
|
|
526
|
+
if ('conflict' in resolved || definitions.some((r) => !sameSetupMeshDefinition(r, definitions[0]))) {
|
|
527
|
+
throw new CoreInputError(`${where}: skins [${fills.join(', ')}] fill this attachment differently; choose a named skin`);
|
|
528
|
+
}
|
|
529
|
+
const geometry = resolved.record.geometry;
|
|
530
|
+
const mesh = geometry?.kind === 'linkedmesh'
|
|
531
|
+
? view.skins.find((s) => s.name === geometry.skin)?.attachments[geometry.slot]?.[geometry.source]?.geometry
|
|
532
|
+
: geometry;
|
|
533
|
+
if (mesh?.kind !== 'mesh') throw new CoreInputError(`${where}: ${resolved.record.kind} is not a mesh`);
|
|
534
|
+
const { world, shown, drawOrder } = setupPosed(view, {}, RAW_MODE);
|
|
535
|
+
const selected = shown.find((s) => s.slot === slot.name && s.placeholder === target.attachment && s.skin === resolved.skin);
|
|
536
|
+
const bone = world.get(slot.bone);
|
|
537
|
+
if (bone === undefined) throw new CoreInputError(`${where}: no setup transform for bone ${JSON.stringify(slot.bone)}`);
|
|
538
|
+
const vertices = selected === undefined
|
|
539
|
+
? numbersOf(worldVertices(mesh.vertices, bone, world).map(rawNumber), `${where} vertex`, RAW_MODE)
|
|
540
|
+
: rawDrawn(view, [selected], world, drawOrder, {}).drawn[0].vertices;
|
|
541
|
+
return { slot: slot.name, attachment: target.attachment, skin: resolved.skin, vertices: [...vertices], triangles: [...mesh.triangles], hull: mesh.hull ?? null };
|
|
542
|
+
}
|
|
543
|
+
|
|
450
544
|
/** `poseRawSetup` in `mode` — under `keep`, a value that is not finite stays in the pose (`./walk.ts`). */
|
|
451
545
|
export function setupPoseIn(doc: CompiledDocument, plant: CorePlant, mode: WalkMode): RawPose {
|
|
452
546
|
const { world, shown, slots, drawOrder } = setupPosed(doc, plant, mode);
|
package/src/core/vertices.ts
CHANGED
|
@@ -147,6 +147,41 @@ export type CoreGeometry =
|
|
|
147
147
|
| { kind: 'boundingbox'; vertices: ModelVertices }
|
|
148
148
|
| { kind: 'path'; vertices: ModelVertices; closed: boolean; constantSpeed: boolean; lengths: number[] };
|
|
149
149
|
|
|
150
|
+
/** A mesh's geometry as the document view files it — what `meshArraysOf` keys on. */
|
|
151
|
+
export type CoreMeshGeometry = Extract<CoreGeometry, { kind: 'mesh' }>;
|
|
152
|
+
|
|
153
|
+
/** A mesh's UVs and triangles as a pose hands them out: read-only, and the same arrays on every pose. */
|
|
154
|
+
export interface MeshArrays {
|
|
155
|
+
readonly uvs: readonly number[];
|
|
156
|
+
readonly triangles: readonly number[];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** What hands out a mesh's `MeshArrays`: `meshArraysOf` unless a control passes another (the core suite's shared-array control). */
|
|
160
|
+
export type MeshArraysOf = (mesh: CoreMeshGeometry) => MeshArrays;
|
|
161
|
+
|
|
162
|
+
/** Each mesh geometry's frozen arrays, keyed on the document view's own geometry object, which no reader writes. */
|
|
163
|
+
const meshArraysByGeometry = new WeakMap<CoreMeshGeometry, MeshArrays>();
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* A mesh's UVs and triangles, copied once per geometry object and frozen
|
|
167
|
+
* (issue #1530). The raw entry used to spread both into new arrays on every
|
|
168
|
+
* pose for every drawn mesh, so that a consumer writing into a pose's arrays
|
|
169
|
+
* could not reach the document's; frozen, a write throws instead, and every
|
|
170
|
+
* pose of every walk and track over the same document view hands out the
|
|
171
|
+
* same two arrays — so a consumer keyed on them by identity reuses what it
|
|
172
|
+
* built from them. The copy is what keeps the document's own arrays out of a
|
|
173
|
+
* consumer's hands: they are the reader's, and freezing them would write to
|
|
174
|
+
* a value the caller passed in.
|
|
175
|
+
*/
|
|
176
|
+
export function meshArraysOf(mesh: CoreMeshGeometry): MeshArrays {
|
|
177
|
+
let kept = meshArraysByGeometry.get(mesh);
|
|
178
|
+
if (kept === undefined) {
|
|
179
|
+
kept = Object.freeze({ uvs: Object.freeze([...mesh.uvs]), triangles: Object.freeze([...mesh.triangles]) });
|
|
180
|
+
meshArraysByGeometry.set(mesh, kept);
|
|
181
|
+
}
|
|
182
|
+
return kept;
|
|
183
|
+
}
|
|
184
|
+
|
|
150
185
|
/** One row of `setup.attachments`: `[slot, attachment, kind, vertices]`, the oracle's row. */
|
|
151
186
|
export type CoreAttachmentRow = [string, string, 'region' | 'mesh', Array<number | null>];
|
|
152
187
|
/** One row of `setup.clips`: `[slot, attachment, end, polygon]`, the oracle's row. */
|
|
@@ -220,6 +255,46 @@ export function worldVertices(vertices: ModelVertices, bone: CoreWorld, world: R
|
|
|
220
255
|
return out;
|
|
221
256
|
}
|
|
222
257
|
|
|
258
|
+
/**
|
|
259
|
+
* `worldVertices(deformedVertices(vertices, deform), bone, world, EXACT_COORDS)`
|
|
260
|
+
* in one pass (issue #1530): the same operations on the same numbers in the
|
|
261
|
+
* same order — an unweighted vertex's coordinates are the deform array's,
|
|
262
|
+
* read as they are; a weighted binding's are its float32 coordinate plus the
|
|
263
|
+
* deform's pair, its weight through `Math.fround` — without building the
|
|
264
|
+
* deformed array (and, weighted, one binding record per influence) on every
|
|
265
|
+
* pose only to map it again. A planted vertex poser still reads the deformed
|
|
266
|
+
* array (`poseGeometry`). Posed output is held equal by the core gate's
|
|
267
|
+
* sample attachments and by `tools/posed_frames.ts` across the change.
|
|
268
|
+
*/
|
|
269
|
+
export function deformedWorldVertices(vertices: ModelVertices, deform: readonly number[], bone: CoreWorld, world: ReadonlyMap<string, CoreWorld>): number[] {
|
|
270
|
+
const out: number[] = [];
|
|
271
|
+
if (!vertices.weighted) {
|
|
272
|
+
for (let i = 0; i + 1 < deform.length; i += 2) {
|
|
273
|
+
const x = deform[i];
|
|
274
|
+
const y = deform[i + 1];
|
|
275
|
+
out.push(x * bone.a + y * bone.b + bone.worldX, x * bone.c + y * bone.d + bone.worldY);
|
|
276
|
+
}
|
|
277
|
+
return out;
|
|
278
|
+
}
|
|
279
|
+
let k = 0;
|
|
280
|
+
for (const influences of vertices.bindings) {
|
|
281
|
+
let wx = 0;
|
|
282
|
+
let wy = 0;
|
|
283
|
+
for (const binding of influences) {
|
|
284
|
+
const t = world.get(binding.bone);
|
|
285
|
+
if (t === undefined) throw new Error(`a binding names bone "${binding.bone}", which has no world transform (readModel refuses it first)`);
|
|
286
|
+
const x = Math.fround(binding.x) + deform[k];
|
|
287
|
+
const y = Math.fround(binding.y) + deform[k + 1];
|
|
288
|
+
k += 2;
|
|
289
|
+
const weight = Math.fround(binding.weight);
|
|
290
|
+
wx += (x * t.a + y * t.b + t.worldX) * weight;
|
|
291
|
+
wy += (x * t.c + y * t.d + t.worldY) * weight;
|
|
292
|
+
}
|
|
293
|
+
out.push(wx, wy);
|
|
294
|
+
}
|
|
295
|
+
return out;
|
|
296
|
+
}
|
|
297
|
+
|
|
223
298
|
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
224
299
|
const finite = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v);
|
|
225
300
|
const RECT_FIELDS = ['width', 'height', 'offsetX', 'offsetY', 'originalWidth', 'originalHeight'] as const;
|
|
@@ -397,6 +472,32 @@ export interface ShownGeometry {
|
|
|
397
472
|
frame?: number;
|
|
398
473
|
}
|
|
399
474
|
|
|
475
|
+
/**
|
|
476
|
+
* A row's numbers: `values` through `round`, as `values.map(round)` writes
|
|
477
|
+
* them — and `values` itself, not a copy, where `round` leaves every one of
|
|
478
|
+
* them the same number (`Object.is`, so a `-0` the round writes as `0` is a
|
|
479
|
+
* change). Issue #1530: the raw entry's `rawNumber` is the identity on every
|
|
480
|
+
* finite double but `-0`, so on every pose that drew a finite attachment the
|
|
481
|
+
* map was a second array of the same numbers, per attachment per pose. The
|
|
482
|
+
* array handed in is the poser's own (`regionCorners` and `worldVertices`
|
|
483
|
+
* build a fresh one per call, and so does the deform path's
|
|
484
|
+
* `deformedWorldVertices`), held by nothing but this call, so the row may own it; the
|
|
485
|
+
* draw walk's step reads the same array and writes nothing into it
|
|
486
|
+
* (`DrawStep`'s arrays are readonly). A planted poser that hands out an array
|
|
487
|
+
* it keeps is the shape the core suite's retained-frame control plants.
|
|
488
|
+
*/
|
|
489
|
+
export function roundedRow(values: number[], round: (v: number) => number | null): Array<number | null> {
|
|
490
|
+
for (let i = 0; i < values.length; i++) {
|
|
491
|
+
const r = round(values[i]);
|
|
492
|
+
if (Object.is(r, values[i])) continue;
|
|
493
|
+
const out: Array<number | null> = values.slice(0, i);
|
|
494
|
+
out.push(r);
|
|
495
|
+
for (let j = i + 1; j < values.length; j++) out.push(round(values[j]));
|
|
496
|
+
return out;
|
|
497
|
+
}
|
|
498
|
+
return values;
|
|
499
|
+
}
|
|
500
|
+
|
|
400
501
|
/** Resolves a linked mesh's source to its vertex array, or names why not. */
|
|
401
502
|
export type SourceOf = (skin: string, slot: string, source: string) => ModelVertices | string;
|
|
402
503
|
|
|
@@ -409,7 +510,7 @@ export type SourceOf = (skin: string, slot: string, source: string) => ModelVert
|
|
|
409
510
|
export interface DrawWalk {
|
|
410
511
|
order: readonly string[];
|
|
411
512
|
active: ReadonlySet<string>;
|
|
412
|
-
meshOf: (skin: string, slot: string, source: string) => { uvs: number[]; triangles: number[] } | undefined;
|
|
513
|
+
meshOf: (skin: string, slot: string, source: string) => { uvs: readonly number[]; triangles: readonly number[] } | undefined;
|
|
413
514
|
clip?: TriangleClipper;
|
|
414
515
|
/** What the drawn rows are cut through (`clipThrough`), replaceable by the render suite's plants. */
|
|
415
516
|
through?: ShapeClipper;
|
|
@@ -436,38 +537,44 @@ export function poseGeometry(
|
|
|
436
537
|
const attachments: CoreAttachmentRow[] = [];
|
|
437
538
|
const clips: CoreClipRow[] = [];
|
|
438
539
|
const nulls: string[] = [];
|
|
540
|
+
// Issue #1530: the draw walk cuts drawn attachments through the clips the pose shows, and with none shown it cuts nothing — `poseClipped`
|
|
541
|
+
// over a walk with no clip step returns no row and no reason — so the walk's steps are built only where a clip is shown.
|
|
542
|
+
const walked = draw !== undefined && shown.some((s) => s.geometry.kind === 'clipping');
|
|
439
543
|
const steps = new Map<string, DrawStep>();
|
|
440
544
|
for (const s of shown) {
|
|
441
545
|
const bone = world.get(s.bone);
|
|
442
546
|
if (bone === undefined) throw new Error(`slot "${s.slot}": bone "${s.bone}" has no world transform`);
|
|
443
547
|
const g = s.geometry;
|
|
444
548
|
// A deform array replaces the stored vertices, its coordinates read as they are (`./deform.ts`).
|
|
445
|
-
|
|
549
|
+
// Unplanted, the deformed positions are read in one pass (`deformedWorldVertices`, issue #1530) rather than through a deformed copy of the array.
|
|
550
|
+
const deform = s.deform;
|
|
551
|
+
const drawn = (v: ModelVertices): number[] => (deform === undefined ? vertices(v, bone, world) : plant.vertices === undefined ? deformedWorldVertices(v, deform, bone, world) : vertices(deformedVertices(v, deform), bone, world, EXACT_COORDS));
|
|
446
552
|
if (g.kind === 'region') {
|
|
447
553
|
const rect = s.frame === undefined ? g.region.atlas : (g.region.frames?.[s.frame] ?? g.region.atlas);
|
|
448
554
|
if (rect === null) {
|
|
449
555
|
nulls.push(`slot "${s.slot}" shows region "${s.name}" (skin "${s.skin}", placeholder "${s.placeholder}")`);
|
|
450
556
|
continue;
|
|
451
557
|
}
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
558
|
+
// The record's own geometry wherever the rectangle drawn is its own (no series frame): the same fields, without a copy per pose.
|
|
559
|
+
const corners = region(rect === g.region.atlas ? (g.region as CoreRegionGeometry & { atlas: ModelAtlasRect }) : { ...g.region, atlas: rect }, bone);
|
|
560
|
+
attachments.push([s.slot, s.name, 'region', roundedRow(corners, round)]);
|
|
561
|
+
if (walked) steps.set(s.slot, { slot: s.slot, kind: 'draw', attachment: s.name, vertices: corners, triangles: REGION_TRIANGLES, uvs: REGION_UVS });
|
|
455
562
|
} else if (g.kind === 'mesh') {
|
|
456
563
|
const world = drawn(g.vertices);
|
|
457
|
-
attachments.push([s.slot, s.name, 'mesh', world
|
|
458
|
-
steps.set(s.slot, { slot: s.slot, kind: 'draw', attachment: s.name, vertices: world, triangles: g.triangles, uvs: g.uvs });
|
|
564
|
+
attachments.push([s.slot, s.name, 'mesh', roundedRow(world, round)]);
|
|
565
|
+
if (walked) steps.set(s.slot, { slot: s.slot, kind: 'draw', attachment: s.name, vertices: world, triangles: g.triangles, uvs: g.uvs });
|
|
459
566
|
} else if (g.kind === 'linkedmesh') {
|
|
460
567
|
const source = sourceOf(g.skin, g.slot, g.source);
|
|
461
568
|
if (typeof source === 'string') throw new Error(`slot "${s.slot}": ${source} (readModel refuses it first)`);
|
|
462
569
|
const world = drawn(source);
|
|
463
|
-
attachments.push([s.slot, s.name, 'mesh', world
|
|
570
|
+
attachments.push([s.slot, s.name, 'mesh', roundedRow(world, round)]);
|
|
464
571
|
// A linked mesh draws its source's triangles over its source's UVs.
|
|
465
572
|
const mesh = draw?.meshOf(g.skin, g.slot, g.source);
|
|
466
573
|
if (draw !== undefined && mesh === undefined) throw new Error(`slot "${s.slot}": the linked mesh's source "${g.source}" carries no triangles (readModel refuses it first)`);
|
|
467
|
-
if (mesh !== undefined) steps.set(s.slot, { slot: s.slot, kind: 'draw', attachment: s.name, vertices: world, triangles: mesh.triangles, uvs: mesh.uvs });
|
|
574
|
+
if (walked && mesh !== undefined) steps.set(s.slot, { slot: s.slot, kind: 'draw', attachment: s.name, vertices: world, triangles: mesh.triangles, uvs: mesh.uvs });
|
|
468
575
|
} else if (g.kind === 'clipping') {
|
|
469
576
|
const polygon = drawn(g.vertices);
|
|
470
|
-
clips.push([s.slot, s.name, g.end, polygon
|
|
577
|
+
clips.push([s.slot, s.name, g.end, roundedRow(polygon, round)]);
|
|
471
578
|
steps.set(s.slot, { slot: s.slot, kind: 'clip', attachment: s.name, active: draw?.active.has(s.bone) ?? false, end: g.end, polygon, inverse: g.inverse, convex: g.convex });
|
|
472
579
|
}
|
|
473
580
|
}
|
|
@@ -478,7 +585,12 @@ export function poseGeometry(
|
|
|
478
585
|
let clippedWhy: string | null = draw === undefined ? 'the draw order was not given' : attachmentsWhy;
|
|
479
586
|
let drawnClipped: CoreClippedRow[] | null = null;
|
|
480
587
|
let drawnClippedWhy: string | null = clippedWhy;
|
|
481
|
-
if (draw !== undefined && attachmentsWhy === null) {
|
|
588
|
+
if (draw !== undefined && attachmentsWhy === null && !walked) {
|
|
589
|
+
clipped = [];
|
|
590
|
+
clippedWhy = null;
|
|
591
|
+
drawnClipped = [];
|
|
592
|
+
drawnClippedWhy = null;
|
|
593
|
+
} else if (draw !== undefined && attachmentsWhy === null) {
|
|
482
594
|
const walk = draw.order.map((slot): DrawStep => steps.get(slot) ?? { slot, kind: 'none' });
|
|
483
595
|
const posed = poseClipped(walk, round, draw.clip, draw.through);
|
|
484
596
|
clipped = posed.rows;
|
package/src/pose.ts
CHANGED
|
@@ -431,6 +431,18 @@ export function floorClause(): string {
|
|
|
431
431
|
* already separates; the closest repeat measured was the legs at 0.603.
|
|
432
432
|
* √(0.336 × 0.603) ≈ 0.45.
|
|
433
433
|
*
|
|
434
|
+
* 📍 It is also the radius `repeatedPlaces` groups the margin into places by
|
|
435
|
+
* (issue #1526), so it was measured again over every PAIR in a margin rather
|
|
436
|
+
* than only pairs with the best. Over the 41 above-floor ambiguities of the
|
|
437
|
+
* same 420 trials, every pair closer than 0.45 span was at most 0.336 apart —
|
|
438
|
+
* one spot read at two scales, or turned, down to 0.000 for a half-turn — and
|
|
439
|
+
* every farther pair at least 0.779, save one trial: a 24 px gun whose pairs
|
|
440
|
+
* run on from 0.06 to 3.4 spans at scales up to 77% apart, which reads the
|
|
441
|
+
* window sentence at every radius from 0 to 3 because the scale bar fails it.
|
|
442
|
+
* The grid's readings came out the same at every radius in that range, so
|
|
443
|
+
* nothing there moves the gap's middle; the repeats bound it from above
|
|
444
|
+
* exactly as before.
|
|
445
|
+
*
|
|
434
446
|
* ⚠️ One grid trial above the floor takes this branch and is not a repeat: a
|
|
435
447
|
* blurred 48 px shin whose four placements all stopped on the scale FLOOR of the
|
|
436
448
|
* default window, at 0.5 against a truth at 1.0. Every placement on one wall has
|
|
@@ -443,26 +455,67 @@ export const REPEAT_DISTANCE_SPANS = 0.45;
|
|
|
443
455
|
|
|
444
456
|
/** How an ambiguity's placements stand to the best one, when they are one part in several places at one size. */
|
|
445
457
|
export interface PoseRepeat {
|
|
446
|
-
/** The largest `|
|
|
458
|
+
/** The largest `|place.scale / best.scale − 1|` over the other places, each read at its best-scoring placement. */
|
|
447
459
|
scaleRatio: number;
|
|
448
|
-
/** The nearest
|
|
460
|
+
/** The nearest other place's centre distance from the best's, in spans at the best's scale. */
|
|
449
461
|
spans: number;
|
|
462
|
+
/**
|
|
463
|
+
* Placements in the margin counted as a place already counted rather than as
|
|
464
|
+
* a place of their own — a quarter- or half-turn fit at the same centre, or a
|
|
465
|
+
* second size read at one spot (issue #1526).
|
|
466
|
+
*/
|
|
467
|
+
samePlace: number;
|
|
450
468
|
}
|
|
451
469
|
|
|
452
470
|
/**
|
|
453
|
-
* Whether every
|
|
454
|
-
*
|
|
455
|
-
*
|
|
471
|
+
* Whether every OTHER PLACE in the margin holds the part at the SAME scale as
|
|
472
|
+
* the best, against `REPEAT_SCALE_RATIO` and `REPEAT_DISTANCE_SPANS` — `null`
|
|
473
|
+
* when one does not, or there is no other place. `span` is the part's longest
|
|
456
474
|
* material side in frame pixels at the best placement's scale.
|
|
457
475
|
*
|
|
476
|
+
* 📍 The margin is grouped into places first (issue #1526): walked best-scoring
|
|
477
|
+
* first, a placement whose centre is closer than `samePlaceSpans` spans to a
|
|
478
|
+
* place already counted is counted with it, and every other one opens a place
|
|
479
|
+
* of its own, read at that placement. The bars then apply to the places other
|
|
480
|
+
* than the best's own. Without the grouping, a fit the search keeps on purpose —
|
|
481
|
+
* the quarter- and half-turn polish of its primary candidate, at the primary's
|
|
482
|
+
* own centre — sat 0 spans from the best, the nearest distance went to 0, and a
|
|
483
|
+
* part drawn twice read the window sentence: the one remedy that can take the
|
|
484
|
+
* turned duplicate away and leave the two copies unresolved.
|
|
485
|
+
*
|
|
486
|
+
* ⚠️ The grouping radius defaults to `REPEAT_DISTANCE_SPANS` and is not a
|
|
487
|
+
* second number, because it is the same question asked of the same
|
|
488
|
+
* populations — *is this placement in another place than that one?* — which the
|
|
489
|
+
* distance bar already decides between an alternate and the best. Its
|
|
490
|
+
* measurement over every PAIR in the margin, not only pairs with the best, is
|
|
491
|
+
* beside the constant.
|
|
492
|
+
*
|
|
458
493
|
* ⭐ Exported so the selftest can hand it a forged alternate and watch the
|
|
459
|
-
* reading change branch
|
|
494
|
+
* reading change branch, and plant the grouping out: at `samePlaceSpans` 0 no
|
|
495
|
+
* two centres are closer than the radius, every alternate is its own place, and
|
|
496
|
+
* the bars read the minimum over all of them — issue #1519's reading. The search
|
|
497
|
+
* calls it with exactly what it reported.
|
|
460
498
|
*/
|
|
461
|
-
export function repeatedPlaces(
|
|
499
|
+
export function repeatedPlaces(
|
|
500
|
+
best: PosePlacement,
|
|
501
|
+
alternates: readonly PosePlacement[],
|
|
502
|
+
span: number,
|
|
503
|
+
samePlaceSpans: number = REPEAT_DISTANCE_SPANS,
|
|
504
|
+
): PoseRepeat | null {
|
|
462
505
|
if (alternates.length === 0 || !(span > 0)) return null;
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
506
|
+
// Best-scoring first, so a place is read at its best placement. The margin
|
|
507
|
+
// comes sorted that way already; a forged list need not.
|
|
508
|
+
const places: PosePlacement[] = [best];
|
|
509
|
+
let samePlace = 0;
|
|
510
|
+
for (const alt of [...alternates].sort((a, b) => a.residual - b.residual)) {
|
|
511
|
+
if (places.some((p) => hypot2(alt.x - p.x, alt.y - p.y) < samePlaceSpans * span)) samePlace++;
|
|
512
|
+
else places.push(alt);
|
|
513
|
+
}
|
|
514
|
+
const others = places.slice(1);
|
|
515
|
+
if (others.length === 0) return null;
|
|
516
|
+
const scaleRatio = Math.max(...others.map((a) => Math.abs(a.scale / best.scale - 1)));
|
|
517
|
+
const spans = Math.min(...others.map((a) => hypot2(a.x - best.x, a.y - best.y) / span));
|
|
518
|
+
return scaleRatio <= REPEAT_SCALE_RATIO && spans >= REPEAT_DISTANCE_SPANS ? { scaleRatio, spans, samePlace } : null;
|
|
466
519
|
}
|
|
467
520
|
|
|
468
521
|
/**
|
|
@@ -520,10 +573,18 @@ export function legibilityReading(
|
|
|
520
573
|
repeat === null
|
|
521
574
|
? 'either the frame holds more than one place this part fits, or every candidate missed the true one — ' +
|
|
522
575
|
'a narrower --scale or --rotation window around what you know of the part tells the two apart'
|
|
523
|
-
:
|
|
576
|
+
: // 📍 The subject is the places the bars measured (issue #1526): when the
|
|
577
|
+
// grouping set placements aside, those were not measured, so the sentence
|
|
578
|
+
// speaks of every other *place*; when it set none aside, places are
|
|
579
|
+
// placements and the sentence is the one it was.
|
|
580
|
+
(repeat.samePlace === 0
|
|
581
|
+
? 'every placement in the margin'
|
|
582
|
+
: `every other place in the margin — the ${repeat.samePlace} placement(s) closer than ${REPEAT_DISTANCE_SPANS} ` +
|
|
583
|
+
'span to a better-scoring one counted as its place, another angle or size of that fit —') +
|
|
584
|
+
` is at the best's scale (within ${(repeat.scaleRatio * 100).toFixed(2)}%, ` +
|
|
524
585
|
`bar ${REPEAT_SCALE_RATIO * 100}%) and in another place (the nearest ${repeat.spans.toFixed(2)} span(s) away, ` +
|
|
525
|
-
`bar ${REPEAT_DISTANCE_SPANS}), which is
|
|
526
|
-
'drawing — drawn more than once: read the placements as an unordered set and order it across frames ' +
|
|
586
|
+
`bar ${REPEAT_DISTANCE_SPANS}), which is` +
|
|
587
|
+
' the part — or a near-copy, such as a left/right pair cut from one drawing — drawn more than once: read the placements as an unordered set and order it across frames ' +
|
|
527
588
|
'(MOTION.md §2.2 🔀); a --rotation window that excludes one place chooses the answer rather than measuring it';
|
|
528
589
|
return (
|
|
529
590
|
`${figures} — above the measured floor (AUTHORING §11.5: ${floorClause()}), so size and texture do not explain this; ` +
|