incanto 0.44.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.
package/dist/3d.d.ts CHANGED
@@ -1796,6 +1796,8 @@ declare class ModelInstance3D extends Node3D {
1796
1796
  private entry;
1797
1797
  private mountedRef;
1798
1798
  private fittedHeight;
1799
+ /** A skinned model waiting for a posed skeleton it can be measured against. */
1800
+ private fitPending;
1799
1801
  private fitGroup;
1800
1802
  /**
1801
1803
  * @internal The mounted model, or null while the asset is still loading.
@@ -1855,6 +1857,16 @@ declare class ModelInstance3D extends Node3D {
1855
1857
  availableAnimations(): string[];
1856
1858
  /** Called by the 3D sync pass with the renderer's asset store. */
1857
1859
  _syncModel(assets: AssetStore3D): void;
1860
+ /**
1861
+ * Scale the model so it stands `targetHeight` tall, measured ONCE.
1862
+ *
1863
+ * Once, deliberately: the measurement reflects the pose the model happens to
1864
+ * be in, and re-running it every frame would let a walk cycle resize the
1865
+ * character mid-stride. In practice the pose barely matters — across a whole
1866
+ * idle clip this rig's height moves 0.5% — but "barely" is not "never", and
1867
+ * a size that breathes is worse than one that is half a percent off.
1868
+ */
1869
+ private applyFit;
1858
1870
  /** Apply per-instance material mods — `tint` (multiplied into colour) and the
1859
1871
  * `metalness`/`roughness` overrides — by cloning the captured originals
1860
1872
  * (never mutates the shared source), disposing prior clones on change. */
package/dist/3d.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import { A as Water3D, F as PhysicsBody3D, I as RigidBody3D, L as StaticBody3D, M as WaterCutout3D, N as Area3D, P as CharacterBody3D, R as Node3D, V as WATER_MAX_RIPPLES, j as WATER_CUTOUT_MAX } from "./gameplay-BQOeAid6.js";
2
- import { A as Terrain3D, B as keyboardIntensity, C as resolveFlowerDensity, D as BoneLookAt3D, E as Camera3D, F as InstancedMesh3D, G as acquireTexture, H as rigPose, I as MeshInstance3D, M as TERRAIN_THEMES, N as terrainThemeLayers, O as BoneAttachment3D, P as Joint3D, R as QUARTER_PITCH, S as Flowers3D, T as CharacterController3D, U as TextureCache3D, V as movementState, W as acquireOwnTexture, _ as LoftMesh3D, a as Tree3D, b as Foliage3D, c as buildRiverRings, d as riverCarveChannels, f as riverStepFor, g as ModelInstance3D, h as Particles3D, i as VoxelGrid3D, j as DEFAULT_TERRAIN_TEXTURE_BASE, k as Billboard3D, l as findRiverCoverageGaps, m as traceDownhillPath, n as registerNodes3D, o as Trail3D, p as smoothCourse, r as VOXEL_PALETTE, s as River3D, u as projectToRiver, v as DirectionalLight3D, w as FLOWER_VARIETIES, x as DENSITY_PRESETS, y as OmniLight3D, z as cameraRelative } from "./environment-presets-Ds5kXLoF.js";
3
- import { a as Environment3D, c as parseEnvironment3D, d as AssetStore3D, i as syncTree, l as sunDirectionFromElevationAzimuth, o as setEnvironment3D, r as Renderer3D, s as horizonColorFromSky, t as createGame3D, u as sunDirectionFromSky } from "./create-game-D90pyPMx.js";
2
+ import { A as Terrain3D, B as keyboardIntensity, C as resolveFlowerDensity, D as BoneLookAt3D, E as Camera3D, F as InstancedMesh3D, G as acquireTexture, H as rigPose, I as MeshInstance3D, M as TERRAIN_THEMES, N as terrainThemeLayers, O as BoneAttachment3D, P as Joint3D, R as QUARTER_PITCH, S as Flowers3D, T as CharacterController3D, U as TextureCache3D, V as movementState, W as acquireOwnTexture, _ as LoftMesh3D, a as Tree3D, b as Foliage3D, c as buildRiverRings, d as riverCarveChannels, f as riverStepFor, g as ModelInstance3D, h as Particles3D, i as VoxelGrid3D, j as DEFAULT_TERRAIN_TEXTURE_BASE, k as Billboard3D, l as findRiverCoverageGaps, m as traceDownhillPath, n as registerNodes3D, o as Trail3D, p as smoothCourse, r as VOXEL_PALETTE, s as River3D, u as projectToRiver, v as DirectionalLight3D, w as FLOWER_VARIETIES, x as DENSITY_PRESETS, y as OmniLight3D, z as cameraRelative } from "./environment-presets-2GgTlPt1.js";
3
+ import { a as Environment3D, c as parseEnvironment3D, d as AssetStore3D, i as syncTree, l as sunDirectionFromElevationAzimuth, o as setEnvironment3D, r as Renderer3D, s as horizonColorFromSky, t as createGame3D, u as sunDirectionFromSky } from "./create-game-CCFUOWfb.js";
4
4
  import { a as frameStats, i as frameSignature, n as diffSignatures, o as frameText, r as diffText, t as SIGNATURE_GRID } from "./frame-report-Lr3VO24R.js";
5
5
  import { n as splatWeights, t as buildHeightmap } from "./heightmap-CRK0M4jT.js";
6
- import { n as enablePhysics3D, t as Physics3D } from "./physics-3d-CLPFv99o.js";
6
+ import { n as enablePhysics3D, t as Physics3D } from "./physics-3d-nLI_8bUR.js";
7
7
  //#region src/3d/terrain-nav.ts
8
8
  function buildTerrainNav(terrain, opts) {
9
9
  const cellSize = opts?.cellSize ?? 2;
@@ -6,9 +6,9 @@ import { i as resolveRendering, n as attachTouchControls } from "./touch-BoNg_Mn
6
6
  import { a as openBundledEditor, i as devServerLibrary, n as pauseWhenHidden, o as poseFromRenderer, r as crossFade, t as teardown } from "./teardown-yePMOE1K.js";
7
7
  import { B as createCausticsQuad, F as PhysicsBody3D, R as Node3D, n as registerGameplayBehaviors } from "./gameplay-BQOeAid6.js";
8
8
  import { t as debugSources } from "./debug-draw-BM3DsvtT.js";
9
- import { E as Camera3D, U as TextureCache3D, g as ModelInstance3D, n as registerNodes3D, t as resolveEnvironmentHdri, v as DirectionalLight3D } from "./environment-presets-Ds5kXLoF.js";
9
+ import { E as Camera3D, U as TextureCache3D, g as ModelInstance3D, n as registerNodes3D, t as resolveEnvironmentHdri, v as DirectionalLight3D } from "./environment-presets-2GgTlPt1.js";
10
10
  import { a as frameStats, i as frameSignature } from "./frame-report-Lr3VO24R.js";
11
- import { n as enablePhysics3D } from "./physics-3d-CLPFv99o.js";
11
+ import { n as enablePhysics3D } from "./physics-3d-nLI_8bUR.js";
12
12
  import { ACESFilmicToneMapping, AmbientLight, Box3, BufferAttribute, BufferGeometry, Color, DepthTexture, EquirectangularReflectionMapping, FloatType, Fog, HalfFloatType, LineBasicMaterial, LineSegments, Matrix4, Mesh, PCFShadowMap, PMREMGenerator, PerspectiveCamera, PlaneGeometry, Quaternion, Raycaster, Scene, ShaderMaterial, Vector2, Vector3, WebGLRenderTarget, WebGLRenderer } from "three";
13
13
  import { VRMLoaderPlugin, VRMUtils } from "@pixiv/three-vrm";
14
14
  import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
package/dist/debug.d.ts CHANGED
@@ -71,6 +71,9 @@ declare class DebugOverlay {
71
71
  private readonly cleanups;
72
72
  private readonly menuButton;
73
73
  private dropdown;
74
+ private dismissDropdown;
75
+ /** Where the ☰ button sits, in container CSS px. Drag moves it; see bindMenuDrag. */
76
+ private menuPos;
74
77
  private selected;
75
78
  /** Explorer subtrees the user collapsed (nodes keep identity across frames). */
76
79
  private readonly collapsedFlags;
@@ -121,6 +124,18 @@ declare class DebugOverlay {
121
124
  dispose(): void;
122
125
  /** `✓ Colliders · selected` — check for on, suffix for the collider mode. */
123
126
  private menuLabel;
127
+ /**
128
+ * The ☰ button is draggable, because top-left is exactly where a game puts
129
+ * its own menu and a debug chip pinned on top of it is a debug chip you
130
+ * cannot use.
131
+ *
132
+ * Drag and click share one pointer, so they are told apart by distance: under
133
+ * the threshold the press was a click and the menu opens, over it the press
134
+ * was a grab and the menu must NOT open under the finger that just let go.
135
+ */
136
+ private bindMenuDrag;
137
+ private moveMenuButton;
138
+ private closeDropdown;
124
139
  private toggleDropdown;
125
140
  private openStatsChip;
126
141
  private renderStats;
package/dist/debug.js CHANGED
@@ -193,6 +193,59 @@ function clampPanelRect(x, y, w, h, viewW, viewH, minW = 180, minH = 120) {
193
193
  h: ch
194
194
  };
195
195
  }
196
+ /**
197
+ * Where a dropdown goes relative to the thing that opened it.
198
+ *
199
+ * Left-aligned under the anchor, flipped above it when there is no room below,
200
+ * and never off the side — a menu that opens past the edge of the window is a
201
+ * menu with items nobody can reach. The anchor moves (the ☰ button is
202
+ * draggable), so this cannot be the constant it used to be.
203
+ */
204
+ function anchorDropdown(anchor, menuW, menuH, viewW, viewH, gap = 4) {
205
+ const x = Math.min(Math.max(0, anchor.x), Math.max(0, viewW - menuW));
206
+ const below = anchor.y + anchor.h + gap;
207
+ const above = anchor.y - menuH - gap;
208
+ if (below + menuH <= viewH) return {
209
+ x,
210
+ y: below
211
+ };
212
+ if (above >= 0) return {
213
+ x,
214
+ y: above
215
+ };
216
+ return {
217
+ x,
218
+ y: Math.min(Math.max(0, below), Math.max(0, viewH - menuH))
219
+ };
220
+ }
221
+ /**
222
+ * Make a transient thing dismissible: Esc, or a press anywhere outside it.
223
+ *
224
+ * Returns the disposer. Attaching this is how "you can always get out of it"
225
+ * stops being something each caller has to remember — the overlay had a
226
+ * dismissible capture layer and a dropdown you could only close by choosing
227
+ * something from it.
228
+ */
229
+ function dismissOn(doc, inside, close) {
230
+ const onKey = (event) => {
231
+ if (event.key === "Escape") close();
232
+ };
233
+ const onDown = (event) => {
234
+ const target = event.target;
235
+ if (!target) return;
236
+ for (const el of inside) {
237
+ if (!el) continue;
238
+ if (el === target || el.contains?.(target)) return;
239
+ }
240
+ close();
241
+ };
242
+ doc.addEventListener?.("keydown", onKey);
243
+ doc.addEventListener?.("pointerdown", onDown);
244
+ return () => {
245
+ doc.removeEventListener?.("keydown", onKey);
246
+ doc.removeEventListener?.("pointerdown", onDown);
247
+ };
248
+ }
196
249
  function applyStyle(el, rules) {
197
250
  for (const [key, value] of Object.entries(rules)) el.style[key] = value;
198
251
  }
@@ -658,6 +711,8 @@ function isPlainObject(value) {
658
711
  /** The slider's ceiling. Beyond this the number box is the tool — a 0..8 slider
659
712
  * makes the 0.1-to-1 range, where slow-motion debugging actually lives, unusable. */
660
713
  const TIME_SLIDER_MAX = 2;
714
+ /** Under this much movement the press was a click, not a grab. */
715
+ const MENU_DRAG_THRESHOLD = 4;
661
716
  /** Attach the overlay; null when no DOM is available (headless). */
662
717
  function attachDebugOverlay(engine, opts = {}) {
663
718
  const doc = opts.doc ?? (typeof document !== "undefined" ? document : null);
@@ -685,6 +740,12 @@ var DebugOverlay = class {
685
740
  cleanups = [];
686
741
  menuButton;
687
742
  dropdown = null;
743
+ dismissDropdown = null;
744
+ /** Where the ☰ button sits, in container CSS px. Drag moves it; see bindMenuDrag. */
745
+ menuPos = {
746
+ x: 8,
747
+ y: 8
748
+ };
688
749
  selected = null;
689
750
  /** Explorer subtrees the user collapsed (nodes keep identity across frames). */
690
751
  collapsedFlags = /* @__PURE__ */ new Map();
@@ -737,7 +798,7 @@ var DebugOverlay = class {
737
798
  zIndex: "50",
738
799
  pointerEvents: "auto"
739
800
  });
740
- this.menuButton.addEventListener("click", () => this.toggleDropdown());
801
+ this.bindMenuDrag();
741
802
  if (!container.style.position) container.style.position = "relative";
742
803
  container.appendChild(this.menuButton);
743
804
  this.cleanups.push(engine.log.added.connect((entry) => {
@@ -921,8 +982,7 @@ var DebugOverlay = class {
921
982
  for (const cleanup of this.cleanups) cleanup();
922
983
  for (const id of [...this.panels.keys()]) this.close(id);
923
984
  this.close("stats");
924
- this.dropdown?.remove();
925
- this.dropdown = null;
985
+ this.closeDropdown();
926
986
  this.menuButton.remove();
927
987
  }
928
988
  /** `✓ Colliders · selected` — check for on, suffix for the collider mode. */
@@ -930,17 +990,79 @@ var DebugOverlay = class {
930
990
  const suffix = id === "colliders" && this.colliderMode !== "off" ? ` · ${this.colliderMode}` : "";
931
991
  return `${this.isOpen(id) ? "✓ " : ""}${label}${suffix}`;
932
992
  }
993
+ /**
994
+ * The ☰ button is draggable, because top-left is exactly where a game puts
995
+ * its own menu and a debug chip pinned on top of it is a debug chip you
996
+ * cannot use.
997
+ *
998
+ * Drag and click share one pointer, so they are told apart by distance: under
999
+ * the threshold the press was a click and the menu opens, over it the press
1000
+ * was a grab and the menu must NOT open under the finger that just let go.
1001
+ */
1002
+ bindMenuDrag() {
1003
+ let start = null;
1004
+ let dragged = false;
1005
+ this.menuButton.addEventListener("pointerdown", (event) => {
1006
+ const e = event;
1007
+ start = {
1008
+ x: e.clientX ?? 0,
1009
+ y: e.clientY ?? 0,
1010
+ bx: this.menuPos.x,
1011
+ by: this.menuPos.y
1012
+ };
1013
+ dragged = false;
1014
+ if (e.pointerId !== void 0) this.menuButton.setPointerCapture?.(e.pointerId);
1015
+ });
1016
+ this.menuButton.addEventListener("pointermove", (event) => {
1017
+ if (!start) return;
1018
+ const e = event;
1019
+ const dx = (e.clientX ?? 0) - start.x;
1020
+ const dy = (e.clientY ?? 0) - start.y;
1021
+ if (!dragged && Math.hypot(dx, dy) < MENU_DRAG_THRESHOLD) return;
1022
+ if (!dragged) {
1023
+ dragged = true;
1024
+ this.closeDropdown();
1025
+ applyStyle(this.menuButton, { cursor: "grabbing" });
1026
+ }
1027
+ this.moveMenuButton(start.bx + dx, start.by + dy);
1028
+ });
1029
+ const release = () => {
1030
+ if (!start) return;
1031
+ const wasDrag = dragged;
1032
+ start = null;
1033
+ dragged = false;
1034
+ applyStyle(this.menuButton, { cursor: "pointer" });
1035
+ if (!wasDrag) this.toggleDropdown();
1036
+ };
1037
+ this.menuButton.addEventListener("pointerup", release);
1038
+ this.menuButton.addEventListener("pointercancel", release);
1039
+ }
1040
+ moveMenuButton(x, y) {
1041
+ const el = this.menuButton;
1042
+ const box = clampPanelRect(x, y, el.offsetWidth ?? 0, el.offsetHeight ?? 0, this.container.clientWidth || 0, this.container.clientHeight || 0, 0, 0);
1043
+ this.menuPos = {
1044
+ x: box.x,
1045
+ y: box.y
1046
+ };
1047
+ applyStyle(this.menuButton, {
1048
+ left: `${box.x}px`,
1049
+ top: `${box.y}px`
1050
+ });
1051
+ }
1052
+ closeDropdown() {
1053
+ this.dismissDropdown?.();
1054
+ this.dismissDropdown = null;
1055
+ this.dropdown?.remove();
1056
+ this.dropdown = null;
1057
+ }
933
1058
  toggleDropdown() {
934
1059
  if (this.dropdown) {
935
- this.dropdown.remove();
936
- this.dropdown = null;
1060
+ this.closeDropdown();
937
1061
  return;
938
1062
  }
939
1063
  const menu = this.doc.createElement("div");
940
1064
  applyStyle(menu, {
941
1065
  position: "absolute",
942
- top: "34px",
943
- left: "8px",
944
1066
  background: "rgba(18,20,26,0.95)",
945
1067
  border: "1px solid rgba(255,255,255,0.18)",
946
1068
  borderRadius: "6px",
@@ -973,8 +1095,7 @@ var DebugOverlay = class {
973
1095
  item.textContent = this.menuLabel(id, label);
974
1096
  return;
975
1097
  }
976
- this.dropdown?.remove();
977
- this.dropdown = null;
1098
+ this.closeDropdown();
978
1099
  });
979
1100
  menu.appendChild(item);
980
1101
  }
@@ -989,14 +1110,26 @@ var DebugOverlay = class {
989
1110
  color: "rgba(158,232,220,0.95)"
990
1111
  });
991
1112
  item.addEventListener("click", () => {
992
- this.dropdown?.remove();
993
- this.dropdown = null;
1113
+ this.closeDropdown();
994
1114
  action.run();
995
1115
  });
996
1116
  menu.appendChild(item);
997
1117
  }
998
1118
  this.container.appendChild(menu);
999
1119
  this.dropdown = menu;
1120
+ const el = menu;
1121
+ const btn = this.menuButton;
1122
+ const at = anchorDropdown({
1123
+ x: this.menuPos.x,
1124
+ y: this.menuPos.y,
1125
+ w: btn.offsetWidth ?? 0,
1126
+ h: btn.offsetHeight ?? 26
1127
+ }, el.offsetWidth ?? 0, el.offsetHeight ?? 0, this.container.clientWidth || 0, this.container.clientHeight || 0);
1128
+ applyStyle(menu, {
1129
+ left: `${at.x}px`,
1130
+ top: `${at.y}px`
1131
+ });
1132
+ this.dismissDropdown = dismissOn(this.doc, [menu, this.menuButton], () => this.closeDropdown());
1000
1133
  }
1001
1134
  openStatsChip() {
1002
1135
  if (this.statsChip) return;
@@ -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
  }
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-B3n06SsL.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-D90pyPMx.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.44.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.js CHANGED
@@ -1,6 +1,6 @@
1
- import { t as VERSION } from "./src-B3n06SsL.js";
1
+ import { t as VERSION } from "./src-CV_uN7j4.js";
2
2
  import { n as diffSignatures } from "./frame-report-Lr3VO24R.js";
3
- import { s as validateScene } from "./test-BMgiiD5i.js";
3
+ import { s as validateScene } from "./test-R8JCuDlv.js";
4
4
  import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
5
5
  import { basename, dirname, join, normalize, relative, resolve, sep } from "node:path";
6
6
  //#region src/vite/frame-endpoint.ts