@rig-c/core 2.69.0 → 2.71.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rig-c/core",
3
- "version": "2.69.0",
3
+ "version": "2.71.0",
4
4
  "description": "AI-authored 2D rigging and animation, verified before it is written. rigc compiles a rig spec into skeleton data in the Spine 4.3 format, gates it with named assertions held to a spine-core round trip's verdicts, and emits nothing that fails. Ships as an agent skill.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -19,6 +19,7 @@
19
19
  "./errors": "./src/errors.ts",
20
20
  "./meshcompare": "./src/meshcompare.ts",
21
21
  "./core": "./src/core/entry.ts",
22
+ "./runtime": "./src/runtime/index.ts",
22
23
  "./cli": "./cli.ts",
23
24
  "./package.json": "./package.json",
24
25
  "./*.ts": "./*.ts",
@@ -32,7 +33,7 @@
32
33
  "./*": "./*.ts"
33
34
  },
34
35
  "engines": {
35
- "bun": ">=1.2.0"
36
+ "bun": ">=1.4.0"
36
37
  },
37
38
  "license": "MIT",
38
39
  "author": "firejune",
@@ -106,6 +107,8 @@
106
107
  "@esotericsoftware/spine-core": "4.3.13",
107
108
  "@types/bun": "^1.4.0",
108
109
  "eslint": "^10.9.0",
110
+ "phaser": "3.90.0",
111
+ "pixi.js": "8.22.0",
109
112
  "spine-html": "^0.4.1",
110
113
  "typescript": "^5.9.3",
111
114
  "typescript-eslint": "^8.67.0",
@@ -2,7 +2,7 @@
2
2
  name: rigc
3
3
  description: Author, build and validate Spine 4.3 skeleton data (skeleton.json plus its .atlas) from loose part PNGs with rigc, the rig compiler that verifies its own output with named assertions before writing it. Use for any request to make a Spine rig or Spine animation from PNG parts, or where the source is a Live2D, Unity or video model whose pictures you can render, to run or read rigc build, validate, render, preview, check or vote, or to write or fix a *.rig.json or *.motion.json spec; it says which shipped guide to open for the need at hand. Not for Live2D conversion, cutting an illustration into parts, or real-time face tracking.
4
4
  license: MIT
5
- compatibility: Requires Bun 1.2 or later. The tool is the npm package rig-c (bunx rig-c, or bun add -d rig-c); the command it installs is rigc.
5
+ compatibility: Requires Bun 1.4 or later. The tool is the npm package rig-c (bunx rig-c, or bun add -d rig-c); the command it installs is rigc.
6
6
  ---
7
7
 
8
8
  # rigc — a rig compiler for agents
@@ -2,7 +2,7 @@
2
2
  name: rigc-face
3
3
  description: Author a face on plain Spine data with rigc — a blink, a gaze shift, a breathing portrait and a head turn a few degrees off axis, built from deform timelines and per-part parallax. Use when the request is a talking or living portrait, a standing character, an expression or a head turn, such as "rig this face", "make the portrait blink and look around" or "turn the head". Not for Live2D file conversion, cutting a face illustration into parts, or VTuber-style real-time face tracking.
4
4
  license: MIT
5
- compatibility: Requires Bun 1.2 or later and the npm package rig-c.
5
+ compatibility: Requires Bun 1.4 or later and the npm package rig-c.
6
6
  ---
7
7
 
8
8
  # Face — a turn, a gaze and a blink
@@ -2,7 +2,7 @@
2
2
  name: rigc-ingest
3
3
  description: Work with a Spine skeleton.json somebody else authored — exported from the Spine editor or another tool — using rigc. Read and validate it, understand a complaint rigc raised about it, decompile it into rigc specs with `rigc ingest`, normalise, re-pivot or rename it, and extend it with an animation it does not have. Use when the input is an existing skeleton.json with its .atlas and page images rather than loose part PNGs. Not for Live2D file conversion or runtime tracking.
4
4
  license: MIT
5
- compatibility: Requires Bun 1.2 or later and the npm package rig-c.
5
+ compatibility: Requires Bun 1.4 or later and the npm package rig-c.
6
6
  ---
7
7
 
8
8
  # Ingest — a skeleton you did not author
@@ -2,7 +2,7 @@
2
2
  name: rigc-motion
3
3
  description: Author a Spine animation with rigc from key poses — an idle, a loop, a move from one picture to another — with timing and spacing, ease in and out, anticipation, arcs, overlap, follow-through, squash and stretch, and candidate variants a person can choose between. Use when the request is a movement on an existing or planned rig, in the animator's words too: "animate this rig", "make it breathe", "go from pose A to pose B", "make it feel heavier", "the cape should follow through". Not for Live2D, separating an image into parts, or real-time tracking.
4
4
  license: MIT
5
- compatibility: Requires Bun 1.2 or later and the npm package rig-c.
5
+ compatibility: Requires Bun 1.4 or later and the npm package rig-c.
6
6
  ---
7
7
 
8
8
  # Motion — what goes between two poses
@@ -2,7 +2,7 @@
2
2
  name: rigc-rigging
3
3
  description: Decide a Spine rig's hierarchy with rigc — how many bones, where each pivot sits, what hangs off what, offsets, chains and what a chain can reach, siblings versus chains, constraints as structure — and which of those decisions the reference frames can check. Use when the request is a skeleton from loose part PNGs, such as "rig these parts", "make a Spine skeleton" or "where do the joints go", before any motion is authored. Not for Live2D, cutting an illustration into parts, or VTuber-style tracking.
4
4
  license: MIT
5
- compatibility: Requires Bun 1.2 or later and the npm package rig-c.
5
+ compatibility: Requires Bun 1.4 or later and the npm package rig-c.
6
6
  ---
7
7
 
8
8
  # Rigging — the hierarchy itself
@@ -20,7 +20,7 @@ export const VALIDATE_PROFILES: readonly AssertionProfile[] = ['spine', 'rig-pla
20
20
  * the target and never the alias.
21
21
  */
22
22
  export function profileAliasLine(alias: string, target: AssertionProfile): string {
23
- return `rigc: --profile ${alias} is deprecated and reads as --profile ${target}, the same rules; it is accepted until the next minor release — pass --profile ${target}`;
23
+ return `rigc: --profile ${alias} is deprecated and reads as --profile ${target}, the same rules; it is accepted until the next major release — pass --profile ${target}`;
24
24
  }
25
25
 
26
26
  /**
package/src/atlas.ts CHANGED
@@ -775,6 +775,11 @@ export interface PackInput {
775
775
  absPath: string;
776
776
  width: number;
777
777
  height: number;
778
+ /**
779
+ * Already-drawn pixels (`render --sheet`), used in place of `readPlate(absPath)`
780
+ * when given so an in-memory crop is not written out only to be read back in.
781
+ */
782
+ plate?: Plate;
778
783
  /**
779
784
  * What the region's attachments draw (`packFootprints`), read only under
780
785
  * `shape: 'polygon'`. Absent means the rectangle.
@@ -2535,7 +2540,7 @@ export function packAtlas(inputs: PackInput[], opts: PackOptions = {}): PackResu
2535
2540
  const owners: Array<{ source: Plate; place: Placement; shape: CellShape }> = [];
2536
2541
  for (const place of onPage) {
2537
2542
  const input = byRegion.get(place.region)!;
2538
- const source = readPlate(input.absPath);
2543
+ const source = input.plate ?? readPlate(input.absPath);
2539
2544
  if (source.width !== input.width || source.height !== input.height) {
2540
2545
  // The size in the atlas came from the PNG's IHDR (`readPngInfo`); this is
2541
2546
  // the decoded image. They disagreeing means the file changed between the
@@ -40,6 +40,7 @@ import { outsideLine, outsideViewport, pinnedRecord, readViewportBox, viewportOf
40
40
  import { PASS_THROUGH_FILE, passFrameLine, type PassPair, passSummaryLine, passThroughFileOf, passThroughOf, passThroughText, PassThroughError, readPassPairs } from '../passthrough.ts';
41
41
  import type { Plate } from '../../tools/plate.ts';
42
42
  import { OVERLAPS_FILE, OverlapError, overlapLine, overlapPlate, overlapsFileOf, overlapsOf, overlapsPlateFile, overlapsText, plateParticipants, staticLine } from '../overlaps.ts';
43
+ import { buildSheetSet, SHEET_MANIFEST_FILE, type SheetSetPack, SheetUsageError, sheetRigNameOf, sheetSidecarRecord, sheetSourceOf } from '../sheet.ts';
43
44
  import { readSeamPairs, SEAM_FILE, seamFileOf, seamLine, SeamError, type SeamPair, seamsOf, seamText } from '../seam.ts';
44
45
  import { environmentCollisions, environmentFrame, EnvironmentError, environmentResponse, readEnvironmentSchedule, type EnvironmentStep } from '../environment.ts';
45
46
  import { estimatePose, poseLines, type PoseOptions } from '../pose.ts';
@@ -49,7 +50,7 @@ import { attachmentRegionJoins } from '../region_joins.ts';
49
50
  import { cmdRepack } from './repack.ts';
50
51
  import { writeWhole } from './write_whole.ts';
51
52
  import { GUIDE_FILE, GUIDE_RULE_TABLES_ELSEWHERE, guideAnswer, refusalLine } from '../guide.ts';
52
- import { ATLAS_ABSENT, COMMANDS, type CommandRun, DelegateError, modelTextBeside, resolveBuild, type BuildGate, runBuild, PACKAGE_ROOT, DEFAULT_CHAINFIT_OUT, DEFAULT_POSE_OUT, DEFAULT_SKILLS_DIR, ExplainError, GuideError, meshBudget, meshDepthNote, meshFit, meshInfluenceNote, PAGE_GRID_UNLOCATED, readAnimationFlag, readJsonFile, readPoserFlag, readSkeletonText, readVersion, resolveCut, resolveDrawable, runCheck, SkillsInstallError, STAGELESS_FRAMING, UsageError, writeJson, documentStageBeside } from './shared.ts';
53
+ import { ATLAS_ABSENT, COMMANDS, type CommandRun, DelegateError, modelTextBeside, resolveBuild, type BuildGate, runBuild, PACKAGE_ROOT, DEFAULT_CHAINFIT_OUT, DEFAULT_POSE_OUT, DEFAULT_SKILLS_DIR, ExplainError, GuideError, meshBudget, meshDepthNote, meshFit, meshInfluenceNote, PAGE_GRID_UNLOCATED, readAnimationFlag, readJsonFile, readPageEdges, readPoserFlag, readSheetFormat, readSkeletonText, readVersion, resolveCut, resolveDrawable, runCheck, SkillsInstallError, STAGELESS_FRAMING, UsageError, writeJson, documentStageBeside } from './shared.ts';
53
54
  import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
54
55
  import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
55
56
  import { spawnSync } from 'node:child_process';
@@ -656,6 +657,18 @@ export function cmdRender(flags: Record<string, string>): void {
656
657
  const maxSide = readPositiveNumber(flags, 'max', 256, 16);
657
658
  // A box given rather than fitted (issue #1414), read before a line is printed, so a malformed one is refused first.
658
659
  const given = readGivenViewport(flags);
660
+ const wantsSheet = flags.sheet !== undefined;
661
+ if (flags['sheet-format'] !== undefined && !wantsSheet) {
662
+ throw new UsageError('--sheet-format takes --sheet: pass --sheet alongside --sheet-format <format>, or drop --sheet-format');
663
+ }
664
+ if (flags['page-edges'] !== undefined && !wantsSheet) {
665
+ throw new UsageError(
666
+ '--page-edges on render takes --sheet: without --sheet, render writes loose f####.png frames and contact.png, ' +
667
+ 'which have no packed atlas pages. Pass --sheet or drop --page-edges',
668
+ );
669
+ }
670
+ const sheetFormat = wantsSheet ? readSheetFormat(flags) : undefined;
671
+ const sheetPageEdges = wantsSheet ? readPageEdges(flags) : undefined;
659
672
  const outRoot = resolve(flags.out ?? 'render');
660
673
  const geometry = flags.geometry !== undefined;
661
674
  // The fold reading (issue #1383) is its own flag rather than a part of --geometry, so a caller that asked for
@@ -953,9 +966,45 @@ export function cmdRender(flags: Record<string, string>): void {
953
966
  }
954
967
  }
955
968
  }
956
- return { viewport, fitted, sampled, geometryTexts, foldReadings, passReadings, seamReadings, angleReadings, overlapReadings };
969
+ const sheetPacks = new Map<string, SheetSetPack>();
970
+ if (wantsSheet) {
971
+ const modelDocText = modelTextBeside(skeletonPath, skeletonText) ?? null;
972
+ const rigName = sheetRigNameOf(skeletonPath, modelDocText, skeletonText);
973
+ const source = sheetSourceOf(skeletonPath, skeletonText, modelDocText);
974
+ const pinned = given === undefined ? undefined : pinnedRecord(given.by, outsideViewport(sampled, viewport));
975
+ for (const [name, frames] of sampled) {
976
+ const animation = name === SETUP_POSE_DIR && facts.animations.length === 0 ? null : name;
977
+ const setName = frameSetDir(name, fps);
978
+ try {
979
+ sheetPacks.set(
980
+ name,
981
+ buildSheetSet({
982
+ rigName,
983
+ setName,
984
+ animation,
985
+ fps,
986
+ maxSide,
987
+ viewport,
988
+ frames,
989
+ atlasPages: pages,
990
+ source,
991
+ format: sheetFormat,
992
+ pageEdges: sheetPageEdges,
993
+ skin,
994
+ subset,
995
+ environment: environment?.steps,
996
+ pinned,
997
+ }),
998
+ );
999
+ } catch (err) {
1000
+ if (err instanceof SheetUsageError) throw new UsageError(err.message);
1001
+ throw err;
1002
+ }
1003
+ }
1004
+ }
1005
+ return { viewport, fitted, sampled, geometryTexts, foldReadings, passReadings, seamReadings, angleReadings, overlapReadings, sheetPacks };
957
1006
  });
958
- const { viewport, fitted, sampled, geometryTexts, foldReadings, passReadings, seamReadings, angleReadings, overlapReadings } = posed.value;
1007
+ const { viewport, fitted, sampled, geometryTexts, foldReadings, passReadings, seamReadings, angleReadings, overlapReadings, sheetPacks } = posed.value;
959
1008
  console.log(` .. poser ${posed.note}`);
960
1009
  if (environment !== undefined) {
961
1010
  const response = environmentResponse(skeletonText);
@@ -1014,6 +1063,14 @@ export function cmdRender(flags: Record<string, string>): void {
1014
1063
  writeFileSync(join(dir, OVERLAPS_FILE), overlapsText(overlapReading.file));
1015
1064
  for (const [index, plate] of overlapReading.plates) plate.writePng(join(dir, overlapsPlateFile(index)));
1016
1065
  }
1066
+ const sheetPack = sheetPacks.get(name);
1067
+ if (sheetPack !== undefined) {
1068
+ for (const page of sheetPack.pages) {
1069
+ page.plate.writePng(join(dir, page.image));
1070
+ writeFileSync(join(dir, page.table), page.jsonText);
1071
+ }
1072
+ writeFileSync(join(dir, SHEET_MANIFEST_FILE), sheetPack.manifestText);
1073
+ }
1017
1074
  const duration = frames[frames.length - 1].time;
1018
1075
  const physics = physicsDeclared.length === 0 ? undefined : physicsSteppedOf(frames, fps);
1019
1076
  sets.push({
@@ -1026,6 +1083,7 @@ export function cmdRender(flags: Record<string, string>): void {
1026
1083
  duration,
1027
1084
  ...(physics === undefined ? {} : { physics: { stepped: physics.stepped.length, fps } }),
1028
1085
  ...(overlapReading === undefined ? {} : { overlaps: overlapsRecord(overlapReading.file, overlapReading.plates.size) }),
1086
+ ...(sheetPack === undefined ? {} : { sheet: sheetSidecarRecord(sheetPack) }),
1029
1087
  });
1030
1088
  const how = frames.length === 1 ? 'a single pose' : `${duration.toFixed(3)}s`;
1031
1089
  const folding = foldReading?.meshes.filter((m) => m.worst !== null).length ?? 0;
@@ -1038,7 +1096,11 @@ export function cmdRender(flags: Record<string, string>): void {
1038
1096
  overlapReading === undefined
1039
1097
  ? ''
1040
1098
  : ` + ${OVERLAPS_FILE} (${overlapReading.file.pairs.filter((p) => p.varies).length} moving pair(s), ${overlapReading.plates.size} outline plate(s))`;
1041
- const extras = `${sheet ? ` + ${SHEET_FILE}` : ''}${geometryOut === undefined ? '' : ` + ${GEOMETRY_FILE}`}${foldNote}${passNote}${seamNote}${overlapNote}`;
1099
+ const sheetNote =
1100
+ sheetPack === undefined
1101
+ ? ''
1102
+ : ` + ${sheetPack.pages.map((p) => `${p.image} + ${p.table}`).join(' + ')} + ${SHEET_MANIFEST_FILE} (${sheetPack.pages.length} page(s), ${sheetPack.pageEdges})`;
1103
+ const extras = `${sheet ? ` + ${SHEET_FILE}` : ''}${sheetNote}${geometryOut === undefined ? '' : ` + ${GEOMETRY_FILE}`}${foldNote}${passNote}${seamNote}${overlapNote}`;
1042
1104
  console.log(` .. ${name.padEnd(16)} ${frames.length} frame(s), ${how}${extras} -> ${dir}`);
1043
1105
  if (physics !== undefined) console.log(` .. ${physicsLine(physics.stepped, physicsDeclared, fps, frames.length)}`);
1044
1106
  for (const mesh of foldReading?.meshes ?? []) {
@@ -2313,7 +2375,7 @@ function resolveDelegate(name: string): string | null {
2313
2375
 
2314
2376
  /**
2315
2377
  * Whether the Bun running this hands an empty argument to a process it starts
2316
- * (issue #1465). Bun 1.2.0 — the declared minimum — does not: `["a", "", "b"]`
2378
+ * (issue #1465). Bun 1.2.0 (then the declared minimum) does not: `["a", "", "b"]`
2317
2379
  * reaches the child as `["a", "b"]`, through `spawnSync` and `Bun.spawnSync`
2318
2380
  * alike and whatever the child is; 1.4.2 hands it over. That is measured here
2319
2381
  * rather than read off a version number, by starting this Bun twice with
@@ -2466,7 +2528,7 @@ function cmdDelegate(command: string, raw: readonly string[]): void {
2466
2528
  // `rigc review -- <dir>` reached the bin as `<dir>` (measured, issue #1486). One given here is the one dropped, and
2467
2529
  // the arguments after it arrive as they were.
2468
2530
  const ran = spawnSync(process.execPath, [bin, '--', ...argv], { stdio: 'inherit' });
2469
- // ⚠️ The exit and the signal are read before `error`: Bun 1.2.0 — the declared minimum — sets `error` on every
2531
+ // ⚠️ The exit and the signal are read before `error`: Bun 1.2.0 (then the declared minimum) sets `error` on every
2470
2532
  // child that exits non-zero, so reading it first refused review's own exit 7 as "could not run". `error` is a
2471
2533
  // launch that failed only where the child left neither a status nor a signal.
2472
2534
  if (typeof ran.status === 'number') process.exit(ran.status);
package/src/cli/shared.ts CHANGED
@@ -41,6 +41,7 @@ import { FOLDS_FILE } from '../folds.ts';
41
41
  import { DRAWN_ALPHA, PASS_THROUGH_FILE, PASS_THROUGH_RUN } from '../passthrough.ts';
42
42
  import { SEAM_FILE } from '../seam.ts';
43
43
  import { OVERLAPS_FILE } from '../overlaps.ts';
44
+ import { DEFAULT_SHEET_FORMAT, SHEET_FORMATS, SHEET_MANIFEST_FILE, SHEET_MANIFEST_SPEC, SHEET_PADDING, SHEET_PAGE_SIZE, type SheetFormat } from '../sheet.ts';
44
45
  import { readViewportBox, ViewportError } from '../viewportpin.ts';
45
46
  import { parseJsonWithPosition } from '../json-position.ts';
46
47
  import { RUNG_IDS } from '../ladder.ts';
@@ -151,7 +152,7 @@ export { PACKAGE_ROOT, readPackageMeta, readVersion };
151
152
  * needs a value` (issue #328). `CLI10`/`CLI11` in `selftest.ts` now hold the two
152
153
  * halves together by reading `--help` rather than by naming a flag.
153
154
  */
154
- const BOOLEAN_FLAGS = new Set(['all', 'all-frames', 'all-bones', 'help', 'copy-images', 'again', 'pack', 'copy', 'geometry', 'folds', 'overlaps', 'accept-skeleton-differences', 'keep-pages', 'dim']);
155
+ const BOOLEAN_FLAGS = new Set(['all', 'all-frames', 'all-bones', 'help', 'copy-images', 'again', 'pack', 'copy', 'geometry', 'folds', 'overlaps', 'sheet', 'accept-skeleton-differences', 'keep-pages', 'dim']);
155
156
 
156
157
  /**
157
158
  * The flags a command is allowed to spell more than once.
@@ -1078,6 +1079,15 @@ const FLAG_MEANINGS: Record<string, string> = {
1078
1079
  'pair per set: the lowest and the highest angle in degrees, the span, and the frame each was first reached on. Read on the frames\' own ' +
1079
1080
  'grid, so a peak between two frames is not read. A reading, not a gate: nothing is written. An unknown bone, a bone against itself, ' +
1080
1081
  'a malformed list and a pair named twice are refused by name, and so is a bone with no direction on a frame (a world matrix of zero)',
1082
+ sheet:
1083
+ `also pack each rendered set's frames over a transparent [0, 0, 0, 0] canvas into deterministic RGBA8 sprite sheet page(s) ` +
1084
+ `(\`sheet.png\`, \`sheet2.png\`, …; max edge ${SHEET_PAGE_SIZE}, padding ${SHEET_PADDING} with clamped edge extrusion, trimmed to alpha > 0 ` +
1085
+ 'with a 1x1 transparent sentinel for empty frames), a shared TexturePacker-style JSON-Hash table per page (`sheet.json`, `sheet2.json`, …; ' +
1086
+ `\`anchor\` at world (0, 0)), and \`${SHEET_MANIFEST_FILE}\` (\`${SHEET_MANIFEST_SPEC}\`) ` +
1087
+ 'in each set directory; diagnostic `f####.png` and `contact.png` stay over opaque `BACKGROUND` unchanged',
1088
+ 'sheet-format':
1089
+ `the table format \`render --sheet\` writes beside each sheet page (default \`${DEFAULT_SHEET_FORMAT}\`, verified against pixi.js@8.22.0 and phaser@3.90.0 by tools/sheet_readers.ts; known formats: ${SHEET_FORMATS.join(', ')}). ` +
1090
+ 'Takes --sheet',
1081
1091
  'viewport-from':
1082
1092
  "draw on an earlier render's grid exactly — the box, the scale and the pixel size its frames.json records (the file, or the " +
1083
1093
  'directory `render --out` wrote it into) — so a world point lands on the same pixel in both renders. A slot that reaches past ' +
@@ -1285,6 +1295,7 @@ const FLAG_VALUES: Record<string, string> = {
1285
1295
  'pass-through': '<cloth>:<leg>[,<cloth>:<leg>…]',
1286
1296
  seam: '<slot>:<slot>[,<slot>:<slot>…]',
1287
1297
  'angle-range': '<bone>:<relative-to>[,…]',
1298
+ 'sheet-format': 'json-hash',
1288
1299
  'viewport-from': '<frames.json|dir>',
1289
1300
  reference: '<dir|skeleton.json>',
1290
1301
  'reference-atlas': '<path>',
@@ -1753,9 +1764,15 @@ export const COMMANDS: CommandDoc[] = [
1753
1764
  'rigc render … --overlaps (which slot each frame drew over which, by name, with an outline plate)',
1754
1765
  'rigc render … --angle-range <bone>:<relative-to>[,…] (how far a bone turns against another, per animation)',
1755
1766
  'rigc render … --viewport <x,y,w,h> | --viewport-from <frames.json> (a box given, not fitted: two renders on one pixel grid)',
1767
+ 'rigc render … --sheet [--sheet-format json-hash] [--page-edges pot|free] (deterministic sprite sheets + JSON hash table + sheet-manifest.json per set)',
1756
1768
  ],
1757
- flags: ['candidate', 'atlas', 'animation', 'skin', 'slot', 'hide', 'fps', 'max', 'viewport', 'viewport-from', 'geometry', 'folds', 'pass-through', 'seam', 'overlaps', 'angle-range', 'environment', 'poser', 'out'],
1769
+ flags: ['candidate', 'atlas', 'animation', 'skin', 'slot', 'hide', 'fps', 'max', 'viewport', 'viewport-from', 'geometry', 'folds', 'pass-through', 'seam', 'overlaps', 'angle-range', 'sheet', 'sheet-format', 'page-edges', 'environment', 'poser', 'out'],
1758
1770
  overrides: {
1771
+ 'page-edges': {
1772
+ meaning:
1773
+ `what each sprite sheet page's edges may be, --sheet only (default ${DEFAULT_PAGE_EDGES}, max edge ${SHEET_PAGE_SIZE}, padding ${SHEET_PADDING}): ` +
1774
+ 'pot is a power of two on both; free chooses the smallest-area rectangle up to the max edge that holds the trimmed frames',
1775
+ },
1759
1776
  out: {
1760
1777
  value: '<dir>',
1761
1778
  meaning:
@@ -2252,6 +2269,17 @@ export function readPageEdges(flags: Record<string, string>): PageEdges {
2252
2269
  return found;
2253
2270
  }
2254
2271
 
2272
+ /**
2273
+ * Read `--sheet-format`, or its default — `json-hash` (issue #1560).
2274
+ */
2275
+ export function readSheetFormat(flags: Record<string, string>): SheetFormat {
2276
+ const raw = flags['sheet-format'];
2277
+ if (raw === undefined) return DEFAULT_SHEET_FORMAT;
2278
+ const found = SHEET_FORMATS.find((f) => f === raw);
2279
+ if (!found) throw new UsageError(`--sheet-format ${JSON.stringify(raw)}; known formats: ${SHEET_FORMATS.join(', ')}`);
2280
+ return found;
2281
+ }
2282
+
2255
2283
  /**
2256
2284
  * Read `--pack-shape`, or its default — `rect` (issue #1099).
2257
2285
  *
@@ -3,7 +3,7 @@
3
3
  * refusal — the writer a command uses for a document too large to trust to
4
4
  * `console.log` (issue #1487).
5
5
  *
6
- * ⚖️ Why not `console.log`: on Bun 1.2.0, the declared minimum, one ~322 KB
6
+ * ⚖️ Why not `console.log`: on Bun 1.2.0 (then the declared minimum), one ~322 KB
7
7
  * line with non-ASCII in it came out 330,634 bytes for 330,632, a stray
8
8
  * `\xe2\x00` at a 64 KiB chunk boundary — to a file, a pipe and a slow pipe
9
9
  * alike. And on a stdout whose pipe is non-blocking (a parent set `O_NONBLOCK`
package/src/compile.ts CHANGED
@@ -3015,6 +3015,46 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
3015
3015
  // The same for a `soft` mask's per-vertex level, which a deform envelope's
3016
3016
  // `mask` reads (issue #1413). Never emitted either.
3017
3017
  const attachmentSoftMasks = new Map<string, DeformSoftMask>();
3018
+ // `skin/slot/attachment` -> per-axis narrowing mode when a deform turn key on
3019
+ // that attachment declares `narrowing: false` (issue #1584), so `sampleMeshDepth`
3020
+ // evaluates `turnCeiling` with the `asin(|A₀ / A_axis|)` closed form that
3021
+ // matches `A39` on the keyed turn.
3022
+ const attachmentTurnNarrowing = new Map<string, { yaw?: boolean; pitch?: boolean }>();
3023
+ const attachmentTurnNarrowingBy = new Map<string, { yaw?: { mode: boolean; anim: string }; pitch?: { mode: boolean; anim: string } }>();
3024
+ withMotionSource(() => {
3025
+ for (const [turnAnimName, turnAnim] of Object.entries(motion.animations ?? {}).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) {
3026
+ for (const turnTrack of turnAnim.deform ?? []) {
3027
+ const turnDeformSkin = turnTrack.skin ?? DEFAULT_SKIN;
3028
+ const turnTriple = `${turnDeformSkin}/${turnTrack.slot}/${turnTrack.attachment}`;
3029
+ for (const turnKey of turnTrack.keys ?? []) {
3030
+ const turnSpec = turnKey.transform as { kind?: unknown; narrowing?: unknown } | undefined;
3031
+ if (turnSpec && (turnSpec.kind === 'yaw' || turnSpec.kind === 'pitch')) {
3032
+ const turnAxis = turnSpec.kind;
3033
+ const turnNarrowMode = turnSpec.narrowing !== false;
3034
+ const turnPrev = attachmentTurnNarrowingBy.get(turnTriple)?.[turnAxis];
3035
+ if (turnPrev !== undefined && turnPrev.mode !== turnNarrowMode) {
3036
+ const turnFalseAnim = turnNarrowMode ? turnPrev.anim : turnAnimName;
3037
+ const turnTrueAnim = turnNarrowMode ? turnAnimName : turnPrev.anim;
3038
+ throw new CompileError(
3039
+ `deform ${turnTriple} keys "${turnAxis}" with narrowing=false in animation "${turnFalseAnim}" and with narrowing=true in animation "${turnTrueAnim}". ` +
3040
+ 'An attachment\'s turn ceiling on one axis is either asin(|A₀ / A_axis|) (without narrowing) or atan(|A₀ / A_axis|) (with narrowing) — ' +
3041
+ `mixing both on one attachment and axis would report one ceiling where two apply. Use one "narrowing" setting across every "${turnAxis}" key on ${turnTriple}.`,
3042
+ 'CE_COMPILE_DEFORM_TURN_NARROWING_MIXED',
3043
+ );
3044
+ }
3045
+ const curBy = attachmentTurnNarrowingBy.get(turnTriple) ?? {};
3046
+ if (curBy[turnAxis] === undefined) curBy[turnAxis] = { mode: turnNarrowMode, anim: turnAnimName };
3047
+ attachmentTurnNarrowingBy.set(turnTriple, curBy);
3048
+ if (!turnNarrowMode) {
3049
+ const cur = attachmentTurnNarrowing.get(turnTriple) ?? {};
3050
+ cur[turnAxis] = false;
3051
+ attachmentTurnNarrowing.set(turnTriple, cur);
3052
+ }
3053
+ }
3054
+ }
3055
+ }
3056
+ }
3057
+ });
3018
3058
 
3019
3059
  for (const rigSlot of rig.slots) {
3020
3060
  const part = partBySlot.get(rigSlot.name);
@@ -3183,6 +3223,7 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
3183
3223
  imagesDir,
3184
3224
  depths: attachmentDepths,
3185
3225
  softMasks: attachmentSoftMasks,
3226
+ turnNarrowing: attachmentTurnNarrowing.get(`${skinName}/${rigSlot.name}/${placeholder}`),
3186
3227
  slotNames: new Set(rig.slots.map((s) => s.name)),
3187
3228
  atlasRegions: atlasIn === null ? null : atlasIn.byName,
3188
3229
  links: pendingLinks,
@@ -4333,6 +4374,12 @@ interface AttachmentContext {
4333
4374
  * emitted, for `depths`' reason.
4334
4375
  */
4335
4376
  softMasks: Map<string, DeformSoftMask>;
4377
+ /**
4378
+ * Per-axis `narrowing` mode when a deform turn key on this attachment states
4379
+ * `"narrowing": false` (issue #1584), so `sampleMeshDepth` computes the turn
4380
+ * ceiling with `asin(|A₀ / A_axis|)` on that axis.
4381
+ */
4382
+ turnNarrowing?: { yaw?: boolean; pitch?: boolean };
4336
4383
  /** Every slot the rig declares — a clipping attachment's `end` resolves here. */
4337
4384
  slotNames: Set<string>;
4338
4385
  /**
@@ -6324,6 +6371,7 @@ function sampleMeshDepth(
6324
6371
  points.map(([px, py]) => toBind(px, py)),
6325
6372
  z,
6326
6373
  triangles,
6374
+ ctx.turnNarrowing === undefined ? undefined : { narrowing: ctx.turnNarrowing },
6327
6375
  ),
6328
6376
  },
6329
6377
  };
@@ -189,11 +189,12 @@ import {
189
189
  foldInheritMode,
190
190
  gridRound,
191
191
  readBlend,
192
- readColour,
193
192
  shownAttachment,
194
193
  shownRow,
195
194
  slidersKeyingWhy,
196
195
  sourceOfDoc,
196
+ viewColours,
197
+ WHITE,
197
198
  type CompiledDocument,
198
199
  type CoreAnimation,
199
200
  type CoreBoneRow,
@@ -834,12 +835,15 @@ export function posedSlots(
834
835
  /** Filled with each slot's placeholder once the sample's own timelines have moved it, before the sliders — what the attachments are posed from (`attachmentStates`). */
835
836
  placeholders?: Map<string, string | null>,
836
837
  ): { rows: CoreSlotRow[]; conflicts: string[] } {
837
- const colour = plant.colour ?? readColour;
838
+ // The view's stated colours, read once (`viewColours`); a planted reader of the light colour is called as it always was.
839
+ const stated = viewColours(doc);
840
+ const colour = plant.colour ?? stated;
838
841
  const byName = new Map(timelines.slots.map((s) => [s.name, s]));
839
842
  return slotRowsFrom(doc, plant, sliders, placeholders, (slot, live) => {
840
843
  let placeholder = slot.setup;
841
- const light = slot.color === undefined ? [1, 1, 1, 1] : colour(slot.color);
842
- const setupDark = slot.dark === undefined ? null : readColour(slot.dark);
844
+ const setupLight = slot.color === undefined ? WHITE : colour(slot.color);
845
+ const light = [setupLight[0], setupLight[1], setupLight[2], setupLight[3]];
846
+ const setupDark = slot.dark === undefined ? null : stated(slot.dark);
843
847
  const dark = setupDark === null ? null : [setupDark[0], setupDark[1], setupDark[2]];
844
848
  for (const tl of live ? (byName.get(slot.name)?.timelines ?? []) : []) {
845
849
  if (tl.kind === 'attachment') {
@@ -848,7 +852,6 @@ export function posedSlots(
848
852
  continue;
849
853
  }
850
854
  const v = valuesAt(tl.keys, t, plant);
851
- const setupLight = slot.color === undefined ? [1, 1, 1, 1] : colour(slot.color);
852
855
  const channels = SLOT_COLOUR_TIMELINES[tl.kind].channels;
853
856
  channels.forEach((name, i) => {
854
857
  const at = { r: 0, g: 1, b: 2, a: 3, r2: 4, g2: 5, b2: 6 }[name];
@@ -955,15 +958,22 @@ export function posedBoneRows(doc: CompiledDocument, timelines: CoreAnimationTim
955
958
  /** `posedBoneRows`, with the world transforms the rows were read off — what the sample's attachments are posed through. */
956
959
  export function posedBoneWorld(doc: CompiledDocument, timelines: CoreAnimationTimelines, t: number, plant: TimelinePlant = {}, constraints?: CoreConstraintTimelines, sliders?: SliderApplication[], step?: PoseStep): { rows: CoreBoneRow[]; world: Map<string, CoreWorld> } {
957
960
  const posed = posedBoneStep(doc, timelines, t, plant, constraints, sliders, step);
958
- return { rows: stepRows(posed), world: posed.world };
961
+ const stage = plant.stage;
962
+ if (stage !== undefined) stage.begin('readout');
963
+ const rows = stepRows(posed);
964
+ if (stage !== undefined) stage.end('readout');
965
+ return { rows, world: posed.world };
959
966
  }
960
967
 
961
968
  /**
962
969
  * `posedBoneWorld`'s world transforms without its rows (issue #1179, the
963
970
  * second part): the same pose by the same operations in the same order, since
964
971
  * `stepRows` only reads the world once it is posed. A walk reads `world` and
965
- * no row, so the scan's walk (`scanWalkIn` in `./raw.ts`, A10's) asks for
966
- * this; the raw entry's walk still forms the rows it does not read, unchanged.
972
+ * no row, so every walk asks for this — the scan's (`scanWalkIn` in
973
+ * `./raw.ts`, A10's), the raw entry's batch walks and the live track's step.
974
+ * The rows were the only thing the other variant adds, and their rounding
975
+ * (`rawNumber`, `keptNumber`) refuses nothing, so leaving them out moves no
976
+ * number and no refusal of a walk.
967
977
  */
968
978
  export function posedBoneWorldAlone(doc: CompiledDocument, timelines: CoreAnimationTimelines, t: number, plant: TimelinePlant, constraints: CoreConstraintTimelines | undefined, sliders: SliderApplication[] | undefined, step: PoseStep | undefined): Map<string, CoreWorld> {
969
979
  return posedBoneStep(doc, timelines, t, plant, constraints, sliders, step).world;
@@ -997,20 +1007,34 @@ function stepRows(posed: PosedStep): CoreBoneRow[] {
997
1007
 
998
1008
  /** `posedBoneWorld` up to its rows, refusing a bone the evaluator gave no transform (`PosedStep`). */
999
1009
  function posedBoneStep(doc: CompiledDocument, timelines: CoreAnimationTimelines, t: number, plant: TimelinePlant, constraints: CoreConstraintTimelines | undefined, sliders: SliderApplication[] | undefined, step: PoseStep | undefined): PosedStep {
1010
+ const stage = plant.stage;
1011
+ if (stage !== undefined) stage.begin('timeline');
1000
1012
  const bones = posedBones(doc, timelines, t, plant);
1013
+ if (stage !== undefined) stage.end('timeline');
1001
1014
  // One step of the stepped phase (issue #956, `./constraints_physics.ts`): the physics records posed by their timelines and stepped under the walk's context; the deformed curve a path walks as above.
1002
- if (constraints !== undefined && step !== undefined) return steppedLocals(doc, bones, plant, () => pathDeformed(doc, posedRecords(constraintRecords(doc), constraints, t), timelines, t, plant), constraints.physicsKeyed ?? [], t, sliders, step);
1015
+ if (constraints !== undefined && step !== undefined) {
1016
+ return steppedLocals(doc, bones, plant, () => pathDeformed(doc, posedRecords(constraintRecords(doc), constraints, t), timelines, t, plant), constraints.physicsKeyed ?? [], t, sliders, step);
1017
+ }
1003
1018
  const active = activeBones(doc);
1004
1019
  // The caller's adjustment, after the timelines and before anything reads the locals (`LocalAdjust`).
1005
- if (step?.adjustment !== undefined) adjustLocals(doc, bones, step.adjustment.callback, step.adjustment.context);
1006
- let world = (plant.evaluate ?? worldTransforms)(bones, active);
1020
+ if (step?.adjustment !== undefined) {
1021
+ if (stage !== undefined) stage.begin('timeline');
1022
+ adjustLocals(doc, bones, step.adjustment.callback, step.adjustment.context);
1023
+ if (stage !== undefined) stage.end('timeline');
1024
+ }
1025
+ if (stage !== undefined) stage.begin('world');
1026
+ const initialWorld = (plant.evaluate ?? worldTransforms)(bones, active);
1027
+ if (stage !== undefined) stage.end('world');
1028
+ let world = initialWorld;
1007
1029
  if (constraints !== undefined) {
1030
+ if (stage !== undefined) stage.begin('constraints');
1008
1031
  const setupRecords = constraintRecords(doc);
1009
1032
  // A path walks the curve the sample's deform timelines left on its slot (`./deform.ts`); a slider's deform of a walked path leaves the bones out (`pathAnimationsWhy`).
1010
1033
  const records = pathDeformed(doc, posedRecords(setupRecords, constraints, t), timelines, t, plant);
1011
1034
  // A path constraint may read a slot bone the runtime has not yet brought up to date in this pass, as the previous pass left it (`./constraints_path.ts`, *Which slot bone*); `poseAnimations` holds that pass to the setup pose's reading.
1012
1035
  const previous = setupRecords.some((r) => r.kind === 'path') ? applyConstraints(doc.bones, (plant.evaluate ?? worldTransforms)(doc.bones, active), active, plant.constraints ? plant.constraints(setupRecords) : setupRecords, null, undefined, undefined, undefined, solverRules(plant.solver)) : null;
1013
- world = applyConstraints(bones, world, active, plant.constraints ? plant.constraints(records) : records, previous, sliders, undefined, undefined, solverRules(plant.solver));
1036
+ world = applyConstraints(bones, initialWorld, active, plant.constraints ? plant.constraints(records) : records, previous, sliders, undefined, undefined, solverRules(plant.solver));
1037
+ if (stage !== undefined) stage.end('constraints');
1014
1038
  }
1015
1039
  for (const b of bones) if (world.get(b.name) === undefined) throw new CoreInputError(`the evaluator returned no transform for bone "${b.name}"`);
1016
1040
  return { bones, world, active, round: plant.round ?? gridRound };
@@ -1027,27 +1051,35 @@ function posedBoneStep(doc: CompiledDocument, timelines: CoreAnimationTimelines,
1027
1051
  * past the timelines for both.
1028
1052
  */
1029
1053
  function steppedLocals(doc: CompiledDocument, bones: ModelBone[], plant: TimelinePlant, records: () => CoreConstraintRecord[], physicsKeyed: NonNullable<CoreConstraintTimelines['physicsKeyed']>, t: number, sliders: SliderApplication[] | undefined, step: PoseStep): PosedStep {
1054
+ const stage = plant.stage;
1030
1055
  const active = activeBones(doc);
1031
1056
  // The caller's adjustment, after the timelines and before anything reads the locals (`LocalAdjust`).
1032
- if (step.adjustment !== undefined) adjustLocals(doc, bones, step.adjustment.callback, step.adjustment.context);
1033
- let world = (plant.evaluate ?? worldTransforms)(bones, active);
1057
+ if (step.adjustment !== undefined) {
1058
+ if (stage !== undefined) stage.begin('timeline');
1059
+ adjustLocals(doc, bones, step.adjustment.callback, step.adjustment.context);
1060
+ if (stage !== undefined) stage.end('timeline');
1061
+ }
1062
+ if (stage !== undefined) stage.begin('world');
1063
+ const initialWorld = (plant.evaluate ?? worldTransforms)(bones, active);
1064
+ if (stage !== undefined) {
1065
+ stage.end('world');
1066
+ stage.begin('constraints');
1067
+ }
1034
1068
  const stepped = stepPhysicsRecords(records(), physicsKeyed, t, active, step.ctx, step.before);
1035
- world = applyConstraints(bones, world, active, plant.constraints ? plant.constraints(stepped) : stepped, null, sliders, step.ctx, undefined, solverRules(plant.solver));
1069
+ const world = applyConstraints(bones, initialWorld, active, plant.constraints ? plant.constraints(stepped) : stepped, null, sliders, step.ctx, undefined, solverRules(plant.solver));
1070
+ if (stage !== undefined) stage.end('constraints');
1036
1071
  for (const b of bones) if (world.get(b.name) === undefined) throw new CoreInputError(`the evaluator returned no transform for bone "${b.name}"`);
1037
1072
  return { bones, world, active, round: plant.round ?? gridRound };
1038
1073
  }
1039
1074
 
1040
1075
  /**
1041
- * `steppedLocals`' world transforms, the oracle's rows formed beside them when
1042
- * `rows` is set (as `posedBoneWorld` forms them and `posedBoneWorldAlone` does
1043
- * not): the live tracks' entry to the stepped phase (`./tracks.ts`). The
1044
- * document's constraint records as they stand — the live tracks mix no
1045
- * constraint timeline — and no physics timeline keyed.
1076
+ * `steppedLocals`' world transforms, without the oracle's rows (as
1077
+ * `posedBoneWorldAlone` poses them): the live tracks' entry to the stepped
1078
+ * phase (`./tracks.ts`). The document's constraint records as they stand —
1079
+ * the live tracks mix no constraint timeline — and no physics timeline keyed.
1046
1080
  */
1047
- export function steppedLocalsWorld(doc: CompiledDocument, bones: ModelBone[], plant: TimelinePlant, sliders: SliderApplication[], step: PoseStep, rows: boolean): Map<string, CoreWorld> {
1048
- const posed = steppedLocals(doc, bones, plant, () => constraintRecords(doc), [], 0, sliders, step);
1049
- if (rows) stepRows(posed);
1050
- return posed.world;
1081
+ export function steppedLocalsWorld(doc: CompiledDocument, bones: ModelBone[], plant: TimelinePlant, sliders: SliderApplication[], step: PoseStep): Map<string, CoreWorld> {
1082
+ return steppedLocals(doc, bones, plant, () => constraintRecords(doc), [], 0, sliders, step).world;
1051
1083
  }
1052
1084
 
1053
1085
  /** Each path record with the curve its slot shows deformed by the sample's own deform timelines at `t` (`./deform.ts`), or as it was. */