@rig-c/core 2.84.0 → 2.86.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
@@ -588,7 +588,7 @@ material, like the gallery: a clone runs them.
588
588
  | `ingest` | Read a skeleton back into specs. [`ingest` in the CLI reference](docs/AUTHORING.md#cmd-ingest) |
589
589
  | `split` | Separate disconnected mesh pieces. [`split` in the CLI reference](docs/AUTHORING.md#cmd-split) |
590
590
  | `explain` | Print the compiled rig as a table. [`explain` in the CLI reference](docs/AUTHORING.md#cmd-explain) |
591
- | `render` | Write PNG frames and a contact sheet. [`render` in the CLI reference](docs/AUTHORING.md#cmd-render) |
591
+ | `render` | Write PNG frames and a contact sheet, or a named dial’s geometry-only parallax reading. [`render` in the CLI reference](docs/AUTHORING.md#cmd-render) |
592
592
  | `preview` | Write a playable HTML preview. [`preview` in the CLI reference](docs/AUTHORING.md#cmd-preview) |
593
593
  | `vote` | Make a ballot and record its answer. [`vote` in the CLI reference](docs/AUTHORING.md#cmd-vote) |
594
594
  | `pose` | Read part placements from a picture. [`pose` in the CLI reference](docs/AUTHORING.md#cmd-pose) |
package/docs/AUTHORING.md CHANGED
@@ -378,6 +378,7 @@ What the flags mean:
378
378
  | `--show`, `--dim`, `--at`, `--zoom` | `inspect` only — what it draws, whether the art is faded, which times of `--animation` it poses and the bone, slot or folds it frames to; §13 says each, with the refusals |
379
379
  | `--geometry` | `render` only: also write a `geometry.json` into each frame directory — per frame, every bone's world transform and every slot's region or mesh vertices in world units after skinning, plus each attachment's rest geometry and topology, on the frames' own grid and viewport. Every other file is byte for byte what the render writes without it. Refused with `--slot`/`--hide`, because the geometry is the whole pose whatever is drawn — **§8.2** |
380
380
  | `--extremes-only` | `render` only: imply `--extremes`, but write only `extremes.json` and readable `extremes.png` with the same bytes. Pose the same grid; rasterize only the setup/min/max pictures used by the plate. No ordinary frame PNGs, contact sheet or `frames.json`. Takes no `--sheet`, `--sheet-format`, `--page-edges`, `--geometry`, `--folds`, `--pass-through`, `--seam`, `--overlaps` or `--angle-range`; drop the named flag or use `--extremes` instead — **§8.11** |
381
+ | `--parallax` | `render` only: a named slider dial, through the core poser. Write `parallax.json` and a readable `parallax.png` into `--out` on a fixed 9-value grid. This is a separate dial render mode, with `--skin`, `--max`, `--candidate`, `--atlas` and `--poser core`; it takes no animation, fps, subset, other diagnostic, sheet, environment or viewport flag. A missing dial is refused by name; unavailable fits report SKIP, never a pass — **§8.12** |
381
382
  | `--extremes` | `render` only: add `extremes.json` and an `EXTREMES` line per set; a moving set with comparable geometry and readable images also gets a native six-panel `extremes.png`: setup, geometry-pair min/max and three premultiplied RGBA diffs with a legend. Per-channel extrema are independent of that geometry pair; valid channels survive geometry/image SKIPs, and channel refusals are named separately. Still or uncomparable geometry is a named SKIP, with no invented pair or PNG; observation failures do not change a successful render's exit status. Supports `--slot`/`--hide` on both posers, on the whole rig's viewport. Without the flag, no extremes observations are added — **§8.11** |
382
383
  | `--folds` | `render` only: also read which mesh triangles **fold** — turn over against the setup pose — and write a `folds.json` into each frame directory: per mesh, the frames with a folded triangle, the worst frame's count and which triangles, by index, with their world box and setup-pose area. One printed line per mesh that folds; a triangle degenerate or inverted at the setup pose is said once and never counted. A reading, not a gate. Every other file is byte for byte what the render writes without it, `geometry.json` included. Refused with `--slot`/`--hide`, for `--geometry`'s reason — **§8.3** |
383
384
  | `--pass-through` | `render` only: `<cloth>:<leg>[,<cloth>:<leg>…]` — also read where each cloth slot **passes through** its leg slot, and write a `pass-through.json` into each frame directory: the frames where the cloth draws over the leg and its drawn side lies inside the leg's drawn outline by 2 steps or more, each with the depth and the world box. One printed line per pair, and one per frame that passes through. The first name must draw over the second for a frame to be read — the declared order, never both. A reading, not a gate. Every other file is byte for byte what the render writes without it. An unknown slot, a slot that draws on no frame, a malformed list and a pair named twice are refused by name, and so is `--slot`/`--hide` beside it — **§8.5** |
@@ -9734,6 +9735,78 @@ Without `--extremes` the existing frames and files are unchanged. The flag adds
9734
9735
  its observations alongside them. A SKIP removes a stale `extremes.png` for that
9735
9736
  set so an earlier pair cannot masquerade as the current reading.
9736
9737
 
9738
+ ### 8.12 Geometry of a value-driven turn — `render --parallax <dial>`
9739
+
9740
+ ```sh
9741
+ bun cli_core.ts render --candidate gallery/look/build --parallax yaw --max 640 --out parallax/
9742
+ ```
9743
+
9744
+ This reads a **slider constraint name**, not its animation or driving bone name.
9745
+ It writes `parallax.json` (`rigc-parallax/1`) and, when the images and extremes
9746
+ are readable, `parallax.png` at the output root. Use a dedicated output directory;
9747
+ no ordinary frames or `frames.json` are written in this mode. An unavailable
9748
+ picture removes an earlier `parallax.png` and leaves a named JSON SKIP. Ordinary
9749
+ render without the flag keeps its files and posing path unchanged.
9750
+
9751
+ On current compiled models, depth coherence and near/far travel always report
9752
+ SKIP: this adapter supplies neither per-vertex depth nor a world projection axis.
9753
+ Geometry residuals and layer spread are still read from the posed vertices.
9754
+
9755
+ Only a rigc build with a matching `skeleton.model.json`, posed by the core, is
9756
+ accepted; a Spine export, mismatched model or `--poser spine` is refused. Keep the
9757
+ model beside the build, or rebuild it. A missing slider is refused by name with
9758
+ the available slider names. `--animation`, `--fps`, subset, other diagnostics,
9759
+ sheet, environment and viewport flags are refused together rather than ignored.
9760
+ `--skin` selects the same skin as ordinary render; `--max` sizes the picture.
9761
+
9762
+ The setup pose and nine evenly spaced dial values are independent reset poses,
9763
+ not a track playing the lookup animation. Other inputs stay at setup; all
9764
+ constraints are applied, and other sliders may respond to the driven bone or its
9765
+ constraint chain. Physics is reset at every value. A bone-driven dial uses `from..max` when the model states
9766
+ `max`; without it the last animation time is inverted through the mapping. A
9767
+ bone-less slider uses `0..duration`, in seconds. The grid is numerically ascending even for a negative mapping; the declared
9768
+ endpoints remain in `sampling.range`. The JSON states the exact grid,
9769
+ requested values, actual applied times, drive field and setup value. The core's
9770
+ existing dial override preserves the bones' setup values for additive/nonadditive
9771
+ blends. Local properties are driven directly; a world property is solved through
9772
+ a responsive local field and verified against the actual property and mapped
9773
+ time. Unreachable values (including world rotation wrapping), inactive sliders
9774
+ or a zero/nonfinite range are named; no successful drive is invented.
9775
+
9776
+ Every **sample is fitted separately** from setup positions to that sample's
9777
+ positions. Fitting displacements as though they were positions, or pooling
9778
+ different dial values into one map, is a different question and is not done.
9779
+
9780
+ | JSON reading | Definition and unavailable cases |
9781
+ | --- | --- |
9782
+ | `samples[].meshes[].affine` | Forward geometric least squares, normalized with independent centroids and isotropic RMS scales, solved by reorthogonalized QR. World residual RMS divided by world RMS setup-to-sample motion over the same vertices. |
9783
+ | `homography` | A normalized inhomogeneous linear initializer and an affine initializer, each refined for up to 80 damped geometric least-squares iterations. Report the smallest finite **forward geometric** residual found, not the algebraic initializer objective. This is a local numerical fit, **not a certified global optimum**. |
9784
+ | `layers.spread` | One affine fit over the whole comparable rig, regions and meshes including stationary layers. Compute each layer's mean residual vector, then the unweighted RMS distance of those vectors from their mean, in world px. `ratio` divides by the whole fit's RMS motion. One layer is insufficient. Dense meshes influence the whole fit more; the spread itself weights layers equally. |
9785
+ | `depth` | Signed Spearman with average ranks between declared per-vertex depth and setup-to-sample motion projected onto a declared world turn axis. Opposite extrema can reverse the sign. Constant depth or constant projected motion has undefined correlation. Near/far travel means RMS travel of vertices at the maximum/minimum declared depth divided by absolute dial distance from setup, in world px per dial unit. A generic slider declares no turn axis. Current compiled models retain depth summaries and provenance, **not vertex depth samples**, so these quantities are explicitly SKIP; neither a summary range nor motion is used to reconstruct depth. |
9786
+
9787
+ Meshes appearing without setup correspondence are named SKIPs, not omitted.
9788
+ No vertices, unequal vertex counts, missing attachments, singular maps, deficient
9789
+ fit rank and nonfinite coordinates report named SKIPs. A stationary or unresolved
9790
+ RMS-motion denominator reports SKIP with a null ratio. Affine fits with at most
9791
+ three correspondences and homographies with at most four are saturated, so their
9792
+ ratios are unavailable: four nondegenerate points can fit arbitrary motion, and
9793
+ zero there cannot test paper. Numeric tolerance is a resolution check, never a
9794
+ quality threshold. `READ` means a measurement was obtained; there is no PASS.
9795
+
9796
+ The picture has six panels at one fitted pixel scale: setup, dial minimum, dial
9797
+ maximum, premultiplied RGBA absolute max/min difference, and two affine-residual
9798
+ heat maps at original mesh vertices. Blue is zero, red is the largest world
9799
+ residual across those two maps, with that value in the legend; unavailable
9800
+ vertices stay dark. An empty or unreadable picture never supplies a score.
9801
+
9802
+ **This cannot certify volume.** A rigid flat sheet under orthographic projection
9803
+ is affine; a perspective sheet is projective. But sloped depth can also be affine,
9804
+ and an articulated collection of flat surfaces need not share a map. Nonlinear
9805
+ 2D warps can produce large residuals without depth. The reading measures geometry,
9806
+ not draw order, colour, occlusion, tears, ghost lines or rendered optical flow.
9807
+ Compare the two fits, the per-sample curves and an explicit control before
9808
+ attributing residual to relief. No assertion, pass mark or build gate is added.
9809
+
9737
9810
  ## 9. Checking against the frames — `rigc check`
9738
9811
 
9739
9812
  ```bash
@@ -12792,6 +12865,57 @@ for `declared` and the run says so on a line of its own.
12792
12865
  - **Not a second poser.** Everything it draws is a pose `render` would have drawn at
12793
12866
  the same frame; where it disagrees with `render --geometry`, it is wrong (`IN04`).
12794
12867
 
12868
+ ### Texture scale — warning-only region reading
12869
+
12870
+ ```bash
12871
+ rigc texture-scale --candidate build/ --json texture-scale.json
12872
+ ```
12873
+
12874
+ `REGION_TEXTURE_MINIFIED` names each drawn region attachment whose **smallest
12875
+ sampled draw scale is below 0.5 world units per stored texture pixel**, with its
12876
+ slot, attachment, atlas region/page/index, stored texture width and height, and
12877
+ the animation and time of that sample (`animation: null`, `time: 0` for setup).
12878
+ The strict threshold applies to the measured value: a nominal authored 0.5 can
12879
+ read just below 0.5 after runtime transforms and floating-point storage.
12880
+ It is a warning, never an assertion or a build refusal. This separate reading
12881
+ changes no build, atlas or rendered frame. Scale 1.0 and exactly 0.5 stay silent.
12882
+
12883
+ The scale is the smallest singular value of the texture-pixel → world 2×2
12884
+ map read from posed region corners and their page UVs. It includes attachment
12885
+ size/scale, parent transforms, constraints, rotation, reflection, anisotropy,
12886
+ shear, trimming and atlas rotation. Raw bone scale alone cannot give this value.
12887
+ The consumer's camera, device pixels and viewport scaling are outside it.
12888
+
12889
+ Setup is always read, then every animation under `--skin <name>` (without it,
12890
+ no skin is set). `--fps <n>` defaults to 60. The sorted sample schedule combines
12891
+ that grid, each timeline's float32 key times, key-interval midpoints and the
12892
+ animation endpoint. Physics is stepped along this schedule, using the render
12893
+ clock. The JSON (`rigc-texture-scale/1`) records the exact schedule, threshold,
12894
+ rate, skin, count of distinct drawn region attachments and `warnings`.
12895
+ `--json` must be outside the candidate directory (symlinks are resolved). An
12896
+ existing file is replaced only when it is already a `rigc-texture-scale/1`
12897
+ report; a source artifact is refused before posing and left byte-identical.
12898
+ Text and JSON report the **minimum among these samples**, not a continuous
12899
+ minimum: interpolated transforms, shear and constraints can reach narrower
12900
+ extrema between them. Increase the rate to investigate; silence is not proof
12901
+ that no unsampled extreme exists. A tie keeps the first observation, setup first.
12902
+
12903
+ Only selected, active region attachments with positive combined alpha,
12904
+ nonempty clipped geometry and a source rectangle containing alpha are read.
12905
+ Meshes, unselected attachments, alpha-zero regions and wholly clipped regions
12906
+ are excluded. This is a geometric draw reading; it does not test occlusion by
12907
+ later slots or whether the visible clipped portion samples only transparent
12908
+ texels. The threshold identifies potential minification without mipmaps; it
12909
+ does not assert visible dither for every source texture.
12910
+
12911
+ **Remedies:** area-downsample the source texture and preserve the region's world
12912
+ width and height (and its placement) when rebuilding, or enable mipmaps in the
12913
+ consumer. For example, a 730×400 texture drawn at 0.167 can be area-downsampled
12914
+ to 122×67 while retaining world dimensions 121.91×66.8; the new draw scale is
12915
+ about 1.0. This removes high-frequency aliasing in the planted fixture, not a
12916
+ guarantee about arbitrary artwork or every consumer filter. rigc never resizes
12917
+ art automatically and this reading changes no atlas mipmap settings.
12918
+
12795
12919
  ## 14. Read a named mesh at setup
12796
12920
 
12797
12921
  `rig-c/core` promises `readSetupMesh(view, target)` and the types
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rig-c/core",
3
- "version": "2.84.0",
3
+ "version": "2.86.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": {
@@ -1,3 +1,5 @@
1
+ import { sampleParallax, parallaxPlate } from '../parallax_render.ts';
2
+ import { textureScaleReading, textureScaleLines, TEXTURE_SCALE_FPS } from '../texture_scale.ts';
1
3
  import { extremesOf, extremesText, extremesPlate, extremesLine, extremesRefusal } from '../extremes.ts';
2
4
  /**
3
5
  * The bodies of the commands that reach nothing of spine-core — `explain`,
@@ -53,7 +55,7 @@ import { writeWhole } from './write_whole.ts';
53
55
  import { GUIDE_FILE, GUIDE_RULE_TABLES_ELSEWHERE, guideAnswer, refusalLine } from '../guide.ts';
54
56
  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, printAlphaClips, 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';
55
57
  import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
56
- import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
58
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
57
59
  import { spawnSync } from 'node:child_process';
58
60
 
59
61
 
@@ -775,6 +777,36 @@ export function cmdRender(flags: Record<string, string>): void {
775
777
  { skeleton: skeletonPath, atlas: atlasPath },
776
778
  posersAsked(flags),
777
779
  );
780
+ if (flags.parallax !== undefined) {
781
+ for (const flag of ['animation', 'fps', 'slot', 'hide', 'geometry', 'folds', 'extremes', 'extremes-only', 'pass-through', 'seam', 'overlaps', 'angle-range', 'sheet', 'sheet-format', 'page-edges', 'environment', 'viewport', 'viewport-from']) {
782
+ if (flags[flag] !== undefined) throw new UsageError(`--parallax takes no --${flag}: it samples one named dial on its own 9-value grid; drop --${flag} or run ordinary render separately`);
783
+ }
784
+ if (flags.poser !== undefined && flags.poser !== 'core') throw new UsageError('--parallax requires --poser core: it reads only a rigc compiled model');
785
+ const modelText = modelTextBeside(skeletonPath, skeletonText);
786
+ if (modelText === undefined || choice.core === null) throw new UsageError('--parallax requires a matching skeleton.model.json and the core poser; rebuild the candidate and keep its model beside skeleton.json');
787
+ const selectedSkin = readSkinFlag(flags, [...facts.skins]);
788
+ let result: ReturnType<typeof sampleParallax>;
789
+ try { result = sampleParallax(modelText, atlasText ?? '', flags.parallax, selectedSkin); }
790
+ catch (error) {
791
+ if (error instanceof CoreInputError) throw new UsageError(error.message);
792
+ throw error;
793
+ }
794
+ let picture: Plate | null = null;
795
+ try {
796
+ picture = parallaxPlate(result, pages, maxSide);
797
+ if (picture !== null) result.reading.picture = { status: 'READ', reason: 'setup, dial extremes, diff and affine residual heat maps', file: 'parallax.png' };
798
+ } catch (error) { result.reading.picture.reason = `picture unavailable: ${error instanceof Error ? error.message : String(error)}`; }
799
+ mkdirSync(outRoot, { recursive: true });
800
+ writeFileSync(join(outRoot, 'parallax.json'), JSON.stringify(result.reading, null, 2) + '\n');
801
+ if (picture !== null) picture.writePng(join(outRoot, 'parallax.png'));
802
+ else rmSync(join(outRoot, 'parallax.png'), { force: true });
803
+ console.log(` .. poser core — named dial ${JSON.stringify(flags.parallax)}, independent reset poses`);
804
+ console.log(` .. PARALLAX ${JSON.stringify(flags.parallax)} ${result.reading.status}: ${result.reading.reason} -> ${outRoot}`);
805
+ for (const sample of result.reading.samples) {
806
+ for (const mesh of sample.meshes) console.log(` .. PARALLAX ${JSON.stringify(mesh.slot + '/' + mesh.attachment)} dial=${sample.value} affine=${mesh.affine.ratio ?? 'SKIP'} homography=${mesh.homography.ratio ?? 'SKIP'}`);
807
+ }
808
+ return;
809
+ }
778
810
  const only = readAnimationFlag(flags, [...facts.animations]);
779
811
  const skin = readSkinFlag(flags, [...facts.skins]);
780
812
  const subset = readSlotSubsetFlags(flags, facts, skin);
@@ -1254,6 +1286,40 @@ export function physicsLine(stepped: readonly string[], declared: readonly strin
1254
1286
  return `${head} — ${idle.length} of the ${declared.length} declared took none: ${idle.map((n) => JSON.stringify(n)).join(', ')} (${why})`;
1255
1287
  }
1256
1288
 
1289
+ /** Warning-only reading, independent of build and of raster output. */
1290
+ export function cmdTextureScale(flags: Record<string, string>): void {
1291
+ const accepted = ['candidate', 'atlas', 'skin', 'fps', 'poser', 'json', 'help'];
1292
+ const stray = Object.keys(flags).filter(k => !accepted.includes(k));
1293
+ if (stray.length > 0) throw new UsageError(`texture-scale takes no --${stray.join(', --')}; use texture-scale --help`);
1294
+ if (flags.candidate === undefined) throw new UsageError('texture-scale needs --candidate <dir | skeleton.json>');
1295
+ const fps = readPositiveNumber(flags, 'fps', TEXTURE_SCALE_FPS, 1);
1296
+ const { skeletonPath, atlasPath, atlasText } = resolveDrawable(flags.candidate, flags.atlas);
1297
+ if (flags.json !== undefined) {
1298
+ const target = realpathAhead(flags.json);
1299
+ const within = relative(realpathSync(dirname(skeletonPath)), target);
1300
+ if (within === '' || (within !== '..' && !within.startsWith(`..${sep}`) && !isAbsolute(within))) {
1301
+ throw new UsageError('--json must name a report outside the candidate directory; texture-scale never writes into a build');
1302
+ }
1303
+ if (existsSync(target)) {
1304
+ let isReport = false;
1305
+ if (statSync(target).isFile()) {
1306
+ try { isReport = (JSON.parse(readFileSync(target, 'utf8')) as { spec?: unknown }).spec === 'rigc-texture-scale/1'; } catch { /* An existing non-report is never replaced. */ }
1307
+ }
1308
+ if (!isReport) throw new UsageError('--json target exists and is not a rigc-texture-scale/1 report; name a new file outside the candidate directory');
1309
+ }
1310
+ }
1311
+ const skeletonText = readSkeletonText(skeletonPath);
1312
+ const candidate = loadCandidate({ skeletonText, atlasText, atlasDir: dirname(atlasPath), label: skeletonPath },
1313
+ { skeleton: skeletonPath, atlas: atlasPath }, posersAsked(flags));
1314
+ readPoserFlag(flags);
1315
+ const skin = readSkinFlag(flags, [...candidate.facts.skins]);
1316
+ refuseUnchosen(candidate.choice);
1317
+ const read = throughPoser(candidate.choice, poser => textureScaleReading(poser, candidate.pages, skeletonText, fps, skin));
1318
+ console.log(`poser: ${read.note}`);
1319
+ for (const line of textureScaleLines(read.value.report)) console.log(line);
1320
+ if (flags.json !== undefined) writeJson(flags.json, read.value.report);
1321
+ }
1322
+
1257
1323
  /** Where `inspect` writes when `--out` is not given, and the name it takes inside a directory `--out` names. */
1258
1324
  export const DEFAULT_INSPECT_OUT = 'inspect.png';
1259
1325
 
@@ -2679,6 +2745,7 @@ export const CORE_COMMAND_RUNS: Readonly<Record<string, CommandRun>> = {
2679
2745
  check: ({ flags }) => cmdCheck(flags),
2680
2746
  render: ({ flags }) => cmdRender(flags),
2681
2747
  inspect: ({ flags }) => cmdInspect(flags),
2748
+ 'texture-scale': ({ flags }) => cmdTextureScale(flags),
2682
2749
  pose: ({ flags }) => cmdPose(flags),
2683
2750
  chainfit: ({ flags }) => cmdChainFit(flags),
2684
2751
  guide: ({ flags, positional }) => cmdGuide(flags, positional),
package/src/cli/shared.ts CHANGED
@@ -1066,6 +1066,7 @@ const FLAG_MEANINGS: Record<string, string> = {
1066
1066
  '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 ' +
1067
1067
  '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 ' +
1068
1068
  "author's, and the draw-order key in the motion spec is the remedy. A reading, not a gate. Not with --slot/--hide",
1069
+ parallax: 'read one named slider dial through the core poser on 9 evenly spaced values: parallax.json plus setup/extreme/diff/affine-residual heat-map parallax.png; geometry only, no assertion or pass threshold. A separate dial render mode; takes no animation, fps, other diagnostics, sheet, subset, environment or viewport flags',
1069
1070
  'extremes-only': 'write only extremes.json and readable extremes.png per set, with the same posed grid and bytes as --extremes; no ordinary frame PNGs, contact.png or frames.json. Implies --extremes; takes no --sheet, --sheet-format, --page-edges, --geometry, --folds, --pass-through, --seam, --overlaps or --angle-range',
1070
1071
  extremes: 'also write extremes.json and a native six-panel extremes.png per set: setup, geometry-pair min/max and premultiplied RGBA diffs, with a legend. Per-channel extrema are recorded separately and valid channels survive geometry/image SKIPs; channel refusals are named separately. Still geometry reports SKIP; a reading, not a gate. Attachment identity/count changes record a named SKIP without changing render exit; supports --slot/--hide on both posers',
1071
1072
  seam:
@@ -1297,6 +1298,7 @@ const FLAG_VALUES: Record<string, string> = {
1297
1298
  'pass-through': '<cloth>:<leg>[,<cloth>:<leg>…]',
1298
1299
  seam: '<slot>:<slot>[,<slot>:<slot>…]',
1299
1300
  'angle-range': '<bone>:<relative-to>[,…]',
1301
+ parallax: '<dial>',
1300
1302
  'sheet-format': 'json-hash',
1301
1303
  'viewport-from': '<frames.json|dir>',
1302
1304
  reference: '<dir|skeleton.json>',
@@ -1763,12 +1765,13 @@ export const COMMANDS: CommandDoc[] = [
1763
1765
  'rigc render … --environment <schedule.json> (the wind and gravity the physics answers, in time)',
1764
1766
  'rigc render … --pass-through <cloth>:<leg>[,…] (the frames where a cloth drawn over a leg passes through it)',
1765
1767
  'rigc render … --seam <slot>:<slot>[,…] (joint gaps, visible ghosts and each named slot’s background exposure)',
1768
+ 'rigc render … --parallax <dial> (geometry reading on the named dial, model/core only)',
1766
1769
  'rigc render … --overlaps (which slot each frame drew over which, by name, with an outline plate)',
1767
1770
  'rigc render … --angle-range <bone>:<relative-to>[,…] (how far a bone turns against another, per animation)',
1768
1771
  'rigc render … --viewport <x,y,w,h> | --viewport-from <frames.json> (a box given, not fitted: two renders on one pixel grid)',
1769
1772
  'rigc render … --sheet [--sheet-format json-hash] [--page-edges pot|free] (deterministic sprite sheets + JSON hash table + sheet-manifest.json per set)',
1770
1773
  ],
1771
- flags: ['candidate', 'atlas', 'animation', 'skin', 'slot', 'hide', 'fps', 'max', 'viewport', 'viewport-from', 'geometry', 'folds', 'extremes', 'extremes-only', 'pass-through', 'seam', 'overlaps', 'angle-range', 'sheet', 'sheet-format', 'page-edges', 'environment', 'poser', 'out'],
1774
+ flags: ['candidate', 'atlas', 'animation', 'skin', 'slot', 'hide', 'fps', 'max', 'viewport', 'viewport-from', 'geometry', 'folds', 'extremes', 'extremes-only', 'parallax', 'pass-through', 'seam', 'overlaps', 'angle-range', 'sheet', 'sheet-format', 'page-edges', 'environment', 'poser', 'out'],
1772
1775
  overrides: {
1773
1776
  'page-edges': {
1774
1777
  meaning:
@@ -1800,6 +1803,18 @@ export const COMMANDS: CommandDoc[] = [
1800
1803
  },
1801
1804
  },
1802
1805
  },
1806
+ {
1807
+ name: 'texture-scale',
1808
+ runtime: false,
1809
+ spineFormat: false,
1810
+ usage: ['rigc texture-scale --candidate <dir | skeleton.json> [--skin <name>] [--fps 60] [--poser core|spine] [--json <out>]'],
1811
+ flags: ['candidate', 'atlas', 'skin', 'fps', 'poser', 'json'],
1812
+ overrides: {
1813
+ fps: { meaning: 'sample setup and every animation at this rate (default 60), also including float32 key times, key-interval midpoints and endpoints; reports sampled minima, never continuous extrema' },
1814
+ json: { meaning: 'write the report outside the candidate directory; an existing file is replaced only if it is a rigc-texture-scale/1 report' },
1815
+ },
1816
+ notes: ['REGION_TEXTURE_MINIFIED is a warning only: smallest singular value of texture-pixel to world map < 0.5. Names the region, texture size and sampled minimum with animation/time. Skips unselected, transparent and fully clipped regions; no occlusion or consumer camera test. Never changes a build. See docs/AUTHORING.md, Texture scale.'],
1817
+ },
1803
1818
  {
1804
1819
  name: 'inspect',
1805
1820
  runtime: false,
package/src/core/hooks.ts CHANGED
@@ -104,6 +104,8 @@ export type DialOverride = { bone: string; field: DialBoneField; value: number }
104
104
 
105
105
  /** A dial pose, and its two readings (the header's *A dial*). */
106
106
  export interface CoreDialPose extends CoreSurveyPose {
107
+ /** Complete posed slot order, including slots without a shown attachment. */
108
+ drawOrder: readonly string[];
107
109
  /** The slider's property read off its bone, or `null` on a bone-less slider. */
108
110
  read: number | null;
109
111
  applied: number;
@@ -167,8 +169,8 @@ export function poseDial(doc: CompiledDocument, override: DialOverride, slider:
167
169
  const view = overridden(doc, override);
168
170
  const record = sliderRecordOf(view, slider);
169
171
  const posed = poseSetup(view, RAW_PLANT, freshStepContext());
170
- const missing = posed.absent.filter(([b]) => b === 'setup.bones' || b === 'setup.slots');
171
- if (missing.length > 0 || posed.world === null || posed.setup.slots === null) throw new CoreInputError(`the dial pose leaves ${missing.map(([b, w]) => `${b} out (${w})`).join('; ') || 'the bones out'}`);
172
+ const missing = posed.absent.filter(([b]) => b === 'setup.bones' || b === 'setup.slots' || b === 'setup.drawOrder');
173
+ if (missing.length > 0 || posed.world === null || posed.setup.slots === null || posed.setup.drawOrder === null) throw new CoreInputError(`the dial pose leaves ${missing.map(([b, w]) => `${b} out (${w})`).join('; ') || 'the bones out'}`);
172
174
  if (posed.shown === null) throw new CoreInputError('the dial pose leaves the shown records out (a timeline keys a placeholder several skins fill)');
173
175
  // The solver's last state, for the reading: the same pass `poseSetup` ran, with its `settled` sink.
174
176
  const active = activeBones(view);
@@ -187,7 +189,7 @@ export function poseDial(doc: CompiledDocument, override: DialOverride, slider:
187
189
  }
188
190
  const read = record.bone === null ? null : sourceValue(settled, { source: record.bone, localSource: record.local, offsets: ZERO_OFFSETS }, record.property);
189
191
  const ran = applied.find((a) => a.name === slider);
190
- return { doc: view, world: posed.world, slots: posed.setup.slots, shown: posed.shown, read, applied: ran === undefined ? record.time : ran.time };
192
+ return { doc: view, world: posed.world, slots: posed.setup.slots, shown: posed.shown, drawOrder: posed.setup.drawOrder, read, applied: ran === undefined ? record.time : ran.time };
191
193
  }
192
194
 
193
195
  /** `skin/slot/placeholder` — the identity a deform timeline keys a record under (`timelineIdentity`). */
package/src/core/raw.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { poseDial, type DialOverride } from './hooks.ts';
1
2
  /**
2
3
  * The core's raw entry (issue #966, step 3b of issue #380): one pose, or one
3
4
  * animation walked at the caller's own steps, returned as the doubles the
@@ -476,6 +477,18 @@ export function poseRawSetup(doc: CompiledDocument, plant: CorePlant = {}): RawP
476
477
  return setupPoseIn(doc, plant, RAW_MODE);
477
478
  }
478
479
 
480
+ /** A value-driven reset pose, using the same dial override as the independent deform survey. */
481
+ export function poseRawDial(doc: CompiledDocument, override: DialOverride, slider: string, plant: CorePlant = {}): { pose: RawPose; read: number | null; applied: number } {
482
+ const result = poseDial(doc, override, slider);
483
+ const { world, shown } = result;
484
+ const drawOrder = [...result.drawOrder];
485
+ const drawn = rawDrawn(result.doc, shown, world, drawOrder, plant);
486
+ return { read: result.read, applied: result.applied, pose: {
487
+ trackTime: 0, animationTime: 0, bones: rawBones(result.doc, world), slots: [...result.slots], drawOrder,
488
+ ...drawn, events: [], shown: [...shown],
489
+ } };
490
+ }
491
+
479
492
  /** A table key to read, with an optional skin-view override; omitted skin keeps the supplied view. */
480
493
  export interface SetupMeshTarget {
481
494
  readonly slot: string;
@@ -0,0 +1,208 @@
1
+ /** Geometry-only observations for a dial; no assertion or acceptance threshold. */
2
+ export type FitKind = 'affine' | 'homography';
3
+ export interface MotionFit {
4
+ status: 'READ' | 'SKIP'; reason: string; method: string;
5
+ vertices: number; motionRms: number | null; rms: number | null; ratio: number | null;
6
+ /** Observed minus fitted positions, world x/y; used by the residual picture. */
7
+ residuals: number[];
8
+ }
9
+ export interface ParallaxMesh {
10
+ slot: string; attachment: string; kind: 'mesh' | 'region'; vertices: number[];
11
+ /** Only actual per-vertex declarations, never inferred from motion or a depth range. */
12
+ depths?: number[];
13
+ /** Runtime source identity, deliberately excluded from serialized readings. */
14
+ identity?: object;
15
+ }
16
+ export interface DialSample { value: number; meshes: ParallaxMesh[] }
17
+ const method = (kind: FitKind): string => kind === 'affine'
18
+ ? 'per-sample forward geometric least squares, centered isotropic coordinates, reorthogonalized QR'
19
+ : 'per-sample normalized inhomogeneous linear initializer and affine initializer, forward geometric LM refinement (80 iterations each); lowest finite objective found, not certified global optimum';
20
+ /** Scaled sum of squares avoids intermediate over/underflow using only reproducible arithmetic. */
21
+ const norm = (a: readonly number[]): number => {
22
+ const scale = a.reduce((largest, v) => Math.max(largest, Math.abs(v)), 0);
23
+ if (scale === 0) return 0;
24
+ return scale * Math.sqrt(a.reduce((sum, v) => { const r = v / scale; return sum + r * r; }, 0));
25
+ };
26
+ const rms = (a: readonly number[]): number => norm(a) / Math.sqrt(a.length / 2);
27
+
28
+ /** Tiny full-column-rank least squares; twice-reorthogonalized QR, no normal-equation initializer. */
29
+ function leastSquares(rows: number[][], rhs: number[]): number[] | null {
30
+ const n = rows[0]?.length ?? 0;
31
+ if (n === 0 || rows.length < n) return null;
32
+ const q: number[][] = [], r: number[][] = Array.from({ length: n }, () => new Array<number>(n).fill(0));
33
+ for (let j = 0; j < n; j++) {
34
+ const v = rows.map((row) => row[j]);
35
+ const original = norm(v);
36
+ for (let pass = 0; pass < 2; pass++) for (let k = 0; k < j; k++) {
37
+ const dot = q[k].reduce((s, a, i) => s + a * v[i], 0);
38
+ r[k][j] += dot;
39
+ for (let i = 0; i < v.length; i++) v[i] -= dot * q[k][i];
40
+ }
41
+ r[j][j] = norm(v);
42
+ if (r[j][j] <= 1e-10 * Math.max(1, original)) return null;
43
+ q.push(v.map((a) => a / r[j][j]));
44
+ }
45
+ const x = q.map((v) => v.reduce((s, a, i) => s + a * rhs[i], 0));
46
+ for (let j = n - 1; j >= 0; j--) {
47
+ for (let k = j + 1; k < n; k++) x[j] -= r[j][k] * x[k];
48
+ x[j] /= r[j][j];
49
+ }
50
+ return x.every(Number.isFinite) ? x : null;
51
+ }
52
+ function normalized(a: readonly number[]): { points: number[]; scale: number } | null {
53
+ const n = a.length / 2, x0 = a[0], y0 = a[1];
54
+ const cx = a.reduce((s, v, i) => s + (i % 2 ? 0 : (v - x0) / n), 0) + x0;
55
+ const cy = a.reduce((s, v, i) => s + (i % 2 ? (v - y0) / n : 0), 0) + y0;
56
+ const centered = a.map((v, i) => v - (i % 2 ? cy : cx));
57
+ const scale = rms(centered);
58
+ return Number.isFinite(scale) && scale > 0 ? { points: centered.map((v) => v / scale), scale } : null;
59
+ }
60
+ function predict(h: number[], x: number, y: number): [number, number] | null {
61
+ const den = 1 + h[6] * x + h[7] * y;
62
+ if (Math.abs(den) < 1e-9 || !Number.isFinite(den)) return null;
63
+ const out: [number, number] = [(h[0] * x + h[1] * y + h[2]) / den, (h[3] * x + h[4] * y + h[5]) / den];
64
+ return out.every(Number.isFinite) ? out : null;
65
+ }
66
+ function errors(h: number[], p: number[], t: number[]): number[] | null {
67
+ const out: number[] = [];
68
+ for (let i = 0; i < p.length; i += 2) {
69
+ const got = predict(h, p[i], p[i + 1]);
70
+ if (got === null) return null;
71
+ out.push(t[i] - got[0], t[i + 1] - got[1]);
72
+ }
73
+ return out;
74
+ }
75
+ const sse = (a: number[]): number => a.reduce((s, v) => s + v * v, 0);
76
+ /** LM minimizes actual forward x/y distances, rather than the initializer's algebraic objective. */
77
+ function refine(start: number[], p: number[], t: number[]): number[] {
78
+ let h = [...start], lambda = 1e-4;
79
+ for (let iteration = 0; iteration < 80; iteration++) {
80
+ const e = errors(h, p, t);
81
+ if (e === null) break;
82
+ const rows: number[][] = [];
83
+ for (let i = 0; i < p.length; i += 2) {
84
+ const x = p[i], y = p[i + 1], den = 1 + h[6] * x + h[7] * y;
85
+ const got = predict(h, x, y)!;
86
+ rows.push([x / den, y / den, 1 / den, 0, 0, 0, -got[0] * x / den, -got[0] * y / den]);
87
+ rows.push([0, 0, 0, x / den, y / den, 1 / den, -got[1] * x / den, -got[1] * y / den]);
88
+ }
89
+ // Damped least squares as an augmented QR problem; numerical failures never become a score.
90
+ const rhs = [...e];
91
+ for (let j = 0; j < 8; j++) {
92
+ rows.push(Array.from({ length: 8 }, (_, k) => k === j ? Math.sqrt(lambda) : 0)); rhs.push(0);
93
+ }
94
+ const step = leastSquares(rows, rhs);
95
+ if (step === null) break;
96
+ const candidate = h.map((v, i) => v + step[i]);
97
+ const next = errors(candidate, p, t);
98
+ if (next !== null && sse(next) < sse(e)) {
99
+ h = candidate; lambda = Math.max(1e-12, lambda / 3);
100
+ if (norm(step) < 1e-12) break;
101
+ } else { lambda *= 10; if (lambda > 1e12) break; }
102
+ }
103
+ return h;
104
+ }
105
+ export function fitMotion(source: readonly number[], target: readonly number[], kind: FitKind): MotionFit {
106
+ const result: MotionFit = { status: 'SKIP', reason: '', method: method(kind), vertices: source.length / 2, motionRms: null, rms: null, ratio: null, residuals: [] };
107
+ const skip = (reason: string): MotionFit => ({ ...result, reason, rms: null, ratio: null, residuals: [] });
108
+ if (source.length !== target.length || source.length % 2 || !source.length || [...source, ...target].some((v) => !Number.isFinite(v))) return skip('no comparable finite x/y vertices');
109
+ if (source.length / 2 <= (kind === 'affine' ? 3 : 4)) return skip(`at most ${kind === 'affine' ? 3 : 4} correspondences: a saturated ${kind} fit cannot test relief`);
110
+ const a = normalized(source), b = normalized(target);
111
+ if (a === null || b === null) return skip('zero extent or non-finite normalization');
112
+ const motion = rms(source.map((v, i) => target[i] - v));
113
+ result.motionRms = motion;
114
+ const coordinateUlp = Number.EPSILON * Math.max(...source.map(Math.abs), ...target.map(Math.abs));
115
+ if (motion <= Math.max(a.scale * 1e-12, coordinateUlp * 16)) return skip('stationary or below coordinate resolution: RMS motion denominator unavailable');
116
+ const p = a.points, t = b.points;
117
+ const rows: number[][] = [], rhs: number[] = [];
118
+ for (let i = 0; i < p.length; i += 2) {
119
+ const x = p[i], y = p[i + 1];
120
+ rows.push([x, y, 1, 0, 0, 0]); rhs.push(t[i]);
121
+ rows.push([0, 0, 0, x, y, 1]); rhs.push(t[i + 1]);
122
+ }
123
+ const affine = leastSquares(rows, rhs);
124
+ if (affine === null) return skip('rank deficient source geometry');
125
+ let h = [...affine, 0, 0];
126
+ if (kind === 'homography') {
127
+ const projectiveRows = rows.map((row, i) => [...row, -rhs[i] * p[2 * Math.floor(i / 2)], -rhs[i] * p[2 * Math.floor(i / 2) + 1]]);
128
+ const linear = leastSquares(projectiveRows, rhs);
129
+ if (linear === null) return skip('rank deficient homography design');
130
+ const starts = [h, linear].map((start) => refine(start, p, t)).filter((start) => errors(start, p, t) !== null);
131
+ if (!starts.length) return skip('homography prediction denominator unavailable');
132
+ h = starts.reduce((best, next) => sse(errors(next, p, t)!) < sse(errors(best, p, t)!) ? next : best);
133
+ }
134
+ const det = h[0] * (h[4] - h[5] * h[7]) - h[1] * (h[3] - h[5] * h[6]) + h[2] * (h[3] * h[7] - h[4] * h[6]);
135
+ if (Math.abs(det) < 1e-10) return skip('singular fitted map');
136
+ const e = errors(h, p, t);
137
+ if (e === null) return skip('fitted map has unavailable prediction denominator');
138
+ result.residuals = e.map((v) => v * b.scale);
139
+ result.rms = rms(result.residuals); result.ratio = result.rms / motion;
140
+ if (![result.rms, result.ratio].every(Number.isFinite)) return skip('non-finite geometric residual');
141
+ return { ...result, status: 'READ', reason: 'geometry only; zero residual does not certify flatness or volume' };
142
+ }
143
+ function ranks(a: number[]): number[] {
144
+ const sorted = a.map((value, i) => ({ value, i })).sort((a, b) => a.value - b.value);
145
+ const out: number[] = [];
146
+ for (let i = 0; i < sorted.length;) {
147
+ let end = i + 1; while (end < sorted.length && sorted[end].value === sorted[i].value) end++;
148
+ for (let k = i; k < end; k++) out[sorted[k].i] = (i + end - 1) / 2;
149
+ i = end;
150
+ }
151
+ return out;
152
+ }
153
+ export function spearman(a: number[], b: number[]): number | null {
154
+ if (a.length !== b.length || a.length < 2 || [...a, ...b].some((v) => !Number.isFinite(v))) return null;
155
+ const x = ranks(a), y = ranks(b), mean = (a.length - 1) / 2;
156
+ let xy = 0, xx = 0, yy = 0;
157
+ for (let i = 0; i < x.length; i++) { const u = x[i] - mean, v = y[i] - mean; xy += u * v; xx += u * u; yy += v * v; }
158
+ return xx > 0 && yy > 0 ? xy / Math.sqrt(xx * yy) : null;
159
+ }
160
+ function depthReading(rest: ParallaxMesh, target: ParallaxMesh, delta: number, axis?: [number, number]) {
161
+ const skip = (reason: string) => ({ status: 'SKIP' as const, reason, spearman: null, nearTravel: null, farTravel: null, units: 'world px / dial unit' });
162
+ const z = rest.depths;
163
+ if (z === undefined || z.length * 2 !== rest.vertices.length || z.some((v) => !Number.isFinite(v))) return skip('declared per-vertex depth unavailable; model depth summaries are not vertex depths');
164
+ const lo = Math.min(...z), hi = Math.max(...z);
165
+ if (lo === hi) return skip('constant declared depth: ranks and near/far split undefined');
166
+ if (!Number.isFinite(delta) || delta === 0) return skip('zero or unavailable dial distance from setup');
167
+ if (axis === undefined) return skip('turn axis unavailable: a generic slider does not declare a world turn axis');
168
+ const d = rest.vertices.map((v, i) => target.vertices[i] - v);
169
+ const along = z.map((_, i) => d[2 * i] * axis[0] + d[2 * i + 1] * axis[1]);
170
+ const rho = spearman(z, along);
171
+ const travel = (near: boolean): number => {
172
+ const indices = z.flatMap((v, i) => (near ? v === hi : v === lo) ? [i] : []);
173
+ return Math.sqrt(indices.reduce((s, i) => s + d[2 * i] ** 2 + d[2 * i + 1] ** 2, 0) / indices.length) / Math.abs(delta);
174
+ };
175
+ return { status: rho === null ? 'SKIP' as const : 'READ' as const, reason: rho === null ? 'constant motion along axis: correlation undefined' : 'signed setup-to-sample projected motion; sign may reverse at opposite extremes', spearman: rho, nearTravel: travel(true), farTravel: travel(false), units: 'world px / dial unit' };
176
+ }
177
+ export function parallaxOf(dial: string, rest: ParallaxMesh[], samples: DialSample[], setupValue?: number, axis?: [number, number]) {
178
+ const rows = samples.map((sample) => {
179
+ const pairs = rest.map((r) => ({ r, t: sample.meshes.find((m) => m.slot === r.slot && m.attachment === r.attachment && m.kind === r.kind && (r.identity === undefined || m.identity === r.identity)) }));
180
+ const comparable = pairs.filter((p): p is { r: ParallaxMesh; t: ParallaxMesh } => p.t !== undefined && p.t.vertices.length === p.r.vertices.length);
181
+ const meshes = pairs.filter((p) => p.r.kind === 'mesh').map(({ r, t }) => {
182
+ const target = t?.vertices ?? [];
183
+ return { slot: r.slot, attachment: r.attachment, affine: fitMotion(r.vertices, target, 'affine'), homography: fitMotion(r.vertices, target, 'homography'), depth: t === undefined || target.length !== r.vertices.length ? { status: 'SKIP' as const, reason: 'attachment correspondence unavailable', spearman: null, nearTravel: null, farTravel: null, units: 'world px / dial unit' } : depthReading(r, t, sample.value - (setupValue ?? NaN), axis) };
184
+ });
185
+ const appearing = sample.meshes.filter((t) => !rest.some((r) => r.slot === t.slot && r.attachment === t.attachment && r.kind === t.kind));
186
+ for (const t of appearing.filter((t) => t.kind === 'mesh')) meshes.push({
187
+ slot: t.slot, attachment: t.attachment, affine: fitMotion([], t.vertices, 'affine'), homography: fitMotion([], t.vertices, 'homography'),
188
+ depth: { status: 'SKIP', reason: 'no setup correspondence for appearing mesh', spearman: null, nearTravel: null, farTravel: null, units: 'world px / dial unit' },
189
+ });
190
+ const whole = fitMotion(comparable.flatMap((p) => p.r.vertices), comparable.flatMap((p) => p.t.vertices), 'affine');
191
+ const centers: Array<{ slot: string; attachment: string; residual: [number, number] }> = [];
192
+ let offset = 0;
193
+ if (whole.status === 'READ') for (const { r } of comparable) {
194
+ const n = r.vertices.length / 2;
195
+ if (n > 0) centers.push({ slot: r.slot, attachment: r.attachment, residual: [0, 1].map((axis) => r.vertices.reduce((s, _, i) => s + (i % 2 === axis ? whole.residuals[offset + i] / n : 0), 0)) as [number, number] });
196
+ offset += r.vertices.length;
197
+ }
198
+ const center = [0, 1].map((k) => centers.reduce((s, c) => s + c.residual[k] / centers.length, 0));
199
+ const spread = centers.length > 1 ? Math.sqrt(centers.reduce((s, c) => s + (c.residual[0] - center[0]) ** 2 + (c.residual[1] - center[1]) ** 2, 0) / centers.length) : null;
200
+ return { value: sample.value, meshes, layers: { status: spread === null ? 'SKIP' : 'READ', reason: spread === null ? 'need a moving full-rank whole-rig fit and at least two comparable layers' : 'unweighted RMS spread of per-layer mean residual vectors after one whole-rig affine fit; regions and meshes, including stationary layers', spread, ratio: spread === null || whole.motionRms === null ? null : spread / whole.motionRms, units: 'world px', whole, centers, omitted: [...pairs.filter((p) => !comparable.includes(p as { r: ParallaxMesh; t: ParallaxMesh })).map((p) => [p.r.slot, p.r.attachment]), ...appearing.map((t) => [t.slot, t.attachment])] } };
201
+ });
202
+ const hasVertices = rest.some((r) => r.vertices.length > 0);
203
+ const measured = rows.some((s) => s.layers.status === 'READ' || s.meshes.some((m) => m.affine.status === 'READ' || m.homography.status === 'READ'));
204
+ return { spec: 'rigc-parallax/1', dial, status: measured ? 'READ' : 'SKIP', reason: !hasVertices ? 'no vertices to fit' : measured ? 'observations only; no flatness or volume certificate' : 'no moving full-rank comparable fits on this grid', setupValue: setupValue ?? null, axis: axis ?? null,
205
+ method: { grid: 'independent setup-to-sample position fits; never pool different dial values into one map', normalization: 'residual RMS / setup-to-sample RMS motion, same vertices, world px; no stationary denominator', scope: 'original mesh and region vertices before clipping/visibility; surface on meshes only; no draw order, colour, occlusion or optical flow', limitations: 'non-affine articulation or deformation can produce relief readings without depth; orthographic sloped depth can be affine; zero does not prove paper; homography refinement is local, not a global optimum certificate' }, samples: rows };
206
+ }
207
+ export type ParallaxReading = ReturnType<typeof parallaxOf>;
208
+ export const parallaxText = (reading: ParallaxReading): string => JSON.stringify(reading, null, 2) + '\n';
@@ -0,0 +1,112 @@
1
+ import { hypot2 } from './numeric.ts';
2
+ import { attachmentSource } from './render_channels.ts';
3
+ /** Named-dial sampling through the core poser, independent reset poses, no timeline playback. */
4
+ import { corePoser } from './render_core.ts';
5
+ import { readModel, CoreInputError, underSkin, underNoSkin } from './core/index.ts';
6
+ import { sliderRecordOf, DIAL_BONE_FIELDS, type DialBoneField } from './core/hooks.ts';
7
+ import { parallaxOf, type ParallaxMesh, type ParallaxReading } from './parallax.ts';
8
+ import { renderFrame, TRANSPARENT_BACKGROUND, viewportFor, type Frame, type Posed } from './render_shared.ts';
9
+ import { Plate } from '../tools/plate.ts';
10
+ import { premultipliedDiff } from './extremes.ts';
11
+
12
+ export function sampleParallax(modelText: string, atlasText: string, dial: string, skin?: string) {
13
+ const doc = readModel(modelText);
14
+ const view = skin === undefined ? underNoSkin(doc) : underSkin(doc, skin);
15
+ const record = sliderRecordOf(view, dial); // Names the missing dial before any output is made.
16
+ const raw = JSON.parse(modelText) as { constraints: Array<{ kind: string; name: string; max?: number }> };
17
+ const declared = raw.constraints.find((c) => c.kind === 'slider' && c.name === dial);
18
+ const duration = record.timelines.duration;
19
+ const start = record.bone === null ? 0 : record.from;
20
+ const finish = record.bone === null ? duration : declared?.max ?? (record.scale === 0 ? NaN : record.from + (duration - record.to) / record.scale);
21
+ if (![start, finish].every(Number.isFinite) || start === finish) throw new CoreInputError(`--parallax dial ${JSON.stringify(dial)}: no finite nonzero range (from ${start}, max ${finish}); declare a range or a nonzero mapping`);
22
+ const poser = corePoser(modelText, atlasText);
23
+ const frame = (posed: Posed, index: number): Frame => ({ index, time: 0, pieces: posed.pieces({ subset: undefined, unclipped: false, texture: false }), attachments: posed.attachments(true) });
24
+ const setup = frame(poser.setup(skin), -1);
25
+ const geometry = (f: Frame): ParallaxMesh[] => (f.attachments ?? []).map((a) => ({
26
+ slot: a.slot, attachment: a.attachment, vertices: [...a.vertices], identity: attachmentSource(a),
27
+ kind: (attachmentSource(a) as { kind?: string } | undefined)?.kind === 'region' ? 'region' : 'mesh',
28
+ }));
29
+ const bone = view.bones.find((b) => b.name === record.bone);
30
+ const base = (field: DialBoneField): number => bone?.[field] ?? (field === 'scaleX' || field === 'scaleY' ? 1 : 0);
31
+ const ask = (field: DialBoneField | null, value: number) => poser.dial(dial, field === null || record.bone === null ? { slider: dial, time: value } : { bone: record.bone, field, value }, skin);
32
+ const fields = record.bone === null ? [] : record.local ? [record.property === 'rotate' ? 'rotation' as const : record.property] : [...DIAL_BONE_FIELDS];
33
+ let field: DialBoneField | null = fields[0] ?? null;
34
+ let u0 = field === null ? record.time : base(field), v0 = record.time, gain = 1;
35
+ if (record.bone !== null) {
36
+ let best = 0;
37
+ for (const candidate of fields) {
38
+ const u = base(candidate), a = ask(candidate, u).read, b = ask(candidate, u + 1).read;
39
+ if (a !== null && b !== null && Math.abs(b - a) > best) { best = Math.abs(b - a); field = candidate; u0 = u; v0 = a; gain = b - a; }
40
+ }
41
+ if (best < 1e-10) throw new CoreInputError(`--parallax dial ${JSON.stringify(dial)}: no driving bone field moves its property; inspect constraints on ${JSON.stringify(record.bone)}`);
42
+ }
43
+ const setupValue = record.bone === null ? record.time : v0;
44
+ const samples: Array<{ value: number; meshes: ParallaxMesh[]; appliedTime: number | null; reason: string | null }> = [];
45
+ const frames: Frame[] = [];
46
+ const values = Array.from({ length: 9 }, (_, i) => Math.min(start, finish) + Math.abs(finish - start) * i / 8);
47
+ for (const [index, value] of values.entries()) {
48
+ let drive = u0 + (value - v0) / gain;
49
+ let previous = u0, previousRead = v0;
50
+ let result: ReturnType<typeof ask> | undefined;
51
+ let reason: string | null = null;
52
+ for (let iteration = 0; iteration < 16; iteration++) {
53
+ if (!Number.isFinite(drive) || Math.abs(drive) > 2 ** 24) { reason = 'dial drive is outside finite ±2^24'; break; }
54
+ result = ask(field, drive);
55
+ const read = result.read ?? drive;
56
+ if (Math.abs(read - value) <= 1e-9 * (1 + Math.abs(value))) break;
57
+ if (read === previousRead) { reason = 'dial reader did not respond to the drive'; break; }
58
+ const next = drive + (value - read) * (drive - previous) / (read - previousRead);
59
+ previous = drive; previousRead = read; drive = next;
60
+ }
61
+ const actual = result?.read ?? drive;
62
+ if (Math.abs(actual - value) > 1e-9 * (1 + Math.abs(value))) reason ??= 'dial reader did not reach requested value (world rotation may wrap)';
63
+ const wantedTime = record.bone === null ? value : record.to + (value - record.from) * record.scale;
64
+ const appliedTime = record.bone === null ? wantedTime : record.loop ? duration + wantedTime % duration : Math.max(0, wantedTime);
65
+ if (result === undefined || !Number.isFinite(result.applied) || Math.abs(result.applied - appliedTime) > 1e-7 * (1 + Math.abs(appliedTime))) reason ??= 'slider did not apply the time its mapping requested (inactive or overwritten)';
66
+ if (reason !== null || result === undefined) { samples.push({ value, meshes: [], appliedTime: result?.applied ?? null, reason }); continue; }
67
+ const f = frame(result.posed, index); frames.push(f);
68
+ samples.push({ value, meshes: geometry(f), appliedTime: result.applied, reason: null });
69
+ }
70
+ const reading = { ...parallaxOf(dial, geometry(setup), samples, setupValue),
71
+ sampling: { poser: 'core', skin: skin ?? null, range: [start, finish], values, setupValue, driveField: field, reset: 'independent Physics.reset per value; other inputs stay at setup; all constraints are applied and other sliders may respond to the driven bone; no animation track is played', depth: 'unavailable: adapter supplies no per-vertex depth or world turn axis; compiled model retains depth summaries only; no inference from motion', samples: samples.map(({ meshes: _meshes, ...s }) => s) },
72
+ picture: { status: 'SKIP' as 'READ' | 'SKIP', reason: 'no drawable comparable extremes', file: null as string | null },
73
+ };
74
+ return { reading, setup, frames, values, rest: geometry(setup) };
75
+ }
76
+
77
+ /** Setup, dial min/max, native diffs and affine-residual heat maps; one shared fitted world box. */
78
+ export function parallaxPlate(sampled: ReturnType<typeof sampleParallax>, pages: Map<string, Plate>, maxSide: number): Plate | null {
79
+ const { reading, setup, frames } = sampled;
80
+ const left = frames.find((f) => f.index === 0), right = frames.find((f) => f.index === 8);
81
+ if (left === undefined || right === undefined) return null;
82
+ const all = [setup, ...frames].flatMap((f) => (f.attachments ?? []).flatMap((a) => a.vertices));
83
+ if (!all.length) return null;
84
+ const xs = all.filter((_, i) => i % 2 === 0), ys = all.filter((_, i) => i % 2 === 1);
85
+ const viewport = viewportFor(Math.min(...xs), Math.min(...ys), Math.max(...xs), Math.max(...ys), maxSide);
86
+ if (![viewport.scale, viewport.width, viewport.height].every(Number.isFinite)) return null;
87
+ const image = (f: Frame): Plate => renderFrame(f, pages, viewport, TRANSPARENT_BACKGROUND);
88
+ const base = image(setup), min = image(left), max = image(right);
89
+ const heatMax = Math.max(0, ...[0, 8].flatMap((i) => reading.samples[i].meshes.flatMap((m) => m.affine.residuals.flatMap((_, k, a) => k % 2 ? [] : [hypot2(a[k], a[k + 1])]))));
90
+ const heat = (f: Frame, index: number): Plate => {
91
+ const out = new Plate(viewport.width, viewport.height);
92
+ out.rect(0, 0, out.width, out.height, [20, 20, 20, 255]);
93
+ for (const mesh of reading.samples[index].meshes) {
94
+ const a = f.attachments?.find((a) => a.slot === mesh.slot && a.attachment === mesh.attachment);
95
+ if (a === undefined || mesh.affine.status !== 'READ') continue;
96
+ for (let k = 0; k < a.vertices.length; k += 2) {
97
+ const residual = hypot2(mesh.affine.residuals[k], mesh.affine.residuals[k + 1]);
98
+ const v = heatMax > 0 ? residual / heatMax : 0;
99
+ out.rect(Math.round((a.vertices[k] - viewport.minX) * viewport.scale) - 1, Math.round((viewport.maxY - a.vertices[k + 1]) * viewport.scale) - 1, 3, 3, [Math.round(255 * v), Math.round(160 * (1 - v)), Math.round(255 * (1 - v)), 255]);
100
+ }
101
+ }
102
+ return out;
103
+ };
104
+ const panels = [base, min, max, premultipliedDiff(max, min), heat(left, 0), heat(right, 8)];
105
+ const legend = `setup | dial min ${sampled.values[0]} | dial max ${sampled.values[8]} | abs(max-min) | affine residual min | affine residual max. Blue=0 red=${heatMax.toPrecision(5)} world px; dark=unavailable. Geometry only, original mesh vertices; no visibility claim. Scale ${viewport.scale.toPrecision(5)} px/world unit.`;
106
+ const width = viewport.width * panels.length, columns = Math.max(1, Math.floor(width / 6)), lines = Math.ceil(legend.length / columns);
107
+ const out = new Plate(width, viewport.height + 9 * lines + 4);
108
+ panels.forEach((panel, k) => { for (let y = 0; y < panel.height; y++) out.data.set(panel.data.subarray(y * panel.width * 4, (y + 1) * panel.width * 4), (y * width + k * panel.width) * 4); });
109
+ out.rect(0, viewport.height, width, out.height - viewport.height, [0, 0, 0, 255]);
110
+ for (let i = 0; i < lines; i++) out.text(legend.slice(i * columns, (i + 1) * columns), 0, viewport.height + 2 + i * 9, 1, [255, 255, 255, 255]);
111
+ return out;
112
+ }
package/src/render.ts CHANGED
@@ -503,7 +503,7 @@ export function spinePoser(data: SkeletonData): Poser {
503
503
  slots: data.slots.map((slot) => ({ name: slot.name, bone: slot.boneData.name })),
504
504
  subset: (opts, skin) => slotSubsetOf(data, opts, skin),
505
505
  setup: (skin) => spinePosed(setupPosed(data, skin)),
506
- animation: (name, skin, fps, count, visit, environment) => {
506
+ animation: (name, skin, fps, count, visit, environment, _channels, times) => {
507
507
  const skeleton = skeletonUnderSkin(data, skin);
508
508
  const state = new AnimationState(new AnimationStateData(data));
509
509
  // Not looping: the last frame sits at the animation's duration, and a looping
@@ -516,10 +516,11 @@ export function spinePoser(data: SkeletonData): Poser {
516
516
  const posed = spinePosed(skeleton);
517
517
  for (let i = 0; i <= count; i++) {
518
518
  if (i > 0) {
519
- state.update(step);
519
+ const delta = times === undefined ? step : times[i] - times[i - 1];
520
+ state.update(delta);
520
521
  state.apply(skeleton);
521
522
  setSkeletonEnvironment(skeleton, environment?.(i));
522
- skeleton.update(clock);
523
+ skeleton.update(times === undefined ? clock : physicsClockStep(delta));
523
524
  skeleton.updateWorldTransform(Physics.update);
524
525
  } else {
525
526
  state.apply(skeleton);
@@ -723,7 +724,7 @@ function drawPieces(skeleton: Skeleton, draw: DrawOptions): Piece[] {
723
724
  continue;
724
725
  }
725
726
  const drawn = subset === undefined || named === undefined || named.has(slot.data.name) === (subset.mode === 'slots');
726
- const piece = drawn ? pieceOf(skeleton, slot, draw.texture, clipper) : null;
727
+ const piece = drawn ? pieceOf(skeleton, slot, draw.texture, clipper, draw.regionInfo) : null;
727
728
  if (piece !== null) pieces.push(piece);
728
729
  clipper?.clipEnd(slot);
729
730
  }
@@ -743,6 +744,7 @@ function pieceOf(
743
744
  slot: Slot,
744
745
  withTexture: boolean,
745
746
  clipper: SkeletonClipping | null,
747
+ withRegionInfo = false,
746
748
  ): Piece | null {
747
749
  const pose = slot.appliedPose;
748
750
  const attachment = pose.attachment;
@@ -765,7 +767,9 @@ function pieceOf(
765
767
  const darkPose = pose.darkColor;
766
768
  const dark: [number, number, number] | undefined =
767
769
  darkPose === null ? undefined : [darkPose.r, darkPose.g, darkPose.b];
768
- const common = { tint, dark, slot: slot.data.name, page: region.page.name };
770
+ const common = { tint, dark, slot: slot.data.name, page: region.page.name,
771
+ ...(withRegionInfo && !isMesh ? { regionInfo: { attachment: attachment.name, region: region.name, index: region.index, width: region.width, height: region.height } } : {}),
772
+ };
769
773
  const texture = withTexture ? artUvsOf(attachment, region) : undefined;
770
774
  const uvs = attachment.sequence.getUVs(index);
771
775
 
@@ -1,3 +1,4 @@
1
+ import type { DialOverride } from './core/hooks.ts';
1
2
  import { localChannels, deformChannels, identifyAttachment } from './render_channels.ts';
2
3
  import type { ChannelValue } from './render_shared.ts';
3
4
  /**
@@ -102,7 +103,7 @@ import { atlasRegionLookup, parseAtlasText } from './atlas.ts';
102
103
  import { pagesOfAtlas, spineFileSha256, type ModelPage } from './model.ts';
103
104
  import { clipThrough, type ClipShape, type ShapeClipper } from './core/clipping.ts';
104
105
  import { activeBones, CoreInputError, readModel, sourceOfDoc, underNoSkin, underSkin, type CompiledDocument, type CoreSlotRow } from './core/index.ts';
105
- import { PHYSICS_CLOCK_LEAD, physicsClockStep, poseRawAnimationEach, poseRawSetup, type RawDrawn, type RawPose, type StepEnvironment } from './core/raw.ts';
106
+ import { PHYSICS_CLOCK_LEAD, physicsClockStep, poseRawAnimationEach, poseRawDial, poseRawSetup, type RawDrawn, type RawPose, type StepEnvironment } from './core/raw.ts';
106
107
  import type { TimelinePlant } from './core/animation.ts';
107
108
  import { stepPhysics, type PhysicsStepper } from './core/constraints_physics.ts';
108
109
  import { CORE_DEFAULT_SKIN, lookupSkins } from './core/skins.ts';
@@ -557,7 +558,9 @@ function pieces(
557
558
  const atlasRegion = draw.texture ? input.region(found.region) : null;
558
559
  if (draw.texture && atlasRegion === null) throw new CoreInputError(`slot "${d.slot}": atlas region "${found.region}" is not in the atlas`);
559
560
  const texture = atlasRegion === null ? undefined : artUvsOf(d, atlasRegion);
560
- const common = { tint: tintOf(d), dark: darkOf(d), slot: d.slot, page: found.found.page.name };
561
+ const common = { tint: tintOf(d), dark: darkOf(d), slot: d.slot, page: found.found.page.name,
562
+ ...(draw.regionInfo && d.kind === 'region' ? { regionInfo: { attachment: d.attachment, region: found.region, index: input.region(found.region)?.index ?? -1, width: found.found.region.width, height: found.found.region.height } } : {}),
563
+ };
561
564
  const world = [...d.vertices];
562
565
  const piece: Piece =
563
566
  d.kind === 'mesh'
@@ -733,7 +736,7 @@ export function corePoser(
733
736
  skeleton?: { path: string; bytes: Uint8Array },
734
737
  through: ShapeClipper = clipThrough,
735
738
  plants: CorePoserPlants = {},
736
- ): Poser {
739
+ ): Poser & { dial(name: string, override: DialOverride, skin?: string): { posed: Posed; read: number | null; applied: number } } {
737
740
  const doc = readModel(modelText, where);
738
741
  // The document poses the rig it was built with; the Spine file beside it must be that build's (issue #968).
739
742
  if (skeleton !== undefined) {
@@ -775,6 +778,11 @@ export function corePoser(
775
778
  bones: doc.bones.map((b) => ({ name: b.name, parent: b.parent ?? null })),
776
779
  slots: doc.slots.map((s) => ({ name: s.name, bone: s.bone })),
777
780
  subset: (opts, skin) => subsetOver(roster, opts, skin),
781
+ dial: (name, override, skin) => {
782
+ const input = inputOf(skin);
783
+ const result = poseRawDial(input.doc, override, name, { through });
784
+ return { posed: corePosed(input, result.pose, through, []), read: result.read, applied: result.applied };
785
+ },
778
786
  setup: (skin, channels) => {
779
787
  const input = inputOf(skin);
780
788
  let values: ChannelValue[] | undefined;
@@ -782,7 +790,7 @@ export function corePoser(
782
790
  const pose = poseRawSetup(input.doc, { through, ...observe });
783
791
  return corePosed(input, pose, through, [], values);
784
792
  },
785
- animation: (name, skin, fps, count, visit, environment, channels) => {
793
+ animation: (name, skin, fps, count, visit, environment, channels, times) => {
786
794
  let values: ChannelValue[] | undefined;
787
795
  const observe = channels ? { readLocals: (bones: readonly import('./model.ts').ModelBone[], sliders: readonly import('./core/constraints_slider.ts').SliderApplication[]) => { values = localChannels(bones, sliders); } } : {};
788
796
  const input = inputOf(skin);
@@ -802,7 +810,7 @@ export function corePoser(
802
810
  if (ctx.time > 0 && ctx.states.get(r.name)?.lastTime === ctx.time) steppedNow.add(r.name);
803
811
  return moved;
804
812
  };
805
- walk(input.doc, name, new Array<number>(count).fill(step), { through, physicsStep, ...observe }, (pose, i) => {
813
+ walk(input.doc, name, times === undefined ? new Array<number>(count).fill(step) : times.slice(1).map((t, i) => t - times[i]), { through, physicsStep, ...observe }, (pose, i) => {
806
814
  const stepped = [...steppedNow].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
807
815
  steppedNow.clear();
808
816
  if (drawFailed.length > 0) return;
@@ -324,6 +324,8 @@ export interface PinnedViewport {
324
324
 
325
325
  /** What every drawable has in common, whatever shape it is. */
326
326
  export interface PieceCommon {
327
+ /** Opt-in atlas identity for texture-scale; absent on ordinary renders. */
328
+ regionInfo?: { attachment: string; region: string; index: number; width: number; height: number };
327
329
  /**
328
330
  * World-space vertex positions, `x, y` per vertex.
329
331
  *
@@ -1101,6 +1103,8 @@ function coreFacts(skeletonText: string, atlasText: string | null, poser: Poser,
1101
1103
 
1102
1104
  /** What a sampler hands a posed frame's draw walk — `PoseOptions` with the subset already resolved. */
1103
1105
  export interface DrawOptions {
1106
+ /** Texture-scale only: atlas identity and stored texture size on whole regions. */
1107
+ regionInfo?: boolean;
1104
1108
  /** The slots drawn, resolved against the posed skeleton (`slotSubsetOf`); `undefined` draws every slot. */
1105
1109
  subset: SlotSubset | undefined;
1106
1110
  /** Every attachment whole, no clip applied — the framing box's reading. */
@@ -1159,8 +1163,11 @@ export interface Poser {
1159
1163
  * `i` from 0 to `count`, the pose at `i/fps`. The caller has checked `name`.
1160
1164
  * `environment(i)`, when given, is the change to the world the physics
1161
1165
  * answers set before the step that poses frame `i` (issue #1416).
1166
+ * Optional `times` replaces the uniform grid with increasing absolute times
1167
+ * starting at 0 (count = times.length - 1), stepped by adjacent differences.
1168
+ * Used by texture-scale to include key times; ordinary renders are unchanged.
1162
1169
  */
1163
- animation(name: string, skin: string | undefined, fps: number, count: number, visit: (index: number, posed: Posed) => void, environment?: (index: number) => EnvironmentChange | undefined, channels?: boolean): void;
1170
+ animation(name: string, skin: string | undefined, fps: number, count: number, visit: (index: number, posed: Posed) => void, environment?: (index: number) => EnvironmentChange | undefined, channels?: boolean, times?: readonly number[]): void;
1164
1171
  /** The rest table for the (slot, attachment) pairs `shown` holds — see `AttachmentRest`. */
1165
1172
  rest(skin: string | undefined, shown: readonly AttachmentPose[][]): AttachmentRest[];
1166
1173
  }
@@ -0,0 +1,153 @@
1
+ /** Warning-only region minification, in world units per stored texture pixel. */
2
+ import type { Piece, Poser, Posed } from './render_shared.ts';
3
+ import type { Plate } from '../tools/plate.ts';
4
+ import { walkTimelines } from './timelines.ts';
5
+ import { hypot2 } from './numeric.ts';
6
+
7
+ export const TEXTURE_SCALE_WARNING = 'REGION_TEXTURE_MINIFIED';
8
+ export const TEXTURE_SCALE_THRESHOLD = 0.5;
9
+ export const TEXTURE_SCALE_FPS = 60;
10
+ export const TEXTURE_SCALE_LIMIT =
11
+ 'Minimum among sampled poses only: grid + float32 key times + key-interval midpoints + endpoint; ' +
12
+ 'interpolated scale/shear/constraints can have smaller extrema between samples. ' +
13
+ 'World units per texture pixel, before consumer camera/viewport; no occlusion test.';
14
+
15
+ export interface RegionScale {
16
+ slot: string;
17
+ attachment: string;
18
+ region: string;
19
+ index: number;
20
+ page: string;
21
+ texture: { width: number; height: number };
22
+ smallestSampledScale: number;
23
+ animation: string | null;
24
+ time: number;
25
+ }
26
+ export interface TextureScaleReport {
27
+ spec: 'rigc-texture-scale/1';
28
+ threshold: number;
29
+ fps: number;
30
+ skin: string | null;
31
+ sampling: string;
32
+ samples: Array<{ animation: string | null; times: number[] }>;
33
+ drawnRegions: number;
34
+ warnings: Array<RegionScale & { name: typeof TEXTURE_SCALE_WARNING }>;
35
+ }
36
+
37
+ /** Stable 2x2 sigma-min: |det| / sigma-max avoids subtracting nearly equal eigenvalues. */
38
+ export function smallestSingularValue(a: number, b: number, c: number, d: number): number {
39
+ const scale = Math.max(Math.abs(a), Math.abs(b), Math.abs(c), Math.abs(d));
40
+ if (scale === 0) return 0;
41
+ a /= scale; b /= scale; c /= scale; d /= scale;
42
+ const sum = a * a + b * b + c * c + d * d;
43
+ const disc = hypot2(a * a + c * c - b * b - d * d, 2 * (a * b + c * d));
44
+ return scale * Math.abs(a * d - b * c) / Math.sqrt((sum + disc) / 2);
45
+ }
46
+
47
+ /** Texel-space affine map from three corresponding quad corners; includes trim, rotation, reflection and shear. */
48
+ export function regionDrawScale(piece: Piece, page: Pick<Plate, 'width' | 'height'>): number {
49
+ const [x0, y0, x1, y1, x2, y2] = piece.world;
50
+ const u0 = piece.uvs[0] * page.width, v0 = piece.uvs[1] * page.height;
51
+ const u1 = piece.uvs[2] * page.width - u0, v1 = piece.uvs[3] * page.height - v0;
52
+ const u2 = piece.uvs[4] * page.width - u0, v2 = piece.uvs[5] * page.height - v0;
53
+ const det = u1 * v2 - u2 * v1;
54
+ if (det === 0) throw new Error(`slot ${JSON.stringify(piece.slot)}: region has a singular texture rectangle`);
55
+ return smallestSingularValue(
56
+ ((x1 - x0) * v2 - (x2 - x0) * v1) / det,
57
+ ((x2 - x0) * u1 - (x1 - x0) * u2) / det,
58
+ ((y1 - y0) * v2 - (y2 - y0) * v1) / det,
59
+ ((y2 - y0) * u1 - (y1 - y0) * u2) / det,
60
+ );
61
+ }
62
+
63
+ /** A schedule, not an extrema solver. Key times use the same float32 storage as the poser. */
64
+ export function textureScaleTimes(skeletonText: string, animation: string, duration: number, fps: number): number[] {
65
+ if (!Number.isFinite(fps) || fps < 1) throw new Error(`texture-scale fps ${fps}: expected a finite number >= 1`);
66
+ const root = JSON.parse(skeletonText) as { animations?: Record<string, unknown> };
67
+ const keys = new Set<number>([0, duration]);
68
+ walkTimelines({ animations: { [animation]: root.animations?.[animation] } }, (_p, _k, _n, rows) => {
69
+ for (const row of rows) {
70
+ const t = Math.fround((row as { time?: number }).time ?? 0);
71
+ if (Number.isFinite(t) && t >= 0 && t <= duration) keys.add(t);
72
+ }
73
+ });
74
+ const sorted = [...keys].sort((a, b) => a - b);
75
+ const times = new Set(sorted);
76
+ for (let i = 1; i < sorted.length; i++) times.add((sorted[i - 1] + sorted[i]) / 2);
77
+ for (let i = 0; i / fps < duration; i++) times.add(i / fps);
78
+ return [...times].sort((a, b) => a - b);
79
+ }
80
+
81
+ /** Has a submitted triangle of positive area; fully clipped or collapsed geometry draws nothing. */
82
+ function hasArea(p: Piece): boolean {
83
+ const ts = p.kind === 'region' ? [0, 1, 2, 2, 3, 0] : p.triangles;
84
+ for (let i = 0; i < ts.length; i += 3) {
85
+ const a = ts[i] * 2, b = ts[i + 1] * 2, c = ts[i + 2] * 2;
86
+ if (Math.abs((p.world[b] - p.world[a]) * (p.world[c + 1] - p.world[a + 1]) -
87
+ (p.world[b + 1] - p.world[a + 1]) * (p.world[c] - p.world[a])) > 0) return true;
88
+ }
89
+ return false;
90
+ }
91
+
92
+ /** Excludes wholly transparent source rectangles; no per-frame raster or screen-space occlusion claim. */
93
+ function hasAlpha(piece: Piece, page: Plate): boolean {
94
+ const us = [0, 2, 4, 6].map(i => piece.uvs[i] * page.width);
95
+ const vs = [1, 3, 5, 7].map(i => piece.uvs[i] * page.height);
96
+ const x0 = Math.max(0, Math.round(Math.min(...us))), x1 = Math.min(page.width, Math.round(Math.max(...us)));
97
+ const y0 = Math.max(0, Math.round(Math.min(...vs))), y1 = Math.min(page.height, Math.round(Math.max(...vs)));
98
+ for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) if (page.data[(y * page.width + x) * 4 + 3] > 0) return true;
99
+ return false;
100
+ }
101
+
102
+ /** Streaming read: setup first, then animations in declaration order; ties keep the first observation. Never emits a build. */
103
+ export function textureScaleReading(poser: Poser, pages: Map<string, Plate>, skeletonText: string, fps = TEXTURE_SCALE_FPS, skin?: string): { report: TextureScaleReport; regions: RegionScale[] } {
104
+ if (!Number.isFinite(fps) || fps < 1) throw new Error(`texture-scale fps ${fps}: expected a finite number >= 1`);
105
+ const readings = new Map<string, RegionScale>();
106
+ const slotBones = new Map(poser.slots.map(s => [s.name, s.bone]));
107
+ const alpha = new Map<string, boolean>();
108
+ const samples: TextureScaleReport['samples'] = [{ animation: null, times: [0] }];
109
+ const visit = (pose: Posed, animation: string | null, time: number): void => {
110
+ const unposed = new Set(pose.bones().filter(b => b.a === 0 && b.b === 0 && b.c === 0 && b.d === 0).map(b => b.name));
111
+ const drawn = new Map(pose.pieces({ subset: undefined, unclipped: false, texture: false }).map(p => [p.slot, p]));
112
+ for (const whole of pose.pieces({ subset: undefined, unclipped: true, texture: false, regionInfo: true })) {
113
+ if (unposed.has(slotBones.get(whole.slot) ?? '')) continue;
114
+ if (whole.kind !== 'region' || whole.regionInfo === undefined || !(whole.tint[3] > 0)) continue;
115
+ const visible = drawn.get(whole.slot);
116
+ if (visible === undefined || !hasArea(visible)) continue;
117
+ const info = whole.regionInfo;
118
+ const page = pages.get(whole.page);
119
+ if (page === undefined) throw new Error(`region ${JSON.stringify(info.region)}: page ${JSON.stringify(whole.page)} is absent`);
120
+ const key = JSON.stringify([whole.slot, info.attachment, whole.page, info.region, info.index]);
121
+ const sourceAlpha = alpha.get(key) ?? hasAlpha(whole, page);
122
+ alpha.set(key, sourceAlpha);
123
+ if (!sourceAlpha) continue;
124
+ const scale = regionDrawScale(whole, page);
125
+ if (!Number.isFinite(scale)) throw new Error(`region ${JSON.stringify(info.region)} at ${animation ?? 'setup'} t=${time}: draw scale ${scale} is not finite`);
126
+ const old = readings.get(key);
127
+ if (old === undefined || scale < old.smallestSampledScale) readings.set(key, {
128
+ slot: whole.slot, attachment: info.attachment, region: info.region, index: info.index, page: whole.page,
129
+ texture: { width: info.width, height: info.height }, smallestSampledScale: scale, animation, time,
130
+ });
131
+ }
132
+ };
133
+ visit(poser.setup(skin), null, 0);
134
+ for (const a of poser.animations) {
135
+ const times = textureScaleTimes(skeletonText, a.name, a.duration, fps);
136
+ samples.push({ animation: a.name, times });
137
+ poser.animation(a.name, skin, fps, times.length - 1, (i, posed) => visit(posed, a.name, times[i]), undefined, false, times);
138
+ }
139
+ const regions = [...readings.values()];
140
+ return { regions, report: {
141
+ spec: 'rigc-texture-scale/1', threshold: TEXTURE_SCALE_THRESHOLD, fps, skin: skin ?? null, sampling: TEXTURE_SCALE_LIMIT,
142
+ samples, drawnRegions: regions.length,
143
+ warnings: regions.filter(r => r.smallestSampledScale < TEXTURE_SCALE_THRESHOLD).map(r => ({ name: TEXTURE_SCALE_WARNING, ...r })),
144
+ } };
145
+ }
146
+
147
+ export function textureScaleLines(report: TextureScaleReport): string[] {
148
+ return [
149
+ `texture-scale: ${report.warnings.length} warning(s) / ${report.drawnRegions} drawn region attachment(s), threshold < ${report.threshold}, ${report.fps} fps + keys/midpoints/endpoints`,
150
+ report.sampling,
151
+ ...report.warnings.map(r => `WARN ${r.name}: region ${JSON.stringify(r.region)} slot ${JSON.stringify(r.slot)} attachment ${JSON.stringify(r.attachment)} texture ${r.texture.width}x${r.texture.height}, smallest sampled draw scale ${r.smallestSampledScale.toFixed(6)} at ${r.animation === null ? 'setup' : JSON.stringify(r.animation)} t=${r.time}s`),
152
+ ];
153
+ }