@rig-c/core 2.65.1 → 2.65.2
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 +22 -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/index.ts +3 -1
- package/src/core/raw.ts +35 -17
- 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
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rig-c/core",
|
|
3
|
-
"version": "2.65.
|
|
3
|
+
"version": "2.65.2",
|
|
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/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`). */
|
package/src/core/raw.ts
CHANGED
|
@@ -121,7 +121,7 @@ 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, type CoreAttachmentRow, type CoreClipRow, type ShownGeometry } from './vertices.ts';
|
|
125
125
|
import { RUNTIME_DEG, type CoreWorld } from './world.ts';
|
|
126
126
|
import type { SliderApplication } from './constraints_slider.ts';
|
|
127
127
|
|
|
@@ -148,8 +148,10 @@ export interface RawDrawn {
|
|
|
148
148
|
attachment: string;
|
|
149
149
|
kind: 'region' | 'mesh';
|
|
150
150
|
vertices: number[];
|
|
151
|
-
|
|
152
|
-
|
|
151
|
+
/** Frozen, and the same array on every pose of the document view (issue #1530): `REGION_UVS` for a region, `meshArraysOf` for a mesh. */
|
|
152
|
+
uvs: readonly number[];
|
|
153
|
+
/** Frozen and identity-stable, as `uvs` is. */
|
|
154
|
+
triangles: readonly number[];
|
|
153
155
|
hull: number | null;
|
|
154
156
|
sequenceIndex: number;
|
|
155
157
|
colour: [number, number, number, number];
|
|
@@ -363,11 +365,11 @@ function drawnGeometry(doc: CompiledDocument, shown: readonly ShownGeometry[], w
|
|
|
363
365
|
* maps it as before where it does, so A10 reads the same values either way.
|
|
364
366
|
* What makes the row's array safe to keep past the step — A10 collects an
|
|
365
367
|
* 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.
|
|
368
|
+
* `poseGeometry` (`./vertices.ts`) hands every row the corners or the skinned
|
|
369
|
+
* vertices the poser just computed, a new array per attachment per pose, or a
|
|
370
|
+
* rounded copy of it (`roundedRow`, issue #1530), held by nothing but the row.
|
|
371
|
+
* The core suite's `CO45` plants a scan that hands every pose the same array
|
|
372
|
+
* and `CO59` a poser that does, and both read the retained frames red.
|
|
371
373
|
*/
|
|
372
374
|
function scanDrawn(doc: CompiledDocument, shown: readonly ShownGeometry[], world: ReadonlyMap<string, CoreWorld>, order: readonly string[], plant: CorePlant, mode: WalkMode, scanPlant: ScanPlant = {}): ScanDrawn[] {
|
|
373
375
|
const { rows } = drawnGeometry(doc, shown, world, order, plant, mode);
|
|
@@ -385,6 +387,23 @@ function scanDrawn(doc: CompiledDocument, shown: readonly ShownGeometry[], world
|
|
|
385
387
|
return drawn;
|
|
386
388
|
}
|
|
387
389
|
|
|
390
|
+
/**
|
|
391
|
+
* A row's numbers as the pose hands them on (issue #1530): the row's own
|
|
392
|
+
* array wherever it holds no `null` — `numberOf` is then the identity on
|
|
393
|
+
* every one of them, under either mode — and mapped through `numberOf` as
|
|
394
|
+
* before where it does, so a raw walk refuses the same value by the same
|
|
395
|
+
* name and a kept one turns it into NaN. The row's array is the pose's own:
|
|
396
|
+
* `poseGeometry` (`./vertices.ts`, `roundedRow`) hands each row the array
|
|
397
|
+
* the poser just built or a fresh rounded copy of it, and `poseClipped` maps
|
|
398
|
+
* every clipped row into a new array; nothing else holds one, so a frame a
|
|
399
|
+
* consumer keeps is not written by the next pose. The core suite's
|
|
400
|
+
* retained-frame control plants a poser that keeps one buffer per slot and
|
|
401
|
+
* reads the kept frames red.
|
|
402
|
+
*/
|
|
403
|
+
function numbersOf(values: Array<number | null>, what: string, mode: WalkMode): number[] {
|
|
404
|
+
return allNumbers(values) ? values : values.map((v) => numberOf(v, what, mode));
|
|
405
|
+
}
|
|
406
|
+
|
|
388
407
|
/** 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
408
|
function allNumbers(values: Array<number | null>): values is number[] {
|
|
390
409
|
for (const v of values) if (v === null) return false;
|
|
@@ -417,24 +436,23 @@ function rawDrawn(doc: CompiledDocument, shown: readonly ShownGeometry[], world:
|
|
|
417
436
|
if (row === undefined || row[0] !== s.slot) throw new CoreInputError(`slot "${s.slot}": the attachment rows are not in the shown records' order`);
|
|
418
437
|
const record = doc.skins.find((x) => x.name === s.skin)?.attachments[s.slot]?.[s.placeholder];
|
|
419
438
|
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[];
|
|
439
|
+
let uvs: readonly number[];
|
|
440
|
+
let triangles: readonly number[];
|
|
422
441
|
let hull: number | null = null;
|
|
423
442
|
if (g.kind === 'region') {
|
|
424
|
-
uvs =
|
|
425
|
-
triangles =
|
|
443
|
+
uvs = REGION_UVS;
|
|
444
|
+
triangles = REGION_TRIANGLES;
|
|
426
445
|
} else {
|
|
427
446
|
const source = g.kind === 'mesh' ? g : doc.skins.find((x) => x.name === g.skin)?.attachments[g.slot]?.[g.source]?.geometry;
|
|
428
447
|
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];
|
|
448
|
+
({ uvs, triangles } = (plant.meshArrays ?? meshArraysOf)(source));
|
|
431
449
|
hull = source.hull ?? null;
|
|
432
450
|
}
|
|
433
451
|
const sequenceIndex = s.frame ?? record?.sequenceSetup ?? 0;
|
|
434
|
-
drawn.push({ slot: s.slot, attachment: row[1], kind: row[2], vertices: row[3]
|
|
452
|
+
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
453
|
}
|
|
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]
|
|
454
|
+
const clips = geometry.clips.map((c): RawClip => [c[0], c[1], c[2], numbersOf(c[3], `clip vertex of slot "${c[0]}"`, mode)]);
|
|
455
|
+
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
456
|
return { drawn, clips, clipped };
|
|
439
457
|
}
|
|
440
458
|
|
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; ` +
|