spine-rigc 2.1.3 → 2.3.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/README.md +8 -2
- package/docs/AUTHORING.md +69 -22
- package/docs/INGEST.md +44 -25
- package/package.json +1 -1
- package/src/assertions/emitted/index.ts +28 -1
- package/src/assertions/facts/stage.ts +6 -3
- package/src/assertions/model/index.ts +41 -1
- package/src/cli/core_commands.ts +15 -8
- package/src/cli/shared.ts +70 -3
- package/src/cli/spine_commands.ts +13 -7
- package/src/compile.ts +100 -4
- package/src/core/raw.ts +101 -1
- package/src/diff.ts +71 -65
- package/src/emit_spine.ts +26 -12
- package/src/ingest.ts +38 -9
- package/src/model.ts +14 -10
- package/src/rig.ts +9 -2
- package/src/types.ts +4 -2
- package/src/validate.ts +41 -7
- package/tools/editor_roundtrip.ts +1 -1
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,
|
|
140
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
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 ---
|