@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 +1 -1
- package/docs/AUTHORING.md +124 -0
- package/package.json +1 -1
- package/src/cli/core_commands.ts +68 -1
- package/src/cli/shared.ts +16 -1
- package/src/core/hooks.ts +5 -3
- package/src/core/raw.ts +13 -0
- package/src/parallax.ts +208 -0
- package/src/parallax_render.ts +112 -0
- package/src/render.ts +9 -5
- package/src/render_core.ts +13 -5
- package/src/render_shared.ts +8 -1
- package/src/texture_scale.ts +153 -0
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.
|
|
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": {
|
package/src/cli/core_commands.ts
CHANGED
|
@@ -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;
|
package/src/parallax.ts
ADDED
|
@@ -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
|
-
|
|
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
|
|
package/src/render_core.ts
CHANGED
|
@@ -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;
|
package/src/render_shared.ts
CHANGED
|
@@ -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
|
+
}
|