@rig-c/core 2.85.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 +73 -0
- package/package.json +1 -1
- package/src/cli/core_commands.ts +31 -0
- package/src/cli/shared.ts +4 -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_core.ts +8 -2
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
|
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,4 @@
|
|
|
1
|
+
import { sampleParallax, parallaxPlate } from '../parallax_render.ts';
|
|
1
2
|
import { textureScaleReading, textureScaleLines, TEXTURE_SCALE_FPS } from '../texture_scale.ts';
|
|
2
3
|
import { extremesOf, extremesText, extremesPlate, extremesLine, extremesRefusal } from '../extremes.ts';
|
|
3
4
|
/**
|
|
@@ -776,6 +777,36 @@ export function cmdRender(flags: Record<string, string>): void {
|
|
|
776
777
|
{ skeleton: skeletonPath, atlas: atlasPath },
|
|
777
778
|
posersAsked(flags),
|
|
778
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
|
+
}
|
|
779
810
|
const only = readAnimationFlag(flags, [...facts.animations]);
|
|
780
811
|
const skin = readSkinFlag(flags, [...facts.skins]);
|
|
781
812
|
const subset = readSlotSubsetFlags(flags, facts, skin);
|
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:
|
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_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';
|
|
@@ -735,7 +736,7 @@ export function corePoser(
|
|
|
735
736
|
skeleton?: { path: string; bytes: Uint8Array },
|
|
736
737
|
through: ShapeClipper = clipThrough,
|
|
737
738
|
plants: CorePoserPlants = {},
|
|
738
|
-
): Poser {
|
|
739
|
+
): Poser & { dial(name: string, override: DialOverride, skin?: string): { posed: Posed; read: number | null; applied: number } } {
|
|
739
740
|
const doc = readModel(modelText, where);
|
|
740
741
|
// The document poses the rig it was built with; the Spine file beside it must be that build's (issue #968).
|
|
741
742
|
if (skeleton !== undefined) {
|
|
@@ -777,6 +778,11 @@ export function corePoser(
|
|
|
777
778
|
bones: doc.bones.map((b) => ({ name: b.name, parent: b.parent ?? null })),
|
|
778
779
|
slots: doc.slots.map((s) => ({ name: s.name, bone: s.bone })),
|
|
779
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
|
+
},
|
|
780
786
|
setup: (skin, channels) => {
|
|
781
787
|
const input = inputOf(skin);
|
|
782
788
|
let values: ChannelValue[] | undefined;
|