spine-rigc 1.6.2 → 1.8.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.
Files changed (39) hide show
  1. package/cli.ts +166 -60
  2. package/docs/AUTHORING.md +25 -16
  3. package/package.json +1 -1
  4. package/src/assertions/bodies/a03.ts +27 -0
  5. package/src/assertions/bodies/a11.ts +15 -0
  6. package/src/assertions/bodies/a17.ts +26 -0
  7. package/src/assertions/bodies/a45.ts +172 -0
  8. package/src/assertions/facts/atlas_pages.ts +19 -0
  9. package/src/assertions/facts/skin_entries.ts +35 -0
  10. package/src/assertions/facts/slot_colour.ts +43 -0
  11. package/src/assertions/harness.ts +109 -0
  12. package/src/assertions/kinds.ts +102 -0
  13. package/src/assertions/model/atlas_pages.ts +15 -0
  14. package/src/assertions/model/index.ts +130 -0
  15. package/src/assertions/model/parse.ts +136 -0
  16. package/src/assertions/model/skin_entries.ts +58 -0
  17. package/src/assertions/model/slot_colour.ts +80 -0
  18. package/src/assertions/reasons.ts +127 -0
  19. package/src/assertions/values.ts +39 -0
  20. package/src/atlas.ts +26 -16
  21. package/src/check.ts +70 -47
  22. package/src/compile.ts +118 -93
  23. package/src/core/constraints_path.ts +60 -38
  24. package/src/core/constraints_physics.ts +22 -4
  25. package/src/core/index.ts +102 -15
  26. package/src/core/uvs.ts +25 -5
  27. package/src/core/world.ts +80 -24
  28. package/src/deformmeasure.ts +383 -157
  29. package/src/deformreport.ts +3 -0
  30. package/src/deformstructure.ts +335 -0
  31. package/src/emit.ts +17 -6
  32. package/src/ingest.ts +18 -13
  33. package/src/keyorder.ts +2 -2
  34. package/src/model.ts +114 -13
  35. package/src/render.ts +520 -59
  36. package/src/render_core.ts +178 -21
  37. package/src/transform.ts +113 -128
  38. package/src/types.ts +6 -5
  39. package/src/validate.ts +200 -490
@@ -49,6 +49,9 @@ function sameKeyTime(specTime: number, loaded: number): boolean {
49
49
  export function deformReportBlock(survey: DeformSurvey, deformTransforms: CompileResult['deformTransforms'], exempt: ReadonlySet<string>): string[] {
50
50
  if (survey.timelines === 0) return [];
51
51
  const out: string[] = ['', 'deform (what each key does to the geometry — figures with names, never a bar; issue #316)'];
52
+ // A survey that fell back from the reader it was meant to use says so where it is read (issue #1019): the
53
+ // figures are the same survey either way, and a fallback nobody can see is a second opinion nobody asked for.
54
+ if (survey.source.why !== null) out.push(` .. read and posed through ${survey.source.used}: ${survey.source.why}`);
52
55
  // The legend costs six lines and is worth them exactly once — on a report that
53
56
  // has figures in it. A bounding box or a clipping polygon deformed and nothing
54
57
  // else gets the reason it has no figures and no essay about them.
@@ -0,0 +1,335 @@
1
+ /**
2
+ * What the deform survey reads of a skeleton's STRUCTURE — as opposed to what
3
+ * it reads off a posed one — behind one interface, and the model document's
4
+ * implementation of it (issue #1019, step 4b of issue #380).
5
+ *
6
+ * ## Why there are two readers
7
+ *
8
+ * The survey (`src/deformmeasure.ts`) poses a skeleton and measures what each
9
+ * deform key does to it, and its posing has run through rigc's own core since
10
+ * issue #969. Everything else it read was still spine-core's: the animations
11
+ * and their deform timelines, each key's time and expanded vertex array, the
12
+ * runtime's curve storage, which slot a timeline names and which other slots it
13
+ * reaches, which skin holds the mesh, and each slider's settings — so `explain`
14
+ * on a rigc build parsed the pair through the runtime to learn what the model
15
+ * document it carries already states. This module states those reads once
16
+ * (`SurveyStructure`), and gives the model document's reading of them
17
+ * (`modelStructure`). The runtime's reading stays in `src/deformmeasure.ts`,
18
+ * which links spine-core; the survey's body is written once, against the
19
+ * interface, and the survey's record names which pair it ran through.
20
+ *
21
+ * ## What the model states, and what is derived from it — each rule measured
22
+ *
23
+ * Every read below was compared with spine-core's, value for value at
24
+ * tolerance 0 (`tools/survey_hashes.ts structure` over the nineteen recipes
25
+ * `tools/emit_hashes.ts` generates; the deform-core suite's `DM12` over the
26
+ * gallery's builds and 80 hand-written rigs — sliders, second skins and linked
27
+ * meshes in slots of their own among them). Where the model holds the
28
+ * runtime's value as it is, the reader passes it on; where it holds the value
29
+ * the runtime COMPUTED from, the computation is one rigc already runs — the
30
+ * core's, measured against the runtime for posing, or the emitter's own order —
31
+ * and not a second copy. Each derivation, with how often the stated value alone
32
+ * would have been the runtime's on the nineteen:
33
+ *
34
+ * - **A key's time** is the float32 of the stated time (`CoreDeformKey.time`,
35
+ * which the core's reader stores) — the runtime's `frames` are a
36
+ * `Float32Array`. The stated double was the runtime's on 43 of 88 keys.
37
+ * - **A key's vertex array** is what the runtime holds for the key, not the
38
+ * run the document states: positions unweighted (each the float32 sum of the
39
+ * setup coordinate and the offset), offsets weighted (each float32), the run
40
+ * placed at its `offset` into the attachment's whole array and zeros (setup
41
+ * positions, unweighted) around it — `heldArray` in `./core/deform.ts`. The
42
+ * stated run was the runtime's array on 0 of 88.
43
+ * - **A Bézier segment's polyline** is the nine points the runtime samples into
44
+ * its curve storage: the cubic through the key's four handles, from the key's
45
+ * stated time at fraction 0 to the next key's at a far end of `0.99999999` —
46
+ * `bezierPolyline` with `DEFORM_CURVE_END`, the core's deform curve. The four
47
+ * handles are, of course, never the eighteen numbers (0 of 23).
48
+ * - **The animations' order** is the Spine file's, which is not the model's:
49
+ * the emitter keys `animations` in the editor's order over the model's names
50
+ * (`editorAnimationOrder` in `src/compile.ts`, the one call `emitAnimations`
51
+ * is handed), and the survey iterates the file's order. The model's own order
52
+ * was the file's on 14 of 19 rows. Every other order the survey reads — a
53
+ * skin's, an animation's deform timelines, the sliders — the file keeps from
54
+ * the model.
55
+ * - **A slider's duration** is the runtime's (`CoreAnimationTimelines.duration`:
56
+ * the last key time of every timeline, float32), not the document's declared
57
+ * one, which was the runtime's on 1 of 2.
58
+ * - **The slots a deform reaches besides its own** (`timelineSlots`) are the
59
+ * slots of every linked mesh whose source is the deformed record and which
60
+ * plays its timelines (`timelines: true`) — the rule `./core/deform.ts`'s
61
+ * *Which slot a deform timeline moves* measured. The survey reads them as a
62
+ * set: whether any of them draws the mesh, and their slot timelines' key
63
+ * times. No corpus row has one; `DM12`'s linked rigs do, on 20 of 20.
64
+ *
65
+ * ⛔ Nothing here defaults a value the document does not state: a record the
66
+ * timeline names that the document does not hold, or an order the editor's
67
+ * comparator cannot settle, is a `CoreInputError` naming it — and the survey's
68
+ * seam then falls back to spine-core naming why, as the posing does.
69
+ *
70
+ * ## Purity
71
+ *
72
+ * Nothing from the Spine runtime package: what the runtime reader needs to
73
+ * carry beside these handles (the parsed objects themselves) is kept by that
74
+ * reader, in `src/deformmeasure.ts`.
75
+ */
76
+ import { CompileError } from './errors.ts';
77
+ import { editorAnimationOrder } from './compile.ts';
78
+ import { bezierPolyline } from './core/animation.ts';
79
+ import { DEFORM_CURVE_END, heldArray, type CoreAttachmentTimeline, type CoreDeformKey } from './core/deform.ts';
80
+ import type { DialBoneField } from './core/hooks.ts';
81
+ import { CoreInputError, type CompiledDocument, type CoreAnimation, type CoreAttachment } from './core/index.ts';
82
+
83
+ /** The record a mesh is in the model document — the identity a deform timeline keys and the core poses by. */
84
+ export interface SurveyRecord {
85
+ skin: string;
86
+ slot: string;
87
+ placeholder: string;
88
+ }
89
+
90
+ /** A skin, as the survey wears it. One handle per skin: the survey keys its caches on the handle. */
91
+ export interface SurveySkin {
92
+ readonly name: string;
93
+ }
94
+
95
+ /** A mesh a deform timeline moves, as far as the survey reads it. */
96
+ export interface SurveyMesh {
97
+ /** The attachment's name — the record's stated `name`, else its placeholder. */
98
+ readonly name: string;
99
+ readonly triangles: ArrayLike<number>;
100
+ /** Two numbers per vertex. */
101
+ readonly worldVerticesLength: number;
102
+ /** Whether its vertices are bound to bones (`MeshAttachment.bones !== null`). */
103
+ readonly weighted: boolean;
104
+ /** The other slots the deform reaches (`Attachment.timelineSlots`), read as a set. */
105
+ readonly timelineSlots: readonly number[];
106
+ /** The model record it is, or `null` when no skin holds it — the core then refuses to pose it, by name. */
107
+ readonly record: SurveyRecord | null;
108
+ }
109
+
110
+ /** How the runtime gets from one key's geometry to the next. */
111
+ export type SurveyCurve = { kind: 'linear' } | { kind: 'stepped' } | { kind: 'bezier'; points: ArrayLike<number> };
112
+
113
+ /** One deform timeline of one animation. */
114
+ export interface SurveyDeformTimeline {
115
+ /** The slot it is filed under, by index into `SurveyStructure.slotName`. */
116
+ readonly slotIndex: number;
117
+ /** The mesh it moves, or `null` when the attachment has a vertex array and no triangles (a bounding box, a clipping polygon, a path). */
118
+ readonly mesh: SurveyMesh | null;
119
+ /** Each key's time, as the runtime stores it. */
120
+ readonly frames: ArrayLike<number>;
121
+ /** Key `frame`'s vertex array, as the runtime holds it. */
122
+ vertices(frame: number): ArrayLike<number> | undefined;
123
+ /** The curve from key `frame` to key `frame + 1`. */
124
+ curve(frame: number): SurveyCurve;
125
+ /** The skin that holds the mesh, by name and handle (`null` when none does), and the placeholder the timeline names. */
126
+ placement(): { skin: string; holder: SurveySkin | null; placeholder: string };
127
+ }
128
+
129
+ /** One animation, as far as the survey reads it. */
130
+ export interface SurveyAnimation {
131
+ readonly name: string;
132
+ /** Its deform timelines, in the runtime's order. */
133
+ readonly deforms: readonly SurveyDeformTimeline[];
134
+ /** Every key time of every timeline filed under one of `slots` — the times a slot's visibility can change at. */
135
+ slotKeyTimes(slots: ReadonlySet<number>): number[];
136
+ }
137
+
138
+ /**
139
+ * One slider constraint, as far as the survey reads it.
140
+ *
141
+ * ⚠️ `stated`, `reader`, `from`, `to` and `scale` are the dial's mapping and are
142
+ * read only for a slider with a `bone`: a bone-less one's dial is its time. The
143
+ * two readers part there and nothing reads the difference — spine-core sets no
144
+ * property on a bone-less slider and leaves its `scale` at 0, where the model
145
+ * document states the parser's 1 (measured, `tools/survey_hashes.ts structure`).
146
+ */
147
+ export interface SurveySlider {
148
+ readonly name: string;
149
+ /** Its mix at setup. */
150
+ readonly mix: number;
151
+ /** The animation it applies, by name. */
152
+ readonly animation: string;
153
+ /** That animation's duration, as the runtime computes it. */
154
+ readonly duration: number;
155
+ /** Its driving bone, or `null` on the bone-less form. */
156
+ readonly bone: string | null;
157
+ readonly local: boolean;
158
+ /** The bone field its property reads — `null` on a reader this file does not know. */
159
+ readonly stated: DialBoneField | null;
160
+ /** What that reader is called, for a report that has to name an unknown one. */
161
+ readonly reader: string;
162
+ /** `from` — the property value at which the animation's time is `to`. */
163
+ readonly from: number;
164
+ /** `to`. */
165
+ readonly to: number;
166
+ readonly scale: number;
167
+ readonly loop: boolean;
168
+ }
169
+
170
+ /** Everything the survey reads of a skeleton's structure. */
171
+ export interface SurveyStructure {
172
+ /** Every skin, one handle each, in the skeleton's order — not read by the survey, which wears the skin a timeline names; the instruments walk it. */
173
+ readonly skins: readonly SurveySkin[];
174
+ /** In the runtime's order. */
175
+ readonly animations: readonly SurveyAnimation[];
176
+ /** In the skeleton's constraint order. */
177
+ readonly sliders: readonly SurveySlider[];
178
+ /** A slot's name, or `#<index>` for an index that is not a slot. */
179
+ slotName(index: number): string;
180
+ /** How many skins carry this name — the core wears a skin by its name, so two of one name cannot be posed. */
181
+ skinsNamed(name: string): number;
182
+ }
183
+
184
+ /** The bone field each rig-spec property reads. */
185
+ const PROPERTY_FIELD: Record<string, DialBoneField> = { rotate: 'rotation', x: 'x', y: 'y', scaleX: 'scaleX', scaleY: 'scaleY', shearY: 'shearY' };
186
+
187
+ /** The model document's reading of the survey's structure (the header). */
188
+ export function modelStructure(doc: CompiledDocument): SurveyStructure {
189
+ const slotIndex = new Map(doc.slots.map((s, i) => [s.name, i] as const));
190
+ const skinHandles = new Map<string, SurveySkin>();
191
+ const skinOf = (name: string): SurveySkin | null => {
192
+ if (!doc.skins.some((k) => k.name === name)) return null;
193
+ let handle = skinHandles.get(name);
194
+ if (handle === undefined) {
195
+ handle = { name };
196
+ skinHandles.set(name, handle);
197
+ }
198
+ return handle;
199
+ };
200
+ const recordAt = (skin: string, slot: string, placeholder: string): CoreAttachment | undefined => doc.skins.find((k) => k.name === skin)?.attachments[slot]?.[placeholder];
201
+ /** The slots of every linked mesh that plays this record's timelines (the header's last rule). */
202
+ const linkedSlots = (record: SurveyRecord): number[] => {
203
+ const out: number[] = [];
204
+ for (const skin of doc.skins) {
205
+ for (const [slot, table] of Object.entries(skin.attachments)) {
206
+ for (const entry of Object.values(table)) {
207
+ const g = entry.geometry;
208
+ if (g?.kind !== 'linkedmesh' || entry.timelines !== true) continue;
209
+ if (g.skin !== record.skin || g.slot !== record.slot || g.source !== record.placeholder) continue;
210
+ const at = slotIndex.get(slot);
211
+ if (at !== undefined && !out.includes(at)) out.push(at);
212
+ }
213
+ }
214
+ }
215
+ return out;
216
+ };
217
+ const meshes = new Map<string, SurveyMesh>();
218
+ const meshOf = (t: CoreAttachmentTimeline): SurveyMesh | null => {
219
+ const key = `${t.skin}/${t.slot}/${t.attachment}`;
220
+ const already = meshes.get(key);
221
+ if (already !== undefined) return already;
222
+ const entry = recordAt(t.skin, t.slot, t.attachment);
223
+ if (entry === undefined) throw new CoreInputError(`the deform timeline ${key} names no record of the model document`);
224
+ const g = entry.geometry;
225
+ if (g?.kind !== 'mesh') return null;
226
+ const record = { skin: t.skin, slot: t.slot, placeholder: t.attachment };
227
+ const mesh: SurveyMesh = {
228
+ name: entry.name ?? t.attachment,
229
+ triangles: g.triangles,
230
+ worldVerticesLength: g.vertices.weighted ? 2 * g.vertices.bindings.length : g.vertices.xy.length,
231
+ weighted: g.vertices.weighted,
232
+ timelineSlots: linkedSlots(record),
233
+ record,
234
+ };
235
+ meshes.set(key, mesh);
236
+ return mesh;
237
+ };
238
+ const deformOf = (t: CoreAttachmentTimeline, keys: readonly CoreDeformKey[]): SurveyDeformTimeline => {
239
+ const at = slotIndex.get(t.slot);
240
+ if (at === undefined) throw new CoreInputError(`the deform timeline ${t.skin}/${t.slot}/${t.attachment} names slot "${t.slot}", which is not a slot of the model document`);
241
+ const mesh = meshOf(t);
242
+ const entry = recordAt(t.skin, t.slot, t.attachment);
243
+ const g = entry?.geometry;
244
+ const vertices = g !== undefined && g.kind !== 'region' && g.kind !== 'linkedmesh' ? g.vertices : null;
245
+ const held = new Map<number, number[]>();
246
+ return {
247
+ slotIndex: at,
248
+ mesh,
249
+ frames: keys.map((k) => k.time),
250
+ vertices: (frame) => {
251
+ const key = keys[frame];
252
+ if (key === undefined || vertices === null) return undefined;
253
+ let array = held.get(frame);
254
+ if (array === undefined) {
255
+ array = heldArray(vertices, key);
256
+ held.set(frame, array);
257
+ }
258
+ return array;
259
+ },
260
+ curve: (frame) => {
261
+ const a = keys[frame];
262
+ const b = keys[frame + 1];
263
+ if (a === undefined || b === undefined) throw new CoreInputError(`the deform timeline ${t.skin}/${t.slot}/${t.attachment} has no segment after key ${frame}`);
264
+ if (a.curve === 'linear') return { kind: 'linear' };
265
+ if (a.curve === 'stepped') return { kind: 'stepped' };
266
+ const c = a.curve;
267
+ return { kind: 'bezier', points: bezierPolyline(a.stated, 0, c[0], c[1], c[2], c[3], b.stated, DEFORM_CURVE_END) };
268
+ },
269
+ placement: () => ({ skin: t.skin, holder: skinOf(t.skin), placeholder: t.attachment }),
270
+ };
271
+ };
272
+ const animationOf = (a: CoreAnimation): SurveyAnimation => {
273
+ const deforms: SurveyDeformTimeline[] = [];
274
+ for (const t of a.timelines.attachments) if (t.deform !== null) deforms.push(deformOf(t, t.deform));
275
+ return {
276
+ name: a.name,
277
+ deforms,
278
+ slotKeyTimes: (slots) => {
279
+ const times: number[] = [];
280
+ for (const target of a.timelines.slots) {
281
+ const at = slotIndex.get(target.name);
282
+ if (at === undefined || !slots.has(at)) continue;
283
+ for (const tl of target.timelines) for (const k of tl.keys) times.push(k.time);
284
+ }
285
+ for (const t of a.timelines.attachments) {
286
+ const at = slotIndex.get(t.slot);
287
+ if (at === undefined || !slots.has(at)) continue;
288
+ for (const k of [...(t.deform ?? []), ...(t.sequence ?? [])]) times.push(k.time);
289
+ }
290
+ return times;
291
+ },
292
+ };
293
+ };
294
+ let order: string[];
295
+ try {
296
+ order = editorAnimationOrder(doc.animations.map((a) => a.name));
297
+ } catch (err) {
298
+ if (!(err instanceof CompileError)) throw err;
299
+ throw new CoreInputError(`the animations' order in the Spine file is not settled by the model's names — ${err.message}`);
300
+ }
301
+ const animations = order.map((name) => {
302
+ const a = doc.animations.find((x) => x.name === name);
303
+ if (a === undefined) throw new CoreInputError(`the editor's order named animation "${name}", which is not in the model document`);
304
+ return animationOf(a);
305
+ });
306
+ const sliders: SurveySlider[] = [];
307
+ for (const c of doc.constraints) {
308
+ const r = c.record;
309
+ if (c.kind !== 'slider' || r?.kind !== 'slider') continue;
310
+ sliders.push({
311
+ name: r.name,
312
+ mix: r.mix,
313
+ animation: r.animation,
314
+ duration: r.timelines.duration,
315
+ bone: r.bone,
316
+ local: r.local,
317
+ stated: PROPERTY_FIELD[r.property] ?? null,
318
+ reader: r.property,
319
+ from: r.from,
320
+ to: r.to,
321
+ scale: r.scale,
322
+ loop: r.loop,
323
+ });
324
+ }
325
+ return {
326
+ skins: doc.skins.flatMap((k) => {
327
+ const handle = skinOf(k.name);
328
+ return handle === null || doc.skins.find((x) => x.name === k.name) !== k ? [] : [handle];
329
+ }),
330
+ animations,
331
+ sliders,
332
+ slotName: (index) => doc.slots[index]?.name ?? `#${index}`,
333
+ skinsNamed: (name) => doc.skins.filter((k) => k.name === name).length,
334
+ };
335
+ }
package/src/emit.ts CHANGED
@@ -91,7 +91,24 @@ export interface CopyImagesResult {
91
91
  */
92
92
  export function copyAtlasPages(atlasText: string, outDir: string): CopyImagesResult {
93
93
  mkdirSync(outDir, { recursive: true });
94
+ const planned = plannedPageCopies(atlasText, outDir);
95
+ for (const page of planned.pages) {
96
+ const source = resolve(outDir, page.from);
97
+ const destination = join(outDir, page.to);
98
+ if (source !== resolve(destination)) copyFileSync(source, destination);
99
+ }
100
+ return planned;
101
+ }
94
102
 
103
+ /**
104
+ * What `copyAtlasPages` will do, without doing it: the rewritten atlas text
105
+ * and each page's source and copy name, from the text and `outDir` alone — no
106
+ * file is read or written (issue #1016). `build --copy-images` spells the
107
+ * model document from this text BEFORE the gate, because the document states
108
+ * where each region sits on its page (`pages`) and the copy renames every
109
+ * page; `copyAtlasPages` returns exactly this, after copying.
110
+ */
111
+ export function plannedPageCopies(atlasText: string, outDir: string): CopyImagesResult {
95
112
  const parsed = parseAtlasText(atlasText);
96
113
  const claimedBy = new Map<string, string>(); // destination filename -> absolute source holding it
97
114
  const pages: CopiedPage[] = [];
@@ -113,11 +130,5 @@ export function copyAtlasPages(atlasText: string, outDir: string): CopyImagesRes
113
130
  pages.push({ from: page.name, to: name, regions: page.regions.length });
114
131
  }
115
132
 
116
- for (const page of pages) {
117
- const source = resolve(outDir, page.from);
118
- const destination = join(outDir, page.to);
119
- if (source !== resolve(destination)) copyFileSync(source, destination);
120
- }
121
-
122
133
  return { atlasText: rewritePageNames(parsed, (_name, index) => pages[index].to), pages };
123
134
  }
package/src/ingest.ts CHANGED
@@ -52,6 +52,7 @@ import {
52
52
  SPINE_VERSION,
53
53
  } from './compile.ts';
54
54
  import { CompileError } from './errors.ts';
55
+ import { physicsDrives } from './core/constraints_physics.ts';
55
56
  import { CHANNELS_BY_KIND } from './timelines.ts';
56
57
  import {
57
58
  LEGACY_BONE_INHERIT_KEY,
@@ -303,13 +304,14 @@ const CONSTRAINT_FIELDS: Record<string, string[]> = {
303
304
  * The physics constraints that drive nothing, by name, each with the component
304
305
  * fields it DOES state — every one of them at most 0 (issue #731).
305
306
  *
306
- * 🔑 The runtime's own predicate, not a reading of it: `PhysicsConstraint.update`
307
- * decides what it applies with `this.data.x > 0` and its four siblings
308
- * (`PhysicsConstraint.js:112`) and nothing else, so a constraint with none of
309
- * `PHYSICS_COMPONENTS` above 0 moves no bone. `build` refuses exactly that shape
310
- * by name — `A23_PHYSICS_CONSTRAINT_EFFECTIVE` at the gate — so carrying one
311
- * through made the decompiled spec of a file an editor exports unbuildable as a
312
- * whole, over a constraint that did nothing in it.
307
+ * 🔑 The question is the core's `physicsDrives` (`src/core/constraints_physics.ts`,
308
+ * issue #1015): a component above 0 drives its part of the step and nothing
309
+ * else does, so a constraint with none of `PHYSICS_COMPONENTS` above 0 moves no
310
+ * bone — `PhysicsConstraint.update` applies a part only on that test
311
+ * (`PhysicsConstraint.js:112`). An unstated component is the parser's 0. `build`
312
+ * refuses exactly that shape by name — `A23_PHYSICS_CONSTRAINT_EFFECTIVE` at the
313
+ * gate — so carrying one through made the decompiled spec of a file an editor
314
+ * exports unbuildable as a whole, over a constraint that did nothing in it.
313
315
  *
314
316
  * ⚠️ A component that is present and NOT a number is not inert: the parser takes
315
317
  * it as written and `"0.5" > 0` is true in the runtime's comparison, so such a
@@ -321,7 +323,10 @@ function inertPhysics(root: JsonObject): Map<string, string[]> {
321
323
  const constraint = obj(raw);
322
324
  if (constraint.type !== 'physics' || typeof constraint.name !== 'string') continue;
323
325
  const stated = PHYSICS_COMPONENTS.filter((field) => constraint[field] !== undefined);
324
- if (!stated.every((field) => typeof constraint[field] === 'number' && !((constraint[field] as number) > 0))) continue;
326
+ if (!stated.every((field) => typeof constraint[field] === 'number')) continue;
327
+ const value = (field: (typeof PHYSICS_COMPONENTS)[number]): number => (constraint[field] === undefined ? 0 : (constraint[field] as number));
328
+ const drives = physicsDrives({ x: value('x'), y: value('y'), rotate: value('rotate'), shearX: value('shearX'), scaleX: value('scaleX') });
329
+ if (drives.x || drives.y || drives.rotateOrShearX || drives.scaleX) continue;
325
330
  out.set(
326
331
  constraint.name,
327
332
  stated.map((field) => `${field} ${String(constraint[field])}`),
@@ -1305,9 +1310,9 @@ function ingestAttachment(
1305
1310
  const type = att.type === undefined ? 'region' : String(att.type);
1306
1311
  const out: JsonObject = {};
1307
1312
 
1308
- // A LINKED mesh, in either of the format's two spellings. The test is the
1309
- // parser's own — one branch for `mesh` and `linkedmesh`, and a truthy `source`
1310
- // decides (`SkeletonJson.js:568-569`, `:582`) — so `type: "mesh"` carrying
1313
+ // A LINKED mesh, in either of the format's two spellings. The format decides
1314
+ // it — one branch of the reader for `mesh` and `linkedmesh`, and a truthy
1315
+ // `source` decides (`SkeletonJson.js:568-569`, `:582`) — so `type: "mesh"` carrying
1311
1316
  // `source` inverts to a link, and a `linkedmesh` with none does NOT: that map
1312
1317
  // is read as an ordinary mesh, whose `uvs` it does not have, and the parser
1313
1318
  // throws on it. Reading the second as a link would be this module inventing a
@@ -1327,8 +1332,8 @@ function ingestAttachment(
1327
1332
  // `buildRigRegion` writes `path` when the image basename differs from the
1328
1333
  // name the attachment carries — its stated `name`, else the placeholder —
1329
1334
  // so naming the image after the region this attachment RESOLVES
1330
- // (`path ?? name ?? placeholder`, the parser's own defaults at
1331
- // `SkeletonJson.js:526`, `:529`, `:560`) reproduces the same `path`
1335
+ // (`path ?? name ?? placeholder`, the defaults the format's reader applies
1336
+ // at `SkeletonJson.js:526`, `:529`, `:560`) reproduces the same `path`
1332
1337
  // decision AND the same atlas region name.
1333
1338
  if (opts.art === 'loose') {
1334
1339
  const region = att.path ?? (typeof att.name === 'string' ? att.name : placeholder);
package/src/keyorder.ts CHANGED
@@ -288,8 +288,8 @@ export function inEditorKeyOrder<T extends object>(skeleton: T, table: KeyOrderT
288
288
  // ## Why this is a table and not a parse
289
289
  //
290
290
  // The parser is the only authority on what a default is, and this table is
291
- // not a second one: it is a transcription the selftest holds to the parser row
292
- // by row (`S103` on the in-tree builds, `IG82` on the corpus) by loading an
291
+ // not a second one: it is a table of the parser's readings that the selftest
292
+ // holds to the parser row by row (`S103` on the in-tree builds, `IG82` on the corpus) by loading an
293
293
  // object of each kind with the key at the row's value and without it, and
294
294
  // comparing everything `SkeletonData` holds — and then one float32 step off,
295
295
  // which must differ. It cannot BE a parse: `compile.ts` must not link the
package/src/model.ts CHANGED
@@ -82,10 +82,12 @@
82
82
  * they are the atlas emitter's constants, and the model does not carry them.
83
83
  *
84
84
  * 📄 **It is written as a document** (issue #922, cut 1f): `modelDocument` below
85
- * spells it as `rigc-compiled/1`, and `build` writes that text into `--out` as
86
- * `skeleton.model.json` beside the Spine files, after the gate, like them.
85
+ * spells it as `rigc-compiled/2` (`/1` until issue #1016 added `pages`), and
86
+ * `build` writes that text into `--out` as `skeleton.model.json` beside the
87
+ * Spine files, after the gate, like them.
87
88
  */
88
89
  import { createHash } from 'node:crypto';
90
+ import { parseAtlasText } from './atlas.ts';
89
91
  import { CompileError } from './errors.ts';
90
92
  import type { BoneTransform } from './transform.ts';
91
93
  import type { RigSkinConstraintKey } from './rig.ts';
@@ -211,10 +213,12 @@ export interface ModelSequence {
211
213
  * (#931: the atlas's `rotate` transposed into the corners was exact on 2018 of
212
214
  * 3000 probes, ignored on 3000 of 3000). The trim and the original size do not
213
215
  * move under either: rigc's packer never trims or rotates (`src/atlas.ts`).
214
- * The draw, which does need the four — a region's page and page UVs — reads
216
+ * The draw, which does need the four — a region's page and page UVs — read
215
217
  * them off the atlas written beside this document, as a second input
216
- * (`src/core/uvs.ts`, issue #967): measured at tolerance 0 against the
217
- * runtime's own arrays on every corpus row with the model unchanged.
218
+ * (`src/core/uvs.ts`, issue #967), until issue #1016 gave the document a
219
+ * `pages` section spelled from the atlas text `build` writes (`pagesOfAtlas`
220
+ * below): the four are still not this record's, because they are the written
221
+ * arrangement's, and the section moves with it.
218
222
  */
219
223
  export interface ModelAtlasRect {
220
224
  width: number;
@@ -613,11 +617,17 @@ export interface CompiledModel extends CarriedFromCompileResult {
613
617
  }
614
618
 
615
619
  // ---------------------------------------------------------------------------
616
- // the document: `rigc-compiled/1` (issue #922, cut 1f)
620
+ // the document: `rigc-compiled/2` (issue #922, cut 1f; `pages` and `/2`, issue #1016)
617
621
  // ---------------------------------------------------------------------------
618
622
 
619
- /** The document's `spec` value. */
620
- export const MODEL_DOCUMENT_SPEC = 'rigc-compiled/1';
623
+ /**
624
+ * The document's `spec` value. `/2` since issue #1016 added the `pages`
625
+ * section: a `/1` reader (`readModel` of rigc 1.6) refuses a section it does
626
+ * not know by name, so the same spec over a new section would be refused by
627
+ * every reader already installed rather than read wrongly — a new spec says so
628
+ * before the first section is opened. `readModel` reads both.
629
+ */
630
+ export const MODEL_DOCUMENT_SPEC = 'rigc-compiled/2';
621
631
 
622
632
  /** The file `build` writes the document to, in `--out` beside `skeleton.json` and `skeleton.atlas`. */
623
633
  export const MODEL_DOCUMENT_FILE = 'skeleton.model.json';
@@ -809,7 +819,7 @@ const MODEL_DOCUMENT_FIELDS: readonly string[] = [
809
819
  const MODEL_DOCUMENT_LEFT_OUT: readonly string[] = ['setupWorld'];
810
820
 
811
821
  /**
812
- * The compiled model as a document: `rigc-compiled/1`, `JSON.stringify(doc,
822
+ * The compiled model as a document: `rigc-compiled/2`, `JSON.stringify(doc,
813
823
  * null, 2)` and a newline — the text `build` writes to `skeleton.model.json`,
814
824
  * and the record rigc's own posing core reads (`readModel` in
815
825
  * `src/core/index.ts`, issue #380, step 2). Its cost in a build is measured in
@@ -820,7 +830,9 @@ const MODEL_DOCUMENT_LEFT_OUT: readonly string[] = ['setupWorld'];
820
830
  * `constraints`, `events`, `animations` — then the fields carried from `CompileResult` in
821
831
  * `CarriedFromCompileResult`'s order: `images`, `pageGrids`, `droppedStates`,
822
832
  * `absentParts`, `meshBones`, `meshes`, `physics`, `deformTransforms`,
823
- * `trackDerivations`, `rig`; and last `spine`, the digest of the
833
+ * `trackDerivations`, `rig`; then `pages`, where each region sits on its page
834
+ * in the atlas written beside it (`pagesOfAtlas`, issue #1016), which is why
835
+ * the atlas text is the third argument; and last `spine`, the digest of the
824
836
  * `skeleton.json` written beside it (`spineFileSha256`, issue #968), which is
825
837
  * why the Spine text is the second argument. Inside a model record, its interface's field
826
838
  * order (`ModelBone`, `ModelSlot`, `ModelSkin`, each attachment kind,
@@ -884,8 +896,11 @@ const MODEL_DOCUMENT_LEFT_OUT: readonly string[] = ['setupWorld'];
884
896
  *
885
897
  * - `setupWorld` — computed from `bones` by `computeWorldTransforms`
886
898
  * (`src/transform.ts`), so it states nothing `bones` does not, and a core
887
- * is to compute it rather than read it; and it is the one field holding a
888
- * `-0` (a root bone's `b` is `-sin 0`), which JSON cannot spell.
899
+ * is to compute it rather than read it; and it is the one field that can
900
+ * hold a `-0` (a bone's `c` is `sin 0 · scaleX`, which is `-0` at a
901
+ * `scaleX` of −1), which JSON cannot spell. (A root's `b` was `-sin 0`
902
+ * until issue #1021; under the runtime's arithmetic it is `cos 90°` at
903
+ * pi = 3.1415927, −2.3e-8.)
889
904
  * - `images[].absPath` — where the part was on this machine's disk, for the
890
905
  * size assertions.
891
906
  * - `droppedStates[].why` — the sentence names the `--atlas-in` file by its
@@ -894,7 +909,7 @@ const MODEL_DOCUMENT_LEFT_OUT: readonly string[] = ['setupWorld'];
894
909
  * What the Spine emitter adds (`emitSkeleton`'s header, its spellings, its
895
910
  * orders and omissions) is not in the model and so not here.
896
911
  */
897
- export function modelDocument(model: CompiledModel, skeletonText: string): string {
912
+ export function modelDocument(model: CompiledModel, skeletonText: string, atlasText: string): string {
898
913
  for (const key of Object.keys(model)) {
899
914
  if (!MODEL_DOCUMENT_FIELDS.includes(key) && !MODEL_DOCUMENT_LEFT_OUT.includes(key)) {
900
915
  throw new CompileError(`internal: the model document has no place for the model's field "${key}"; it writes [${MODEL_DOCUMENT_FIELDS.join(', ')}] and leaves out [${MODEL_DOCUMENT_LEFT_OUT.join(', ')}]`);
@@ -921,11 +936,97 @@ export function modelDocument(model: CompiledModel, skeletonText: string): strin
921
936
  deformTransforms: plain(model.deformTransforms, 'deformTransforms'),
922
937
  trackDerivations: plain(model.trackDerivations, 'trackDerivations'),
923
938
  rig: plain(model.rig, 'rig'),
939
+ pages: plain(pagesOfAtlas(atlasText), 'pages'),
924
940
  spine: { sha256: spineFileSha256(skeletonText) },
925
941
  };
926
942
  return `${JSON.stringify(doc, null, 2)}\n`;
927
943
  }
928
944
 
945
+ // --- #1016 where each region sits on its page: begin ---
946
+ /**
947
+ * One region of a page, as the document's `pages` section states it: the
948
+ * region's name exactly as the atlas line spells it (untrimmed — the core finds
949
+ * a region by that spelling, `./core/uvs.ts`), its rectangle's top-left `x`,
950
+ * `y` on the page (y down) and `width`, `height` in the drawing's orientation,
951
+ * the trim (`offsetX` from the drawing's left, `offsetY` from its bottom) and
952
+ * the untrimmed size, its turn in `degrees`, and its `index:` field — every
953
+ * number in the page's texels, exactly as `parseAtlasText` reads it.
954
+ */
955
+ export interface ModelPageRegion {
956
+ name: string;
957
+ x: number;
958
+ y: number;
959
+ width: number;
960
+ height: number;
961
+ offsetX: number;
962
+ offsetY: number;
963
+ originalWidth: number;
964
+ originalHeight: number;
965
+ degrees: number;
966
+ index: number;
967
+ }
968
+
969
+ /** One page: its name (a path from the directory `build` writes into, trimmed), its `size`, and its regions in file order. */
970
+ export interface ModelPage {
971
+ name: string;
972
+ width: number;
973
+ height: number;
974
+ regions: ModelPageRegion[];
975
+ }
976
+
977
+ /** The fields of a page and of a region, in the order the document writes them — `readModel` mirrors both lists. */
978
+ export const MODEL_PAGE_FIELDS = ['name', 'width', 'height', 'regions'] as const;
979
+ export const MODEL_PAGE_REGION_FIELDS = ['name', 'x', 'y', 'width', 'height', 'offsetX', 'offsetY', 'originalWidth', 'originalHeight', 'degrees', 'index'] as const;
980
+
981
+ /**
982
+ * The document's `pages` section (issue #1016): every page of `atlasText` and
983
+ * every region on it, in file order, with the numbers the draw reads — where
984
+ * each drawing sits, which `./core/uvs.ts` turns into page UVs. `atlasText`
985
+ * is the atlas `build` writes beside the document, the one that run's gate
986
+ * last read: after `--pack` the packed text, after `--copy-images` the text
987
+ * with the copies' page names.
988
+ *
989
+ * ⭐ **Why it is a function of the written atlas rather than a model field.**
990
+ * Issue #939 left the page, `x`, `y` and `rotate` out of `ModelAtlasRect`
991
+ * because `--pack` moves them after `compile` returns and `--copy-images`
992
+ * renames every page — a value the model held from `compile` would state a
993
+ * place the written atlas does not have. The section is spelled from the
994
+ * atlas text instead, so it moves exactly when that text does, and `build`
995
+ * spells the document from the text it writes (`cli.ts`): the document a pack
996
+ * writes is spelled from the packed text and gated by the packed pass, where
997
+ * `A18` compares it with a second compile's document spelled from a second,
998
+ * independent pack.
999
+ *
1000
+ * 🔸 **What it leaves out.** The page's `format`, `filter`, `repeat`, `pma`
1001
+ * and `scale:` lines: nothing that draws reads them (the rasteriser samples
1002
+ * one way, `src/render.ts`'s header; the pose reads only the ratios the
1003
+ * trimmed and original sizes make, #939's decision 2). Every region of every
1004
+ * page is written, drawn or not: which regions a rig draws is the core's
1005
+ * lookup rule (`./core/uvs.ts`, *Which region, on which page*), and a writer
1006
+ * choosing a subset would restate it.
1007
+ */
1008
+ export function pagesOfAtlas(atlasText: string): ModelPage[] {
1009
+ return parseAtlasText(atlasText).pages.map((page) => ({
1010
+ name: page.name,
1011
+ width: page.width,
1012
+ height: page.height,
1013
+ regions: page.regions.map((r) => ({
1014
+ name: r.name,
1015
+ x: r.x,
1016
+ y: r.y,
1017
+ width: r.width,
1018
+ height: r.height,
1019
+ offsetX: r.offsetX,
1020
+ offsetY: r.offsetY,
1021
+ originalWidth: r.originalWidth,
1022
+ originalHeight: r.originalHeight,
1023
+ degrees: r.degrees,
1024
+ index: r.index,
1025
+ })),
1026
+ }));
1027
+ }
1028
+ // --- #1016 where each region sits on its page: end ---
1029
+
929
1030
  // --- #968 the Spine file the document was written beside: begin ---
930
1031
  /**
931
1032
  * The document's last section, `spine`: `{ "sha256": "<64 lowercase hex>" }`,