spine-rigc 1.5.3 → 1.6.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/src/render.ts CHANGED
@@ -78,7 +78,10 @@
78
78
  *
79
79
  * ⚠️ Two notes on where this sits. It imports `spine-core`, which `src/` is
80
80
  * otherwise careful about: posing a skeleton *is* running the runtime, and there
81
- * is no honest way to render one without it. The rule that matters is unchanged
81
+ * is no honest way to render one without it. (Since issue #968 that holds for a
82
+ * Spine export: a rigc build is posed by rigc's own core through
83
+ * `./render_core.ts`, and this file keeps the runtime for the export's poser,
84
+ * the atlas pages and texture substitution — see *which poser* below.) The rule that matters is unchanged
82
85
  * — `src/compile.ts` must stay independent of the runtime so the compiler and
83
86
  * the gate are not checking each other's assumptions — and this file is neither.
84
87
  * It also imports `tools/plate.ts` for the PNG codec, which is dependency-free.
@@ -96,13 +99,16 @@ import {
96
99
  SkeletonJson,
97
100
  TextureAtlas,
98
101
  TextureAtlasRegion,
99
- type SkeletonData,
102
+ SkeletonData,
100
103
  type Slot,
101
104
  } from '@esotericsoftware/spine-core';
102
- import { readFileSync } from 'node:fs';
103
- import { join } from 'node:path';
105
+ import { existsSync, readFileSync } from 'node:fs';
106
+ import { dirname, join, resolve } from 'node:path';
104
107
  import { Plate, readPlate, type RGBA } from '../tools/plate.ts';
105
108
  import { pageFootprint } from './atlas.ts';
109
+ import { CoreInputError } from './core/index.ts';
110
+ import { MODEL_DOCUMENT_FILE } from './model.ts';
111
+ import { clipSourceOf, corePoser, inactiveBoneSnapshot, SlotSubsetError, subsetOver, unposedBones } from './render_core.ts';
106
112
 
107
113
  /** Opaque, and light: both of rung 3's parts are dark slate, so is every ground. */
108
114
  export const BACKGROUND: RGBA = [232, 232, 232, 255];
@@ -316,6 +322,12 @@ export interface PieceTexture {
316
322
  region: string;
317
323
  /** Original-art coordinates, `u, v` per vertex, parallel to `uvs`. */
318
324
  artUvs: number[];
325
+ /**
326
+ * A clipped piece only (`Mesh.source`): the original-art UVs of each drawn
327
+ * triangle's SOURCE triangle, six numbers per drawn triangle, parallel to
328
+ * `Mesh.source.uvs` — what `substituteTexture` re-seats the source map with.
329
+ */
330
+ sourceArtUvs?: number[];
319
331
  }
320
332
 
321
333
  /** A page-UV rectangle outside which a piece samples nothing. */
@@ -398,16 +410,10 @@ export interface PoseOptions {
398
410
  }
399
411
 
400
412
  /**
401
- * Why a slot subset cannot be drawn — a `--slot`/`--hide` naming no slot, a slot
402
- * whose art only another skin carries, or both flags at once (issue #835).
403
- *
404
- * A class of its own so `cli.ts` can turn it into a usage refusal (exit 2,
405
- * nothing written) without reading a message to decide what kind it is.
413
+ * Why a slot subset cannot be drawn — `./render_core.ts` declares it, so the
414
+ * spine-core poser and the core poser throw one class (issue #968).
406
415
  */
407
- export class SlotSubsetError extends Error {}
408
-
409
- /** The flag spelling each half of a subset is refused under — the UI's, since that is who reads it. */
410
- const SUBSET_FLAG = { slots: '--slot', hidden: '--hide' } as const;
416
+ export { SlotSubsetError };
411
417
 
412
418
  /**
413
419
  * A slot subset resolved against a skeleton: which half was asked for, and the
@@ -449,45 +455,20 @@ export function slotSubsetOf(
449
455
  opts: Pick<PoseOptions, 'slots' | 'hidden'> | undefined,
450
456
  skin: string | undefined,
451
457
  ): SlotSubset | undefined {
452
- if (opts?.slots !== undefined && opts.hidden !== undefined) {
453
- throw new SlotSubsetError(
454
- '--slot and --hide are one statement two ways; name the slots to draw or the slots to hide, not both',
455
- );
456
- }
457
- const mode = opts?.slots !== undefined ? 'slots' : opts?.hidden !== undefined ? 'hidden' : undefined;
458
- if (mode === undefined) return undefined;
459
- const asked = (mode === 'slots' ? opts?.slots : opts?.hidden) ?? [];
460
- const flag = SUBSET_FLAG[mode];
461
- const declared = data.slots.map((slot) => slot.name);
462
-
463
- const unknown = asked.filter((name) => data.findSlot(name) === null);
464
- if (unknown.length > 0 || asked.length === 0) {
465
- const named =
466
- unknown.length === 0
467
- ? 'was given no slot name'
468
- : `${unknown.map((name) => JSON.stringify(name)).join(', ')} ${unknown.length === 1 ? 'names' : 'name'} no slot`;
469
- throw new SlotSubsetError(
470
- `${flag} ${named}; this skeleton declares, in draw order: ${declared.join(', ') || 'none'} (${declared.length})`,
471
- );
472
- }
473
-
474
- const resolving = new Set([skin ?? null, data.defaultSkin?.name ?? null]);
475
- const underThisPose =
476
- skin === undefined ? 'under no skin (the default skin alone)' : `under skin ${JSON.stringify(skin)}`;
477
- for (const name of asked) {
478
- const index = data.findSlot(name)?.index ?? -1;
479
- const carriers = data.skins.filter((s) => s.getAttachments().some((entry) => entry.slotIndex === index));
480
- if (carriers.length === 0 || carriers.some((s) => resolving.has(s.name))) continue;
481
- const skins = carriers.map((s) => JSON.stringify(s.name));
482
- throw new SlotSubsetError(
483
- `${flag} ${JSON.stringify(name)} draws nothing ${underThisPose}: its attachments are declared only under ` +
484
- `${skins.length === 1 ? 'skin' : 'skins'} ${skins.join(', ')} — pass --skin ${
485
- skins.length === 1 ? skins[0] : 'with one of them'
486
- }`,
487
- );
488
- }
489
- const chosen = new Set(asked);
490
- return { mode, names: declared.filter((name) => chosen.has(name)) };
458
+ // The rule is `subsetOver`'s (`./render_core.ts`), shared with the core
459
+ // poser; this is the roster a parsed Spine skeleton gives it.
460
+ return subsetOver(
461
+ {
462
+ declared: data.slots.map((slot) => slot.name),
463
+ carriers: (name) => {
464
+ const index = data.findSlot(name)?.index ?? -1;
465
+ return data.skins.filter((s) => s.getAttachments().some((entry) => entry.slotIndex === index)).map((s) => s.name);
466
+ },
467
+ defaultSkin: data.defaultSkin?.name ?? null,
468
+ },
469
+ opts,
470
+ skin,
471
+ );
491
472
  }
492
473
 
493
474
  /**
@@ -527,18 +508,6 @@ function skeletonUnderSkin(data: SkeletonData, skin: string | undefined): Skelet
527
508
  return skeleton;
528
509
  }
529
510
 
530
- /**
531
- * The same options with the skin taken off — what the samplers hand `piecesOf`.
532
- *
533
- * Spelled once, so the one function that must not see a `skin` cannot come to
534
- * see one because a second call site forgot.
535
- */
536
- function piecesOptions(opts: PoseOptions | undefined): PoseOptions | undefined {
537
- if (opts === undefined || opts.skin === undefined) return opts;
538
- const { skin: _applied, ...rest } = opts;
539
- return rest;
540
- }
541
-
542
511
  /**
543
512
  * One bone's world transform in one posed frame.
544
513
  *
@@ -571,9 +540,17 @@ export interface BoneSnapshot {
571
540
  scaleY: number;
572
541
  }
573
542
 
574
- /** Every bone's world transform in the skeleton's own declaration order. */
543
+ /**
544
+ * Every bone's world transform in the skeleton's own declaration order — a bone
545
+ * the posed skin leaves unposed (inactive, or below an inactive bone) written as the zero transform
546
+ * (`inactiveBoneSnapshot` in `./render_core.ts`: it is not posed, and what a
547
+ * constraint left in its matrix is not a pose — issue #968).
548
+ */
575
549
  export function boneSnapshots(skeleton: Skeleton): BoneSnapshot[] {
550
+ const unposed = unposedBones(skeleton.bones.map((bone) => ({ name: bone.data.name, parent: bone.parent?.data.name ?? null, active: bone.active })));
576
551
  return skeleton.bones.map((bone) => {
552
+ // Not posed under this skin: the seam's zero snapshot (`inactiveBoneSnapshot`, issue #968).
553
+ if (unposed.has(bone.data.name)) return inactiveBoneSnapshot(bone.data.name);
577
554
  const pose = bone.appliedPose;
578
555
  return {
579
556
  name: bone.data.name,
@@ -614,6 +591,23 @@ export interface Mesh extends PieceCommon {
614
591
  uvs: ArrayLike<number>;
615
592
  /** Vertex index triplets. */
616
593
  triangles: ArrayLike<number>;
594
+ /**
595
+ * A piece a clip cut only: for each drawn triangle, in triangle order, the
596
+ * SOURCE triangle it was cut from — its three world corners and their page
597
+ * UVs, six numbers each per drawn triangle (`ClipSource`). The rasteriser
598
+ * samples such a triangle's pixels at the source triangle's affine UV map,
599
+ * so the picture does not depend on which convex pieces the clipper cut
600
+ * (issue #964). Absent on every piece no clip cut, whose path is unchanged.
601
+ */
602
+ source?: ClipSource;
603
+ }
604
+
605
+ /** The source triangles of a clipped piece's drawn triangles — see `Mesh.source`. */
606
+ export interface ClipSource {
607
+ /** Three world corners (`x, y` each) per drawn triangle. */
608
+ world: number[];
609
+ /** The page UVs of those corners, parallel to `world`. */
610
+ uvs: number[];
617
611
  }
618
612
 
619
613
  /** One drawable in a posed frame. */
@@ -740,50 +734,134 @@ export function posableFromText(skeletonText: string, atlasText: string, atlasDi
740
734
  return { data, pages };
741
735
  }
742
736
 
737
+ // ---------------------------------------------------------------------------
738
+ // the posing seam (issue #965, step 3a of #380)
739
+ // ---------------------------------------------------------------------------
740
+ //
741
+ // ⭐ Everything this file reads off a posed skeleton goes through one
742
+ // interface, `Poser`, and the samplers below are written against it alone. The
743
+ // implementation behind it today is spine-core's (`spinePoser`, further down);
744
+ // the core-backed poser of issue #968 is a SECOND implementation of the same
745
+ // interface, handed to the same samplers, rather than a rewrite of them. What
746
+ // the interface fixes is exactly what a consumer of a posed frame reads:
747
+ //
748
+ // - the setup pose, under a skin or under none;
749
+ // - an animation's frames at `i/fps` for `i = 0..count`, one continuous
750
+ // trajectory stepped once per frame (the stepping recipe is the
751
+ // implementation's; the schedule — which `i`, what `count`, what time a
752
+ // frame is filed under — is the sampler's, and stays here);
753
+ // - per posed frame: the pieces in draw order (world vertices, page UVs,
754
+ // triangles, tint, dark, page, clip output), the bone snapshots, and every
755
+ // slot's whole attachment geometry;
756
+ // - the rest table, and the names a geometry file and a slot subset read.
757
+ //
758
+ // The framing samples are not a fourth entry: `framingViewport` is the same
759
+ // animation entry at `FRAMING_FPS` with the clip off.
760
+ //
761
+ // 🔒 What stays spine-core's and outside the seam, deliberately: the atlas
762
+ // (`posableFromText`'s pages, `substituteTexture`'s region lookup — step 3c of
763
+ // #380) and `nonFiniteOfPosed`, which `validate.ts` calls on a spine-core
764
+ // skeleton it stepped itself (A10), so the round trip keeps its spine-core
765
+ // entry whatever poses the renders.
766
+
767
+ /** What a sampler hands a posed frame's draw walk — `PoseOptions` with the subset already resolved. */
768
+ export interface DrawOptions {
769
+ /** The slots drawn, resolved against the posed skeleton (`slotSubsetOf`); `undefined` draws every slot. */
770
+ subset: SlotSubset | undefined;
771
+ /** Every attachment whole, no clip applied — the framing box's reading. */
772
+ unclipped: boolean;
773
+ /** Also record each piece's original-art UVs — see `PieceTexture`. */
774
+ texture: boolean;
775
+ }
776
+
777
+ /**
778
+ * One posed moment, readable only while the `Poser` call that produced it is
779
+ * running: an implementation may step one skeleton in place, so a reader takes
780
+ * what it needs before the next frame is posed.
781
+ */
782
+ export interface Posed {
783
+ /** The drawables in draw order — see `piecesOf` for the walk and the clip. */
784
+ pieces(draw: DrawOptions): Piece[];
785
+ /** Every bone's world transform, in the skeleton's declaration order. */
786
+ bones(): BoneSnapshot[];
787
+ /** Every slot's region or mesh attachment, whole, in the posed draw order. */
788
+ attachments(): AttachmentPose[];
789
+ }
790
+
791
+ /**
792
+ * The posing seam: one skeleton, posed on demand.
793
+ *
794
+ * Named for what it does rather than for the runtime behind it, because the
795
+ * point of the name is that there are two: `spinePoser` today, the core's at
796
+ * #968. Every sampler, the framing, the geometry export and the non-finite
797
+ * sentence take one (or a `SkeletonData`, which they wrap in `spinePoser`).
798
+ */
799
+ export interface Poser {
800
+ /** Every animation, in declaration order, with its duration in seconds. */
801
+ readonly animations: ReadonlyArray<{ name: string; duration: number }>;
802
+ /** Every bone in declaration order, and its parent's name. */
803
+ readonly bones: ReadonlyArray<{ name: string; parent: string | null }>;
804
+ /** Every slot in declaration order, and the bone it hangs from. */
805
+ readonly slots: ReadonlyArray<{ name: string; bone: string }>;
806
+ /** `slotSubsetOf` against this skeleton posed under `skin` — refused by `SlotSubsetError`. */
807
+ subset(opts: Pick<PoseOptions, 'slots' | 'hidden'> | undefined, skin: string | undefined): SlotSubset | undefined;
808
+ /** The setup pose under `skin` (absent: no skin set at all). */
809
+ setup(skin: string | undefined): Posed;
810
+ /**
811
+ * Animation `name` under `skin`, not looping: `visit(i, posed)` for every
812
+ * `i` from 0 to `count`, the pose at `i/fps`. The caller has checked `name`.
813
+ */
814
+ animation(name: string, skin: string | undefined, fps: number, count: number, visit: (index: number, posed: Posed) => void): void;
815
+ /** The rest table for the (slot, attachment) pairs `shown` holds — see `AttachmentRest`. */
816
+ rest(skin: string | undefined, shown: readonly AttachmentPose[][]): AttachmentRest[];
817
+ }
818
+
819
+ /** What every sampler takes: a poser, or spine-core's parsed skeleton, which is posed through `spinePoser`. */
820
+ export type PoseSource = Poser | SkeletonData;
821
+
822
+ function poserOf(source: PoseSource): Poser {
823
+ return source instanceof SkeletonData ? spinePoser(source) : source;
824
+ }
825
+
826
+ /** A sampler's `PoseOptions` as a draw walk reads them, the subset resolved once, on the first frame posed. */
827
+ function drawResolver(poser: Poser, opts: PoseOptions | undefined): () => DrawOptions {
828
+ let draw: DrawOptions | undefined;
829
+ return () => {
830
+ draw ??= { subset: poser.subset(opts, opts?.skin), unclipped: opts?.unclipped === true, texture: opts?.texture === true };
831
+ return draw;
832
+ };
833
+ }
834
+
835
+ /** One frame off one posed moment, with what `opts` asked to record beside the pieces. */
836
+ function frameOf(index: number, time: number, posed: Posed, draw: DrawOptions, opts: PoseOptions | undefined): Frame {
837
+ return {
838
+ index,
839
+ time,
840
+ pieces: posed.pieces(draw),
841
+ ...(opts?.bones ? { bones: posed.bones() } : {}),
842
+ ...(opts?.geometry ? { attachments: posed.attachments() } : {}),
843
+ };
844
+ }
845
+
743
846
  /**
744
847
  * Sample one animation at a fixed rate and collect the posed pieces per frame.
745
848
  *
746
- * The pose is driven through `AnimationState` rather than `Animation.apply`
747
- * because that is the path a runtime actually takes, and 4.3's `Animation.apply`
748
- * takes a `MixFrom` that only the state machine has any business choosing.
849
+ * Frame `i` is the pose at `i/fps`, for `i = 0..round(duration·fps)` — the
850
+ * schedule is this function's; how a pose reaches `i/fps` is the poser's.
749
851
  */
750
- export function sampleAnimation(data: SkeletonData, name: string, fps: number, opts?: PoseOptions): Frame[] {
751
- const animation = data.findAnimation(name);
852
+ export function sampleAnimation(source: PoseSource, name: string, fps: number, opts?: PoseOptions): Frame[] {
853
+ const poser = poserOf(source);
854
+ const animation = poser.animations.find((a) => a.name === name);
752
855
  if (!animation) {
753
856
  throw new Error(
754
- `no animation "${name}" in this skeleton; it has [${data.animations.map((a) => a.name).join(', ') || 'none'}]`,
857
+ `no animation "${name}" in this skeleton; it has [${poser.animations.map((a) => a.name).join(', ') || 'none'}]`,
755
858
  );
756
859
  }
757
- const skeleton = skeletonUnderSkin(data, opts?.skin);
758
- const pieceOpts = piecesOptions(opts);
759
- const state = new AnimationState(new AnimationStateData(data));
760
- // Not looping: the last frame sits at the animation's duration, and a looping
761
- // entry would wrap it back onto the first pose.
762
- state.setAnimation(0, name, false);
763
- skeleton.setupPose();
764
-
765
860
  const step = 1 / fps;
766
861
  const count = Math.round(animation.duration * fps);
862
+ const draw = drawResolver(poser, opts);
767
863
  const frames: Frame[] = [];
768
- for (let i = 0; i <= count; i++) {
769
- if (i > 0) {
770
- state.update(step);
771
- state.apply(skeleton);
772
- skeleton.update(step);
773
- skeleton.updateWorldTransform(Physics.update);
774
- } else {
775
- state.apply(skeleton);
776
- skeleton.update(0);
777
- skeleton.updateWorldTransform(Physics.reset);
778
- }
779
- frames.push({
780
- index: i,
781
- time: i * step,
782
- pieces: piecesOf(skeleton, pieceOpts),
783
- ...(opts?.bones ? { bones: boneSnapshots(skeleton) } : {}),
784
- ...(opts?.geometry ? { attachments: attachmentsOf(skeleton) } : {}),
785
- });
786
- }
864
+ poser.animation(name, opts?.skin, fps, count, (i, posed) => frames.push(frameOf(i, i * step, posed, draw(), opts)));
787
865
  return frames;
788
866
  }
789
867
 
@@ -795,17 +873,69 @@ export function sampleAnimation(data: SkeletonData, name: string, fps: number, o
795
873
  * ladder's first rung ships one (`1-weight-and-mass`'s second export), and its
796
874
  * whole content is the setup pose.
797
875
  */
798
- export function sampleSetupPose(data: SkeletonData, opts?: PoseOptions): Frame[] {
799
- const skeleton = setupPosed(data, opts?.skin);
800
- return [
801
- {
802
- index: 0,
803
- time: 0,
804
- pieces: piecesOf(skeleton, piecesOptions(opts)),
805
- ...(opts?.bones ? { bones: boneSnapshots(skeleton) } : {}),
806
- ...(opts?.geometry ? { attachments: attachmentsOf(skeleton) } : {}),
876
+ export function sampleSetupPose(source: PoseSource, opts?: PoseOptions): Frame[] {
877
+ const poser = poserOf(source);
878
+ const posed = poser.setup(opts?.skin);
879
+ return [frameOf(0, 0, posed, drawResolver(poser, opts)(), opts)];
880
+ }
881
+
882
+ // ---------------------------------------------------------------------------
883
+ // the spine-core implementation of the seam
884
+ // ---------------------------------------------------------------------------
885
+
886
+ /**
887
+ * `Poser` over spine-core: the runtime's own `Skeleton`, `AnimationState` and
888
+ * `SkeletonClipping`, stepped the way a runtime steps them.
889
+ *
890
+ * The pose is driven through `AnimationState` rather than `Animation.apply`
891
+ * because that is the path a runtime actually takes, and 4.3's
892
+ * `Animation.apply` takes a `MixFrom` that only the state machine has any
893
+ * business choosing. Frame 0 applies, updates by 0 and resets physics; every
894
+ * later frame is one `state.update(1/fps)`, apply, `skeleton.update(1/fps)` and
895
+ * `Physics.update` — one continuous trajectory, so the track time of frame `i`
896
+ * is the sum of `i` steps.
897
+ */
898
+ export function spinePoser(data: SkeletonData): Poser {
899
+ return {
900
+ animations: data.animations.map((a) => ({ name: a.name, duration: a.duration })),
901
+ bones: data.bones.map((bone) => ({ name: bone.name, parent: bone.parent?.name ?? null })),
902
+ slots: data.slots.map((slot) => ({ name: slot.name, bone: slot.boneData.name })),
903
+ subset: (opts, skin) => slotSubsetOf(data, opts, skin),
904
+ setup: (skin) => spinePosed(setupPosed(data, skin)),
905
+ animation: (name, skin, fps, count, visit) => {
906
+ const skeleton = skeletonUnderSkin(data, skin);
907
+ const state = new AnimationState(new AnimationStateData(data));
908
+ // Not looping: the last frame sits at the animation's duration, and a looping
909
+ // entry would wrap it back onto the first pose.
910
+ state.setAnimation(0, name, false);
911
+ skeleton.setupPose();
912
+ const step = 1 / fps;
913
+ const posed = spinePosed(skeleton);
914
+ for (let i = 0; i <= count; i++) {
915
+ if (i > 0) {
916
+ state.update(step);
917
+ state.apply(skeleton);
918
+ skeleton.update(step);
919
+ skeleton.updateWorldTransform(Physics.update);
920
+ } else {
921
+ state.apply(skeleton);
922
+ skeleton.update(0);
923
+ skeleton.updateWorldTransform(Physics.reset);
924
+ }
925
+ visit(i, posed);
926
+ }
807
927
  },
808
- ];
928
+ rest: (skin, shown) => restOf(data, skin, shown),
929
+ };
930
+ }
931
+
932
+ /** A spine-core skeleton as it stands posed, read through the seam's `Posed`. */
933
+ function spinePosed(skeleton: Skeleton): Posed {
934
+ return {
935
+ pieces: (draw) => drawPieces(skeleton, draw),
936
+ bones: () => boneSnapshots(skeleton),
937
+ attachments: () => attachmentsOf(skeleton),
938
+ };
809
939
  }
810
940
 
811
941
  /**
@@ -826,13 +956,135 @@ function setupPosed(data: SkeletonData, skin: string | undefined): Skeleton {
826
956
  * filed under. A skeleton with no animation at all contributes its setup pose
827
957
  * under `SETUP_POSE_DIR`.
828
958
  */
829
- export function sampleAll(data: SkeletonData, fps: number, opts?: PoseOptions): Map<string, Frame[]> {
959
+ export function sampleAll(source: PoseSource, fps: number, opts?: PoseOptions): Map<string, Frame[]> {
960
+ const poser = poserOf(source);
830
961
  const out = new Map<string, Frame[]>();
831
- if (data.animations.length === 0) out.set(SETUP_POSE_DIR, sampleSetupPose(data, opts));
832
- else for (const animation of data.animations) out.set(animation.name, sampleAnimation(data, animation.name, fps, opts));
962
+ if (poser.animations.length === 0) out.set(SETUP_POSE_DIR, sampleSetupPose(poser, opts));
963
+ else for (const animation of poser.animations) out.set(animation.name, sampleAnimation(poser, animation.name, fps, opts));
833
964
  return out;
834
965
  }
835
966
 
967
+ // ---------------------------------------------------------------------------
968
+ // which poser a render poses through (issue #968, step 3d of #380)
969
+ // ---------------------------------------------------------------------------
970
+ //
971
+ // ⭐ Two implementations of the seam, chosen by what the input carries and
972
+ // never guessed: a rigc build writes `skeleton.model.json` beside the Spine
973
+ // pair, and that document is what the core poses (`./render_core.ts`); a
974
+ // Spine export (`bench/reference/*`, `examples/*/export/*`) has none and
975
+ // poses through spine-core. The choice, and the reason for it, is returned
976
+ // beside the result so the caller can say it (`render` prints it as its
977
+ // `poser` line) — a render that fell back without saying so would be a
978
+ // second opinion about the pose that nobody could see.
979
+
980
+ /** Which implementation of the seam posed a render: rigc's own core, or spine-core. */
981
+ export type PoserName = 'core' | 'spine';
982
+
983
+ /** The `--poser` spellings, in the order the usage lists them. */
984
+ export const POSER_NAMES: readonly PoserName[] = ['core', 'spine'];
985
+
986
+ /**
987
+ * A `--poser` the input cannot carry — `--poser core` on a Spine export, or on
988
+ * a build whose document the core refuses. A class of its own so `cli.ts`
989
+ * refuses it as a usage error (exit 2, nothing written).
990
+ */
991
+ export class PoserChoiceError extends Error {}
992
+
993
+ /** Both posers for one input, and why the core one is or is not there. */
994
+ export interface PoserChoice {
995
+ /** What `--poser` asked for, or `undefined` for the input's own choice. */
996
+ forced: PoserName | undefined;
997
+ /** The core poser, or `null` when the input cannot carry it (`why`). */
998
+ core: Poser | null;
999
+ /** For a core poser, the document it poses; otherwise why there is none. */
1000
+ why: string;
1001
+ spine: Poser;
1002
+ }
1003
+
1004
+ /**
1005
+ * The first way a model document's rosters differ from the Spine skeleton they
1006
+ * sit beside, or `null` — a document from another build would pose another
1007
+ * rig in the same files' name, so it is refused rather than drawn.
1008
+ */
1009
+ function rosterDifference(core: Poser, data: SkeletonData): string | null {
1010
+ const bones = (list: ReadonlyArray<{ name: string; parent: string | null }>): string => list.map((b) => `${b.name}<${b.parent ?? ''}`).join('|');
1011
+ const slots = (list: ReadonlyArray<{ name: string; bone: string }>): string => list.map((x) => `${x.name}@${x.bone}`).join('|');
1012
+ const spine = spinePoser(data);
1013
+ if (bones(core.bones) !== bones(spine.bones)) return 'the bones (names, parents or order) differ';
1014
+ if (slots(core.slots) !== slots(spine.slots)) return 'the slots (names, bones or draw order) differ';
1015
+ const animations = (list: ReadonlyArray<{ name: string; duration: number }>): string =>
1016
+ list.map((a) => `${a.name}=${a.duration}`).sort().join('|');
1017
+ if (animations(core.animations) !== animations(spine.animations)) return 'the animations (names or durations) differ';
1018
+ return null;
1019
+ }
1020
+
1021
+ /**
1022
+ * Both posers for the skeleton at `skeletonPath` drawn through the atlas at
1023
+ * `atlasPath` — the core one when `skeleton.model.json` sits beside the
1024
+ * skeleton, the skeleton's bytes hash to the digest the document records
1025
+ * (`spine.sha256`: it is the file that build wrote, not one edited after it),
1026
+ * the atlas is the one beside it too, the core reads both and the document's
1027
+ * rosters are the skeleton's. `forced` is `--poser`; `core` on an
1028
+ * input that cannot carry it is refused by name (`PoserChoiceError`).
1029
+ */
1030
+ export function candidatePosers(
1031
+ data: SkeletonData,
1032
+ skeletonPath: string,
1033
+ atlasPath: string,
1034
+ forced: PoserName | undefined,
1035
+ /** What builds the core poser: `corePoser` — the suite's `RC02` passes a planted copy, and nothing else passes any. */
1036
+ make: (modelText: string, atlasText: string, where: string, skeleton: { path: string; bytes: Uint8Array }) => Poser = corePoser,
1037
+ ): PoserChoice {
1038
+ const spine = spinePoser(data);
1039
+ const dir = dirname(resolve(skeletonPath));
1040
+ const modelPath = join(dir, MODEL_DOCUMENT_FILE);
1041
+ let core: Poser | null = null;
1042
+ let why: string;
1043
+ if (forced === 'spine') why = '--poser spine';
1044
+ else if (!existsSync(modelPath)) why = `no ${MODEL_DOCUMENT_FILE} beside ${resolve(skeletonPath)} — a Spine export, not a rigc build`;
1045
+ else if (dirname(resolve(atlasPath)) !== dir) {
1046
+ why =
1047
+ `the atlas ${resolve(atlasPath)} is not beside ${modelPath}: the document's region trims are its own build's ` +
1048
+ "atlas's, and the core would pose them against another one's pages";
1049
+ } else {
1050
+ try {
1051
+ const candidate = make(readFileSync(modelPath, 'utf8'), readFileSync(atlasPath, 'utf8'), modelPath, { path: resolve(skeletonPath), bytes: readFileSync(skeletonPath) });
1052
+ const differs = rosterDifference(candidate, data);
1053
+ if (differs === null) {
1054
+ core = candidate;
1055
+ why = modelPath;
1056
+ } else why = `${modelPath} does not describe ${resolve(skeletonPath)}: ${differs}`;
1057
+ } catch (err) {
1058
+ if (!(err instanceof CoreInputError)) throw err;
1059
+ // The reader names the document itself (`readModel`'s `where`), so a message that already starts with its path is not given it twice.
1060
+ why = `the core refused ${err.message.startsWith(`${modelPath}: `) ? err.message : `${modelPath}: ${err.message}`}`;
1061
+ }
1062
+ }
1063
+ if (forced === 'core' && core === null) throw new PoserChoiceError(`--poser core: ${why}`);
1064
+ return { forced, core, why, spine };
1065
+ }
1066
+
1067
+ /**
1068
+ * `run` through the chosen poser: the core one when there is one, and
1069
+ * spine-core otherwise — or when the core refuses the input partway
1070
+ * (`CoreInputError`, e.g. a clip polygon that is not simple), in
1071
+ * which case `run` starts again from nothing on spine-core and `note` names the
1072
+ * refusal. Under `--poser core` that refusal is a `PoserChoiceError` instead.
1073
+ * `run` must write nothing: a fallback re-runs it whole.
1074
+ */
1075
+ export function throughPoser<T>(choice: PoserChoice, run: (poser: Poser) => T): { value: T; poser: PoserName; note: string } {
1076
+ if (choice.core !== null) {
1077
+ try {
1078
+ return { value: run(choice.core), poser: 'core', note: `rigc core — ${choice.why}` };
1079
+ } catch (err) {
1080
+ if (!(err instanceof CoreInputError)) throw err;
1081
+ if (choice.forced === 'core') throw new PoserChoiceError(`--poser core: the core refused this input: ${err.message}`);
1082
+ return { value: run(choice.spine), poser: 'spine', note: `spine-core — the core refused this input: ${err.message}` };
1083
+ }
1084
+ }
1085
+ return { value: run(choice.spine), poser: 'spine', note: `spine-core — ${choice.why}` };
1086
+ }
1087
+
836
1088
  /**
837
1089
  * The posed drawables of one frame, in draw order.
838
1090
  *
@@ -868,6 +1120,17 @@ export function sampleAll(data: SkeletonData, fps: number, opts?: PoseOptions):
868
1120
  * list — and one wholly outside becomes a mesh with no triangle, which keeps the
869
1121
  * slot in the frame, undrawn, rather than absent.
870
1122
  *
1123
+ * ⭐ A cut piece's pixels are sampled at each drawn triangle's SOURCE triangle's
1124
+ * affine UV (`Mesh.source`), not at its own float32 corner UVs (issue #964):
1125
+ * which convex pieces a clipper cuts a concave polygon into is the clipper's —
1126
+ * spine-core's, the core's, or spine-core's under another spelling of the same
1127
+ * polygon — and only this keeps the picture from depending on it. Measured: the
1128
+ * same notched square spelled from four start vertices moved up to 18 pixels one
1129
+ * level against itself through spine-core before, 0 after; the 19 tree rows'
1130
+ * renders did not move (spineboy-pro's portal, the one clipped row, included).
1131
+ * An unclipped piece carries no `source` and is rasterised as before, byte for
1132
+ * byte.
1133
+ *
871
1134
  * The clip is applied whatever `slots`/`hidden` draw: a hidden clip still masks
872
1135
  * what is shown, so a subset frame is the whole frame's pixels for those slots.
873
1136
  * `unclipped` turns it off for the framing box alone — see its note.
@@ -888,8 +1151,14 @@ export function piecesOf(skeleton: Skeleton, opts?: PoseOptions): Piece[] {
888
1151
  // it was set to — so a name that draws nothing is refused here, where the one
889
1152
  // application point is, rather than matching no piece in silence.
890
1153
  const subset = slotSubsetOf(skeleton.data, opts, skeleton.skin?.name);
1154
+ return drawPieces(skeleton, { subset, unclipped: opts?.unclipped === true, texture: opts?.texture === true });
1155
+ }
1156
+
1157
+ /** `piecesOf`'s walk over a posed spine-core skeleton, the subset already resolved — the spine poser's `Posed.pieces`. */
1158
+ function drawPieces(skeleton: Skeleton, draw: DrawOptions): Piece[] {
1159
+ const { subset } = draw;
891
1160
  const named = subset === undefined ? undefined : new Set(subset.names);
892
- const clipper = opts?.unclipped === true ? null : new SkeletonClipping();
1161
+ const clipper = draw.unclipped ? null : new SkeletonClipping();
893
1162
  const pieces: Piece[] = [];
894
1163
  for (const slot of skeleton.drawOrder.appliedPose) {
895
1164
  const attachment = slot.appliedPose.attachment;
@@ -903,7 +1172,7 @@ export function piecesOf(skeleton: Skeleton, opts?: PoseOptions): Piece[] {
903
1172
  continue;
904
1173
  }
905
1174
  const drawn = subset === undefined || named === undefined || named.has(slot.data.name) === (subset.mode === 'slots');
906
- const piece = drawn ? pieceOf(skeleton, slot, opts, clipper) : null;
1175
+ const piece = drawn ? pieceOf(skeleton, slot, draw.texture, clipper) : null;
907
1176
  if (piece !== null) pieces.push(piece);
908
1177
  clipper?.clipEnd(slot);
909
1178
  }
@@ -921,7 +1190,7 @@ const QUAD_TRIANGLES = [0, 1, 2, 2, 3, 0];
921
1190
  function pieceOf(
922
1191
  skeleton: Skeleton,
923
1192
  slot: Slot,
924
- opts: PoseOptions | undefined,
1193
+ withTexture: boolean,
925
1194
  clipper: SkeletonClipping | null,
926
1195
  ): Piece | null {
927
1196
  const pose = slot.appliedPose;
@@ -946,7 +1215,7 @@ function pieceOf(
946
1215
  const dark: [number, number, number] | undefined =
947
1216
  darkPose === null ? undefined : [darkPose.r, darkPose.g, darkPose.b];
948
1217
  const common = { tint, dark, slot: slot.data.name, page: region.page.name };
949
- const texture = opts?.texture !== true ? undefined : artUvsOf(attachment, region);
1218
+ const texture = withTexture ? artUvsOf(attachment, region) : undefined;
950
1219
  const uvs = attachment.sequence.getUVs(index);
951
1220
 
952
1221
  let piece: Piece;
@@ -1032,7 +1301,20 @@ function clippedPiece(piece: Piece, triangles: number[], uvs: Float32Array, clip
1032
1301
  const world = Array.from(clipper.clippedVerticesTyped);
1033
1302
  const clippedUvs = Array.from(clipper.clippedUVsTyped);
1034
1303
  const clipped = Array.from(clipper.clippedTrianglesTyped);
1304
+ // Which source triangle each drawn triangle was cut from (`Mesh.source`): the clipper cuts triangle by triangle, so each source
1305
+ // triangle is clipped alone and its drawn triangles counted; the concatenation is held to the whole call's output, vertex for vertex.
1306
+ const sources: number[] = [];
1307
+ const again: number[] = [];
1308
+ for (let t = 0; t + 2 < triangles.length; t += 3) {
1309
+ clipper.clipTrianglesUnpacked(piece.world, 0, triangles.slice(t, t + 3), 3, uvs, 2);
1310
+ for (let k = 0; k < clipper.clippedTrianglesTyped.length; k += 3) sources.push(t / 3);
1311
+ again.push(...clipper.clippedVerticesTyped);
1312
+ }
1313
+ if (sources.length !== clipped.length / 3 || again.length !== world.length || again.some((v, i) => v !== world[i])) {
1314
+ throw new Error(`slot "${piece.slot}": the clipper cut ${clipped.length / 3} triangle(s) in one call and ${sources.length} triangle by triangle, over other vertices — the source of each drawn triangle cannot be named`);
1315
+ }
1035
1316
  let texture = piece.texture;
1317
+ const source = clipSourceOf(piece.world, Array.from(uvs), triangles, sources, texture?.artUvs);
1036
1318
  if (texture !== undefined) {
1037
1319
  clipper.clipTrianglesUnpacked(piece.world, 0, triangles, triangles.length, texture.artUvs, 2);
1038
1320
  const artUvs = Array.from(clipper.clippedUVsTyped);
@@ -1042,10 +1324,10 @@ function clippedPiece(piece: Piece, triangles: number[], uvs: Float32Array, clip
1042
1324
  `${artUvs.length / 2} for the original-art UVs over the same geometry`,
1043
1325
  );
1044
1326
  }
1045
- texture = { region: texture.region, artUvs };
1327
+ texture = { region: texture.region, artUvs, sourceArtUvs: source.artUvs };
1046
1328
  }
1047
1329
  const { tint, dark, slot, page } = piece;
1048
- return { kind: 'mesh', tint, dark, slot, page, texture, world, uvs: clippedUvs, triangles: clipped };
1330
+ return { kind: 'mesh', tint, dark, slot, page, texture, world, uvs: clippedUvs, triangles: clipped, source: { world: source.world, uvs: source.uvs } };
1049
1331
  }
1050
1332
 
1051
1333
  // ---------------------------------------------------------------------------
@@ -1166,7 +1448,7 @@ export function sidecarViewport(v: Viewport): FramesSidecar['viewport'] {
1166
1448
  * vertex (or the bone), when any number in it is not finite.
1167
1449
  */
1168
1450
  export function geometryFileOf(
1169
- data: SkeletonData,
1451
+ source: PoseSource,
1170
1452
  animation: string | null,
1171
1453
  fps: number,
1172
1454
  frames: Frame[],
@@ -1187,7 +1469,8 @@ export function geometryFileOf(
1187
1469
  time: frame.time,
1188
1470
  ...geometryFrame(frame, `frame ${frame.index}`),
1189
1471
  }));
1190
- const setupFrame = sampleSetupPose(data, { ...(skin === undefined ? {} : { skin }), bones: true, geometry: true })[0];
1472
+ const poser = poserOf(source);
1473
+ const setupFrame = sampleSetupPose(poser, { ...(skin === undefined ? {} : { skin }), bones: true, geometry: true })[0];
1191
1474
  const setup = geometryFrame(setupFrame, 'the setup pose');
1192
1475
  const file: GeometryFile = {
1193
1476
  spec: GEOMETRY_SPEC,
@@ -1196,9 +1479,9 @@ export function geometryFileOf(
1196
1479
  ...(skin === undefined ? {} : { skin }),
1197
1480
  fps,
1198
1481
  viewport: sidecarViewport(viewport),
1199
- bones: data.bones.map((bone) => ({ name: bone.name, parent: bone.parent?.name ?? null })),
1200
- slots: data.slots.map((slot) => ({ name: slot.name, bone: slot.boneData.name })),
1201
- rest: restOf(data, skin, [setup.attachments, ...posed.map((frame) => frame.attachments)]),
1482
+ bones: poser.bones.map(({ name, parent }) => ({ name, parent })),
1483
+ slots: poser.slots.map(({ name, bone }) => ({ name, bone })),
1484
+ rest: poser.rest(skin, [setup.attachments, ...posed.map((frame) => frame.attachments)]),
1202
1485
  setup,
1203
1486
  frames: posed,
1204
1487
  };
@@ -1215,7 +1498,7 @@ export function geometryFileOf(
1215
1498
  * frames drew. A region's rest corners are read for the sequence frame the setup
1216
1499
  * pose resolves, which is the frame its setup pose draws.
1217
1500
  */
1218
- function restOf(data: SkeletonData, skin: string | undefined, shown: AttachmentPose[][]): AttachmentRest[] {
1501
+ function restOf(data: SkeletonData, skin: string | undefined, shown: readonly AttachmentPose[][]): AttachmentRest[] {
1219
1502
  const skeleton = setupPosed(data, skin);
1220
1503
  // Slot, then attachment: two maps rather than one joined key, so no pair of
1221
1504
  // names can fold into another's entry whatever characters they carry.
@@ -1384,10 +1667,11 @@ function refuseNonFinite(file: GeometryFile): void {
1384
1667
  * pays for it.
1385
1668
  */
1386
1669
  export function nonFinitePoseOf(
1387
- data: SkeletonData,
1670
+ source: PoseSource,
1388
1671
  skin: string | undefined,
1389
1672
  sets: ReadonlyArray<{ animation: string | null; fps: number }>,
1390
1673
  ): string | null {
1674
+ const poser = poserOf(source);
1391
1675
  const opts: PoseOptions = { ...(skin === undefined ? {} : { skin }), bones: true, geometry: true };
1392
1676
  // Every attachment list the frames showed, for the rest table's roster.
1393
1677
  const shown: AttachmentPose[][] = [];
@@ -1398,11 +1682,11 @@ export function nonFinitePoseOf(
1398
1682
  shown.push(frame.attachments);
1399
1683
  return { where, attachments: frame.attachments, bones: frame.bones };
1400
1684
  };
1401
- const setup = named(sampleSetupPose(data, opts)[0], 'the setup pose');
1685
+ const setup = named(sampleSetupPose(poser, opts)[0], 'the setup pose');
1402
1686
  const frames = sets.flatMap(({ animation, fps }) =>
1403
1687
  animation === null
1404
1688
  ? []
1405
- : sampleAnimation(data, animation, fps, opts).map((frame) =>
1689
+ : sampleAnimation(poser, animation, fps, opts).map((frame) =>
1406
1690
  named(frame, frameWhere(animation, frame.index, frame.time, fps)),
1407
1691
  ),
1408
1692
  );
@@ -1410,7 +1694,7 @@ export function nonFinitePoseOf(
1410
1694
  // what they show, and would be posing the same overflow a third time.
1411
1695
  const found = nonFiniteSentence(setup, frames, []);
1412
1696
  if (found !== null) return found;
1413
- return nonFiniteSentence({ attachments: [], bones: [] }, [], restOf(data, skin, shown));
1697
+ return nonFiniteSentence({ attachments: [], bones: [] }, [], poser.rest(skin, shown));
1414
1698
  }
1415
1699
 
1416
1700
  /**
@@ -1640,6 +1924,14 @@ export function substituteTexture(
1640
1924
  // handled. Calling it rather than repeating it is what keeps this from being
1641
1925
  // a second opinion about the atlas format.
1642
1926
  MeshAttachment.computeUVs(region, texture.artUvs, uvs);
1927
+ // A clipped piece's source map is re-seated the same way, through the drawing's own coordinates (`Mesh.source`).
1928
+ if (piece.kind === 'mesh' && piece.source !== undefined) {
1929
+ if (texture.sourceArtUvs === undefined) throw new Error(`slot "${piece.slot}": a clipped piece carries no original-art UVs for its source triangles`);
1930
+ const sourceUvs = new Array<number>(texture.sourceArtUvs.length).fill(0);
1931
+ MeshAttachment.computeUVs(region, texture.sourceArtUvs, sourceUvs);
1932
+ pieces.push({ ...piece, page: SUBSTITUTE_PAGE + region.page.name, uvs, uvWindow: windowOf(region), source: { world: piece.source.world, uvs: sourceUvs } });
1933
+ continue;
1934
+ }
1643
1935
  pieces.push({ ...piece, page: SUBSTITUTE_PAGE + region.page.name, uvs, uvWindow: windowOf(region) });
1644
1936
  }
1645
1937
  return { frame: { ...frame, pieces }, unmatched };
@@ -1818,6 +2110,157 @@ export function trimmedUnionBounds(
1818
2110
  return { minX, minY, maxX, maxY };
1819
2111
  }
1820
2112
 
2113
+ /**
2114
+ * A pose that drew vertices and cannot be framed, because every one of them sits
2115
+ * at one point (issue #997) — refused by the slots that drew, the bone each hangs
2116
+ * from and, where those bones are unposed, the skins that would pose them.
2117
+ *
2118
+ * A `GeometryError`, because it is the same family as the non-finite pose of
2119
+ * #873: a picture that has no size is as unwritable as a vertex at Infinity. It
2120
+ * is a class of its own because the reader's next step is different — there the
2121
+ * rig posed a number no picture can hold, here the invocation posed the rig under
2122
+ * a skin that leaves every drawn bone without a world transform, and `--skin` is
2123
+ * usually the whole fix — so `cli.ts` exits 2 on it, as it does on the other
2124
+ * framing refusal, *nothing to draw*.
2125
+ *
2126
+ * Before it the framing answered a viewport over the one point: `maxSide / 0` is
2127
+ * Infinity, `0 · Infinity` is NaN, and `render` wrote a 0×0 frame set with exit
2128
+ * 0, its summary saying `NaNxNaNpx` and `frames.json` a viewport of width 0 and
2129
+ * scale `null`.
2130
+ */
2131
+ export class UnframeablePoseError extends GeometryError {}
2132
+
2133
+ /**
2134
+ * Which bones a skin leaves unposed, and which skins there are — the rig
2135
+ * structure the unframeable sentence names a skin from (issue #997). Not a
2136
+ * poser's: the `Poser` seam carries no skin roster, and both posers pose the one
2137
+ * Spine file this is read from (the core's document is bound to it by hash).
2138
+ */
2139
+ export interface SkinRoster {
2140
+ /** Every skin, in declaration order. */
2141
+ readonly skins: readonly string[];
2142
+ /** The bones left unposed under `skin` (absent: no skin set) — inactive, or below an inactive bone. */
2143
+ unposedUnder(skin: string | undefined): Set<string>;
2144
+ }
2145
+
2146
+ /**
2147
+ * The skin roster of a parsed Spine file, as the runtime flags it —
2148
+ * `unposedBones` over a fresh skeleton's `active` under each skin, so Spine's
2149
+ * activation rule is the runtime's and is not restated here. Nothing is posed
2150
+ * until a question is asked: the framing asks one (the skin it frames under,
2151
+ * `slotsOnUnposedBones`) and the refusal asks one per skin.
2152
+ */
2153
+ export function skinRosterOf(data: SkeletonData): SkinRoster {
2154
+ return {
2155
+ skins: data.skins.map((k) => k.name),
2156
+ unposedUnder: (skin) => {
2157
+ const skeleton = skeletonUnderSkin(data, skin);
2158
+ return unposedBones(skeleton.bones.map((bone) => ({ name: bone.data.name, parent: bone.parent?.data.name ?? null, active: bone.active })));
2159
+ },
2160
+ };
2161
+ }
2162
+
2163
+ /** The roster behind a pose source: its own when it is Spine data, else the one the caller passed. */
2164
+ function rosterBehind(source: PoseSource, roster: SkinRoster | undefined): SkinRoster | undefined {
2165
+ return source instanceof SkeletonData ? skinRosterOf(source) : roster;
2166
+ }
2167
+
2168
+ /**
2169
+ * ⭐ **The one derivation of "a drawn slot on an unposed bone"** (issues #997,
2170
+ * #1000): the names of the slots of `slots` whose bone `roster` says `skin`
2171
+ * leaves unposed — inactive itself, or below an inactive bone. Both posers draw
2172
+ * such a slot through the zero matrix, so it draws no pixel and every vertex of
2173
+ * it sits at the origin. `framingViewport` takes these slots off the box
2174
+ * (#1000) and `unframeableSentence` refuses a pose that drew nothing else
2175
+ * (#997), from this one set.
2176
+ *
2177
+ * Empty without a roster: a bare `Poser` carries no skin structure, and what
2178
+ * cannot be read is not guessed — every slot then counts, as it did before.
2179
+ */
2180
+ export function slotsOnUnposedBones(
2181
+ slots: ReadonlyArray<{ name: string; bone: string }>,
2182
+ skin: string | undefined,
2183
+ roster: SkinRoster | undefined,
2184
+ ): Set<string> {
2185
+ if (roster === undefined) return new Set();
2186
+ const unposed = roster.unposedUnder(skin);
2187
+ return new Set(slots.filter((slot) => unposed.has(slot.bone)).map((slot) => slot.name));
2188
+ }
2189
+
2190
+ /** How many drawn slots a refusal names before it says how many more there are. */
2191
+ const UNFRAMEABLE_NAMED = 3;
2192
+
2193
+ /**
2194
+ * ⭐ **The one derivation of the unframeable sentence** (issue #997): `null`
2195
+ * when the vertices of `frameSets` span a box with extent, and otherwise the
2196
+ * sentence `UnframeablePoseError` carries. `framingViewport` and `check` both
2197
+ * reach it, so `render`, `render --geometry`, `check` and a library caller
2198
+ * refuse one rig in the same words.
2199
+ *
2200
+ * Two sentences, told apart by what the reader must change:
2201
+ *
2202
+ * - **every drawn slot hangs from a bone the skin leaves unposed** — inactive
2203
+ * itself or below an inactive bone (`unposedBones`). Both posers draw such a
2204
+ * slot through the zero matrix, so every vertex lands on the origin. Each
2205
+ * bone is named with the skins that pose it, read off the runtime's own
2206
+ * `active` flag under each skin rather than restating Spine's rule here.
2207
+ * - otherwise **every drawn vertex sits at one point** — the bones posed, and
2208
+ * collapsed their attachments (a world scale of 0 does).
2209
+ *
2210
+ * `roster` is the skin structure of the Spine file the poser poses
2211
+ * (`skinRosterOf`), which is what says whether a bone is unposed
2212
+ * (`slotsOnUnposedBones`, the set the framing box leaves out) and which skins
2213
+ * pose it. Without it the first sentence cannot be told from the second, and
2214
+ * the second is what is said.
2215
+ *
2216
+ * Distinct from *nothing to draw*: that is a skeleton that posed no vertex at
2217
+ * all, and its fix is art; this one posed vertices, and they have no place.
2218
+ */
2219
+ export function unframeableSentence(
2220
+ frameSets: ReadonlyArray<readonly Frame[]>,
2221
+ slots: ReadonlyArray<{ name: string; bone: string }>,
2222
+ skin: string | undefined,
2223
+ roster: SkinRoster | undefined,
2224
+ ): string | null {
2225
+ const box = unionBounds(frameSets.map((frames) => [...frames]));
2226
+ if (![box.minX, box.minY, box.maxX, box.maxY].every(Number.isFinite)) return null;
2227
+ if (box.maxX - box.minX !== 0 || box.maxY - box.minY !== 0) return null;
2228
+ const drew = new Set<string>();
2229
+ for (const frames of frameSets) {
2230
+ for (const frame of frames) for (const piece of frame.pieces) if (piece.world.length > 0) drew.add(piece.slot);
2231
+ }
2232
+ // Declaration order, so the sentence does not depend on which frame drew first.
2233
+ const drawn = slots.filter((slot) => drew.has(slot.name));
2234
+ const under = skin === undefined ? 'under no skin' : `under skin ${JSON.stringify(skin)}`;
2235
+ const counted = `${drawn.length} drawn slot${drawn.length === 1 ? '' : 's'}`;
2236
+ const more = drawn.length > UNFRAMEABLE_NAMED ? `; and ${drawn.length - UNFRAMEABLE_NAMED} more` : '';
2237
+ const named = (describe: (slot: { name: string; bone: string }) => string): string =>
2238
+ drawn.slice(0, UNFRAMEABLE_NAMED).map(describe).join('; ') + more;
2239
+ if (roster !== undefined) {
2240
+ const offBox = slotsOnUnposedBones(slots, skin, roster);
2241
+ if (drawn.length > 0 && drawn.every((slot) => offBox.has(slot.name))) {
2242
+ const bySkin = roster.skins.map((name) => ({ name, unposed: roster.unposedUnder(name) }));
2243
+ const posers = (bone: string): string => {
2244
+ const names = bySkin.filter((k) => !k.unposed.has(bone)).map((k) => JSON.stringify(k.name));
2245
+ if (names.length === 0) return 'which no skin poses';
2246
+ return `which skin${names.length === 1 ? '' : 's'} ${names.join(', ')} pose${names.length === 1 ? 's' : ''}`;
2247
+ };
2248
+ return (
2249
+ `${under}, every drawn slot hangs from a bone that skin leaves unposed — ${counted}: ` +
2250
+ named((slot) => `slot ${JSON.stringify(slot.name)} on bone ${JSON.stringify(slot.bone)}, ${posers(slot.bone)}`) +
2251
+ ' — so no drawn vertex has a world transform and the frame would be 0x0: pose it under a skin that poses ' +
2252
+ 'those bones (`--skin`), or name the bones in the skin it is posed under'
2253
+ );
2254
+ }
2255
+ }
2256
+ return (
2257
+ `${under}, every drawn vertex sits at the one point (${box.minX}, ${box.minY}) — ${counted}: ` +
2258
+ named((slot) => `slot ${JSON.stringify(slot.name)} on bone ${JSON.stringify(slot.bone)}`) +
2259
+ ' — so the posed box has no extent and the frame would be 0x0: the bones those slots hang from collapse ' +
2260
+ 'every vertex onto it (a world scale of 0 does)'
2261
+ );
2262
+ }
2263
+
1821
2264
  /**
1822
2265
  * The viewport a skeleton is framed to: its union box at `FRAMING_FPS`, padded,
1823
2266
  * scaled so the long side is `maxSide` pixels.
@@ -1827,9 +2270,26 @@ export function trimmedUnionBounds(
1827
2270
  *
1828
2271
  * `null` means the skeleton posed no vertex at all — nothing to draw. A pose
1829
2272
  * holding a vertex at Infinity or NaN is refused by a `GeometryError` naming the
1830
- * bone or vertex and its value (issue #873), never answered `null`.
2273
+ * bone or vertex and its value (issue #873), never answered `null`. A pose whose
2274
+ * every vertex sits at one point is refused by an `UnframeablePoseError`
2275
+ * (`unframeableSentence`, issue #997), never framed at a scale of Infinity.
2276
+ *
2277
+ * The box is over the slots that POSE (issue #1000): a drawn slot whose bone
2278
+ * the skin leaves unposed (`slotsOnUnposedBones`) draws no pixel — both posers
2279
+ * put it through the zero matrix — so its vertices at the origin are not part
2280
+ * of the shot, and counting them framed every frame of a rig that carries one
2281
+ * to a point nothing is drawn at (256x94 where the posed slot alone gives
2282
+ * 256x55). When every drawn slot is such a slot there is no posed box at all,
2283
+ * and that is #997's refusal, never an empty or invented one.
2284
+ *
2285
+ * `roster` is the skin roster behind a `Poser` source (`skinRosterOf`) — what
2286
+ * says which bones the skin leaves unposed, and lets the refusal name the skins
2287
+ * that pose one. Spine data as the source is its own. A bare `Poser` with no
2288
+ * roster cannot tell an unposed bone from a posed one, so every drawn slot
2289
+ * counts there; every CLI caller passes the roster, so both posers frame alike.
1831
2290
  */
1832
- export function framingViewport(data: SkeletonData, maxSide: number, opts?: PoseOptions): Viewport | null {
2291
+ export function framingViewport(source: PoseSource, maxSide: number, opts?: PoseOptions, rosterGiven?: SkinRoster): Viewport | null {
2292
+ const poser = poserOf(source);
1833
2293
  // The skin belongs here as much as in the frames: the union box is over the
1834
2294
  // attachments that POSE, and two skins fill a slot with art of different sizes
1835
2295
  // in different places. Framing one skin's shot with another skin's box would
@@ -1848,9 +2308,9 @@ export function framingViewport(data: SkeletonData, maxSide: number, opts?: Pose
1848
2308
  const { slots: _drawn, hidden: _hidden, bones: _bones, geometry: _geometry, ...whole } = opts ?? {};
1849
2309
  const framed: PoseOptions = { ...whole, unclipped: true };
1850
2310
  const sets =
1851
- data.animations.length === 0
1852
- ? [sampleSetupPose(data, framed)]
1853
- : data.animations.map((a) => sampleAnimation(data, a.name, FRAMING_FPS, framed));
2311
+ poser.animations.length === 0
2312
+ ? [sampleSetupPose(poser, framed)]
2313
+ : poser.animations.map((a) => sampleAnimation(poser, a.name, FRAMING_FPS, framed));
1854
2314
  // ⚠️ Two different reasons a box is not finite, told apart here and nowhere
1855
2315
  // else (issue #873). A skeleton that posed no vertex at all has nothing to
1856
2316
  // draw, and that is `null`. One that posed a vertex at Infinity or NaN has a
@@ -1859,16 +2319,27 @@ export function framingViewport(data: SkeletonData, maxSide: number, opts?: Pose
1859
2319
  // this, the first case's `null` covered both, and a single overflowing bone
1860
2320
  // among finite ones reached neither: its box was finite on one side, and
1861
2321
  // `render` wrote a NaN-by-NaN frame set with exit 0.
1862
- const posedAny = sets.some((frames) => frames.some((frame) => frame.pieces.some((piece) => piece.world.length > 0)));
1863
- if (!posedAny) return null;
1864
- const box = unionBounds(sets);
2322
+ const drewAny = (frameSets: ReadonlyArray<readonly Frame[]>): boolean =>
2323
+ frameSets.some((frames) => frames.some((frame) => frame.pieces.some((piece) => piece.world.length > 0)));
2324
+ if (!drewAny(sets)) return null;
2325
+ // ⭐ The box is over the slots that pose (issue #1000). A drawn slot on a bone
2326
+ // the skin leaves unposed is taken off — it draws no pixel — unless nothing
2327
+ // else drew, in which case every set is kept whole so the pose reaches #997's
2328
+ // refusal below exactly as it did before this: the same inputs, the same
2329
+ // sentence, never an empty box framed to something.
2330
+ const roster = rosterBehind(source, rosterGiven);
2331
+ const offBox = slotsOnUnposedBones(poser.slots, framed.skin, roster);
2332
+ const posedSets =
2333
+ offBox.size === 0 ? sets : sets.map((frames) => frames.map((frame) => ({ ...frame, pieces: frame.pieces.filter((piece) => !offBox.has(piece.slot)) })));
2334
+ const boxed = drewAny(posedSets) ? posedSets : sets;
2335
+ const box = unionBounds(boxed);
1865
2336
  if (![box.minX, box.minY, box.maxX, box.maxY].every(Number.isFinite)) {
1866
2337
  const found = nonFinitePoseOf(
1867
- data,
2338
+ poser,
1868
2339
  framed.skin,
1869
- data.animations.length === 0
2340
+ poser.animations.length === 0
1870
2341
  ? [{ animation: null, fps: FRAMING_FPS }]
1871
- : data.animations.map((a) => ({ animation: a.name, fps: FRAMING_FPS })),
2342
+ : poser.animations.map((a) => ({ animation: a.name, fps: FRAMING_FPS })),
1872
2343
  );
1873
2344
  // Reaching here with nothing found would mean the pieces and the whole
1874
2345
  // attachments disagree about one pose — a defect here, said as one.
@@ -1880,6 +2351,11 @@ export function framingViewport(data: SkeletonData, maxSide: number, opts?: Pose
1880
2351
  }
1881
2352
  throw new GeometryError(found);
1882
2353
  }
2354
+ // A finite box over one point (issue #997): every drawn slot on a bone the
2355
+ // skin leaves unposed, or every vertex collapsed. Framed, it is a scale of
2356
+ // Infinity and a frame of NaN by NaN pixels, written as 0x0 with exit 0.
2357
+ const unframeable = unframeableSentence(boxed, poser.slots, framed.skin, roster);
2358
+ if (unframeable !== null) throw new UnframeablePoseError(unframeable);
1883
2359
  const pad = Math.max(box.maxX - box.minX, box.maxY - box.minY) * PAD;
1884
2360
  return viewportFor(box.minX - pad, box.minY - pad, box.maxX + pad, box.maxY + pad, maxSide);
1885
2361
  }
@@ -2146,6 +2622,10 @@ export function rasteriseMesh(
2146
2622
  const u2 = mesh.uvs[i2 * 2];
2147
2623
  const v2 = mesh.uvs[i2 * 2 + 1];
2148
2624
 
2625
+ // A clipped piece (`Mesh.source`, issue #964): the UV of a pixel is the SOURCE triangle's affine map, in doubles from its own
2626
+ // projected corners and page UVs, so which convex pieces the clipper cut changes no pixel. Coverage is still this triangle's.
2627
+ const src = mesh.source === undefined ? null : sourceMap(mesh.source, t / 3, project);
2628
+
2149
2629
  for (let y = minY; y <= maxY; y++) {
2150
2630
  const sy = y + 0.5;
2151
2631
  for (let x = minX; x <= maxX; x++) {
@@ -2157,11 +2637,15 @@ export function rasteriseMesh(
2157
2637
  const w2 = (x1 - x0) * (sy - y0) - (y1 - y0) * (sx - x0);
2158
2638
  if (topLeft2 ? w2 < 0 : w2 <= 0) continue;
2159
2639
 
2160
- const b0 = w0 / area;
2161
- const b1 = w1 / area;
2162
- const b2 = w2 / area;
2163
- const u = b0 * u0 + b1 * u1 + b2 * u2;
2164
- const v = b0 * v0 + b1 * v1 + b2 * v2;
2640
+ let u: number;
2641
+ let v: number;
2642
+ if (src === null) {
2643
+ const b0 = w0 / area;
2644
+ const b1 = w1 / area;
2645
+ const b2 = w2 / area;
2646
+ u = b0 * u0 + b1 * u1 + b2 * u2;
2647
+ v = b0 * v0 + b1 * v1 + b2 * v2;
2648
+ } else [u, v] = src(sx, sy);
2165
2649
  if (outsideWindow(mesh.uvWindow, u, v)) continue;
2166
2650
  const sample = bilinear(page, u * page.width - 0.5, v * page.height - 0.5);
2167
2651
  const alpha = sample[3] * mesh.tint[3];
@@ -2179,6 +2663,28 @@ export function rasteriseMesh(
2179
2663
  }
2180
2664
  }
2181
2665
 
2666
+ /**
2667
+ * The UV map of a clipped piece's drawn triangle `t`: its source triangle's
2668
+ * affine map (`Mesh.source`), from the three projected source corners and their
2669
+ * page UVs, in doubles — the barycentric weights of the pixel centre in the
2670
+ * projected source triangle times the corners' UVs. The same for every
2671
+ * decomposition of the source triangle, which is the point (issue #964).
2672
+ */
2673
+ function sourceMap(source: ClipSource, t: number, project: (wx: number, wy: number) => [number, number]): (sx: number, sy: number) => [number, number] {
2674
+ const o = t * 6;
2675
+ const [ax, ay] = project(source.world[o], source.world[o + 1]);
2676
+ const [bx, by] = project(source.world[o + 2], source.world[o + 3]);
2677
+ const [cx, cy] = project(source.world[o + 4], source.world[o + 5]);
2678
+ const uv = source.uvs;
2679
+ const area = (bx - ax) * (cy - ay) - (by - ay) * (cx - ax);
2680
+ return (sx, sy) => {
2681
+ const wa = ((cx - bx) * (sy - by) - (cy - by) * (sx - bx)) / area;
2682
+ const wb = ((ax - cx) * (sy - cy) - (ay - cy) * (sx - cx)) / area;
2683
+ const wc = ((bx - ax) * (sy - ay) - (by - ay) * (sx - ax)) / area;
2684
+ return [wa * uv[o] + wb * uv[o + 2] + wc * uv[o + 4], wa * uv[o + 1] + wb * uv[o + 3] + wc * uv[o + 5]];
2685
+ };
2686
+ }
2687
+
2182
2688
  /**
2183
2689
  * Rasterise whichever shape this piece is.
2184
2690
  *