spine-rigc 2.12.0 → 2.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -63,8 +63,13 @@ it cuts both ways:
63
63
  selftest call, and on a private corpus of production rigs measured before each
64
64
  release. Of the 51 assertions, 50 run on both; `A00_ROUNDTRIP_PARSE`, the official
65
65
  parser's own parse, runs only where the runtime is installed and is reported as a
66
- SKIP elsewhere. Install it beside rigc (`bun add -d @esotericsoftware/spine-core`)
67
- and the same `rigc` runs the round trip on every build. The
66
+ SKIP elsewhere. There, in the parse's place, the report also carries the model
67
+ side's own two parse rules, `A00_MODEL_READ` and `A00_MODEL_REGIONS_ON_PAGES`,
68
+ which no round-trip rule matches and the registry does not hold — so its summary
69
+ totals 53 on the core entry, and its last line counts 44 rules on the model side
70
+ ([AUTHORING §5.2](docs/AUTHORING.md) has a row for each). Install it beside rigc
71
+ (`bun add -d @esotericsoftware/spine-core`) and the same `rigc` runs the round
72
+ trip on every build. The
68
73
  [Spine Runtimes License Agreement](https://esotericsoftware.com/spine-runtimes-license)
69
74
  applies to that dependency wherever it is installed.
70
75
 
package/docs/AUTHORING.md CHANGED
@@ -155,9 +155,11 @@ That entry's `build` takes the same flags and writes the same files, byte for by
155
155
  `skeleton.json`, `skeleton.atlas`, `skeleton.model.json`, and the pages `--pack` or
156
156
  `--copy-images` put in `--out`.
157
157
  Only the gate differs, because the round trip is spine-core's: it runs the model
158
- side over the document, and the round trip's own rules restated over the text the
159
- emitter wrote (A01, A02, A05, A07, A16, A31, A35, and A18 over a second compile's
160
- three files), and it does not run `A00_ROUNDTRIP_PARSE`, which reports **SKIP**
158
+ side over the document — its own two parse rules first, `A00_MODEL_READ` and
159
+ `A00_MODEL_REGIONS_ON_PAGES`, which stand where spine-core's parse stands and are
160
+ not in the registry, then the registry's rules that read the model — and the round
161
+ trip's own rules restated over the text the emitter wrote (A01, A02, A05, A07, A16,
162
+ A31, A35, and A18 over a second compile's three files), and it does not run `A00_ROUNDTRIP_PARSE`, which reports **SKIP**
161
163
  naming spine-core. Its report ends on a line saying which rules ran there and
162
164
  which did not, and it writes only when none of them fails (§5.2).
163
165
 
@@ -6358,6 +6360,16 @@ you can reproduce by counting rows:
6358
6360
  assertions and not `FAIL` lines: one assertion that finds six wrong vertices
6359
6361
  prints six rows and is one failure here.
6360
6362
 
6363
+ 🔢 **On the entry without the runtime the report has two rows the registry does not.**
6364
+ `cli_core.ts build` (and `repack`) prints the model side's own parse rules,
6365
+ `A00_MODEL_READ` and `A00_MODEL_REGIONS_ON_PAGES` — the first two rows of the
6366
+ table below — which stand where `A00_ROUNDTRIP_PARSE`'s parse stands and have no
6367
+ round-trip counterpart, so they are not in the registry. That is the whole of the
6368
+ difference between the two totals: `<N>` is 53 on the core entry and 51 on `cli.ts build`,
6369
+ and the core report's last line states the split — 44 rules on the model side,
6370
+ which are the 42 registry assertions the model side runs and those two; the 8 restated over the emitted text;
6371
+ and `A00_ROUNDTRIP_PARSE`, which it reports as a SKIP.
6372
+
6361
6373
  🚨 **When `A00_ROUNDTRIP_PARSE` fails, read the report as a report about A00 and
6362
6374
  nothing else.** Most of the rules read the skeleton or the atlas that A00 loads,
6363
6375
  and with no parse there is nothing for them to look at — so they report `SKIP`
@@ -6368,7 +6380,9 @@ Fix A00 and run it again.
6368
6380
 
6369
6381
  | Assertion | Profile | What tripped it, and where to fix it |
6370
6382
  | --- | --- | --- |
6371
- | `A00_ROUNDTRIP_PARSE` | both | `spine-core` could not parse the skeleton or the atlas. Almost everything else in the report is downstream of this one — fix it first. When it fails, every rule that reads the loaded skeleton or the loaded atlas reports **SKIP** saying so by name, so the row count stays at the full registry and the summary's *measured* figure tells you how little was actually asked. ⚠️ **Two rules run before it and can be upstream of it**: `A31_DRAW_ORDER_OFFSETS_RESOLVE`, because a bad draw-order key makes the loader spin rather than return, and `A08_REGION_NAMES_MATCH_ATTACHMENTS`, because a `path` naming no region makes it throw. The round trip is still attempted either way; when the loader refuses a path A08 has already refused, this row **defers** to A08 by name instead of restating the miss in the parser's poorer words Under `cli_core.ts build`, which links none of spine-core, it is a **SKIP** naming spine-core — the one rule of the 50 that entry does not run. The entry that links spine-core runs it on `build` and `validate`: installed, the same `rigc` once `@esotericsoftware/spine-core` is installed beside the package; from a clone, `bun cli.ts` (`RC28`). |
6383
+ | `A00_MODEL_READ` | both | **Printed only by `cli_core.ts build` and `repack`**, where it runs before every other rule and its line leads the lines of its verdict; `cli.ts` never prints it, because there the parse is `A00_ROUNDTRIP_PARSE`'s. rigc's own reader of the model document (`readModel`) refused the `skeleton.model.json` this build compiled — the model side's parse, and like A00 the row to read first: when it fails, `A00_MODEL_REGIONS_ON_PAGES` **SKIPs** naming it and every registry rule of the model side **SKIPs** with *the model side read no document to measure*. The detail is the reader's own sentence, naming each path it refused, the value there and what it must be: `the model document: 1 problem(s): skins[0] "default".attachments["torso"]["torso"]: width is "wide", not a finite number`, `spec is "rigc-compiled/9", not "rigc-compiled/3" (…)`, `section "rig" is missing`, or `… in the rig section the model side reads its declarations from: rig.archetype is 7, not a string`. ⚠️ **No file of yours reaches it, written or edited**: the document it reads is the one the compiler just wrote in the same process, before anything is on disk, and `validate` on this entry refuses a rigc build rather than reading one (`RC36`). So a FAIL here is the compiler writing a document its own reader refuses — a rigc fault, whatever the spec says. Report it with the rig spec, the motion spec, the manifest and art if the build used them, and the command line. Not one of the registry's assertions: it is one of the two rows that make a core-entry summary total two more than `cli.ts`'s (§5.2 above) |
6384
+ | `A00_MODEL_REGIONS_ON_PAGES` | both | **Printed only by `cli_core.ts build` and `repack`**, run second, after `A00_MODEL_READ`. A record of the document that draws through an atlas region — a region, a mesh or a linked mesh, every frame of a `sequence` by its numbered name — resolves through a region no page of the document holds, or states that the build had no rectangle for it: `skin "default" slot "torso" placeholder "torso": resolves through the region "torso_typo", which no page of the document holds`, beside `…: the region record's atlas is null — the build had no rectangle for "torso_typo", and a runtime loading it finds no region`. **The rig spec reaches this one**: an attachment with no `image`, its `width` and `height` given, whose `path` names a region the build atlases no art for. `A08_REGION_NAMES_MATCH_ATTACHMENTS` fails on the same record with the attachment's own name, and the round trip refuses the same build at A08 and `A00_ROUNDTRIP_PARSE`. Change the attachment's `path` in the rig spec to a region the build has — a PNG under `images`, or a region of the `--atlas-in` pack — or give it the `image` it draws. Two more shapes it prints are documents a build of this rigc does not write, so on `build` they are a rigc fault, reported as for `A00_MODEL_READ`: `the document is rigc-compiled/1, which states no pages, …` and `the model document's sequences: … start is "x", not a whole number of at least 0`. **SKIP** when `A00_MODEL_READ` refused the document (it names that row), and when no record draws through a region — a rig with no region, mesh or linked-mesh attachment. Not one of the registry's assertions, for the reason the row above gives |
6385
+ | `A00_ROUNDTRIP_PARSE` | both | `spine-core` could not parse the skeleton or the atlas. Almost everything else in the report is downstream of this one — fix it first. When it fails, every rule that reads the loaded skeleton or the loaded atlas reports **SKIP** saying so by name, so the row count stays at the full registry and the summary's *measured* figure tells you how little was actually asked. ⚠️ **Two rules run before it and can be upstream of it**: `A31_DRAW_ORDER_OFFSETS_RESOLVE`, because a bad draw-order key makes the loader spin rather than return, and `A08_REGION_NAMES_MATCH_ATTACHMENTS`, because a `path` naming no region makes it throw. The round trip is still attempted either way; when the loader refuses a path A08 has already refused, this row **defers** to A08 by name instead of restating the miss in the parser's poorer words Under `cli_core.ts build`, which links none of spine-core, it is a **SKIP** naming spine-core — the one rule of the registry that entry does not run. The entry that links spine-core runs it on `build` and `validate`: installed, the same `rigc` once `@esotericsoftware/spine-core` is installed beside the package; from a clone, `bun cli.ts` (`RC28`). |
6372
6386
  | `A01_NO_LEGACY_TOPLEVEL_CONSTRAINT_ARRAYS` | both | a 4.1/4.2-shaped `ik`/`transform`/`path`/`physics`/`slider` array. rigc emits the 4.3 `constraints` array, so this normally means hand-edited JSON Under `cli_core.ts build` the same clause runs over the text the emitter wrote and prints this line in the same words (`RC28`). |
6373
6387
  | `A02_NO_BONE_TRANSFORM_KEY` | both | `bone "…" uses "transform", the key 4.0 and 4.1 spelled; 4.2 and 4.3 spell it "inherit"` — rename it `inherit` in the rig spec Under `cli_core.ts build` the same clause runs over the text the emitter wrote and prints this line in the same words (`RC28`). |
6374
6388
  | `A03_REGION_WIDTH_HEIGHT_FINITE` | both | a region loaded `NaN` or a non-positive size — the attachment has no `image` and no `width`/`height`. **SKIP** when the skeleton carries no region attachment |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-rigc",
3
- "version": "2.12.0",
3
+ "version": "2.13.1",
4
4
  "description": "AI-authored Spine 2D rigging and animation, verified before it is written. rigc compiles a rig spec into Spine 4.3 skeleton data, gates it with named assertions held to a spine-core round trip's verdicts, and emits nothing that fails. Output imports into the Spine editor. Ships as an agent skill.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -49,7 +49,10 @@ through `@esotericsoftware/spine-core`: install it beside the package
49
49
  (`bun add -d @esotericsoftware/spine-core`) and the same `rigc` runs them, and
50
50
  `build` runs the round trip as well. Without it they are refused by name, and
51
51
  `build` writes the same files gated without the parse — `rigc --version` names the
52
- entry that ran.
52
+ entry that ran. In the parse's place that `build` runs two rules of its own over
53
+ rigc's model document, `A00_MODEL_READ` and `A00_MODEL_REGIONS_ON_PAGES`; they are
54
+ not registry assertions, so its summary counts two more, and AUTHORING §5.2 has a
55
+ row for each.
53
56
 
54
57
  ## The loop
55
58
 
@@ -42,9 +42,11 @@ const modeOf = (bone: ModelBone): string | null => (foldInheritMode(bone.inherit
42
42
  /** A channel the walk computed; a slot row holds `null` only where a rounding wrote one, which the walk does not. */
43
43
  const channel = (v: number | null): number => (v === null ? Number.NaN : v);
44
44
 
45
- /** One walk pose as A10 reads it, each bone's mode taken from `posed` (the bones the timelines posed at the pose's time). */
46
- function frameOf(pose: WalkPose, posed: readonly ModelBone[]): SteppedFrame {
47
- const modes = new Map(posed.map((b) => [b.name, modeOf(b)]));
45
+ /** Each bone's posed mode as the facts spell it (`modeOf`), by name. */
46
+ const modesOf = (posed: readonly ModelBone[]): ReadonlyMap<string, string | null> => new Map(posed.map((b) => [b.name, modeOf(b)]));
47
+
48
+ /** One walk pose as A10 reads it, each bone's mode taken from `modes` (`modesOf` the bones the timelines posed at the pose's time). */
49
+ function frameOf(pose: WalkPose, modes: ReadonlyMap<string, string | null>): SteppedFrame {
48
50
  return {
49
51
  bones: pose.bones.map((b) => ({ name: b.name, a: b.a, b: b.b, c: b.c, d: b.d, worldX: b.worldX, worldY: b.worldY, inherit: modes.get(b.name) ?? null })),
50
52
  drawn: pose.drawn.map((d) => ({ slot: d.slot, attachment: d.attachment, vertices: d.vertices })),
@@ -80,11 +82,15 @@ export function modelSteppedPoses(read: ReadDocument): SteppedPoseFacts {
80
82
  },
81
83
  // The document's stated mode — asked only of a bone posing none, which the reader leaves this side no document to hold.
82
84
  statedInherit: (name) => `${JSON.stringify(read.doc.bones.find((b) => b.name === name)?.inheritMode)}`,
83
- setup: () => frameOf(poseWalkSetup(viewOf()), viewOf().bones),
85
+ setup: () => frameOf(poseWalkSetup(viewOf()), modesOf(viewOf().bones)),
84
86
  walk: (animation, step, frames) => {
85
87
  const anim = animationOf(animation);
86
88
  const poses = poseLoopingWalk(viewOf(), animation, new Array<number>(frames).fill(step));
87
- return poses.slice(1).map((pose) => frameOf(pose, posedBones(viewOf(), anim.timelines, pose.animationTime)));
89
+ // A bone's posed mode moves only by an `inherit` timeline: every other one `posedBones` reads leaves the setup's mode on its copy. So an animation
90
+ // keying none poses the setup's modes at every time, and they are read once rather than posed again at each of its frames (issue #1179).
91
+ const keysInherit = anim.timelines.bones.some((target) => target.timelines.some((tl) => tl.kind === 'inherit'));
92
+ const setupModes = keysInherit ? null : modesOf(viewOf().bones);
93
+ return poses.slice(1).map((pose) => frameOf(pose, setupModes ?? modesOf(posedBones(viewOf(), anim.timelines, pose.animationTime))));
88
94
  },
89
95
  };
90
96
  }
@@ -1529,9 +1529,9 @@ export function applyConstraints(bones: readonly ModelBone[], world: ReadonlyMap
1529
1529
  if (inactiveWhy(state, c) !== null) skipped.add(i);
1530
1530
  });
1531
1531
  // A path constraint's offset reads its slot bone's world as the runtime last brought it up to date (`./constraints_path.ts`, *Which slot bone*).
1532
- const plan = records.some((c) => c.kind === 'path') ? slotBonePlan(bones, active, records, skipped) : new Map<number, SlotBoneEvent | null>();
1532
+ const plan = records.some((c) => c.kind === 'path') ? keptPlan(slotBonePlans, state, records, skipped, () => slotBonePlan(bones, active, records, skipped)) : new Map<number, SlotBoneEvent | null>();
1533
1533
  // An ik over an inactive bone reads the frame above it as the pass left it (issue #979).
1534
- const updated = records.some((c) => c.kind === 'ik' && c.bones.some((b) => !active.has(b))) ? frameUpdatedBefore(bones, active, records, skipped) : new Map<number, boolean>();
1534
+ const updated = records.some((c) => c.kind === 'ik' && c.bones.some((b) => !active.has(b))) ? keptPlan(frameUpdates, state, records, skipped, () => frameUpdatedBefore(bones, active, records, skipped)) : new Map<number, boolean>();
1535
1535
  const snapshots = new Map<number, CoreWorld>();
1536
1536
  // Issue #1049: under the step a slider's physics timelines write the pass's physics records, which a later physics constraint steps with.
1537
1537
  const physicsPose = physics === undefined ? undefined : sliderPhysicsTarget(records, active, physics, rules);
@@ -1578,24 +1578,111 @@ export function applyConstraints(bones: readonly ModelBone[], world: ReadonlyMap
1578
1578
  return state.world;
1579
1579
  }
1580
1580
 
1581
- /** The last bone index each view's active set was asked for, with the names it was built over (`boneIndex`). */
1582
- const indexOfView = new WeakMap<ReadonlySet<string>, { names: string[]; index: Map<string, number> }>();
1581
+ /**
1582
+ * What `slotBonePlan` (`./constraints_path.ts`) and `frameUpdatedBefore` read
1583
+ * of a record: its kind and the names it orders — a name by value, a list by
1584
+ * identity. Every pass of a walk poses its records afresh (`posedRecords`,
1585
+ * `pathDeformed`, `stepPhysicsRecords` spread each one), and a spread carries
1586
+ * these lists over as the same arrays.
1587
+ */
1588
+ function planShape(c: CoreConstraintRecord): readonly unknown[] {
1589
+ switch (c.kind) {
1590
+ case 'slider':
1591
+ return [c.kind, c.timelines.bones];
1592
+ case 'ik':
1593
+ return [c.kind, c.target, c.bones];
1594
+ case 'physics':
1595
+ return [c.kind, c.bone];
1596
+ case 'transform':
1597
+ return [c.kind, c.source, c.bones];
1598
+ case 'path':
1599
+ return [c.kind, c.bones, c.slotDeps, c.slotBone];
1600
+ }
1601
+ }
1602
+
1603
+ /** A plan kept per solver index, with the record shapes (`planShape`) and the skipped records it was built over. */
1604
+ interface KeptPlan<T> {
1605
+ shapes: ReadonlyArray<readonly unknown[]>;
1606
+ skipped: readonly number[];
1607
+ plan: T;
1608
+ }
1609
+ const slotBonePlans = new WeakMap<ReadonlyMap<string, number>, KeptPlan<Map<number, SlotBoneEvent | null>>>();
1610
+ const frameUpdates = new WeakMap<ReadonlyMap<string, number>, KeptPlan<Map<number, boolean>>>();
1611
+
1612
+ /**
1613
+ * A pass's ordering plan — `slotBonePlan` or `frameUpdatedBefore` — reused
1614
+ * from the pass before when nothing it reads has changed (issue #1179): the
1615
+ * bones' names and parents and the active set (one solver index stands for
1616
+ * those, `boneIndex`), each record's shape, and which records are skipped.
1617
+ * Otherwise built again. Both read names and nothing else, so a reused plan is
1618
+ * the plan the pass would have built; neither is written once built.
1619
+ */
1620
+ function keptPlan<T>(kept: WeakMap<ReadonlyMap<string, number>, KeptPlan<T>>, state: SolverState, records: readonly CoreConstraintRecord[], skipped: ReadonlySet<number>, build: () => T): T {
1621
+ const shapes = records.map(planShape);
1622
+ const skips = [...skipped];
1623
+ const last = kept.get(state.index);
1624
+ if (
1625
+ last !== undefined &&
1626
+ last.shapes.length === shapes.length &&
1627
+ shapes.every((sh, i) => sh.length === last.shapes[i].length && sh.every((v, j) => v === last.shapes[i][j])) &&
1628
+ last.skipped.length === skips.length &&
1629
+ skips.every((k, i) => k === last.skipped[i])
1630
+ ) {
1631
+ return last.plan;
1632
+ }
1633
+ const plan = build();
1634
+ kept.set(state.index, { shapes, skipped: skips, plan });
1635
+ return plan;
1636
+ }
1637
+
1638
+ /** The last bone index each view's active set was asked for, with the names and parents it was built over (`boneIndex`). */
1639
+ const indexOfView = new WeakMap<ReadonlySet<string>, { names: string[]; parents: Array<string | undefined>; index: Map<string, number> }>();
1583
1640
 
1584
1641
  /**
1585
1642
  * Each bone's position in `bones`, by name — the solver's index. Every pose of
1586
1643
  * a view lists the same bones in the same order (issue #1134: a walk built it
1587
1644
  * anew on each of its passes), so the index last built under the view's
1588
1645
  * active set (`activeBones`, one object per view) is reused when the names
1589
- * agree position by position, and built again otherwise. Read, never written.
1646
+ * and the parents agree position by position, and built again otherwise —
1647
+ * the parents because `repose` keeps each bone's subtree beside the index
1648
+ * (`subtreesOf`), and a subtree is a reading of them. Read, never written.
1590
1649
  */
1591
1650
  function boneIndex(bones: readonly ModelBone[], active: ReadonlySet<string>): Map<string, number> {
1592
1651
  const kept = indexOfView.get(active);
1593
- if (kept !== undefined && kept.names.length === bones.length && bones.every((b, i) => b.name === kept.names[i])) return kept.index;
1652
+ if (kept !== undefined && kept.names.length === bones.length && bones.every((b, i) => b.name === kept.names[i] && b.parent === kept.parents[i])) return kept.index;
1594
1653
  const index = new Map(bones.map((b, i) => [b.name, i]));
1595
- indexOfView.set(active, { names: bones.map((b) => b.name), index });
1654
+ indexOfView.set(active, { names: bones.map((b) => b.name), parents: bones.map((b) => b.parent), index });
1596
1655
  return index;
1597
1656
  }
1598
1657
 
1658
+ /** Each bone's subtree as `subtreesOf` built it, kept per solver index (one object per view's bones, `boneIndex`). */
1659
+ const subtreesOfIndex = new WeakMap<ReadonlyMap<string, number>, ReadonlyArray<readonly number[]>>();
1660
+
1661
+ /**
1662
+ * For the bone at each position of `state.bones`, the positions of the bone
1663
+ * and of every bone below it, ascending (issue #1179). Bones are parents
1664
+ * first, so each position is appended to its own list and its ancestors' in
1665
+ * ascending order. Built from the bones' parents once per solver index —
1666
+ * `boneIndex` hands back one index only while the names and the parents are
1667
+ * the ones it was built over, and the other two solver states (`previousPassSlotBones`,
1668
+ * `historyTaint`) build an index of their own.
1669
+ */
1670
+ function subtreesOf(state: SolverState): ReadonlyArray<readonly number[]> {
1671
+ const kept = subtreesOfIndex.get(state.index);
1672
+ if (kept !== undefined) return kept;
1673
+ const lists: number[][] = state.bones.map(() => []);
1674
+ for (let k = 0; k < state.bones.length; k++) {
1675
+ let at: number | undefined = k;
1676
+ while (at !== undefined) {
1677
+ lists[at].push(k);
1678
+ const parent: string | undefined = state.bones[at].parent;
1679
+ at = parent === undefined ? undefined : state.index.get(parent);
1680
+ }
1681
+ }
1682
+ subtreesOfIndex.set(state.index, lists);
1683
+ return lists;
1684
+ }
1685
+
1599
1686
  /** After a constraint: the bones it moved in world space read back into local values, and every bone below one it moved posed again (the header's update order). */
1600
1687
  function repose(state: SolverState, changed: readonly string[], inWorld: readonly string[]): void {
1601
1688
  for (const name of inWorld) {
@@ -1606,13 +1693,17 @@ function repose(state: SolverState, changed: readonly string[], inWorld: readonl
1606
1693
  const moved = changed;
1607
1694
  const below = new Set(changed);
1608
1695
  const keep = inWorld;
1609
- // Bones are parents first (`readModel` refuses any other order) and a bone joins `below` only through its parent, so none before the first bone moved can: the walk starts there (issue #1134).
1610
- let from = state.bones.length;
1696
+ // A bone joins `below` only through its parent, so the bones that can are the moved bones' subtrees: the walk visits those, in bone order (issue
1697
+ // #1179), where it visited every bone from the first one moved (issue #1134) — the bones it no longer visits are the ones whose parent `below`
1698
+ // could never hold, so each bone it does visit reads the same `below`, and is posed again in the same order.
1699
+ const subtrees = subtreesOf(state);
1700
+ const lists: Array<readonly number[]> = [];
1611
1701
  for (const name of changed) {
1612
1702
  const at = state.index.get(name);
1613
- if (at !== undefined && at < from) from = at;
1703
+ if (at !== undefined) lists.push(subtrees[at]);
1614
1704
  }
1615
- for (let k = from; k < state.bones.length; k++) {
1705
+ const visit = lists.length === 1 ? lists[0] : [...new Set(lists.flat())].sort((x, y) => x - y);
1706
+ for (const k of visit) {
1616
1707
  const b = state.bones[k];
1617
1708
  // Not through an inactive bone the constraint did not move itself (issue #979): the bones below it keep what the constraints wrote into them.
1618
1709
  if (b.parent !== undefined && below.has(b.parent) && (!state.rules.inactiveHoldsItsWorld || state.active.has(b.parent) || moved.includes(b.parent))) below.add(b.name);
package/src/core/raw.ts CHANGED
@@ -120,7 +120,7 @@ import { freshStepContext, steppedPreviousPassWhy } from './constraints_physics.
120
120
  import { attachmentStates } from './deform.ts';
121
121
  import { drawOrderAt } from './draw_order.ts';
122
122
  import { eventsFired } from './events.ts';
123
- import { REGION_TRIANGLES, REGION_UVS } from './clipping.ts';
123
+ import { REGION_TRIANGLES, REGION_UVS, type ShapeClipper } from './clipping.ts';
124
124
  import { poseGeometry, type ShownGeometry } from './vertices.ts';
125
125
  import { RUNTIME_DEG, type CoreWorld } from './world.ts';
126
126
  import type { SliderApplication } from './constraints_slider.ts';
@@ -240,6 +240,16 @@ function numberOf(v: number | null, what: string, mode: WalkMode): number {
240
240
  return mode.keep ? (v === null ? Number.NaN : v) : finite(v, what);
241
241
  }
242
242
 
243
+ /**
244
+ * What the looping walk cuts a drawn attachment through under a clip: nothing
245
+ * (issue #1179). Its one reader, A10, reads no clipped row — `./walk.ts`
246
+ * narrows them away with the events — so the walk does not cut them; what it
247
+ * still does is plan every clip it starts (`poseClipped`'s `clipShapeOf`), so
248
+ * a clip the core does not draw refuses the walk by name as before. Every
249
+ * number a looping pose carries is computed as it was.
250
+ */
251
+ const NO_CLIPPED_ROWS: ShapeClipper = () => null;
252
+
243
253
  /** The bones of a pose from the world transforms (the header's `bones`). */
244
254
  function rawBones(doc: CompiledDocument, world: ReadonlyMap<string, CoreWorld>): RawBone[] {
245
255
  const active = activeBones(doc);
@@ -266,7 +276,8 @@ function rawBones(doc: CompiledDocument, world: ReadonlyMap<string, CoreWorld>):
266
276
 
267
277
  /** Every region and mesh the shown records draw, with what the renderer reads past the vertices (the header's `drawn`). */
268
278
  function rawDrawn(doc: CompiledDocument, shown: readonly ShownGeometry[], world: ReadonlyMap<string, CoreWorld>, order: readonly string[], plant: CorePlant, mode: WalkMode = RAW_MODE): { drawn: RawDrawn[]; clips: RawClip[]; clipped: RawClipped[] } {
269
- const geometry = poseGeometry(shown, world, sourceOfDoc(doc), mode.keep ? keptNumber : rawNumber, { region: plant.region, vertices: plant.vertices }, drawWalkOf(doc, order, plant));
279
+ const draw = drawWalkOf(doc, order, plant);
280
+ const geometry = poseGeometry(shown, world, sourceOfDoc(doc), mode.keep ? keptNumber : rawNumber, { region: plant.region, vertices: plant.vertices }, mode.loop && plant.through === undefined ? { ...draw, through: NO_CLIPPED_ROWS } : draw);
270
281
  if (geometry.attachments === null) throw new CoreInputError(`the raw pose leaves the attachments out: ${geometry.attachmentsWhy}`);
271
282
  // The drawn rows (issue #964): the oracle's `clipped` rows where it is posed, and a concave or inverse clip cut through the core's own decomposition.
272
283
  if (geometry.drawnClipped === null) throw new CoreInputError(`the raw pose leaves the clipped triangles out: ${geometry.drawnClippedWhy}`);
@@ -399,7 +410,8 @@ export function walkIn(doc: CompiledDocument, animation: string, steps: readonly
399
410
  const rank = new Map(drawOrder.map((n, r) => [n, r]));
400
411
  const shown = [...states.shown].sort((a, b) => (rank.get(a.slot) ?? 0) - (rank.get(b.slot) ?? 0));
401
412
  const drawn = rawDrawn(doc, shown, posed.world, drawOrder, plant, mode);
402
- // A looping walk fires no event here: what fires across the wrap was not measured, and the walk's one reader (A10) reads none.
413
+ // A looping walk fires no event here: what fires across the wrap was not measured, and the walk's one reader (A10) reads none. Nor does it cut its
414
+ // drawn attachments through a clip (`NO_CLIPPED_ROWS`), so its `clipped` rows are empty.
403
415
  const events = mode.loop ? [] : (plant.events ?? eventsFired)(anim.timelines.events, last, t, rawNumber).map((e): RawEvent => [e[0], finite(e[1], 'event time'), e[2], finite(e[3], 'event float'), e[4]]);
404
416
  last = t;
405
417
  poses.push({ trackTime, animationTime: t, bones: rawBones(doc, posed.world), slots: slots.rows, drawOrder, ...drawn, events, shown });
package/src/core/walk.ts CHANGED
@@ -58,7 +58,9 @@
58
58
  * NaN and Infinity stood where spine-core's did.
59
59
  *
60
60
  * Events are not carried: A10 reads none, and what a looping track fires
61
- * across the wrap was not measured.
61
+ * across the wrap was not measured. Nor are the clipped rows, and the walk
62
+ * does not cut them (issue #1179): every clip it starts is still planned, so
63
+ * a clip the core does not draw refuses the walk by name, as below.
62
64
  *
63
65
  * ⛔ **What is still refused** is the raw entry's other refusal: a construct
64
66
  * the core would leave out (a block the oracle's core dump names absent) is a