spine-rigc 2.55.0 → 2.57.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
@@ -39,10 +39,11 @@ import { compile, CompileError, droppedStateReason, headerBoundsOf, type Compile
39
39
  import { depthStepLevels, type FoldLimit, type TurnCeiling } from '../depth.ts';
40
40
  import { FOLDS_FILE } from '../folds.ts';
41
41
  import { PASS_THROUGH_FILE, PASS_THROUGH_RUN } from '../passthrough.ts';
42
+ import { readViewportBox, ViewportError } from '../viewportpin.ts';
42
43
  import { parseJsonWithPosition } from '../json-position.ts';
43
44
  import { RUNG_IDS } from '../ladder.ts';
44
45
  import { MODEL_DOCUMENT_FILE, MODEL_DOCUMENT_SPEC, modelDocument, spineFileSha256 } from '../model.ts';
45
- import { compileErrorLine } from '../guide.ts';
46
+ import { compileErrorLine, nearestNames } from '../guide.ts';
46
47
  import { PACKAGE_ROOT, readPackageMeta, readVersion } from '../package_meta.ts';
47
48
  import { DEFAULT_MAX_RESIDUAL, DEFAULT_SCALE_MAX, DEFAULT_SCALE_MIN } from '../pose.ts';
48
49
  import {
@@ -64,6 +65,7 @@ import { InspectError, DEFAULT_SHOW, DIM_KEEP, INSPECT_DEFAULT_FPS, ON_GRID_FRAM
64
65
  import { NotAPngError } from '../png.ts';
65
66
  import { PoseError } from '../pose.ts';
66
67
  import { RepackError } from '../repack.ts';
68
+ import { SplitError } from '../meshsplit.ts';
67
69
  import { SpineRuntimeError, SPINE_SIDE_ABSENT } from '../spine_side.ts';
68
70
  import type { CompileResult, DroppedState } from '../types.ts';
69
71
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
@@ -675,12 +677,12 @@ function readCheckFlags(
675
677
  out.fps = fps;
676
678
  }
677
679
  if (flags.viewport !== undefined) {
678
- const parts = flags.viewport.split(',').map((s) => Number(s.trim()));
679
- if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n))) {
680
- throw new UsageError('--viewport takes four numbers: <x>,<y>,<width>,<height> — the world box, y up');
680
+ try {
681
+ out.viewport = readViewportBox(flags.viewport);
682
+ } catch (err) {
683
+ if (err instanceof ViewportError) throw new UsageError(err.message);
684
+ throw err;
681
685
  }
682
- if (parts[2] <= 0 || parts[3] <= 0) throw new UsageError('--viewport width and height must be positive');
683
- out.viewport = { x: parts[0], y: parts[1], width: parts[2], height: parts[3] };
684
686
  }
685
687
  if (flags.as !== undefined) out.as = flags.as;
686
688
  // The name is not checked against the candidate here: `check` owns that
@@ -1021,6 +1023,17 @@ const FLAG_MEANINGS: Record<string, string> = {
1021
1023
  '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. ' +
1022
1024
  'The order is the declaration: the first name must draw over the second for a frame to be read. A reading, not a gate. ' +
1023
1025
  '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',
1026
+ 'angle-range':
1027
+ 'also read, for each <bone>:<relative-to> pair, the range of the first bone\'s world direction against the second\'s over each set\'s ' +
1028
+ 'frames — its rotationX less the other\'s, followed from frame to frame so a sweep past ±180° reads as itself — and print one line per ' +
1029
+ '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 ' +
1030
+ '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, ' +
1031
+ '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)',
1032
+ 'viewport-from':
1033
+ "draw on an earlier render's grid exactly — the box, the scale and the pixel size its frames.json records (the file, or the " +
1034
+ 'directory `render --out` wrote it into) — so a world point lands on the same pixel in both renders. A slot that reaches past ' +
1035
+ 'the box is named as under --viewport, and frames.json records the same under `pinned`. Not with --viewport, and not with --max: ' +
1036
+ 'the file fixes the pixel size',
1024
1037
  environment:
1025
1038
  'the world the rig\'s physics answers, in time: a file { "spec": "rigc-environment/1", "steps": [{ "t": <seconds>, ' +
1026
1039
  '"wind"?: [x, y], "gravity"?: [x, y] }, …] }, x right and y down, each step holding from its t until the next and set ' +
@@ -1035,6 +1048,12 @@ const FLAG_MEANINGS: Record<string, string> = {
1035
1048
  'line of the render or the check report says which and why. `--poser core` on an input that cannot carry it is refused by name. ' +
1036
1049
  "`explain`: which reads and poses the DEFORM block's survey — `core` (the model document the compile writes, spine-core untouched) or " +
1037
1050
  '`spine` (the Spine skeleton parsed and posed by spine-core); default `core`, and `spine` where the core refuses the document, which the block names',
1051
+ 'lever-fold':
1052
+ '`explain` only: also read, on the turn lines\' walk, the steps where a physics constraint that drives scaleX has its inertia lever ' +
1053
+ 'divide by less than this fraction (above 0, at most 1) of the divisor it takes at the setup pose — the bone\'s length times its world x-axis ' +
1054
+ 'length, less scaleLag × shown on a drive that does not rotate — and the steps where the scale factor it applies moves by more than the ' +
1055
+ 'bone\'s setup scale in one step: each line the constraint, the steps, the extreme value and its step. No default: without it the reading ' +
1056
+ 'is not taken, and says so; a value that is not such a fraction is refused by name. A reading, not a gate',
1038
1057
  'texture-from':
1039
1058
  "also measure this run through this atlas's texels, keeping the candidate's own geometry, and report how much " +
1040
1059
  'of the MAE is texture resampling rather than the rig — pass the atlas the reference frames were rendered ' +
@@ -1080,6 +1099,9 @@ const FLAG_MEANINGS: Record<string, string> = {
1080
1099
  `printed (default ${DEFAULT_MIN_LEVER_PX}); below it the bone is refused \`no-bracket\` naming the measured ` +
1081
1100
  'lever, because an angle read across a short lever turns a half-pixel anchor error into several degrees',
1082
1101
  animation: 'which animation to show; the default is every one for `render` and the first for `preview`',
1102
+ attachment:
1103
+ "the placeholder the mesh is filed under in its slot, as the rig spec's skin names it. Absent, the slot's one " +
1104
+ 'authored mesh in that skin; a slot holding none or several is refused naming them',
1083
1105
  skin:
1084
1106
  'pose under this skin, by the name the skeleton declares. Without it NO skin is set — every slot resolves ' +
1085
1107
  'through the default skin alone, so a slot whose art lives only in a named skin draws nothing. A name the ' +
@@ -1211,6 +1233,8 @@ const FLAG_VALUES: Record<string, string> = {
1211
1233
  poser: 'core|spine',
1212
1234
  environment: '<schedule.json>',
1213
1235
  'pass-through': '<cloth>:<leg>[,<cloth>:<leg>…]',
1236
+ 'angle-range': '<bone>:<relative-to>[,…]',
1237
+ 'viewport-from': '<frames.json|dir>',
1214
1238
  reference: '<dir|skeleton.json>',
1215
1239
  'reference-atlas': '<path>',
1216
1240
  bones: `<correspondence.json|${IDENTITY_CORRESPONDENCE}>`,
@@ -1225,6 +1249,7 @@ const FLAG_VALUES: Record<string, string> = {
1225
1249
  scale: '<min,max>',
1226
1250
  rotation: '<min,max>',
1227
1251
  'max-residual': '<0..1>',
1252
+ 'lever-fold': '<fraction>',
1228
1253
  anchor: '<pose.json>',
1229
1254
  hinge: '<min,max>',
1230
1255
  stretch: '<ratio>',
@@ -1233,6 +1258,7 @@ const FLAG_VALUES: Record<string, string> = {
1233
1258
  'anchor-residual': '<0..1>',
1234
1259
  'inward-lever': '<px>',
1235
1260
  animation: '<name>',
1261
+ attachment: '<name>',
1236
1262
  skin: '<name>',
1237
1263
  slot: '<name[,name…]>',
1238
1264
  hide: '<name[,name…]>',
@@ -1476,8 +1502,9 @@ export const COMMANDS: CommandDoc[] = [
1476
1502
  'rigc explain --rig <path> --motion <path> --out <dir> [--manifest <path>] [--images <dir>] [--poser core|spine] (it never gates, and writes nothing)',
1477
1503
  'rigc explain … --atlas-in <skeleton.atlas> (resolve the parts against a pack somebody already made, as build does)',
1478
1504
  'rigc explain --cut <name> --cuts <cuts.json>',
1505
+ 'rigc explain … --lever-fold <fraction> (also name the steps a scaleX lever divides by a collapsed length)',
1479
1506
  ],
1480
- flags: ['rig', 'motion', 'out', 'manifest', 'images', 'atlas-in', 'cut', 'cuts', 'poser'],
1507
+ flags: ['rig', 'motion', 'out', 'manifest', 'images', 'atlas-in', 'cut', 'cuts', 'poser', 'lever-fold'],
1481
1508
  notes: [
1482
1509
  'this line said "the same arguments as build, minus --profile" and was false in both',
1483
1510
  'directions (issue #697): --atlas-in was not listed here, so the one flag that lets this',
@@ -1518,6 +1545,46 @@ export const COMMANDS: CommandDoc[] = [
1518
1545
  },
1519
1546
  },
1520
1547
  },
1548
+ {
1549
+ name: 'split',
1550
+ runtime: false,
1551
+ spineFormat: false,
1552
+ usage: ['rigc split --rig <path> --motion <path> --slot <name> [--attachment <name>] [--skin <name>] --out <dir>'],
1553
+ flags: ['rig', 'motion', 'slot', 'attachment', 'skin', 'out'],
1554
+ overrides: {
1555
+ out: {
1556
+ value: '<dir>',
1557
+ meaning:
1558
+ 'directory to write the proposal into — rig.json and motion.json, the two specs with the mesh split, and ' +
1559
+ 'split.json, where every original vertex and triangle went. Absent or empty, and refused otherwise: the ' +
1560
+ "proposal never lands on top of a file, the author's own specs included",
1561
+ },
1562
+ slot: {
1563
+ value: '<name>',
1564
+ meaning: 'the slot whose mesh to split, as the rig spec declares it. A name it does not declare is refused with every one it does',
1565
+ },
1566
+ skin: {
1567
+ meaning:
1568
+ 'the skin the mesh is filed under in the rig spec (default: default). A name the rig spec does not declare ' +
1569
+ 'is refused with the ones it does',
1570
+ },
1571
+ },
1572
+ notes: [
1573
+ 'split reads the two specs — not a build: a mesh of several pieces does not build —',
1574
+ 'and finds the pieces of one authored mesh: triangles that share a vertex are one piece.',
1575
+ 'A mesh of one piece is said so on one line, exit 0, and nothing is written. Otherwise it',
1576
+ 'writes one mesh per piece: piece 1 (the piece whose first triangle is listed first) stays',
1577
+ 'in the slot, piece k gets a new slot <slot>_<k> on the same bone, after it in the draw',
1578
+ "order, under the same placeholder. Each piece carries the original's uvs and weights (or",
1579
+ 'vertices) unchanged and its triangles renumbered — outline first where the hull rule',
1580
+ 'needs it — and every deform key is re-sliced to the new indices; colour and attachment',
1581
+ 'tracks on the slot are copied to the new slots. Nothing is invented: a deform index the',
1582
+ 'original run did not cover is written 0, which is what the parser held there. What the',
1583
+ 'split cannot carry is refused by name, exit 1 (docs/AUTHORING.md §3.4.11 lists it); a',
1584
+ 'name the specs do not have is refused with the names they do, exit 2. Nothing is ever',
1585
+ 'written over the specs it read: build the proposal, look at it, then adopt it.',
1586
+ ],
1587
+ },
1521
1588
  {
1522
1589
  name: 'diff',
1523
1590
  runtime: false,
@@ -1618,11 +1685,24 @@ export const COMMANDS: CommandDoc[] = [
1618
1685
  'rigc render … --slot <name[,name…]> | --hide <name[,name…]> (a subset of the slots, on the whole rig\'s grid)',
1619
1686
  'rigc render … --environment <schedule.json> (the wind and gravity the physics answers, in time)',
1620
1687
  'rigc render … --pass-through <cloth>:<leg>[,…] (the frames where a cloth drawn over a leg passes through it)',
1688
+ 'rigc render … --angle-range <bone>:<relative-to>[,…] (how far a bone turns against another, per animation)',
1689
+ 'rigc render … --viewport <x,y,w,h> | --viewport-from <frames.json> (a box given, not fitted: two renders on one pixel grid)',
1621
1690
  ],
1622
- flags: ['candidate', 'atlas', 'animation', 'skin', 'slot', 'hide', 'fps', 'max', 'geometry', 'folds', 'pass-through', 'environment', 'poser', 'out'],
1691
+ flags: ['candidate', 'atlas', 'animation', 'skin', 'slot', 'hide', 'fps', 'max', 'viewport', 'viewport-from', 'geometry', 'folds', 'pass-through', 'angle-range', 'environment', 'poser', 'out'],
1623
1692
  overrides: {
1624
1693
  out: { value: '<dir>', meaning: 'directory to write the frame series into (default `render/`)' },
1625
- fps: { meaning: `frames per second to sample the animation at (default ${PROTOCOL_FPS})` },
1694
+ fps: {
1695
+ meaning:
1696
+ `frames per second to sample the animation at (default ${PROTOCOL_FPS}). A set drawn at any rate but the default is written to ` +
1697
+ '`<anim>@<fps>fps/` rather than `<anim>/`, so two rates sit side by side: `--fps 24` writes `<anim>@24fps/`, and the default 12 writes `<anim>/`',
1698
+ },
1699
+ viewport: {
1700
+ meaning:
1701
+ 'draw every frame over this world box, y up — x,y its bottom-left corner, the numbers frames.json\'s `viewport` records — ' +
1702
+ 'instead of the box fitted to the rig; its pixel size follows from --max. A slot that reaches past the box is named by set and ' +
1703
+ 'frame, with how far and on which side: what lies past it is not drawn, since each frame is the box. frames.json records that ' +
1704
+ 'the box was given, and those slots, under `pinned`. Not with --viewport-from',
1705
+ },
1626
1706
  },
1627
1707
  },
1628
1708
  {
@@ -1910,6 +1990,16 @@ const USAGE_PARAGRAPHS: ReadonlyArray<{ about: readonly string[] | { flag: strin
1910
1990
  'is missing from it beats no spec at all.',
1911
1991
  ],
1912
1992
  },
1993
+ {
1994
+ about: ['split'],
1995
+ lines: () => [
1996
+ "split answers the refusal a mesh of several pieces meets at compile — the triangles'",
1997
+ 'outline is not one closed loop — with one mesh per piece, its uvs, weights, triangles',
1998
+ 'and deform keys renumbered, written beside the specs and never over them:',
1999
+ ' rigc split --rig rig.json --motion motion.json --slot legs --out split/',
2000
+ 'Build what it wrote. See `rigc split --help`.',
2001
+ ],
2002
+ },
1913
2003
  {
1914
2004
  about: ['pose'],
1915
2005
  lines: () => [
@@ -1995,6 +2085,7 @@ export function usageText(docs: readonly CommandDoc[], checkout: string, rules:
1995
2085
  ...docs.flatMap((c) => c.usage.map((u) => ` ${u}`)),
1996
2086
  '',
1997
2087
  ' rigc <command> --help that command\'s own flag table',
2088
+ ...HELP_DOC.usage.map((u) => ` ${u}`),
1998
2089
  ' rigc --version print the installed version (-v works too)',
1999
2090
  ];
2000
2091
  // Read only by the --profile paragraph, which only an entry running a command that takes --profile prints.
@@ -2748,6 +2839,46 @@ function commandNeedsRuntime(doc: CommandDoc, runs: readonly string[], args: rea
2748
2839
  );
2749
2840
  }
2750
2841
 
2842
+ /**
2843
+ * `rigc help` documented as a command is (issue #1440): on the usage of both
2844
+ * entries, and its own page for `rigc help --help` and `rigc help help`, as
2845
+ * every listed command answers `--help`. Not a row of `COMMANDS`, because it
2846
+ * has no body of its own: it is the dispatch reading another spelling of
2847
+ * `--help` (`helpArgv`), on every entry alike.
2848
+ */
2849
+ const HELP_DOC: CommandDoc = {
2850
+ name: 'help',
2851
+ usage: ['rigc help [<command>] the same page as rigc [<command>] --help, and the same exit'],
2852
+ flags: [],
2853
+ runtime: false,
2854
+ spineFormat: false,
2855
+ };
2856
+
2857
+ /**
2858
+ * `rigc help [<command>]` (issue #1440): the command line it stands for, which
2859
+ * the dispatch then runs in its place — `rigc --help` for `help` alone,
2860
+ * `rigc <command> --help` for a command the table documents — or `null` for
2861
+ * `help`'s own page (`help --help`, `help help`). Rewritten rather than
2862
+ * printed here, so the page and the exit code are the ones that spelling gets
2863
+ * on this entry and cannot come apart from them: a command this entry does not
2864
+ * run is refused as `<command> --help` is.
2865
+ *
2866
+ * ⚠️ A name the table does not document is refused by name with the nearest
2867
+ * one, as a `UsageError` — printed and exited as an unknown command is, exit 2
2868
+ * — and so is more than one name, rather than a guess at which was meant.
2869
+ */
2870
+ export function helpArgv(rest: readonly string[]): string[] | null {
2871
+ if (rest.length === 0) return ['--help'];
2872
+ if (rest.length > 1) {
2873
+ throw new UsageError(`help takes one command name and was given ${rest.length}: ${rest.join(' ')} — \`rigc help <command>\` is \`rigc <command> --help\``);
2874
+ }
2875
+ const [name] = rest;
2876
+ if (name === HELP_DOC.name || name === '--help' || name === '-h') return null;
2877
+ if (KNOWN_COMMANDS.includes(name)) return [name, '--help'];
2878
+ const [nearest] = nearestNames(name, [...KNOWN_COMMANDS, HELP_DOC.name], 1);
2879
+ throw new UsageError(`unknown command: ${name} — the nearest command is ${nearest} (\`rigc help ${nearest}\`)`);
2880
+ }
2881
+
2751
2882
  /**
2752
2883
  * Run `argv` (`process.argv` without the runtime and the script) through
2753
2884
  * `entry`: the usage, `--version`, a command's `--help`, the command, and
@@ -2762,6 +2893,20 @@ export function runCli(entry: CliEntry, argv: readonly string[]): void {
2762
2893
  throw new Error(`internal: this entry runs [${known.join(', ')}] by the command table and registers [${registered.join(', ')}]`);
2763
2894
  }
2764
2895
  const USAGE = usageText(docs, entry.checkout, entry.profileRules);
2896
+ if (argv[0] === 'help') {
2897
+ let target: string[] | null;
2898
+ try {
2899
+ target = helpArgv(argv.slice(1));
2900
+ } catch (err) {
2901
+ refuse(err, 'help', USAGE, entry);
2902
+ }
2903
+ if (target === null) {
2904
+ console.log(commandHelp(HELP_DOC.name, [HELP_DOC]));
2905
+ process.exit(0);
2906
+ }
2907
+ runCli(entry, target);
2908
+ return;
2909
+ }
2765
2910
  const [command, ...rest] = argv;
2766
2911
  try {
2767
2912
  if (command === undefined) {
@@ -2878,6 +3023,12 @@ function refuse(err: unknown, command: string | undefined, USAGE: string, entry:
2878
3023
  console.error(`rigc repack: ${err.message}`);
2879
3024
  process.exit(err.status);
2880
3025
  }
3026
+ // A split refused (issue #1414): a name the specs do not have (2 — the invocation has to change), or something
3027
+ // the specs hold that the split cannot carry (1). The message names it, and the usage under it would bury that.
3028
+ if (err instanceof SplitError) {
3029
+ console.error(`rigc split: ${err.message}`);
3030
+ process.exit(err.status);
3031
+ }
2881
3032
  // A page or frame that is not a PNG rigc can read, from any command that
2882
3033
  // opens one (`render`, `check`, `preview`, …) — issue #732. The sentence is
2883
3034
  // the one reader's and already names the file, what it is and what rigc
package/src/compile.ts CHANGED
@@ -99,6 +99,7 @@ import {
99
99
  type MeshGeometry,
100
100
  type MeshVertexWeight,
101
101
  } from './mesh.ts';
102
+ import { meshPieces, shellWord } from './meshsplit.ts';
102
103
  import { Plate, readPlate } from '../tools/plate.ts';
103
104
  import {
104
105
  extractRegion,
@@ -120,7 +121,7 @@ import {
120
121
  type PhysicsPoseRule,
121
122
  type SlotColorChannel,
122
123
  } from './timelines.ts';
123
- import { evaluateDeformTransform } from './deformgen.ts';
124
+ import { evaluateDeformTransform, type DeformSoftMask } from './deformgen.ts';
124
125
  import { evaluateTrackDerive, TRACK_DERIVE_PROJECTIONS, type TrackDeriveMember } from './trackgen.ts';
125
126
  import {
126
127
  computeExactFrameTransforms,
@@ -2875,6 +2876,9 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
2875
2876
  // `skin/slot/attachment` -> per-vertex z, for the attachments that named a
2876
2877
  // depth map. Read at deform-key time; never emitted. See `AttachmentContext`.
2877
2878
  const attachmentDepths = new Map<string, number[]>();
2879
+ // The same for a `soft` mask's per-vertex level, which a deform envelope's
2880
+ // `mask` reads (issue #1413). Never emitted either.
2881
+ const attachmentSoftMasks = new Map<string, DeformSoftMask>();
2878
2882
 
2879
2883
  for (const rigSlot of rig.slots) {
2880
2884
  const part = partBySlot.get(rigSlot.name);
@@ -3042,6 +3046,7 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
3042
3046
  skinName,
3043
3047
  imagesDir,
3044
3048
  depths: attachmentDepths,
3049
+ softMasks: attachmentSoftMasks,
3045
3050
  slotNames: new Set(rig.slots.map((s) => s.name)),
3046
3051
  atlasRegions: atlasIn === null ? null : atlasIn.byName,
3047
3052
  links: pendingLinks,
@@ -3201,6 +3206,17 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
3201
3206
  if (!boneNames.has(spec.bone)) {
3202
3207
  throw new CompileError(`physics constraint "${name}" targets unknown bone "${spec.bone}"`);
3203
3208
  }
3209
+ // A stop's copy and clamps sit right after the rig constraint that swings
3210
+ // each link (`src/stop.ts`); this table is emitted after every rig
3211
+ // constraint, so a swing here would run after the clamp meant to hold it.
3212
+ const stop = (rig.stops ?? []).find((s) => s.links.includes(spec.bone));
3213
+ if (stop !== undefined) {
3214
+ throw new CompileError(
3215
+ `physics constraint "${name}" drives "${spec.bone}", a link stop "${stop.chain}" holds, from the physics table — ` +
3216
+ "the table is emitted after every rig constraint, past the stop's copy and clamp, which would read the link " +
3217
+ 'before this swings it; declare the constraint in the rig spec\'s "constraints"',
3218
+ );
3219
+ }
3204
3220
  // Every component and parameter the table states is held, 0 and the
3205
3221
  // parser's default included; which of them Spine leaves out is the
3206
3222
  // emitter's (`emitConstraint`). `components` is the report's list of the
@@ -3544,6 +3560,7 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
3544
3560
  {
3545
3561
  ...deformGeometryOf(attachment, at, transforms),
3546
3562
  depth: attachmentDepths.get(`${skinName}/${track.slot}/${track.attachment}`) ?? null,
3563
+ soft: attachmentSoftMasks.get(`${skinName}/${track.slot}/${track.attachment}`) ?? null,
3547
3564
  },
3548
3565
  deformTransforms,
3549
3566
  );
@@ -4174,6 +4191,12 @@ interface AttachmentContext {
4174
4191
  * read by `evaluateDeformTransform` at key time and never emitted at all.
4175
4192
  */
4176
4193
  depths: Map<string, number[]>;
4194
+ /**
4195
+ * A `soft` mask's file and its sampled level per vertex, keyed like `depths`
4196
+ * — what a deform envelope's `mask` reads at key time (issue #1413). Never
4197
+ * emitted, for `depths`' reason.
4198
+ */
4199
+ softMasks: Map<string, DeformSoftMask>;
4177
4200
  /** Every slot the rig declares — a clipping attachment's `end` resolves here. */
4178
4201
  slotNames: Set<string>;
4179
4202
  /**
@@ -5477,6 +5500,7 @@ function authoredHullAndEdges(
5477
5500
  vertexCount: number,
5478
5501
  triangles: number[],
5479
5502
  where: string,
5503
+ named: { skin: string; slot: string; placeholder: string },
5480
5504
  ): { hull: number; edges: number[] } {
5481
5505
  try {
5482
5506
  const outline = traceOutline(vertexCount, triangles);
@@ -5489,11 +5513,31 @@ function authoredHullAndEdges(
5489
5513
  checkHullOrder(outline, vertexCount);
5490
5514
  return { hull: outline.hull, edges: att.edges ?? meshEdges(vertexCount, triangles, outline.hull) };
5491
5515
  } catch (err) {
5492
- if (err instanceof MeshError) throw new CompileError(`${where}: ${err.message}`);
5516
+ if (err instanceof MeshError) throw new CompileError(`${where}: ${err.message}${splitPointer(vertexCount, triangles, named)}`);
5493
5517
  throw err;
5494
5518
  }
5495
5519
  }
5496
5520
 
5521
+ /**
5522
+ * The command that answers an outline refusal whose cause is pieces that share
5523
+ * no vertex (issue #1414): one mesh is one outline, so the fix is one mesh per
5524
+ * piece, and `rigc split` writes that proposal. Empty for every other cause —
5525
+ * a pinch, a hole, a doubled triangle — whose fix is the triangles, and for a
5526
+ * list the refusal is about because its indices are not vertices at all.
5527
+ */
5528
+ function splitPointer(vertexCount: number, triangles: readonly number[], named: { skin: string; slot: string; placeholder: string }): string {
5529
+ if (triangles.length % 3 !== 0 || triangles.some((v) => !Number.isInteger(v) || v < 0 || v >= vertexCount)) return '';
5530
+ const count = meshPieces(vertexCount, triangles).pieces.length;
5531
+ if (count < 2) return '';
5532
+ // Each name one shell word (issue #1442): the command is copied into a shell, and a bare `left legs` is two arguments.
5533
+ const skin = named.skin === 'default' ? '' : ` --skin ${shellWord(named.skin)}`;
5534
+ return (
5535
+ ` — the triangles are ${count} pieces that share no vertex, and one mesh is one outline: ` +
5536
+ `\`rigc split --rig <rig> --motion <motion> --slot ${shellWord(named.slot)} --attachment ${shellWord(named.placeholder)}${skin} --out <dir>\` ` +
5537
+ 'writes one mesh per piece, its deform keys renumbered (AUTHORING §3.4.11)'
5538
+ );
5539
+ }
5540
+
5497
5541
  /**
5498
5542
  * The same two fields for geometry rigc built. The generators already put the
5499
5543
  * outline first and in order — a contour is all outline, a ring's outer ring
@@ -5577,7 +5621,7 @@ function buildRigMesh(
5577
5621
  vertices = { weighted: false, xy: raw.map(f32) };
5578
5622
  }
5579
5623
  }
5580
- const { hull, edges } = authoredHullAndEdges(att, uvCount / 2, att.triangles, where);
5624
+ const { hull, edges } = authoredHullAndEdges(att, uvCount / 2, att.triangles, where, { skin: ctx.skinName, slot: ctx.slotName, placeholder });
5581
5625
  // `width`/`height` are the size of the image the mesh is drawn on. Stated
5582
5626
  // wins; otherwise the PNG the attachment names, already measured for the
5583
5627
  // atlas — the same `CompiledImage.width` a region reads, so a page with a
@@ -6292,7 +6336,7 @@ function softRegionWeights(
6292
6336
  grid: SheetGrid,
6293
6337
  where: string,
6294
6338
  ctx: AttachmentContext,
6295
- ): { weights: MeshVertexWeight[][]; bone: string; mask: string; digest: string; carried: number; ramped: number } {
6339
+ ): { weights: MeshVertexWeight[][]; levels: number[]; bone: string; mask: string; digest: string; carried: number; ramped: number } {
6296
6340
  const { bone } = spec;
6297
6341
  if (typeof bone !== 'string' || bone.length === 0) {
6298
6342
  throw new CompileError(`${where}: the "soft" block has no "bone"; it is the bone the soft region is carried by`);
@@ -6316,8 +6360,10 @@ function softRegionWeights(
6316
6360
  const [toSheetX, toSheetY] = sheetRatio(grid);
6317
6361
  let carried = 0;
6318
6362
  let ramped = 0;
6363
+ const levels: number[] = [];
6319
6364
  const weights = points.map(([x, y]) => {
6320
6365
  const w = sampleLevel(sheet, x * toSheetX, y * toSheetY) / 255;
6366
+ levels.push(w);
6321
6367
  if (w >= 1) carried++;
6322
6368
  else if (w > 0) ramped++;
6323
6369
  if (w <= 0) return [{ bone: 'anchor', weight: 1 }] as MeshVertexWeight[];
@@ -6334,7 +6380,7 @@ function softRegionWeights(
6334
6380
  'check that the mask is painted where the mesh actually is, and that it is white where the art is soft.',
6335
6381
  );
6336
6382
  }
6337
- return { weights, bone, mask: spec.mask, digest: depthDigest(sheet), carried, ramped };
6383
+ return { weights, levels, bone, mask: spec.mask, digest: depthDigest(sheet), carried, ramped };
6338
6384
  }
6339
6385
 
6340
6386
  /**
@@ -6461,6 +6507,7 @@ function buildGridAttachment(
6461
6507
  ctx.meshBones.add(ctx.anchorBone);
6462
6508
  if (bound) ctx.meshBones.add(bound.bone);
6463
6509
  if (depth !== undefined) ctx.depths.set(`${ctx.skinName}/${ctx.slotName}/${placeholder}`, depth.z);
6510
+ if (bound) ctx.softMasks.set(`${ctx.skinName}/${ctx.slotName}/${placeholder}`, { mask: bound.mask, levels: bound.levels });
6464
6511
  ctx.meshes.push({
6465
6512
  slot: ctx.slotName,
6466
6513
  kind: 'grid',
@@ -8520,6 +8567,12 @@ export interface DeformGeometry {
8520
8567
  * caller, which is where the skin, slot and attachment names live.
8521
8568
  */
8522
8569
  depth: readonly number[] | null;
8570
+ /**
8571
+ * The attachment's `soft` mask and its level per vertex, when its generator
8572
+ * named one — what a deform envelope's `mask` reads (issue #1413). Filled in
8573
+ * by the caller, as `depth` is, and for the same reason.
8574
+ */
8575
+ soft: DeformSoftMask | null;
8523
8576
  }
8524
8577
 
8525
8578
  /**
@@ -8596,8 +8649,9 @@ export function deformGeometryOf(
8596
8649
  influenceBones: null,
8597
8650
  setupWhy: null,
8598
8651
  // Filled in by the caller; `deformGeometryOf` reads the attachment's
8599
- // record, and the depth is deliberately not in it.
8652
+ // record, and the depth is deliberately not in it — nor the soft mask.
8600
8653
  depth: null,
8654
+ soft: null,
8601
8655
  };
8602
8656
  }
8603
8657
  // Walk the bindings for the per-vertex influence counts. Their shape is
@@ -8631,7 +8685,7 @@ export function deformGeometryOf(
8631
8685
  // LOADED attachment, which is where the `/3*2` in the parser comes from.
8632
8686
  let influences = 0;
8633
8687
  for (const n of boneCounts) influences += n;
8634
- const common = { weighted, deformLength: influences * 2, vertexCount: boneCounts.length, boneCounts, depth: null };
8688
+ const common = { weighted, deformLength: influences * 2, vertexCount: boneCounts.length, boneCounts, depth: null, soft: null };
8635
8689
  // -- one bind space, unchanged -------------------------------------------
8636
8690
  //
8637
8691
  // Every vertex a single influence of a single bone: the run IS that bone's
@@ -8874,7 +8928,14 @@ function compileDeformTrack(
8874
8928
  'yourself and start the run with "offset".',
8875
8929
  );
8876
8930
  }
8877
- const report = evaluateDeformTransform(key.transform, geometry.setup, onModelGrid, `${where} (t=${key.t})`, geometry.depth);
8931
+ const report = evaluateDeformTransform(
8932
+ key.transform,
8933
+ geometry.setup,
8934
+ onModelGrid,
8935
+ `${where} (t=${key.t})`,
8936
+ geometry.depth,
8937
+ geometry.soft,
8938
+ );
8878
8939
  if (report.offsets.length !== geometry.vertexCount * 2) {
8879
8940
  // Unreachable while `setup` is one pair per vertex, which is the whole
8880
8941
  // reason both are derived from the same walk. Stated rather than
package/src/core/entry.ts CHANGED
@@ -20,7 +20,11 @@
20
20
  * `openTracks`, issue #1336; `physicsClockStep` and `PHYSICS_CLOCK_LEAD`,
21
21
  * the clock a live track's `clock` option takes, issue #1396;
22
22
  * `PhysicsEnvironment`, what a live track's `setEnvironment` takes, issue
23
- * #1416).
23
+ * #1416; `bezierPolyline` and `BEZIER_PIECES`, the polyline the poser draws
24
+ * a Bézier segment as, so that a fitter holds its tolerance to the poser's
25
+ * own evaluation rather than to a mirror of it, issue #1417 — re-exported,
26
+ * never copied, because a copy is the drift the request exists to remove;
27
+ * `CA12` holds the identity).
24
28
  * The list is `scripts/install_smoke.ts`'s `OBSERVED_SYMBOLS` rows for
25
29
  * `./core`, and a symbol added here and not there is not promised.
26
30
  */
@@ -39,4 +43,5 @@ export type { DrawnRegion, UvPage, UvRegion, UvSource } from './uvs.ts';
39
43
  export { clipThrough, clipShapeOf, convexWhy, REGION_UVS, REGION_TRIANGLES } from './clipping.ts';
40
44
  export type { ClipShape, ClipResult } from './clipping.ts';
41
45
  export { eventsFired } from './events.ts';
46
+ export { bezierPolyline, BEZIER_PIECES } from './animation.ts';
42
47
  export type { ModelPage, ModelPageRegion } from '../model.ts';