@rig-c/core 2.62.0 → 2.64.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/cli/shared.ts CHANGED
@@ -40,6 +40,7 @@ import { depthStepLevels, type FoldLimit, type TurnCeiling } from '../depth.ts';
40
40
  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
+ import { OVERLAPS_FILE } from '../overlaps.ts';
43
44
  import { readViewportBox, ViewportError } from '../viewportpin.ts';
44
45
  import { parseJsonWithPosition } from '../json-position.ts';
45
46
  import { RUNG_IDS } from '../ladder.ts';
@@ -69,6 +70,7 @@ import { RepackError } from '../repack.ts';
69
70
  import { SplitError } from '../meshsplit.ts';
70
71
  import { SpineRuntimeError, SPINE_SIDE_ABSENT } from '../spine_side.ts';
71
72
  import type { CompileResult, DroppedState } from '../types.ts';
73
+ import { StdoutWriteError } from './write_whole.ts';
72
74
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
73
75
  import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
74
76
 
@@ -146,7 +148,7 @@ export { PACKAGE_ROOT, readPackageMeta, readVersion };
146
148
  * needs a value` (issue #328). `CLI10`/`CLI11` in `selftest.ts` now hold the two
147
149
  * halves together by reading `--help` rather than by naming a flag.
148
150
  */
149
- const BOOLEAN_FLAGS = new Set(['all', 'all-frames', 'all-bones', 'help', 'copy-images', 'again', 'pack', 'copy', 'geometry', 'folds', 'accept-skeleton-differences', 'keep-pages', 'dim']);
151
+ 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']);
150
152
 
151
153
  /**
152
154
  * The flags a command is allowed to spell more than once.
@@ -1052,6 +1054,14 @@ const FLAG_MEANINGS: Record<string, string> = {
1052
1054
  'world units, one texel of the coarser drawing to a step — each with the depth and the world box. One line per pair, and one per frame that passes through. ' +
1053
1055
  'The order is the declaration: the first name must draw over the second for a frame to be read. A reading, not a gate. ' +
1054
1056
  'An unknown slot, a slot that draws on no frame, a malformed list and a pair named twice are refused by name. Not with --slot/--hide',
1057
+ overlaps:
1058
+ `also read which slot each frame drew over which, and write ${OVERLAPS_FILE} into each frame directory: per frame, every pair ` +
1059
+ `of slots where the slot drawn later covers pixels the earlier one drew (drawn at ${DRAWN_ALPHA} of 255 or more, on the frames' own ` +
1060
+ 'pixels), with the pixel count and the pixel box. One OVERLAP line per pair that covers anything — the frames, and the most pixels ' +
1061
+ 'and the frame of them — and one line counting the pairs that cover the same pixels on every frame (layering the motion does not move). ' +
1062
+ 'Each frame with a moving pair also gets an outline plate, overlaps-f<NNNN>.png: every slot in a moving pair as its contour in its own ' +
1063
+ 'colour, dashed where a slot drawn later covers it, named at its top. The render reports what was drawn; who should be in front is the ' +
1064
+ "author's, and the draw-order key in the motion spec is the remedy. A reading, not a gate. Not with --slot/--hide",
1055
1065
  seam:
1056
1066
  `also read, for each <slot>:<slot> pair, where the two drawings stop meeting, and write ${SEAM_FILE} into each frame directory: ` +
1057
1067
  'per pair, the frames with a gap — background inside the hull of the band the two drawings share at the setup pose, carried to the frame by both — ' +
@@ -1409,8 +1419,12 @@ interface CommandDoc {
1409
1419
  * delegate's exit code is the command's. `flags` is empty for that reason:
1410
1420
  * rigc documents no flag of a program it does not own. A control that reads
1411
1421
  * a command's flag table off `--help` reads it off the commands without this.
1422
+ *
1423
+ * `bareTarget` and `subcommands` (issue #1486): a first argument that is none
1424
+ * of `subcommands` and does not start with `-` is a target, handed over as
1425
+ * `<bareTarget> <target> …` — the one rewrite the launcher makes.
1412
1426
  */
1413
- delegate?: { package: string; bin: string };
1427
+ delegate?: { package: string; bin: string; bareTarget?: string; subcommands?: readonly string[] };
1414
1428
  }
1415
1429
 
1416
1430
  export const COMMANDS: CommandDoc[] = [
@@ -1733,10 +1747,11 @@ export const COMMANDS: CommandDoc[] = [
1733
1747
  'rigc render … --environment <schedule.json> (the wind and gravity the physics answers, in time)',
1734
1748
  'rigc render … --pass-through <cloth>:<leg>[,…] (the frames where a cloth drawn over a leg passes through it)',
1735
1749
  'rigc render … --seam <slot>:<slot>[,…] (the frames where two slots that meet open a gap or show a ghost)',
1750
+ 'rigc render … --overlaps (which slot each frame drew over which, by name, with an outline plate)',
1736
1751
  'rigc render … --angle-range <bone>:<relative-to>[,…] (how far a bone turns against another, per animation)',
1737
1752
  'rigc render … --viewport <x,y,w,h> | --viewport-from <frames.json> (a box given, not fitted: two renders on one pixel grid)',
1738
1753
  ],
1739
- flags: ['candidate', 'atlas', 'animation', 'skin', 'slot', 'hide', 'fps', 'max', 'viewport', 'viewport-from', 'geometry', 'folds', 'pass-through', 'seam', 'angle-range', 'environment', 'poser', 'out'],
1754
+ flags: ['candidate', 'atlas', 'animation', 'skin', 'slot', 'hide', 'fps', 'max', 'viewport', 'viewport-from', 'geometry', 'folds', 'pass-through', 'seam', 'overlaps', 'angle-range', 'environment', 'poser', 'out'],
1740
1755
  overrides: {
1741
1756
  out: { value: '<dir>', meaning: 'directory to write the frame series into (default `render/`)' },
1742
1757
  fps: {
@@ -1942,9 +1957,12 @@ export const COMMANDS: CommandDoc[] = [
1942
1957
  name: 'review',
1943
1958
  runtime: false,
1944
1959
  spineFormat: false,
1945
- delegate: { package: '@rig-c/review', bin: 'rig-review' },
1960
+ // rig-review's own first words: every one of them, `--help`, `-h` and `--` (rig-review's way to its legacy bare
1961
+ // form) pass through; anything else first is a build directory, handed over as `open <dir>` (issue #1486).
1962
+ delegate: { package: '@rig-c/review', bin: 'rig-review', bareTarget: 'open', subcommands: ['open', 'check', 'feedback', 'control', 'strip', 'compare'] },
1946
1963
  usage: [
1947
- "rigc review <dir> [...] (@rig-c/review's rig-review, installed beside: every argument handed over untouched, its exit code returned — `rigc review open <dir>` prints a loopback URL to give a person, `rigc review --help` is rig-review's own usage)",
1964
+ "rigc review <dir> [--reference <video>] [--animation <name>] [--compare <dir>] (rig-review open <dir> …: prints a loopback URL to give a person)",
1965
+ "rigc review open|check|feedback|control|strip|compare|--help … (@rig-c/review's rig-review, installed beside: every argument handed over untouched, its stdout and exit code returned; rigc never installs it)",
1948
1966
  ],
1949
1967
  flags: [],
1950
1968
  },
@@ -3168,6 +3186,12 @@ function refuse(err: unknown, command: string | undefined, USAGE: string, entry:
3168
3186
  console.error(`rigc skills install: ${err.message}`);
3169
3187
  process.exit(1);
3170
3188
  }
3189
+ // A document the reader stopped taking (issue #1487): exit 1 like a lookup that could not answer, and the line
3190
+ // says how many bytes went out of how many and why — a silent short write is what `writeWhole` exists to refuse.
3191
+ if (err instanceof StdoutWriteError) {
3192
+ console.error(`rigc ${command}: ${err.message}`);
3193
+ process.exit(1);
3194
+ }
3171
3195
  // A rule or section the guide does not have (issue #1397): exit 1 like a lookup that found nothing, and the
3172
3196
  // message names what was asked and the nearest real names — the usage under it would bury them.
3173
3197
  if (err instanceof GuideError) {
@@ -0,0 +1,92 @@
1
+ /**
2
+ * `writeWhole(fd, text)`: every byte of `text`, UTF-8, onto `fd`, or a named
3
+ * refusal — the writer a command uses for a document too large to trust to
4
+ * `console.log` (issue #1487).
5
+ *
6
+ * ⚖️ Why not `console.log`: on Bun 1.2.0, the declared minimum, one ~322 KB
7
+ * line with non-ASCII in it came out 330,634 bytes for 330,632, a stray
8
+ * `\xe2\x00` at a 64 KiB chunk boundary — to a file, a pipe and a slow pipe
9
+ * alike. And on a stdout whose pipe is non-blocking (a parent set `O_NONBLOCK`
10
+ * on it), it stopped near 64 KiB on 1.2.0 *and* 1.4.2 with exit 0.
11
+ *
12
+ * ⚖️ Why a loop and not one `writeSync(fd, text)`: on that non-blocking pipe
13
+ * `writeSync` returns a short count — 65,536 of 330,632 — and throws nothing,
14
+ * so a single call drops the rest in silence on both Buns. Each call here
15
+ * writes from where the last one stopped.
16
+ *
17
+ * ⚖️ Why not `process.stdout.write` and a wait for `drain`: whole on 1.4.2,
18
+ * but on 1.2.0 over the non-blocking pipe it wrote more bytes than it was
19
+ * given (397,312 and climbing, diverging at byte 69,633) and never exited.
20
+ *
21
+ * A pipe that is full answers `EAGAIN` (or a zero count): the loop waits —
22
+ * 1 ms, doubling to {@link STALL_WAIT_CAP_MS} — and tries again, and after
23
+ * {@link MAX_STALLS} answers in a row with no byte taken it refuses with
24
+ * {@link StdoutWriteError}. `EPIPE` — the reader closed its end, as `| head`
25
+ * does — stops the write quietly, which is what `console.log` did there (exit 0
26
+ * on both Buns, measured): the reader has what it asked for. Any other error
27
+ * (`EBADF`, `EIO`) refuses at once.
28
+ * The wait is `Atomics.wait` on a cell nothing notifies: a delay, not a reading
29
+ * of the clock — no value written depends on how long it lasted, and the bound
30
+ * is a count of attempts, so whether it refuses depends on the reader alone.
31
+ */
32
+ import { writeSync } from 'node:fs';
33
+
34
+ /** The answers in a row with no byte taken before the writer refuses — about a minute of waits at the cap. */
35
+ export const MAX_STALLS = 1000;
36
+ /** The longest single wait between two attempts on a full pipe, in milliseconds. */
37
+ export const STALL_WAIT_CAP_MS = 64;
38
+
39
+ /** A write the reader never took: what was written, of how many bytes, and why it stopped. */
40
+ export class StdoutWriteError extends Error {
41
+ override name = 'StdoutWriteError';
42
+ }
43
+
44
+ /** `fs.writeSync`'s buffer form: bytes written, or a throw carrying `code`. */
45
+ export type WriteFn = (fd: number, buffer: Uint8Array, offset: number, length: number) => number;
46
+
47
+ /**
48
+ * The selftest's plants (issue #1487): `write` stands in for `fs.writeSync` so a
49
+ * short count or an `EAGAIN` can be forced, and `wait` for the delay so a plant
50
+ * runs without sleeping. Nothing outside the selftest passes either.
51
+ */
52
+ export interface WriteWholePlants {
53
+ write?: WriteFn;
54
+ wait?: (ms: number) => void;
55
+ }
56
+
57
+ const STALL_CODES = new Set(['EAGAIN', 'EWOULDBLOCK', 'EINTR']);
58
+
59
+ function sleep(ms: number): void {
60
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
61
+ }
62
+
63
+ export function writeWhole(fd: number, text: string, plants: WriteWholePlants = {}): void {
64
+ const write = plants.write ?? ((f: number, b: Uint8Array, o: number, l: number) => writeSync(f, b, o, l));
65
+ const wait = plants.wait ?? sleep;
66
+ const bytes = Buffer.from(text, 'utf8');
67
+ let offset = 0;
68
+ let stalls = 0;
69
+ while (offset < bytes.length) {
70
+ let taken = 0;
71
+ try {
72
+ taken = write(fd, bytes, offset, bytes.length - offset);
73
+ } catch (err) {
74
+ const code = (err as NodeJS.ErrnoException).code;
75
+ // The reader closed its end (`| head`): it has what it asked for, and nobody is left to read the rest.
76
+ if (code === 'EPIPE') return;
77
+ if (code === undefined || !STALL_CODES.has(code)) {
78
+ throw new StdoutWriteError(`${offset} of ${bytes.length} byte(s) confirmed written to fd ${fd} before it stopped: ${code ?? String(err)} — the descriptor is not writable`);
79
+ }
80
+ }
81
+ if (taken > 0) {
82
+ offset += taken;
83
+ stalls = 0;
84
+ continue;
85
+ }
86
+ stalls += 1;
87
+ if (stalls > MAX_STALLS) {
88
+ throw new StdoutWriteError(`${offset} of ${bytes.length} byte(s) confirmed written to fd ${fd}, and the reader took nothing for ${MAX_STALLS} attempt(s) in a row — it stopped reading`);
89
+ }
90
+ wait(Math.min(2 ** (stalls - 1), STALL_WAIT_CAP_MS));
91
+ }
92
+ }