@rig-c/core 2.60.0 β†’ 2.61.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -433,7 +433,10 @@ frames. It draws with rigc's own rasteriser (the one `check` measures with), so
433
433
  it needs no browser and no network, and the `frames.json` it leaves beside the
434
434
  directories makes the result a frame set like any other β€” the world box every
435
435
  frame is a picture of. `--animation <name>` narrows it to one, `--fps` and
436
- `--max` change the rate and the frame size.
436
+ `--max` change the rate and the frame size. A set drawn at any rate but the
437
+ default is written to `<anim>@<fps>fps/` rather than `<anim>/`, so two rates sit
438
+ side by side: `--fps 24` writes `<anim>@24fps/`, and the default 12 writes
439
+ `<anim>/`. Each set's summary line ends with the directory it wrote.
437
440
 
438
441
  > 🚨 **`--skin <name>` if your rig has more than one.** With no `--skin` no skin
439
442
  > is set at all, so every slot resolves through the **default** skin alone β€” and
@@ -603,7 +606,7 @@ commands take it and what its default is.
603
606
  | `ingest <skeleton.json> --out <dir>` | `build` run backwards: reads a Spine 4.3 skeleton and writes the rig spec and motion spec that **rebuild it**, plus a findings report naming everything it could not carry. a skeleton that declares no stage is carried as declaring none, `--stage x,y,w,h` adds a box to one β€” and is refused, rather than ignored, beside one that declares a box β€” and `--images <dir>` writes the spec's own images directory β€” the opposite direction from `build --images`, which overrides it β€” so the rebuild carries no flag at all |
604
607
  | `split --rig … --motion … --slot <name> --out <dir>` | the answer to a mesh refused because its triangles are **several pieces that share no vertex** β€” two legs after the triangles bridging them were deleted. It reads the two specs and writes them back into an absent or empty `--out` with one mesh per piece: piece 1 stays in the slot, piece k gets a new slot `<slot>_<k>` after it, each with the original's uvs and weights unchanged, its triangles renumbered outline first where the hull rule needs it, and every deform key re-sliced to the new indices β€” plus `split.json`, where every original vertex and triangle went. One piece is said so and nothing is written; what it cannot carry is refused by name ([AUTHORING Β§3.4.11](docs/AUTHORING.md)) |
605
608
  | `explain --rig … --motion …` | the compiled rig as a table β€” every bone with its resolved parent, the slots in draw order, every timeline key by key. Writes nothing. What to reach for when a rig compiles and still looks wrong |
606
- | `render --candidate <dir>` | PNG frames plus a contact sheet, in `render/`. `--hide <slot,…>` or `--slot <slot,…>` draws part of the rig on the **same grid** as the whole, so the two frames overlay and the difference is the part; `frames.json` records the subset and `check` refuses such a set as a reference. `--geometry` adds a `geometry.json` per set: every frame's bone world transforms and skinned attachment vertices, with the rest geometry, on the frames' own grid. `--folds` adds a `folds.json` per set and prints one line per mesh with a triangle turned over against the setup pose: the frames, the worst frame's count and which triangles, their world box and their setup-pose area. `--pass-through <cloth>:<leg>` adds a `pass-through.json` per set and prints the frames where a cloth drawn over a leg has its side inside the leg's drawn outline, with the depth and the world box β€” a reading, not a gate. `--seam <slot>:<slot>` adds a `seam.json` per set and prints, per pair, the frames where two slots that meet open background inside the joint (a gap) or show the overlap band of one past the other (a ghost), with the worst frame's cell count and world box β€” a reading, not a gate; the exit status never moves. `--angle-range <bone>:<relative-to>` prints how far one bone turns against another over each set: the lowest and highest angle and the frames they fall on β€” a reading, not a gate. `--viewport <x,y,w,h>` or `--viewport-from <frames.json>` draws over a given box instead of a fitted one, so two builds land on one pixel grid; a slot that reaches past it is named by set and frame, and `frames.json` records it. `--environment <schedule.json>` draws the rig under the wind and gravity its physics answers, in time β€” the same frames on the core and on spine-core; an inspection of the response the rig states, not a scene |
609
+ | `render --candidate <dir>` | PNG frames plus a contact sheet, in `render/`, one directory per set: `<anim>@<fps>fps/` rather than `<anim>/` at any rate but the default β€” `--fps 24` writes `<anim>@24fps/`, and the default 12 writes `<anim>/`. `--hide <slot,…>` or `--slot <slot,…>` draws part of the rig on the **same grid** as the whole, so the two frames overlay and the difference is the part; `frames.json` records the subset and `check` refuses such a set as a reference. `--geometry` adds a `geometry.json` per set: every frame's bone world transforms and skinned attachment vertices, with the rest geometry, on the frames' own grid. `--folds` adds a `folds.json` per set and prints one line per mesh with a triangle turned over against the setup pose: the frames, the worst frame's count and which triangles, their world box and their setup-pose area. `--pass-through <cloth>:<leg>` adds a `pass-through.json` per set and prints the frames where a cloth drawn over a leg has its side inside the leg's drawn outline, with the depth and the world box β€” a reading, not a gate. `--seam <slot>:<slot>` adds a `seam.json` per set and prints, per pair, the frames where two slots that meet open background inside the joint (a gap) or show the overlap band of one past the other (a ghost), with the worst frame's cell count and world box β€” a reading, not a gate; the exit status never moves. `--angle-range <bone>:<relative-to>` prints how far one bone turns against another over each set: the lowest and highest angle and the frames they fall on β€” a reading, not a gate. `--viewport <x,y,w,h>` or `--viewport-from <frames.json>` draws over a given box instead of a fitted one, so two builds land on one pixel grid; a slot that reaches past it is named by set and frame, and `frames.json` records it. `--environment <schedule.json>` draws the rig under the wind and gravity its physics answers, in time β€” the same frames on the core and on spine-core; an inspection of the response the rig states, not a scene |
607
610
  | `preview --candidate <dir>` | one self-contained `.html` that plays it, headed by the line `validate <dir>` prints for it and the rigc version β€” a refused candidate is still previewed, and its header says so in the gate's words. Repeat `--candidate` for one page with a pane per candidate, in the order given; a green `build` ends by naming this command for its own `--out` |
608
611
  | `vote --candidate a --candidate b` | one `.html` that asks a human which; `vote --record <file>` checks the answer into `votes.jsonl` |
609
612
  | `pose --images <dir> --frame <png>` | reads part placements **out of** a picture |
package/docs/AUTHORING.md CHANGED
@@ -1242,7 +1242,11 @@ bun cli.ts pose --images path/to/parts --frame poseA.png [--out pose.json]
1242
1242
  gate can know you did not mean them. `render` writes
1243
1243
  `render/<animation>/f0000.png…` with a `contact.png` grid of **every** frame
1244
1244
  beside them (open that one first β€” spacing is a comparison across frames) and a
1245
- `frames.json` sidecar naming the world box they are pictures of. `contact.png`
1245
+ `frames.json` sidecar naming the world box they are pictures of. It also says
1246
+ whether the physics ran: under each set's line, `physics: N constraint(s)
1247
+ stepped at <fps> fps`, and the same two figures on that set in `frames.json` β€”
1248
+ or, once, `physics: none in this rig` for a rig that declares no physics
1249
+ constraint (Β§8.4). `contact.png`
1246
1250
  is for spacing across frames; a defect is read on **one frame at full size**,
1247
1251
  because a tile is too small to say which part a pixel belongs to. When a frame
1248
1252
  looks wrong, render it again with `--hide <slot>` β€” the **largest attachment**
@@ -6843,6 +6847,14 @@ it had nothing to step (`nanStepping=skipped`). What it does not see is a skin o
6843
6847
  than the default, which A10 does not pose. The sentence, and where to look for the
6844
6848
  number, is under *nothing to draw* in Β§3.4.
6845
6849
 
6850
+ ⌨️ **An invocation missing a flag is one line, before any of these.** `build`,
6851
+ `explain` and `validate` given a cut spelled out with `--rig`, `--motion` or
6852
+ `--out` absent print `rigc: <command> needs --<flag> <value> (or --cut <name>
6853
+ --cuts <cuts.json>)` on stderr and exit 2 β€” the line alone, with no command
6854
+ catalogue under it. An input flag that names a **directory** is a compile error
6855
+ with a remedy in it: `CE_COMPILE_JSON_READ_FAILED` says what the flag wants and
6856
+ names the one file there that reads as it (Β§5.1.2). It is never read as that file.
6857
+
6846
6858
  ### 5.1 Compile errors β€” before the gate
6847
6859
 
6848
6860
  A `CompileError` names the object and the field, and nothing is written. Its line
@@ -6919,7 +6931,7 @@ retain the minimal inputs and report that code rather than inventing author data
6919
6931
  | <a id="CE_COMPILE_RGBA_CHANNEL_COUNT"></a>`CE_COMPILE_RGBA_CHANNEL_COUNT` | β€œrgba value needs 4 channels, got &lt;value&gt;” | Supply four rgba channels. |
6920
6932
  | <a id="CE_COMPILE_RGBA2_CHANNEL_COUNT"></a>`CE_COMPILE_RGBA2_CHANNEL_COUNT` | β€œrgba2 value needs 7 channels, got &lt;value&gt;” | Supply seven rgba2 channels. |
6921
6933
  | <a id="CE_COMPILE_COLOUR_CHANNEL_COUNT"></a>`CE_COMPILE_COLOUR_CHANNEL_COUNT` | β€œ&lt;value&gt; value needs &lt;value&gt; channel[ / s], got &lt;value&gt;” | Supply the channel count named for this colour shape. |
6922
- | <a id="CE_COMPILE_JSON_READ_FAILED"></a>`CE_COMPILE_JSON_READ_FAILED` | β€œcannot read &lt;value&gt;: &lt;value&gt;”<br>Also: β€œ&lt;value&gt; (line &lt;value&gt;, column &lt;value&gt;)” | Fix the named JSON file's path, permissions or JSON syntax. |
6934
+ | <a id="CE_COMPILE_JSON_READ_FAILED"></a>`CE_COMPILE_JSON_READ_FAILED` | β€œcannot read &lt;value&gt;: &lt;value&gt;”<br>Also: β€œ&lt;value&gt; (line &lt;value&gt;, column &lt;value&gt;)”<br>Also, when `--rig`, `--motion` or `--manifest` names a directory: β€œ&lt;value&gt; names a directory; it wants &lt;value&gt; β€” &lt;value&gt; exists there” Β· β€œβ€¦ β€” &lt;value&gt; holds no .json file” Β· β€œβ€¦ β€” &lt;value&gt; holds &lt;value&gt; .json file(s) and none reads as one: &lt;value&gt;” Β· β€œβ€¦ β€” &lt;value&gt; .json files in &lt;value&gt; read as one, so name it: &lt;value&gt;”, a list cut at eight names with β€œ, … &lt;value&gt; in all” | Fix the named JSON file's path, permissions or JSON syntax. For a directory, name the file the line names β€” the one `.json` there that reads as that input (a rig spec through the rig spec's parser, a motion spec through the motion spec's; for `--manifest`, any `.json`) β€” or pick one of the files it lists. A directory is never read as its file: rigc compiles what it was given. |
6923
6935
  | <a id="CE_COMPILE_IMAGE_MISSING"></a>`CE_COMPILE_IMAGE_MISSING` | β€œimage "&lt;value&gt;" is not on disk at &lt;value&gt;”<br>Also: β€œ&lt;value&gt;.png” | Supply the PNG at the named images path or correct the image name. |
6924
6936
  | <a id="CE_COMPILE_IMAGE_PNG_INVALID"></a>`CE_COMPILE_IMAGE_PNG_INVALID` | β€œ&lt;value&gt; is &lt;value&gt;, not a PNG: its first &lt;value&gt; byte(s) are &lt;value&gt;, where a PNG's first 8 are &lt;value&gt;&lt;value&gt;. rigc reads PNG and nothing else β€” its page-size and alpha readers, its renderer and its region lift all decode PNG, and it links no decoder for any other format β€” so nothing in this file was measured. Re-export it as PNG”<br>β€œ&lt;value&gt; is a PNG whose IHDR chunk declares &lt;value&gt; byte(s) of data, where the format's is 13”<br>β€œ&lt;value&gt; is a PNG whose IHDR chunk states its size as &lt;value&gt;x&lt;value&gt;: &lt;value&gt; 0, where the format requires each dimension to be at least 1, so the file describes no texel and nothing in it can be measured or drawn. Re-export it”<br>β€œ&lt;value&gt; is a PNG whose first chunk is &lt;value&gt; rather than the IHDR the format requires first”<br>β€œ&lt;value&gt; is a truncated PNG: it is &lt;value&gt; byte(s) long and &lt;value&gt;, so no reader can decode it. Re-export or re-copy the file whole”<br>β€œ&lt;value&gt; is a truncated PNG: it is &lt;value&gt; byte(s) long and ends &lt;value&gt; byte(s) into the 8-byte PNG signature, so no reader can decode it. Re-export or re-copy the file whole”<br>β€œ&lt;value&gt; is a truncated PNG: it is &lt;value&gt; byte(s) long and ends after the signature with no IEND chunk, so no reader can decode it. Re-export or re-copy the file whole”<br>β€œ&lt;value&gt; is a truncated PNG: it is &lt;value&gt; byte(s) long and its &lt;value&gt; chunk at byte &lt;value&gt; declares &lt;value&gt; byte(s) of data, which with its CRC runs to byte &lt;value&gt;, so no reader can decode it. Re-export or re-copy the file whole”<br>β€œ&lt;value&gt; is a truncated PNG: it is &lt;value&gt; byte(s) long and {ends after &lt;value&gt; with no IEND chunk / ends &lt;value&gt; byte(s) into the header of the chunk after &lt;value&gt;}, so no reader can decode it. Re-export or re-copy the file whole”<br>β€œ&lt;value&gt; is empty (0 bytes), and a PNG's first 8 bytes are &lt;value&gt;”<br>β€œ[; its name ends in .png, and it is the bytes that decide what a file is / ]”<br>β€œ[its width and its height are / [its width is / its height is]]”<br>β€œa &lt;value&gt; image”<br>β€œa WebP image (a RIFF/WEBP container whose first chunk is &lt;value&gt;)”<br>β€œimage "&lt;value&gt;": &lt;value&gt;”<br>β€œin no image format rigc recognises by its signature (it names &lt;value&gt; when it meets them)”<br>Here the values named in the message include: β€œKTX2”, β€œGIF”, β€œJPEG”. | Supply a readable PNG with the required header and dimensions at the named path. |
6925
6937
  | <a id="CE_COMPILE_ATLAS_INPUT_MISSING"></a>`CE_COMPILE_ATLAS_INPUT_MISSING` | β€œ--atlas-in names &lt;value&gt;, which is not on disk” | Correct --atlas-in or supply the atlas file at that path. |
@@ -8496,6 +8508,34 @@ from its own frame and no earlier (`RE02`); and plants on the core the readings
8496
8508
  mapping rejects β€” the schedule ignored, its y read up, each change a frame late or
8497
8509
  early β€” each red against spine-core (`RE03`).
8498
8510
 
8511
+ **Whether the physics ran.** Every render β€” with this flag or without β€” says, under
8512
+ each set's line, how many physics constraints the poser stepped on at least one of
8513
+ its frames, and at what rate:
8514
+
8515
+ ```text
8516
+ .. idle 25 frame(s), 2.000s + contact.png -> render/idle
8517
+ .. physics: 3 constraint(s) stepped at 12 fps
8518
+ ```
8519
+
8520
+ and `frames.json` carries the same two figures on that set, `"physics": { "stepped":
8521
+ 3, "fps": 12 }`. The rate is the set's own: one physics update a frame. The count is
8522
+ read off the posed state, not off the declaration: a constraint counts when, after
8523
+ a step that moved the skeleton's clock, its own clock reads the skeleton's β€” which
8524
+ the physics update sets and every way of not running leaves behind. So a constraint
8525
+ that is inactive on every frame, or at a mix of 0, is not counted, and the line names
8526
+ it: `physics: 0 constraint(s) stepped at 12 fps β€” 1 of the 1 declared took none:
8527
+ "iris_settle" (inactive, or a mix of 0, on every frame)`; a set of one pose β€” an
8528
+ animation of no length, or the setup pose of a skeleton with none β€” takes no step
8529
+ and says so. A rig that declares no physics constraint prints `physics: none in this
8530
+ rig β€” it declares no physics constraint, so no frame steps one` once, before the
8531
+ sets, and its `frames.json` carries no `physics` field, as every frame set written
8532
+ before the field existed carries none. This is what tells a reading β€” a fold count
8533
+ from `--folds`, say (Β§8.3) β€” taken with the physics from one taken without. Both
8534
+ posers read the same constraints on every frame, and `cli.ts`, `cli.ts --poser
8535
+ spine` and `cli_core.ts` write the same files (`PH01`, `PH02`); the step removed β€”
8536
+ on the core, or spine-core's `PhysicsConstraint.update` made a no-op β€” reads 0
8537
+ (`PH03`), and a constraint at mix 0 is named (`PH04`).
8538
+
8499
8539
  **The files.** `frames.json` and each `geometry.json` record the schedule as
8500
8540
  `environment`, its steps as read, `t`, `wind`, `gravity` in that order. The
8501
8541
  framing reads it too: a gust can carry a coat past the box a still rig fills, so
package/docs/INGEST.md CHANGED
@@ -191,6 +191,7 @@ rigc render
191
191
  .. atlas …/examples/spineboy/export/spineboy.atlas
192
192
  .. poser spine-core β€” no skeleton.model.json beside …/examples/spineboy/export/spineboy-pro.json β€” a Spine export, not a rigc build
193
193
  .. 200x186px at 8 fps, 1 set(s) -> …/render/sb
194
+ .. physics: none in this rig β€” it declares no physics constraint, so no frame steps one
194
195
  .. walk 9 frame(s), 1.000s + contact.png -> …/render/sb/walk@8fps
195
196
  rigc: wrote …/render/sb/frames.json
196
197
  ```
package/docs/MOTION.md CHANGED
@@ -1067,6 +1067,7 @@ rigc render --candidate spine --fps 24 --max 200
1067
1067
 
1068
1068
  ```
1069
1069
  .. 134x200px at 24 fps, 1 set(s) -> …/semaphore/render
1070
+ .. physics: none in this rig β€” it declares no physics constraint, so no frame steps one
1070
1071
  .. raise 14 frame(s), 0.542s + contact.png -> …/semaphore/render/raise@24fps
1071
1072
  ```
1072
1073
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rig-c/core",
3
- "version": "2.60.0",
3
+ "version": "2.61.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": {
@@ -736,6 +736,8 @@ export function cmdRender(flags: Record<string, string>): void {
736
736
  const only = readAnimationFlag(flags, [...facts.animations]);
737
737
  const skin = readSkinFlag(flags, [...facts.skins]);
738
738
  const subset = readSlotSubsetFlags(flags, facts, skin);
739
+ // The physics constraints the skeleton declares, off the Spine file both posers read (issue #1467).
740
+ const physicsDeclared = environmentResponse(skeletonText).declared;
739
741
  const passPairs = readPassThroughFlag(passThrough, facts);
740
742
  const seamPairs = readSeamFlag(seam, facts);
741
743
  // The angle reading (issue #1414) reads bones, which a subset of the drawn slots does not change, so it takes --slot/--hide.
@@ -810,7 +812,14 @@ export function cmdRender(flags: Record<string, string>): void {
810
812
  // The bones ride along for --geometry and --angle-range, the whole attachments for --geometry and the two readings off them.
811
813
  const wantsBones = geometry || anglePairs !== undefined;
812
814
  const wantsGeometry = geometry || folds || passPairs !== undefined || seamPairs !== undefined;
813
- const sampling = !wantsBones && !wantsGeometry ? pose : { ...pose, ...(wantsBones ? { bones: true } : {}), ...(wantsGeometry ? { geometry: true } : {}) };
815
+ // The physics constraints each frame's step stepped ride along whenever the rig declares one (issue #1467): the
816
+ // `physics:` line and frames.json's `physics` field are read off them.
817
+ const sampling = {
818
+ ...pose,
819
+ ...(wantsBones ? { bones: true } : {}),
820
+ ...(wantsGeometry ? { geometry: true } : {}),
821
+ ...(physicsDeclared.length > 0 ? { physics: true } : {}),
822
+ };
814
823
  const sampled: Map<string, Frame[]> =
815
824
  only === undefined
816
825
  ? sampleAll(poser, fps, sampling)
@@ -916,6 +925,8 @@ export function cmdRender(flags: Record<string, string>): void {
916
925
  );
917
926
  }
918
927
  console.log(` .. ${viewport.width}x${viewport.height}px at ${fps} fps, ${sampled.size} set(s) -> ${outRoot}`);
928
+ // Whether physics ran (issue #1467): said once for a rig with nothing to step, and per set below for one with.
929
+ if (physicsDeclared.length === 0) console.log(` .. ${PHYSICS_NONE_LINE}`);
919
930
  if (!facts.declaresStage) console.log(` .. ${STAGELESS_FRAMING.render}`);
920
931
  // A given box (issue #1414): said with the box it replaced, and every slot it leaves part of out, by set and frame.
921
932
  const outside = given === undefined ? [] : outsideViewport(sampled, viewport);
@@ -959,6 +970,7 @@ export function cmdRender(flags: Record<string, string>): void {
959
970
  const seamReading = seamReadings.get(name);
960
971
  if (seamReading !== undefined) writeFileSync(join(dir, SEAM_FILE), seamText(seamReading));
961
972
  const duration = frames[frames.length - 1].time;
973
+ const physics = physicsDeclared.length === 0 ? undefined : physicsSteppedOf(frames, fps);
962
974
  sets.push({
963
975
  dir: dirName,
964
976
  animation: name === SETUP_POSE_DIR && facts.animations.length === 0 ? null : name,
@@ -967,6 +979,7 @@ export function cmdRender(flags: Record<string, string>): void {
967
979
  written: frames.length,
968
980
  stride: 1,
969
981
  duration,
982
+ ...(physics === undefined ? {} : { physics: { stepped: physics.stepped.length, fps } }),
970
983
  });
971
984
  const how = frames.length === 1 ? 'a single pose' : `${duration.toFixed(3)}s`;
972
985
  const folding = foldReading?.meshes.filter((m) => m.worst !== null).length ?? 0;
@@ -977,6 +990,7 @@ export function cmdRender(flags: Record<string, string>): void {
977
990
  const seamNote = seamReading === undefined ? '' : ` + ${SEAM_FILE} (${opening} of ${seamReading.pairs.length} pair(s) open)`;
978
991
  const extras = `${sheet ? ` + ${SHEET_FILE}` : ''}${geometryOut === undefined ? '' : ` + ${GEOMETRY_FILE}`}${foldNote}${passNote}${seamNote}`;
979
992
  console.log(` .. ${name.padEnd(16)} ${frames.length} frame(s), ${how}${extras} -> ${dir}`);
993
+ if (physics !== undefined) console.log(` .. ${physicsLine(physics.stepped, physicsDeclared, fps, frames.length)}`);
980
994
  for (const mesh of foldReading?.meshes ?? []) {
981
995
  const key = `${mesh.slot}\u0000${mesh.attachment}`;
982
996
  const fault = faultsSaid.has(key) ? null : setupFaultLine(mesh);
@@ -1022,6 +1036,28 @@ export function cmdRender(flags: Record<string, string>): void {
1022
1036
  console.log(`rigc: wrote ${join(outRoot, FRAMES_SIDECAR)}`);
1023
1037
  }
1024
1038
 
1039
+ /** `render`'s line for a rig that declares no physics constraint (issue #1467): nothing was stepped because nothing could be. */
1040
+ export const PHYSICS_NONE_LINE = 'physics: none in this rig β€” it declares no physics constraint, so no frame steps one';
1041
+
1042
+ /** The physics constraints any frame of a set stepped (issue #1467), by name in code-unit order. */
1043
+ export function physicsSteppedOf(frames: readonly Frame[], fps: number): { stepped: string[]; fps: number } {
1044
+ const names = new Set<string>();
1045
+ for (const frame of frames) for (const name of frame.physics ?? []) names.add(name);
1046
+ return { stepped: [...names].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)), fps };
1047
+ }
1048
+
1049
+ /**
1050
+ * `render`'s `physics:` line for one set (issue #1467): how many constraints were stepped at the set's rate, and β€” when
1051
+ * fewer than the rig declares β€” the ones that took no step, by name, with why a constraint takes none.
1052
+ */
1053
+ export function physicsLine(stepped: readonly string[], declared: readonly string[], fps: number, frames: number): string {
1054
+ const head = `physics: ${stepped.length} constraint(s) stepped at ${fps} fps`;
1055
+ const idle = declared.filter((name) => !stepped.includes(name));
1056
+ if (idle.length === 0) return head;
1057
+ const why = frames === 1 ? 'a single pose takes no step' : 'inactive, or a mix of 0, on every frame';
1058
+ return `${head} β€” ${idle.length} of the ${declared.length} declared took none: ${idle.map((n) => JSON.stringify(n)).join(', ')} (${why})`;
1059
+ }
1060
+
1025
1061
  /** Where `inspect` writes when `--out` is not given, and the name it takes inside a directory `--out` names. */
1026
1062
  export const DEFAULT_INSPECT_OUT = 'inspect.png';
1027
1063
 
@@ -1351,7 +1387,7 @@ export function cmdExplain(flags: Record<string, string>): void {
1351
1387
  const leverFold = flags['lever-fold'] === undefined ? null : Number(flags['lever-fold']);
1352
1388
  const leverWhy = leverFold === null ? null : leverFractionWhy(leverFold);
1353
1389
  if (leverWhy !== null) throw new UsageError(`--lever-fold ${JSON.stringify(flags['lever-fold'])}: ${leverWhy}`);
1354
- const { label, opts } = resolveCut(flags);
1390
+ const { label, opts } = resolveCut(flags, 'explain');
1355
1391
  console.log(`rigc explain ${label}`);
1356
1392
  // See the identical pair of lines in `cmdBuild` for why both paths are named
1357
1393
  // here rather than only the one the header's `label` happens to carry.
package/src/cli/shared.ts CHANGED
@@ -94,6 +94,19 @@ export type CutTable = Record<string, CutEntry>;
94
94
 
95
95
  export class UsageError extends Error {}
96
96
 
97
+ /**
98
+ * A usage error that is one required flag missing from a spelled-out cut β€”
99
+ * printed as its one line and exit 2, without the usage block under it (issue
100
+ * #1466). The catalogue under `--out is required` buried the only line that
101
+ * said what to add, which is `PoseError`'s reason for printing alone. Still a
102
+ * `UsageError`, so every reader that asks whether an invocation was refused
103
+ * reads it as one.
104
+ */
105
+ export class MissingFlagError extends UsageError {}
106
+
107
+ /** The `<value>` each flag of a spelled-out cut takes, for the line that names a missing one. */
108
+ const CUT_FLAG_VALUES = { rig: '<rig.json>', motion: '<motion.json>', out: '<dir>' } as const;
109
+
97
110
  /**
98
111
  * `explain` refusing a pair it cannot pose β€” a usage error in kind, printed
99
112
  * without the usage block for `PoseError`'s and `IngestError`'s reason: the
@@ -264,7 +277,7 @@ function entryToOptions(dir: string, name: string, entry: CutEntry): CompileOpti
264
277
  * Resolve the cut a command was pointed at, either spelled out on the command
265
278
  * line or looked up by name in a cuts.json.
266
279
  */
267
- export function resolveCut(flags: Record<string, string>): { label: string; opts: CompileOptions } {
280
+ export function resolveCut(flags: Record<string, string>, command: 'build' | 'explain' | 'validate'): { label: string; opts: CompileOptions } {
268
281
  const explicit =
269
282
  flags.rig !== undefined || flags.manifest !== undefined || flags.motion !== undefined || flags.out !== undefined;
270
283
  if (explicit) {
@@ -272,7 +285,7 @@ export function resolveCut(flags: Record<string, string>): { label: string; opts
272
285
  throw new UsageError('--rig/--motion/--out and --cut/--cuts are two ways to say the same thing; pick one');
273
286
  }
274
287
  for (const key of ['rig', 'motion', 'out'] as const) {
275
- if (flags[key] === undefined) throw new UsageError(`--${key} is required when the cut is spelled out`);
288
+ if (flags[key] === undefined) throw new MissingFlagError(`${command} needs --${key} ${CUT_FLAG_VALUES[key]} (or --cut <name> --cuts <cuts.json>)`);
276
289
  }
277
290
  const opts: CompileOptions = {
278
291
  rigPath: resolve(flags.rig),
@@ -2291,7 +2304,7 @@ function pageRectangle(region: AtlasRegion): string {
2291
2304
  * alone (`./core_commands.ts`). Moved here unchanged from `cmdBuild`.
2292
2305
  */
2293
2306
  export function readBuildInvocation(flags: Record<string, string>): { label: string; opts: CompileOptions; profile: AssertionProfile; packing: boolean } {
2294
- const { label, opts } = resolveCut(flags);
2307
+ const { label, opts } = resolveCut(flags, 'build');
2295
2308
  const profile = readProfile(flags);
2296
2309
  const packing = flags.pack !== undefined;
2297
2310
  // Two combinations are refused rather than silently resolved, because in each
@@ -3001,6 +3014,10 @@ export function runCli(entry: CliEntry, argv: readonly string[]): void {
3001
3014
  * (`CliEntry.refusals`). Anything else is not a refusal and is thrown on.
3002
3015
  */
3003
3016
  function refuse(err: unknown, command: string | undefined, USAGE: string, entry: CliEntry): never {
3017
+ if (err instanceof MissingFlagError) {
3018
+ console.error(`rigc: ${err.message}`);
3019
+ process.exit(2);
3020
+ }
3004
3021
  if (err instanceof UsageError) {
3005
3022
  console.error(`rigc: ${err.message}\n\n${USAGE}`);
3006
3023
  process.exit(2);
@@ -76,7 +76,7 @@ export function cmdValidate(flags: Record<string, string>, positional: string[])
76
76
  // a directory alone cannot supply β€” and the report says which it had.
77
77
  const named = flags.cut !== undefined || flags.rig !== undefined;
78
78
  const profile = readProfile(flags);
79
- const derivedOpts = named ? resolveCut(flags).opts : null;
79
+ const derivedOpts = named ? resolveCut(flags, 'validate').opts : null;
80
80
  // Both files there before the first line, from the one statement of a build (issue #1046): a directory without them
81
81
  // read the skeleton unguarded and died on an ENOENT and a stack.
82
82
  const { skeletonPath, atlasPath } = spinePairOf(resolveBuild(derivedOpts ? derivedOpts.outDir : (positional[0] ?? '.'), flags.atlas));
package/src/compile.ts CHANGED
@@ -18,7 +18,7 @@
18
18
  * the two emits byte for byte (assertion A18).
19
19
  */
20
20
  import { basename, dirname, join, relative, resolve } from 'node:path';
21
- import { existsSync, readFileSync } from 'node:fs';
21
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
22
22
  import { readPngHeader, readPngInfo } from './png.ts';
23
23
  import {
24
24
  DEPTH_TONE_IDENTITY,
@@ -1649,8 +1649,62 @@ function easingCurve(
1649
1649
  // inputs
1650
1650
  // ---------------------------------------------------------------------------
1651
1651
 
1652
- function readJson<T>(path: string): T {
1652
+ /**
1653
+ * Which input a JSON path was given as, for the refusal a directory gets: the
1654
+ * flag that named it, the file it wants, and whether a file's contents read as
1655
+ * one (`null` where no parser exists here β€” the manifest is cast, not parsed).
1656
+ */
1657
+ interface JsonInputRole {
1658
+ flag: '--rig' | '--motion' | '--manifest';
1659
+ wants: string;
1660
+ reads: ((raw: unknown, where: string) => unknown) | null;
1661
+ }
1662
+
1663
+ const RIG_INPUT: JsonInputRole = { flag: '--rig', wants: 'the rig spec file', reads: parseRigSpec };
1664
+ const MOTION_INPUT: JsonInputRole = { flag: '--motion', wants: 'the motion spec file', reads: parseMotionSpec };
1665
+ const MANIFEST_INPUT: JsonInputRole = { flag: '--manifest', wants: 'the cut manifest file', reads: null };
1666
+
1667
+ /** The most `.json` names a directory refusal lists before it says how many there are. */
1668
+ const LISTED_JSON_NAMES = 8;
1669
+
1670
+ /**
1671
+ * The refusal for an input flag that names a directory (issue #1466): what the
1672
+ * flag wants, and what the directory holds β€” the one file there that reads as
1673
+ * that input, named by its path, or every `.json` there when that is not
1674
+ * exactly one. Nothing is accepted on the strength of it: the caller is told
1675
+ * the path, and the compile still reads only what it was given. The names are
1676
+ * sorted, so the sentence is the same on every machine.
1677
+ */
1678
+ function directoryInputRefusal(dir: string, role: JsonInputRole): string {
1679
+ const head = `${role.flag} names a directory; it wants ${role.wants} β€”`;
1680
+ const names = readdirSync(dir)
1681
+ .filter((name) => name.endsWith('.json') && statSync(join(dir, name)).isFile())
1682
+ .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
1683
+ if (names.length === 0) return `${head} ${dir} holds no .json file`;
1684
+ const reading = names.filter((name) => {
1685
+ if (role.reads === null) return true;
1686
+ const file = join(dir, name);
1687
+ try {
1688
+ role.reads(parseJsonWithPosition(readFileSync(file, 'utf8')), file);
1689
+ return true;
1690
+ } catch {
1691
+ return false;
1692
+ }
1693
+ });
1694
+ if (reading.length === 1) return `${head} ${join(dir, reading[0])} exists there`;
1695
+ const listed = (list: string[]): string =>
1696
+ list.slice(0, LISTED_JSON_NAMES).join(', ') + (list.length > LISTED_JSON_NAMES ? `, … ${list.length} in all` : '');
1697
+ if (reading.length === 0) {
1698
+ return `${head} ${dir} holds ${names.length} .json file(s) and none reads as one: ${listed(names)}`;
1699
+ }
1700
+ return `${head} ${reading.length} .json files in ${dir} read as one, so name it: ${listed(reading)}`;
1701
+ }
1702
+
1703
+ function readJson<T>(path: string, role: JsonInputRole): T {
1653
1704
  let text: string;
1705
+ if (existsSync(path) && statSync(path).isDirectory()) {
1706
+ throw new CompileError(directoryInputRefusal(path, role), 'CE_COMPILE_JSON_READ_FAILED');
1707
+ }
1654
1708
  try {
1655
1709
  text = readFileSync(path, 'utf8');
1656
1710
  } catch (err) {
@@ -2309,11 +2363,11 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
2309
2363
  const manifestPath = opts.manifestPath === undefined ? null : resolve(opts.manifestPath);
2310
2364
  const manifestDir = manifestPath === null ? null : dirname(manifestPath);
2311
2365
 
2312
- const rig = parseRigSpec(readJson<unknown>(rigPath), rigPath);
2366
+ const rig = parseRigSpec(readJson<unknown>(rigPath, RIG_INPUT), rigPath);
2313
2367
  // Parsed, not cast, since issue #307 β€” `src/motion.ts` states what it proves
2314
2368
  // and which refusals it deliberately leaves in this file.
2315
- const motion = parseMotionSpec(readJson<unknown>(motionPath), motionPath);
2316
- const manifest = manifestPath === null ? null : readJson<FaceManifest>(manifestPath);
2369
+ const motion = parseMotionSpec(readJson<unknown>(motionPath, MOTION_INPUT), motionPath);
2370
+ const manifest = manifestPath === null ? null : readJson<FaceManifest>(manifestPath, MANIFEST_INPUT);
2317
2371
  // The manifest is the record of what was measured, and its numbers become
2318
2372
  // bone positions and the stage: a `crop.h` of 1e309 built as `"height": null`
2319
2373
  // before issue #881. The rig spec's walk is `parseRigSpec`'s; this is the
package/src/render.ts CHANGED
@@ -549,9 +549,25 @@ function spinePosed(skeleton: Skeleton): Posed {
549
549
  pieces: (draw) => drawPieces(skeleton, draw),
550
550
  bones: () => boneSnapshots(skeleton),
551
551
  attachments: () => attachmentsOf(skeleton),
552
+ physics: () => steppedPhysicsOf(skeleton),
552
553
  };
553
554
  }
554
555
 
556
+ /**
557
+ * `Posed.physics` on spine-core (issue #1467): every physics constraint whose
558
+ * `lastTime` reads the skeleton's `time` after a step that moved it β€” which
559
+ * `PhysicsConstraint.update` sets under `Physics.update` and `Physics.reset`,
560
+ * and which an inactive constraint, a mix of 0 or `Physics.none` leaves where
561
+ * it was. The setup pose and frame 0 sit at time 0 and step none.
562
+ */
563
+ function steppedPhysicsOf(skeleton: Skeleton): string[] {
564
+ if (!(skeleton.time > 0)) return [];
565
+ return skeleton.physics
566
+ .filter((c) => c.lastTime === skeleton.time)
567
+ .map((c) => c.data.name)
568
+ .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
569
+ }
570
+
555
571
  /**
556
572
  * A fresh skeleton under `skin`, stepped into its setup pose β€” the one recipe
557
573
  * `sampleSetupPose` and the geometry export's `rest` table both pose from, so
@@ -102,6 +102,7 @@ import { clipThrough, type ClipShape, type ShapeClipper } from './core/clipping.
102
102
  import { activeBones, CoreInputError, readModel, sourceOfDoc, underNoSkin, underSkin, type CompiledDocument, type CoreSlotRow } from './core/index.ts';
103
103
  import { PHYSICS_CLOCK_LEAD, physicsClockStep, poseRawAnimationEach, poseRawSetup, type RawDrawn, type RawPose, type StepEnvironment } from './core/raw.ts';
104
104
  import type { TimelinePlant } from './core/animation.ts';
105
+ import { stepPhysics, type PhysicsStepper } from './core/constraints_physics.ts';
105
106
  import { CORE_DEFAULT_SKIN, lookupSkins } from './core/skins.ts';
106
107
  import { documentPageLookup, drawnRegions, meshPageUvs, readUvSequences, regionPageUvs, type DrawnRegion, type UvRegion, type UvSource } from './core/uvs.ts';
107
108
  import { regionCorners, worldVertices } from './core/vertices.ts';
@@ -378,10 +379,14 @@ export const CORE_RAW_WALK: CoreRawWalk = (doc, animation, steps, plant, visit,
378
379
  */
379
380
  export { PHYSICS_CLOCK_LEAD, physicsClockStep };
380
381
 
381
- /** What a suite passes `corePoser` in place of the poser's own parts β€” `PieceArrays` (`RC43`) and the raw walk (`RC44`). */
382
+ /**
383
+ * What a suite passes `corePoser` in place of the poser's own parts β€” `PieceArrays` (`RC43`), the raw walk (`RC44`), and
384
+ * the physics step the walk runs (issue #1467: a step that does nothing has to read 0 on the render's `physics:` line).
385
+ */
382
386
  export interface CorePoserPlants {
383
387
  arrays?: () => PieceArrays;
384
388
  walk?: CoreRawWalk;
389
+ physicsStep?: PhysicsStepper;
385
390
  }
386
391
 
387
392
  /**
@@ -457,7 +462,7 @@ function clipCover(pose: RawPose, active: ReadonlySet<string>): Map<string, Clip
457
462
  }
458
463
 
459
464
  /** One posed moment of the core, read through the seam's `Posed`. */
460
- function corePosed(input: CoreInput, pose: RawPose, through: ShapeClipper): Posed {
465
+ function corePosed(input: CoreInput, pose: RawPose, through: ShapeClipper, stepped: readonly string[]): Posed {
461
466
  const slotRows = new Map<string, CoreSlotRow>(pose.slots.map((row) => [row[0], row]));
462
467
  let regions: Map<string, DrawnRegion> | null = null;
463
468
  const regionsOf = (): Map<string, DrawnRegion> => {
@@ -501,6 +506,7 @@ function corePosed(input: CoreInput, pose: RawPose, through: ShapeClipper): Pose
501
506
  },
502
507
  attachments: (): AttachmentPose[] =>
503
508
  pose.drawn.map((d) => ({ slot: d.slot, attachment: d.attachment, vertices: [...d.vertices], color: tintOf(d) })),
509
+ physics: (): string[] => [...stepped],
504
510
  };
505
511
  }
506
512
 
@@ -752,7 +758,7 @@ export function corePoser(
752
758
  subset: (opts, skin) => subsetOver(roster, opts, skin),
753
759
  setup: (skin) => {
754
760
  const input = inputOf(skin);
755
- return corePosed(input, poseRawSetup(input.doc, { through }), through);
761
+ return corePosed(input, poseRawSetup(input.doc, { through }), through, []);
756
762
  },
757
763
  animation: (name, skin, fps, count, visit, environment) => {
758
764
  const input = inputOf(skin);
@@ -762,10 +768,22 @@ export function corePoser(
762
768
  // walk that refuses a later pose still refuses the call, and a draw that throws is re-thrown once the walk has
763
769
  // finished without refusing β€” the first such throw, by frame β€” and no frame after it is drawn.
764
770
  const drawFailed: unknown[] = [];
765
- walk(input.doc, name, new Array<number>(count).fill(step), { through }, (pose, i) => {
771
+ // `Posed.physics` on the core (issue #1467): the constraints whose state's clock reads the skeleton's after a step
772
+ // that moved it β€” spine-core's reading (`steppedPhysicsOf` in `src/render.ts`), taken off the same state the step
773
+ // writes, as each constraint is stepped, and handed to the pose the step made.
774
+ const steppedNow = new Set<string>();
775
+ const inner = plants.physicsStep ?? stepPhysics;
776
+ const physicsStep: PhysicsStepper = (r, w, length, ctx) => {
777
+ const moved = inner(r, w, length, ctx);
778
+ if (ctx.time > 0 && ctx.states.get(r.name)?.lastTime === ctx.time) steppedNow.add(r.name);
779
+ return moved;
780
+ };
781
+ walk(input.doc, name, new Array<number>(count).fill(step), { through, physicsStep }, (pose, i) => {
782
+ const stepped = [...steppedNow].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
783
+ steppedNow.clear();
766
784
  if (drawFailed.length > 0) return;
767
785
  try {
768
- visit(i, corePosed(input, pose, through));
786
+ visit(i, corePosed(input, pose, through, stepped));
769
787
  } catch (thrown) {
770
788
  drawFailed.push(thrown);
771
789
  }
@@ -190,6 +190,20 @@ export interface FrameSet {
190
190
  * file could only ever be the stale one.
191
191
  */
192
192
  duration: number;
193
+ /**
194
+ * Whether the physics ran while these frames were posed (issue #1467): how
195
+ * many physics constraints the poser stepped on at least one frame of this
196
+ * set, and the rate it stepped them at β€” the set's own `fps`, one skeleton
197
+ * update a frame. `render` prints the same two figures on its `physics:` line.
198
+ *
199
+ * ⭐ Absent on a rig that declares no physics constraint, for `skin`'s
200
+ * reason in `FramesSidecar`: there is nothing for the field to count, and
201
+ * every frame set written before it existed says the same. Present, a 0 is a
202
+ * claim β€” the rig declares physics and no constraint took a step on any
203
+ * frame β€” and that is what tells a reading taken with physics from one
204
+ * taken without.
205
+ */
206
+ physics?: { stepped: number; fps: number };
193
207
  }
194
208
 
195
209
  export interface FramesSidecar {
@@ -445,6 +459,8 @@ export interface PoseOptions {
445
459
  * so `framingViewport` keeps it and the box holds what the frames draw.
446
460
  */
447
461
  environment?: readonly EnvironmentStep[];
462
+ /** Also record the physics constraints the step that posed each frame stepped β€” see `Frame.physics` (issue #1467). */
463
+ physics?: boolean;
448
464
  }
449
465
 
450
466
  /**
@@ -586,6 +602,12 @@ export interface Frame {
586
602
  * second derivation of it.
587
603
  */
588
604
  attachments?: AttachmentPose[];
605
+ /**
606
+ * The physics constraints the step that posed this frame stepped, by name in
607
+ * code-unit order β€” present only when `PoseOptions.physics` asked for it
608
+ * (issue #1467). See `Posed.physics` for what counts as a step.
609
+ */
610
+ physics?: string[];
589
611
  }
590
612
 
591
613
  /**
@@ -1055,6 +1077,16 @@ export interface Posed {
1055
1077
  bones(): BoneSnapshot[];
1056
1078
  /** Every slot's region or mesh attachment, whole, in the posed draw order. */
1057
1079
  attachments(): AttachmentPose[];
1080
+ /**
1081
+ * The physics constraints the step that posed this moment stepped, by name in
1082
+ * code-unit order (issue #1467). A constraint is stepped when, after a step
1083
+ * that moved the skeleton's clock, its own clock reads the skeleton's: the
1084
+ * physics update sets it there, and every way of not running β€” inactive, a
1085
+ * mix of 0, the step skipped β€” leaves it behind. Read off the posed state on
1086
+ * both posers, never off which call was made, so a step removed reads 0. The
1087
+ * setup pose and a reset pose, where the clock has not moved, step none.
1088
+ */
1089
+ physics(): string[];
1058
1090
  }
1059
1091
 
1060
1092
  /**
@@ -1126,6 +1158,7 @@ function frameOf(index: number, time: number, posed: Posed, draw: DrawOptions, o
1126
1158
  pieces: posed.pieces(draw),
1127
1159
  ...(opts?.bones ? { bones: posed.bones() } : {}),
1128
1160
  ...(opts?.geometry ? { attachments: posed.attachments() } : {}),
1161
+ ...(opts?.physics ? { physics: posed.physics() } : {}),
1129
1162
  };
1130
1163
  }
1131
1164