spine-rigc 2.11.1 → 2.12.0

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.
@@ -0,0 +1,97 @@
1
+ /**
2
+ * A50, the body (issue #1168): the stage box a rig asked for is the stage —
3
+ * in the Spine files a consumer ships, read back the way that consumer reads
4
+ * it.
5
+ *
6
+ * Since issue #907 the Spine header carries the setup-pose bounding box and the
7
+ * stage is stated only in `skeleton.model.json`, which a consumer does not ship.
8
+ * A rig that asks (`skeleton.stageBox`) carries the stage as a bounding-box
9
+ * attachment, whose numbers `compile` writes from the stage. This rule holds
10
+ * that box to the stage the document states, on both readings a consumer has:
11
+ *
12
+ * 1. **The data.** The slot carries, in the `default` skin under the asked
13
+ * name, an unweighted bounding box of four vertices, and its stored
14
+ * vertices are the stage's corners — `(x, y)`, `(x + w, y)`,
15
+ * `(x + w, y + h)`, `(x, y + h)` — each exactly, as the float32 the
16
+ * runtime loads. That is what a reader of the attachment gets without
17
+ * posing anything.
18
+ * 2. **The setup pose.** Its world vertices at the setup pose — constraints
19
+ * applied, no physics, no skin set — are those corners as the runtime
20
+ * poses them on an unrotated root: through the frame the core computes
21
+ * for a root that states no transform (`worldTransforms` in
22
+ * `../../core/world.ts`, whose unrotated `b` is cos 90° at the runtime's
23
+ * pi, −2.3e-8, not 0), compared on the header's grid
24
+ * (`headerBoxNumber` in `../../compile.ts`) — the tolerance the header's
25
+ * own box is held at. A constraint that moves the box's bone at setup
26
+ * is caught here and nowhere else: `compile` poses no constraint.
27
+ *
28
+ * **SKIP** where nothing asks: an export or a directory with no `/3` document,
29
+ * and a document that states a stage and no box — a rig that does not ask is
30
+ * unmeasured, never certified.
31
+ *
32
+ * Links nothing from the runtime.
33
+ */
34
+ import type { Verdicts } from '../harness.ts';
35
+ import type { StageBoxFacts } from '../facts/stage_box.ts';
36
+ import { worldTransforms } from '../../core/world.ts';
37
+ import { headerBoxNumber } from '../../compile.ts';
38
+
39
+ const CODE = 'A50_STAGE_BOX_IS_THE_STAGE';
40
+
41
+ /** The stage's four corners in Spine world, bottom-left first and counter-clockwise — the order `compile` writes. */
42
+ export function stageCorners(stage: { x: number; y: number; width: number; height: number }): number[] {
43
+ const right = stage.x + stage.width;
44
+ const top = stage.y + stage.height;
45
+ return [stage.x, stage.y, right, stage.y, right, top, stage.x, top];
46
+ }
47
+
48
+ export function a50StageBoxIsTheStage({ fail, skip }: Verdicts, facts: StageBoxFacts): void {
49
+ const { asked } = facts;
50
+ if (asked === null) return skip(CODE, facts.why);
51
+ const where = `the stage box (slot "${asked.slot}", attachment "${asked.attachment}")`;
52
+ const { x, y, width, height } = asked.stage;
53
+ const required = `the stage ${x},${y} ${width}x${height}`;
54
+ if (!facts.slot) return fail(CODE, `${where}: the skeleton has no slot "${asked.slot}", so a consumer finds no stage — ${required} — in it`);
55
+ const held = facts.held;
56
+ if (held === null) {
57
+ return fail(CODE, `${where}: the default skin holds no attachment "${asked.attachment}" on slot "${asked.slot}", so a consumer finds no stage — ${required} — in it`);
58
+ }
59
+ if (held.type !== 'boundingbox') {
60
+ return fail(CODE, `${where}: the attachment is a ${held.type}, not a bounding box — ${required} travels as a boundingbox attachment, which every runtime returns by slot and name`);
61
+ }
62
+ if (held.weighted) {
63
+ return fail(CODE, `${where}: the box's vertices are bound to bones, and the stage is four unweighted corners in the root's space — ${required}`);
64
+ }
65
+ const corners = stageCorners(asked.stage);
66
+ if (held.stored.length !== corners.length) {
67
+ return fail(CODE, `${where}: the box holds ${held.stored.length / 2} vertices, and the stage is 4 corners — ${required}`);
68
+ }
69
+ let faults = 0;
70
+ for (let i = 0; i < corners.length; i += 2) {
71
+ const want = [Math.fround(corners[i]), Math.fround(corners[i + 1])];
72
+ const found = [held.stored[i], held.stored[i + 1]];
73
+ if (found[0] !== want[0] || found[1] !== want[1]) {
74
+ faults++;
75
+ fail(CODE, `${where}: vertex ${i / 2} is stored as (${found[0]}, ${found[1]}) and the stage's corner is (${want[0]}, ${want[1]}) — ${required}, bottom-left first and counter-clockwise`);
76
+ }
77
+ }
78
+ if (faults > 0) return;
79
+ if (typeof held.world === 'string') return fail(CODE, `${where}: its setup pose cannot be read — ${held.world}`);
80
+ // The runtime's frame of a root that states no transform, as the core computes it.
81
+ const frame = worldTransforms([{ name: 'root' }]).get('root');
82
+ if (frame === undefined) throw new Error('internal: the core posed no frame for an unrotated root');
83
+ for (let i = 0; i < corners.length; i += 2) {
84
+ const cx = Math.fround(corners[i]);
85
+ const cy = Math.fround(corners[i + 1]);
86
+ const want = [headerBoxNumber(cx * frame.a + cy * frame.b + frame.worldX), headerBoxNumber(cx * frame.c + cy * frame.d + frame.worldY)];
87
+ const found = [headerBoxNumber(held.world[i]), headerBoxNumber(held.world[i + 1])];
88
+ if (found[0] !== want[0] || found[1] !== want[1]) {
89
+ fail(
90
+ CODE,
91
+ `${where}: vertex ${i / 2} poses at (${found[0]}, ${found[1]}) at the setup pose and the stage's corner poses at ` +
92
+ `(${want[0]}, ${want[1]}) on an unrotated root, on the header's grid — something moves the box's bone at setup ` +
93
+ '(a constraint, or a pose the root does not state), so the box a consumer reads is not the stage',
94
+ );
95
+ }
96
+ }
97
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * The stage box a rig asked for and what the skeleton holds there (issue
3
+ * #1168) — what `A50_STAGE_BOX_IS_THE_STAGE` reads.
4
+ *
5
+ * ⭐ **The question is asked by the model document and answered by the
6
+ * skeleton.** The document states which slot and attachment carry the stage
7
+ * (`stage.box`, `ModelStage.box`) and the stage they were written from; a Spine
8
+ * file carries no such statement, so an export, a bare directory and a
9
+ * document that states no box all ask nothing and the body SKIPs, naming which.
10
+ * What answers is read off the skeleton: through spine-core's loaded
11
+ * `SkeletonData` and a skeleton posed at setup (`spineStageBox` in
12
+ * `../../validate.ts`), or through the document's records and rigc's core
13
+ * poser (`../model/stage_box.ts`).
14
+ *
15
+ * Links nothing from the runtime.
16
+ */
17
+
18
+ /** The box the document says the rig asked for, and the stage it was written from. */
19
+ export interface StageBoxAsked {
20
+ readonly slot: string;
21
+ readonly attachment: string;
22
+ readonly stage: { readonly x: number; readonly y: number; readonly width: number; readonly height: number };
23
+ }
24
+
25
+ /** The attachment the slot carries under the asked name, in the `default` skin. */
26
+ export interface StageBoxHeld {
27
+ /**
28
+ * The attachment's type in the format's words — `boundingbox`, `region`,
29
+ * `mesh`, `clipping`, `path`, `point` — as the runtime loads it: a linked
30
+ * mesh loads as a `mesh`, so both suppliers spell it so.
31
+ */
32
+ readonly type: string;
33
+ /** Whether its vertices are bound to bones (a weighted run) rather than stated in the slot bone's space. */
34
+ readonly weighted: boolean;
35
+ /** The vertex numbers it holds as loaded — `x, y` per vertex for an unweighted box. Empty for a type that holds none. */
36
+ readonly stored: readonly number[];
37
+ /**
38
+ * Its world vertices at the setup pose — constraints applied, no physics,
39
+ * no skin set — `x, y` per vertex; or why there are none (its bone is
40
+ * inactive, the setup pose cannot be posed).
41
+ */
42
+ readonly world: readonly number[] | string;
43
+ }
44
+
45
+ /** What A50 reads. */
46
+ export interface StageBoxFacts {
47
+ /** The box the rig asked for, or `null` where nothing asks — and then `why` says what asked nothing. */
48
+ readonly asked: StageBoxAsked | null;
49
+ /** The SKIP's reason where `asked` is `null`; empty otherwise. */
50
+ readonly why: string;
51
+ /** Whether the skeleton has a slot of the asked name. */
52
+ readonly slot: boolean;
53
+ /** The `default` skin's attachment of the asked name on that slot, or `null` for none (no default skin, nothing under that name). */
54
+ readonly held: StageBoxHeld | null;
55
+ }
56
+
57
+ /**
58
+ * The SKIP wherever nothing asks: a `/3` document whose stage states no box, a
59
+ * document that declares no stage, a `/2` or `/1` document, or none at all (an
60
+ * export, a bare directory). One sentence for all of them, because the rule
61
+ * reads one fact — that no box was asked for — and two suppliers that differ
62
+ * only in whether a caller handed the document over must print one line.
63
+ */
64
+ export const SKIP_NO_STAGE_BOX =
65
+ 'nothing asks for a stage box: no rigc-compiled/3 model document beside this skeleton states one (stage.box, from the rig spec\'s skeleton.stageBox) — a rig that does not ask, or an export, carries no stage in its Spine files to measure';
@@ -97,6 +97,7 @@ export const ASSERTION_KIND: Readonly<Record<string, AssertionKind>> = {
97
97
  A47_IK_CONSTRAINT_NOT_MUTED_THROUGHOUT: 'validity',
98
98
  A48_TRANSFORM_CONSTRAINT_NOT_MUTED_THROUGHOUT: 'validity',
99
99
  A49_PACKED_FOOTPRINTS_DO_NOT_OVERLAP: 'renderer',
100
+ A50_STAGE_BOX_IS_THE_STAGE: 'validity',
100
101
  };
101
102
 
102
103
  /** Whether `profile` runs a rule of `kind`: `spine` runs validity alone, `spine-html` every kind. */
@@ -63,6 +63,9 @@ import { a38SkinMembersAreSkinRequired } from '../bodies/a38.ts';
63
63
  import { a06AtlasPageSizeMatchesPng } from '../bodies/a06.ts';
64
64
  import { a19OverlayPngsHaveAlpha } from '../bodies/a19.ts';
65
65
  import { a49PackedFootprintsDoNotOverlap } from '../bodies/a49.ts';
66
+ import { a50StageBoxIsTheStage } from '../bodies/a50.ts';
67
+ import { modelStageBox } from './stage_box.ts';
68
+ import type { StageBoxFacts } from '../facts/stage_box.ts';
66
69
  import { a27RegionNameMatchesPageFilename } from '../bodies/a27.ts';
67
70
  import { modelAnimatedBones } from './animated_bones.ts';
68
71
  import { modelSkinMembers } from './skin_members.ts';
@@ -193,6 +196,8 @@ export interface ModelSupply {
193
196
  sliderComposition: (read: ReadDocument) => SliderCompositionFacts;
194
197
  constraintTargets: (read: ReadDocument) => ConstraintTargetFacts;
195
198
  steppedPoses: (read: ReadDocument) => SteppedPoseFacts;
199
+ /** The stage box the document asks for and what its default skin holds there (issue #1168, `./stage_box.ts`). */
200
+ stageBox: (read: ReadDocument) => StageBoxFacts;
196
201
  /** The rig info the document declares (issue #1054, `./declared.ts`); a caller's `rig` beside it is refused by name. */
197
202
  rigInfo: (read: ReadDocument, input: ModelValidateInput) => RigInfo;
198
203
  /** Each animation's declared duration as the document states it (issue #1054); a caller's `declaredDurations` beside it is refused by name. */
@@ -223,6 +228,7 @@ export const MODEL_SUPPLY: ModelSupply = {
223
228
  sliderComposition: modelSliderComposition,
224
229
  constraintTargets: modelConstraintTargets,
225
230
  steppedPoses: modelSteppedPoses,
231
+ stageBox: modelStageBox,
226
232
  rigInfo: (read, input) => {
227
233
  refuseDeclaredBeside(read, input, 'rig');
228
234
  return modelRigInfo(read);
@@ -306,6 +312,7 @@ export const MOVED_ASSERTIONS: readonly MovedAssertion[] = [
306
312
  { code: 'A06_ATLAS_PAGE_SIZE_MATCHES_PNG', run: (v, read, input, supply) => a06AtlasPageSizeMatchesPng(v, supply.atlasRegions(read, input), input, input.profile === 'spine-html'), unread: SKIP_NO_ATLAS },
307
313
  { code: 'A19_OVERLAY_PNGS_HAVE_ALPHA', run: (v, read, input, supply) => a19OverlayPngsHaveAlpha(v, supply.atlasRegions(read, input), supply.stage(read, input), supply.skinEntries(read), { atlasDir: input.atlasDir, rig: supply.rigInfo(read, input) }), unread: SKIP_NO_ATLAS },
308
314
  { code: 'A49_PACKED_FOOTPRINTS_DO_NOT_OVERLAP', run: (v, read, input, supply) => a49PackedFootprintsDoNotOverlap(v, supply.atlasRegions(read, input), supply.regionJoins(read), supply.meshes(read)), unread: SKIP_NO_ATLAS },
315
+ { code: 'A50_STAGE_BOX_IS_THE_STAGE', run: (v, read, _input, supply) => a50StageBoxIsTheStage(v, supply.stageBox(read)), unread: SKIP_NO_MODEL },
309
316
  { code: 'A24_AXIS_SPACE_STROKE', run: (v, read, input, supply) => a24AxisSpaceStroke(v, supply.boneTimelines(read), { rig: supply.rigInfo(read, input) }), unread: SKIP_NO_MODEL, beforeTheParse: true },
310
317
  { code: 'A25_DETACHED_BONE_PARENTAGE', run: (v, read, input, supply) => a25DetachedBoneParentage(v, supply.skeletonRoster(read), { rig: supply.rigInfo(read, input) }), unread: SKIP_NO_MODEL, beforeTheParse: true },
311
318
  { code: 'A26_SLOT_DRAW_ORDER', run: (v, read, input, supply) => a26SlotDrawOrder(v, supply.skeletonRoster(read), { rig: supply.rigInfo(read, input) }), unread: SKIP_NO_MODEL, beforeTheParse: true },
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The model side's supply of `StageBoxFacts` (issue #1168): the box the
3
+ * document's `stage.box` asks for, and what the document's `default` skin
4
+ * holds there — its record as the runtime would load it, and its world
5
+ * vertices at the setup pose as rigc's core poses them (`poseSetup` under no
6
+ * skin, constraints applied, then `worldVertices`, the function every vertex
7
+ * attachment of the core is posed by — no second arithmetic for the box).
8
+ *
9
+ * A `/2` or `/1` document states no stage and asks nothing; a `/3` document
10
+ * whose stage states no box asks nothing, and neither does one that declares no
11
+ * stage. The core's poses are held to spine-core's at tolerance 0 by
12
+ * `tools/core_gate.ts`, which is what lets this side and the round trip print
13
+ * one line.
14
+ *
15
+ * Links nothing from the runtime.
16
+ */
17
+ import { activeBones, poseSetup, rawNumber, underNoSkin } from '../../core/index.ts';
18
+ import { worldVertices } from '../../core/vertices.ts';
19
+ import { SKIP_NO_STAGE_BOX, type StageBoxFacts, type StageBoxHeld } from '../facts/stage_box.ts';
20
+ import type { ReadDocument } from './parse.ts';
21
+
22
+ export function modelStageBox(read: ReadDocument): StageBoxFacts {
23
+ const doc = read.doc;
24
+ const stage = doc.stated?.stage;
25
+ if (doc.stated === null || stage === undefined || stage === null) return { asked: null, why: SKIP_NO_STAGE_BOX, slot: false, held: null };
26
+ if (stage.box === undefined) return { asked: null, why: SKIP_NO_STAGE_BOX, slot: false, held: null };
27
+ const asked = { slot: stage.box.slot, attachment: stage.box.attachment, stage: { x: stage.x, y: stage.y, width: stage.width, height: stage.height } };
28
+ const slot = doc.slots.find((s) => s.name === asked.slot);
29
+ if (slot === undefined) return { asked, why: '', slot: false, held: null };
30
+ const record = doc.skins.find((k) => k.name === 'default')?.attachments[asked.slot]?.[asked.attachment];
31
+ if (record === undefined) return { asked, why: '', slot: true, held: null };
32
+ const g = record.geometry;
33
+ const type = record.kind === 'linkedmesh' ? 'mesh' : record.kind;
34
+ if (g === undefined || g.kind !== 'boundingbox') return { asked, why: '', slot: true, held: { type, weighted: false, stored: [], world: '' } };
35
+ const vertices = g.vertices;
36
+ const stored = vertices.weighted ? [] : vertices.xy.map(Math.fround);
37
+ const held: StageBoxHeld = { type, weighted: vertices.weighted, stored, world: poseWorld(read, slot.bone, vertices) };
38
+ return { asked, why: '', slot: true, held };
39
+ }
40
+
41
+ /** The box's world vertices at the setup pose under no skin, or why there are none. */
42
+ function poseWorld(read: ReadDocument, bone: string, vertices: Parameters<typeof worldVertices>[0]): number[] | string {
43
+ const view = underNoSkin(read.doc);
44
+ if (!activeBones(view).has(bone)) return `its bone "${bone}" is inactive with no skin set, so nothing poses it`;
45
+ const posed = poseSetup(view, { round: rawNumber });
46
+ const world = posed.world;
47
+ if (world === null) return posed.absent.find(([block]) => block === 'setup.bones')?.[1] ?? "rigc's core does not pose this setup pose";
48
+ const frame = world.get(bone);
49
+ if (frame === undefined) return `its bone "${bone}" has no setup world transform`;
50
+ return worldVertices(vertices, frame, world);
51
+ }
package/src/atlas.ts CHANGED
@@ -249,8 +249,13 @@ export function pageFootprint(region: { width: number; height: number; degrees:
249
249
  * field". Four values maximum, because that is where the runtime stops (`if (i
250
250
  * === 4) return 4`) and a fifth would silently mean something here that it does
251
251
  * not mean there.
252
+ *
253
+ * Exported for `src/repack.ts` (issue #1169), which reads the keys a page and a
254
+ * region state — the ones this parser drops (`filter`, `format`, `repeat`,
255
+ * `split`, `pad`) included — with this function rather than a second spelling
256
+ * of the format, and holds what it walked to `parseAtlasText`'s regions.
252
257
  */
253
- function readEntry(line: string | null): { key: string; values: string[] } | null {
258
+ export function readEntry(line: string | null): { key: string; values: string[] } | null {
254
259
  if (line === null) return null;
255
260
  const trimmed = line.trim();
256
261
  if (trimmed.length === 0) return null;
@@ -34,6 +34,7 @@ import { estimatePose, poseLines, type PoseOptions } from '../pose.ts';
34
34
  import { BACKGROUND, contactSheet, type Frame, FRAMES_SIDECAR, FRAMES_SPEC, type FrameSet, type FramesSidecar, framingViewport, GEOMETRY_FILE, geometryFileOf, geometryText, loadCandidate, POSER_NAMES, type PoserName, PROTOCOL_FPS, refuseUnchosen, renderFrame, sampleAll, sampleAnimation, SETUP_POSE_DIR, SHEET_FILE, SHEET_TILE, sidecarViewport, type SkeletonFacts, type SlotSubset, SlotSubsetError, throughPoser } from '../render_shared.ts';
35
35
  import { type CompileResult } from '../types.ts';
36
36
  import { attachmentRegionJoins } from '../region_joins.ts';
37
+ import { cmdRepack } from './repack.ts';
37
38
  import { ATLAS_ABSENT, COMMANDS, type CommandRun, resolveBuild, type BuildGate, runBuild, PACKAGE_ROOT, DEFAULT_CHAINFIT_OUT, DEFAULT_POSE_OUT, DEFAULT_SKILLS_DIR, ExplainError, meshBudget, meshDepthNote, meshFit, meshInfluenceNote, PAGE_GRID_UNLOCATED, readAnimationFlag, readJsonFile, readPoserFlag, readSkeletonText, readVersion, resolveCut, resolveDrawable, runCheck, SkillsInstallError, STAGELESS_FRAMING, UsageError, writeJson, documentStageBeside } from './shared.ts';
38
39
  import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
39
40
  import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
@@ -1299,6 +1300,7 @@ export function cmdIngest(flags: Record<string, string>, positional: string[]):
1299
1300
  if (documentStage !== undefined) {
1300
1301
  console.log(` .. stage ${documentStage === null ? 'none declared' : `${documentStage.width} x ${documentStage.height}`} — read from the ${MODEL_DOCUMENT_FILE} beside it (its spine.sha256 is this skeleton's); the header's box is the setup-pose bounding box`);
1301
1302
  }
1303
+ if (flags['stage-box'] !== undefined) console.log(` .. stage read from the bounding box in slot "${flags['stage-box']}" (--stage-box); the header's box is the setup-pose bounding box`);
1302
1304
  let result: { rig: unknown; motion: unknown; findings: IngestFinding[] };
1303
1305
  try {
1304
1306
  result = ingest(readJsonFile(skeletonPath), {
@@ -1307,6 +1309,7 @@ export function cmdIngest(flags: Record<string, string>, positional: string[]):
1307
1309
  art,
1308
1310
  images: specImages,
1309
1311
  stage,
1312
+ ...(flags['stage-box'] === undefined ? {} : { stageBox: flags['stage-box'] }),
1310
1313
  source: basename(skeletonPath),
1311
1314
  version: readVersion(),
1312
1315
  });
@@ -1552,8 +1555,11 @@ export function cmdSkills(flags: Record<string, string>, positional: string[]):
1552
1555
  * the pages where the compile measured them — the files the round trip's own
1553
1556
  * A17 reads on `cli.ts` — and A18 compares the documents as written, the
1554
1557
  * copies' names in them. Under `--pack` the two are one text.
1558
+ *
1559
+ * Exported for the selftest's `RPK` plants (issue #1169), which run `repack`
1560
+ * through this entry's dispatch with a hook between the lift and the build.
1555
1561
  */
1556
- const MODEL_AND_TEXT_GATE: BuildGate = {
1562
+ export const MODEL_AND_TEXT_GATE: BuildGate = {
1557
1563
  heading: (profile) =>
1558
1564
  ` .. validate (the model side over the document + the round trip's rules restated over the emitted text, profile ${profile}; this entry links no spine-core, so the round trip does not run)`,
1559
1565
  run: ({ result, atlasText, atlasDir, modelText, reEmit, profile }) => {
@@ -1592,10 +1598,13 @@ const MODEL_AND_TEXT_GATE: BuildGate = {
1592
1598
  * The bodies the second entry runs under a name whose full-entry body is the
1593
1599
  * runtime's (`runtime.core` in `COMMANDS`, issue #1060) — registered by
1594
1600
  * `cli_core.ts` alone, beside `CORE_COMMAND_RUNS`. `build` is `runBuild` with
1595
- * this entry's gate: it writes what `cli.ts build` writes.
1601
+ * this entry's gate: it writes what `cli.ts build` writes. `repack` (issue
1602
+ * #1169) is `./repack.ts`'s body with the same gate, for the same reason: it
1603
+ * is `build --pack` run over a build's own lifted regions.
1596
1604
  */
1597
1605
  export const CORE_ENTRY_RUNS: Readonly<Record<string, CommandRun>> = {
1598
1606
  build: ({ flags }) => runBuild(flags, MODEL_AND_TEXT_GATE),
1607
+ repack: (args) => cmdRepack(args, MODEL_AND_TEXT_GATE),
1599
1608
  };
1600
1609
 
1601
1610
  /**