spine-rigc 2.1.2 → 2.2.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.
package/src/cli/shared.ts CHANGED
@@ -22,11 +22,11 @@ import { MAX_CANDIDATES, MIN_CANDIDATES } from '../ballot.ts';
22
22
  import { BONEDIST_SPEC, IDENTITY_CORRESPONDENCE } from '../correspondence.ts';
23
23
  import { ANCHOR_MAX_RESIDUAL, ANCHOR_MAX_UNEXPLAINED, DEFAULT_HINGE_MAX, DEFAULT_HINGE_MIN, DEFAULT_MIN_LEVER_PX, DEFAULT_MIN_VISIBLE, DEFAULT_PASSES } from '../chainfit.ts';
24
24
  import { checkAgainstFrames, type CheckOptions, CheckPlates, type CheckReport } from '../check.ts';
25
- import { compile, CompileError, droppedStateReason, type CompileOptions } from '../compile.ts';
25
+ import { compile, CompileError, droppedStateReason, headerBoundsOf, type CompileOptions } from '../compile.ts';
26
26
  import { depthStepLevels, type FoldLimit, type TurnCeiling } from '../depth.ts';
27
27
  import { parseJsonWithPosition } from '../json-position.ts';
28
28
  import { RUNG_IDS } from '../ladder.ts';
29
- import { MODEL_DOCUMENT_FILE, modelDocument } from '../model.ts';
29
+ import { MODEL_DOCUMENT_FILE, MODEL_DOCUMENT_SPEC, modelDocument, spineFileSha256 } from '../model.ts';
30
30
  import { DEFAULT_MAX_RESIDUAL, DEFAULT_SCALE_MAX, DEFAULT_SCALE_MIN } from '../pose.ts';
31
31
  import {
32
32
  CandidateAtlasError,
@@ -42,7 +42,7 @@ import {
42
42
  import { BallotError } from '../ballot.ts';
43
43
  import { ChainFitError } from '../chainfit.ts';
44
44
  import { CheckError } from '../check.ts';
45
- import { IngestError } from '../ingest.ts';
45
+ import { IngestError, type IngestStage } from '../ingest.ts';
46
46
  import { NotAPngError } from '../png.ts';
47
47
  import { PoseError } from '../pose.ts';
48
48
  import { SpineRuntimeError, SPINE_SIDE_ABSENT } from '../spine_side.ts';
@@ -2082,6 +2082,72 @@ function runGate(
2082
2082
  });
2083
2083
  }
2084
2084
 
2085
+ /**
2086
+ * The model document `build` wrote beside a skeleton, when the one beside it
2087
+ * is that build's: a `skeleton.model.json` in the skeleton's directory whose
2088
+ * `spine.sha256` is the digest of exactly this skeleton text (the digest the
2089
+ * core poser checks before it poses a build, issue #968). Since issue #907 a
2090
+ * rigc build's header carries the setup-pose bounding box and its stage is the
2091
+ * document's, so a gate run over a directory reads A14's and A19's stage from
2092
+ * this document, as `build`'s own gate did, and `ingest` reads the rebuild's
2093
+ * stage from it (`documentStageBeside`); a skeleton with no such document
2094
+ * beside it — an export, or a document of another skeleton — is read off its
2095
+ * header, as before.
2096
+ */
2097
+ export function modelTextBeside(skeletonPath: string, skeletonText: string): string | undefined {
2098
+ const path = join(dirname(skeletonPath), MODEL_DOCUMENT_FILE);
2099
+ if (!existsSync(path)) return undefined;
2100
+ const text = readFileSync(path, 'utf8');
2101
+ let digest: unknown;
2102
+ try {
2103
+ const doc: unknown = JSON.parse(text);
2104
+ digest = typeof doc === 'object' && doc !== null ? (doc as { spine?: { sha256?: unknown } }).spine?.sha256 : undefined;
2105
+ } catch {
2106
+ return undefined;
2107
+ }
2108
+ return digest === spineFileSha256(skeletonText) ? text : undefined;
2109
+ }
2110
+
2111
+ /**
2112
+ * The stage a rigc build's model document states, for `ingest` (issue #907):
2113
+ * the `rigc-compiled/3` document beside the skeleton whose digest is that
2114
+ * skeleton's (`modelTextBeside`) — its `stage`, four numbers or `null` for a
2115
+ * rig that declared none. `undefined` — read the header, as before — for an
2116
+ * export or a bare file (no document), a document of another skeleton, a
2117
+ * `/2` or `/1` document (written when the header still was the stage), or a
2118
+ * `stage` that is not one of those two shapes.
2119
+ *
2120
+ * ⭐ Why `ingest` needs it: since #907 a rigc build's header carries the
2121
+ * setup-pose bounding box, and the stage is only in the document. Reading the
2122
+ * header as the stage would rebuild a rig whose stage is its own bounding box
2123
+ * — gallery/nod's 640x700 stage came back 640x725 — so `A14` and `A19` on the
2124
+ * rebuild would measure against a box the author never stated.
2125
+ */
2126
+ export function documentStageBeside(skeletonPath: string, skeletonText: string): IngestStage | null | undefined {
2127
+ const text = modelTextBeside(skeletonPath, skeletonText);
2128
+ if (text === undefined) return undefined;
2129
+ const doc = JSON.parse(text) as { spec?: unknown; stage?: unknown };
2130
+ if (doc.spec !== MODEL_DOCUMENT_SPEC || !('stage' in doc)) return undefined;
2131
+ const stage = doc.stage;
2132
+ if (stage === null) return null;
2133
+ if (typeof stage !== 'object') return undefined;
2134
+ const { x, y, width, height } = stage as Record<string, unknown>;
2135
+ return typeof x === 'number' && typeof y === 'number' && typeof width === 'number' && typeof height === 'number' ? { x, y, width, height } : undefined;
2136
+ }
2137
+
2138
+ /**
2139
+ * The line `build` prints when the rig declares a stage and the header carries
2140
+ * no setup-pose bounding box (issue #907): why — nothing drawn, a region with
2141
+ * no atlas rectangle, or a setup pose rigc's core leaves out
2142
+ * (`headerBoundsOf`). Silent where the box is written, or where the rig
2143
+ * declares no stage, which asked for no box.
2144
+ */
2145
+ function printHeaderBox(result: CompileResult): void {
2146
+ if (result.model.stage === null || typeof result.skeleton.skeleton.width === 'number') return;
2147
+ const { why } = headerBoundsOf(result.model, result.atlasText);
2148
+ if (why !== null) console.log(` .. header no setup-pose bounding box — ${why}`);
2149
+ }
2150
+
2085
2151
  /**
2086
2152
  * build — moved here unchanged from `./spine_commands.ts` (issue #1060) but
2087
2153
  * for its gate, which the entry hands in (`BuildGate`): both entries write
@@ -2092,6 +2158,7 @@ export function runBuild(flags: Record<string, string>, gate: BuildGate): void {
2092
2158
  printBuildHeader(label, opts);
2093
2159
  const result = compile(opts);
2094
2160
  printCompiled(opts, result);
2161
+ printHeaderBox(result);
2095
2162
 
2096
2163
  // The model's document is spelled before the gate, so the text A18 compares
2097
2164
  // is the text written, and a model the document cannot carry is refused
@@ -19,7 +19,7 @@ import { buildPreview, buildPreviewPanes, PLAYER_LINE, type PreviewGate, type Pr
19
19
  import { atlasPageNames } from '../render.ts';
20
20
  import { BoneDistError } from '../bonedist.ts';
21
21
  import { assertionCountForProfile, CLI_DEFAULT_PROFILE, reportLines, validate, type ValidateProfile } from '../validate.ts';
22
- import { type CliRefusal, type CommandRun, DEFAULT_BALLOT, PACKAGE_ROOT, DEFAULT_LEDGER, parseJsonNamed, readAnimationFlag, readJsonFile, readPackageMeta, readSkeletonText, readVersion, resolveBuild, resolveCut, resolveViewable, runCheck, spinePairOf, type BuildGate, runBuild, readProfile, STAGELESS_FRAMING, UsageError, writeJson } from './shared.ts';
22
+ import { type CliRefusal, type CommandRun, DEFAULT_BALLOT, PACKAGE_ROOT, DEFAULT_LEDGER, parseJsonNamed, readAnimationFlag, readJsonFile, readPackageMeta, readSkeletonText, readVersion, resolveBuild, resolveCut, resolveViewable, runCheck, spinePairOf, type BuildGate, runBuild, readProfile, STAGELESS_FRAMING, UsageError, writeJson, modelTextBeside } from './shared.ts';
23
23
  import { appendFileSync, existsSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs';
24
24
  import { dirname, join, resolve } from 'node:path';
25
25
 
@@ -83,12 +83,14 @@ export function cmdValidate(flags: Record<string, string>, positional: string[])
83
83
  const atlasText = readFileSync(atlasPath, 'utf8');
84
84
  const derived = derivedOpts ? compile(derivedOpts) : null;
85
85
 
86
+ const modelText = modelTextBeside(skeletonPath, skeletonText);
86
87
  const report = validate({
87
88
  skeletonText,
88
89
  atlasText,
89
90
  atlasDir: dirname(atlasPath),
90
91
  declaredDurations: derived?.declaredDurations,
91
92
  rig: derived?.rig,
93
+ ...(modelText === undefined ? {} : { modelText }),
92
94
  profile,
93
95
  });
94
96
  for (const line of reportLines(report)) console.log(line);
@@ -136,15 +138,18 @@ function skeletonAnimationNames(skeletonText: string, path: string): string[] {
136
138
  * takes it (issue #837).
137
139
  *
138
140
  * ⭐ The same call `cmdValidate` makes on a bare directory: the two texts, the
139
- * atlas's own directory, the default profile, and nothing a directory cannot
140
- * supply — no rig spec, no declared durations, no second compile. So `A09` and
141
+ * atlas's own directory, the default profile, the build's model document
142
+ * where the one beside the skeleton is its own (`modelTextBeside`, issue #907
143
+ * — the stage A14 and A19 read), and nothing a directory cannot supply — no
144
+ * rig spec, no declared durations, no second compile. So `A09` and
141
145
  * `A18` report SKIP here exactly as they do there, and the line is the line
142
146
  * that command prints for these files, not the one `build` printed for the
143
147
  * compile that wrote them. Measured on every run, because the page must not
144
148
  * carry a figure this run did not measure.
145
149
  */
146
- function previewGate(skeletonText: string, atlasText: string, atlasDir: string): PreviewGate {
147
- const lines = reportLines(validate({ skeletonText, atlasText, atlasDir, profile: CLI_DEFAULT_PROFILE }));
150
+ function previewGate(skeletonPath: string, skeletonText: string, atlasText: string, atlasDir: string): PreviewGate {
151
+ const modelText = modelTextBeside(skeletonPath, skeletonText);
152
+ const lines = reportLines(validate({ skeletonText, atlasText, atlasDir, ...(modelText === undefined ? {} : { modelText }), profile: CLI_DEFAULT_PROFILE }));
148
153
  const refusal = lines.find((line) => line.startsWith(' FAIL '));
149
154
  return {
150
155
  // `reportLines` ends on the summary by construction; the gutter is the
@@ -253,7 +258,7 @@ export function cmdPreview(flags: Record<string, string>, candidates: string[]):
253
258
  console.log(` .. page ${page.name.padEnd(28)} ${(page.bytes.length / 1024).toFixed(1)} KiB`);
254
259
  }
255
260
  if (!declaresSetupStage(skeletonHeaderOf(skeletonText))) console.log(` .. ${STAGELESS_FRAMING.preview}`);
256
- const gate = previewGate(skeletonText, atlasText, atlasDir);
261
+ const gate = previewGate(skeletonPath, skeletonText, atlasText, atlasDir);
257
262
  console.log(` .. gate ${gate.summary}`);
258
263
  if (gate.refusal !== null) {
259
264
  console.log(
@@ -540,7 +545,8 @@ export function cmdBench(flags: Record<string, string>, positional: string[]): v
540
545
  console.log('');
541
546
 
542
547
  console.log(` ── validate (profile ${profile}) ──`);
543
- const report = validate({ skeletonText, atlasText, atlasDir: dirname(atlasPath), profile });
548
+ const benchModel = modelTextBeside(skeletonPath, skeletonText);
549
+ const report = validate({ skeletonText, atlasText, atlasDir: dirname(atlasPath), ...(benchModel === undefined ? {} : { modelText: benchModel }), profile });
544
550
  for (const line of reportLines(report)) console.log(` ${line}`);
545
551
  console.log('');
546
552
 
package/src/compile.ts CHANGED
@@ -132,8 +132,11 @@ import { emitSkeleton, type SkeletonHeader } from './emit_spine.ts';
132
132
  import { PHYSICS_PARAMS, UNSTATED_REFERENCE_SCALE } from './keyorder.ts';
133
133
  import { curveLengthTable } from './core/constraints_path.ts';
134
134
  import { frameRegionName } from './core/uvs.ts';
135
+ import { setupBounds, type CoreBounds } from './core/raw.ts';
136
+ import { CoreInputError, readModel } from './core/index.ts';
135
137
  import {
136
138
  isModelVertexAttachment,
139
+ modelDocument,
137
140
  type CarriedFromCompileResult,
138
141
  type CompiledAnimation,
139
142
  type CompiledModel,
@@ -990,6 +993,30 @@ function onModelGrid(n: number): number {
990
993
  return f32(Math.round(n * 1e6) / 1e6);
991
994
  }
992
995
 
996
+ /**
997
+ * One number of the header's setup-pose bounding box as `build` writes it
998
+ * (issue #907): the double rigc's core computed — equal to spine-core's
999
+ * `getBounds` at tolerance 0 (`tools/core_gate.ts`'s bounds rows) — on the
1000
+ * model's 1e-6 grid at float32 (`onModelGrid`).
1001
+ *
1002
+ * ⭐ **Why the grid and not the bare float32.** A box edge that sits at the
1003
+ * setup pose's origin lands on the residue of a cancellation: gallery/flex's
1004
+ * and gallery/ride's lower edge is 1.1368683772161603e-13 — one ulp of 720 —
1005
+ * in rigc's core and in `getBounds` alike. A float32 keeps that residue (it is
1006
+ * a float in its own right), so the written header followed the platform's
1007
+ * libm to the last bit: with every libm-backed `Math` function nudged one ulp,
1008
+ * the header of those two rows moved, and with it their documents'
1009
+ * `spine.sha256` (`TB02`, which the gallery bases on two machines stand on).
1010
+ * On the grid the residue is the 0 it is, and the box is otherwise the box to
1011
+ * within half a millionth of a unit — three orders below the 0.0071 by which
1012
+ * the editor's own header differs from `getBounds` on the twelve example
1013
+ * exports. Exported for the two readers that hold a header to `getBounds`:
1014
+ * `tools/core_gate.ts` and the corpus suite (`withRuntimeBox`).
1015
+ */
1016
+ export function headerBoxNumber(n: number): number {
1017
+ return onModelGrid(n);
1018
+ }
1019
+
993
1020
  /**
994
1021
  * Of the decimals `a·10^scale` and `b·10^scale` (`a < b`), the one closer to
995
1022
  * the positive float `f`, compared exactly; a tie goes to the even digit.
@@ -2133,8 +2160,10 @@ export function compile(opts: CompileOptions): CompileResult {
2133
2160
  // does not hold — the rest of the header — and the editor's orders, the
2134
2161
  // same functions `compileModel` stated the model's `editorOrder` with: the
2135
2162
  // sorts are applied at emission, so the model's `skins` and `animations`
2136
- // keep the spec's own order.
2137
- const skeleton = emitSkeleton(model, header, EDITOR_ORDERS);
2163
+ // keep the spec's own order. The header's box is the setup-pose bounding
2164
+ // box rigc's core computes over the model (issue #907, `headerBoundsOf`),
2165
+ // never the stage.
2166
+ const skeleton = emitSkeleton(model, { ...header, bounds: headerBoundsOf(model, atlasText).box }, EDITOR_ORDERS);
2138
2167
  return {
2139
2168
  skeleton,
2140
2169
  skeletonText: `${JSON.stringify(skeleton, null, 2)}\n`,
@@ -2146,6 +2175,73 @@ export function compile(opts: CompileOptions): CompileResult {
2146
2175
  });
2147
2176
  }
2148
2177
 
2178
+ /**
2179
+ * The box the Spine header carries (issue #907): the setup-pose bounding box
2180
+ * — what the format says `skeleton.x`, `y`, `width`, `height` are — computed
2181
+ * by rigc's own core over the model (`setupBounds` in `src/core/raw.ts`,
2182
+ * whose header states each measured rule), each of the four on the model's
2183
+ * 1e-6 grid at float32 (`headerBoxNumber`).
2184
+ *
2185
+ * ⭐ **The stage is not this value and does not move.** `model.stage` — the
2186
+ * rig spec's `skeleton.width`/`height` or the manifest's crop, the working
2187
+ * area the coordinate transform reads (`src/transform.ts`) — stays the
2188
+ * model's and the document's; until issue #907 the emitter copied it into the
2189
+ * header, which is the defect the issue names: a reader who takes the header
2190
+ * for what the format says it is got the crop.
2191
+ *
2192
+ * `null` — no box in the header — in two cases, each a statement rather than
2193
+ * a number nobody measured:
2194
+ *
2195
+ * - the rig declares no stage (`"width": null, "height": null`, issue
2196
+ * #578): the header carried no box before, and the input that asked for
2197
+ * none keeps getting none;
2198
+ * - nothing is drawn at the setup pose: a box around no vertex does not
2199
+ * exist (`getBounds` returns an infinite offset and a negative-infinite
2200
+ * size for it);
2201
+ * - a region the build has no atlas rectangle for (`atlas: null`,
2202
+ * `ModelRegionAttachment.atlas`): its corners read the trim and original
2203
+ * size, which nobody measured, and the build is refused by name anyway —
2204
+ * the emitted atlas has no such region, and
2205
+ * `A08_REGION_NAMES_MATCH_ATTACHMENTS` names it. Refusing the compile
2206
+ * here instead would replace that named failure with a different one;
2207
+ * - a setup pose rigc's core leaves out (`CoreInputError` — measured on the
2208
+ * tree: a slider whose animation keys an ik constraint's timelines, which
2209
+ * the core's constraint cut does not pose). The entry without spine-core
2210
+ * has no other poser, and both entries must write the same bytes, so the
2211
+ * box is absent on both rather than present on one; refusing the compile
2212
+ * would refuse a rig the gate passes. `tools/core_gate.ts` reports such a
2213
+ * row SKIP naming the reason, never IDENTICAL.
2214
+ *
2215
+ * Every case but the first comes back with `why`, the sentence `build` prints
2216
+ * beside its output (`printHeaderBox` in `src/cli/shared.ts`), so an absent
2217
+ * box is never silent.
2218
+ *
2219
+ * The core poses the model through its document — the text `build` writes
2220
+ * beside the pair, read back by `readModel` — so the box is of the document a
2221
+ * reader holds, not of an object only this process saw. The document's
2222
+ * `spine.sha256` names the skeleton written beside it, which does not exist
2223
+ * yet; the digest of an empty text stands in for it here, and nothing the box
2224
+ * depends on reads it.
2225
+ */
2226
+ export function headerBoundsOf(model: CompiledModel, atlasText: string): { box: ModelStage | null; why: string | null } {
2227
+ if (model.stage === null) return { box: null, why: null };
2228
+ const unplaced = model.skins.flatMap((skin) =>
2229
+ Object.entries(skin.attachments).flatMap(([slot, table]) =>
2230
+ Object.entries(table).flatMap(([placeholder, entry]) => (entry.kind === 'region' && entry.atlas === null ? [`skin "${skin.name}" slot "${slot}" region "${placeholder}"`] : [])),
2231
+ ),
2232
+ );
2233
+ if (unplaced.length > 0) return { box: null, why: `${unplaced[0]} has no atlas rectangle, so its corners are not known — A08_REGION_NAMES_MATCH_ATTACHMENTS names it` };
2234
+ let box: CoreBounds | null;
2235
+ try {
2236
+ box = setupBounds(readModel(modelDocument(model, '', atlasText), 'the compiled model'));
2237
+ } catch (err) {
2238
+ if (err instanceof CoreInputError) return { box: null, why: `rigc's core does not pose this setup pose — ${err.message}` };
2239
+ throw err;
2240
+ }
2241
+ if (box === null) return { box: null, why: 'the setup pose draws nothing, and a box around no vertex does not exist' };
2242
+ return { box: { x: headerBoxNumber(box.x), y: headerBoxNumber(box.y), width: headerBoxNumber(box.width), height: headerBoxNumber(box.height) }, why: null };
2243
+ }
2244
+
2149
2245
  /**
2150
2246
  * What `compileModel` returns: the compiled model, and what a caller needs
2151
2247
  * beside it to gate it, write its document and — if it asks — emit it.
@@ -2154,7 +2250,7 @@ export interface CompiledBuild {
2154
2250
  /** The compiled model (`src/model.ts`), `stage` and `editorOrder` included (issue #1026). */
2155
2251
  model: CompiledModel;
2156
2252
  /** The rest of the Spine header the model does not hold (`SkeletonHeader`): what `compile` hands the emitter beside the model. */
2157
- header: SkeletonHeader;
2253
+ header: Omit<SkeletonHeader, 'bounds'>;
2158
2254
  /** The atlas text the compile measured its art against — `CompileResult.atlasText`. */
2159
2255
  atlasText: string;
2160
2256
  /** Declared durations, carried into the validator (rule 4) — `CompileResult.declaredDurations`. */
@@ -3532,7 +3628,7 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
3532
3628
  ? { x: rig.skeleton?.x ?? 0, y: rig.skeleton?.y ?? 0, width: stageWidth, height: stageHeight }
3533
3629
  : null;
3534
3630
  // What the skeleton carries that the model does not hold: the rest of the header.
3535
- const header: SkeletonHeader = {
3631
+ const header: Omit<SkeletonHeader, 'bounds'> = {
3536
3632
  spine: SPINE_VERSION,
3537
3633
  fps: rig.skeleton?.fps,
3538
3634
  images: skeletonImagesPath(rig.skeleton?.images, opts, outDir, partDirs),
package/src/core/raw.ts CHANGED
@@ -113,7 +113,7 @@
113
113
  * naming why (`CoreInputError`): a renderer handed a pose with a block
114
114
  * missing would draw a different picture in silence.
115
115
  */
116
- import { activeBones, CoreInputError, drawWalkOf, poseSetup, rawNumber, readColour, shownAttachment, sourceOfDoc, type CompiledDocument, type CorePlant, type CoreSlotRow } from './index.ts';
116
+ import { activeBones, CoreInputError, drawWalkOf, poseSetup, rawNumber, readColour, shownAttachment, sourceOfDoc, underNoSkin, type CompiledDocument, type CorePlant, type CoreSlotRow } from './index.ts';
117
117
  import { posedBoneWorld, posedSlots, type TimelinePlant } from './animation.ts';
118
118
  import { constraintsAbsentWhy, pathAnimationsWhy } from './constraints.ts';
119
119
  import { freshStepContext, steppedPreviousPassWhy } from './constraints_physics.ts';
@@ -406,3 +406,103 @@ export function walkIn(doc: CompiledDocument, animation: string, steps: readonly
406
406
  }
407
407
  return poses;
408
408
  }
409
+
410
+ // --- #907 the setup-pose bounding box: begin ---
411
+ /**
412
+ * The setup-pose bounding box (issue #907) — `setupBounds` below: the axis-aligned box around every
413
+ * region and mesh a skeleton draws at its setup pose, as spine-core's
414
+ * `Skeleton.getBounds(offset, size, temp)` returns it on a fresh skeleton —
415
+ * no skin set, `updateWorldTransform(Physics.none)` — which is what the Spine
416
+ * format says the header's `x`, `y`, `width` and `height` are.
417
+ *
418
+ * Every rule below was measured by running spine-core 4.3.13 through the
419
+ * tree's own loader, not read off its source:
420
+ *
421
+ * - **The view is "no skin set"** (`underNoSkin`, issue #1051): a skeleton
422
+ * `build` writes is loaded and bounded before anyone calls `setSkin`, so
423
+ * the slots resolve through the default skin alone and no skin's bones or
424
+ * constraint lists apply.
425
+ * - **Constraints applied.** The box is of the pose `updateWorldTransform`
426
+ * leaves, every ik, transform, path and slider constraint applied in the
427
+ * document's order — the pose `poseSetup` computes. The editor's own
428
+ * header is of that pose too: on `examples/spineboy/export/spineboy-pro.json`
429
+ * (seven ik and seven transform constraints) `getBounds` with the
430
+ * constraints applied is within 0.0035 of the header the editor wrote, and
431
+ * with them stripped it is 0.31 off in `y` and `height`.
432
+ * - **Physics.none.** A physics constraint applies nothing at the setup pose
433
+ * (the oracle's setup reading). Under `Physics.reset` spine-core's box was
434
+ * the same, to the bit, on all nineteen tree rows (two declare a physics constraint).
435
+ * - **What is counted.** A region's four corners and a mesh's (a linked
436
+ * mesh's) world vertices — the rows of the oracle's `setup.attachments`
437
+ * block, in full doubles. A clipping polygon, a bounding box, a path and a
438
+ * point are not counted (measured: each placed 5000 units out moved
439
+ * `getBounds` by nothing), and the box is not clipped: `getBounds` called
440
+ * without its optional clipper.
441
+ * - **A slot on an inactive bone is not counted** — measured: a skin-required
442
+ * bone 1000 units out, which only a named skin activates, carrying a region
443
+ * left `getBounds` at the other region's 20 units with no skin set, and 1020
444
+ * with the bone not skin-required. A bone that is not skin-required under an
445
+ * inactive parent is active and unposed, and its slot is counted at the
446
+ * vertices that pose gives; the core poses the same vertices (the oracle's
447
+ * `setup.attachments`, held at tolerance 0), and a probe of that shape read
448
+ * the same box both ways.
449
+ * - **The box.** `x`, `y` are the least `x` and `y` over every counted
450
+ * vertex and `width`, `height` the greatest less the least — the
451
+ * bottom-left corner in Spine's y-up world, not the top-left. In full
452
+ * doubles; `build` writes each on the model's 1e-6 grid at float32
453
+ * (`headerBoxNumber` in `src/compile.ts`, whose comment says why).
454
+ * - **Nothing drawn is no box.** `getBounds` over no vertex returns an offset
455
+ * of `+Infinity` and a size of `-Infinity`, which is not a box; this
456
+ * returns `null` for it, and the emitter writes no box rather than a number
457
+ * nobody measured.
458
+ *
459
+ * Pure, like the rest of `src/core/`: it links nothing from the runtime, and
460
+ * `tools/core_gate.ts` holds it to `getBounds` at tolerance 0 over the corpus
461
+ * (its `BOUNDS` rows).
462
+ */
463
+
464
+ /** The setup-pose box: its bottom-left corner and its extent, in Spine world units, in full doubles. */
465
+ export interface CoreBounds {
466
+ x: number;
467
+ y: number;
468
+ width: number;
469
+ height: number;
470
+ }
471
+
472
+ /**
473
+ * The setup-pose bounding box of a model document (the header's rules), or
474
+ * `null` where nothing is drawn. Refused by name — `CoreInputError` — where
475
+ * the core leaves the setup attachments out, since a box over the rest would
476
+ * be a box of a different pose.
477
+ */
478
+ export function setupBounds(doc: CompiledDocument, plant: CorePlant = {}): CoreBounds | null {
479
+ const view = underNoSkin(doc);
480
+ const posed = poseSetup(view, { ...plant, round: rawNumber });
481
+ const rows = posed.setup.attachments;
482
+ if (rows === null) {
483
+ const why = posed.absent.find(([block]) => block === 'setup.attachments')?.[1] ?? 'the setup attachments were not posed';
484
+ throw new CoreInputError(`the setup-pose bounding box cannot be computed: ${why}`);
485
+ }
486
+ const active = activeBones(view);
487
+ const boneOf = new Map(view.slots.map((s) => [s.name, s.bone]));
488
+ let minX = Infinity;
489
+ let minY = Infinity;
490
+ let maxX = -Infinity;
491
+ let maxY = -Infinity;
492
+ for (const [slot, attachment, , vertices] of rows) {
493
+ const bone = boneOf.get(slot);
494
+ if (bone === undefined || !active.has(bone)) continue;
495
+ for (let i = 0; i + 1 < vertices.length; i += 2) {
496
+ const x = vertices[i];
497
+ const y = vertices[i + 1];
498
+ if (x === null || y === null) throw new CoreInputError(`the setup-pose bounding box cannot be computed: slot "${slot}" attachment "${attachment}" vertex ${i / 2} is not finite`);
499
+ minX = Math.min(minX, x);
500
+ minY = Math.min(minY, y);
501
+ maxX = Math.max(maxX, x);
502
+ maxY = Math.max(maxY, y);
503
+ }
504
+ }
505
+ if (minX === Infinity) return null;
506
+ return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
507
+ }
508
+ // --- #907 the setup-pose bounding box: end ---