incanto 0.43.0 → 0.45.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.
@@ -5958,6 +5958,96 @@ function boneSubtree(root) {
5958
5958
  return out;
5959
5959
  }
5960
5960
  //#endregion
5961
+ //#region src/3d/animation/retarget-rig.ts
5962
+ /**
5963
+ * Play someone else's animation without becoming someone else.
5964
+ *
5965
+ * A Mixamo clip is not a list of rotations. It carries a POSITION and a SCALE
5966
+ * track for every bone — 195 tracks for 65 bones on the stock character — and
5967
+ * three's mixer applies all of them. Bind a clip like that to a character by
5968
+ * node name and the clip does not animate the character: it REPLACES the
5969
+ * character's skeleton with the one the clip was authored on. A stocky
5970
+ * four-heads-tall model quietly becomes eight heads tall the moment it idles,
5971
+ * and there is nothing in the scene JSON that says so.
5972
+ *
5973
+ * That is backwards. The model owns its proportions; the animation is the thing
5974
+ * that has to adapt. So only the rotations cross over — they mean the same
5975
+ * thing on any skeleton — and of the translations only the hips survive, scaled
5976
+ * by the ratio of hip heights. That one exception matters: hip travel is in the
5977
+ * SOURCE rig's units, so handing a short character a tall rig's stride slides
5978
+ * its feet and overshoots its jumps.
5979
+ *
5980
+ * Same algorithm the VRM path has always used (`retarget-vrm.ts`), which until
5981
+ * now was the only path that had it.
5982
+ */
5983
+ /** Mixamo exports this bone as `mixamorigHips` or `mixamorig:Hips`. */
5984
+ function findHips(root) {
5985
+ let found = null;
5986
+ root.traverse((o) => {
5987
+ if (found) return;
5988
+ if (/^mixamorig[:_]?hips$/i.test(o.name) || /^hips$/i.test(o.name)) found = o;
5989
+ });
5990
+ return found;
5991
+ }
5992
+ /** How high the hips sit above the rig's own root, in that rig's own units. */
5993
+ function hipHeight(root, hips) {
5994
+ root.updateMatrixWorld(true);
5995
+ const inRoot = hips.matrixWorld.clone().premultiply(root.matrixWorld.clone().invert());
5996
+ return Math.abs(inRoot.elements[13] ?? 0);
5997
+ }
5998
+ const boneOf = (trackName) => trackName.split(".")[0] ?? "";
5999
+ const propertyOf = (trackName) => trackName.split(".").pop() ?? "";
6000
+ /**
6001
+ * A copy of `clip` that animates `targetRoot` without re-proportioning it.
6002
+ *
6003
+ * Passes the clip through untouched when it cannot do better — a clip whose
6004
+ * bones the target does not have would be stripped to nothing, and a silent
6005
+ * empty animation is worse than a wrong-sized one.
6006
+ */
6007
+ function retargetClipToRig(clip, sourceScene, targetRoot) {
6008
+ const targetBones = /* @__PURE__ */ new Set();
6009
+ targetRoot.traverse((o) => targetBones.add(o.name));
6010
+ if (clip.tracks.filter((t) => propertyOf(t.name) === "quaternion" && targetBones.has(boneOf(t.name))).length === 0) return {
6011
+ clip,
6012
+ droppedTracks: 0,
6013
+ hipsScale: 1,
6014
+ passthrough: true,
6015
+ reason: "no bone in this clip matches the model — played as authored"
6016
+ };
6017
+ const sourceHips = findHips(sourceScene);
6018
+ const targetHips = findHips(targetRoot);
6019
+ const sourceHeight = sourceHips ? hipHeight(sourceScene, sourceHips) : 0;
6020
+ const targetHeight = targetHips ? hipHeight(targetRoot, targetHips) : 0;
6021
+ const hipsScale = sourceHeight > 1e-6 && targetHeight > 1e-6 ? targetHeight / sourceHeight : 1;
6022
+ const hipsName = targetHips?.name ?? "";
6023
+ const tracks = [];
6024
+ let dropped = 0;
6025
+ for (const track of clip.tracks) {
6026
+ const bone = boneOf(track.name);
6027
+ const property = propertyOf(track.name);
6028
+ if (!targetBones.has(bone)) {
6029
+ dropped += 1;
6030
+ continue;
6031
+ }
6032
+ if (property === "quaternion") {
6033
+ tracks.push(track);
6034
+ continue;
6035
+ }
6036
+ if (property === "position" && bone === hipsName) {
6037
+ const values = Array.from(track.values, (v) => v * hipsScale);
6038
+ tracks.push(new VectorKeyframeTrack(track.name, Array.from(track.times), values));
6039
+ continue;
6040
+ }
6041
+ dropped += 1;
6042
+ }
6043
+ return {
6044
+ clip: new AnimationClip(`${clip.name} (retargeted)`, clip.duration, tracks),
6045
+ droppedTracks: dropped,
6046
+ hipsScale,
6047
+ passthrough: false
6048
+ };
6049
+ }
6050
+ //#endregion
5961
6051
  //#region src/3d/animation/rigmap.ts
5962
6052
  /**
5963
6053
  * Mixamo rig name → VRM humanoid bone name (the vibe-starter-3d /
@@ -6190,6 +6280,8 @@ var ModelInstance3D = class extends Node3D {
6190
6280
  entry = null;
6191
6281
  mountedRef = "";
6192
6282
  fittedHeight = -1;
6283
+ /** A skinned model waiting for a posed skeleton it can be measured against. */
6284
+ fitPending = false;
6193
6285
  fitGroup = null;
6194
6286
  /**
6195
6287
  * @internal The mounted model, or null while the asset is still loading.
@@ -6340,18 +6432,15 @@ var ModelInstance3D = class extends Node3D {
6340
6432
  });
6341
6433
  });
6342
6434
  }
6343
- if (this.fitGroup && this.fittedHeight !== this.targetHeight) {
6344
- this.fittedHeight = this.targetHeight;
6345
- if (this.targetHeight > 0) {
6346
- const prev = this.fitGroup.scale.x;
6347
- this.fitGroup.scale.setScalar(1);
6348
- const size = skinnedAwareBox(this.fitGroup).getSize(new Vector3());
6349
- const scale = size.y > 0 ? this.targetHeight / size.y : prev;
6350
- if (scale < .05 || scale > 20) {
6435
+ if (this.fitGroup) {
6436
+ if (this.fittedHeight !== this.targetHeight) {
6437
+ this.fittedHeight = this.targetHeight;
6438
+ if (this.targetHeight <= 0) {
6351
6439
  this.fitGroup.scale.setScalar(1);
6352
- this.diagnose("warn", `[incanto] ModelInstance3D '${this.name}': targetHeight fit measured an implausible scale (${scale.toFixed(2)}x) — skinned rigs defeat bounding-box measurement. Rendering at the model's authored scale instead.`);
6353
- } else this.fitGroup.scale.setScalar(scale);
6354
- } else this.fitGroup.scale.setScalar(1);
6440
+ this.fitPending = false;
6441
+ } else if (hasSkinning(this.fitGroup)) this.fitPending = true;
6442
+ else this.applyFit();
6443
+ }
6355
6444
  }
6356
6445
  if (this.fitGroup) this.fitGroup.traverse((obj) => {
6357
6446
  const mesh = obj;
@@ -6365,6 +6454,30 @@ var ModelInstance3D = class extends Node3D {
6365
6454
  this.syncAnimation(assets);
6366
6455
  this.syncUpperAnimation(assets);
6367
6456
  }
6457
+ /**
6458
+ * Scale the model so it stands `targetHeight` tall, measured ONCE.
6459
+ *
6460
+ * Once, deliberately: the measurement reflects the pose the model happens to
6461
+ * be in, and re-running it every frame would let a walk cycle resize the
6462
+ * character mid-stride. In practice the pose barely matters — across a whole
6463
+ * idle clip this rig's height moves 0.5% — but "barely" is not "never", and
6464
+ * a size that breathes is worse than one that is half a percent off.
6465
+ */
6466
+ applyFit() {
6467
+ const fit = this.fitGroup;
6468
+ if (!fit) return;
6469
+ this.fitPending = false;
6470
+ const previous = fit.scale.x;
6471
+ fit.scale.setScalar(1);
6472
+ const height = renderedBox(fit).getSize(new Vector3()).y;
6473
+ const scale = height > 0 ? this.targetHeight / height : previous;
6474
+ if (scale < .05 || scale > 20) {
6475
+ fit.scale.setScalar(1);
6476
+ this.diagnose("warn", `[incanto] ModelInstance3D '${this.name}': targetHeight fit measured an implausible scale (${scale.toFixed(2)}x) — the model may have no geometry. Rendering at the model's authored scale instead.`);
6477
+ return;
6478
+ }
6479
+ fit.scale.setScalar(scale);
6480
+ }
6368
6481
  /** Apply per-instance material mods — `tint` (multiplied into colour) and the
6369
6482
  * `metalness`/`roughness` overrides — by cloning the captured originals
6370
6483
  * (never mutates the shared source), disposing prior clones on change. */
@@ -6427,7 +6540,12 @@ var ModelInstance3D = class extends Node3D {
6427
6540
  this.entry.retargeted.set(ref, result.clip);
6428
6541
  return result.clip;
6429
6542
  }
6430
- return source;
6543
+ const cached = this.entry.retargeted.get(ref);
6544
+ if (cached) return cached;
6545
+ const rig = retargetClipToRig(source, anim.scene, this.fitGroup ?? this._ensureObject3D());
6546
+ if (rig.passthrough) this.diagnose("warn", `incanto: animation '${ref}' — ${rig.reason}`);
6547
+ this.entry.retargeted.set(ref, rig.clip);
6548
+ return rig.clip;
6431
6549
  }
6432
6550
  /** Masked upper/lower variants of a clip for two-layer playback (cached). */
6433
6551
  layerVariants(clip) {
@@ -6536,6 +6654,7 @@ var ModelInstance3D = class extends Node3D {
6536
6654
  }
6537
6655
  this.mixer?.update(dt);
6538
6656
  if (this.entry?.isVrm && this.entry.claimedBy === this) this.entry.vrm?.update(dt);
6657
+ if (this.fitPending) this.applyFit();
6539
6658
  }
6540
6659
  onExitTree() {
6541
6660
  this.unmount(this._ensureObject3D());
@@ -6560,39 +6679,57 @@ var ModelInstance3D = class extends Node3D {
6560
6679
  this.fittedHeight = -1;
6561
6680
  }
6562
6681
  };
6682
+ /** Does anything under here reach the screen through a skeleton? */
6683
+ function hasSkinning(root) {
6684
+ let found = false;
6685
+ root.traverse((o) => {
6686
+ if (o.isSkinnedMesh) found = true;
6687
+ });
6688
+ return found;
6689
+ }
6563
6690
  /**
6564
- * Height measurement SkinnedMesh can't lie about. Static bounding boxes fail
6565
- * twice on mixamo-style rigs (0.01 armature scale hides the true size, and
6566
- * the current animation pose skews any vertex-based box), so skinned meshes
6567
- * measure their BONE BIND positions — skeleton.boneInverses inverted gives
6568
- * each bone's rest position in mesh space, pose-independent and exact.
6569
- * Plain meshes keep the geometry-box route.
6691
+ * How big the model will actually BE on screen, in the fit group's own space.
6692
+ *
6693
+ * A skinned vertex does not reach the screen through `matrixWorld`. three puts
6694
+ * it through the whole skinning chain
6695
+ *
6696
+ * world = matrixWorld · bindMatrixInverse · boneMatrix · bindMatrix · v
6697
+ *
6698
+ * and on a mixamo rig those terms are not decoration: the armature carries a
6699
+ * 0.01 node scale that lives in one of them and the 100 cancelling it lives in
6700
+ * another. Measuring the geometry box through `matrixWorld` alone — the obvious
6701
+ * thing, and what this did for four releases — reported the character a HUNDRED
6702
+ * times too small, tripped the plausibility guard, and silently threw away
6703
+ * `targetHeight` on every skinned character in four shipped templates.
6704
+ *
6705
+ * Every cheaper proxy is wrong too, and by enough to see: on the same character
6706
+ * the joint positions measure 1.49 and the bind-pose geometry box 1.50 where
6707
+ * the renderer draws 1.66. So skinned meshes are measured the way three itself
6708
+ * measures them — `getVertexPosition`, which walks each vertex through its bone
6709
+ * transforms. Fitting to that gave 1.7000 for a `targetHeight` of 1.7 and
6710
+ * 2.5001 for 2.5.
6711
+ *
6712
+ * Rigid meshes keep their bounding box: it is exact for them and free.
6570
6713
  */
6571
- function skinnedAwareBox(root) {
6572
- root.updateWorldMatrix(true, true);
6714
+ function renderedBox(root) {
6715
+ root.updateMatrixWorld(true);
6573
6716
  const box = new Box3();
6574
6717
  const tmp = new Box3();
6575
- const invRoot = root.matrixWorld.clone().invert();
6576
- const m4 = new Matrix4();
6577
6718
  const v = new Vector3();
6719
+ const invRoot = root.matrixWorld.clone().invert();
6578
6720
  root.traverse((o) => {
6579
- const skinned = o;
6580
- if (skinned.isSkinnedMesh && skinned.skeleton) {
6581
- for (const inverse of skinned.skeleton.boneInverses) {
6582
- m4.copy(inverse).invert().premultiply(skinned.matrixWorld).premultiply(invRoot);
6583
- v.setFromMatrixPosition(m4);
6584
- box.expandByPoint(v);
6585
- }
6586
- return;
6587
- }
6588
6721
  const mesh = o;
6589
- if (mesh.isMesh && mesh.geometry) {
6590
- mesh.geometry.computeBoundingBox();
6591
- if (mesh.geometry.boundingBox) {
6592
- tmp.copy(mesh.geometry.boundingBox).applyMatrix4(mesh.matrixWorld).applyMatrix4(invRoot);
6593
- box.union(tmp);
6722
+ if (!mesh.isMesh || !mesh.geometry) return;
6723
+ const position = mesh.geometry.getAttribute("position");
6724
+ if (mesh.isSkinnedMesh && typeof mesh.getVertexPosition === "function" && position && mesh.geometry.getAttribute("skinIndex") && mesh.geometry.getAttribute("skinWeight") && position) {
6725
+ for (let i = 0; i < position.count; i++) {
6726
+ mesh.getVertexPosition?.(i, v);
6727
+ box.expandByPoint(v.applyMatrix4(mesh.matrixWorld).applyMatrix4(invRoot));
6594
6728
  }
6729
+ return;
6595
6730
  }
6731
+ mesh.geometry.computeBoundingBox();
6732
+ if (mesh.geometry.boundingBox) box.union(tmp.copy(mesh.geometry.boundingBox).applyMatrix4(mesh.matrixWorld).applyMatrix4(invRoot));
6596
6733
  });
6597
6734
  return box;
6598
6735
  }
@@ -0,0 +1,80 @@
1
+ //#region src/3d/frame-report.d.ts
2
+ /**
3
+ * What the frame looks like, as something worth saying.
4
+ *
5
+ * The pixels are not the deliverable. A 1280x720 frame is 2.8 million numbers:
6
+ * unreadable by a person, unaffordable for a model (roughly a thousand tokens
7
+ * as an image, roughly a million as text). The deliverable is the JUDGEMENT —
8
+ * black screen, one flat colour, washed out — and the numbers behind it, small
9
+ * enough to print and compare.
10
+ *
11
+ * Same principle as `framing`'s "lit by: NOTHING": a report is a sentence
12
+ * before it is a number, because a number needs a reader who already knows what
13
+ * to compare it against.
14
+ *
15
+ * Pure arithmetic over an RGBA buffer — no three, no DOM, no GPU. Whoever has
16
+ * the pixels calls this; in practice that is the browser holding the canvas.
17
+ */
18
+ /** Per-cell mean colour, `[r, g, b]` 0..255. */
19
+ type GridCell = [number, number, number];
20
+ interface FrameStats {
21
+ width: number;
22
+ height: number;
23
+ /** Every pixel below the black threshold — the render produced nothing. */
24
+ black: boolean;
25
+ /** One colour everywhere: a clear-colour fill, or a camera inside geometry. */
26
+ uniform: boolean;
27
+ /** 0..1 mean relative luminance. */
28
+ meanLuma: number;
29
+ /** Coarse mean colour per cell, rows top to bottom. */
30
+ grid: GridCell[][];
31
+ }
32
+ interface FrameStatsOptions {
33
+ /** Cells across and down. Default 16x9 — 144 cells, printable. */
34
+ grid?: [number, number];
35
+ }
36
+ declare function frameStats(pixels: Uint8Array | Uint8ClampedArray, width: number, height: number, opts?: FrameStatsOptions): FrameStats;
37
+ /** The report a person or an agent reads. Verdict first, numbers after. */
38
+ declare function frameText(stats: FrameStats): string;
39
+ /**
40
+ * Comparison resolution. Fine enough that a one-pixel line moves a cell by
41
+ * several levels at 720p, small enough (6912 bytes) to hold and to send.
42
+ */
43
+ declare const SIGNATURE_GRID: readonly [number, number];
44
+ interface FrameSignature {
45
+ /** The frame this was made from — a different size is not comparable. */
46
+ width: number;
47
+ height: number;
48
+ cols: number;
49
+ rows: number;
50
+ /** base64 of `cols * rows * 3` bytes: cell means, rows top to bottom. */
51
+ data: string;
52
+ }
53
+ interface FrameDiff {
54
+ /** False when the two cannot be subtracted at all; see `reason`. */
55
+ comparable: boolean;
56
+ reason?: string;
57
+ /** 0..1 fraction of cells that moved past the threshold. */
58
+ changed: number;
59
+ /** Largest single-channel move, 0..255. */
60
+ maxDelta: number;
61
+ /** Mean single-channel move across the frame, 0..255. */
62
+ meanDelta: number;
63
+ /** Where the change is, in pixels of the compared frame. */
64
+ box: {
65
+ x: number;
66
+ y: number;
67
+ w: number;
68
+ h: number;
69
+ } | null;
70
+ /** A thin full-length band is worth naming: that is what a seam looks like. */
71
+ shape: "vertical band" | "horizontal band" | null;
72
+ }
73
+ declare function frameSignature(pixels: Uint8Array | Uint8ClampedArray, width: number, height: number, grid?: readonly [number, number]): FrameSignature;
74
+ declare function diffSignatures(before: FrameSignature, after: FrameSignature, opts?: {
75
+ threshold?: number;
76
+ }): FrameDiff;
77
+ /** The comparison a person or an agent reads. */
78
+ declare function diffText(diff: FrameDiff): string;
79
+ //#endregion
80
+ export { GridCell as a, diffText as c, frameText as d, FrameStatsOptions as i, frameSignature as l, FrameSignature as n, SIGNATURE_GRID as o, FrameStats as r, diffSignatures as s, FrameDiff as t, frameStats as u };
@@ -0,0 +1,197 @@
1
+ //#region src/3d/frame-report.ts
2
+ /** Rec. 709 relative luminance of an 8-bit triple, 0..1. */
3
+ function luma(r, g, b) {
4
+ return (.2126 * r + .7152 * g + .0722 * b) / 255;
5
+ }
6
+ /**
7
+ * A pixel this dark is indistinguishable from "nothing was drawn". Not zero:
8
+ * a real render of a night scene still lands a few levels above black, and
9
+ * calling that a failure would make the check useless where it matters most.
10
+ */
11
+ const BLACK_LEVEL = .012;
12
+ /** Cell-to-cell luminance range below this: the frame is one flat colour. */
13
+ const UNIFORM_EPSILON = .004;
14
+ /**
15
+ * Mean colour of every cell of a `cols` x `rows` box filter, as a flat
16
+ * `cols * rows * 3` byte array, rows top to bottom.
17
+ *
18
+ * The one place a frame is reduced. Both the printable report and the
19
+ * comparison signature are this at different resolutions, so a boundary that
20
+ * moves moves for both.
21
+ */
22
+ function cellMeans(pixels, width, height, cols, rows) {
23
+ const out = new Uint8Array(cols * rows * 3);
24
+ for (let row = 0; row < rows; row++) for (let col = 0; col < cols; col++) {
25
+ const x0 = Math.floor(col * width / cols);
26
+ const x1 = Math.max(x0 + 1, Math.floor((col + 1) * width / cols));
27
+ const y0 = Math.floor(row * height / rows);
28
+ const y1 = Math.max(y0 + 1, Math.floor((row + 1) * height / rows));
29
+ let r = 0;
30
+ let g = 0;
31
+ let b = 0;
32
+ let n = 0;
33
+ for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) {
34
+ const i = (y * width + x) * 4;
35
+ r += pixels[i] ?? 0;
36
+ g += pixels[i + 1] ?? 0;
37
+ b += pixels[i + 2] ?? 0;
38
+ n += 1;
39
+ }
40
+ const o = (row * cols + col) * 3;
41
+ out[o] = Math.round(r / n);
42
+ out[o + 1] = Math.round(g / n);
43
+ out[o + 2] = Math.round(b / n);
44
+ }
45
+ return out;
46
+ }
47
+ function frameStats(pixels, width, height, opts = {}) {
48
+ const [cols, rows] = opts.grid ?? [16, 9];
49
+ const means = cellMeans(pixels, width, height, cols, rows);
50
+ const grid = [];
51
+ let total = 0;
52
+ let maxLuma = 0;
53
+ let minLuma = 1;
54
+ for (let row = 0; row < rows; row++) {
55
+ const line = [];
56
+ for (let col = 0; col < cols; col++) {
57
+ const o = (row * cols + col) * 3;
58
+ const cell = [
59
+ means[o],
60
+ means[o + 1],
61
+ means[o + 2]
62
+ ];
63
+ const cellLuma = luma(cell[0], cell[1], cell[2]);
64
+ total += cellLuma;
65
+ maxLuma = Math.max(maxLuma, cellLuma);
66
+ minLuma = Math.min(minLuma, cellLuma);
67
+ line.push(cell);
68
+ }
69
+ grid.push(line);
70
+ }
71
+ const cells = cols * rows;
72
+ const meanLuma = total / cells;
73
+ return {
74
+ width,
75
+ height,
76
+ black: maxLuma <= BLACK_LEVEL,
77
+ uniform: maxLuma - minLuma < UNIFORM_EPSILON,
78
+ meanLuma: Math.round(meanLuma * 1e3) / 1e3,
79
+ grid
80
+ };
81
+ }
82
+ /** The report a person or an agent reads. Verdict first, numbers after. */
83
+ function frameText(stats) {
84
+ const lines = [];
85
+ if (stats.black) lines.push("BLACK SCREEN — nothing was drawn (no light, no camera, or nothing in view)");
86
+ else if (stats.uniform) lines.push("one flat colour — the camera may be inside geometry, or only the sky is drawn");
87
+ lines.push(`frame ${stats.width}×${stats.height} · luminance ${stats.meanLuma.toFixed(2)}`);
88
+ return lines.join("\n");
89
+ }
90
+ /**
91
+ * Comparison resolution. Fine enough that a one-pixel line moves a cell by
92
+ * several levels at 720p, small enough (6912 bytes) to hold and to send.
93
+ */
94
+ const SIGNATURE_GRID = [64, 36];
95
+ /**
96
+ * Below this, a cell has not changed. Compression, dithering and the last bit
97
+ * of a tonemap all wander by a level or two between two captures of the same
98
+ * still frame; calling that a change would bury the real one.
99
+ */
100
+ const DIFF_THRESHOLD = 3;
101
+ function frameSignature(pixels, width, height, grid = SIGNATURE_GRID) {
102
+ const [cols, rows] = grid;
103
+ return {
104
+ width,
105
+ height,
106
+ cols,
107
+ rows,
108
+ data: toBase64(cellMeans(pixels, width, height, cols, rows))
109
+ };
110
+ }
111
+ function diffSignatures(before, after, opts = {}) {
112
+ const none = {
113
+ changed: 0,
114
+ maxDelta: 0,
115
+ meanDelta: 0,
116
+ box: null,
117
+ shape: null
118
+ };
119
+ if (before.width !== after.width || before.height !== after.height) return {
120
+ comparable: false,
121
+ reason: `frame size changed: ${before.width}×${before.height} → ${after.width}×${after.height}`,
122
+ ...none
123
+ };
124
+ if (before.cols !== after.cols || before.rows !== after.rows) return {
125
+ comparable: false,
126
+ reason: "signature grid changed",
127
+ ...none
128
+ };
129
+ const threshold = opts.threshold ?? DIFF_THRESHOLD;
130
+ const a = fromBase64(before.data);
131
+ const b = fromBase64(after.data);
132
+ const { cols, rows } = after;
133
+ let changedCells = 0;
134
+ let maxDelta = 0;
135
+ let totalDelta = 0;
136
+ let minCol = cols;
137
+ let maxCol = -1;
138
+ let minRow = rows;
139
+ let maxRow = -1;
140
+ for (let row = 0; row < rows; row++) for (let col = 0; col < cols; col++) {
141
+ const o = (row * cols + col) * 3;
142
+ const delta = Math.max(Math.abs((a[o] ?? 0) - (b[o] ?? 0)), Math.abs((a[o + 1] ?? 0) - (b[o + 1] ?? 0)), Math.abs((a[o + 2] ?? 0) - (b[o + 2] ?? 0)));
143
+ totalDelta += delta;
144
+ maxDelta = Math.max(maxDelta, delta);
145
+ if (delta >= threshold) {
146
+ changedCells += 1;
147
+ minCol = Math.min(minCol, col);
148
+ maxCol = Math.max(maxCol, col);
149
+ minRow = Math.min(minRow, row);
150
+ maxRow = Math.max(maxRow, row);
151
+ }
152
+ }
153
+ const cells = cols * rows;
154
+ const box = maxCol < 0 ? null : {
155
+ x: Math.floor(minCol * after.width / cols),
156
+ y: Math.floor(minRow * after.height / rows),
157
+ w: Math.ceil((maxCol + 1 - minCol) * after.width / cols),
158
+ h: Math.ceil((maxRow + 1 - minRow) * after.height / rows)
159
+ };
160
+ const spanCols = maxCol < 0 ? 0 : maxCol + 1 - minCol;
161
+ const spanRows = maxRow < 0 ? 0 : maxRow + 1 - minRow;
162
+ return {
163
+ comparable: true,
164
+ changed: changedCells / cells,
165
+ maxDelta,
166
+ meanDelta: Math.round(totalDelta / cells * 10) / 10,
167
+ box,
168
+ shape: spanCols > 0 && spanCols <= 2 && spanRows >= rows / 2 ? "vertical band" : spanRows > 0 && spanRows <= 2 && spanCols >= cols / 2 ? "horizontal band" : null
169
+ };
170
+ }
171
+ /** The comparison a person or an agent reads. */
172
+ function diffText(diff) {
173
+ if (!diff.comparable) return `cannot compare — ${diff.reason ?? "unknown reason"}`;
174
+ if (diff.changed === 0) return `identical — nothing moved (largest cell shift ${diff.maxDelta}/255)`;
175
+ const lines = [`changed ${(diff.changed * 100).toFixed(1)}% of the frame · largest shift ${diff.maxDelta}/255, mean ${diff.meanDelta}`];
176
+ if (diff.box) {
177
+ const { x, y, w, h } = diff.box;
178
+ lines.push(`region x ${x}..${x + w}, y ${y}..${y + h}${diff.shape ? ` — a ${diff.shape}` : ""}`);
179
+ }
180
+ if (diff.changed > .9) lines.push("the whole frame moved — if the scene is animating, pause it first (debug ☰ → Time → Pause) so the comparison is of your change, not of time passing");
181
+ return lines.join("\n");
182
+ }
183
+ /** Portable in a browser and in node; both have had `btoa`/`atob` for years. */
184
+ function toBase64(bytes) {
185
+ let binary = "";
186
+ const CHUNK = 32768;
187
+ for (let i = 0; i < bytes.length; i += CHUNK) binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
188
+ return btoa(binary);
189
+ }
190
+ function fromBase64(text) {
191
+ const binary = atob(text);
192
+ const out = new Uint8Array(binary.length);
193
+ for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
194
+ return out;
195
+ }
196
+ //#endregion
197
+ export { frameStats as a, frameSignature as i, diffSignatures as n, frameText as o, diffText as r, SIGNATURE_GRID as t };
package/dist/index.js CHANGED
@@ -7,7 +7,7 @@ import { n as jsonEquals, r as jsonKind, t as jsonClone } from "./json-BLk7H2Qa.
7
7
  import { a as getNodeSignals, c as mergeStaticSignals, i as getNodeSchema, l as registerNode, n as clearRegistry, o as getNodeType, r as createNode, u as registeredTypes } from "./registry-IyWCGe4q.js";
8
8
  import { t as createNoise2D } from "./noise-CGUMx44x.js";
9
9
  import { i as applyParticlePreset, n as PARTICLE_PRESETS, r as PARTICLE_PRESET_NAMES, t as ParticleSim } from "./particle-sim-Bw7hB93B.js";
10
- import { a as findPath, i as preloadUrls, n as newUid, o as gridFromRows, r as assetUrls, t as VERSION } from "./src-CwYxzZKl.js";
10
+ import { a as findPath, i as preloadUrls, n as newUid, o as gridFromRows, r as assetUrls, t as VERSION } from "./src-CV_uN7j4.js";
11
11
  import { i as resolveRendering, n as attachTouchControls, r as joystickVector, t as TouchControls } from "./touch-BoNg_MnF.js";
12
12
  import { t as duplicateNode } from "./duplicate-CRtihGmC.js";
13
13
  export { AudioBuses, AudioPlayer, BASE_LOCALE, Behavior, CONST_REF_KEY, Engine, HudLayer, IncantoError, InputMap, Localization, LogManager, MusicManager, Node, ORDER_GROUP_BASE, PARTICLE_PRESETS, PARTICLE_PRESET_NAMES, ParticleSim, ROLLOFF_MODELS, Rng, SCENE_FORMAT, SFX_PRESETS, SFX_PRESET_NAMES, SaveSlots, Scene, SceneTree, Settings, SfxEngine, Signal, T_PREFIX, Timer, TouchControls, UiBanner, UiBar, UiButton, UiDialogue, UiImage, UiPanel, UiSelect, UiSlider, UiText, UiToggle, VERSION, WebAudioMusicBackend, applyParticlePreset, assetUrls, attachTouchControls, auditScene, behaviorSchema, behaviorSignals, behaviorsWithoutSave, captureBehaviors, clearBehaviors, clearRegistry, computeViewport, createNode, createNoise2D, createSaveStore, crossfadeGains, duplicateNode, effectiveOrder, fadeGain, findPath, getBehavior, getNodeSchema, getNodeSignals, getNodeType, gridFromRows, isAudioContextAvailable, isConstRef, joystickVector, jsonClone, jsonEquals, jsonKind, loadScene, mergeStaticSignals, newUid, parseNodePath, preloadUrls, qualityEnvironment, readDeviceHints, registerBehavior, registerCoreNodes, registerNode, registeredBehaviors, registeredTypes, replay, resolveConstants, resolveOrderGroups, resolveRendering, resolveViewport, restoreBehaviors, serializeNode, spatialGain, spatialPan, startRecording, suggestLocale, suggestQuality, synthSfx, translationKey };
@@ -3,7 +3,7 @@ import { v as diagnose } from "./loader-r49nDwB4.js";
3
3
  import { t as IncantoError } from "./errors-BpWbnbb_.js";
4
4
  import { F as PhysicsBody3D, I as RigidBody3D, N as Area3D, P as CharacterBody3D, R as Node3D, z as validateCollider3D } from "./gameplay-BQOeAid6.js";
5
5
  import { n as registerDebugSource } from "./debug-draw-BM3DsvtT.js";
6
- import { A as Terrain3D, F as InstancedMesh3D, L as buildMeshGeometry, P as Joint3D } from "./environment-presets-Ds5kXLoF.js";
6
+ import { A as Terrain3D, F as InstancedMesh3D, L as buildMeshGeometry, P as Joint3D } from "./environment-presets-2GgTlPt1.js";
7
7
  import { Euler, Matrix4, Quaternion, Vector3 } from "three";
8
8
  //#region src/3d/physics/collider-lines.ts
9
9
  /**
package/dist/react.js CHANGED
@@ -156,7 +156,7 @@ function IncantoCanvas(props) {
156
156
  pointer: latest.pointer,
157
157
  ...keyboard !== void 0 ? { keyboard } : {}
158
158
  };
159
- const next = await (_gameFactory ?? (mode === "3d" ? async (o) => (await import("./create-game-B3vBWgVD.js").then((n) => n.n)).createGame3D(o) : async (o) => (await import("./create-game-DFRbG0Bj.js").then((n) => n.n)).createGame2D(o)))(opts);
159
+ const next = await (_gameFactory ?? (mode === "3d" ? async (o) => (await import("./create-game-CCFUOWfb.js").then((n) => n.n)).createGame3D(o) : async (o) => (await import("./create-game-DFRbG0Bj.js").then((n) => n.n)).createGame2D(o)))(opts);
160
160
  if (disposed) {
161
161
  next.dispose();
162
162
  return;
@@ -161,6 +161,6 @@ function newUid() {
161
161
  //#endregion
162
162
  //#region src/index.ts
163
163
  /** Engine version. Kept in sync with package.json by the release pipeline. */
164
- const VERSION = "0.43.0";
164
+ const VERSION = "0.45.0";
165
165
  //#endregion
166
166
  export { findPath as a, preloadUrls as i, newUid as n, gridFromRows as o, assetUrls as r, VERSION as t };
@@ -6,7 +6,7 @@ import { n as jsonEquals, t as jsonClone } from "./json-BLk7H2Qa.js";
6
6
  import { i as getNodeSchema, s as mergeStaticProps } from "./registry-IyWCGe4q.js";
7
7
  import { n as registerGameplayBehaviors } from "./gameplay-BQOeAid6.js";
8
8
  import { t as registerNodes2D } from "./register-R2JTnIMw.js";
9
- import { n as registerNodes3D, t as resolveEnvironmentHdri } from "./environment-presets-Ds5kXLoF.js";
9
+ import { n as registerNodes3D, t as resolveEnvironmentHdri } from "./environment-presets-2GgTlPt1.js";
10
10
  import { t as registerNodesNet } from "./register-D651it1J.js";
11
11
  import { Box3, Euler, Matrix4, PerspectiveCamera, Quaternion, Vector3 } from "three";
12
12
  //#region src/test/framing.ts
@@ -1370,7 +1370,7 @@ async function runScript(json, opts) {
1370
1370
  const { enablePhysics2D } = await import("./physics-2d-BmgXBNDB.js").then((n) => n.r);
1371
1371
  await enablePhysics2D(engine);
1372
1372
  } else if (physics === "3d" || physics === "auto" && scene.dimension === "3d") {
1373
- const { enablePhysics3D } = await import("./physics-3d-CLPFv99o.js").then((n) => n.r);
1373
+ const { enablePhysics3D } = await import("./physics-3d-nLI_8bUR.js").then((n) => n.r);
1374
1374
  await enablePhysics3D(engine);
1375
1375
  }
1376
1376
  const failures = [];
@@ -1486,7 +1486,7 @@ async function createPlaySession(json, opts = {}) {
1486
1486
  const { enablePhysics2D } = await import("./physics-2d-BmgXBNDB.js").then((n) => n.r);
1487
1487
  await enablePhysics2D(engine);
1488
1488
  } else if (physics === "3d" || physics === "auto" && scene.dimension === "3d") {
1489
- const { enablePhysics3D } = await import("./physics-3d-CLPFv99o.js").then((n) => n.r);
1489
+ const { enablePhysics3D } = await import("./physics-3d-nLI_8bUR.js").then((n) => n.r);
1490
1490
  await enablePhysics3D(engine);
1491
1491
  }
1492
1492
  const stepMs = 1e3 / (opts.fixedHz ?? 60);
package/dist/test.js CHANGED
@@ -1,3 +1,3 @@
1
1
  import { r as auditScene } from "./replay-DilbZgQI.js";
2
- import { _ as framingText, a as registerAllNodes, c as feelReport, d as facingText, f as failingReplays, g as describeFraming, h as playtestText, i as findFloatingProps, l as feelText, m as playtest, n as createPlaySession, o as runScript, p as findPlayer, r as describeCapture, s as validateScene, t as captureScene, u as facingReport } from "./test-BMgiiD5i.js";
2
+ import { _ as framingText, a as registerAllNodes, c as feelReport, d as facingText, f as failingReplays, g as describeFraming, h as playtestText, i as findFloatingProps, l as feelText, m as playtest, n as createPlaySession, o as runScript, p as findPlayer, r as describeCapture, s as validateScene, t as captureScene, u as facingReport } from "./test-R8JCuDlv.js";
3
3
  export { auditScene, captureScene, createPlaySession, describeCapture, describeFraming, facingReport, facingText, failingReplays, feelReport, feelText, findFloatingProps, findPlayer, framingText, playtest, playtestText, registerAllNodes, runScript, validateScene };
package/dist/vite.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { n as FrameSignature } from "./frame-report-Ct8XgsmV.js";
2
+
1
3
  //#region src/vite/discover.d.ts
2
4
  /**
3
5
  * The listening TCP ports in `/proc/net/tcp` (and `tcp6`) format.
@@ -25,7 +27,7 @@ interface FrameHost {
25
27
  root?: string;
26
28
  };
27
29
  middlewares: {
28
- use(path: string, handler: (req: unknown, res: FrameRes) => void): void;
30
+ use(path: string, handler: (req: FrameReq, res: FrameRes) => void): void;
29
31
  };
30
32
  /** Vite's HMR channel. `ws` on v5, `hot` on v6+ — both are accepted. */
31
33
  ws?: FrameChannel;
@@ -44,10 +46,22 @@ interface FrameRes {
44
46
  setHeader(name: string, value: string): void;
45
47
  end(body?: string): void;
46
48
  }
49
+ /** Connect strips the mount path off `url`; `originalUrl` keeps the query. */
50
+ interface FrameReq {
51
+ url?: string;
52
+ originalUrl?: string;
53
+ }
47
54
  interface FrameEndpointOptions {
48
55
  /** Overridden in tests; defaults to the real clock. */
49
56
  now?: () => number;
50
57
  timeoutMs?: number;
58
+ /**
59
+ * Remembered frames, by label. Lives in the dev server's MEMORY on purpose:
60
+ * a baseline is only valid for the browser and the window that made it, so it
61
+ * should die with the server rather than linger in the project as a file
62
+ * nobody asked for.
63
+ */
64
+ store?: Map<string, FrameSignature>;
51
65
  }
52
66
  /**
53
67
  * Wire `/__incanto/ping` and `/__incanto/frame` onto a dev server.