@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 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. |
@@ -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** placement inside
11376
- the margin is at the best's scale and in another place — the two bars the sentence
11377
- prints beside the figures it measured — the reading says the frame does hold it more than once and
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. ⚠️ Above the floor is **not** a
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.1",
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
@@ -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 `|alternate.scale / best.scale − 1|` inside the margin. */
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 alternate's centre distance from the best's, in spans at the best's scale. */
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 alternate inside the margin is a DIFFERENT place at the SAME
454
- * scale as the best, against `REPEAT_SCALE_RATIO` and `REPEAT_DISTANCE_SPANS`
455
- * — `null` when any one is not, or there is none. `span` is the part's longest
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; the search calls it with exactly what it reported.
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(best: PosePlacement, alternates: readonly PosePlacement[], span: number): PoseRepeat | null {
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
- const scaleRatio = Math.max(...alternates.map((a) => Math.abs(a.scale / best.scale - 1)));
464
- const spans = Math.min(...alternates.map((a) => hypot2(a.x - best.x, a.y - best.y) / span));
465
- return scaleRatio <= REPEAT_SCALE_RATIO && spans >= REPEAT_DISTANCE_SPANS ? { scaleRatio, spans } : 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;
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
- : `every placement in the margin is at the best's scale (within ${(repeat.scaleRatio * 100).toFixed(2)}%, ` +
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 the part — or a near-copy, such as a left/right pair cut from one ` +
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; ` +