spine-rigc 2.13.0 → 2.13.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/package.json +1 -1
- package/src/assertions/model/stepped_poses.ts +11 -5
- package/src/core/constraints.ts +102 -11
- package/src/core/raw.ts +39 -7
- package/src/core/walk.ts +3 -1
- package/src/render.ts +1 -1
- package/src/render_core.ts +124 -8
- package/src/render_shared.ts +87 -16
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spine-rigc",
|
|
3
|
-
"version": "2.13.
|
|
3
|
+
"version": "2.13.2",
|
|
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": {
|
|
@@ -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
|
-
/**
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/core/constraints.ts
CHANGED
|
@@ -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
|
-
/**
|
|
1582
|
-
|
|
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
|
|
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
|
-
//
|
|
1610
|
-
|
|
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
|
|
1703
|
+
if (at !== undefined) lists.push(subtrees[at]);
|
|
1614
1704
|
}
|
|
1615
|
-
|
|
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
|
|
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}`);
|
|
@@ -339,6 +350,20 @@ export function poseRawAnimation(doc: CompiledDocument, animation: string, steps
|
|
|
339
350
|
return walkIn(doc, animation, steps, plant, reset, RAW_MODE);
|
|
340
351
|
}
|
|
341
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
|
+
|
|
342
367
|
/**
|
|
343
368
|
* The animation time a looping track applies its animation at (`./walk.ts`,
|
|
344
369
|
* *The time*): the track time wrapped by the duration, and 0 over a duration
|
|
@@ -355,6 +380,13 @@ export function loopedTime(trackTime: number, duration: number): number {
|
|
|
355
380
|
* `mode` here, so the raw entry's walk is the one it was.
|
|
356
381
|
*/
|
|
357
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 {
|
|
358
390
|
const anim = doc.animations.find((a) => a.name === animation);
|
|
359
391
|
if (anim === undefined) throw new CoreInputError(`animation "${animation}" is not one of this document's [${doc.animations.map((a) => a.name).join(', ')}]`);
|
|
360
392
|
const bad = steps.findIndex((s) => !Number.isFinite(s) || s < 0);
|
|
@@ -366,7 +398,7 @@ export function walkIn(doc: CompiledDocument, animation: string, steps: readonly
|
|
|
366
398
|
const duration = anim.timelines.duration;
|
|
367
399
|
const ctx = freshStepContext(plant.physicsStep);
|
|
368
400
|
if (mode.loop && mode.wrapResets !== false) ctx.loop = true;
|
|
369
|
-
|
|
401
|
+
let visited = 0;
|
|
370
402
|
let trackTime = 0;
|
|
371
403
|
let last = -1;
|
|
372
404
|
if (reset === 'setup') {
|
|
@@ -376,7 +408,7 @@ export function walkIn(doc: CompiledDocument, animation: string, steps: readonly
|
|
|
376
408
|
if (absent.length > 0) throw new CoreInputError(`the raw walk's reset pose leaves ${absent.map(([b, w]) => `${b} out (${w})`).join('; ')}`);
|
|
377
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');
|
|
378
410
|
ctx.phase = 'update';
|
|
379
|
-
|
|
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++);
|
|
380
412
|
}
|
|
381
413
|
for (let i = reset === 'setup' ? 1 : 0; i <= steps.length; i++) {
|
|
382
414
|
const dt = i === 0 ? 0 : steps[i - 1];
|
|
@@ -399,12 +431,12 @@ export function walkIn(doc: CompiledDocument, animation: string, steps: readonly
|
|
|
399
431
|
const rank = new Map(drawOrder.map((n, r) => [n, r]));
|
|
400
432
|
const shown = [...states.shown].sort((a, b) => (rank.get(a.slot) ?? 0) - (rank.get(b.slot) ?? 0));
|
|
401
433
|
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.
|
|
434
|
+
// 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
|
|
435
|
+
// drawn attachments through a clip (`NO_CLIPPED_ROWS`), so its `clipped` rows are empty.
|
|
403
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]]);
|
|
404
437
|
last = t;
|
|
405
|
-
|
|
438
|
+
visit({ trackTime, animationTime: t, bones: rawBones(doc, posed.world), slots: slots.rows, drawOrder, ...drawn, events, shown }, visited++);
|
|
406
439
|
}
|
|
407
|
-
return poses;
|
|
408
440
|
}
|
|
409
441
|
|
|
410
442
|
// --- #907 the setup-pose bounding box: begin ---
|
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
|
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`
|
|
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);
|
package/src/render_core.ts
CHANGED
|
@@ -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 {
|
|
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 =
|
|
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:
|
|
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(
|
|
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
|
-
|
|
643
|
-
|
|
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);
|
package/src/render_shared.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
2220
|
-
|
|
2221
|
-
const
|
|
2222
|
-
const
|
|
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
|
-
|
|
2245
|
-
|
|
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
|
}
|