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.
@@ -25,20 +25,46 @@ const PKG = join(dirname(fileURLToPath(import.meta.url)), '..');
25
25
  const { listeningPorts, parseProcNetTcp } = await import(
26
26
  pathToFileURL(join(PKG, 'dist', 'vite.js')).href
27
27
  );
28
- const { frameText } = await import(pathToFileURL(join(PKG, 'dist', '3d.js')).href);
28
+ const { frameText, diffText } = await import(pathToFileURL(join(PKG, 'dist', '3d.js')).href);
29
29
 
30
30
  const args = process.argv.slice(2);
31
31
  const asJson = args.includes('--json');
32
- const gridArg = args[args.indexOf('--grid') + 1];
32
+ /** `--flag` with an optional value: `--remember` and `--remember shader` both. */
33
+ const flag = (name) => {
34
+ const i = args.indexOf(name);
35
+ if (i < 0) return null;
36
+ const next = args[i + 1];
37
+ return next && !next.startsWith('-') ? next : '';
38
+ };
39
+ const gridArg = flag('--grid');
40
+ const remember = flag('--remember');
41
+ const diff = flag('--diff');
42
+ const threshold = flag('--threshold');
33
43
  if (args.includes('--help') || args.includes('-h')) {
34
44
  console.error(`Usage: incanto-frame [options]
35
45
 
36
- --json the full report as JSON instead of prose
37
- --grid WxH cells across and down (default 16x9)
38
- --port N skip discovery and use this port
46
+ --json the full report as JSON instead of prose
47
+ --grid WxH cells across and down (default 16x9)
48
+ --port N skip discovery and use this port
49
+
50
+ --remember [L] keep this frame as the baseline named L (default "last")
51
+ --diff [L] say what changed since baseline L
52
+ --threshold N a cell has changed when it moves this far, 0..255 (default 3)
39
53
 
40
54
  Run your game's dev server and open the preview page first: the pixels live in
41
- the browser, not in the dev server.`);
55
+ the browser, not in the dev server.
56
+
57
+ The report of ONE frame has a floor: a one-pixel seam is a fraction of a level
58
+ once a cell is averaged, and nothing can tell it apart from a thin rope in the
59
+ scene. Two frames of the SAME view can:
60
+
61
+ incanto-frame --remember # before your change
62
+ ...edit the shader...
63
+ incanto-frame --diff # "a vertical band at x 640..660"
64
+
65
+ Baselines live in the dev server's memory and die with it — no file is written.
66
+ Pause the game first (debug ☰ → Time → Pause) or you are measuring time
67
+ passing, not your change.`);
42
68
  process.exit(0);
43
69
  }
44
70
 
@@ -110,7 +136,13 @@ if (!found) {
110
136
  process.exit(1);
111
137
  }
112
138
 
113
- const res = await fetch(`http://${found.host}:${found.port}/__incanto/frame`, {
139
+ const params = new URLSearchParams();
140
+ if (gridArg !== null) params.set('grid', gridArg);
141
+ if (remember !== null) params.set('remember', remember);
142
+ if (diff !== null) params.set('diff', diff);
143
+ if (threshold) params.set('threshold', threshold);
144
+ const q = params.toString();
145
+ const res = await fetch(`http://${found.host}:${found.port}/__incanto/frame${q ? `?${q}` : ''}`, {
114
146
  signal: AbortSignal.timeout(10_000),
115
147
  });
116
148
  const body = await res.json().catch(() => null);
@@ -134,11 +166,23 @@ if (report.error) {
134
166
  }
135
167
 
136
168
  if (asJson) {
137
- process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
169
+ process.stdout.write(`${JSON.stringify(body, null, 2)}\n`);
138
170
  } else {
139
- process.stdout.write(`${frameText(report)}\n`);
171
+ const lines = [frameText(report)];
172
+ if (body.diffError === 'no-baseline') {
173
+ lines.push(
174
+ `no baseline named "${diff || 'last'}" — run \`incanto-frame --remember${diff ? ` ${diff}` : ''}\` first,\n` +
175
+ ' then again with --diff after your change. The baseline lives in the dev\n' +
176
+ ' server, so restarting it forgets.',
177
+ );
178
+ } else if (body.diffError) {
179
+ lines.push(`cannot compare: ${body.diffError}`);
180
+ }
181
+ if (body.diff) lines.push(diffText(body.diff));
182
+ if (body.remembered) lines.push(`remembered as "${body.remembered}"`);
183
+ process.stdout.write(`${lines.join('\n')}\n`);
140
184
  }
141
- process.exit(report.black ? 1 : 0);
185
+ // A missing baseline is a usage error, not a picture: fail so a script notices.
186
+ process.exit(report.black || body.diffError ? 1 : 0);
142
187
 
143
188
  void parseProcNetTcp; // re-exported for tests; referenced so bundlers keep it
144
- void gridArg;
package/dist/3d.d.ts CHANGED
@@ -3,6 +3,7 @@ import { n as DiagnosticSink, t as EditorSwitchOptions } from "./editor-switch-D
3
3
  import { i as SceneJson, s as JsonObject } from "./schema-CFeioQRE.js";
4
4
  import { t as LoadSceneOptions } from "./loader-CeyU_bm1.js";
5
5
  import { t as ParticleSim } from "./particle-sim-BzJ1yxoE.js";
6
+ import { a as GridCell, c as diffText, d as frameText, i as FrameStatsOptions, l as frameSignature, n as FrameSignature, o as SIGNATURE_GRID, r as FrameStats, s as diffSignatures, t as FrameDiff, u as frameStats } from "./frame-report-Ct8XgsmV.js";
6
7
  import { n as PathGrid, s as SpatialPose } from "./pathfinding-C49JSNNq.js";
7
8
  import { AnimationClip, AnimationMixer, BufferGeometry, Color, DirectionalLight, Group, InstancedMesh, Mesh, MeshPhysicalMaterial, Object3D, PerspectiveCamera, Scene, ShaderMaterial, Texture, Vector3, WebGLRenderer } from "three";
8
9
  import { VRM } from "@pixiv/three-vrm";
@@ -190,45 +191,6 @@ declare function keyboardIntensity(moving: boolean, sprinting: boolean): number;
190
191
  /** intensity → movement state (the animation driver). */
191
192
  declare function movementState(grounded: boolean, intensity: number): "idle" | "walk" | "run" | "fastRun" | "airborne";
192
193
  //#endregion
193
- //#region src/3d/frame-report.d.ts
194
- /**
195
- * What the frame looks like, as something worth saying.
196
- *
197
- * The pixels are not the deliverable. A 1280x720 frame is 2.8 million numbers:
198
- * unreadable by a person, unaffordable for a model (roughly a thousand tokens
199
- * as an image, roughly a million as text). The deliverable is the JUDGEMENT —
200
- * black screen, one flat colour, washed out — and the numbers behind it, small
201
- * enough to print and compare.
202
- *
203
- * Same principle as `framing`'s "lit by: NOTHING": a report is a sentence
204
- * before it is a number, because a number needs a reader who already knows what
205
- * to compare it against.
206
- *
207
- * Pure arithmetic over an RGBA buffer — no three, no DOM, no GPU. Whoever has
208
- * the pixels calls this; in practice that is the browser holding the canvas.
209
- */
210
- /** Per-cell mean colour, `[r, g, b]` 0..255. */
211
- type GridCell = [number, number, number];
212
- interface FrameStats {
213
- width: number;
214
- height: number;
215
- /** Every pixel below the black threshold — the render produced nothing. */
216
- black: boolean;
217
- /** One colour everywhere: a clear-colour fill, or a camera inside geometry. */
218
- uniform: boolean;
219
- /** 0..1 mean relative luminance. */
220
- meanLuma: number;
221
- /** Coarse mean colour per cell, rows top to bottom. */
222
- grid: GridCell[][];
223
- }
224
- interface FrameStatsOptions {
225
- /** Cells across and down. Default 16x9 — 144 cells, printable. */
226
- grid?: [number, number];
227
- }
228
- declare function frameStats(pixels: Uint8Array | Uint8ClampedArray, width: number, height: number, opts?: FrameStatsOptions): FrameStats;
229
- /** The report a person or an agent reads. Verdict first, numbers after. */
230
- declare function frameText(stats: FrameStats): string;
231
- //#endregion
232
194
  //#region src/3d/nodes/node-3d.d.ts
233
195
  /**
234
196
  * Per-frame hand-off from `Renderer3D` to nodes that need the LIVE WebGL
@@ -1834,6 +1796,8 @@ declare class ModelInstance3D extends Node3D {
1834
1796
  private entry;
1835
1797
  private mountedRef;
1836
1798
  private fittedHeight;
1799
+ /** A skinned model waiting for a posed skeleton it can be measured against. */
1800
+ private fitPending;
1837
1801
  private fitGroup;
1838
1802
  /**
1839
1803
  * @internal The mounted model, or null while the asset is still loading.
@@ -1893,6 +1857,16 @@ declare class ModelInstance3D extends Node3D {
1893
1857
  availableAnimations(): string[];
1894
1858
  /** Called by the 3D sync pass with the renderer's asset store. */
1895
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;
1896
1870
  /** Apply per-instance material mods — `tint` (multiplied into colour) and the
1897
1871
  * `metalness`/`roughness` overrides — by cloning the captured originals
1898
1872
  * (never mutates the shared source), disposing prior clones on change. */
@@ -3647,4 +3621,4 @@ declare function createNavDebugNode(nav: TerrainNav, opts?: {
3647
3621
  name?: string;
3648
3622
  }): InstancedMesh3D;
3649
3623
  //#endregion
3650
- export { Area3D, AssetStore3D, type BedSampler, Billboard3D, type BillboardGroupMode, BoneAttachment3D, BoneLookAt3D, Camera3D, CharacterBody3D, CharacterController3D, type CreateGame3DOptions, DEFAULT_TERRAIN_TEXTURE_BASE, DirectionalLight3D, type DownhillTraceOptions, Environment3D, type Environment3DConfig, DENSITY_PRESETS as FLOWER_DENSITY_PRESETS, FLOWER_VARIETIES, type FlowerVariety, Flowers3D, type FogEnvironment, Foliage3D, type FoliageKind, type FoliageStyle, type FrameStats, type FrameStatsOptions, type Game3D, type GridCell, type HeightSampler, type Heightmap, type HeightmapOptions, InstancedMesh3D, Joint3D, type JointType3D, LoftMesh3D, type LoftSection, MeshInstance3D, type MeshKind, type MeshMaterialProps, type ModelEntry, ModelInstance3D, Node3D, OmniLight3D, Particles3D, Physics3D, type Physics3DOptions, PhysicsBody3D, QUARTER_PITCH, type RenderContext3D, type RenderHook3D, Renderer3D, type Renderer3DOptions, type RigView, RigidBody3D, type Ripple, River3D, type RiverCarveOptions, type RiverCoverageGap, type RiverHit, type RiverRing, type RiverRingOptions, type ShadowsEnvironment, type SkyEnvironment, StaticBody3D, type SunConsumer3D, type SyncOptions, type SyncResult, TERRAIN_THEMES, Terrain3D, type TerrainLayer, type TerrainNav, type TerrainNavOptions, type TerrainTheme, TextureCache3D, type TextureSpec, Trail3D, Tree3D, type TreeTier, type TreeType, VOXEL_PALETTE, type VoxelBlock, VoxelGrid3D, WATER_CUTOUT_MAX, WATER_MAX_RIPPLES, Water3D, WaterCutout3D, acquireOwnTexture, acquireTexture, buildHeightmap, buildRiverRings, buildTerrainNav, cameraRelative, createGame3D, createNavDebugNode, enablePhysics3D, findRiverCoverageGaps, frameStats, frameText, horizonColorFromSky, keyboardIntensity, movementState, parseEnvironment3D, projectToRiver, registerNodes3D, resolveFlowerDensity, rigPose, riverCarveChannels, riverStepFor, setEnvironment3D, smoothCourse, splatWeights, sunDirectionFromElevationAzimuth, sunDirectionFromSky, syncTree, terrainThemeLayers, traceDownhillPath };
3624
+ export { Area3D, AssetStore3D, type BedSampler, Billboard3D, type BillboardGroupMode, BoneAttachment3D, BoneLookAt3D, Camera3D, CharacterBody3D, CharacterController3D, type CreateGame3DOptions, DEFAULT_TERRAIN_TEXTURE_BASE, DirectionalLight3D, type DownhillTraceOptions, Environment3D, type Environment3DConfig, DENSITY_PRESETS as FLOWER_DENSITY_PRESETS, FLOWER_VARIETIES, type FlowerVariety, Flowers3D, type FogEnvironment, Foliage3D, type FoliageKind, type FoliageStyle, type FrameDiff, type FrameSignature, type FrameStats, type FrameStatsOptions, type Game3D, type GridCell, type HeightSampler, type Heightmap, type HeightmapOptions, InstancedMesh3D, Joint3D, type JointType3D, LoftMesh3D, type LoftSection, MeshInstance3D, type MeshKind, type MeshMaterialProps, type ModelEntry, ModelInstance3D, Node3D, OmniLight3D, Particles3D, Physics3D, type Physics3DOptions, PhysicsBody3D, QUARTER_PITCH, type RenderContext3D, type RenderHook3D, Renderer3D, type Renderer3DOptions, type RigView, RigidBody3D, type Ripple, River3D, type RiverCarveOptions, type RiverCoverageGap, type RiverHit, type RiverRing, type RiverRingOptions, SIGNATURE_GRID, type ShadowsEnvironment, type SkyEnvironment, StaticBody3D, type SunConsumer3D, type SyncOptions, type SyncResult, TERRAIN_THEMES, Terrain3D, type TerrainLayer, type TerrainNav, type TerrainNavOptions, type TerrainTheme, TextureCache3D, type TextureSpec, Trail3D, Tree3D, type TreeTier, type TreeType, VOXEL_PALETTE, type VoxelBlock, VoxelGrid3D, WATER_CUTOUT_MAX, WATER_MAX_RIPPLES, Water3D, WaterCutout3D, acquireOwnTexture, acquireTexture, buildHeightmap, buildRiverRings, buildTerrainNav, cameraRelative, createGame3D, createNavDebugNode, diffSignatures, diffText, enablePhysics3D, findRiverCoverageGaps, frameSignature, frameStats, frameText, horizonColorFromSky, keyboardIntensity, movementState, parseEnvironment3D, projectToRiver, registerNodes3D, resolveFlowerDensity, rigPose, riverCarveChannels, riverStepFor, setEnvironment3D, smoothCourse, splatWeights, sunDirectionFromElevationAzimuth, sunDirectionFromSky, syncTree, terrainThemeLayers, traceDownhillPath };
package/dist/3d.js CHANGED
@@ -1,8 +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 setEnvironment3D, d as sunDirectionFromElevationAzimuth, f as sunDirectionFromSky, i as syncTree, l as horizonColorFromSky, o as frameStats, p as AssetStore3D, r as Renderer3D, s as frameText, t as createGame3D, u as parseEnvironment3D } from "./create-game-B3vBWgVD.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
+ 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";
4
5
  import { n as splatWeights, t as buildHeightmap } from "./heightmap-CRK0M4jT.js";
5
- 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";
6
7
  //#region src/3d/terrain-nav.ts
7
8
  function buildTerrainNav(terrain, opts) {
8
9
  const cellSize = opts?.cellSize ?? 2;
@@ -114,4 +115,4 @@ function createNavDebugNode(nav, opts) {
114
115
  return node;
115
116
  }
116
117
  //#endregion
117
- export { Area3D, AssetStore3D, Billboard3D, BoneAttachment3D, BoneLookAt3D, Camera3D, CharacterBody3D, CharacterController3D, DEFAULT_TERRAIN_TEXTURE_BASE, DirectionalLight3D, Environment3D, DENSITY_PRESETS as FLOWER_DENSITY_PRESETS, FLOWER_VARIETIES, Flowers3D, Foliage3D, InstancedMesh3D, Joint3D, LoftMesh3D, MeshInstance3D, ModelInstance3D, Node3D, OmniLight3D, Particles3D, Physics3D, PhysicsBody3D, QUARTER_PITCH, Renderer3D, RigidBody3D, River3D, StaticBody3D, TERRAIN_THEMES, Terrain3D, TextureCache3D, Trail3D, Tree3D, VOXEL_PALETTE, VoxelGrid3D, WATER_CUTOUT_MAX, WATER_MAX_RIPPLES, Water3D, WaterCutout3D, acquireOwnTexture, acquireTexture, buildHeightmap, buildRiverRings, buildTerrainNav, cameraRelative, createGame3D, createNavDebugNode, enablePhysics3D, findRiverCoverageGaps, frameStats, frameText, horizonColorFromSky, keyboardIntensity, movementState, parseEnvironment3D, projectToRiver, registerNodes3D, resolveFlowerDensity, rigPose, riverCarveChannels, riverStepFor, setEnvironment3D, smoothCourse, splatWeights, sunDirectionFromElevationAzimuth, sunDirectionFromSky, syncTree, terrainThemeLayers, traceDownhillPath };
118
+ export { Area3D, AssetStore3D, Billboard3D, BoneAttachment3D, BoneLookAt3D, Camera3D, CharacterBody3D, CharacterController3D, DEFAULT_TERRAIN_TEXTURE_BASE, DirectionalLight3D, Environment3D, DENSITY_PRESETS as FLOWER_DENSITY_PRESETS, FLOWER_VARIETIES, Flowers3D, Foliage3D, InstancedMesh3D, Joint3D, LoftMesh3D, MeshInstance3D, ModelInstance3D, Node3D, OmniLight3D, Particles3D, Physics3D, PhysicsBody3D, QUARTER_PITCH, Renderer3D, RigidBody3D, River3D, SIGNATURE_GRID, StaticBody3D, TERRAIN_THEMES, Terrain3D, TextureCache3D, Trail3D, Tree3D, VOXEL_PALETTE, VoxelGrid3D, WATER_CUTOUT_MAX, WATER_MAX_RIPPLES, Water3D, WaterCutout3D, acquireOwnTexture, acquireTexture, buildHeightmap, buildRiverRings, buildTerrainNav, cameraRelative, createGame3D, createNavDebugNode, diffSignatures, diffText, enablePhysics3D, findRiverCoverageGaps, frameSignature, frameStats, frameText, horizonColorFromSky, keyboardIntensity, movementState, parseEnvironment3D, projectToRiver, registerNodes3D, resolveFlowerDensity, rigPose, riverCarveChannels, riverStepFor, setEnvironment3D, smoothCourse, splatWeights, sunDirectionFromElevationAzimuth, sunDirectionFromSky, syncTree, terrainThemeLayers, traceDownhillPath };
@@ -6,8 +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";
10
- import { n as enablePhysics3D } from "./physics-3d-CLPFv99o.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
+ import { a as frameStats, i as frameSignature } from "./frame-report-Lr3VO24R.js";
11
+ import { n as enablePhysics3D } from "./physics-3d-nLI_8bUR.js";
11
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";
12
13
  import { VRMLoaderPlugin, VRMUtils } from "@pixiv/three-vrm";
13
14
  import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
@@ -486,76 +487,6 @@ function setEnvironment3D(engine, patch) {
486
487
  scene.environment = merged;
487
488
  }
488
489
  //#endregion
489
- //#region src/3d/frame-report.ts
490
- /** Rec. 709 relative luminance of an 8-bit triple, 0..1. */
491
- function luma(r, g, b) {
492
- return (.2126 * r + .7152 * g + .0722 * b) / 255;
493
- }
494
- /**
495
- * A pixel this dark is indistinguishable from "nothing was drawn". Not zero:
496
- * a real render of a night scene still lands a few levels above black, and
497
- * calling that a failure would make the check useless where it matters most.
498
- */
499
- const BLACK_LEVEL = .012;
500
- /** Cell-to-cell luminance range below this: the frame is one flat colour. */
501
- const UNIFORM_EPSILON = .004;
502
- function frameStats(pixels, width, height, opts = {}) {
503
- const [cols, rows] = opts.grid ?? [16, 9];
504
- const grid = [];
505
- let total = 0;
506
- let maxLuma = 0;
507
- let minLuma = 1;
508
- for (let row = 0; row < rows; row++) {
509
- const line = [];
510
- for (let col = 0; col < cols; col++) {
511
- const x0 = Math.floor(col * width / cols);
512
- const x1 = Math.max(x0 + 1, Math.floor((col + 1) * width / cols));
513
- const y0 = Math.floor(row * height / rows);
514
- const y1 = Math.max(y0 + 1, Math.floor((row + 1) * height / rows));
515
- let r = 0;
516
- let g = 0;
517
- let b = 0;
518
- let n = 0;
519
- for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) {
520
- const i = (y * width + x) * 4;
521
- r += pixels[i] ?? 0;
522
- g += pixels[i + 1] ?? 0;
523
- b += pixels[i + 2] ?? 0;
524
- n += 1;
525
- }
526
- const cell = [
527
- Math.round(r / n),
528
- Math.round(g / n),
529
- Math.round(b / n)
530
- ];
531
- const cellLuma = luma(cell[0], cell[1], cell[2]);
532
- total += cellLuma;
533
- maxLuma = Math.max(maxLuma, cellLuma);
534
- minLuma = Math.min(minLuma, cellLuma);
535
- line.push(cell);
536
- }
537
- grid.push(line);
538
- }
539
- const cells = cols * rows;
540
- const meanLuma = total / cells;
541
- return {
542
- width,
543
- height,
544
- black: maxLuma <= BLACK_LEVEL,
545
- uniform: maxLuma - minLuma < UNIFORM_EPSILON,
546
- meanLuma: Math.round(meanLuma * 1e3) / 1e3,
547
- grid
548
- };
549
- }
550
- /** The report a person or an agent reads. Verdict first, numbers after. */
551
- function frameText(stats) {
552
- const lines = [];
553
- if (stats.black) lines.push("BLACK SCREEN — nothing was drawn (no light, no camera, or nothing in view)");
554
- else if (stats.uniform) lines.push("one flat colour — the camera may be inside geometry, or only the sky is drawn");
555
- lines.push(`frame ${stats.width}×${stats.height} · luminance ${stats.meanLuma.toFixed(2)}`);
556
- return lines.join("\n");
557
- }
558
- //#endregion
559
490
  //#region src/3d/adaptive-resolution.ts
560
491
  const DEFAULTS = {
561
492
  slowMs: 20,
@@ -2475,6 +2406,7 @@ async function createGame3D(opts) {
2475
2406
  const overlay = attachDebugOverlay(engine, {
2476
2407
  container: host,
2477
2408
  statsSource: () => renderer.stats?.() ?? {},
2409
+ ...renderer.captureFrame ? { frameSource: () => captureFramePixels() } : {},
2478
2410
  ...editorSwitch ? { actions: [editorAction] } : {},
2479
2411
  ...opts._debugDoc ? { doc: opts._debugDoc } : {}
2480
2412
  });
@@ -2504,19 +2436,22 @@ async function createGame3D(opts) {
2504
2436
  cleanups.push(engine.settings.bindRenderScale((scale) => {
2505
2437
  renderer.setRenderScale?.(scale);
2506
2438
  }));
2507
- const captureReport = async (frameOpts) => {
2439
+ const captureFramePixels = () => {
2508
2440
  const capture = renderer.captureFrame;
2509
2441
  if (!capture) throw new IncantoError("TREE_VIOLATION", "this renderer cannot capture frames");
2510
- const shot = await capture.call(renderer);
2511
- return frameStats(shot.pixels, shot.width, shot.height, frameOpts);
2442
+ return capture.call(renderer);
2512
2443
  };
2513
2444
  const hot = import.meta.hot;
2514
2445
  if (hot) {
2515
2446
  const onFrameRequest = (data) => {
2516
- const id = data?.id;
2517
- captureReport().then((frameReport) => hot.send("incanto:frame-report", {
2447
+ const request = data;
2448
+ const id = request?.id;
2449
+ captureFramePixels().then((shot) => hot.send("incanto:frame-report", {
2518
2450
  id,
2519
- report: frameReport
2451
+ report: {
2452
+ ...frameStats(shot.pixels, shot.width, shot.height, { grid: request?.grid }),
2453
+ signature: frameSignature(shot.pixels, shot.width, shot.height)
2454
+ }
2520
2455
  })).catch((error) => hot.send("incanto:frame-report", {
2521
2456
  id,
2522
2457
  report: { error: error instanceof Error ? error.message : String(error) }
@@ -2618,4 +2553,4 @@ function wireAudioUnlock(engine, root, canvas) {
2618
2553
  return detachAll;
2619
2554
  }
2620
2555
  //#endregion
2621
- export { Environment3D as a, setEnvironment3D as c, sunDirectionFromElevationAzimuth as d, sunDirectionFromSky as f, syncTree as i, horizonColorFromSky as l, create_game_exports as n, frameStats as o, AssetStore3D as p, Renderer3D as r, frameText as s, createGame3D as t, parseEnvironment3D as u };
2556
+ export { Environment3D as a, parseEnvironment3D as c, AssetStore3D as d, syncTree as i, sunDirectionFromElevationAzimuth as l, create_game_exports as n, setEnvironment3D as o, Renderer3D as r, horizonColorFromSky as s, createGame3D as t, sunDirectionFromSky as u };
package/dist/debug.d.ts CHANGED
@@ -3,16 +3,21 @@ import { At as LogLevel, T as RendererStats, b as Engine } from "./behavior-62q0
3
3
  //#region src/debug/panel.d.ts
4
4
  /** Minimal document surface the overlay needs (injectable for tests). */
5
5
  interface DocumentLike {
6
+ /** Optional: present on a real document, absent in the test double. */
7
+ addEventListener?(type: string, cb: (event: unknown) => void): void;
8
+ removeEventListener?(type: string, cb: (event: unknown) => void): void;
6
9
  createElement(tag: string): HTMLElement;
7
10
  }
8
11
  //#endregion
9
12
  //#region src/debug/index.d.ts
10
13
  type DebugPanelId = "explorer" | "inspector" | "logs" | "stats" | "time";
14
+ /** Menu entries that DO something once rather than opening a panel. */
15
+ type DebugCommandId = "copyScene" | "captureRegion";
11
16
  /** Menu entries that are switches rather than panels. */
12
17
  type DebugSwitchId = "colliders";
13
18
  /** `all` is every collider in the world; `selected` follows the Explorer. */
14
19
  type ColliderMode = "off" | "all" | "selected";
15
- type DebugMenuId = DebugPanelId | DebugSwitchId;
20
+ type DebugMenuId = DebugPanelId | DebugSwitchId | DebugCommandId;
16
21
  /** An extra ☰ menu entry the host app supplies (e.g. "edit this scene"). */
17
22
  interface DebugAction {
18
23
  /** Menu label. Keep it a verb — the menu is a list of things you can do. */
@@ -37,6 +42,19 @@ interface DebugOverlayOptions {
37
42
  * renderers): the chip shows engine stats only.
38
43
  */
39
44
  statsSource?: () => Partial<RendererStats>;
45
+ /**
46
+ * The renderer's pixels, for the region capture. Wired by createGame like
47
+ * `statsSource`, because the overlay holds the engine and not the renderer.
48
+ *
49
+ * It must be `captureFrame()` and not `canvas.toDataURL()`: the drawing
50
+ * buffer is gone by the time anything outside a render can read it, and the
51
+ * image comes back black. That trap has been hit here before.
52
+ */
53
+ frameSource?: () => Promise<{
54
+ pixels: Uint8Array;
55
+ width: number;
56
+ height: number;
57
+ }>;
40
58
  /** @internal Test seam — replaces `document`. */
41
59
  doc?: DocumentLike;
42
60
  }
@@ -48,10 +66,14 @@ declare class DebugOverlay {
48
66
  private readonly doc;
49
67
  private readonly statsSource?;
50
68
  private readonly actions;
69
+ private readonly frameSource?;
51
70
  private readonly panels;
52
71
  private readonly cleanups;
53
72
  private readonly menuButton;
54
73
  private dropdown;
74
+ private dismissDropdown;
75
+ /** Where the ☰ button sits, in container CSS px. Drag moves it; see bindMenuDrag. */
76
+ private menuPos;
55
77
  private selected;
56
78
  /** Explorer subtrees the user collapsed (nodes keep identity across frames). */
57
79
  private readonly collapsedFlags;
@@ -75,7 +97,7 @@ declare class DebugOverlay {
75
97
  private timeEls;
76
98
  /** Pointer inside the inspector — it stops refreshing under your hand. */
77
99
  private hovering;
78
- constructor(engine: Engine, container: HTMLElement, doc: DocumentLike, statsSource?: (() => Partial<RendererStats>) | undefined, actions?: DebugAction[]);
100
+ constructor(engine: Engine, container: HTMLElement, doc: DocumentLike, statsSource?: (() => Partial<RendererStats>) | undefined, actions?: DebugAction[], frameSource?: DebugOverlayOptions["frameSource"] | undefined);
79
101
  isOpen(id: DebugMenuId): boolean;
80
102
  /** How much of the physics world the wireframes show. */
81
103
  colliderMode: ColliderMode;
@@ -102,6 +124,18 @@ declare class DebugOverlay {
102
124
  dispose(): void;
103
125
  /** `✓ Colliders · selected` — check for on, suffix for the collider mode. */
104
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;
105
139
  private toggleDropdown;
106
140
  private openStatsChip;
107
141
  private renderStats;
@@ -117,6 +151,28 @@ declare class DebugOverlay {
117
151
  private trackEditing;
118
152
  private pushLog;
119
153
  /**
154
+ * The scene, on the clipboard, ready to paste into a conversation.
155
+ *
156
+ * This is half of "here is what I am looking at" — the half a screenshot
157
+ * cannot carry. The other half is the region capture below.
158
+ */
159
+ copyScene(): Promise<boolean>;
160
+ /**
161
+ * Drag a rectangle over the game; the selection copies as an image.
162
+ *
163
+ * The pixels come from `frameSource` — the renderer's own end-of-frame read —
164
+ * and never from `canvas.toDataURL()`, which returns black once the frame has
165
+ * composited unless `preserveDrawingBuffer` is on (it is not, because it costs
166
+ * bandwidth on every frame).
167
+ */
168
+ captureRegion(): void;
169
+ private capture;
170
+ private captureScale;
171
+ /** Crop the last frame to `drag` and put the image on the clipboard. */
172
+ private copyRegion;
173
+ /** A line that says what happened and gets out of the way. */
174
+ private toast;
175
+ /**
120
176
  * Set game time, defensively. `timeScale` multiplies every dt in the engine,
121
177
  * so a NaN from a text field would poison physics, timers and animation in
122
178
  * one frame, and a negative would run the simulation backwards through code
@@ -144,4 +200,4 @@ declare class DebugOverlay {
144
200
  private renderLogs;
145
201
  }
146
202
  //#endregion
147
- export { ColliderMode, DebugAction, DebugMenuId, DebugOverlay, DebugOverlayOptions, DebugPanelId, DebugSwitchId, attachDebugOverlay };
203
+ export { ColliderMode, DebugAction, DebugCommandId, DebugMenuId, DebugOverlay, DebugOverlayOptions, DebugPanelId, DebugSwitchId, attachDebugOverlay };