spine-rigc 2.11.1 → 2.13.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/compile.ts CHANGED
@@ -2854,7 +2854,11 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
2854
2854
  partBySlot.has(rigSlot.name) &&
2855
2855
  (slotAttachments.get(rigSlot.name) ?? rigAttachmentNames.get(rigSlot.name) ?? []).length > 0,
2856
2856
  );
2857
- if (skinNames.includes(DEFAULT_SKIN) || partsFillDefault) tableFor(DEFAULT_SKIN);
2857
+ // The stage box (issue #1168): resolved before the slots, so every refusal
2858
+ // names the field before any slot is built — and a `default` skin is one it
2859
+ // files the box under, so it is created here with the others.
2860
+ const stageBox = resolveStageBox(rig, { stageWidth, stageHeight, bones, partBySlot, slotAttachments, rigAttachmentNames });
2861
+ if (skinNames.includes(DEFAULT_SKIN) || partsFillDefault || stageBox !== null) tableFor(DEFAULT_SKIN);
2858
2862
  // ...and every skin the rig declares: a skin can carry `bones`/constraint lists
2859
2863
  // with no attachments at all, and a skin that only switches bones on would
2860
2864
  // otherwise never reach the emitted array.
@@ -2867,7 +2871,8 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
2867
2871
 
2868
2872
  for (const rigSlot of rig.slots) {
2869
2873
  const part = partBySlot.get(rigSlot.name);
2870
- const names = slotAttachments.get(rigSlot.name) ?? rigAttachmentNames.get(rigSlot.name) ?? [];
2874
+ const boxHere = stageBox !== null && stageBox.slot === rigSlot.name;
2875
+ const names = boxHere ? [stageBox.attachment] : (slotAttachments.get(rigSlot.name) ?? rigAttachmentNames.get(rigSlot.name) ?? []);
2871
2876
  // 🔑 A slot nothing fills is EMITTED, empty — it is not dropped (issue #575).
2872
2877
  // A `continue` stood here instead, and what it bought was the format's own
2873
2878
  // silence: the emitted array came back a slot short with no line saying
@@ -2969,6 +2974,11 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
2969
2974
  // As stated: `parseRigSpec` refused every spelling the runtime would read as no mode (issue #946).
2970
2975
  if (rigSlot.blend !== undefined) slot.blend = rigSlot.blend;
2971
2976
  slots.push(slot);
2977
+ // The stage box is the slot's one attachment, in the `default` skin (issue #1168).
2978
+ if (boxHere) {
2979
+ tableFor(DEFAULT_SKIN)[rigSlot.name] = { [stageBox.attachment]: stageBox.record };
2980
+ continue;
2981
+ }
2972
2982
  // ...and nothing below this line has anything to build. An empty entry in a
2973
2983
  // skin's attachment table would be rigc writing a key the editor does not,
2974
2984
  // so the skins array is left exactly as it was before #575 for every slot
@@ -3625,7 +3635,7 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
3625
3635
  // model's value into the header, as it writes `referenceScale`.
3626
3636
  const stage: ModelStage | null =
3627
3637
  stageWidth !== undefined && stageHeight !== undefined
3628
- ? { x: rig.skeleton?.x ?? 0, y: rig.skeleton?.y ?? 0, width: stageWidth, height: stageHeight }
3638
+ ? { x: rig.skeleton?.x ?? 0, y: rig.skeleton?.y ?? 0, width: stageWidth, height: stageHeight, ...(stageBox === null ? {} : { box: { slot: stageBox.slot, attachment: stageBox.attachment } }) }
3629
3639
  : null;
3630
3640
  // What the skeleton carries that the model does not hold: the rest of the header.
3631
3641
  const header: Omit<SkeletonHeader, 'bounds'> = {
@@ -4448,6 +4458,112 @@ function bindingsOnF32(vertices: Extract<ModelVertices, { weighted: true }>): Ex
4448
4458
  };
4449
4459
  }
4450
4460
 
4461
+ /**
4462
+ * The stage box a rig asked for (`skeleton.stageBox`, issue #1168), resolved:
4463
+ * its slot and attachment name, and the bounding box `compile` files under
4464
+ * them — or `null` for a rig that did not ask.
4465
+ *
4466
+ * ⭐ **Its numbers are the stage's and nobody else's.** The four vertices are
4467
+ * the stage's corners in Spine world — `(x, y)`, `(x + w, y)`, `(x + w, y + h)`,
4468
+ * `(x, y + h)`, the bottom-left first and counter-clockwise — the same four
4469
+ * fields `ModelStage` holds and the coordinate transform reads, each written on
4470
+ * the float32 grid every other emitted vertex is on. Nothing in the rig spec
4471
+ * states them, so nothing can disagree with the stage.
4472
+ *
4473
+ * 🔒 **The box hangs on the root, and the root states no setup transform —
4474
+ * or the box is refused.** Two readings were weighed. Computing local vertices
4475
+ * through the inverse of any bone's setup transform would admit every bone,
4476
+ * and the posed box would land on the stage only to within the float32
4477
+ * rounding of those local numbers, a tolerance of the gate's own. Requiring an
4478
+ * unmoved root makes the stored vertices the stage's numbers exactly —
4479
+ * readable straight off the attachment by a consumer that poses nothing — and
4480
+ * the posed box is those numbers through the runtime's frame of an unrotated
4481
+ * root, whose `b` is cos 90° at the runtime's pi (−2.3e-8, measured: the
4482
+ * compiler's own world transform of `gallery/nod`'s root, and the `-0.000016`
4483
+ * of that build's header `x`), which `A50_STAGE_BOX_IS_THE_STAGE` holds on the
4484
+ * header's grid with no new tolerance. A bone below the root is refused even
4485
+ * when it states nothing, because that residue is taken once per level. A
4486
+ * constraint that moves the root at setup is not seen here — the compiler
4487
+ * poses no constraint — and is A50's to name, through the runtime.
4488
+ *
4489
+ * Refused by name: a slot `slots` does not declare; a slot something else
4490
+ * fills (a skin entry, a manifest part); a slot on a bone below the root, or
4491
+ * on a root that states a setup transform; a rig with no stage, which `parseRigSpec` already refuses
4492
+ * for a stated absence and which cannot reach here otherwise.
4493
+ */
4494
+ function resolveStageBox(
4495
+ rig: RigSpec,
4496
+ ctx: {
4497
+ stageWidth: number | undefined;
4498
+ stageHeight: number | undefined;
4499
+ bones: readonly ModelBone[];
4500
+ partBySlot: ReadonlyMap<string, unknown>;
4501
+ slotAttachments: ReadonlyMap<string, readonly string[]>;
4502
+ rigAttachmentNames: ReadonlyMap<string, readonly string[]>;
4503
+ },
4504
+ ): { slot: string; attachment: string; record: ModelBoundingBoxAttachment } | null {
4505
+ const asked = rig.skeleton?.stageBox;
4506
+ if (asked === undefined) return null;
4507
+ const field = `skeleton.stageBox (slot "${asked.slot}", attachment "${asked.attachment}")`;
4508
+ if (ctx.stageWidth === undefined || ctx.stageHeight === undefined) {
4509
+ throw new CompileError(`${field}: the rig declares no stage, so there is no box to write — state skeleton.width and skeleton.height`);
4510
+ }
4511
+ const rigSlot = rig.slots.find((s) => s.name === asked.slot);
4512
+ if (rigSlot === undefined) {
4513
+ throw new CompileError(
4514
+ `${field}: slot "${asked.slot}" is not one the rig declares. Declare it in "slots" — where it stands in the ` +
4515
+ `array is its place in the draw order, and its "bone" is the box's bone — e.g. { "name": "${asked.slot}", "bone": "root", "attachment": "${asked.attachment}" }`,
4516
+ );
4517
+ }
4518
+ const filledBy = [
4519
+ ...(ctx.partBySlot.has(asked.slot) ? ['a cut manifest part'] : []),
4520
+ ...((ctx.slotAttachments.get(asked.slot) ?? ctx.rigAttachmentNames.get(asked.slot) ?? []).length > 0
4521
+ ? [`the attachment(s) [${(ctx.slotAttachments.get(asked.slot) ?? ctx.rigAttachmentNames.get(asked.slot) ?? []).join(', ')}]`]
4522
+ : []),
4523
+ ];
4524
+ if (filledBy.length > 0) {
4525
+ throw new CompileError(
4526
+ `${field}: slot "${asked.slot}" is also filled by ${filledBy.join(' and ')}. The stage box is its slot's one ` +
4527
+ 'attachment, so a reader of that slot finds the stage and nothing else: give the box a slot of its own',
4528
+ );
4529
+ }
4530
+ // The box's bone: the root, stating no setup transform — as built, so a root placed `from` the manifest is read as placed.
4531
+ const bone = ctx.bones.find((b) => b.name === rigSlot.bone);
4532
+ if (bone === undefined) {
4533
+ throw new CompileError(`${field}: slot "${asked.slot}" names bone "${rigSlot.bone}", which the rig does not declare`);
4534
+ }
4535
+ if (bone.parent !== undefined) {
4536
+ throw new CompileError(
4537
+ `${field}: slot "${asked.slot}" hangs on bone "${rigSlot.bone}", which is not the root. The box's vertices are ` +
4538
+ "the stage's corners in world space, written as they are, and the gate holds them on the header's grid " +
4539
+ "through the runtime's own frame of an unrotated root; every bone below it adds that frame's residue once " +
4540
+ "more (the runtime spells an unrotated b as cos 90° at its pi, -2.3e-8 per level). Put the slot on the root " +
4541
+ `("${ctx.bones[0]?.name ?? 'root'}")`,
4542
+ );
4543
+ }
4544
+ const stated = [
4545
+ ...(['x', 'y', 'rotation', 'shearX', 'shearY'] as const).filter((key) => (bone[key] ?? 0) !== 0).map((key) => `${key} ${bone[key]}`),
4546
+ ...(['scaleX', 'scaleY'] as const).filter((key) => (bone[key] ?? 1) !== 1).map((key) => `${key} ${bone[key]}`),
4547
+ ];
4548
+ if (stated.length > 0) {
4549
+ throw new CompileError(
4550
+ `${field}: slot "${asked.slot}" hangs on the root "${bone.name}", which states a setup transform (${stated.join(', ')}). ` +
4551
+ "The box's vertices are the stage's corners in world space, written as they are, so the root must not move, " +
4552
+ 'turn, scale or shear at setup — move what the root carries onto a bone below it, or drop stageBox',
4553
+ );
4554
+ }
4555
+ const x = rig.skeleton?.x ?? 0;
4556
+ const y = rig.skeleton?.y ?? 0;
4557
+ const right = x + ctx.stageWidth;
4558
+ const top = y + ctx.stageHeight;
4559
+ const record: ModelBoundingBoxAttachment = {
4560
+ kind: 'boundingbox',
4561
+ vertexCount: 4,
4562
+ vertices: { weighted: false, xy: [x, y, right, y, right, top, x, top].map(f32) },
4563
+ };
4564
+ return { slot: asked.slot, attachment: asked.attachment, record };
4565
+ }
4566
+
4451
4567
  function buildRigBoundingBox(
4452
4568
  att: RigBoundingBoxAttachment,
4453
4569
  where: string,
package/src/core/index.ts CHANGED
@@ -243,6 +243,8 @@ export const CORE_PAGE_FIELDS = ['name', 'width', 'height', 'pma', 'scale', 'reg
243
243
  export const CORE_PAGE_FIELDS_2: readonly string[] = CORE_PAGE_FIELDS.filter((key) => key !== 'pma' && key !== 'scale');
244
244
  /** The stage's fields, as the writer lists them (`MODEL_STAGE_FIELDS` in `src/model.ts`, mirrored). */
245
245
  export const CORE_STAGE_FIELDS = ['x', 'y', 'width', 'height'] as const;
246
+ /** The stage box's fields (issue #1168), as the writer lists them (`MODEL_STAGE_BOX_FIELDS` in `src/model.ts`, mirrored). */
247
+ export const CORE_STAGE_BOX_FIELDS = ['slot', 'attachment'] as const;
246
248
  export const CORE_PAGE_REGION_FIELDS = ['name', 'x', 'y', 'width', 'height', 'offsetX', 'offsetY', 'originalWidth', 'originalHeight', 'degrees', 'index'] as const;
247
249
 
248
250
  /** The fields a bone record may carry, as the writer lists them. A field outside this list is refused. */
@@ -484,12 +486,24 @@ function readStage(value: unknown, problems: string[]): ModelStage | null {
484
486
  return null;
485
487
  }
486
488
  const before = problems.length;
487
- unknownFields(value, CORE_STAGE_FIELDS, 'stage', problems);
489
+ unknownFields(value, [...CORE_STAGE_FIELDS, 'box'], 'stage', problems);
488
490
  for (const key of CORE_STAGE_FIELDS) {
489
491
  const v = value[key];
490
492
  if (typeof v !== 'number' || !Number.isFinite(v)) problems.push(`stage: ${key} is ${JSON.stringify(v) ?? 'absent'}, not a finite number`);
491
493
  }
492
- return problems.length === before ? { x: value.x as number, y: value.y as number, width: value.width as number, height: value.height as number } : null;
494
+ // The stage box (issue #1168): written only where the rig asked for one, and then exactly its two names.
495
+ const box = value.box;
496
+ if (box !== undefined) {
497
+ if (!isRecord(box)) problems.push(`stage.box is ${JSON.stringify(box)}, not { slot, attachment }`);
498
+ else {
499
+ unknownFields(box, CORE_STAGE_BOX_FIELDS, 'stage.box', problems);
500
+ for (const key of CORE_STAGE_BOX_FIELDS) if (typeof box[key] !== 'string' || box[key] === '') problems.push(`stage.box: ${key} is ${JSON.stringify(box[key]) ?? 'absent'}, not a non-empty string`);
501
+ }
502
+ }
503
+ if (problems.length !== before) return null;
504
+ const stage: ModelStage = { x: value.x as number, y: value.y as number, width: value.width as number, height: value.height as number };
505
+ if (isRecord(box)) stage.box = { slot: box.slot as string, attachment: box.attachment as string };
506
+ return stage;
493
507
  }
494
508
 
495
509
  /**
package/src/ingest.ts CHANGED
@@ -221,6 +221,18 @@ export interface IngestOptions {
221
221
  * document beside the skeleton, only when its digest is that skeleton's).
222
222
  */
223
223
  documentStage?: IngestStage | null;
224
+ /**
225
+ * The slot whose bounding box carries the stage (issue #1168, `--stage-box
226
+ * <slot>`): a rig built with `skeleton.stageBox` carries its stage there,
227
+ * in the files a consumer ships, while the header carries the setup-pose
228
+ * bounding box. Named, that box is read as the stage — in place of the
229
+ * header's box, as `documentStage` is — and the rebuilt rig spec asks for
230
+ * the same box (`skeleton.stageBox`) instead of transcribing it as an
231
+ * attachment. Every shape `build` could not write back is refused by name
232
+ * (`readStageBox`). Absent, nothing is read from a slot: no box is the stage
233
+ * because of its name.
234
+ */
235
+ stageBox?: string;
224
236
  /** The source file's basename, for the provenance note. No path: no leak. */
225
237
  source: string;
226
238
  /** rigc's own version, for the provenance note. Passed in — `src/` reads no files. */
@@ -725,7 +737,8 @@ export function ingest(skeleton: unknown, opts: IngestOptions): IngestResult {
725
737
  // stage is the only value in this whole module that a skeleton cannot answer
726
738
  // for. Since #578 a rig spec can SAY that a skeleton declares no stage, and
727
739
  // since #714 this is where a file that declares none is carried as saying so.
728
- const rigHeader = ingestHeader(obj(root.skeleton), opts, note);
740
+ const stageBox = opts.stageBox === undefined ? null : readStageBox(root, opts.stageBox, note);
741
+ const rigHeader = ingestHeader(obj(root.skeleton), opts, note, stageBox);
729
742
 
730
743
  // -- physics constraints that drive nothing (issue #731) ------------------
731
744
  // Read before the skins, because a skin's `physics` member list is one of the
@@ -785,10 +798,13 @@ export function ingest(skeleton: unknown, opts: IngestOptions): IngestResult {
785
798
  for (const [slot, placeholders] of objEntries(entry.attachments)) {
786
799
  const perSlot: JsonObject = {};
787
800
  for (const [placeholder, att] of Object.entries(placeholders)) {
801
+ // The stage box is the rebuilt spec's `skeleton.stageBox`, which `build` writes from the stage — not an attachment to transcribe (issue #1168).
802
+ if (stageBox !== null && nameOf(entry) === 'default' && slot === stageBox.slot && placeholder === stageBox.attachment) continue;
788
803
  perSlot[placeholder] = ingestAttachment(obj(att), placeholder, boneNames, opts, note, {
789
804
  where: `skin "${nameOf(entry)}" slot "${slot}" attachment "${placeholder}"`,
790
805
  });
791
806
  }
807
+ if (stageBox !== null && nameOf(entry) === 'default' && slot === stageBox.slot) continue;
792
808
  table[slot] = perSlot;
793
809
  }
794
810
  const lists: JsonObject = {};
@@ -1158,6 +1174,115 @@ function spellStage(x: unknown, y: unknown, width: unknown, height: unknown): st
1158
1174
  return [x ?? 0, y ?? 0, width, height].map((value) => String(value)).join(',');
1159
1175
  }
1160
1176
 
1177
+ /**
1178
+ * The rig spec's header fields a skeleton's own header can state — every one
1179
+ * but `stageBox`, which is the caller's reading of a slot (`--stage-box`,
1180
+ * issue #1168) and never a key of the Spine header: a header that carried a
1181
+ * key of that name would otherwise be copied into the rebuilt spec as a
1182
+ * request for a box nobody named.
1183
+ */
1184
+ const HEADER_FIELDS_FROM_FILE: readonly string[] = RIG_KEYS.RigSkeletonHeader.filter((field) => field !== 'stageBox');
1185
+
1186
+ /** The stage box `--stage-box` read (`readStageBox`): its slot, its attachment and the stage its four corners are. */
1187
+ interface StageBoxRead {
1188
+ slot: string;
1189
+ attachment: string;
1190
+ stage: IngestStage;
1191
+ }
1192
+
1193
+ /**
1194
+ * Read the stage from the bounding box in `slotName` (issue #1168,
1195
+ * `--stage-box`): the box `build` writes for a rig that asks for one
1196
+ * (`skeleton.stageBox`), read back as the rebuild's stage.
1197
+ *
1198
+ * 🔒 **Only a box `build` could write back is read, and everything else is
1199
+ * refused by name** — the slot is named by the caller, so a shape that is not
1200
+ * the stage is a mistake to report, not a guess to make. Refused: a slot the
1201
+ * skeleton does not have; a slot on a bone other than the root, or on a root
1202
+ * that states a setup transform (the box would not be the stage in world
1203
+ * space, and `build` refuses the same); a slot with no attachment in the
1204
+ * `default` skin, more than one, or one in another skin; an attachment that
1205
+ * is not a bounding box, states a `name` other than its placeholder, or is not
1206
+ * four unweighted vertices; four vertices that are not the corners of one
1207
+ * axis-aligned rectangle of positive size.
1208
+ *
1209
+ * The stage is the rectangle's least corner and its extent. What the rebuild
1210
+ * writes differently is one lossy finding, `STAGE_BOX_REWRITTEN`: `build`
1211
+ * writes the corners bottom-left first and counter-clockwise and states no
1212
+ * `color`, so a box in another order or with the editor's colour comes back as
1213
+ * the same rectangle, spelled `build`'s way.
1214
+ */
1215
+ function readStageBox(root: JsonObject, slotName: string, note: Note): StageBoxRead {
1216
+ const flag = `--stage-box ${slotName}`;
1217
+ const slot = arr(root.slots).map(obj).find((s) => s.name === slotName);
1218
+ if (slot === undefined) {
1219
+ throw new IngestError(`${flag}: the skeleton has no slot "${slotName}"; it declares [${arr(root.slots).map(nameOf).join(', ')}]`);
1220
+ }
1221
+ const bones = arr(root.bones).map(obj);
1222
+ const bone = bones.find((b) => b.name === slot.bone);
1223
+ if (bone === undefined || bone.parent !== undefined) {
1224
+ throw new IngestError(
1225
+ `${flag}: slot "${slotName}" hangs on bone "${String(slot.bone)}", which is not the root. A stage box is the stage's ` +
1226
+ "corners in world space, and build writes one only on the root — on any other bone the box's numbers are not the stage",
1227
+ );
1228
+ }
1229
+ const moved: string[] = [
1230
+ ...['x', 'y', 'rotation', 'shearX', 'shearY'].filter((key) => bone[key] !== undefined && bone[key] !== 0),
1231
+ ...['scaleX', 'scaleY'].filter((key) => bone[key] !== undefined && bone[key] !== 1),
1232
+ ];
1233
+ if (moved.length > 0) {
1234
+ throw new IngestError(
1235
+ `${flag}: slot "${slotName}" hangs on the root "${nameOf(bone)}", which states ${moved.map((key) => `${key} ${JSON.stringify(bone[key])}`).join(', ')}. ` +
1236
+ "A box on a root that moves at setup is not the stage in world space, and build refuses to write one there",
1237
+ );
1238
+ }
1239
+ const elsewhere = arr(root.skins).map(obj).filter((skin) => skin.name !== 'default' && Object.keys(obj(obj(skin.attachments)[slotName])).length > 0);
1240
+ if (elsewhere.length > 0) {
1241
+ throw new IngestError(
1242
+ `${flag}: slot "${slotName}" is also filled by skin(s) ${elsewhere.map((skin) => `"${nameOf(skin)}"`).join(', ')}. A stage box is its ` +
1243
+ "slot's one attachment, in the default skin, so a reader of that slot finds the stage and nothing else",
1244
+ );
1245
+ }
1246
+ const table = obj(obj(arr(root.skins).map(obj).find((skin) => skin.name === 'default')?.attachments)[slotName]);
1247
+ const placeholders = Object.keys(table);
1248
+ if (placeholders.length !== 1) {
1249
+ throw new IngestError(
1250
+ `${flag}: the default skin holds ${placeholders.length === 0 ? 'no attachment' : `${placeholders.length} attachments [${placeholders.join(', ')}]`} on slot "${slotName}"; ` +
1251
+ "a stage box is its slot's one attachment",
1252
+ );
1253
+ }
1254
+ const attachment = placeholders[0];
1255
+ const att = obj(table[attachment]);
1256
+ const where = `${flag}: attachment "${attachment}" on slot "${slotName}"`;
1257
+ if (att.type !== 'boundingbox') throw new IngestError(`${where} is a ${JSON.stringify(att.type ?? 'region')}, not a bounding box`);
1258
+ if (att.name !== undefined && att.name !== attachment) {
1259
+ throw new IngestError(`${where} states the name ${JSON.stringify(att.name)}; a stage box is named by its placeholder, which is what build writes`);
1260
+ }
1261
+ const vertices = Array.isArray(att.vertices) ? att.vertices : [];
1262
+ if (att.vertexCount !== 4 || vertices.length !== 8 || !vertices.every((v) => typeof v === 'number' && Number.isFinite(v))) {
1263
+ throw new IngestError(
1264
+ `${where} states vertexCount ${JSON.stringify(att.vertexCount)} and ${vertices.length} vertex number(s); the stage is four unweighted ` +
1265
+ 'corners — vertexCount 4 and eight finite numbers',
1266
+ );
1267
+ }
1268
+ const xy = vertices as number[];
1269
+ const xs = [...new Set([xy[0], xy[2], xy[4], xy[6]])].sort((a, b) => a - b);
1270
+ const ys = [...new Set([xy[1], xy[3], xy[5], xy[7]])].sort((a, b) => a - b);
1271
+ const corners = new Set([0, 2, 4, 6].map((i) => `${xy[i]},${xy[i + 1]}`));
1272
+ if (xs.length !== 2 || ys.length !== 2 || corners.size !== 4) {
1273
+ throw new IngestError(`${where} holds [${xy.join(', ')}], which is not the four corners of one axis-aligned rectangle of positive size`);
1274
+ }
1275
+ const stage: IngestStage = { x: xs[0], y: ys[0], width: xs[1] - xs[0], height: ys[1] - ys[0] };
1276
+ const rewritten: string[] = [];
1277
+ const order = [xs[0], ys[0], xs[1], ys[0], xs[1], ys[1], xs[0], ys[1]];
1278
+ if (order.some((v, i) => v !== xy[i])) rewritten.push(`its corners [${xy.join(', ')}] in build's order [${order.join(', ')}], bottom-left first and counter-clockwise`);
1279
+ if (att.color !== undefined) rewritten.push(`no color (the source states ${JSON.stringify(att.color)}, an editor affordance build does not write on a stage box)`);
1280
+ if (rewritten.length > 0) {
1281
+ note('lossy', 'STAGE_BOX_REWRITTEN', `skin "default" slot "${slotName}" attachment "${attachment}"`, `the rebuild writes the same rectangle with ${rewritten.join(', and ')}`);
1282
+ }
1283
+ return { slot: slotName, attachment, stage };
1284
+ }
1285
+
1161
1286
  /**
1162
1287
  * The rig spec's `skeleton` block — and the one judgement in this module.
1163
1288
  *
@@ -1211,10 +1336,29 @@ function spellStage(x: unknown, y: unknown, width: unknown, height: unknown): st
1211
1336
  * a decompiler that is right for a reason it never states is a decompiler nobody
1212
1337
  * can check.
1213
1338
  */
1214
- function ingestHeader(source: JsonObject, opts: IngestOptions, note: Note): JsonObject {
1215
- // A rigc build's stage is its model document's (issue #907, `IngestOptions.documentStage`): read in place of the header's box.
1216
- const head: JsonObject = opts.documentStage === undefined ? source : Object.fromEntries(Object.entries(source).filter(([key]) => !(STAGE_FIELDS as readonly string[]).includes(key)));
1217
- if (opts.documentStage) Object.assign(head, opts.documentStage);
1339
+ function ingestHeader(source: JsonObject, opts: IngestOptions, note: Note, box: StageBoxRead | null = null): JsonObject {
1340
+ // The stage box the caller named (issue #1168) is the file's own statement of its stage, so a second source beside it is refused, as `--stage` beside a declared box is.
1341
+ if (box !== null) {
1342
+ const spelled = spellStage(box.stage.x, box.stage.y, box.stage.width, box.stage.height);
1343
+ if (opts.stage !== undefined) {
1344
+ throw new IngestError(
1345
+ `--stage-box ${box.slot} reads a stage of ${spelled} from the skeleton's bounding box "${box.attachment}" and --stage supplied ` +
1346
+ `${spellStage(opts.stage.x, opts.stage.y, opts.stage.width, opts.stage.height)}: two sources for one value. Drop one of the two flags`,
1347
+ );
1348
+ }
1349
+ const doc = opts.documentStage;
1350
+ if (doc !== undefined && (doc === null || spellStage(doc.x, doc.y, doc.width, doc.height) !== spelled)) {
1351
+ throw new IngestError(
1352
+ `--stage-box ${box.slot} reads a stage of ${spelled} from the skeleton's bounding box "${box.attachment}", and the model document ` +
1353
+ `beside it states ${doc === null ? 'no stage' : spellStage(doc.x, doc.y, doc.width, doc.height)}: two statements of one stage that ` +
1354
+ "disagree, about one build. rigc will not choose between them — A50_STAGE_BOX_IS_THE_STAGE refuses such a build, so one of the files was edited after it",
1355
+ );
1356
+ }
1357
+ }
1358
+ // A rigc build's stage is its model document's (issue #907, `IngestOptions.documentStage`) or its stage box's (issue #1168): read in place of the header's box.
1359
+ const replaced = box !== null ? box.stage : opts.documentStage;
1360
+ const head: JsonObject = replaced === undefined ? source : Object.fromEntries(Object.entries(source).filter(([key]) => !(STAGE_FIELDS as readonly string[]).includes(key)));
1361
+ if (replaced) Object.assign(head, replaced);
1218
1362
  // 🚨 Before a line of transcription, because a header the caller contradicted
1219
1363
  // is not a header to start writing a spec from (issue #626).
1220
1364
  if (declaresStage(head) && opts.stage !== undefined) {
@@ -1226,9 +1370,11 @@ function ingestHeader(source: JsonObject, opts: IngestOptions, note: Note): Json
1226
1370
  );
1227
1371
  }
1228
1372
  const out: JsonObject = {};
1229
- for (const field of RIG_KEYS.RigSkeletonHeader) if (head[field] !== undefined) out[field] = head[field];
1373
+ for (const field of HEADER_FIELDS_FROM_FILE) if (head[field] !== undefined) out[field] = head[field];
1374
+ // The stage box is the caller's reading of a slot, never a header key: a Spine header has no such field (issue #1168).
1375
+ if (box !== null) out.stageBox = { slot: box.slot, attachment: box.attachment };
1230
1376
  for (const key of Object.keys(head)) {
1231
- if ((RIG_KEYS.RigSkeletonHeader as readonly string[]).includes(key)) continue;
1377
+ if (HEADER_FIELDS_FROM_FILE.includes(key)) continue;
1232
1378
  if (HEADER_REDERIVED.includes(key)) {
1233
1379
  // Two-sided on purpose: the same fact reads as bookkeeping when the two
1234
1380
  // agree and as a warning when they do not, and a reader needs to be told
@@ -1285,7 +1431,7 @@ function ingestHeader(source: JsonObject, opts: IngestOptions, note: Note): Json
1285
1431
  // The absence, carried: the pair goes where `RIG_KEYS` orders it, so the
1286
1432
  // spec reads like one a transcriber would have written by hand.
1287
1433
  const carried: JsonObject = {};
1288
- for (const field of RIG_KEYS.RigSkeletonHeader) {
1434
+ for (const field of HEADER_FIELDS_FROM_FILE) {
1289
1435
  if (field === 'width' || field === 'height') carried[field] = null;
1290
1436
  else if (out[field] !== undefined) carried[field] = out[field];
1291
1437
  }
package/src/model.ts CHANGED
@@ -593,6 +593,22 @@ export interface ModelStage {
593
593
  y: number;
594
594
  width: number;
595
595
  height: number;
596
+ /**
597
+ * Where the stage also travels in the Spine files, when the rig asked for it
598
+ * (`skeleton.stageBox`, issue #1168): the slot and the attachment name of the
599
+ * bounding box `compile` wrote from these four numbers. Absent where the rig
600
+ * did not ask, so no document written before the field existed moves a byte;
601
+ * a `/3` reader that predates it refuses the field by name rather than
602
+ * reading the document without it. `A50_STAGE_BOX_IS_THE_STAGE` reads it on
603
+ * both suppliers.
604
+ */
605
+ box?: ModelStageBox;
606
+ }
607
+
608
+ /** The stage box a rig asked for (`ModelStage.box`): the slot it is in and its attachment name. */
609
+ export interface ModelStageBox {
610
+ slot: string;
611
+ attachment: string;
596
612
  }
597
613
 
598
614
  /**
@@ -883,7 +899,10 @@ function animationOf(animation: CompiledAnimation, where: string): { [key: strin
883
899
 
884
900
  /** The stage's fields, in the order the Spine header writes them. */
885
901
  export const MODEL_STAGE_FIELDS = ['x', 'y', 'width', 'height'] as const;
886
- const STAGE_FIELDS: readonly string[] = MODEL_STAGE_FIELDS;
902
+ /** The stage box's fields (issue #1168), in `ModelStageBox`'s order. */
903
+ export const MODEL_STAGE_BOX_FIELDS = ['slot', 'attachment'] as const;
904
+ /** What the `stage` section writes: the four numbers, then the box where the rig asked for one. */
905
+ const STAGE_FIELDS: readonly string[] = [...MODEL_STAGE_FIELDS, 'box'];
887
906
 
888
907
  /**
889
908
  * The `editorOrder` section: `{ skins: [{ name, slots }], animations }`,
@@ -1027,7 +1046,7 @@ export function modelDocument(model: CompiledModel, skeletonText: string, atlasT
1027
1046
  const doc: { [key: string]: DocValue } = {
1028
1047
  spec: MODEL_DOCUMENT_SPEC,
1029
1048
  referenceScale: plain(model.referenceScale, 'referenceScale'),
1030
- stage: model.stage === null ? null : ordered(model.stage, STAGE_FIELDS, 'stage'),
1049
+ stage: model.stage === null ? null : ordered(model.stage, STAGE_FIELDS, 'stage', (key, v) => (key === 'box' ? ordered(v as object, MODEL_STAGE_BOX_FIELDS, 'stage.box') : plain(v, `stage.${key}`))),
1031
1050
  bones: model.bones.map((bone, i) =>
1032
1051
  ordered(bone, BONE_FIELDS, `bones[${i}]`, (key, v) => (key === 'editor' ? ordered(v as object, ['color', 'icon'], `bones[${i}].editor`) : plain(v, `bones[${i}].${key}`))),
1033
1052
  ),