@rig-c/core 2.65.0 → 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 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 animation is a set
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>/`. The line `render` prints for
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` | “&lt;value&gt; (t=&lt;value&gt;): raw curve holds a non-finite value &lt;value&gt;” | 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 "&lt;value&gt;": &lt;value&gt; "&lt;value&gt;" has no property "&lt;value&gt;"” | 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` | “&lt;value&gt;: no keys”<br>Also: “animation "&lt;value&gt;" deform &lt;value&gt;/&lt;value&gt;/&lt;value&gt;”<br>“animation "&lt;value&gt;" events”<br>“animation "&lt;value&gt;" drawOrder”<br>“animation "&lt;value&gt;" slot "&lt;value&gt;" &lt;value&gt;”<br>“animation "&lt;value&gt;" &lt;value&gt; constraint "&lt;value&gt;"”<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` | “&lt;value&gt;: key times must strictly increase — key &lt;value&gt; at t=&lt;value&gt; does not come after key &lt;value&gt; at t=&lt;value&gt; (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=&lt;value&gt; repeat every &lt;value&gt;s, so key &lt;value&gt; closes one cycle and key &lt;value&gt; opens the next at the same time: a phrase that repeats every &lt;value&gt;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 "&lt;value&gt;" deform &lt;value&gt;/&lt;value&gt;/&lt;value&gt;”<br>“animation "&lt;value&gt;" drawOrder”<br>“animation "&lt;value&gt;" slot "&lt;value&gt;" &lt;value&gt;”<br>“animation "&lt;value&gt;" &lt;value&gt; constraint "&lt;value&gt;"”<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` | “&lt;value&gt;: key times must strictly increase — key &lt;value&gt; at t=&lt;value&gt; does not come after key &lt;value&gt; at t=&lt;value&gt; (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=&lt;value&gt; repeat every &lt;value&gt;s, so key &lt;value&gt; closes one cycle and key &lt;value&gt; opens the next at the same time: a phrase that repeats every &lt;value&gt;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 &lt;value&gt;s apart, within two float32 steps of each other (one step at t=&lt;value&gt; is &lt;value&gt;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=&lt;value&gt;” or “key &lt;value&gt; is stored at t=&lt;value&gt;, before key &lt;value&gt; at t=&lt;value&gt;”, then “. Separate them by at least &lt;value&gt;s”<br>Also: “animation "&lt;value&gt;" deform &lt;value&gt;/&lt;value&gt;/&lt;value&gt;”<br>“animation "&lt;value&gt;" drawOrder”<br>“animation "&lt;value&gt;" slot "&lt;value&gt;" &lt;value&gt;”<br>“animation "&lt;value&gt;" &lt;value&gt; constraint "&lt;value&gt;"”<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` | “&lt;value&gt; key at t=&lt;value&gt; carries [an easing ("&lt;value&gt;") / 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 "&lt;value&gt;" &lt;value&gt; "&lt;value&gt;" &lt;value&gt;” | Remove easing/curve from the stepped mode timeline. |
7226
7232
  | <a id="CE_COMPILE_TIMELINE_MODE_UNKNOWN"></a>`CE_COMPILE_TIMELINE_MODE_UNKNOWN` | “&lt;value&gt; key at t=&lt;value&gt; names mode &lt;value&gt;; &lt;value&gt;”<br>“known: &lt;value&gt; — the runtime folds the case of the first letter and of nothing else”<br>Also: “animation "&lt;value&gt;" &lt;value&gt; "&lt;value&gt;" &lt;value&gt;”<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` | “&lt;value&gt;: this timeline takes no value; use null”<br>Also: “animation "&lt;value&gt;" &lt;value&gt; "&lt;value&gt;" &lt;value&gt;” | Use null for a timeline that takes no value. |
@@ -11365,10 +11371,27 @@ ambiguous one ends with a sentence saying which reading its figures support:
11365
11371
 
11366
11372
  > loud.png: the best placement found has residual 0.5000, above --max-residual 0.25; part is 200x150 px (span 200 frame px, opaque 1) with texture 0.2473, detail 49.455; 2 candidate(s), best 0.5000, next 0.5000, spread 0.0000 — above the measured floor (AUTHORING §11.5: detail 0.5, measured from 24 px up), so size and texture do not explain this; the cut (or the search window) is the suspect
11367
11373
 
11374
+ - **above the floor, one size in two places** — two copies of an 18x34 checker 0.6 of its span and 6° apart, the shape a left/right pair cut from one drawing reads as:
11375
+
11376
+ > part is 18x34 px (span 36.2 frame px, opaque 1) with texture 0.0221, detail 0.752; 14 candidate(s), best 0.0160, next 0.0162, spread 0.0002 — above the measured floor (AUTHORING §11.5: detail 0.5, measured from 24 px up), so size and texture do not explain this; every placement in the margin is at the best's scale (within 0.30%, bar 2%) and in another place (the nearest 0.62 span(s) away, bar 0.45), which is 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 (MOTION.md §2.2 🔀); a --rotation window that excludes one place chooses the answer rather than measuring it
11377
+
11368
11378
  Read the figures, not only the verdict. *Below* says plainness alone explains the
11369
11379
  failure: the cut may be right, and `pose` cannot tell. *Above* says it does not,
11370
11380
  so look at the cut, the `--scale`/`--rotation` window, or — for an ambiguity — at
11371
- whether the frame really holds the part twice. ⚠️ Above the floor is **not** a
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
11384
+ names MOTION.md §2.2's unordered set instead of a window: no `--scale` window
11385
+ separates two copies of one size, and a `--rotation` window that excludes one place
11386
+ *chooses* the answer — both files then place on the other copy, one of them off its
11387
+ truth and printed clean. Both bars sit at the geometric middle of the gap measured
11388
+ between such repeats and every other ambiguity on the floor's grid (the constants
11389
+ `REPEAT_SCALE_RATIO` and `REPEAT_DISTANCE_SPANS` in `src/pose.ts` carry the figures);
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
11372
11395
  promise that `pose` will place the part (see the rates below).
11373
11396
 
11374
11397
  **The texture figure.** `legibility.texture` is the mean colour step between
@@ -11513,7 +11536,12 @@ cutoff, and `PO26` holds every polish to strictly descending residuals, read thr
11513
11536
  the same trace. `PO31` places §6's post at the old placement from the page's own
11514
11537
  input bytes, and `PO32` holds it to taking the quarter-rung escape and reading the same twice.
11515
11538
  `PO33` holds the coarse row the tool reads rotations from to the field each seed was
11516
- picked from.
11539
+ picked from. `PO34` holds two copies of one part 0.6 span apart to both coming back — and is
11540
+ seen red on the quarter-span grid releases before 1.4.0 searched on, which put them on
11541
+ one placement — and `PO35` holds the two readings of an ambiguity above the floor to
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.
11517
11545
 
11518
11546
  ## 12. Reading the half of that picture `pose` refuses — `rigc chainfit`
11519
11547
 
package/docs/MOTION.md CHANGED
@@ -234,7 +234,11 @@ frames is your job.** `alternates` non-empty means the answer was not unique;
234
234
  `ambiguous` means at least one alternate is inside the margin. Two identical limbs
235
235
  look exactly like that, and so does a shape whose silhouette fits itself at more than
236
236
  one angle. The instrument has run out — it sees one frame and has no notion of which
237
- limb is which. A method that works:
237
+ limb is which. A left/right pair cut from one drawing is two files that each fit both
238
+ places, so each comes back with both, and narrowing `--scale` or `--rotation` until one
239
+ place is left picks the answer — it does not read it; the `ambiguous:` reading names
240
+ this route instead of a window when every placement in the margin is one size in
241
+ another place (AUTHORING §11.5). A method that works:
238
242
 
239
243
  1. **Enumerate the assignments, not the placements.** With k interchangeable
240
244
  placements of one part in frame A and k in frame B, there are k! ways to pair them.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rig-c/core",
3
- "version": "2.65.0",
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 animation is a set directory under it, named by the rate (see --fps): ' +
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(where: string, keys: ReadonlyArray<{ t: number }>, i: number, duplicate: boolean): never {
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
- const following = Math.min(i - 1, keys.length - 1 - i);
1179
- let repeats = duplicate && period > 0 && following >= 1;
1180
- for (let j = 1; repeats && j <= following; j++) {
1181
- if (Math.abs(keys[i + j].t - keys[j].t - period) > float32Step(keys[i + j].t)) repeats = false;
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 === out[i - 1].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 === out[i - 1].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 === out[i - 1].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 === out[i - 1].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 === out[i - 1].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 === out[i - 1].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') {
@@ -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
- /** 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). */
304
- export const REGION_UVS: readonly number[] = [0, 1, 0, 0, 1, 0, 1, 1];
305
- /** A region's two triangles over its four corners — the runtime's quad triangulation (`src/render.ts`'s `QUAD_TRIANGLES`). */
306
- export const REGION_TRIANGLES: readonly number[] = [0, 1, 2, 2, 3, 0];
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
- uvs: number[];
152
- triangles: number[];
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`) builds every row's vertices with
367
- * `.map(round)` over the corners or the skinned vertices it just computed, a
368
- * new array per attachment per pose, held by nothing but the row. The core
369
- * suite's `CO45` plants a poser that hands every pose the same array and reads
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 = [...REGION_UVS];
425
- triangles = [...REGION_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 = [...source.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].map((v) => numberOf(v, `vertex of slot "${s.slot}"`, mode)), uvs, triangles, hull, sequenceIndex, colour });
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].map((v) => numberOf(v, `clip vertex of slot "${c[0]}"`, mode))]);
437
- const clipped = geometry.drawnClipped.map((c): RawClipped => [c[0], c[1], c[2], c[3].map((v) => numberOf(v, `clipped vertex of slot "${c[0]}"`, mode)), c[4].map((v) => numberOf(v, `clipped uv of slot "${c[0]}"`, mode)), c[5]]);
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
 
@@ -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
- const drawn = (v: ModelVertices): number[] => (s.deform === undefined ? vertices(v, bone, world) : vertices(deformedVertices(v, s.deform), bone, world, EXACT_COORDS));
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
- const corners = region({ ...g.region, atlas: rect }, bone);
453
- attachments.push([s.slot, s.name, 'region', corners.map(round)]);
454
- steps.set(s.slot, { slot: s.slot, kind: 'draw', attachment: s.name, vertices: corners, triangles: [...REGION_TRIANGLES], uvs: [...REGION_UVS] });
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.map(round)]);
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.map(round)]);
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.map(round)]);
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
@@ -185,7 +185,7 @@ export const COARSE_STRIDE_FRACTION = 0.125;
185
185
  * trials found rather than 226, and 12 that placed on the old grid lost rather
186
186
  * than 9 (both at three minima per rung).
187
187
  */
188
- const COARSE_SUPPRESSION_CELLS = 4;
188
+ export const COARSE_SUPPRESSION_CELLS = 4;
189
189
 
190
190
  /** How many scale rungs one octave gets in the coarse ladder. */
191
191
  export const SCALE_STEPS_PER_OCTAVE = 3;
@@ -254,7 +254,27 @@ export const BACKGROUND_BORDER_SHARE = 0.6;
254
254
  * carries it: 227 trials found against 226 at three, none lost, and a CPU cost
255
255
  * inside the grid's run-to-run noise.
256
256
  */
257
- const MINIMA_PER_SCALE = 5;
257
+ export const MINIMA_PER_SCALE = 5;
258
+
259
+ /**
260
+ * The three numbers that decide which basins the coarse pass sends down —
261
+ * `COARSE_STRIDE_FRACTION`, `COARSE_SUPPRESSION_CELLS`, `MINIMA_PER_SCALE` — as
262
+ * one value, so a control can run the search on the grid an earlier release
263
+ * used and show what the current one buys (issue #1519). See
264
+ * `PoseOptions.coarseGrid`.
265
+ */
266
+ export interface PoseCoarseGrid {
267
+ strideFraction: number;
268
+ suppressionCells: number;
269
+ minimaPerScale: number;
270
+ }
271
+
272
+ /** The grid every caller gets — the three constants above, unchanged. */
273
+ export const COARSE_GRID: PoseCoarseGrid = {
274
+ strideFraction: COARSE_STRIDE_FRACTION,
275
+ suppressionCells: COARSE_SUPPRESSION_CELLS,
276
+ minimaPerScale: MINIMA_PER_SCALE,
277
+ };
258
278
 
259
279
  /**
260
280
  * How many candidates survive each refinement level.
@@ -387,6 +407,117 @@ export function floorClause(): string {
387
407
  return `detail ${POSE_FLOOR.detail}, measured from ${POSE_FLOOR.span} px up`;
388
408
  }
389
409
 
410
+ /**
411
+ * When an ambiguity is ONE part fitting several places at one size — the
412
+ * reading a left/right pair cut from one drawing gives, which no window
413
+ * resolves (issue #1519).
414
+ *
415
+ * Both bars are read off the measured gap between the two populations, and
416
+ * each sits at that gap's geometric middle — the same rule for both, so neither
417
+ * is a number chosen to make one case come out:
418
+ *
419
+ * - **Scale**, as `|alternate / best − 1|` and over EVERY alternate inside the
420
+ * margin. Same-size repeats measured 0.00001–0.00034 (a pair of legs and a pair
421
+ * of ears in a picture rendered from a rig), 0.0030 (the selftest's twin
422
+ * fixture, PO34) and 0.0055 (the pose fixture's mirrored arms, PO02). Every
423
+ * other ambiguous part above the floor on `tools/pose_floor.ts`'s 420 trials
424
+ * had an alternate at least 0.0719 off its best's scale — two scales at one
425
+ * spot — and so did a pair of eyes read at 1.000 and 0.922. √(0.0055 × 0.0719)
426
+ * ≈ 0.020.
427
+ * - **Distance**, from the best's centre to the nearest alternate's, over the
428
+ * part's span at the best's scale. On that grid every other alternate above
429
+ * the floor sat within 0.336 span of its best — two readings of one spot — or
430
+ * 2.26 spans or more away at a scale more than 20% off, which the scale bar
431
+ * already separates; the closest repeat measured was the legs at 0.603.
432
+ * √(0.336 × 0.603) ≈ 0.45.
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
+ *
446
+ * ⚠️ One grid trial above the floor takes this branch and is not a repeat: a
447
+ * blurred 48 px shin whose four placements all stopped on the scale FLOOR of the
448
+ * default window, at 0.5 against a truth at 1.0. Every placement on one wall has
449
+ * one scale by construction. Its console line marks the wall beside the value,
450
+ * which is the first thing to move; nothing in the alternates alone separates it
451
+ * from a repeat — a pair of ears in the same picture also sat on a scale floor.
452
+ */
453
+ export const REPEAT_SCALE_RATIO = 0.02;
454
+ export const REPEAT_DISTANCE_SPANS = 0.45;
455
+
456
+ /** How an ambiguity's placements stand to the best one, when they are one part in several places at one size. */
457
+ export interface PoseRepeat {
458
+ /** The largest `|place.scale / best.scale − 1|` over the other places, each read at its best-scoring placement. */
459
+ scaleRatio: number;
460
+ /** The nearest other place's centre distance from the best's, in spans at the best's scale. */
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;
468
+ }
469
+
470
+ /**
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
474
+ * material side in frame pixels at the best placement's scale.
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
+ *
493
+ * ⭐ Exported so the selftest can hand it a forged alternate and watch the
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.
498
+ */
499
+ export function repeatedPlaces(
500
+ best: PosePlacement,
501
+ alternates: readonly PosePlacement[],
502
+ span: number,
503
+ samePlaceSpans: number = REPEAT_DISTANCE_SPANS,
504
+ ): PoseRepeat | null {
505
+ if (alternates.length === 0 || !(span > 0)) return null;
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;
519
+ }
520
+
390
521
  /**
391
522
  * The sentence that tells "the part is wrong" from "the part is too small or too
392
523
  * plain for `pose`", with every figure it rests on (issue #857).
@@ -394,7 +525,11 @@ export function floorClause(): string {
394
525
  * ⭐ Exported for the reason `windowEdgeNote` is: the guide quotes it, and a
395
526
  * message and the document teaching it must be built from one place.
396
527
  */
397
- export function legibilityReading(l: Omit<PoseLegibility, 'reading'>, verdict: 'refused' | 'ambiguous'): string {
528
+ export function legibilityReading(
529
+ l: Omit<PoseLegibility, 'reading'>,
530
+ verdict: 'refused' | 'ambiguous',
531
+ repeat: PoseRepeat | null = null,
532
+ ): string {
398
533
  const figures =
399
534
  `part is ${l.width}x${l.height} px (span ${l.span} frame px, opaque ${l.opaqueShare}) with texture ${l.texture}, ` +
400
535
  `detail ${l.detail}; ` +
@@ -425,12 +560,35 @@ export function legibilityReading(l: Omit<PoseLegibility, 'reading'>, verdict: '
425
560
  // blurred goggles and gun from 128 px and rear shin at 48 px — five of them
426
561
  // 2.1–2.7 px off, the rear shin 19 px. So the sentence names that reading
427
562
  // too, with the one remedy that separates it: a window.
563
+ //
564
+ // 🔀 Except when the placements are one size in different places (issue
565
+ // #1519). Measured on a picture holding a left/right pair cut from one
566
+ // drawing: no --scale window separated them, narrower or wider, because they
567
+ // ARE one scale, and every --rotation window that "resolved" them did so by
568
+ // excluding one place — after which both files placed on the other, one of
569
+ // them 69 px off its truth and printed clean. There the window does not tell
570
+ // the two readings apart, it picks one, so the sentence names the route that
571
+ // does work instead.
572
+ const ambiguity =
573
+ repeat === null
574
+ ? 'either the frame holds more than one place this part fits, or every candidate missed the true one — ' +
575
+ 'a narrower --scale or --rotation window around what you know of the part tells the two apart'
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)}%, ` +
585
+ `bar ${REPEAT_SCALE_RATIO * 100}%) and in another place (the nearest ${repeat.spans.toFixed(2)} span(s) away, ` +
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 ' +
588
+ '(MOTION.md §2.2 🔀); a --rotation window that excludes one place chooses the answer rather than measuring it';
428
589
  return (
429
590
  `${figures} — above the measured floor (AUTHORING §11.5: ${floorClause()}), so size and texture do not explain this; ` +
430
- (verdict === 'refused'
431
- ? 'the cut (or the search window) is the suspect'
432
- : 'either the frame holds more than one place this part fits, or every candidate missed the true one — ' +
433
- 'a narrower --scale or --rotation window around what you know of the part tells the two apart')
591
+ (verdict === 'refused' ? 'the cut (or the search window) is the suspect' : ambiguity)
434
592
  );
435
593
  }
436
594
 
@@ -664,6 +822,19 @@ export interface PoseOptions {
664
822
  * level of every part's refinement is appended to it. See `PoseTrace`.
665
823
  */
666
824
  trace?: PoseTrace;
825
+ /**
826
+ * The coarse grid to search on — default `COARSE_GRID`, which is what the CLI
827
+ * always uses and has no flag to change.
828
+ *
829
+ * ⚠️ A plant, not a tuning knob. It exists so the selftest can run the search
830
+ * on the quarter-span grid releases before 1.4.0 used and watch a control go
831
+ * red there: two near-identical parts 0.6 of a span apart came back as ONE
832
+ * placement on that grid, printed `PLACE` with no alternate (issue #1519). A
833
+ * control nobody has seen fail holds nothing, and the only honest way to see
834
+ * this one fail is to search on the grid that failed. The report states the
835
+ * stride fraction in force in `search.coarse.strideFraction`.
836
+ */
837
+ coarseGrid?: PoseCoarseGrid;
667
838
  }
668
839
 
669
840
  /** A search candidate as the trace states it: where the part image's centre would land, frame pixels. */
@@ -1742,7 +1913,7 @@ export function estimatePose(options: PoseOptions): PoseReport {
1742
1913
  coarse: {
1743
1914
  frameLongSide: COARSE_LONG_SIDE,
1744
1915
  partSpan: COARSE_PART_SPAN,
1745
- strideFraction: COARSE_STRIDE_FRACTION,
1916
+ strideFraction: (options.coarseGrid ?? COARSE_GRID).strideFraction,
1746
1917
  framePyramid: framePyramid.length,
1747
1918
  },
1748
1919
  maxResidual,
@@ -1787,6 +1958,7 @@ export function estimatePose(options: PoseOptions): PoseReport {
1787
1958
  maxResidual,
1788
1959
  { min: scaleMin, max: scaleMax },
1789
1960
  rotationBounds,
1961
+ options.coarseGrid ?? COARSE_GRID,
1790
1962
  options.trace,
1791
1963
  ),
1792
1964
  );
@@ -1804,6 +1976,7 @@ function placePart(
1804
1976
  maxResidual: number,
1805
1977
  scaleBounds: { min: number; max: number },
1806
1978
  rotationBounds: { min: number; max: number; wraps: boolean },
1979
+ coarseGrid: PoseCoarseGrid,
1807
1980
  trace?: PoseTrace,
1808
1981
  ): PosePart {
1809
1982
  const scaleMin = scaleBounds.min;
@@ -1938,14 +2111,14 @@ function placePart(
1938
2111
  }
1939
2112
  const coarse = levels[coarseIndex];
1940
2113
  const spanAtCoarse = (span * scaleReference) / coarse.reduction;
1941
- const stride = Math.max(1, Math.round(spanAtCoarse * COARSE_STRIDE_FRACTION));
2114
+ const stride = Math.max(1, Math.round(spanAtCoarse * coarseGrid.strideFraction));
1942
2115
  const coarseSamples = samplesFor(coarse.reduction, COARSE_SAMPLES);
1943
2116
  let candidates: Candidate[] = [];
1944
2117
  let grid = { reduction: coarse.reduction, cols: 0, rows: 0, stride };
1945
2118
  for (const scale of scales) {
1946
2119
  const field = coarseScan(coarse, coarseSamples, scale, searchRotations, stride);
1947
2120
  grid = { reduction: coarse.reduction, cols: field.cols, rows: field.rows, stride };
1948
- candidates.push(...localMinima(field, COARSE_SUPPRESSION_CELLS, MINIMA_PER_SCALE));
2121
+ candidates.push(...localMinima(field, coarseGrid.suppressionCells, coarseGrid.minimaPerScale));
1949
2122
  }
1950
2123
  base.coarse = grid;
1951
2124
  candidates.sort((a, b) => a.residual - b.residual);
@@ -1976,7 +2149,7 @@ function placePart(
1976
2149
  const plate = plates[li];
1977
2150
  const smooth = li !== coarseIndex;
1978
2151
  const branch = li === branchLevel;
1979
- const keep = li === coarseIndex ? scales.length * MINIMA_PER_SCALE : REFINE_CANDIDATES;
2152
+ const keep = li === coarseIndex ? scales.length * coarseGrid.minimaPerScale : REFINE_CANDIDATES;
1980
2153
  const s = samplesFor(level.reduction, li === 0 ? POLISH_SAMPLES : REFINE_SAMPLES);
1981
2154
  // The coarse pass only sampled every `stride` pixels, so entering the
1982
2155
  // refinement the anchor can be half a stride out; the first polish gets a
@@ -2235,7 +2408,17 @@ function placePart(
2235
2408
  // move, and this sentence is about what moving it cannot change.
2236
2409
  if (base.refusal !== null || base.ambiguous) {
2237
2410
  const verdict = base.refusal !== null ? 'refused' : 'ambiguous';
2238
- const reading = legibilityReading(base.legibility, verdict);
2411
+ // The whole margin rather than the alternates kept, so a repeat is not
2412
+ // read off a list `MAX_ALTERNATES` truncated.
2413
+ const repeat =
2414
+ verdict === 'ambiguous'
2415
+ ? repeatedPlaces(
2416
+ best,
2417
+ close.map((d) => d.placement),
2418
+ span * best.scale,
2419
+ )
2420
+ : null;
2421
+ const reading = legibilityReading(base.legibility, verdict, repeat);
2239
2422
  base.legibility.reading = reading;
2240
2423
  if (base.refusal !== null) base.refusal.detail += `; ${reading}`;
2241
2424
  base.notes.push(`${name}: ${reading}.`);