@jgengine/shell 0.36.0 → 0.38.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/CHANGELOG.md CHANGED
@@ -26,6 +26,153 @@ At publish, rename this heading to the new version and mirror the entries into
26
26
  `packages/core/src/meta/changelog.ts` (the typed `CHANGELOG` export).
27
27
  -->
28
28
 
29
+ ## 0.38.0
30
+
31
+ ### Migrate
32
+
33
+ - **Bump lockstep SDK packages to `^0.38.0`:** `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets,navbake}`. CLI `jgengine` is `0.35.0`; `@jgengine/github` is `0.25.0`.
34
+ - Composing a session loop directly: pass your rule as `settle`, `carries: false` when nothing is carried, and your authored options as `offers`. `createRunLoop` is removed one minor release after Beacon Bastion and Drift Foundry stop using it.
35
+
36
+ ### Added
37
+
38
+ - `GameMenu` and `GamePauseMenu` (`@jgengine/react/gameMenu`): a full-bleed menu surface over the live scene or drifting key art, with a token-sized logo, an animated navigable verb list, a generated selection tick and a detail pane per verb. The document scroll is locked while it is open, so the surface is exactly the viewport at any size.
39
+ - Panes for the detail slot: `ContinuePane`, `NewCharacterPane`, `SaveSlotsPane` (slot cards with a `meta.thumbnail`, playtime, progress, timestamp and a two-press delete) and `CreditsPane` (`@jgengine/react/gameMenuSaves`).
40
+ - `shrinkCapture` (`@jgengine/react/saveThumbnail`) turns a `captureCanvas` frame into a card-sized thumbnail data URL.
41
+ - `createSessionLoop` (`@jgengine/core/run/sessionLoop`) is one session loop for every game: a session opens, may carry a quantity, closes at its boundary, and the game's own `settle` callback decides what happens to what it carried. `carries: false` for a session with nothing at stake, and an optional `offers` table (with `present`/`choose`) for a game whose next session opens with an authored change; without a table nothing is drawn. `installSessionProbe` reports the live session on `window.__jgengineRun`.
42
+
43
+ ### Changed
44
+
45
+ - `switchSlot(slot, { flush: false })` (`@jgengine/core/game/gameSave`) loads a slot without first writing the live state over it. Opening a save from a menu uses it so Continue cannot overwrite the save it resumes.
46
+ - The dev runner's registry and environment modules import outside Vite, so a test or tool can read them.
47
+ - `createRunLoop` is now a thin composition of `createSessionLoop` and is deprecated: bank-or-lose and a per-run change table are one game type's rules, so they live in the composition instead of the base. Its API, state shape and tests are unchanged. One difference: the option draw is seeded under `:offer:` rather than `:run:` (`seededRng(\`${seed}:offer:${index}\`)`), so a career that keeps its seed draws a different change on a given session than 0.37.0 did for the same seed. Ids, payloads and saves stay valid: a saved `changeId` still restores the same change.
48
+
49
+ ### Fixed
50
+
51
+ - `shoot` fails instead of writing a PNG when the whole frame is one flat colour, and when a scene is still behind its loading cover. Both used to write a plausible screenshot that read as evidence of a rendered frame.
52
+ - The editor's play-mode pixel test waits for the warmup cover to clear before screenshotting. It shot the cover, so it read `0.9988` one-colour locally and passed in CI where the race fell the other way.
53
+ - `shoot` prints the WebGL2 renderer it draws with and fails the capture outright when the launched browser has none.
54
+
55
+ Root cause: capture readiness was trusted to mean "the scene has rendered", but a loading cover and a canvas that never committed both photograph as a flat frame, and nothing measured the frame after the fact. Headless Chrome on this PC was never the problem: it renders with the GPU (`ANGLE (NVIDIA GeForce RTX 3070, D3D11)`) and with SwiftShader when asked.
56
+
57
+ Before: `bun run shoot --url <flat canvas page>` exited 0 and wrote a one-colour PNG, and `packages/editor/src/CreatorApp.test.ts` failed locally at `dominantColorShare 0.9988`.
58
+ After: the same shoot exits 1 with `the whole frame is one flat color (100.0%) - no scene reached the capture - nothing was written`, and the editor test passes in 12s with sky and ground in the PNG.
59
+
60
+ Cost: forcing SwiftShader on the same game shot costs 2.4x the capture (16.0s vs 6.7s warm, beacon-bastion play at half size), so the launch keeps the native GPU. The two checks add about 10ms to a shot.
61
+
62
+ Verification:
63
+ - `bun test scripts/shot-metrics.test.ts` - pass
64
+ - `bun test packages/editor/src/CreatorApp.test.ts` - pass
65
+ - `bun run shoot beacon-bastion --mode play --size half` - exit 0, scene renders
66
+ - `bun run shoot --url <flat canvas page>` - exit 1, no PNG written
67
+ - `bun run verify:fast` - pass
68
+
69
+ The CI reliability fix this lane also carried (a browser setup hook shorter than Playwright's launch default) is #2335, so either change can be reverted without the other.
70
+ - The publish workflow's last step no longer reports a whole release as unpublished because it ran seconds after `npm publish`. `check:release-artifacts --wait <seconds>` re-checks while the only failures are absent packages (no metadata, version missing, tarball 404) and gives up when the budget runs out; a wrong version in the tarball, a missing subpath or a main entry that will not import still fails on the first check. The 0.37.0 publish run failed this step at 04:48 listing all 13 packages, then npm served every one of them. The step now passes `-- --wait 300`.
71
+ - A Playwright test file's `beforeAll` now outlives the Chrome launch it waits on, and `browser-test-budget` rejects a file that leaves the hook at or under Playwright's 30 s launch default. On `test (g)` in #2326 the hook's 30 s budget and that default expired together, so CI reported only `(unnamed) hook timed out` beside `killed 1 dangling process` and nothing named the launch.
72
+ - `packages/react/src/dialogBehavior.test.ts` gives its browser navigation its own 20 s deadline and one retry inside a 60 s hook.
73
+
74
+ Root cause: Bun prints a `beforeAll` timeout as an unnamed hook timeout, so a setup budget equal to Playwright's own 30 s launch default reports the anonymous timer instead of the launch that stalled.
75
+
76
+ Before: `test (g)` on #2326 reported `(fail) (unnamed) [30001.51ms] ^ a beforeEach/afterEach hook timed out` and `killed 1 dangling process`.
77
+ After: a stall now surfaces as Playwright's own `Timeout ... exceeded`, and `bun scripts/browser-test-budget.ts` names any file whose setup hook sits at or under the launch default.
78
+
79
+ Verification:
80
+ - `bun scripts/browser-test-budget.ts` - pass, and the new rule names the unraised files on the previous tip
81
+ - `bun test scripts/browser-test-budget.test.ts` - pass
82
+ - `bun test packages/react/src/dialogBehavior.test.ts packages/react/src/scrollRail.test.ts packages/shell/src/settingsBoundary.test.ts` - pass
83
+ - `bun run verify:fast` - pass
84
+ - A prop placed at runtime honours its model's `variants` levels. `StaticObjectModels` applied the level switch only inside its batched branch, so a prop placed with `scene.object.place` — Harbor Heat's palm line — drew its full asset at every camera distance: 15 palms at 9,134 triangles each, and pointing the far level at a dispatch booth changed nothing on screen. `IsolatedEntityModel` now mounts `SingleVariantModel`, which asks the same `createModelVariantSelector` and `lodBandIndex` authority the batched path uses, at the prop's own camera distance, and keeps only the active level mounted so both levels never draw at once. The switch carries the selector's hysteresis, warms every level at mount, measures only the near level, and reads the placement from the scene store through scratch vectors, so a frame allocates nothing. One palm past 60 m draws 4,110 triangles instead of 9,134 (5,024 saved, 45% of the full level).
85
+
86
+ - `singleVariantLevel` and `singleVariantModel` (`@jgengine/shell` internal, `render/singleVariantLod`) are the isolated path's level decision and the model it draws, so a fixture pins the same switch the component runs.
87
+
88
+ `bun test packages/shell/src/render/singleVariantLod.test.ts` — 6 pass. With the old render decision restored, the wiring test fails (`Expected: SingleVariantModel, Received: EntityModel`).
89
+ `bun --cwd=packages/shell run test` — 1,229 pass, 0 fail.
90
+ `bun --cwd=packages/shell run check-types` — clean.
91
+
92
+ ## 0.37.0
93
+
94
+ ### Migrate
95
+
96
+ - **Bump lockstep SDK packages to `^0.37.0`:** `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets,navbake}`. CLI `jgengine` is `0.34.0`; `@jgengine/github` is `0.24.0`.
97
+
98
+ ### Added
99
+
100
+ - `bun run check:script-help` walks every `package.json` script that points into `scripts/`, runs each with `--help`, and fails when one errors, hangs over its own timeout, prints nothing, or when a script names a file the repo does not have. Script files that predate `--help` are listed in `scripts/script-help-baseline.json` and reported, so a new one cannot arrive undocumented.
101
+ - `land`, `lane:close`, `lane:audit`, `ci-status` and `verify:fast` answer `--help` with their purpose, the flags they actually accept, one example and their exit codes, through one shared `scripts/script-help.ts`. No install, network or browser needed.
102
+ - `RunLoop.present(count)` and `RunLoop.choose(id)` (`@jgengine/core/run/runLoop`) draw several next-run changes for a choice screen and adopt the pick, so a game that offers three relics does not compose `game/runDraft` beside the loop. `present` honours the same `after` gates and weights as the single draw and never repeats the change just played while alternatives remain; `choose` opens the next run around the pick and refuses an id the loop's table does not carry.
103
+ - `installRunProbe` (`@jgengine/core/run/runLoop`) publishes `window.__jgengineRun` with the run in progress, what it carries, what it banked or lost, its best bank and the change it offers, so a capture or devtools read proves the loop is live instead of only imported.
104
+ - `bun run find <pattern> [--in <dir>] [--kind ts,tsx] [--files]` answers a repository-wide search in about a second: ripgrep reads the tree once, honours `.gitignore` and never enters `node_modules`, `.git`, `.claude/worktrees` or build output. `--files` lists names by `--glob`, which is what a tree listing was for. Eight of the ten hard timeouts in three hours of lane transcripts were a recursive directory listing over this checkout (160 s, 3.0M files) against a 120 s limit, while every other call's p90 was 10.5 s.
105
+ - `bun run check-recursive-walk` fails a `scripts/**` file or a lane document (`AGENTS.md`, `README.md`, every skill under `.claude/skills`) that ships a recursive directory listing, a `find` walk from a root, or `git ls-files` piped into per-file work; a required one carries `// walk-ok: <reason>`. It runs in CI through the scripts test shard.
106
+ - `ModelConfig.variants` (`levels: [{ url, maxDistance? }]`, `hysteresis?`) declares ordered mesh detail levels for a model. The instanced prop path switches level per instance by distance to the camera, so near and far placements of one species keep their own mesh inside one batch cell. A model without `variants` draws exactly as before.
107
+ - `createModelVariantSelector` (`@jgengine/core/world/modelLod`) returns the level index for an id at a distance, carrying hysteresis across each boundary (`DEFAULT_VARIANT_HYSTERESIS`, 6 m). `lodBandIndex` is now exported from `@jgengine/core/world/lod` and both the LOD scheduler and the selector read distances through it, so simulation throttling and mesh detail cannot disagree.
108
+ - `syncStaticObjectSource` takes an optional `accept` predicate so one batch can split its instances between two instanced draws.
109
+ - `InstancedScatter` now honours `ModelConfig.variants`: each species-chunk batch asks the same `createModelVariantSelector` the static prop path uses for every placement's level, and each level draws its own instanced meshes claiming only its placements. A chunk that holds near and far palms of one species draws both levels from the one batch; a model without `variants` draws exactly as before.
110
+ - `writeScatterVariantLevel` (`@jgengine/shell` internal, `scatter/scatterVariants`) is the per-frame pass: it writes one level's composed matrices into a reserved slot array and returns `count`, allocating nothing per frame, and takes an optional `keep` predicate so a tier-distance or occlusion cull composes with level selection in one loop.
111
+ - `terrain({ cellSize })` states ground density as world units per quad instead of a segment count, and derives the per-axis grid from `bounds` — the same `cellSize` on a bigger world spends more cells rather than stretching one count across it. `segments` still works and also takes `[x, z]` for a non-square grid. Setting both fails naming `cellSize`; a cell size over the 1,000,000-vertex ceiling fails with the cell/vertex numbers and the smallest `cellSize` that fits.
112
+ - `shoot`, `drive` and `perf` take `--quality <low|medium|high|ultra>` and pin that graphics tier on the game page (`?quality=<tier>`). Each run reads the tier back from the shell (`data-jg-quality`) and fails when it is missing or different, so a capture or perf record always names the tier it measured. Every capture summary prints `tier <tier>`; `perf` echoes `perf: tier <tier>` and records it in `quality`.
113
+ - The old `--quality draft|full` iteration mode is now the `--draft` switch (low tier, half viewport, `-draft` file names), on `shoot`, `drive` and `perf`; `perf:gate` takes `--draft` too. An unknown `--quality` value fails naming all four tiers.
114
+ - `attributeTriangles` (`@jgengine/shell/render/triangleAttribution`) sums a scene's triangles by model and geometry with instance counts, and the devtools render probe publishes it as `probes["render/triangles"]` and the `render/triangles` system probe, so `debug_snapshot` names the object behind a heavy frame instead of only counting it. Harbor Heat read 1,021,671 triangles from 840 objects: the 640 m terrain at 300 segments (180,000), the `TwistedTree_2` foliage species (137,010 over 15 trees) and 93 instances of a 1,280-triangle NPC model.
115
+ - `ShotSpec` and `resolveShotSpec` (`@jgengine/core/combat/shotSpec`) describe how a shot travels and what it does on contact as plain data: an instant trace or a travelling body with speed, gravity, drag, fuse, lifetime, radius and homing, plus contact damage, falloff, penetration, splash, ricochet and statuses. Every field but `delivery.kind` is optional with a documented default, so a plain gun is three lines and a grenade, mortar, arrow or hitscan explosive is the same shape with different numbers. `shotArc`, `shotFalloffAt` and `shotHitOutcome` are the one place trajectory, falloff and penetration are solved; `shotSpecFromStats` and `shotSpecFromWeapon` adapt existing item stats and weapon definitions once, and a splash radius or damage number never decides that a shot travels.
116
+ - `ProjectileShotInput.spec` lets a caller state the spec directly; the projectile and weapon systems read the resolved spec at spawn, fuse, hitscan settle and damage application instead of classifying a shot.
117
+ - A trace that carries splash now resolves its area damage at the contact point on the same tick, which is the #2272 hitscan-explosive configuration.
118
+ - `bun run check:path-portability` fails a test that asserts one platform's path separator: a `\\` literal under `toContain`/`toEqual`/`toBe`/`includes`/`endsWith`, a bare `toContain("/")`, a POSIX root handed to `join`/`resolve`, or a literal compared with an OS path result. It reports the file, the line and the `join(...)` that replaces it. A genuinely single-platform assertion is named in `scripts/path-portability-allow.json` with a reason, printed on every run. This is the class behind #2301 (`toContain("proofs\\flows")`) and the `agent-worktree.test.ts` assertions that only pass on one OS.
119
+
120
+ Verification:
121
+ - `bun test scripts/check-path-portability.test.ts` - pass, including the pre-fix `proofs\\flows` line failing through the gate and the fixed one passing
122
+ - `bun run check:path-portability` - ok, 3 allowlisted with reasons
123
+ - `bun test scripts/agent-worktree.test.ts scripts/shot-metrics.test.ts scripts/lane-audit.test.ts` - pass
124
+
125
+ ### Changed
126
+
127
+ - Merge pull request #2309 from Noisemaker111/agent/lane-path-portability
128
+ - `bun run land` hands the PR over instead of waiting for it: it pushes, opens or updates the PR, runs `--verify`, arms auto-merge and exits in seconds, printing the PR URL and `bun run ci:status --pr <n>`. A trivial change spent 5m09s inside `land`'s own poll loop, and one night's lanes spent 449 minutes watching CI. `--watch` still lands the old way and says it is deprecated.
129
+ - `bun run land:queue` is the only process that waits for CI. It lands ready PRs into `beta` in readiness order (auto-merge armed, else the `land:queue` label), rebases a branch whose base moved in a throwaway worktree so a moved base costs one CI run instead of two, waits once per PR and prints one line per PR with its wait time and run id. A lane hands off by running `bun run land`; it never rebases, labels or waits.
130
+ - `bun run lane:close` ends a lane in one command. It stages the worktree's tracked and new files (ignored ones such as `.scratch/` stay out), takes the commit message from the branch's own `changes/` fragment, writes that fragment from the commit summary when the diff has none, pushes, opens or updates the PR with the fragment as the body, arms auto-merge, and prints the PR URL and `bun run ci:status --pr <n>`. One lane's last 20 calls were 13 of commit, push, rebase and PR choreography; this replaces them with one. `bun run land --commit` does the same for a lane that skips `lane:close`, and a clean tree lands as it did before.
131
+ - `bun run bg:run -- <cmd>` starts a long command and returns its id at once; `bun run bg:await <id>` blocks on that task's exit file as an event, then prints its whole output and its exit code. It replaces waiting out a `.out` file with guessed intervals — one lane spent 10 minutes of `Start-Sleep` calls watching a capture it had already started. A runner that dies is answered rather than waited on forever.
132
+ - `--commit` prints every path it will commit before committing, and refuses the ones a lane did not author: a scratch artifact, a path git ignores, a lockfile staged without its `package.json` manifest, and a new `changes/` fragment whose name is not part of the branch's name. `--allow <path>` keeps one refused file on purpose. The first replay committed a test-written `changes/agent-lane-x.md` and this checkout's shared-install `bun.lock` drift; both are refused now, with the file list printed either way.
133
+ - A capture at a non-default tier writes its tier into the file name (`-low`, `-medium`, `-ultra`), so an ultra shot cannot overwrite the high one it was compared against.
134
+
135
+ Root cause: `--quality` carried the iteration preset, not the graphics tier, so a culling lane that asked for ultra got the game's default `high` on both sides of its comparison and could not prove a tier claim.
136
+
137
+ ### Fixed
138
+
139
+ - `verify:fast`, `land` and `lane:close` refuse a checkout with no `node_modules` up front: they print the missing marker, the exact fix (`bun install` in this checkout, or `bun run agent:bootstrap`) and exit 78, instead of a `command not found` or a module error from a test file. A genuine failure still exits on its own code.
140
+ - `lane:audit --cwd` finds the lane whose own tool calls name that worktree, and prints `attributed ses_… (N references to …)`. Ambiguous ties name their candidates and pick the worst-waiting one; no candidate at all exits non-zero naming the path it searched, so `--fail-on-waiting` has a lane to judge instead of an empty table.
141
+ - `land` builds the PR body only from `changes/` fragments its own `git diff origin/beta...HEAD` adds, prints a line for every fragment it ignores, and falls back to the branch's commit subjects.
142
+
143
+ Root cause: a subagent session stores the directory of the agent that spawned it, so a lane was invisible to its own audit; `changes/` is shared by every lane on the branch, so "newest fragment" was free to be a stranger's note.
144
+
145
+ Before: `lane:audit --cwd …\lane-wait-audit` exited 1 with "no child sessions", and a body built from a stray `changes/` fragment claimed work the diff did not contain.
146
+ After: `lane:audit: attributed ses_e94f3aeedffe2CCMsTlrzgvez5 (136 references to …\lane-wait-audit)`, and `land: ignoring changes/scratch-leftover.md: this branch's diff against origin/beta does not add it`.
147
+
148
+ Verification:
149
+ - `bun test scripts/lane-audit.test.ts scripts/lane-close.test.ts scripts/land.test.ts` — pass
150
+ - `bun run verify:fast` — pass
151
+ - `bun run lane:audit --cwd <the real lane worktree> --hours 6` — attributed that lane by 136 references
152
+ - `lane:audit` classifies a call by what it runs, not by the text inside its arguments: `rg`, `grep`, `Select-String`, `findstr`, `Get-Content`, `cat`, `echo`, `Write-Output` and `git` (any subcommand) never count as a sleep or a CI watch, and quoted arguments are stripped before matching. A lane that greps for `Start-Sleep` or writes a PR body about `gh run watch` no longer records waiting it never did.
153
+ - Catches the waits it used to miss: `Start-Sleep` under `powershell -Command`/`pwsh -c`/`bash -lc`, `cmd /c "timeout /t"`, `Thread.sleep`, `Atomics.wait`, `Bun.sleep`, and `gh pr checks` inside a `for`/`while` loop.
154
+
155
+ Over the last 40 hours (209 lanes): sleep 1066.6 -> 1068.7 minutes, CI watch 421.8 -> 421.3 minutes. 15 calls stopped counting as waits, one real `Start-Sleep -Seconds 150` started counting.
156
+
157
+ Verification:
158
+ - `bun test scripts/lane-audit.test.ts scripts/lane-close.test.ts scripts/land.test.ts` — pass
159
+ - `bun run lane:audit --json --hours 40` before and after, compared per command
160
+ - `lane:close` reads `calls` as the count it is, takes waits from a new `waits` list in `lane:audit --json`, and refuses an unfamiliar audit shape with `the audit output shape changed: <field> is <type>, expected <type> (keys seen: …)` instead of a TypeError. Exit codes unchanged: a sleep or CI watch is 2, `--waive` records the reason, no percentage thresholds.
161
+
162
+ Root cause: `lane:audit --json` had always reported `calls` as a number, while `lane:close` held it as a list of call objects; nothing read the real document until lane attribution started returning a lane, so the gate that judges a finishing lane crashed on the first lane that had one.
163
+
164
+ Before: `lane:close` landed the PR, then died with `source.filter is not a function` at `waitingGate`.
165
+ After: the same captured output is judged, the waits are listed, and the lane gets exit 2 with a report skeleton.
166
+
167
+ Verification:
168
+ - `bun test scripts/lane-audit.test.ts scripts/lane-close.test.ts` — pass, including the captured-output fixture
169
+ - `bun run verify:fast` — pass
170
+ - `shoot --url ... --look` now sends the aim to the page it captures, so a lane can measure a branch on a dev runner it started itself. The read-back stays: a page that reports no applied aim still fails the capture instead of returning a plausible wrong view, and a page that applies `--param look=` itself can report it on `data-jg-look` or `window.__jgLook`. `--site --look` is refused at the command line, naming the `--url` form that works.
171
+ - `check:no-blocking-wait` judges code that waits, not prose that describes waiting. In `packages/<pkg>/src/meta` (the changelog mirror and its neighbours) single- and double-quoted text and comments are masked before matching, so a release note reading "one lane spent 10 minutes of `Start-Sleep`" no longer fails the gate; a command in a template literal still fails, and every other `scripts/**` path keeps the strict rule where a quoted command is a real finding. The 0.37.0 release cut failed `types` on `packages/core/src/meta/changelog.ts:46,55,56` with exactly that false positive.
172
+ - `@jgengine/rapier` now accumulates `step(dt)` into its fixed substeps instead of stepping once per call with `fixedDt` as the timestep. A 1 s move covered 0.5 s of simulated time at 30 fps and 2.4 s at 144 fps; it now covers 1 s at either rate, matching `PhysicsWorld` and the `PhysicsBackend` contract.
173
+ - `PhysicsWorld` bounds-floor friction is Coulomb (`friction × g × dt`) like contact friction, not a 60 Hz multiplicative brake: a crate sliding at 4 m/s travelled 0.165 m on the world floor and 0.970 m on a floor collider, and now travels 0.967 m on both.
174
+ - Player and character-controller gravity integrate trapezoidally, so a jump reaches `v²/2g` at any frame rate: the default jump peaked at 0.933 m at 30 fps and 1.026 m at 144 fps, and now at 1.050 m for both.
175
+
29
176
  ## 0.36.0
30
177
 
31
178
  ### Migrate
@@ -242,6 +242,15 @@ export function GamePlayerShell({ playable, multiplayer: rawMultiplayer = null,
242
242
  delete document.documentElement.dataset.jgPresentation;
243
243
  };
244
244
  }, [playable]);
245
+ // The tier the shell actually resolved, so a capture can prove which one it measured.
246
+ useEffect(() => {
247
+ if (typeof document === "undefined")
248
+ return;
249
+ document.documentElement.dataset.jgQuality = graphics.quality;
250
+ return () => {
251
+ delete document.documentElement.dataset.jgQuality;
252
+ };
253
+ }, [graphics.quality]);
245
254
  useEffect(() => {
246
255
  if (ctx === null || typeof document === "undefined")
247
256
  return;
@@ -3,6 +3,7 @@ import { useFrame, useThree } from "@react-three/fiber";
3
3
  import { useEffect, useRef, useState, useSyncExternalStore } from "react";
4
4
  import { devtools, } from "@jgengine/core/devtools/devtools";
5
5
  import { heavyModels } from "../render/modelLoad.js";
6
+ import { triangleProbe } from "../render/triangleAttribution.js";
6
7
  import { buildLeanReport } from "./devtoolsReports.js";
7
8
  import { PerfPanel } from "./PerfPanel.js";
8
9
  import { LogsPanel } from "./LogsPanel.js";
@@ -19,8 +20,41 @@ const RENDER_PROBE_FRAME_PRIORITY = -10;
19
20
  export function DevtoolsRendererProbe() {
20
21
  useEffect(() => devtools.probes.register("heavyModels", () => heavyModels()), []);
21
22
  const gl = useThree((state) => state.gl);
23
+ const scene = useThree((state) => state.scene);
22
24
  const lastSampleAt = useRef(0);
23
25
  useEffect(() => devtools.probes.register("shaderPrograms", () => gl.info.programs?.length ?? 0), [gl]);
26
+ useEffect(() => {
27
+ // One walk serves the counters, the state line and the probe: a snapshot reads the same frame.
28
+ const probe = triangleProbe(scene);
29
+ let cachedAt = 0;
30
+ let cached = probe.table();
31
+ const read = () => {
32
+ const now = performance.now();
33
+ if (now - cachedAt > 500) {
34
+ cachedAt = now;
35
+ cached = probe.table();
36
+ }
37
+ return cached;
38
+ };
39
+ const disposeProbe = devtools.probes.register("render/triangles", () => read());
40
+ const disposeSystem = devtools.probes.registerSystem("render/triangles", {
41
+ counters: () => {
42
+ const { triangles, casters, lines } = read();
43
+ return { triangles, casters, lines: lines.length };
44
+ },
45
+ state: () => {
46
+ const { triangles, casters, lines } = read();
47
+ const top = lines[0];
48
+ return top === undefined
49
+ ? `${triangles} triangles`
50
+ : `${triangles} triangles · casters ${casters} · top ${top.label} ${top.triangles}`;
51
+ },
52
+ });
53
+ return () => {
54
+ disposeProbe();
55
+ disposeSystem();
56
+ };
57
+ }, [scene]);
24
58
  useEffect(() => {
25
59
  // three resets gl.info after every render() call, so with a multi-pass
26
60
  // composer the counters only ever hold the last fullscreen pass. Own the
@@ -21,21 +21,40 @@ export declare const SHADOW_DISTANCE_BY_MAP_SIZE: Readonly<Record<number, number
21
21
  */
22
22
  export declare function resolveShadowDistance(profile?: GraphicsProfile, authored?: number): number;
23
23
  /**
24
- * Frames between shadow-map refreshes for the cascade at `index`, nearest first. The nearest
25
- * cascades move several texels per frame and refresh every frame; the far ones cover ground a
26
- * slow camera barely crosses, so they pay the depth pass a half or a quarter as often.
24
+ * Frames between shadow-map refreshes for the cascade at `index`, nearest first.
25
+ * @deprecated Cascades refresh on a texel step; see {@link refreshCascadesOnTexelStep}.
27
26
  * @internal
28
27
  */
29
28
  export declare function cascadeShadowCadence(index: number, cascades: number): number;
29
+ /** Depth-axis steps worth of a texel the snapped light may drift before it re-anchors. */
30
+ export declare const CASCADE_DEPTH_STEP_TEXELS = 8;
31
+ /** World size of one texel on a cascade's own shadow map. @internal */
32
+ export declare function cascadeTexelSize(light: THREE.DirectionalLight): number;
30
33
  /**
31
- * Set each cascade's shadow refresh policy for `frame`. A cascade that is not due keeps both
32
- * its matrix and its map from the last refresh, so the pair stays consistent with itself.
33
- * `refreshScale` stretches the far cascades' interval further on a tier whose maps are cheap
34
- * enough to lag; `1` is the cadence the cascade rig was measured with. The nearest cascade always
35
- * refreshes every frame: it is the one a moving camera crosses fastest.
34
+ * The cascade centre rounded to whole texels of its own shadow map, in the light's orientation.
35
+ * Lateral texels are what the eye sees move; the depth axis is coarser because it only shifts the
36
+ * ortho depth window. @internal
37
+ */
38
+ export declare function snapCascadeCenter(center: THREE.Vector3, lightDirection: THREE.Vector3, texelSize: number, depthTexels?: number): THREE.Vector3;
39
+ /** Per-frame cascade refresh bookkeeping: the centre each map was last rendered with. */
40
+ export interface CascadeRefreshState {
41
+ centres: THREE.Vector3[];
42
+ /** Depth passes requested since the state was created. */
43
+ refreshes: number;
44
+ /** Leading cascades that refresh every frame whatever the snap says: a moving caster must not freeze. */
45
+ floor: number;
46
+ }
47
+ /** @internal */
48
+ export declare function createCascadeRefreshState(floor?: number): CascadeRefreshState;
49
+ /**
50
+ * Pin each cascade's light to its texel grid and request a depth pass where that grid step moved
51
+ * the light, plus every frame for the `floor` nearest cascades. `floor: 1` keeps the cascade holding
52
+ * the player live so a walking actor's shadow follows it while the camera stands still; the far
53
+ * cascades skip until the camera crosses a texel. An unmoved light keeps `autoUpdate` off, so the
54
+ * map and the sampling matrix always stay the pair they were rendered as.
36
55
  * @internal
37
56
  */
38
- export declare function scheduleCascadeRefresh(lights: readonly THREE.DirectionalLight[], frame: number, refreshScale?: number): void;
57
+ export declare function refreshCascadesOnTexelStep(lights: readonly THREE.DirectionalLight[], lightDirection: THREE.Vector3, state: CascadeRefreshState): number;
39
58
  /**
40
59
  * Cascaded shadow maps for one directional light — outdoor scenes keep shadows past a
41
60
  * single 40-unit ortho frustum. Uses three.js `CSM` (shader injection on standard materials).
@@ -2,7 +2,7 @@ import { useFrame, useThree } from "@react-three/fiber";
2
2
  import { useEffect, useRef } from "react";
3
3
  import * as THREE from "three";
4
4
  import { CSM } from "three/examples/jsm/csm/CSM.js";
5
- import { resolveCascadeCount, resolveShadowMapSize, shadowRefreshScale } from "./shadowTiers.js";
5
+ import { resolveCascadeCount, resolveShadowMapSize } from "./shadowTiers.js";
6
6
  /** @internal Default PCF penumbra in shadow-map texels for configured directional lights. */
7
7
  export const DEFAULT_SHADOW_RADIUS = 1.25;
8
8
  /** @internal Reach in metres used when neither the game nor a graphics profile sets one. */
@@ -33,9 +33,8 @@ export function resolveShadowDistance(profile, authored) {
33
33
  return Math.min(profile.drawDistance, reach);
34
34
  }
35
35
  /**
36
- * Frames between shadow-map refreshes for the cascade at `index`, nearest first. The nearest
37
- * cascades move several texels per frame and refresh every frame; the far ones cover ground a
38
- * slow camera barely crosses, so they pay the depth pass a half or a quarter as often.
36
+ * Frames between shadow-map refreshes for the cascade at `index`, nearest first.
37
+ * @deprecated Cascades refresh on a texel step; see {@link refreshCascadesOnTexelStep}.
39
38
  * @internal
40
39
  */
41
40
  export function cascadeShadowCadence(index, cascades) {
@@ -45,24 +44,70 @@ export function cascadeShadowCadence(index, cascades) {
45
44
  return index === 0 ? 1 : 2;
46
45
  return 2 ** (index - (cascades - 3));
47
46
  }
47
+ /** Depth-axis steps worth of a texel the snapped light may drift before it re-anchors. */
48
+ export const CASCADE_DEPTH_STEP_TEXELS = 8;
49
+ const UP = new THREE.Vector3(0, 1, 0);
50
+ const orientMatrix = new THREE.Matrix4();
51
+ const inverseOrient = new THREE.Matrix4();
52
+ const localCentre = new THREE.Vector3();
53
+ /** World size of one texel on a cascade's own shadow map. @internal */
54
+ export function cascadeTexelSize(light) {
55
+ const shadowCamera = light.shadow.camera;
56
+ const span = Math.max(shadowCamera.right - shadowCamera.left, shadowCamera.top - shadowCamera.bottom);
57
+ return span / Math.max(1, light.shadow.mapSize.x);
58
+ }
48
59
  /**
49
- * Set each cascade's shadow refresh policy for `frame`. A cascade that is not due keeps both
50
- * its matrix and its map from the last refresh, so the pair stays consistent with itself.
51
- * `refreshScale` stretches the far cascades' interval further on a tier whose maps are cheap
52
- * enough to lag; `1` is the cadence the cascade rig was measured with. The nearest cascade always
53
- * refreshes every frame: it is the one a moving camera crosses fastest.
60
+ * The cascade centre rounded to whole texels of its own shadow map, in the light's orientation.
61
+ * Lateral texels are what the eye sees move; the depth axis is coarser because it only shifts the
62
+ * ortho depth window. @internal
63
+ */
64
+ export function snapCascadeCenter(center, lightDirection, texelSize, depthTexels = CASCADE_DEPTH_STEP_TEXELS) {
65
+ const texel = texelSize > 0 ? texelSize : 1;
66
+ orientMatrix.lookAt(new THREE.Vector3(), lightDirection, UP);
67
+ inverseOrient.copy(orientMatrix).invert();
68
+ localCentre.copy(center).applyMatrix4(inverseOrient);
69
+ localCentre.x = Math.round(localCentre.x / texel) * texel;
70
+ localCentre.y = Math.round(localCentre.y / texel) * texel;
71
+ const depth = texel * Math.max(1, Math.floor(depthTexels));
72
+ localCentre.z = Math.round(localCentre.z / depth) * depth;
73
+ return localCentre.applyMatrix4(orientMatrix);
74
+ }
75
+ /** @internal */
76
+ export function createCascadeRefreshState(floor = 1) {
77
+ return { centres: [], refreshes: 0, floor };
78
+ }
79
+ /**
80
+ * Pin each cascade's light to its texel grid and request a depth pass where that grid step moved
81
+ * the light, plus every frame for the `floor` nearest cascades. `floor: 1` keeps the cascade holding
82
+ * the player live so a walking actor's shadow follows it while the camera stands still; the far
83
+ * cascades skip until the camera crosses a texel. An unmoved light keeps `autoUpdate` off, so the
84
+ * map and the sampling matrix always stay the pair they were rendered as.
54
85
  * @internal
55
86
  */
56
- export function scheduleCascadeRefresh(lights, frame, refreshScale = 1) {
57
- const stretch = Math.max(1, Math.floor(refreshScale));
87
+ export function refreshCascadesOnTexelStep(lights, lightDirection, state) {
88
+ let refreshed = 0;
58
89
  for (let index = 0; index < lights.length; index += 1) {
59
- const shadow = lights[index].shadow;
60
- const base = cascadeShadowCadence(index, lights.length);
61
- const cadence = base === 1 ? 1 : base * stretch;
62
- shadow.autoUpdate = cadence === 1;
63
- if (cadence > 1)
64
- shadow.needsUpdate = frame % cadence === 0;
90
+ const light = lights[index];
91
+ const snapped = snapCascadeCenter(light.position, lightDirection, cascadeTexelSize(light));
92
+ const known = state.centres[index];
93
+ const moved = known === undefined || known.distanceToSquared(snapped) > 1e-12;
94
+ const refresh = moved || index < state.floor;
95
+ light.position.copy(snapped);
96
+ light.target.position.copy(snapped).add(lightDirection);
97
+ light.shadow.autoUpdate = refresh;
98
+ light.shadow.needsUpdate = false;
99
+ if (moved) {
100
+ if (known === undefined)
101
+ state.centres[index] = snapped.clone();
102
+ else
103
+ known.copy(snapped);
104
+ }
105
+ if (refresh) {
106
+ refreshed += 1;
107
+ state.refreshes += 1;
108
+ }
65
109
  }
110
+ return refreshed;
66
111
  }
67
112
  function isStandardMaterial(mat) {
68
113
  return mat instanceof THREE.MeshStandardMaterial || mat instanceof THREE.MeshPhysicalMaterial;
@@ -80,12 +125,12 @@ export function CascadedShadows({ entry, profile, modulation, }) {
80
125
  const camera = useThree((s) => s.camera);
81
126
  const scene = useThree((s) => s.scene);
82
127
  const csmRef = useRef(null);
83
- const frameRef = useRef(0);
128
+ const lightDirectionRef = useRef(new THREE.Vector3());
129
+ const refreshRef = useRef(createCascadeRefreshState());
84
130
  const baseIntensity = entry.intensity ?? 1.3;
85
131
  const maxFar = resolveShadowDistance(profile, entry.shadowMaxFar);
86
132
  const shadowMapSize = resolveShadowMapSize(profile, entry.shadowMapSize);
87
133
  const cascades = Math.max(2, Math.min(4, Math.floor(resolveCascadeCount(profile, entry.cascades))));
88
- const refreshScale = shadowRefreshScale(shadowMapSize);
89
134
  useEffect(() => {
90
135
  const lightDirection = new THREE.Vector3(-entry.position[0], -entry.position[1], -entry.position[2]);
91
136
  if (lightDirection.lengthSq() < 1e-8)
@@ -112,6 +157,8 @@ export function CascadedShadows({ entry, profile, modulation, }) {
112
157
  }
113
158
  const unbind = bindCsmMaterials(scene, csm);
114
159
  csmRef.current = csm;
160
+ lightDirectionRef.current.copy(lightDirection);
161
+ refreshRef.current = createCascadeRefreshState();
115
162
  return () => {
116
163
  unbind();
117
164
  releaseCascadedShadows(csm);
@@ -145,7 +192,7 @@ export function CascadedShadows({ entry, profile, modulation, }) {
145
192
  }
146
193
  csm.camera = camera;
147
194
  csm.update();
148
- scheduleCascadeRefresh(csm.lights, frameRef.current++, refreshScale);
195
+ refreshCascadesOnTexelStep(csm.lights, lightDirectionRef.current, refreshRef.current);
149
196
  });
150
197
  return null;
151
198
  }
@@ -45,6 +45,19 @@ export declare function IsolatedEntityModel({ model, instanceId, measure, fallba
45
45
  measure?: MeasureTarget;
46
46
  fallback?: ReactNode;
47
47
  }): import("react").JSX.Element;
48
+ /**
49
+ * One prop drawn on its own, at its own camera distance: the same level switch the batched prop path
50
+ * runs, so a prop placed at runtime (`scene.object.place`) drops to its cheaper asset exactly like an
51
+ * authored one. Only the active level mounts, so both levels never draw at once, and the level changes
52
+ * only when the shared selector clears its hysteresis edge. Distance is measured from the placement the
53
+ * scene store holds, through memoized scratch vectors, so a frame allocates nothing.
54
+ * @internal
55
+ */
56
+ export declare function SingleVariantModel({ model, instanceId, measure, }: {
57
+ model: ModelConfig;
58
+ instanceId: string;
59
+ measure?: MeasureTarget;
60
+ }): import("react").JSX.Element;
48
61
  /** Where a measured model reports its rendered bounds: an entity kind or an object catalog id. */
49
62
  export interface MeasureTarget {
50
63
  target: "entity" | "object";
@@ -1,10 +1,11 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useFrame, useLoader, useThree } from "@react-three/fiber";
3
- import { Component, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef } from "react";
3
+ import { Component, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
4
4
  import * as THREE from "three";
5
5
  import { parseModelHiddenNodes } from "@jgengine/core/editor/modelNodeVisibility";
6
6
  import { createSpriteClipPlayer } from "@jgengine/core/render/sprite2d";
7
7
  import { reportFallbackSeam } from "@jgengine/core/devtools/fallbackSeams";
8
+ import { createModelVariantSelector } from "@jgengine/core/world/modelLod";
8
9
  import { useOptionalGameContext } from "@jgengine/react/provider";
9
10
  import { detectKtx2Support, sharedGltfLoader } from "./modelLoad.js";
10
11
  import { modelAssetRequests } from "./modelAssets.js";
@@ -16,6 +17,7 @@ import { useFootIk } from "./useFootIk.js";
16
17
  import { resolveRigNode } from "./rigNode.js";
17
18
  import { PartMotionRig } from "./PartMotion.js";
18
19
  import { syncSpriteFrame } from "./spriteRender.js";
20
+ import { singleVariantLevel, singleVariantModel } from "./singleVariantLod.js";
19
21
  import { applyMaterialOverride } from "../materialOverride.js";
20
22
  import { applyPaintTextureToMaterials, cacheStandardMaterials, cloneModelScene, createPaintCanvas, disposeModelScene, ownModelMaterial, disposePaintCanvas, syncPaintCanvas, } from "./modelRender.js";
21
23
  export function EntitySprite({ sprite }) {
@@ -90,7 +92,41 @@ export function IsolatedModelPart({ model, seam, children, }) {
90
92
  return (_jsx(ModelFallbackBoundary, { fallback: null, url: model.url, seam: seam, children: _jsx(Suspense, { fallback: null, children: children }) }));
91
93
  }
92
94
  export function IsolatedEntityModel({ model, instanceId, measure, fallback, }) {
93
- return (_jsx(ModelFallbackBoundary, { fallback: fallback ?? null, url: model.url, seam: measure?.target, children: _jsx(Suspense, { fallback: null, children: _jsx(EntityModel, { model: model, instanceId: instanceId, measure: measure }) }) }));
95
+ const levels = model.variants !== undefined && instanceId !== undefined;
96
+ return (_jsx(ModelFallbackBoundary, { fallback: fallback ?? null, url: model.url, seam: measure?.target, children: _jsx(Suspense, { fallback: null, children: levels ? (_jsx(SingleVariantModel, { model: model, instanceId: instanceId, measure: measure })) : (_jsx(EntityModel, { model: model, instanceId: instanceId, measure: measure })) }) }));
97
+ }
98
+ /**
99
+ * One prop drawn on its own, at its own camera distance: the same level switch the batched prop path
100
+ * runs, so a prop placed at runtime (`scene.object.place`) drops to its cheaper asset exactly like an
101
+ * authored one. Only the active level mounts, so both levels never draw at once, and the level changes
102
+ * only when the shared selector clears its hysteresis edge. Distance is measured from the placement the
103
+ * scene store holds, through memoized scratch vectors, so a frame allocates nothing.
104
+ * @internal
105
+ */
106
+ export function SingleVariantModel({ model, instanceId, measure, }) {
107
+ const ctx = useOptionalGameContext();
108
+ const variants = model.variants;
109
+ const selector = useMemo(() => (variants === undefined ? null : createModelVariantSelector(variants)), [variants]);
110
+ const [level, setLevel] = useState(0);
111
+ const cameraAt = useMemo(() => new THREE.Vector3(), []);
112
+ const probe = useMemo(() => new THREE.Vector3(), []);
113
+ // Warm every level at mount, so the switch swaps a cached clone instead of suspending on a load.
114
+ for (const candidate of variants?.levels ?? [])
115
+ useLoader.preload(sharedGltfLoader, candidate.url);
116
+ useFrame((state) => {
117
+ if (selector === null || ctx === null)
118
+ return;
119
+ const placement = ctx.scene.object.get(instanceId)?.position ?? ctx.scene.entity.get(instanceId)?.position;
120
+ if (placement === undefined)
121
+ return;
122
+ const next = singleVariantLevel(selector, instanceId, state.camera, placement, cameraAt, probe);
123
+ if (next !== level)
124
+ setLevel(next);
125
+ });
126
+ const drawn = useMemo(() => singleVariantModel(model, level), [model, level]);
127
+ // The near level owns the measured bounds and collision mesh, like the batched path: a decimated
128
+ // asset must not redefine what the catalog entry collides as.
129
+ return _jsx(EntityModel, { model: drawn, instanceId: instanceId, measure: level === 0 ? measure : undefined });
94
130
  }
95
131
  function BoneAttachment({ rig, model, slot, position, rotation, scale, }) {
96
132
  const gltf = useLoader(sharedGltfLoader, model.url);
@@ -15,16 +15,6 @@ export declare function resolveShadowMapSize(profile?: GraphicsProfile, authored
15
15
  * @internal
16
16
  */
17
17
  export declare function resolveCascadeCount(profile?: GraphicsProfile, authored?: number): number;
18
- /**
19
- * Multiplier on the far cascades' refresh interval, by shadow map size. A tier that halves its map
20
- * also accepts a far cascade lagging a frame or two longer: at 1024 and below the depth pass is
21
- * cheap per cascade but there are several of them, and a half-res silhouette moving is harder to
22
- * see than a full-res one. `high` and `ultra` keep the cadence they had.
23
- * @internal
24
- */
25
- export declare const SHADOW_REFRESH_SCALE_BY_MAP_SIZE: Readonly<Record<number, number>>;
26
- /** Refresh-interval multiplier for a shadow map of `mapSize`. @internal */
27
- export declare function shadowRefreshScale(mapSize: number): number;
28
18
  /**
29
19
  * Map size at which a caster's silhouette is still a few texels wide, the reference
30
20
  * {@link MIN_SHADOW_CASTER_RADIUS} was measured at. A smaller map resolves less, so the smallest
@@ -20,23 +20,6 @@ export function resolveCascadeCount(profile, authored) {
20
20
  const wanted = authored ?? profile?.cascades ?? DEFAULT_CASCADES;
21
21
  return profile === undefined ? wanted : Math.min(wanted, profile.cascades);
22
22
  }
23
- /**
24
- * Multiplier on the far cascades' refresh interval, by shadow map size. A tier that halves its map
25
- * also accepts a far cascade lagging a frame or two longer: at 1024 and below the depth pass is
26
- * cheap per cascade but there are several of them, and a half-res silhouette moving is harder to
27
- * see than a full-res one. `high` and `ultra` keep the cadence they had.
28
- * @internal
29
- */
30
- export const SHADOW_REFRESH_SCALE_BY_MAP_SIZE = {
31
- 512: 2,
32
- 1024: 2,
33
- 2048: 1,
34
- 4096: 1,
35
- };
36
- /** Refresh-interval multiplier for a shadow map of `mapSize`. @internal */
37
- export function shadowRefreshScale(mapSize) {
38
- return SHADOW_REFRESH_SCALE_BY_MAP_SIZE[mapSize] ?? 1;
39
- }
40
23
  /**
41
24
  * Map size at which a caster's silhouette is still a few texels wide, the reference
42
25
  * {@link MIN_SHADOW_CASTER_RADIUS} was measured at. A smaller map resolves less, so the smallest
@@ -0,0 +1,24 @@
1
+ import type * as THREE from "three";
2
+ import type { ModelConfig } from "@jgengine/core/game/playableGame";
3
+ import type { ModelVariantSelector } from "@jgengine/core/world/modelLod";
4
+ /**
5
+ * The model an isolated prop draws at one variant level: that level's asset and no nested variants, so
6
+ * a prop rendered on its own switches assets the way a batched instance does. Out-of-range levels clamp
7
+ * to the nearest one, and a model without variants is returned untouched.
8
+ * @internal
9
+ */
10
+ export declare function singleVariantModel(model: ModelConfig, level: number): ModelConfig;
11
+ /**
12
+ * Metres from the camera's world position to a placement, written through the caller's scratch vectors
13
+ * so a frame allocates nothing.
14
+ * @internal
15
+ */
16
+ export declare function placementDistance(camera: THREE.Camera, position: readonly number[], cameraAt: THREE.Vector3, probe: THREE.Vector3): number;
17
+ /**
18
+ * The level one isolated prop draws this frame. The answer comes from the same
19
+ * {@link ModelVariantSelector} the batched prop path asks, at the prop's own camera distance, so the
20
+ * hysteresis and the `lodBandIndex` boundary are identical for a prop placed at runtime and one
21
+ * authored in the scene.
22
+ * @internal
23
+ */
24
+ export declare function singleVariantLevel(selector: ModelVariantSelector, id: string, camera: THREE.Camera, position: readonly number[], cameraAt: THREE.Vector3, probe: THREE.Vector3): number;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The model an isolated prop draws at one variant level: that level's asset and no nested variants, so
3
+ * a prop rendered on its own switches assets the way a batched instance does. Out-of-range levels clamp
4
+ * to the nearest one, and a model without variants is returned untouched.
5
+ * @internal
6
+ */
7
+ export function singleVariantModel(model, level) {
8
+ const levels = model.variants?.levels;
9
+ if (levels === undefined)
10
+ return model;
11
+ const chosen = levels[Math.min(levels.length - 1, Math.max(0, level))];
12
+ return { ...model, url: chosen.url, variants: undefined };
13
+ }
14
+ /**
15
+ * Metres from the camera's world position to a placement, written through the caller's scratch vectors
16
+ * so a frame allocates nothing.
17
+ * @internal
18
+ */
19
+ export function placementDistance(camera, position, cameraAt, probe) {
20
+ camera.getWorldPosition(cameraAt);
21
+ probe.set(position[0] ?? 0, position[1] ?? 0, position[2] ?? 0);
22
+ return cameraAt.distanceTo(probe);
23
+ }
24
+ /**
25
+ * The level one isolated prop draws this frame. The answer comes from the same
26
+ * {@link ModelVariantSelector} the batched prop path asks, at the prop's own camera distance, so the
27
+ * hysteresis and the `lodBandIndex` boundary are identical for a prop placed at runtime and one
28
+ * authored in the scene.
29
+ * @internal
30
+ */
31
+ export function singleVariantLevel(selector, id, camera, position, cameraAt, probe) {
32
+ return selector.level(id, placementDistance(camera, position, cameraAt, probe));
33
+ }