spine-rigc 2.13.1 → 2.13.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-rigc",
3
- "version": "2.13.1",
3
+ "version": "2.13.3",
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": {
package/src/core/raw.ts CHANGED
@@ -350,6 +350,20 @@ export function poseRawAnimation(doc: CompiledDocument, animation: string, steps
350
350
  return walkIn(doc, animation, steps, plant, reset, RAW_MODE);
351
351
  }
352
352
 
353
+ /**
354
+ * `poseRawAnimation`, each pose handed to `visit` as it is posed rather than
355
+ * collected (issue #1180): the same walk, the same poses in the same order,
356
+ * and the caller holds as many of them as it keeps. `src/render_core.ts`
357
+ * draws each and lets it go, so a render holds one pose and not an
358
+ * animation's series — on the production rig the core poser's render peaked
359
+ * highest on, the largest animation's series was 153 MiB of retained heap.
360
+ * A refusal is thrown from the pose it is met at, after `visit` has seen the
361
+ * poses before it.
362
+ */
363
+ export function poseRawAnimationEach(doc: CompiledDocument, animation: string, steps: readonly number[], plant: TimelinePlant, reset: RawReset, visit: (pose: RawPose, index: number) => void): void {
364
+ walkEach(doc, animation, steps, plant, reset, RAW_MODE, visit);
365
+ }
366
+
353
367
  /**
354
368
  * The animation time a looping track applies its animation at (`./walk.ts`,
355
369
  * *The time*): the track time wrapped by the duration, and 0 over a duration
@@ -366,6 +380,13 @@ export function loopedTime(trackTime: number, duration: number): number {
366
380
  * `mode` here, so the raw entry's walk is the one it was.
367
381
  */
368
382
  export function walkIn(doc: CompiledDocument, animation: string, steps: readonly number[], plant: TimelinePlant, reset: RawReset, mode: WalkMode): RawPose[] {
383
+ const poses: RawPose[] = [];
384
+ walkEach(doc, animation, steps, plant, reset, mode, (pose) => poses.push(pose));
385
+ return poses;
386
+ }
387
+
388
+ /** `walkIn`'s walk, each pose handed to `visit` in order as it is posed (`poseRawAnimationEach`). */
389
+ function walkEach(doc: CompiledDocument, animation: string, steps: readonly number[], plant: TimelinePlant, reset: RawReset, mode: WalkMode, visit: (pose: RawPose, index: number) => void): void {
369
390
  const anim = doc.animations.find((a) => a.name === animation);
370
391
  if (anim === undefined) throw new CoreInputError(`animation "${animation}" is not one of this document's [${doc.animations.map((a) => a.name).join(', ')}]`);
371
392
  const bad = steps.findIndex((s) => !Number.isFinite(s) || s < 0);
@@ -377,7 +398,7 @@ export function walkIn(doc: CompiledDocument, animation: string, steps: readonly
377
398
  const duration = anim.timelines.duration;
378
399
  const ctx = freshStepContext(plant.physicsStep);
379
400
  if (mode.loop && mode.wrapResets !== false) ctx.loop = true;
380
- const poses: RawPose[] = [];
401
+ let visited = 0;
381
402
  let trackTime = 0;
382
403
  let last = -1;
383
404
  if (reset === 'setup') {
@@ -387,7 +408,7 @@ export function walkIn(doc: CompiledDocument, animation: string, steps: readonly
387
408
  if (absent.length > 0) throw new CoreInputError(`the raw walk's reset pose leaves ${absent.map(([b, w]) => `${b} out (${w})`).join('; ')}`);
388
409
  if (setup.world === null || setup.shown === null || setup.setup.slots === null || setup.setup.drawOrder === null) throw new CoreInputError('the raw walk\'s reset pose was not posed');
389
410
  ctx.phase = 'update';
390
- poses.push({ trackTime: 0, animationTime: 0, bones: rawBones(doc, setup.world), slots: setup.setup.slots, drawOrder: setup.setup.drawOrder, ...rawDrawn(doc, setup.shown, setup.world, setup.setup.drawOrder, plant, mode), events: [], shown: drawOrderOf(setup.shown, setup.setup.drawOrder) });
411
+ visit({ trackTime: 0, animationTime: 0, bones: rawBones(doc, setup.world), slots: setup.setup.slots, drawOrder: setup.setup.drawOrder, ...rawDrawn(doc, setup.shown, setup.world, setup.setup.drawOrder, plant, mode), events: [], shown: drawOrderOf(setup.shown, setup.setup.drawOrder) }, visited++);
391
412
  }
392
413
  for (let i = reset === 'setup' ? 1 : 0; i <= steps.length; i++) {
393
414
  const dt = i === 0 ? 0 : steps[i - 1];
@@ -414,9 +435,8 @@ export function walkIn(doc: CompiledDocument, animation: string, steps: readonly
414
435
  // drawn attachments through a clip (`NO_CLIPPED_ROWS`), so its `clipped` rows are empty.
415
436
  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]]);
416
437
  last = t;
417
- poses.push({ trackTime, animationTime: t, bones: rawBones(doc, posed.world), slots: slots.rows, drawOrder, ...drawn, events, shown });
438
+ visit({ trackTime, animationTime: t, bones: rawBones(doc, posed.world), slots: slots.rows, drawOrder, ...drawn, events, shown }, visited++);
418
439
  }
419
- return poses;
420
440
  }
421
441
 
422
442
  // --- #907 the setup-pose bounding box: begin ---
package/src/render.ts CHANGED
@@ -560,7 +560,7 @@ export function candidatePosers(
560
560
  skeletonPath: string,
561
561
  atlasPath: string,
562
562
  forced: PoserName | undefined,
563
- /** What builds the core poser: `corePoser` — the suite's `RC02` passes a planted copy, and nothing else passes any. */
563
+ /** What builds the core poser: `corePoser` — the suite's `RC02`, `RC43` and `RC44` pass planted or counted copies, and nothing else passes any. */
564
564
  make: MakeCorePoser = corePoser,
565
565
  ): PoserChoice {
566
566
  return refuseUnchosen(choosePosers(skeletonPath, atlasPath, readFileSync(atlasPath, 'utf8'), forced, () => data, make).choice);
@@ -26,7 +26,9 @@
26
26
  * `min(sum, duration)`, physics reset at pose 0 and stepped by `1/fps` after
27
27
  * — the recipe `spinePoser` runs, measured bit-exact against it on every
28
28
  * frame of the nineteen tree rows by the core suite's `CR03`. The setup pose
29
- * is `poseRawSetup`.
29
+ * is `poseRawSetup`. Since issue #1180 the walk is `poseRawAnimationEach`,
30
+ * the same walk handing each pose over as it is posed (`CORE_RAW_WALK`), so
31
+ * one pose is held at a time.
30
32
  * - **Vertices, triangles, bones, slot colours, draw order** are the raw
31
33
  * pose's, unchanged: the same doubles `computeWorldVertices`,
32
34
  * `getWorldRotationX` and the slot pose hold (`CR03`).
@@ -94,7 +96,8 @@ import { atlasRegionLookup, parseAtlasText } from './atlas.ts';
94
96
  import { pagesOfAtlas, spineFileSha256, type ModelPage } from './model.ts';
95
97
  import { clipThrough, type ClipShape, type ShapeClipper } from './core/clipping.ts';
96
98
  import { activeBones, CoreInputError, readModel, sourceOfDoc, underNoSkin, underSkin, type CompiledDocument, type CoreSlotRow } from './core/index.ts';
97
- import { poseRawAnimation, poseRawSetup, type RawDrawn, type RawPose } from './core/raw.ts';
99
+ import { poseRawAnimationEach, poseRawSetup, type RawDrawn, type RawPose } from './core/raw.ts';
100
+ import type { TimelinePlant } from './core/animation.ts';
98
101
  import { CORE_DEFAULT_SKIN, lookupSkins } from './core/skins.ts';
99
102
  import { documentPageLookup, drawnRegions, meshPageUvs, readUvSequences, regionPageUvs, type DrawnRegion, type UvRegion, type UvSource } from './core/uvs.ts';
100
103
  import { regionCorners, worldVertices } from './core/vertices.ts';
@@ -273,6 +276,98 @@ interface CoreInput {
273
276
  source: UvSource;
274
277
  /** The region a name draws — the document's `pages`, or for a `/1` document the atlas's — what an original-art UV reads its trim from. */
275
278
  region: (name: string) => TextureRegion | null;
279
+ /** The page UVs and triangle lists the pieces hold (`PieceArrays`), one table for every pose of this poser. */
280
+ arrays: PieceArrays;
281
+ }
282
+
283
+ /**
284
+ * The two arrays a drawn piece holds that are not a function of the pose
285
+ * (issue #1180): its page UVs — a function of the region it samples and,
286
+ * for a mesh, the attachment's own UVs — and a mesh's triangle list.
287
+ *
288
+ * ⭐ Why a table rather than an array per piece. A render holds its frames
289
+ * before it writes one (every animation at `PROTOCOL_FPS`), and the framing
290
+ * reads every animation at `FRAMING_FPS`; a fresh copy of both arrays in every
291
+ * piece of every frame was what made the core poser's render peak at 3.3x
292
+ * spine-core's resident size on a production rig (4,015 against 1,214 MiB,
293
+ * n = 5), where spine-core's pieces hold the attachment's own `uvs` and
294
+ * `triangles` and only the world vertices are per frame. Held here, the count
295
+ * of these arrays is bounded by what the rig draws — one per region and
296
+ * attachment UVs, one per distinct triangle list of a slot's attachment — and
297
+ * not by how many frames are posed (`RC43`).
298
+ *
299
+ * The values are the same doubles either way: the UVs are computed once by
300
+ * the same call from the same inputs, and a triangle list is reused only where
301
+ * it is equal, index for index, to the one the pose carries. No reader writes
302
+ * into a piece's arrays — spine-core's pieces have always shared theirs.
303
+ */
304
+ export interface PieceArrays {
305
+ /** The page UVs `found` is drawn with: `regionPageUvs` for a region, `meshPageUvs` over the attachment's UVs for a mesh. */
306
+ uvs(found: DrawnRegion): number[];
307
+ /** A triangle list equal, index for index, to `drawn.triangles`. */
308
+ triangles(drawn: RawDrawn): number[];
309
+ }
310
+
311
+ /** `PieceArrays` kept for the poser's lifetime — what `corePoser` builds unless a plant passes another. */
312
+ export function keptPieceArrays(): PieceArrays {
313
+ // Keyed on the placement's own object (one per region name, `documentPageLookup`/`atlasRegionLookup`) and the
314
+ // document's UV array (or `null` for a region): both live as long as the poser, so a key is never a copy.
315
+ const uvs = new WeakMap<object, Map<readonly number[] | null, number[]>>();
316
+ // Keyed by slot and attachment, and each list compared index for index before it is reused: a key names where
317
+ // a list was drawn, the comparison is what makes reusing it exact.
318
+ const triangles = new Map<string, number[][]>();
319
+ return {
320
+ uvs: (found) => {
321
+ let byArt = uvs.get(found.found);
322
+ if (byArt === undefined) {
323
+ byArt = new Map();
324
+ uvs.set(found.found, byArt);
325
+ }
326
+ let kept = byArt.get(found.art);
327
+ if (kept === undefined) {
328
+ kept = found.art === null ? regionPageUvs(found.found.region, found.found.page) : meshPageUvs(found.found.region, found.found.page, found.art);
329
+ byArt.set(found.art, kept);
330
+ }
331
+ return kept;
332
+ },
333
+ triangles: (drawn) => {
334
+ const key = `${drawn.slot}\u0000${drawn.attachment}`;
335
+ let lists = triangles.get(key);
336
+ if (lists === undefined) {
337
+ lists = [];
338
+ triangles.set(key, lists);
339
+ }
340
+ const wanted = drawn.triangles;
341
+ const equal = (list: readonly number[]): boolean => {
342
+ if (list.length !== wanted.length) return false;
343
+ for (let i = 0; i < list.length; i++) if (list[i] !== wanted[i]) return false;
344
+ return true;
345
+ };
346
+ let kept = lists.find(equal);
347
+ if (kept === undefined) {
348
+ kept = [...wanted];
349
+ lists.push(kept);
350
+ }
351
+ return kept;
352
+ },
353
+ };
354
+ }
355
+
356
+ /**
357
+ * The raw walk the core poser steps an animation with (issue #1180):
358
+ * `poseRawAnimationEach`, which hands each pose to `visit` as it is posed, so
359
+ * one pose is held at a time rather than the animation's whole series. A plant
360
+ * passes another (`RC44`).
361
+ */
362
+ export type CoreRawWalk = (doc: CompiledDocument, animation: string, steps: readonly number[], plant: TimelinePlant, visit: (pose: RawPose, index: number) => void) => void;
363
+
364
+ /** The walk `corePoser` steps with unless a plant passes another: one pose posed, drawn and released before the next. */
365
+ export const CORE_RAW_WALK: CoreRawWalk = (doc, animation, steps, plant, visit) => poseRawAnimationEach(doc, animation, steps, plant, 'animation', visit);
366
+
367
+ /** What a suite passes `corePoser` in place of the poser's own parts — `PieceArrays` (`RC43`) and the raw walk (`RC44`). */
368
+ export interface CorePoserPlants {
369
+ arrays?: () => PieceArrays;
370
+ walk?: CoreRawWalk;
276
371
  }
277
372
 
278
373
  /**
@@ -419,7 +514,7 @@ function pieces(
419
514
  if (!drawn) continue;
420
515
  const found = regions.get(d.slot);
421
516
  if (found === undefined) throw new CoreInputError(`slot "${d.slot}" drew "${d.attachment}", and no atlas region was resolved for it`);
422
- const uvs = found.art === null ? regionPageUvs(found.found.region, found.found.page) : meshPageUvs(found.found.region, found.found.page, found.art);
517
+ const uvs = input.arrays.uvs(found);
423
518
  const atlasRegion = draw.texture ? input.region(found.region) : null;
424
519
  if (draw.texture && atlasRegion === null) throw new CoreInputError(`slot "${d.slot}": atlas region "${found.region}" is not in the atlas`);
425
520
  const texture = atlasRegion === null ? undefined : artUvsOf(d, atlasRegion);
@@ -427,7 +522,7 @@ function pieces(
427
522
  const world = [...d.vertices];
428
523
  const piece: Piece =
429
524
  d.kind === 'mesh'
430
- ? { kind: 'mesh', ...common, texture, world, uvs, triangles: [...d.triangles] }
525
+ ? { kind: 'mesh', ...common, texture, world, uvs, triangles: input.arrays.triangles(d) }
431
526
  : { kind: 'region', ...common, texture, world, uvs };
432
527
  const shape = cover.get(d.slot);
433
528
  out.push(shape === undefined ? piece : clippedPiece(piece, d, shape, uvs, rows.get(d.slot), through));
@@ -592,7 +687,14 @@ function placementOf(doc: CompiledDocument, atlasText: string, where: string): {
592
687
  * `CoreInputError`, naming why, where the document or the atlas cannot be read; a pose the core leaves a block of out
593
688
  * is refused the same way when it is asked for (the header).
594
689
  */
595
- export function corePoser(modelText: string, atlasText: string, where = 'skeleton.model.json', skeleton?: { path: string; bytes: Uint8Array }, through: ShapeClipper = clipThrough): Poser {
690
+ export function corePoser(
691
+ modelText: string,
692
+ atlasText: string,
693
+ where = 'skeleton.model.json',
694
+ skeleton?: { path: string; bytes: Uint8Array },
695
+ through: ShapeClipper = clipThrough,
696
+ plants: CorePoserPlants = {},
697
+ ): Poser {
596
698
  const doc = readModel(modelText, where);
597
699
  // The document poses the rig it was built with; the Spine file beside it must be that build's (issue #968).
598
700
  if (skeleton !== undefined) {
@@ -613,6 +715,8 @@ export function corePoser(modelText: string, atlasText: string, where = 'skeleto
613
715
  const sequences = readUvSequences(parsed);
614
716
  const { lookup, region } = placementOf(doc, atlasText, where);
615
717
  const views = new Map<string, CompiledDocument>();
718
+ const arrays = (plants.arrays ?? keptPieceArrays)();
719
+ const walk = plants.walk ?? CORE_RAW_WALK;
616
720
  const inputOf = (skin: string | undefined): CoreInput => {
617
721
  const key = skin === undefined ? '' : `=${skin}`;
618
722
  let view = views.get(key);
@@ -620,7 +724,7 @@ export function corePoser(modelText: string, atlasText: string, where = 'skeleto
620
724
  view = skin === undefined ? noSkinView(doc) : underSkin(doc, skin);
621
725
  views.set(key, view);
622
726
  }
623
- return { doc: view, source: { lookup, sequences }, region };
727
+ return { doc: view, source: { lookup, sequences }, region, arrays };
624
728
  };
625
729
  const roster: SubsetRoster = {
626
730
  declared: doc.slots.map((s) => s.name),
@@ -639,8 +743,20 @@ export function corePoser(modelText: string, atlasText: string, where = 'skeleto
639
743
  animation: (name, skin, fps, count, visit) => {
640
744
  const input = inputOf(skin);
641
745
  const step = 1 / fps;
642
- const poses = poseRawAnimation(input.doc, name, new Array<number>(count).fill(step), { through }, 'animation');
643
- poses.forEach((pose, i) => visit(i, corePosed(input, pose, through)));
746
+ // Each pose is drawn as it is posed and released before the next (issue #1180), so the walk holds one pose and not
747
+ // the animation's series. What is thrown is what was thrown when the series was posed first and drawn after: a
748
+ // walk that refuses a later pose still refuses the call, and a draw that throws is re-thrown once the walk has
749
+ // finished without refusing — the first such throw, by frame — and no frame after it is drawn.
750
+ const drawFailed: unknown[] = [];
751
+ walk(input.doc, name, new Array<number>(count).fill(step), { through }, (pose, i) => {
752
+ if (drawFailed.length > 0) return;
753
+ try {
754
+ visit(i, corePosed(input, pose, through));
755
+ } catch (thrown) {
756
+ drawFailed.push(thrown);
757
+ }
758
+ });
759
+ if (drawFailed.length > 0) throw drawFailed[0];
644
760
  },
645
761
  rest: (skin, shown) => {
646
762
  const input = inputOf(skin);
@@ -2148,6 +2148,37 @@ export function unframeableSentence(
2148
2148
  );
2149
2149
  }
2150
2150
 
2151
+ /** A box being widened over vertices: `minX`…`maxY`, empty at ±Infinity. */
2152
+ interface FramingBox {
2153
+ minX: number;
2154
+ minY: number;
2155
+ maxX: number;
2156
+ maxY: number;
2157
+ }
2158
+
2159
+ /** `box` widened over the `[x, y, …]` pairs of `world` — `unionBounds`'s arithmetic, in its order of reading. */
2160
+ function extendBox(box: FramingBox, world: ArrayLike<number>): void {
2161
+ for (let i = 0; i < world.length; i += 2) {
2162
+ box.minX = Math.min(box.minX, world[i]);
2163
+ box.maxX = Math.max(box.maxX, world[i]);
2164
+ box.minY = Math.min(box.minY, world[i + 1]);
2165
+ box.maxY = Math.max(box.maxY, world[i + 1]);
2166
+ }
2167
+ }
2168
+
2169
+ /**
2170
+ * How `framingViewport` is handed its frame sets: `sample` called for each of
2171
+ * `animations` (`null` the setup pose of a skeleton with none), the sets
2172
+ * yielded in that order. The framing reads each set whole before it asks for
2173
+ * the next.
2174
+ */
2175
+ export type FramingSets = (sample: (animation: string | null) => Frame[], animations: ReadonlyArray<string | null>) => Iterable<Frame[]>;
2176
+
2177
+ /** Each set sampled only when the framing asks for it, so the one before it is no longer held (issue #1180). */
2178
+ export const ONE_SET_AT_A_TIME: FramingSets = function* (sample, animations) {
2179
+ for (const animation of animations) yield sample(animation);
2180
+ };
2181
+
2151
2182
  /**
2152
2183
  * The viewport a skeleton is framed to: its union box at `FRAMING_FPS`, padded,
2153
2184
  * scaled so the long side is `maxSide` pixels.
@@ -2174,8 +2205,24 @@ export function unframeableSentence(
2174
2205
  * that pose one. Spine data as the source is its own. A bare `Poser` with no
2175
2206
  * roster cannot tell an unposed bone from a posed one, so every drawn slot
2176
2207
  * counts there; every CLI caller passes the roster, so both posers frame alike.
2177
- */
2178
- export function framingViewport(source: PoseSource, maxSide: number, opts?: PoseOptions, rosterGiven?: SkinRoster): Viewport | null {
2208
+ *
2209
+ * ⭐ One animation's frames at a time (issue #1180). The box is a minimum and
2210
+ * a maximum per axis, which no order of reading changes, so each animation's
2211
+ * frames are read into a box per slot and released before the next animation
2212
+ * is sampled; the box over the slots that pose is the union of theirs. Held
2213
+ * all at once — every animation at 60 fps — they were the largest single
2214
+ * holder of a render's heap: on the production rig whose core-poser render
2215
+ * peaked highest, 961 MiB retained at the framing's high-water under the core
2216
+ * poser and 293 MiB under spine-core's. `RC42` reads that no animation's
2217
+ * frames are read once the next one is sampled; `sets` is a plant's way in.
2218
+ */
2219
+ export function framingViewport(
2220
+ source: PoseSource,
2221
+ maxSide: number,
2222
+ opts?: PoseOptions,
2223
+ rosterGiven?: SkinRoster,
2224
+ sets: FramingSets = ONE_SET_AT_A_TIME,
2225
+ ): Viewport | null {
2179
2226
  const poser = poserOf(source);
2180
2227
  // The skin belongs here as much as in the frames: the union box is over the
2181
2228
  // attachments that POSE, and two skins fill a slot with art of different sizes
@@ -2194,10 +2241,25 @@ export function framingViewport(source: PoseSource, maxSide: number, opts?: Pose
2194
2241
  // both would be taken at `FRAMING_FPS` for every animation only to be dropped.
2195
2242
  const { slots: _drawn, hidden: _hidden, bones: _bones, geometry: _geometry, ...whole } = opts ?? {};
2196
2243
  const framed: PoseOptions = { ...whole, unclipped: true };
2197
- const sets =
2198
- poser.animations.length === 0
2199
- ? [sampleSetupPose(poser, framed)]
2200
- : poser.animations.map((a) => sampleAnimation(poser, a.name, FRAMING_FPS, framed));
2244
+ const sample = (animation: string | null): Frame[] =>
2245
+ animation === null ? sampleSetupPose(poser, framed) : sampleAnimation(poser, animation, FRAMING_FPS, framed);
2246
+ const animations: Array<string | null> = poser.animations.length === 0 ? [null] : poser.animations.map((a) => a.name);
2247
+ // Per slot, in the order a slot first drew: the box over its vertices and whether it drew one. Min and max select, so the
2248
+ // union over any grouping of the same vertices is the same four numbers — NaN and the sign of a zero included.
2249
+ const bySlot = new Map<string, { box: FramingBox; drew: boolean }>();
2250
+ for (const frames of sets(sample, animations)) {
2251
+ for (const frame of frames) {
2252
+ for (const piece of frame.pieces) {
2253
+ let slot = bySlot.get(piece.slot);
2254
+ if (slot === undefined) {
2255
+ slot = { box: { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }, drew: false };
2256
+ bySlot.set(piece.slot, slot);
2257
+ }
2258
+ if (piece.world.length > 0) slot.drew = true;
2259
+ extendBox(slot.box, piece.world);
2260
+ }
2261
+ }
2262
+ }
2201
2263
  // ⚠️ Two different reasons a box is not finite, told apart here and nowhere
2202
2264
  // else (issue #873). A skeleton that posed no vertex at all has nothing to
2203
2265
  // draw, and that is `null`. One that posed a vertex at Infinity or NaN has a
@@ -2206,20 +2268,23 @@ export function framingViewport(source: PoseSource, maxSide: number, opts?: Pose
2206
2268
  // this, the first case's `null` covered both, and a single overflowing bone
2207
2269
  // among finite ones reached neither: its box was finite on one side, and
2208
2270
  // `render` wrote a NaN-by-NaN frame set with exit 0.
2209
- const drewAny = (frameSets: ReadonlyArray<readonly Frame[]>): boolean =>
2210
- frameSets.some((frames) => frames.some((frame) => frame.pieces.some((piece) => piece.world.length > 0)));
2211
- if (!drewAny(sets)) return null;
2271
+ if (![...bySlot.values()].some((slot) => slot.drew)) return null;
2212
2272
  // ⭐ The box is over the slots that pose (issue #1000). A drawn slot on a bone
2213
2273
  // the skin leaves unposed is taken off — it draws no pixel — unless nothing
2214
- // else drew, in which case every set is kept whole so the pose reaches #997's
2274
+ // else drew, in which case every slot is kept so the pose reaches #997's
2215
2275
  // refusal below exactly as it did before this: the same inputs, the same
2216
2276
  // sentence, never an empty box framed to something.
2217
2277
  const roster = rosterBehind(source, rosterGiven);
2218
2278
  const offBox = slotsOnUnposedBones(poser.slots, framed.skin, roster);
2219
- const posedSets =
2220
- offBox.size === 0 ? sets : sets.map((frames) => frames.map((frame) => ({ ...frame, pieces: frame.pieces.filter((piece) => !offBox.has(piece.slot)) })));
2221
- const boxed = drewAny(posedSets) ? posedSets : sets;
2222
- const box = unionBounds(boxed);
2279
+ const posed = [...bySlot].filter(([name]) => !offBox.has(name));
2280
+ const posedOnly = posed.some(([, slot]) => slot.drew);
2281
+ const box: FramingBox = { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity };
2282
+ for (const [, slot] of posedOnly ? posed : [...bySlot]) {
2283
+ box.minX = Math.min(box.minX, slot.box.minX);
2284
+ box.maxX = Math.max(box.maxX, slot.box.maxX);
2285
+ box.minY = Math.min(box.minY, slot.box.minY);
2286
+ box.maxY = Math.max(box.maxY, slot.box.maxY);
2287
+ }
2223
2288
  if (![box.minX, box.minY, box.maxX, box.maxY].every(Number.isFinite)) {
2224
2289
  const found = nonFinitePoseOf(
2225
2290
  poser,
@@ -2241,8 +2306,14 @@ export function framingViewport(source: PoseSource, maxSide: number, opts?: Pose
2241
2306
  // A finite box over one point (issue #997): every drawn slot on a bone the
2242
2307
  // skin leaves unposed, or every vertex collapsed. Framed, it is a scale of
2243
2308
  // Infinity and a frame of NaN by NaN pixels, written as 0x0 with exit 0.
2244
- const unframeable = unframeableSentence(boxed, poser.slots, framed.skin, roster);
2245
- if (unframeable !== null) throw new UnframeablePoseError(unframeable);
2309
+ // Only a box with no extent can be refused, and only then are the frames
2310
+ // sampled again to be read whole — by the sentence's one derivation.
2311
+ if (box.maxX - box.minX === 0 && box.maxY - box.minY === 0) {
2312
+ const whole = animations.map(sample);
2313
+ const boxed = posedOnly ? whole.map((frames) => frames.map((frame) => ({ ...frame, pieces: frame.pieces.filter((piece) => !offBox.has(piece.slot)) }))) : whole;
2314
+ const unframeable = unframeableSentence(boxed, poser.slots, framed.skin, roster);
2315
+ if (unframeable !== null) throw new UnframeablePoseError(unframeable);
2316
+ }
2246
2317
  const pad = Math.max(box.maxX - box.minX, box.maxY - box.minY) * PAD;
2247
2318
  return viewportFor(box.minX - pad, box.minY - pad, box.maxX + pad, box.maxY + pad, maxSide);
2248
2319
  }