diff --git a/.env.example b/.env.example index b50caa1da..2b49cdc14 100644 --- a/.env.example +++ b/.env.example @@ -42,3 +42,19 @@ VITE_SKIP_WELCOME=false # `-preview`; set VITE_RELEASE_LABEL (≤ 24 chars) to name it explicitly. # VITE_ERROR_REPORTS_URL= # VITE_RELEASE_LABEL= + +# 69 T3: Content-Security-Policy for BUILDS (`npm run dev` sends none). The policy is written into +# every page as a (see csp.config.js) and `frame-ancestors` into build/_headers (Cloudflare +# Pages reads it; another server should send that header line itself). All optional: +# VITE_CSP_SCRIPT_SRC= # extra script origins, e.g. https://static.cloudflareinsights.com +# VITE_CSP_CONNECT_SRC= # REPLACES the default connect-src ('self' https: wss: + localhost) +# VITE_CSP_EXTRA_CONNECT= # added to the default connect-src +# VITE_CSP_IMG_SRC= # REPLACES the default img-src / media-src ('self' data: blob: https:) +# VITE_CSP_FRAME_ANCESTORS= # who may frame the app (default * — see VITE_PLAY_ORIGIN) +# VITE_CSP=off # ship no policy (a deployment that sets its own at the edge) + +# 69 T4: an optional second origin for PLAYING (same build, served twice). Play links and embeds point +# there; storage is per origin, so a played scene's page holds none of the editor's (login, AI keys, +# autosaves). On that origin the page is player-only. Unset = one origin for everything (as before). +# VITE_PLAY_ORIGIN=https://play.example.com +# VITE_APP_ORIGIN=https://app.example.com # where the player's "Open in the app" link goes diff --git a/.github/workflows/battery.yml b/.github/workflows/battery.yml new file mode 100644 index 000000000..36309e93b --- /dev/null +++ b/.github/workflows/battery.yml @@ -0,0 +1,110 @@ +# deps-auto — ONE SHARD SET OF THE E2E BATTERY (called by deps.yml: the required set, then the optional set). +# scripts/ci/battery.cjs decides which suites a shard runs (tests/e2e/battery.json); this file only builds the +# machine: the lockfile's Playwright container, core + the sibling content at the refs battery.json pins, the +# module zips, then the shard (its own dev server + local signaling server) and the artifact. +name: battery + +on: + workflow_call: + inputs: + set: + description: required | optional | confirm (re-runs the required set's reds on fresh runners) + type: string + required: true + matrix: + description: the plan job's matrix JSON for this set + type: string + required: true + playwright: + type: string + required: true + refs: + description: battery.json's refs (JSON) + type: string + required: true + deadline: + description: minutes after which a shard starts no new suite + type: number + required: true + +permissions: + contents: read + +jobs: + shard: + name: ${{ inputs.set }} ${{ matrix.shard }}/${{ matrix.of }} + runs-on: ubuntu-latest + # a hard cap far past any deadline: the deadline is what ends a shard normally + timeout-minutes: 110 + # the browsers and their OS libraries at exactly the lockfile's Playwright: no 5-minute apt step per shard + container: + image: mcr.microsoft.com/playwright:v${{ inputs.playwright }}-noble + options: --ipc=host + strategy: + fail-fast: false + matrix: ${{ fromJSON(inputs.matrix) }} + # the sibling checkouts sit beside core, where the suites' own defaults look for them. Inside a + # container the workspace is NOT ${{ github.workspace }} (that is the host path): use the env vars. + env: + E2E_NO_PERF_OFFER: '1' + MODE: quick + steps: + - uses: actions/checkout@v4 + with: + path: core + - uses: actions/checkout@v4 + with: + repository: ${{ fromJSON(inputs.refs).scenes.repo }} + ref: ${{ fromJSON(inputs.refs).scenes.ref }} + path: scenes + - uses: actions/checkout@v4 + with: + repository: ${{ fromJSON(inputs.refs).modules.repo }} + ref: ${{ fromJSON(inputs.refs).modules.ref }} + path: modules + - uses: actions/checkout@v4 + with: + repository: ${{ fromJSON(inputs.refs).packs.repo }} + ref: ${{ fromJSON(inputs.refs).packs.ref }} + path: packs + - uses: actions/setup-node@v4 + with: + node-version-file: core/.nvmrc + cache: npm + cache-dependency-path: | + core/package-lock.json + modules/package-lock.json + - name: install + run: | + command -v openssl > /dev/null || (apt-get update -qq && apt-get install -y -qq --no-install-recommends openssl > /dev/null) + (cd core && npm ci --no-audit --no-fund && npm run certs) + # the module zips the game suites install (MODULES_REPO) + (cd modules && npm ci --no-audit --no-fund && node scripts/pack.cjs --all --force) + # confirm: what the required shards left red (or unrun), from their artifacts + - uses: actions/download-artifact@v4 + if: inputs.set == 'confirm' + with: + pattern: battery-required-* + path: prev + - name: battery shard ${{ matrix.shard }}/${{ matrix.of }} (~${{ matrix.est }} min, ${{ matrix.suites }} suites) + working-directory: core + run: | + W=$GITHUB_WORKSPACE; S=$W/scenes; G=$S/games + export SCENES_DIR=$S SCENES_REPO=$S SHELL_SCENES_DIR=$G GAMES_DIR=$G FBW_SCENES=$S/examples + export MODULES_REPO=$W/modules MODULES_DIR=$W/modules PACKS_DIR=$W/packs + for g in dungeon-realms:DUNGEON_REALMS escape-room:ESCAPE football:FOOTBALL jam-room:JAM_ROOM marble-maze:MARBLE \ + mini-golf:MINIGOLF sky-run:SKY_RUN stars-room:STARS_ROOM target-toss:TARGET_TOSS towers:TOWERS \ + untangle:UNTANGLE waves:WAVES race:RACE; do export "${g#*:}_TPSCENE=$G/${g%%:*}/scene.tpscene"; done + export JELLY_TPSCENE=$S/examples/jelly-room/scene.tpscene TANK_TPSCENE=$S/examples/fluid-tank-toy/scene.tpscene \ + POOL_TPSCENE=$S/examples/pool-party/scene.tpscene + node scripts/ci/battery.cjs run --set ${{ inputs.set }} ${{ inputs.set == 'confirm' && '--from "$W/prev"' || '' }} --shard ${{ matrix.shard }} --of ${{ matrix.of }} --serve --port 5173 \ + --deadline ${{ inputs.deadline }} --out "$W/battery-out" + - uses: actions/upload-artifact@v4 + if: always() + with: + name: battery-${{ inputs.set }}-${{ matrix.shard }} + # every suite's log; screenshots and evidence only for the suites that failed (the runner deletes the + # rest). Beside core, not in it: an artefact inside the served tree reloads the app mid-test. + path: battery-out + retention-days: 14 + if-no-files-found: ignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 496f60fdb..7f1c18e6e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,6 +23,11 @@ on: push: branches: [main, release/next] workflow_call: + inputs: + # deps.yml: the e2e battery runs these suites itself, so the two browser jobs below stand down + battery: + type: boolean + default: false jobs: build: @@ -149,6 +154,7 @@ jobs: - run: npm run test:unit e2e-smoke: + if: ${{ !inputs.battery }} runs-on: ubuntu-latest # NOT a gate yet — see the header. Flip this to false once it has been green on a few # PRs in a row; until then a red here is a signal to read, not a block. @@ -195,6 +201,7 @@ jobs: # screen-reader basics (live regions, selection announce, the object-list tree). One page at a # time and DOM-only assertions, so — unlike e2e-smoke — it gates. a11y: + if: ${{ !inputs.battery }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/deps.yml b/.github/workflows/deps.yml index b02000fec..4ea9ad455 100644 --- a/.github/workflows/deps.yml +++ b/.github/workflows/deps.yml @@ -4,7 +4,9 @@ # target-branch). Every PR into `deps` runs the whole release gate here: ci.yml (build, the # svelte-check ratchet, vitest, scene-lint, kit-diff, a11y, supply chain) and perf-budget.yml, both # called as reusable workflows, then the integrators' e2e battery split into shards on -# GitHub-hosted runners (SwiftShader: no GPU). `deps-gate` is the one required check; the ruleset on +# GitHub-hosted runners (SwiftShader: no GPU, battery.yml): the REQUIRED set gates; the OPTIONAL set (red on +# SwiftShader with unchanged code, each with its reason in tests/e2e/battery.json) runs after it and is only +# reported. `deps-gate` is the one required check; the ruleset on # `deps` also requires the `pc-battery` commit status, which the GPU check on a desktop posts for a # PR that changes what the app ships (scripts/ci/pc-battery.sh — pulled from there, no runner of ours # in this public repo) and this workflow posts as "not needed" for every other PR. @@ -82,17 +84,29 @@ jobs: needs: scope if: needs.scope.outputs.gate_ci == 'true' uses: ./.github/workflows/ci.yml + with: + battery: true + # the budget gate in three parallel parts (~20 min each instead of ~60 in one) perf: needs: scope if: needs.scope.outputs.gate_ci == 'true' + strategy: + fail-fast: false + matrix: + part: [1, 2, 3] uses: ./.github/workflows/perf-budget.yml + with: + part: ${{ matrix.part }} + parts: 3 plan: needs: scope runs-on: ubuntu-latest outputs: matrix: ${{ steps.plan.outputs.matrix }} + optional_matrix: ${{ steps.plan.outputs.optional_matrix }} + optional_shards: ${{ steps.plan.outputs.optional_shards }} playwright: ${{ steps.pw.outputs.version }} refs: ${{ steps.refs.outputs.refs }} steps: @@ -104,81 +118,60 @@ jobs: - id: refs run: echo "refs=$(node -p "JSON.stringify(require('./tests/e2e/battery.json').refs)")" >> "$GITHUB_OUTPUT" + # the suites that gate (sized by battery.json to finish inside the hour) e2e: - name: e2e ${{ matrix.shard }}/${{ matrix.of }} needs: plan + uses: ./.github/workflows/battery.yml + with: + set: required + matrix: ${{ needs.plan.outputs.matrix }} + playwright: ${{ needs.plan.outputs.playwright }} + refs: ${{ needs.plan.outputs.refs }} + deadline: 75 + + # SwiftShader makes any one suite fail now and then, twice on one loaded runner too: a required red (or a suite + # the deadline left unrun) gets two more tries on a FRESH runner before it counts + confirm: + needs: [plan, e2e] + if: always() && needs.e2e.result == 'failure' + uses: ./.github/workflows/battery.yml + with: + set: confirm + matrix: '{"include":[{"shard":1,"of":3,"suites":0,"est":0},{"shard":2,"of":3,"suites":0,"est":0},{"shard":3,"of":3,"suites":0,"est":0}]}' + playwright: ${{ needs.plan.outputs.playwright }} + refs: ${{ needs.plan.outputs.refs }} + deadline: 20 + + # red or flaky on SwiftShader with the same red on unchanged code: run, reported, never waited for. They start + # AFTER the required shards and the confirm step (the free plan runs 20 jobs at once); deps-gate does not need them. + e2e-optional: + needs: [plan, e2e, confirm] + if: always() && needs.plan.outputs.optional_shards != '0' + uses: ./.github/workflows/battery.yml + with: + set: optional + matrix: ${{ needs.plan.outputs.optional_matrix }} + playwright: ${{ needs.plan.outputs.playwright }} + refs: ${{ needs.plan.outputs.refs }} + deadline: 40 + + optional-report: + needs: e2e-optional + if: always() && needs.e2e-optional.result != 'skipped' runs-on: ubuntu-latest - timeout-minutes: 100 - # the browsers and their OS libraries at exactly the lockfile's Playwright: no 5-minute apt step per shard - container: - image: mcr.microsoft.com/playwright:v${{ needs.plan.outputs.playwright }}-noble - options: --ipc=host - strategy: - fail-fast: false - matrix: ${{ fromJSON(needs.plan.outputs.matrix) }} - # the sibling checkouts sit beside core, where the suites' own defaults look for them. Inside a - # container the workspace is NOT ${{ github.workspace }} (that is the host path): use the env vars. - env: - E2E_NO_PERF_OFFER: '1' - MODE: quick steps: - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 with: - path: core - - uses: actions/checkout@v4 - with: - repository: ${{ fromJSON(needs.plan.outputs.refs).scenes.repo }} - ref: ${{ fromJSON(needs.plan.outputs.refs).scenes.ref }} - path: scenes - - uses: actions/checkout@v4 - with: - repository: ${{ fromJSON(needs.plan.outputs.refs).modules.repo }} - ref: ${{ fromJSON(needs.plan.outputs.refs).modules.ref }} - path: modules - - uses: actions/checkout@v4 - with: - repository: ${{ fromJSON(needs.plan.outputs.refs).packs.repo }} - ref: ${{ fromJSON(needs.plan.outputs.refs).packs.ref }} - path: packs - - uses: actions/setup-node@v4 - with: - node-version-file: core/.nvmrc - cache: npm - cache-dependency-path: | - core/package-lock.json - modules/package-lock.json - - name: install - run: | - command -v openssl > /dev/null || (apt-get update -qq && apt-get install -y -qq --no-install-recommends openssl > /dev/null) - (cd core && npm ci --no-audit --no-fund && npm run certs) - # the module zips the game suites install (MODULES_REPO) - (cd modules && npm ci --no-audit --no-fund && node scripts/pack.cjs --all --force) - - name: battery shard ${{ matrix.shard }}/${{ matrix.of }} (~${{ matrix.est }} min, ${{ matrix.suites }} suites) - working-directory: core - run: | - W=$GITHUB_WORKSPACE; S=$W/scenes; G=$S/games - export SCENES_DIR=$S SCENES_REPO=$S SHELL_SCENES_DIR=$G GAMES_DIR=$G FBW_SCENES=$S/examples - export MODULES_REPO=$W/modules MODULES_DIR=$W/modules PACKS_DIR=$W/packs - for g in dungeon-realms:DUNGEON_REALMS escape-room:ESCAPE football:FOOTBALL jam-room:JAM_ROOM marble-maze:MARBLE \ - mini-golf:MINIGOLF sky-run:SKY_RUN stars-room:STARS_ROOM target-toss:TARGET_TOSS towers:TOWERS \ - untangle:UNTANGLE waves:WAVES race:RACE; do export "${g#*:}_TPSCENE=$G/${g%%:*}/scene.tpscene"; done - export JELLY_TPSCENE=$S/examples/jelly-room/scene.tpscene TANK_TPSCENE=$S/examples/fluid-tank-toy/scene.tpscene \ - POOL_TPSCENE=$S/examples/pool-party/scene.tpscene - node scripts/ci/battery.cjs run --shard ${{ matrix.shard }} --of ${{ matrix.of }} --serve --port 5173 --out "$W/battery-out" - - uses: actions/upload-artifact@v4 - if: always() - with: - name: battery-${{ matrix.shard }} - # every suite's log; screenshots and evidence only for the suites that failed (the runner deletes the - # rest). Beside core, not in it: an artefact inside the served tree reloads the app mid-test. - path: battery-out - retention-days: 14 - if-no-files-found: ignore + pattern: battery-optional-* + path: battery + - name: optional suites (report only) + run: node scripts/ci/battery.cjs report battery || true # THE required check (the ruleset on `deps` names it) gate: name: deps-gate - needs: [scope, ci, perf, plan, e2e] + needs: [scope, ci, perf, plan, e2e, confirm] if: always() && needs.scope.result == 'success' runs-on: ubuntu-latest permissions: @@ -190,7 +183,12 @@ jobs: - uses: actions/checkout@v4 - uses: actions/download-artifact@v4 with: - pattern: battery-* + pattern: battery-required-* + path: battery + - uses: actions/download-artifact@v4 + if: needs.confirm.result != 'skipped' + with: + pattern: battery-confirm-* path: battery - name: battery report id: report diff --git a/.github/workflows/perf-budget.yml b/.github/workflows/perf-budget.yml index eb3918cb9..0d4d44bb3 100644 --- a/.github/workflows/perf-budget.yml +++ b/.github/workflows/perf-budget.yml @@ -20,18 +20,26 @@ on: push: branches: [main, release/next] workflow_call: + inputs: + # deps-auto: deps.yml runs the gate as `parts` parallel jobs, this one = `part` (scripts/perf-games.cjs --part) + part: + type: number + default: 0 + parts: + type: number + default: 1 workflow_dispatch: permissions: contents: read concurrency: - group: perf-budget-${{ github.ref }} + group: perf-budget-${{ github.ref }}-${{ inputs.part }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: budget: - name: perf-budget + name: perf-budget${{ inputs.part && format(' {0}/{1}', inputs.part, inputs.parts) || '' }} runs-on: ubuntu-latest timeout-minutes: 75 steps: @@ -91,13 +99,14 @@ jobs: APP_URL: https://localhost:5173/ PERF_SCENES_DIR: ${{ runner.temp }}/perf-deps/scenes PERF_MODULES_DIR: ${{ runner.temp }}/perf-deps/modules - run: node scripts/perf-games.cjs --check --label ci --out perf/out + PART: ${{ inputs.part && format('{0}/{1}', inputs.part, inputs.parts) || '' }} + run: node scripts/perf-games.cjs --check --label ci${PART:+-${PART%/*}} --out perf/out ${PART:+--part $PART} - name: the table if: always() uses: actions/upload-artifact@v7 with: - name: perf-budget + name: perf-budget${{ inputs.part && format('-{0}', inputs.part) || '' }} path: perf/out/ if-no-files-found: ignore diff --git a/CHANGELOG.md b/CHANGELOG.md index 4529c8f1e..88acb8f78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,76 @@ per release, newest first. HTML comments like this one are stripped before rendering, so maintainer notes stay out of the user-facing window. --> +## 1.43.2 — A dependency fix 🔧 + +- 🔧 Updated **body-parser** (a security advisory, GHSA-27r3-jxmh-p828) in the development tools and the agent bridge. + The app you open in the browser does not use it and is unchanged; the downloadable release builds are published + again from this version. + +## 1.43.1 — Security: scene code runs in a sandbox 🔒 + +A security fix ([GHSA-r74q-9hg7-wrgr](https://github.com/theprototype-app/core/security/advisories/GHSA-r74q-9hg7-wrgr)). +Until now, code carried inside a scene — Script nodes, Behaviour files, the Player code of a Character Controller and +**Custom (code)** HUD elements — ran as part of the app page itself, so a scene someone opened (a shared play link, an +embed, a session peer's scene) could read what the page keeps: AI provider keys and, on theprototype.app, your sign-in. +Please update; self-hosters, rebuild from this release. + +- 🔒 **Scene code runs in a sandbox**: a separate JavaScript engine (QuickJS, WebAssembly) that has only the scene API + it is given — no page, storage, cookies, network or DOM. Your scripts and the bundled games work as before; a + scene with code loads the engine while the scene is opening. +- 🧩 **Custom (code) HUD elements** draw through a small document whose markup is cleaned before it is shown + (text, boxes, styles, SVG, a 2D canvas). Event listeners, timers and images by URL are not available inside one. +- 🛡️ **Built apps ship a Content-Security-Policy**, so injected scripts do not run. Self-hosting? The policy is in + every page; `build/_headers` holds the one line (`frame-ancestors`) a server has to send itself. +- 🔑 A diagnostics bundle never contains your API keys. +- 📡 An invite link that names a different signaling server asks before switching to it. +- ▶️ Optional for hosted setups: `VITE_PLAY_ORIGIN` serves play links and embeds from a second origin. On + theprototype.app, play links and embeds now open from play.theprototype.app. +- 🧱 Installed modules are trusted code and are unchanged — install only modules you trust (see SECURITY.md). +- ☁️ On theprototype.app: your sign-in renews itself while you use the app and is no longer kept where page code + can read it; **Sign out everywhere** ends every session at once; and roles in a room are verified — only the host + or an admin can change who may edit, and when the host leaves an admin or editor takes over. + +## 1.43.0 — Scatter everywhere: any model, on any object, edited by hand, the same on every device 🌿 + +Scatter grew up: pack items, prefabs and mixes of models, on any object (not just terrains), filled to a count with +masks and spacing, then edited one instance at a time — and still only a few hundred bytes to send. + +### 🪨 What you can scatter + +- **Pack items** (a Nature Kit rock), **prefabs** and **Use as scatter source** on any object, as well as primitives + and Library models. A prefab scatters as its look; **To objects** brings the real prefab back, logic and all. +- 🎲 **Mix models by weight** (three rocks at 50/30/20), with **Tint** (a little colour variation) and **Sink** (push + them into the ground). +- A scatter is its settings + a seed + your hand edits: everyone in the session, and every reload, gets exactly the + same instances. **Re-roll** gives a new layout and keeps every instance you edited. + +### 🌍 Scatter on any object + +- Properties ▸ Scatter ▸ **Add scatter** on any mesh or placed model — moss on a rock, barnacles on a ship. The + instances move, turn and scale with it. +- **Fill** by count or density, keep a **minimum spacing**, and limit it by **slope**, **height** or a **painted + terrain layer**. + +### ✋ Edit instances + +- The Scatter brush has tools now: **Paint, Erase, Rotate, Scale, Relax** and **Select** — click or box-select + instances, move/turn/size them with the gizmo, re-roll or delete them. **Affects** limits a tool to one model. +- 🔁 **Convert both ways**: instances **To objects**, or several copies of one object ▸ **Make scatter**. +- Terrain paint ▸ **Detail** sets each terrain's paint resolution (64–512). + +### 📱 Fast on every device + +- Far scatter fades out to stay inside each device's budget (Quest, phone, desktop); nearby instances and every + collider stay the same everywhere, so a ball bounces off the same rock on a phone and a PC. Settings ▸ Scene ▸ + Performance ▸ **Distant scatter**. + +### 🔧 Under the hood + +- Updated: svelte 5.57.2, vite 8.3.5, vitest 5.0.5, @threlte/extras 9.22.3, three-mesh-bvh 0.9.16, @lucide/svelte + 1.61.0, iwer 2.7.0, axe-core 4.14.0, prettier 3.9.10, Playwright 1.64.1 (three stays 0.186.1). +- Dependency updates now arrive automatically, tested by the full e2e battery before they reach a release. + ## 1.42.0 — Together and anywhere: type code together, pop the editor out to a second screen, play on a real table 🤝 Two people can now write the same script at once, the code editor can live on its own monitor, and an Android phone can diff --git a/CLAUDE.md b/CLAUDE.md index a1a120aae..7c54a3b11 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2667,6 +2667,14 @@ loadable play content. Everything a user does must be visible to connected peers 2. Never send on a timer after connecting — peerjs silently drops pre-open messages. Send in the connection `open` callback (`sendHandshake`) or retry-until-`conn.open` (`sendNodes`/`sendAnnotations`/`sendModuleStates` pattern). + **`broadcast` skips a conn that is not open**, and an approval DIAL-BACK takes a few + hundred ms to open on a fast box and 15 s on a GitHub runner — a room edit in that window + used to reach the joiner only if its full-state reply happened to be computed later (the + slow GLTF path; the 1.43.2 scatter suites' "B has the terrain" on GitHub). scatter-sync: + ROOM_SCOPED broadcasts to a peer in `approvedDialBacks` are HELD (`dialBackQueue.js`, + 300 msgs / 120 s, overflow abandons the whole queue — the full-state reply still covers + it) and flushed, re-gated through `withholdFrom`, at the END of `sendHandshake`. + Suite `dialback-sync` (approve + create in one tick). 3. Each domain needs a full-state reply for late joiners (`get` in `sendHandshake`) and cleanup in `handleDisconnected`. 4. Key everything by uuid; peers assume the **same app version** (catalogs/builders/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 98baa1989..cad52feba 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -48,6 +48,13 @@ two-peer session on your desk. - Screenshots/clips for anything visual. Playwright can capture them. - Look at issues labeled **good first issue** if you want a scoped entry point. +## Dependency updates + +Dependabot opens its PRs against the `deps` branch, where `deps.yml` runs the full gate plus the e2e +battery and merges patch / development-only / Actions updates by itself. Please do not bump +dependencies in a feature PR; if one needs a newer version, say so in the PR and it goes through +`deps`. The whole loop is in [RELEASING.md](RELEASING.md#dependency-updates-the-deps-branch). + ## Questions GitHub Discussions for ideas/help, issues for bugs (template asks for repro diff --git a/OPEN-CORE.md b/OPEN-CORE.md index d8835e273..97ed2fbef 100644 --- a/OPEN-CORE.md +++ b/OPEN-CORE.md @@ -281,6 +281,16 @@ of `s` (`whatsNew.hasDeepLink()`): the first-run welcome overlay stands down for boot exactly as it does for an invite. What the id means, fetching the record, loading it (`loadRemoteScene`) and honouring `play=1` (`startPlay`) are the plugin's. +#### 69 T4: a separate play origin — `playOrigin` · `playUrl(path)` — additive, no bump + +A deployment may serve the same build from a second origin (`VITE_PLAY_ORIGIN`, e.g. +`https://play.theprototype.app`) and send play links and embeds there, so a played scene runs on a +page that holds none of the editor's storage. `api.playOrigin` is `true` on that origin (the page is +player-only for its whole life, the embed chrome) — a plugin must not offer sign-in or keep a token +there. `api.playUrl('/?s=&play=1')` is the absolute URL to put in a play link or an embed: on +the play origin when one is configured, else on this origin. Probe both (`typeof`): older engines +have neither. `VITE_APP_ORIGIN` is the editor origin the player's "Open in the app" link returns to. + ### 5. Continuity — scene changes and projects (CL-5) — additive, no bump Five members for a tier that keeps scenes and whole projects on a server (cloud saves, rooms that survive diff --git a/RELEASING.md b/RELEASING.md index 768e5289c..34fa79c59 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -45,6 +45,66 @@ The toolchain is pinned too: `.nvmrc` / `.mise.toml` (Node) and `packageManager` `devEngines` in package.json (npm). Bump them together — ci.yml's "toolchain pins agree" step fails when they disagree. +## Dependency updates: the `deps` branch + +Dependabot's version updates never open against `main`. They open against the long-lived **`deps`** +branch (`.github/dependabot.yml`, `target-branch: deps`; monthly, npm at the root and in `tools/agent`, +plus GitHub Actions), and `.github/workflows/deps.yml` decides what happens to each one: + +1. **The gate.** ci.yml (with `battery: true`: its two browser jobs stand down, the battery covers them) and + perf-budget.yml (in three parallel parts, `perf-games.cjs --check --part k/3`) run as reusable workflows, then + the e2e battery on GitHub-hosted runners (SwiftShader, no GPU) through `.github/workflows/battery.yml`: + - the **required** set (`tests/e2e/battery.json` minus `skip` and `optional`) in shards sized by recorded + runner seconds (`shards.target_minutes`; 15-17 shards + the 3 perf parts = the free plan's 20-job cap); + - **confirm**: every required red, and whatever a shard's deadline left unrun, gets two more tries on a fresh + runner; green there = `flaky` (reported), red there = a red. SwiftShader fails any one suite now and then, + and twice on the same loaded runner too, so a same-shard retry proves nothing; + - the **optional** set (red or flaky on SwiftShader with unchanged code, each with its reason on its line) + runs AFTER that and is only reported (`optional-report`); nothing waits for it. + One check sums it all up: **`deps-gate`**. Measured on unchanged code (2026-10-24): about an hour from push + to `deps-gate`, the optional set ~45 min more. The shard runner is `scripts/ci/battery.cjs`. +2. **The GPU half.** `pc-battery` is a commit status posted from a desktop with a real GPU + (`scripts/ci/pc-battery.sh`): /kit, the app surfaces (base shot twice for the noise floor), the level shots + (head against the closer of two base runs; under 2 % of the pixels is animation) and frame time on the + heaviest games (base/head alternating twice; head within the base runs' range), summary in a secret gist + linked from the status. Nothing on GitHub can reach that machine and the repo registers no self-hosted + runner (it is public): a user timer there runs `scripts/ci/pc-battery.sh --poll` every 15 minutes from a + checkout of `deps`, which picks up open PRs into `deps` whose `deps-gate` is green, from a branch of this + repo, by Dependabot or the maintainer, and with no `pc-battery` status yet (a `pending` one older than 3 h + counts as abandoned: the desktop slept). For a PR that changes nothing the app ships, deps.yml posts + `pc-battery` itself as "not needed". By hand: `scripts/ci/pc-battery.sh ` (`--dry-run` says what it + would do). +3. **The policy** (`scripts/ci/deps-policy.cjs`, unit-tested in `tests/unit/depsCi.test.js`): + + | update | once green | + |---|---| + | patch, a development-only package, any GitHub Action | GitHub auto-merge into `deps` | + | a minor of something that ships (a `dependency`, or svelte / vite / kit / tailwind / flowbite) | label `deps-ready`; the monthly deps lane merges it after the release notes + a visual review | + | any Playwright bump | `deps-ready`: the e2e hosts outside GitHub must serve the same browser first | + | a major | `needs-migration` + a comment; a planned migration, never automation | + + The ruleset on `deps` requires `deps-gate` and `pc-battery`, so an auto-merge of a runtime patch waits + for the desktop. The ruleset also forbids deleting `deps`: it is never cleaned up with merged branches. + +**Integrators:** at union time, `git merge origin/deps` into `feat/` before the battery, so the bumps +ship with a tested union and `main` never takes a dependency change on its own. After the release, bring +`deps` up to the released `main` (`git push origin main:deps` when it fast-forwards, else merge `main` +into `deps`). + +**Security updates** are GitHub's exception: they always target `main`, whatever `target-branch` says. +deps.yml runs the same battery and labels on them there; nothing auto-merges into `main`, a person does. + +**When the battery list moves** (an integrator's battery added or dropped suites), refresh it from that +run: `node scripts/ci/battery.cjs refresh --list ... --summary ...` +(names come from the lists, seconds from the summaries). `--results ` takes seconds measured on the +runners instead (deps.yml's `battery-durations` artifact). A suite goes into `optional` (runs, reported, +does not gate) only when it is red or flaky on the unchanged base too, with that reason on its line — +`--red ... --reason "…"` does it from downloaded `battery-*` artifacts of runs on unchanged code; +`skip` is for what a public runner cannot do at all (the self-hosted signaling box, a hosts-mapped origin, +a built app). A required suite that keeps surviving confirm as red on unchanged code belongs in `optional` +too: the gate must be green on `deps` itself. The shard count follows the recorded seconds +(`shards.target_minutes`, at most `shards.max`); keep required shards + 3 under the 20-job cap. + ## Recreate `release/next` after the tag push The repo has `delete_branch_on_merge: true`, so merging the release PR DELETES diff --git a/SECURITY.md b/SECURITY.md index 458eaf1b6..2168cbcff 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -16,8 +16,24 @@ release notes unless they prefer otherwise. - WebRTC exposes peer IP addresses to session members by design (disclosed in the privacy policy). Reports about that design are out of scope; ways to learn peers' addresses **without** being approved into the session are in scope. -- The app executes replicated script/flow content deterministically on every - peer. Escapes from that sandboxed evaluation are in scope and serious. +- Code that travels with a scene — Script nodes, custom node definitions, the + Character Controller's player code, behaviour files and custom HUD elements — + runs inside a separate JavaScript engine compiled to WebAssembly (QuickJS). It + has no access to the page: no DOM, storage, cookies, network, timers or + `import()`. It reaches the scene only through the script API (object views, + variables, events, raycasts, the HUD element proxy), each call has a time + limit, and a custom HUD element's output is rebuilt through an allowlist + sanitizer (no scripts, event handlers or remote URLs). Any way for scene + content to run code in the page, read app storage (AI keys, the login token, + autosaves) or send data off the machine is in scope and serious. +- **Installed modules are not sandboxed.** Installing a module (a zip or a URL + in the Modules manager) runs its code in the page with the same rights as the + app — install only modules you trust. A scene that needs a module asks first, + and only offers modules listed in the module gallery. +- Credentials (AI, mesh and speech-to-text keys) are stored per device in the + browser's localStorage. They are never written into scenes, `.tp` projects, + layouts, shares or the diagnostics bundle. Production builds ship a Content + Security Policy; a self-hoster can extend it (see `docs` / `.env.example`). - Self-hosted infrastructure (signaling server, TURN) has its own repo; reports welcome at the same address. diff --git a/csp.config.js b/csp.config.js new file mode 100644 index 000000000..58cb4e8cc --- /dev/null +++ b/csp.config.js @@ -0,0 +1,101 @@ +// 69 T3 — THE CONTENT SECURITY POLICY A BUILD SHIPS, in one place. +// +// Two consumers read this file: svelte.config.js (kit.csp — SvelteKit hashes its own inline boot +// script and writes the policy into every prerendered page as a ) and +// scripts/csp-headers.cjs (postbuild — `frame-ancestors`, which a meta tag cannot carry, as a real +// header in build/_headers for Cloudflare Pages; a self-hoster's server sends the same line). The +// header carries ONLY that directive: two policies are intersected, and the meta holds the hash of +// SvelteKit's inline boot script that a hand-written header would not. +// +// Builds only: `npm run dev` stays policy-free, so self-hosting needs no config at all. +// +// What it is for. Scene code (scripts, behaviours, custom HUD elements) runs in the QuickJS guest +// and has no network or DOM at all, so the policy is defence in depth against an INJECTED script: +// `script-src` is strict (no inline script, no eval — nothing in the app needs either; wasm needs +// 'wasm-unsafe-eval'; installed modules load by blob import, hence blob:). `connect-src` is open to +// https/wss by default because users point the app at endpoints of their own at run time (a custom +// AI server, ComfyUI, a module repo, an HDRI CDN); a deployment that knows its list sets +// VITE_CSP_CONNECT_SRC to replace it. +// +// Environment (all optional, space-separated source lists): +// VITE_CSP_SCRIPT_SRC added to script-src (e.g. an analytics beacon's origin) +// VITE_CSP_CONNECT_SRC REPLACES the default connect-src +// VITE_CSP_EXTRA_CONNECT added to connect-src (keeps the default) +// VITE_CSP_IMG_SRC REPLACES the default img-src / media-src +// VITE_CSP_FRAME_ANCESTORS the header's frame-ancestors (default: * while play/embeds are served +// from this origin, 'self' once VITE_PLAY_ORIGIN names another one) +// VITE_CSP=off no policy at all (a deployment that sets its own at the edge) + +const KEYWORDS = new Set(['self', 'none', 'unsafe-inline', 'unsafe-eval', 'wasm-unsafe-eval', 'strict-dynamic', 'unsafe-hashes']); + +/** @param {string | undefined} value */ +const list = (value) => + String(value ?? '') + .split(/\s+/) + .map((s) => s.trim().replace(/^'(.*)'$/, '$1')) + .filter(Boolean); + +/** the origin of an absolute http(s) URL, else '' @param {string | undefined} url */ +function originOf(url) { + try { + const u = new URL(String(url ?? '')); + return u.protocol === 'https:' || u.protocol === 'http:' ? u.origin : ''; + } catch { + return ''; + } +} + +/** a self-hosted PeerJS server on plain ws (VITE_PEER_SECURE=false) @param {Record} env */ +function insecurePeer(env) { + if (String(env.VITE_PEER_SECURE ?? '').toLowerCase() !== 'false' || !env.VITE_PEER_HOST) return []; + const port = env.VITE_PEER_PORT ? ':' + env.VITE_PEER_PORT : ''; + return ['ws://' + env.VITE_PEER_HOST + port, 'http://' + env.VITE_PEER_HOST + port]; +} + +/** + * The directives, in kit.csp's shape (keywords bare — SvelteKit quotes them). + * @param {Record} env @returns {Record | null} null = no policy + */ +export function cspDirectives(env) { + if (String(env.VITE_CSP ?? '').toLowerCase() === 'off') return null; + const plugin = originOf(env.VITE_CLOUD_PLUGIN); + const connect = env.VITE_CSP_CONNECT_SRC + ? list(env.VITE_CSP_CONNECT_SRC) + : ['self', 'https:', 'wss:', 'blob:', 'data:', 'http://localhost:*', 'http://127.0.0.1:*', ...insecurePeer(env)]; + const media = env.VITE_CSP_IMG_SRC ? list(env.VITE_CSP_IMG_SRC) : ['self', 'data:', 'blob:', 'https:']; + const uniq = (/** @type {string[]} */ a) => [...new Set(a.filter(Boolean))]; + return { + 'default-src': ['self'], + 'script-src': uniq(['self', 'wasm-unsafe-eval', 'blob:', plugin, ...list(env.VITE_CSP_SCRIPT_SRC)]), + 'worker-src': ['self', 'blob:'], + // svelte transitions and style attributes are inline styles + 'style-src': ['self', 'unsafe-inline'], + 'img-src': media, + 'media-src': media, + 'font-src': ['self', 'data:'], + 'connect-src': uniq([...connect, plugin, ...list(env.VITE_CSP_EXTRA_CONNECT)]), + 'frame-src': ['self', 'https:'], + 'manifest-src': ['self'], + 'object-src': ['none'], + 'base-uri': ['self'], + 'form-action': ['self'] + }; +} + +/** the configured play origin ('' = none) @param {Record} env */ +export function playOrigin(env) { + return originOf(env.VITE_PLAY_ORIGIN); +} + +/** frame-ancestors for the app origin's header @param {Record} env @returns {string[]} */ +export function frameAncestors(env) { + if (env.VITE_CSP_FRAME_ANCESTORS) return list(env.VITE_CSP_FRAME_ANCESTORS); + return playOrigin(env) ? ['self'] : ['*']; +} + +/** one directive set as header text (keywords quoted) @param {Record} directives */ +export function cspText(directives) { + return Object.entries(directives) + .map(([name, sources]) => [name, ...sources.map((s) => (KEYWORDS.has(s) ? `'${s}'` : s))].join(' ')) + .join('; '); +} diff --git a/package-lock.json b/package-lock.json index 7d11dc243..310c55edd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "theprototype.app", - "version": "1.42.0", + "version": "1.43.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "theprototype.app", - "version": "1.42.0", + "version": "1.43.2", "license": "MIT", "dependencies": { "@codemirror/lang-javascript": "^6.2.5", @@ -14,11 +14,12 @@ "@gltf-transform/core": "^4.5.1", "@gltf-transform/extensions": "^4.5.1", "@gltf-transform/functions": "^4.5.1", - "@lucide/svelte": "^1.48.0", + "@jitl/quickjs-wasmfile-release-sync": "0.32.0", + "@lucide/svelte": "^1.61.0", "@shaderfrog/core": "^4.0.2", "@shaderfrog/glsl-parser": "^7.0.1", "@threlte/core": "^8.6.1", - "@threlte/extras": "^9.22.0", + "@threlte/extras": "^9.22.3", "@threlte/xr": "^1.6.1", "@xyflow/svelte": "^1.7.0", "codemirror": "^6.0.2", @@ -29,48 +30,52 @@ "n8ao": "^2.0.1", "peerjs": "^1.5.5", "postprocessing": "^6.39.5", + "quickjs-emscripten-core": "0.32.0", "svelte-hamburgers": "^5.0.0", "three": "^0.186.1", "three-bvh-csg": "^0.0.18", - "three-mesh-bvh": "^0.9.15", + "three-mesh-bvh": "^0.9.16", "y-codemirror.next": "^0.3.6", "y-protocols": "^1.0.7", "yjs": "^13.6.33" }, "devDependencies": { - "@axe-core/playwright": "4.13.0", - "@iwer/devui": "2.6.0", + "@axe-core/playwright": "4.14.0", + "@iwer/devui": "2.7.0", "@sveltejs/adapter-static": "^3.0.10", "@sveltejs/kit": "^2.70.3", "@sveltejs/vite-plugin-svelte": "^7.3.1", "@tailwindcss/vite": "^4.3.3", "flowbite": "^3.1.2", - "iwer": "2.6.0", + "iwer": "2.7.0", "peer": "^1.0.2", - "playwright": "^1.63.0", - "prettier": "^3.9.9", + "playwright": "^1.64.1", + "prettier": "^3.9.10", "prettier-plugin-svelte": "^4.1.1", "prettier-plugin-tailwindcss": "^0.8.1", - "svelte": "^5.57.1", + "svelte": "^5.57.2", "svelte-awesome-color-picker": "^4.1.3", - "svelte-check": "^4.7.6", + "svelte-check": "^4.7.7", "tailwindcss": "^4.3.3", "typescript": "^5.9.3", - "vite": "^8.3.3", - "vitest": "^5.0.2" + "vite": "^8.3.5", + "vitest": "^5.0.5" }, "engines": { "node": ">=24" } }, "node_modules/@axe-core/playwright": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.13.0.tgz", - "integrity": "sha512-6YLx+kxXu5GJceG4ozFg+33a2EMTdjYwWGloJ3sb9Kta5pp+ZNS53uxGVog5JetIY8s++P5UrtX+cri+u0VAVg==", + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.14.0.tgz", + "integrity": "sha512-SGUhG5PIHd2eMdpgBF2PyQLOhX+qyDR2PT4cmoS4Qk2ak+jX/rrSvst9l0PGv5WTHolKpUMvVmoHpr4cZgf1RQ==", "dev": true, "license": "MPL-2.0", "dependencies": { - "axe-core": "~4.13.0" + "axe-core": "~4.14.0" + }, + "engines": { + "node": ">=22.12.0" }, "peerDependencies": { "playwright-core": ">= 1.0.0" @@ -796,9 +801,9 @@ } }, "node_modules/@iwer/devui": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@iwer/devui/-/devui-2.6.0.tgz", - "integrity": "sha512-0wzwiUAvbGNsQOGcOyW6RR2MeWH900FjBL/mbhzWSickj+wyOR0Pl48CecwPSidDOew+6c4k29r4PJgEl/8l+g==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@iwer/devui/-/devui-2.7.0.tgz", + "integrity": "sha512-ilEXc/h+6SSlJiX6No9OKPekeO1lAAsXDHfaGZ6jFCuMUAxKopwDySYYn8Ld9vql8dh0QK39IpXdOKUiIMoGCw==", "dev": true, "license": "MIT", "dependencies": { @@ -812,7 +817,7 @@ "zustand": "^5.0.13" }, "peerDependencies": { - "iwer": "^2.5.0" + "iwer": "^2.7.0" } }, "node_modules/@iwer/devui/node_modules/three": { @@ -822,6 +827,21 @@ "dev": true, "license": "MIT" }, + "node_modules/@jitl/quickjs-ffi-types": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@jitl/quickjs-ffi-types/-/quickjs-ffi-types-0.32.0.tgz", + "integrity": "sha512-v9T+GQpmk43VDJ7d72sf0Nexhk+ArvtUihW27dy7lqAl0zBObFKtSBBIm5RBjwIhE8VwsPPm9PNuvPvNqLWUEg==", + "license": "MIT" + }, + "node_modules/@jitl/quickjs-wasmfile-release-sync": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-release-sync/-/quickjs-wasmfile-release-sync-0.32.0.tgz", + "integrity": "sha512-BKNDI/TPBfGlLNGYpLrhcDGXmIk4xHm4MRAisOBnOzpXVn9HZWsfmMAc9WMBrAHjvvds6HOikKeaOBKdPdpVrg==", + "license": "MIT", + "dependencies": { + "@jitl/quickjs-ffi-types": "0.32.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -903,9 +923,9 @@ } }, "node_modules/@lucide/svelte": { - "version": "1.48.0", - "resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.48.0.tgz", - "integrity": "sha512-bLimVDjehxu3fpu9XEUpqEkBk2sJAHAT2sVcLHhlM70EdNsXuj9ZnYENWv5xgw3+05h4i5TirAxTiTgnZZf8gw==", + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.61.0.tgz", + "integrity": "sha512-Vwj4ADoKgVogLFpEbXv2PS2hh2fj+aAqPuiNydI88Zkyb1l7veL4fXgRrOX3tgpESHhIGneNkSSYhHupvCBshg==", "license": "ISC", "peerDependencies": { "svelte": "^5" @@ -927,9 +947,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.151.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.151.0.tgz", - "integrity": "sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA==", + "version": "0.155.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.155.0.tgz", + "integrity": "sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==", "dev": true, "license": "MIT", "funding": { @@ -1002,9 +1022,9 @@ } }, "node_modules/@rolldown/binding-android-arm-eabi": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.11.tgz", - "integrity": "sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.15.tgz", + "integrity": "sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw==", "cpu": [ "arm" ], @@ -1019,9 +1039,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.11.tgz", - "integrity": "sha512-z6cTycz+iJ4PVkuL4HHW4DfTfoeU/2nqYYuSOrTmH7yHK5Y0LCOnA03V4ZNxavyVaU1oOqUgIg2klN/s+USGOA==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.15.tgz", + "integrity": "sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg==", "cpu": [ "arm64" ], @@ -1036,9 +1056,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.11.tgz", - "integrity": "sha512-jShvqNtP6vDC6/A5JOAzbVV+DkgHqhl/ScVCJEbt+TUY6QYz7YnXcrg3sLtFBniro0f/Ld50ZwCWA6f7KYD1nQ==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.15.tgz", + "integrity": "sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA==", "cpu": [ "arm64" ], @@ -1053,9 +1073,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.11.tgz", - "integrity": "sha512-f2i2xiNWq1Z1l2++q2fuhZRdLAT3aqxD6vRNm1RAxpUoBcdqNB3C0s1Bt+K+PbEx2F5F4gQp6hqKkphCY/xF9w==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.15.tgz", + "integrity": "sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A==", "cpu": [ "x64" ], @@ -1070,9 +1090,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.11.tgz", - "integrity": "sha512-4Ir5FSOKIAMr4r0kExpt1s3bMgzJU3rA45AYOHtQpls0oNeqcYBKrWMlckrYH4KCfGLfkfn1tN1dmZPMVsdXow==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.15.tgz", + "integrity": "sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ==", "cpu": [ "x64" ], @@ -1087,9 +1107,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.11.tgz", - "integrity": "sha512-/gnRDM+39BROzAN/k1OZjDPnDMcZxB/0EUxKjONO5yVkNEvlsoMDrxGNKgZi/ttFriS2gwlDNzB65pvNbFOXIQ==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.15.tgz", + "integrity": "sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ==", "cpu": [ "arm" ], @@ -1104,9 +1124,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.11.tgz", - "integrity": "sha512-PFaK8HwvAHbaKbBcDNQihjMKYvFnA5hiENx/l5tphTDz1E0WFp32l0A7aq7lyUwGsRw/xSrNIy/gIK4thrSCrw==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.15.tgz", + "integrity": "sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw==", "cpu": [ "arm64" ], @@ -1124,9 +1144,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.11.tgz", - "integrity": "sha512-AskzJUIKRLPxkruR1wLKewGbOw+EYfU/9lOrBFj4AFrEA8hPpKFnODWNu2WLaNs0QNkEb9QIJufmVZZIL/bJlg==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.15.tgz", + "integrity": "sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ==", "cpu": [ "arm64" ], @@ -1144,9 +1164,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.11.tgz", - "integrity": "sha512-qlUGAheh2yh8afH7QBgx0PrRHN85hKnNd78x8MeMhXivuevgd8vgf6/CstOzmNKY/lLTHvNTrPy98cLnAugzJw==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.15.tgz", + "integrity": "sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw==", "cpu": [ "ppc64" ], @@ -1164,9 +1184,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.11.tgz", - "integrity": "sha512-secpEad+0vCbSfn8upFySkDskv+bGPk3THSDS9Y89yc4rb4kzqHp8Dmyd9BkQW4SnhNXBZCl/6CrO//hZahNJQ==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.15.tgz", + "integrity": "sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA==", "cpu": [ "s390x" ], @@ -1184,9 +1204,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.11.tgz", - "integrity": "sha512-mOVBT3dPpkWm8XBWPmU4bf+U6dYDLeMo/9ojUmis4N0L5uu10qra5vOyngZ7/PSdoE4G9KvRt4bloRxNjLas7A==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.15.tgz", + "integrity": "sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw==", "cpu": [ "x64" ], @@ -1204,9 +1224,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.11.tgz", - "integrity": "sha512-Is78i9A8Ui4SqcxUwFJ9uMmjDn58IbVTjFWYdQestFEgeuEmHMLGNriXnVJKkwG2YiZjw8cP0zCTyDMdDGtOOg==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.15.tgz", + "integrity": "sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ==", "cpu": [ "x64" ], @@ -1224,9 +1244,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.11.tgz", - "integrity": "sha512-dUCXneZ87INUMyQ0D+C0HrEBNUPNXHaPmU5GTjyKTJEiussw9Kaj5Ln8UztPe4epV/ffvgNBEadksdYhmW6xJA==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.15.tgz", + "integrity": "sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w==", "cpu": [ "arm64" ], @@ -1241,9 +1261,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.11.tgz", - "integrity": "sha512-jByxb6qfd+bH1xUd0qnfFnb17i9sWBPY2tOavJ0l3tdr3OTu+Kvtm8cd/JV5nFt657b1VqGltxg9olOEfofXWw==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.15.tgz", + "integrity": "sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg==", "cpu": [ "arm64" ], @@ -1258,9 +1278,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.11.tgz", - "integrity": "sha512-/PzKqzAJ03i19oy2ItPvyvaVjOjBCNnfaJs8yvUdGBKmiESgnrJSQ2awd81QzFbbnAmu7YO9ZnJrDCb9VSJPRA==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.15.tgz", + "integrity": "sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ==", "cpu": [ "x64" ], @@ -1845,16 +1865,15 @@ } }, "node_modules/@threlte/extras": { - "version": "9.22.0", - "resolved": "https://registry.npmjs.org/@threlte/extras/-/extras-9.22.0.tgz", - "integrity": "sha512-1bJlcFMpY16RKMgILBAki6pPm4r2FDHx/KGmJFlFj4CxAF31VkciBjXx3nAeqiMl2Brevsr9wfaccZ2dxirzrg==", + "version": "9.22.3", + "resolved": "https://registry.npmjs.org/@threlte/extras/-/extras-9.22.3.tgz", + "integrity": "sha512-ZEoc+ZFdhTkH1IZgpU6AJMnfNMg3Y5W3jN226Z8YqAWH/OxN2FY41LwQccdel0yL0p41dm7MjuP4/CVTC8K0YQ==", "license": "MIT", "dependencies": { "@threejs-kit/instanced-sprite-mesh": "^2.5.1", "camera-controls": "^3.1.2", "three-mesh-bvh": "^0.9.1", "three-perf": "^1.0.11", - "three-viewport-gizmo": "^2.2.0", "troika-three-text": "^0.52.4" }, "peerDependencies": { @@ -2151,22 +2170,22 @@ } }, "node_modules/@vitest/mocker": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.2.tgz", - "integrity": "sha512-Z5FS00Q1SJHkB35xATsmWGdQ5WA1/0MV3CDjqyv7GavHv1OfOj145MNfHOlHk7QLes21dKFDHr8EO2zvL+9WGA==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.5.tgz", + "integrity": "sha512-xLL3lkDegJZw+MGoGaCCsFayDZthGgpAtr3Vziyfc4p7xH89K7KRPcRBBh/kEYm0Aqkt5DtTmkYpBtRV/FrHZg==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "0.3.31", - "@vitest/spy": "5.0.2", + "@vitest/spy": "5.0.5", "estree-walker": "^3.0.3", - "magic-string": "^1.2.3" + "magic-string": "^1.4.3" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "msw": "^2.4.9", + "msw": "^2.4.9 || ^3.0.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { @@ -2189,9 +2208,9 @@ } }, "node_modules/@vitest/mocker/node_modules/magic-string": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz", - "integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==", + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2199,9 +2218,9 @@ } }, "node_modules/@vitest/spy": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.2.tgz", - "integrity": "sha512-Ijc7T1nT9efNb5LxvjaBrEqw3f/QwUv5EE0nKqZxgqsaV/FxAAZ8baGylA8X/Z2oS4Lp+K74Jr6dTJsDKxJDeg==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.5.tgz", + "integrity": "sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==", "dev": true, "license": "MIT", "funding": { @@ -2317,9 +2336,9 @@ } }, "node_modules/axe-core": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.13.0.tgz", - "integrity": "sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==", + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.14.0.tgz", + "integrity": "sha512-9WTZxEjsZ7b13TH8JPmbV2z8CHbl80/2hm3XPEG4JgNdQLK81IBRXmSxHfMAOkSqQeRxT/0dwNDz2GOm3zzpcQ==", "dev": true, "license": "MPL-2.0", "engines": { @@ -2345,9 +2364,9 @@ } }, "node_modules/body-parser": { - "version": "1.20.8", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz", - "integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==", + "version": "1.20.9", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.9.tgz", + "integrity": "sha512-FISIQxV5u91Y9gvuFq3q/tA/qTqLJn3AhRcEvyozyhJTh5OBFFtv827AdHU7Rbyct4r+Ba1/99oFa1Y3yuFsdw==", "dev": true, "license": "MIT", "dependencies": { @@ -2434,9 +2453,9 @@ } }, "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", "dev": true, "license": "MIT", "engines": { @@ -3387,9 +3406,9 @@ } }, "node_modules/iwer": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/iwer/-/iwer-2.6.0.tgz", - "integrity": "sha512-DNihqCixizrNjPKScTt0aOy9+YP1pn0Jxj1V6tBOV0ceRm6YrEMLo8QhEwYspPYJ9QZjDJShDMdZAaPXDPaSAw==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/iwer/-/iwer-2.7.0.tgz", + "integrity": "sha512-zC4VfcwFenuaIU/VJgGfBcR2tfALtNZLSn2vZR7PnNU93S6d9bIK07DRuSMGMgdfPPln41stU/wZa6goWA6bRg==", "dev": true, "license": "MIT", "dependencies": { @@ -3900,9 +3919,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.18", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", - "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", "dev": true, "funding": [ { @@ -4162,13 +4181,13 @@ } }, "node_modules/playwright": { - "version": "1.63.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", - "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz", + "integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.63.0" + "playwright-core": "1.64.1" }, "bin": { "playwright": "cli.js" @@ -4178,9 +4197,9 @@ } }, "node_modules/playwright-core": { - "version": "1.63.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", - "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz", + "integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -4191,9 +4210,9 @@ } }, "node_modules/postcss": { - "version": "8.5.28", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", - "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "version": "8.5.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz", + "integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==", "dev": true, "funding": [ { @@ -4211,9 +4230,9 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.18", + "nanoid": "^3.3.20", "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" + "source-map-js": "^1.2.2" }, "engines": { "node": "^10 || ^12 || >=14" @@ -4236,9 +4255,9 @@ } }, "node_modules/prettier": { - "version": "3.9.9", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.9.tgz", - "integrity": "sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==", + "version": "3.9.10", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.10.tgz", + "integrity": "sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA==", "dev": true, "license": "MIT", "bin": { @@ -4385,6 +4404,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/quickjs-emscripten-core": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/quickjs-emscripten-core/-/quickjs-emscripten-core-0.32.0.tgz", + "integrity": "sha512-QFnPfjFey8EqknSrSxe1hZrf1/8z7/6s1QzGOmKo6++02r7QRRX7ZoyNaZh7JuVjWsVW87KnQrbZqnHkOAzUyg==", + "license": "MIT", + "dependencies": { + "@jitl/quickjs-ffi-types": "0.32.0" + } + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -4490,13 +4518,13 @@ } }, "node_modules/rolldown": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.11.tgz", - "integrity": "sha512-qpSwIyz0jHQq5qXBTNxFmE6664rJ7O+4TvPFOiOaBSrz8IOHc1koKKSqTM2H6u1UG1+TveuC6vaDHKXFOvb1Kw==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.15.tgz", + "integrity": "sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.151.0", + "@oxc-project/types": "=0.155.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -4506,21 +4534,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm-eabi": "1.2.11", - "@rolldown/binding-android-arm64": "1.2.11", - "@rolldown/binding-darwin-arm64": "1.2.11", - "@rolldown/binding-darwin-x64": "1.2.11", - "@rolldown/binding-freebsd-x64": "1.2.11", - "@rolldown/binding-linux-arm-gnueabihf": "1.2.11", - "@rolldown/binding-linux-arm64-gnu": "1.2.11", - "@rolldown/binding-linux-arm64-musl": "1.2.11", - "@rolldown/binding-linux-ppc64-gnu": "1.2.11", - "@rolldown/binding-linux-s390x-gnu": "1.2.11", - "@rolldown/binding-linux-x64-gnu": "1.2.11", - "@rolldown/binding-linux-x64-musl": "1.2.11", - "@rolldown/binding-openharmony-arm64": "1.2.11", - "@rolldown/binding-win32-arm64-msvc": "1.2.11", - "@rolldown/binding-win32-x64-msvc": "1.2.11" + "@rolldown/binding-android-arm-eabi": "1.2.15", + "@rolldown/binding-android-arm64": "1.2.15", + "@rolldown/binding-darwin-arm64": "1.2.15", + "@rolldown/binding-darwin-x64": "1.2.15", + "@rolldown/binding-freebsd-x64": "1.2.15", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.15", + "@rolldown/binding-linux-arm64-gnu": "1.2.15", + "@rolldown/binding-linux-arm64-musl": "1.2.15", + "@rolldown/binding-linux-ppc64-gnu": "1.2.15", + "@rolldown/binding-linux-s390x-gnu": "1.2.15", + "@rolldown/binding-linux-x64-gnu": "1.2.15", + "@rolldown/binding-linux-x64-musl": "1.2.15", + "@rolldown/binding-openharmony-arm64": "1.2.15", + "@rolldown/binding-win32-arm64-msvc": "1.2.15", + "@rolldown/binding-win32-x64-msvc": "1.2.15" } }, "node_modules/sade": { @@ -4812,9 +4840,9 @@ } }, "node_modules/std-env": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", - "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", "dev": true, "license": "MIT" }, @@ -4911,9 +4939,9 @@ } }, "node_modules/svelte": { - "version": "5.57.1", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.57.1.tgz", - "integrity": "sha512-Uqj49lWKB+iSSnneuwiYYJ7MZgkB+eXr0LXBhv4uDuAkXqnWmq65Sxflfvp0Lc6MdKjMUxGaeOKWJqz5SNiVIA==", + "version": "5.57.2", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.57.2.tgz", + "integrity": "sha512-1YiPCAXT0Ct6g1vOMmSLa0Nsc3QRMvtDF32ihgglGVrugIuIK38RyDiFNXVDhtQ81TJjSO57NdMw6DyYLqSGDQ==", "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.5", @@ -4964,9 +4992,9 @@ } }, "node_modules/svelte-check": { - "version": "4.7.6", - "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.7.6.tgz", - "integrity": "sha512-t2scM//ZuVbSY/T2w6FSBw1v9s2NEmh/g+sy1lqtosW5ylBV5AF4wFb1Ts9Kf3MbfPDUDJDZ9L436YT0SPTdvw==", + "version": "4.7.7", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.7.7.tgz", + "integrity": "sha512-Ge2IMdeTpbJuzNFGRiiMg0RWgbScysKQu7xwcyFJ22+DU9F5nPX51+Q3gHmGqRxYQMx2knreVWt5oCtfe0vf2g==", "dev": true, "license": "MIT", "dependencies": { @@ -5047,9 +5075,9 @@ } }, "node_modules/three-mesh-bvh": { - "version": "0.9.15", - "resolved": "https://registry.npmjs.org/three-mesh-bvh/-/three-mesh-bvh-0.9.15.tgz", - "integrity": "sha512-9xdEDkCzFoT8VPFH9DznwFSdbaIEjMvM106Iqq7TJurrIheUKDcdW4prfJTCxryih4XS/UxWs0H3K4MLMRFyOQ==", + "version": "0.9.16", + "resolved": "https://registry.npmjs.org/three-mesh-bvh/-/three-mesh-bvh-0.9.16.tgz", + "integrity": "sha512-2RK+5MJOaiHJhopURn2CUkOHiF3tFloD0uZBfhebV0CKB7mgVLpWUWaDpOyhNJRDkKKe7x4rRfTHZ32GwpXdkw==", "license": "MIT", "peerDependencies": { "three": ">= 0.159.0" @@ -5068,19 +5096,10 @@ "three": ">=0.170" } }, - "node_modules/three-viewport-gizmo": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/three-viewport-gizmo/-/three-viewport-gizmo-2.2.0.tgz", - "integrity": "sha512-Jo9Liur1rUmdKk75FZumLU/+hbF+RtJHi1qsKZpntjKlCYScK6tjbYoqvJ9M+IJphrlQJF5oReFW7Sambh0N4Q==", - "license": "MIT", - "peerDependencies": { - "three": ">=0.162.0 <1.0.0" - } - }, "node_modules/tinybench": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz", - "integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.2.tgz", + "integrity": "sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==", "dev": true, "license": "MIT", "engines": { @@ -5088,9 +5107,9 @@ } }, "node_modules/tinyexec": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", - "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", "dev": true, "license": "MIT", "engines": { @@ -5262,16 +5281,16 @@ } }, "node_modules/vite": { - "version": "8.3.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.3.tgz", - "integrity": "sha512-cTAldKPImjg6c+gk48U19POPn3GCBzZwpdsN8ZMEEcbpes+6/wvfqUd0C2y3qYY4wsj8PwgFwrZ/1jBPVttMSg==", + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz", + "integrity": "sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", - "postcss": "^8.5.28", - "rolldown": "~1.2.11", + "postcss": "^8.5.30", + "rolldown": "~1.2.14", "tinyglobby": "^0.2.17" }, "bin": { @@ -5288,7 +5307,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.7.1", + "@vitejs/devtools": "^0.7.1 || ^0.8.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -5648,25 +5667,25 @@ } }, "node_modules/vitest": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.2.tgz", - "integrity": "sha512-7MQrx9pDv5aHiUcovIb/70Ys3tgtkUVgCtledvKdCmEO+/1Dicq5ZqoSxOW034m03oqC+oHOKui2dM6qtMLoJg==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.5.tgz", + "integrity": "sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg==", "dev": true, "license": "MIT", "dependencies": { "@types/chai": "^5.2.2", - "@vitest/mocker": "5.0.2", - "chai": "^6.2.2", + "@vitest/mocker": "5.0.5", + "chai": "^6.3.0", "es-module-lexer": "^2.3.2", "expect-type": "^1.4.0", - "magic-string": "^1.2.3", - "obug": "^2.1.4", + "magic-string": "^1.4.3", + "obug": "^3.0.0", "picomatch": "^4.0.7", - "std-env": "^4.2.0", - "tinybench": "^6.1.4", - "tinyexec": "^1.3.0", + "std-env": "^4.3.0", + "tinybench": "^6.2.2", + "tinyexec": "^1.3.1", "tinyglobby": "^0.2.17", - "why-is-node-running": "^3.2.1" + "why-is-node-running": "3.2.1" }, "bin": { "vitest": "vitest.mjs" @@ -5681,12 +5700,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "5.0.2", - "@vitest/browser-preview": "5.0.2", + "@vitest/browser-playwright": "5.0.5", + "@vitest/browser-preview": "5.0.5", "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", - "@vitest/coverage-istanbul": "5.0.2", - "@vitest/coverage-v8": "5.0.2", - "@vitest/ui": "5.0.2", + "@vitest/coverage-istanbul": "5.0.5", + "@vitest/coverage-v8": "5.0.5", + "@vitest/ui": "5.0.5", "happy-dom": "*", "jsdom": "*", "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" @@ -5731,15 +5750,29 @@ } }, "node_modules/vitest/node_modules/magic-string": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.3.1.tgz", - "integrity": "sha512-rm91zr2Ou+XueDTohjQQjdQEcYM6zVi8KVUCG8Ec3vHwUEKrhSdCNyfuIywkA6hcCAteIn0ZOtAHA6eGpiX+Pg==", + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.6.0" } }, + "node_modules/vitest/node_modules/obug": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz", + "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/w3c-keyname": { "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", @@ -5786,9 +5819,9 @@ } }, "node_modules/why-is-node-running": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.2.tgz", - "integrity": "sha512-NKUzAelcoCXhXL4dJzKIwXeR8iEVqsA0Lq6Vnd0UXvgaKbzVo4ZTHROF2Jidrv+SgxOQ03fMinnNhzZATxOD3A==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", "dev": true, "license": "MIT", "bin": { diff --git a/package.json b/package.json index e22bb1fb3..edd0da8ab 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "theprototype.app", - "version": "1.42.0", + "version": "1.43.2", "license": "MIT", "repository": { "type": "git", @@ -11,7 +11,7 @@ "dev": "vite dev", "certs": "node scripts/gen-certs.cjs", "build": "vite build", - "postbuild": "node scripts/export-manifest.cjs", + "postbuild": "node scripts/csp-headers.cjs && node scripts/export-manifest.cjs", "check:export": "node scripts/check-export.cjs", "preview": "vite preview", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", @@ -28,26 +28,26 @@ "check:zindex": "node scripts/check-zindex.cjs" }, "devDependencies": { - "@axe-core/playwright": "4.13.0", - "@iwer/devui": "2.6.0", + "@axe-core/playwright": "4.14.0", + "@iwer/devui": "2.7.0", "@sveltejs/adapter-static": "^3.0.10", "@sveltejs/kit": "^2.70.3", "@sveltejs/vite-plugin-svelte": "^7.3.1", "@tailwindcss/vite": "^4.3.3", "flowbite": "^3.1.2", - "iwer": "2.6.0", + "iwer": "2.7.0", "peer": "^1.0.2", - "playwright": "^1.63.0", - "prettier": "^3.9.9", + "playwright": "^1.64.1", + "prettier": "^3.9.10", "prettier-plugin-svelte": "^4.1.1", "prettier-plugin-tailwindcss": "^0.8.1", - "svelte": "^5.57.1", + "svelte": "^5.57.2", "svelte-awesome-color-picker": "^4.1.3", - "svelte-check": "^4.7.6", + "svelte-check": "^4.7.7", "tailwindcss": "^4.3.3", "typescript": "^5.9.3", - "vite": "^8.3.3", - "vitest": "^5.0.2" + "vite": "^8.3.5", + "vitest": "^5.0.5" }, "dependencies": { "@codemirror/lang-javascript": "^6.2.5", @@ -55,11 +55,12 @@ "@gltf-transform/core": "^4.5.1", "@gltf-transform/extensions": "^4.5.1", "@gltf-transform/functions": "^4.5.1", - "@lucide/svelte": "^1.48.0", + "@jitl/quickjs-wasmfile-release-sync": "0.32.0", + "@lucide/svelte": "^1.61.0", "@shaderfrog/core": "^4.0.2", "@shaderfrog/glsl-parser": "^7.0.1", "@threlte/core": "^8.6.1", - "@threlte/extras": "^9.22.0", + "@threlte/extras": "^9.22.3", "@threlte/xr": "^1.6.1", "@xyflow/svelte": "^1.7.0", "codemirror": "^6.0.2", @@ -70,10 +71,11 @@ "n8ao": "^2.0.1", "peerjs": "^1.5.5", "postprocessing": "^6.39.5", + "quickjs-emscripten-core": "0.32.0", "svelte-hamburgers": "^5.0.0", "three": "^0.186.1", "three-bvh-csg": "^0.0.18", - "three-mesh-bvh": "^0.9.15", + "three-mesh-bvh": "^0.9.16", "y-codemirror.next": "^0.3.6", "y-protocols": "^1.0.7", "yjs": "^13.6.33" diff --git a/scripts/ci/battery.cjs b/scripts/ci/battery.cjs index f994cf223..ddb71bb2f 100755 --- a/scripts/ci/battery.cjs +++ b/scripts/ci/battery.cjs @@ -8,27 +8,32 @@ // SwiftShader with the same red on the unchanged base — the reason is on the line). Nothing here // names a suite; the list is data, refreshed from the integrators' battery runs (`refresh`). // -// node scripts/ci/battery.cjs plan # matrix JSON for GITHUB_OUTPUT -// node scripts/ci/battery.cjs run --shard 3 --of 14 # run one shard (serves the app itself) +// node scripts/ci/battery.cjs plan # the required + optional matrices for GITHUB_OUTPUT +// node scripts/ci/battery.cjs run --set required --shard 3 --of 14 # one shard (serves the app + the signaling server) +// node scripts/ci/battery.cjs run --set confirm --from --shard 1 --of 3 # re-run a run's required reds // node scripts/ci/battery.cjs report # merge shard results → summary, exit 1 on a required red // node scripts/ci/battery.cjs refresh [--list f]... [--summary f]... [--results dir] // -// Sharding is longest-first onto the least-loaded shard, by name on ties: every shard computes the -// same assignment from the same file, so the plan job only has to say how many shards there are. -// A failed suite runs once more; green on the retry = `flaky` (reported, not a red). +// Two sets, two matrices: the REQUIRED suites gate (deps-gate waits for them, sized to finish inside the hour); +// the OPTIONAL ones run in their own shards AFTER the required ones (the free-plan runner cap is 20 jobs), are +// reported, and gate nothing. Sharding is longest-first onto the least-loaded shard, by name on ties: every shard +// computes the same assignment from the same file, so the plan job only says how many shards there are. +// A required red is NOT retried on its shard: the CONFIRM step re-runs every required red (and whatever the deadline +// left unrun) on fresh runners, two tries; green there = `flaky` (reported, not a red). An optional red is never +// retried, and its run is cut at the deadline (they are the slow ones: a red waits out its timeouts). const fs = require('fs'); const path = require('path'); -const { spawn, spawnSync } = require('child_process'); +const { spawn } = require('child_process'); const ROOT = path.resolve(__dirname, '..', '..'); const BATTERY = path.join(ROOT, 'tests', 'e2e', 'battery.json'); /** - * @typedef {{ comment?: string, refs: Record, shards: {target_minutes: number, max: number, min: number}, + * @typedef {{ comment?: string, refs: Record, shards: {target_minutes: number, max: number, min: number, par?: number}, * pc?: {games: string[], levels: string[]}, skip: Record, optional: Record, setup: Record, * suites: Record }} Battery - * @typedef {{ name: string, status: 'pass'|'flaky'|'fail'|'timeout'|'gone', required: boolean, rc: number|null, secs: number, + * @typedef {{ name: string, status: 'pass'|'flaky'|'fail'|'timeout'|'gone'|'notrun', required: boolean, rc: number|null, secs: number, * retrySecs?: number, pass: number, fail: number, last: string, shard: number }} SuiteResult */ @@ -44,25 +49,46 @@ function runnable(b) { .sort((x, y) => y[1] - x[1] || (x[0] < y[0] ? -1 : 1)); } -/** @param {Battery} b @returns {number} */ -function shardCount(b) { - const env = Number(process.env.BATTERY_SHARDS || 0); +/** @typedef {'required'|'optional'|'all'} BatterySet */ + +/** the runnable suites of one set, longest first @param {Battery} b @param {BatterySet} set @returns {[string, number][]} */ +function suitesOf(b, set) { + return runnable(b).filter(([n]) => set === 'all' || (set === 'optional') === n in b.optional); +} + +/** @param {Battery} b @param {BatterySet} [set] @returns {number} */ +function shardCount(b, set = 'required') { + const env = Number(process.env[set === 'optional' ? 'BATTERY_OPTIONAL_SHARDS' : 'BATTERY_SHARDS'] || 0); if (env > 0) return env; - const total = runnable(b).reduce((t, [, s]) => t + s, 0); - const n = Math.ceil(total / (b.shards.target_minutes * 60)); - return Math.max(b.shards.min, Math.min(b.shards.max, n)); + const total = suitesOf(b, set).reduce((t, [, s]) => t + s, 0); + if (!total) return 0; + // `par` suites run side by side on one runner, so a shard holds par x target_minutes of suite time + const n = Math.ceil(total / (b.shards.target_minutes * 60 * (b.shards.par || 1))); + return Math.max(set === 'optional' ? 1 : b.shards.min, Math.min(b.shards.max, n)); } -/** @param {Battery} b @param {number} of @returns {{suites: string[], secs: number}[]} */ -function assign(b, of) { - const shards = Array.from({ length: of }, () => ({ suites: /** @type {string[]} */ ([]), secs: 0 })); - for (const [name, secs] of runnable(b)) { - let best = 0; - for (let i = 1; i < of; i++) if (shards[i].secs < shards[best].secs) best = i; - shards[best].suites.push(name); - shards[best].secs += secs; - } - return shards; +/** + * Longest first onto the least-loaded shard, name breaking ties, so every shard computes the same split. + * `all` (a local run) queues each shard's optional suites after its required ones, balanced on their own. + * @param {Battery} b @param {number} of @param {BatterySet} [set] @returns {{suites: string[], secs: number}[]} + */ +function assign(b, of, set = 'required') { + const shards = Array.from({ length: of }, () => ({ suites: /** @type {string[]} */ ([]), secs: 0, load: 0 })); + const lpt = (/** @type {[string, number][]} */ list) => { + for (const s of shards) s.load = 0; + for (const [name, secs] of list) { + let best = 0; + for (let i = 1; i < of; i++) if (shards[i].load < shards[best].load) best = i; + shards[best].suites.push(name); + shards[best].load += secs; + shards[best].secs += secs; + } + }; + if (set === 'all') { + lpt(suitesOf(b, 'required')); + lpt(suitesOf(b, 'optional')); + } else lpt(suitesOf(b, set)); + return shards.map(({ suites, secs }) => ({ suites, secs })); } /** pass/fail lines the way the integrators' battery.sh counts them @param {string} log */ @@ -140,8 +166,8 @@ function stop(child) { // ---------------------------------------------------------------- one suite -/** @param {string} name @param {number} est @param {string} out @param {string} tag */ -function runSuite(name, est, out, tag) { +/** @param {string} name @param {number} est @param {string} out @param {string} tag @param {number} capMs */ +function runSuite(name, est, out, tag, capMs) { const file = path.join(ROOT, 'tests', 'e2e', name + '.test.cjs'); const ev = path.join(out, 'evidence', name); fs.mkdirSync(ev, { recursive: true }); @@ -150,7 +176,7 @@ function runSuite(name, est, out, tag) { // every evidence variable the suites read points OUTSIDE the served tree (an .html artefact // inside it full-reloads the app mid-test) and is kept only when the suite fails const env = { ...process.env, EVIDENCE_DIR: ev, EVIDENCE: ev, OUT: ev, SHOT_DIR: ev, SHOTS_DIR: ev }; - const timeout = Math.min(1800, Math.max(300, Math.round(est * 3))) * 1000; + const timeout = Math.max(60000, Math.min(capMs, Math.min(1800, Math.max(600, Math.round(est * 3))) * 1000)); const t0 = Date.now(); return new Promise((resolve) => { const child = spawn('node', [file], { cwd: ROOT, env, stdio: ['ignore', log, log], detached: true }); @@ -174,65 +200,107 @@ function runSuite(name, est, out, tag) { /** @param {string[]} argv */ async function run(argv) { const b = load(); - const of = Number(opt(argv, '--of') || shardCount(b)); + const of = Number(opt(argv, '--of') || shardCount(b, /** @type {BatterySet} */ (opt(argv, '--set') === 'optional' ? 'optional' : 'required'))); const shard = Number(opt(argv, '--shard') || 1); const out = path.resolve(opt(argv, '--out') || process.env.BATTERY_OUT || path.join(ROOT, '..', 'battery-out')); const port = Number(opt(argv, '--port') || 5173); const only = opt(argv, '--only'); fs.mkdirSync(path.join(out, 'logs'), { recursive: true }); - const mine = only ? only.split(',') : assign(b, of)[shard - 1].suites; - const url = process.env.APP_URL || `https://localhost:${port}/`; - let server = argv.includes('--serve') ? await serve(port, path.join(out, 'logs', 'dev-server.log')) : null; + const set = /** @type {BatterySet | 'confirm'} */ (opt(argv, '--set') || 'all'); + // --set confirm --from : the required reds of a finished run (and what its deadline left unrun), re-run on + // THIS fresh runner — part `shard` of `of` takes every of-th one. A red that passes here was the shard's load. + const from = opt(argv, '--from'); + const reds = () => + walk(path.resolve(/** @type {string} */ (from))) + .filter((f) => /results-\d+\.json$/.test(f)) + .flatMap((f) => JSON.parse(fs.readFileSync(f, 'utf8')).results.filter(isRed).map((/** @type {SuiteResult} */ r) => r.name)) + .sort() + .filter((_, i) => i % of === shard - 1); + const mine = only ? only.split(',') : set === 'confirm' ? reds() : assign(b, of, set)[shard - 1].suites; + // the suites' own default is a hosts-file origin: say where the app is + const url = (process.env.APP_URL ||= `https://localhost:${port}/`); + let server = argv.includes('--serve') && mine.length ? await serve(port, path.join(out, 'logs', 'dev-server.log')) : null; + // the two-peer suites meet on a signaling server: with no .env a localhost app picks the local-dev + // one on :9001, so --serve brings it (a public runner never touches the self-hosted or the public box) + const signal = require(path.join(ROOT, 'tests', 'e2e', 'localSignal.cjs')); + let signalChild = server ? await signal.ensureSignalServer() : null; /** @type {SuiteResult[]} */ const results = []; - console.log(`shard ${shard}/${of}: ${mine.length} suites, est ${Math.round(mine.reduce((t, s) => t + (b.suites[s] || 60), 0) / 60)} min`); - for (const name of mine) { - const required = !(name in b.optional); - if (!fs.existsSync(path.join(ROOT, 'tests', 'e2e', name + '.test.cjs'))) { - results.push({ name, status: 'gone', required: false, rc: null, secs: 0, pass: 0, fail: 0, last: 'no such suite (refresh battery.json)', shard }); - continue; - } - // a dead dev server turns every later suite red for the wrong reason: bring it back first - if (server && !(await reachable(url))) { - console.log(`[battery] dev server unreachable before ${name}: restarting`); - stop(server); - server = await serve(port, path.join(out, 'logs', 'dev-server.log')); + const par = Number(opt(argv, '--par') || b.shards.par || 1); + // a second try only in the confirm step: on a FRESH runner it says something a same-shard retry does not (a + // loaded shard fails the same suite twice), and a shard without retries stays inside its estimate + const retry = set === 'confirm' || argv.includes('--retry'); + // past the deadline no suite starts: the rest report `notrun` instead of the job being killed with nothing said + const deadline = Date.now() + Number(opt(argv, '--deadline') || 0) * 60000 || Infinity; + const save = () => fs.writeFileSync(path.join(out, `${set === 'confirm' ? 'confirm' : 'results'}-${shard}.json`), JSON.stringify({ shard, of, set, results }, null, 1)); + console.log(`shard ${shard}/${of}: ${mine.length} suites, est ${Math.round(mine.reduce((t, s) => t + (b.suites[s] || 60), 0) / 60)} min, ${par} at a time`); + /** @type {Promise | null} */ + let agentSetup = null; + const queue = [...mine]; + const worker = async () => { + for (let name = queue.shift(); name; name = queue.shift()) { + const required = !(name in b.optional); + if (!fs.existsSync(path.join(ROOT, 'tests', 'e2e', name + '.test.cjs'))) { + results.push({ name, status: 'gone', required: false, rc: null, secs: 0, pass: 0, fail: 0, last: 'no such suite (refresh battery.json)', shard }); + continue; + } + if (Date.now() > deadline) { + results.push({ name, status: 'notrun', required, rc: null, secs: 0, pass: 0, fail: 0, last: 'shard deadline passed before it started', shard }); + continue; + } + // a dead dev server turns every later suite red for the wrong reason: bring it back first + if (server && !(await reachable(url))) { + console.log(`[battery] dev server unreachable before ${name}: restarting`); + stop(server); + server = await serve(port, path.join(out, 'logs', 'dev-server.log')); + } + if (server && !(await signal.signalUp())) { + console.log(`[battery] signaling server down before ${name}: restarting`); + signalChild = await signal.ensureSignalServer(); + } + // a suite's extra install happens once, on the shard that holds it + if (b.setup[name] === 'agent' && !fs.existsSync(path.join(ROOT, 'tools', 'agent', 'node_modules'))) { + agentSetup ||= new Promise((res) => spawn('npm', ['ci', '--no-audit', '--no-fund'], { cwd: path.join(ROOT, 'tools', 'agent'), stdio: 'inherit' }).on('exit', res)); + await agentSetup; + } + const est = b.suites[name] || 60; + // an optional red is slow to fail: never let one run far past the deadline + let r = await runSuite(name, est, out, '', required ? Infinity : deadline + 300000 - Date.now()); + /** @type {SuiteResult['status']} */ + let status = r.rc === 0 ? 'pass' : r.timedOut ? 'timeout' : 'fail'; + /** @type {number|undefined} */ + let retrySecs; + // only a suite that gates earns a second run (an optional red is expected and only reported) + if (status !== 'pass' && required && retry && Date.now() < deadline) { + const again = await runSuite(name, est, out, '.retry', Infinity); + retrySecs = again.secs; + if (again.rc === 0) status = 'flaky'; + else r = again; + } + results.push({ name, status, required, rc: r.rc, secs: r.secs, retrySecs, pass: r.pass, fail: r.fail, last: r.last, shard }); + save(); + if (status === 'pass' || status === 'flaky') fs.rmSync(path.join(out, 'evidence', name), { recursive: true, force: true }); + console.log(`${name} ${status}${required ? '' : ' (optional)'} rc=${r.rc} pass~${r.pass} fail~${r.fail} ${r.secs}s${retrySecs ? ` retry ${retrySecs}s` : ''} :: ${r.last}`); } - // a suite's extra install happens once, on the shard that holds it - if (b.setup[name] === 'agent' && !fs.existsSync(path.join(ROOT, 'tools', 'agent', 'node_modules'))) { - spawnSync('npm', ['ci', '--no-audit', '--no-fund'], { cwd: path.join(ROOT, 'tools', 'agent'), stdio: 'inherit' }); - } - const est = b.suites[name] || 60; - let r = await runSuite(name, est, out, ''); - /** @type {SuiteResult['status']} */ - let status = r.rc === 0 ? 'pass' : r.timedOut ? 'timeout' : 'fail'; - /** @type {number|undefined} */ - let retrySecs; - if (status !== 'pass') { - const again = await runSuite(name, est, out, '.retry'); - retrySecs = again.secs; - if (again.rc === 0) status = 'flaky'; - else r = again; - } - const res = { name, status, required, rc: r.rc, secs: r.secs, retrySecs, pass: r.pass, fail: r.fail, last: r.last, shard }; - results.push(res); - if (status === 'pass' || status === 'flaky') fs.rmSync(path.join(out, 'evidence', name), { recursive: true, force: true }); - console.log(`${name} ${status}${required ? '' : ' (optional)'} rc=${r.rc} pass~${r.pass} fail~${r.fail} ${r.secs}s${retrySecs ? ` retry ${retrySecs}s` : ''} :: ${r.last}`); - } + }; + await Promise.all(Array.from({ length: par }, worker)); stop(server); - fs.writeFileSync(path.join(out, `results-${shard}.json`), JSON.stringify({ shard, of, results }, null, 1)); - const red = results.filter((r) => r.required && (r.status === 'fail' || r.status === 'timeout')); + signal.stopSignalServer(signalChild); + save(); + const red = results.filter(isRed); if (process.env.GITHUB_STEP_SUMMARY) fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, table(results, `Shard ${shard}/${of}`)); process.exit(red.length ? 1 : 0); } +/** a red that gates: a required suite that failed, timed out or never ran @param {SuiteResult} r */ +const isRed = (r) => r.required && (r.status === 'fail' || r.status === 'timeout' || r.status === 'notrun'); + // ---------------------------------------------------------------- the report /** @param {SuiteResult[]} results @param {string} title */ function table(results, title) { - const red = results.filter((r) => r.status === 'fail' || r.status === 'timeout'); - const req = red.filter((r) => r.required); - const opt = red.filter((r) => !r.required); + const req = results.filter(isRed); + const opt = results.filter((r) => !r.required && (r.status === 'fail' || r.status === 'timeout')); const flaky = results.filter((r) => r.status === 'flaky'); const secs = results.reduce((t, r) => t + r.secs + (r.retrySecs || 0), 0); const row = (/** @type {SuiteResult} */ r) => `| ${r.name} | ${r.status} | ${r.shard} | ${r.pass}/${r.fail} | ${r.secs}s | ${r.last.replace(/\|/g, '\\|')} |`; @@ -259,6 +327,15 @@ function report(argv) { seen.add(r.shard); all.push(...r.results); } + // a red the confirm step saw pass on a fresh runner is a flake of that shard, not a red of the change + for (const f of walk(dir).filter((f) => /confirm-\d+\.json$/.test(f))) { + for (const c of JSON.parse(fs.readFileSync(f, 'utf8')).results) { + const r = all.find((x) => x.name === c.name); + if (!r || !isRed(r) || !(c.status === 'pass' || c.status === 'flaky')) continue; + if (r.status === 'notrun') Object.assign(r, { ...c, shard: r.shard, last: `ran in the confirm step — ${c.last}` }); + else Object.assign(r, { status: 'flaky', last: `green on a fresh runner (confirm ${c.secs}s) — ${r.last}` }); + } + } const b = load(); const missing = Array.from({ length: of || shardCount(b) }, (_, i) => i + 1).filter((s) => !seen.has(s)); let md = table(all, 'e2e battery'); @@ -267,8 +344,7 @@ function report(argv) { else console.log(md); // measured seconds, for `refresh --results` on a later day fs.writeFileSync(path.join(dir, 'durations.json'), JSON.stringify(Object.fromEntries(all.filter((r) => r.status === 'pass').map((r) => [r.name, r.secs])), null, 1)); - const red = all.filter((r) => r.required && (r.status === 'fail' || r.status === 'timeout')); - process.exit(red.length || missing.length ? 1 : 0); + process.exit(all.filter(isRed).length || missing.length ? 1 : 0); } /** @param {string} dir @returns {string[]} */ @@ -329,6 +405,18 @@ function refresh(argv) { for (const [n, s] of Object.entries(JSON.parse(fs.readFileSync(f, 'utf8')))) if (n in b.suites) b.suites[n] = Number(s); } } + // --red ... --reason "…": suites red in those runs' results (fail / timeout / flaky) become optional with + // the reason — for runs on unchanged code, i.e. what this runner cannot pass reliably. --green : a suite + // that passed there is never moved. + const redDirs = opts(argv, '--red'); + if (redDirs.length) { + const reason = opt(argv, '--reason') || 'red on the GitHub runner (SwiftShader) with unchanged code'; + /** @param {string} dir @param {(r: SuiteResult) => boolean} f */ + const names = (dir, f) => new Set(walk(path.resolve(dir)).filter((x) => /results-\d+\.json$/.test(x)).flatMap((x) => JSON.parse(fs.readFileSync(x, 'utf8')).results.filter(f).map((/** @type {SuiteResult} */ r) => r.name))); + const green = new Set(opts(argv, '--green').flatMap((d) => [...names(d, (r) => r.status === 'pass' || r.status === 'flaky')])); + for (const d of redDirs) + for (const n of names(d, (r) => r.status === 'fail' || r.status === 'timeout' || r.status === 'flaky')) if (n in b.suites && !green.has(n) && !(n in b.optional)) b.optional[n] = reason; + } for (const k of Object.keys(b.optional)) if (!(k in b.suites)) delete b.optional[k]; write(b); console.log(`battery.json: ${Object.keys(b.suites).length} suites, ${Object.keys(b.skip).length} skipped, ${Object.keys(b.optional).length} optional, ${shardCount(b)} shards`); @@ -338,13 +426,16 @@ function refresh(argv) { function plan() { const b = load(); - const of = shardCount(b); - const shards = assign(b, of); - const include = shards.map((s, i) => ({ shard: i + 1, of, suites: s.suites.length, est: Math.round(s.secs / 60) })); - const needs = [...new Set(Object.entries(b.setup).filter(([n]) => n in b.suites && !(n in b.skip)).map(([, v]) => v))]; - const line = `matrix=${JSON.stringify({ include })}\nshards=${of}\nsetup=${needs.join(',')}\n`; + let line = ''; + for (const set of /** @type {BatterySet[]} */ (['required', 'optional'])) { + const of = shardCount(b, set); + const include = assign(b, of, set).map((s, i) => ({ shard: i + 1, of, suites: s.suites.length, est: Math.round(s.secs / 60 / (b.shards.par || 1)) })); + const key = set === 'required' ? 'matrix' : 'optional_matrix'; + // an empty matrix is an error in a workflow: the optional job skips on optional_shards=0 instead + line += `${key}=${JSON.stringify({ include: include.length ? include : [{ shard: 0, of: 0, suites: 0, est: 0 }] })}\n${set}_shards=${of}\n`; + console.log(`${set}: ${of} shards\n` + include.map((s) => ` shard ${s.shard}: ${s.suites} suites ~${s.est} min`).join('\n')); + } if (process.env.GITHUB_OUTPUT) fs.appendFileSync(process.env.GITHUB_OUTPUT, line); - console.log(line + include.map((s) => `shard ${s.shard}: ${s.suites} suites ~${s.est} min`).join('\n')); } if (require.main === module) { @@ -354,7 +445,7 @@ if (require.main === module) { else if (cmd === 'report') report(argv); else if (cmd === 'refresh') refresh(argv); else { - console.error('usage: battery.cjs plan | run --shard N --of M [--serve] [--port P] [--only a,b] | report | refresh [--list f] [--summary f] [--results dir]'); + console.error('usage: battery.cjs plan | run --shard N --of M [--set required|optional|all|confirm --from ] [--serve] [--port P] [--par K] [--deadline MIN] [--only a,b] | report | refresh [--list f] [--summary f] [--results dir] [--red dir --reason r [--green dir]]'); process.exit(2); } } diff --git a/scripts/ci/pc-battery.sh b/scripts/ci/pc-battery.sh new file mode 100755 index 000000000..ab46729dc --- /dev/null +++ b/scripts/ci/pc-battery.sh @@ -0,0 +1,217 @@ +#!/usr/bin/env bash +# deps-auto — THE GPU HALF OF THE DEPENDENCY GATE, PULLED FROM A DESKTOP. +# +# GitHub's runners have no GPU, so deps.yml's battery cannot see a shader that compiles differently or a +# frame that got slower. This script runs on a machine that has one. Nothing on GitHub can reach it and no +# runner of ours is registered (this repo is public: a self-hosted runner would run a stranger's PR), so it +# PULLS: a timer on that machine runs `--poll`, which picks the open PRs into `deps` whose `deps-gate` is +# green and which carry no `pc-battery` status yet, and runs each one: +# +# 1 /kit in every theme, base vs head (tests/e2e/tools/kit-diff.cjs) +# 2 the app surfaces, desktop + two phone shapes, base twice (tests/e2e/tools/ab-shots.cjs shoot/diff) +# 3 the level shots, base twice (the noise floor) + head (scripts/perf-levels.cjs SHOTS=1) +# 4 fps on the heaviest games, base/head alternating x2 (scripts/perf-games.cjs) +# +# and posts the commit status `pc-battery` (success | failure, the summary as a secret gist for its link). +# The games and levels are battery.json's `pc` block. +# +# WHO IT RUNS FOR: the PR's head must be a branch of THIS repository (never a fork) and its author +# dependabot[bot] or the account `gh` is logged in as (plus PC_BATTERY_AUTHORS). Anything else is +# refused before a byte of it is checked out — it would be running someone else's code on your desktop. +# +# scripts/ci/pc-battery.sh [--dry-run] one PR (--dry-run: say what would happen, post nothing) +# scripts/ci/pc-battery.sh --poll [--dry-run] every eligible open PR into deps, oldest first +# +# Environment (all optional; machine-specific values belong in the timer's unit, never in this file): +# PC_BATTERY_REPO owner/name (default: theprototype-app/core) +# PC_BATTERY_DIR work dir: clones, worktrees, evidence (default: ~/.cache/pc-battery) +# PC_BATTERY_PORT first of two dev-server ports (default: 5610; head = +1) +# PC_BATTERY_AUTHORS extra allowed authors, space-separated +# PC_BATTERY_WRAP a command prefix that serialises GPU use with other jobs on the machine, e.g. an +# exclusive e2e slot; the measuring part runs inside it (default: none) +# PC_BATTERY_FPS_MAX allowed head/base p50 frame-time ratio (default: 1.15) +# PC_BATTERY_STALE seconds after which a `pending` status of ours counts as abandoned (default: 10800) +# Needs git, gh (logged in: repo status + gist), node/npm at the repo's .nvmrc, curl, flock. +set -uo pipefail + +REPO=${PC_BATTERY_REPO:-theprototype-app/core} +DIR=${PC_BATTERY_DIR:-$HOME/.cache/pc-battery} +PORT=${PC_BATTERY_PORT:-5610} +FPS_MAX=${PC_BATTERY_FPS_MAX:-1.15} +DRY=0 +MODE=one +PR= +for a in "$@"; do + case "$a" in + --dry-run) DRY=1 ;; + --poll) MODE=poll ;; + [0-9]*) PR=$a ;; + *) echo "usage: pc-battery.sh [--dry-run] | --poll [--dry-run]" >&2; exit 2 ;; + esac +done +[ "$MODE" = poll ] || [ -n "$PR" ] || { echo "usage: pc-battery.sh [--dry-run] | --poll [--dry-run]" >&2; exit 2; } +mkdir -p "$DIR" +log() { echo "[pc-battery $(date +%H:%M:%S)] $*"; } + +ME=$(gh api user -q .login) || { echo "gh is not logged in" >&2; exit 1; } +ALLOWED=" dependabot[bot] app/dependabot $ME ${PC_BATTERY_AUTHORS:-} " + +# --- the filter: decided from GitHub's metadata, before anything is checked out +# eligible -> prints "ok " or "no " +eligible() { + local row + row=$(gh pr view "$1" -R "$REPO" --json state,isCrossRepository,headRepository,headRepositoryOwner,author,headRefOid,baseRefName \ + -q '[.state, .isCrossRepository, .headRepositoryOwner.login + "/" + .headRepository.name, .author.login, .headRefOid, .baseRefName] | @tsv') || { echo "no unreadable"; return; } + local state cross head author sha base + IFS=$'\t' read -r state cross head author sha base <<<"$row" + [ "$state" = OPEN ] || { echo "no not open ($state)"; return; } + [ "$cross" = false ] && [ "$head" = "$REPO" ] || { echo "no head is not a branch of $REPO ($head)"; return; } + case "$ALLOWED" in *" $author "*) ;; *) echo "no author $author is not allowed"; return ;; esac + echo "ok $sha $base" +} + +# gate_green : deps.yml's deps-gate concluded success on this commit +gate_green() { + [ "$(gh api "repos/$REPO/commits/$1/check-runs?check_name=deps-gate" -q '[.check_runs[] | select(.conclusion == "success")] | length')" -gt 0 ] +} +# settled : a final pc-battery status, or a pending one younger than PC_BATTERY_STALE seconds. An older +# pending one is a run this machine never finished (it slept or rebooted mid-run): run it again. +settled() { + local row; row=$(gh api "repos/$REPO/commits/$1/statuses" -q '[.[] | select(.context == "pc-battery")][0] | [.state, .updated_at] | @tsv') + local st at; IFS=$'\t' read -r st at <<<"$row" + # no status at all reads as an empty row (a lone tab), not as no output + [ -n "$st" ] || return 1 + [ "$st" != pending ] && return 0 + [ $(($(date +%s) - $(date -d "$at" +%s))) -lt "${PC_BATTERY_STALE:-10800}" ] +} +status() { # [url] + [ "$DRY" = 1 ] && { log "would post pc-battery=$2 ($3)"; return; } + gh api "repos/$REPO/statuses/$1" -f state="$2" -f context=pc-battery -f description="${3:0:139}" ${4:+-f target_url="$4"} > /dev/null +} + +# --- the run +run_pr() { + local pr=$1 sha=$2 base=$3 + local W="$DIR/runs/$pr-${sha:0:7}" C="$DIR/core" + log "PR #$pr head ${sha:0:7} base $base" + [ "$DRY" = 1 ] && { log "would run the GPU battery on #$pr"; return 0; } + status "$sha" pending "Running on the desktop GPU" + rm -rf "$W"; mkdir -p "$W" + [ -d "$C/.git" ] || git clone -q "https://github.com/$REPO.git" "$C" + git -C "$C" fetch -q origin "+refs/heads/$base:refs/remotes/origin/$base" "+refs/pull/$pr/head:refs/pc-battery/$pr" || return 1 + [ "$(git -C "$C" rev-parse "refs/pc-battery/$pr")" = "$sha" ] || { log "head moved since the check: next poll"; return 1; } + local mb; mb=$(git -C "$C" merge-base "origin/$base" "$sha") + for side in base head; do + git -C "$C" worktree remove --force "$DIR/$side" 2>/dev/null; rm -rf "${DIR:?}/$side" + done + git -C "$C" worktree add -q --detach "$DIR/base" "$mb" && git -C "$C" worktree add -q --detach "$DIR/head" "$sha" || return 1 + for side in base head; do + (cd "$DIR/$side" && npm ci --no-audit --no-fund > "$W/npm-$side.log" 2>&1 && npm run certs > /dev/null 2>&1) || { log "npm ci failed on $side"; return 1; } + done + (cd "$DIR/head" && npx playwright install chromium > "$W/playwright.log" 2>&1) + # the sibling content at the refs the head's battery pins + local B="$DIR/head/tests/e2e/battery.json" + for sib in scenes modules; do + local repo ref; repo=$(node -p "require('$B').refs.$sib.repo"); ref=$(node -p "require('$B').refs.$sib.ref") + [ -d "$DIR/$sib/.git" ] || git clone -q "https://github.com/$repo.git" "$DIR/$sib" + git -C "$DIR/$sib" fetch -q origin && git -C "$DIR/$sib" checkout -q --detach "$ref" || return 1 + done + (cd "$DIR/modules" && npm ci --no-audit --no-fund > "$W/npm-modules.log" 2>&1 && node scripts/pack.cjs --all --force > "$W/pack.log" 2>&1) || { log "module pack failed"; return 1; } + local games levels; games=$(node -p "require('$B').pc.games.join(',')"); levels=$(node -p "require('$B').pc.levels.join(',')") + + # servers: base on PORT, head on PORT+1, started one after the other (a cold boot is the slow part) + local pids=() + # exec'd, so $! IS the server (a session leader: one kill takes vite with it), and without fd 9 — a server + # holding the lock's descriptor would keep every later poll out + serve() { (cd "$1" && exec setsid npm run dev -- --port "$2" --strictPort --host localhost > "$W/dev-$2.log" 2>&1 9>&-) & + pids+=("$!") + for _ in $(seq 1 120); do curl -sk --max-time 300 -o /dev/null "https://localhost:$2/" && break; sleep 2; done + curl -sk --max-time 300 -o /dev/null "https://localhost:$2/"; curl -sk --max-time 300 -o /dev/null "https://localhost:$2/kit"; } + stop_servers() { for p in "${pids[@]}"; do kill -- -"$p" 2>/dev/null; done; } + trap stop_servers RETURN + serve "$DIR/base" "$PORT"; serve "$DIR/head" $((PORT + 1)) + local UB="https://localhost:$PORT/" UH="https://localhost:$((PORT + 1))/" + + # every probe runs from the HEAD tree against both URLs, so only the app differs + cat > "$W/measure.sh" <> "$W/rc.txt" +node tests/e2e/tools/ab-shots.cjs shoot $UB "$W/surf/base" && node tests/e2e/tools/ab-shots.cjs shoot $UH "$W/surf/head" && node tests/e2e/tools/ab-shots.cjs shoot $UB "$W/surf/base2" +node tests/e2e/tools/ab-shots.cjs diff "$W/surf/base" "$W/surf/head" "$W/surf/diff" --noise "$W/surf/base2"; echo "surfaces rc=\$?" >> "$W/rc.txt" +for t in base1:$UB head:$UH base2:$UB; do + SHOTS=1 APP_URL=\${t#*:} node scripts/perf-levels.cjs --dir "$DIR/scenes" --only $levels --label ab --out "$W/lvl/\${t%%:*}" > "$W/lvl-\${t%%:*}.log" 2>&1 +done +# a 3D frame of an animated level: identical apps measured up to 0.9 % apart (castle courtyard VR, flags and +# speckle); a shader or colour change moves far more of the frame, so 2 % is the least that means anything +AB_MAX=0.02 node tests/e2e/tools/ab-shots.cjs diff "$W/lvl/base1" "$W/lvl/head" "$W/lvl/diff" --noise "$W/lvl/base2"; echo "levels rc=\$?" >> "$W/rc.txt" +for i in 1 2; do for t in head:$UH base:$UB; do + APP_URL=\${t#*:} node scripts/perf-games.cjs --only $games --label \${t%%:*}-\$i --out "$W/fps" > "$W/fps-\${t%%:*}-\$i.log" 2>&1 +done; done +EOF + : > "$W/rc.txt" + ${PC_BATTERY_WRAP:-} bash "$W/measure.sh" > "$W/measure.log" 2>&1 + stop_servers; trap - RETURN + + # fps: same scene (calls / triangles within 2 %) and no slower than FPS_MAX x the slower base run + node - "$W/fps" "$FPS_MAX" > "$W/fps.md" <<'EOF' +const fs = require('fs'); const [dir, max] = [process.argv[2], Number(process.argv[3])]; +const read = (l) => { try { return JSON.parse(fs.readFileSync(`${dir}/perf-${l}.json`, 'utf8')).rows; } catch { return []; } }; +const runs = { base: [read('base-1'), read('base-2')], head: [read('head-1'), read('head-2')] }; +const by = (rows, slug) => rows.find((r) => r.slug === slug); +const slugs = [...new Set([...runs.base[0], ...runs.head[0]].map((r) => r.slug))]; +let bad = 0; const out = ['| game | calls base→head | tris base→head | p50 ms base (max) → head (min) | verdict |', '|---|---:|---:|---:|---|']; +for (const s of slugs) { + const b = runs.base.map((r) => by(r, s)).filter(Boolean); const h = runs.head.map((r) => by(r, s)).filter(Boolean); + if (!b.length || !h.length) { bad++; out.push(`| ${s} | – | – | – | **missing run** |`); continue; } + // the head inside the base runs' own range, widened: calls 2 % (or 2), triangles 5 % (animated meshes move) + const within = (key, f) => { const v = b.map((r) => r[key]); const lo = Math.min(...v), hi = Math.max(...v), tol = Math.max(2, f * hi); + return h.some((r) => r[key] >= lo - tol && r[key] <= hi + tol); }; + const bp = Math.max(...b.map((r) => r.p50)), hp = Math.min(...h.map((r) => r.p50)); + const scene = within('medianCalls', 0.02) && within('medianTriangles', 0.05); + const ok = scene && hp <= bp * max; if (!ok) bad++; + out.push(`| ${s} | ${b[0].medianCalls}→${h[0].medianCalls} | ${b[0].medianTriangles}→${h[0].medianTriangles} | ${bp.toFixed(1)} → ${hp.toFixed(1)} | ${ok ? 'ok' : scene ? '**slower**' : '**scene changed**'} |`); +} +console.log(out.join('\n')); fs.writeFileSync(`${dir}/verdict`, bad ? 'fail' : 'ok'); +EOF + local fps; fps=$(cat "$W/fps/verdict" 2>/dev/null || echo fail); echo "fps rc=$([ "$fps" = ok ] && echo 0 || echo 1)" >> "$W/rc.txt" + + # the summary: numbers only (no host, path or user names; it is public through the status link) + local fails; fails=$(grep -c 'rc=[1-9]' "$W/rc.txt"); local rcs; rcs=$(tr '\n' ' ' < "$W/rc.txt") + { + echo "# pc-battery — PR #$pr at ${sha:0:7} vs ${mb:0:7} ($base)" + echo; echo "Desktop GPU. $rcs"; echo + echo "## /kit"; cat "$W/kit/diff/report.md" 2>/dev/null || echo "(no report)"; echo + echo "## App surfaces"; cat "$W/surf/diff/report.md" 2>/dev/null || echo "(no report)"; echo + echo "## Level shots (base twice = the noise floor)"; cat "$W/lvl/diff/report.md" 2>/dev/null || echo "(no report)"; echo + echo "## Frame time (perf-games, base/head alternating x2)"; cat "$W/fps.md" + } > "$W/summary.md" + local url; url=$(gh gist create -d "pc-battery $REPO#$pr ${sha:0:7}" "$W/summary.md" 2>/dev/null | tail -1) + if [ "$fails" = 0 ]; then + status "$sha" success "Desktop GPU: kit, surfaces, levels and fps match the base" "$url" + else + status "$sha" failure "Desktop GPU: $rcs" "$url" + fi + log "PR #$pr: $rcs -> ${url:-no gist}" + return 0 +} + +exec 9> "$DIR/lock" +flock -n 9 || { log "another pc-battery run holds $DIR/lock"; exit 0; } + +if [ "$MODE" = one ]; then + read -r verdict sha base <<<"$(eligible "$PR")" + [ "$verdict" = ok ] || { log "PR #$PR refused: $sha $base"; exit 3; } + run_pr "$PR" "$sha" "$base" + exit $? +fi + +# --poll: open PRs into deps, oldest first, green gate, no pc-battery status yet +for pr in $(gh pr list -R "$REPO" --base deps --state open --json number -q 'sort_by(.number) | .[].number'); do + read -r verdict sha base <<<"$(eligible "$pr")" + if [ "$verdict" != ok ]; then log "#$pr skipped: $sha $base"; continue; fi + settled "$sha" && continue + gate_green "$sha" || { log "#$pr: deps-gate not green on ${sha:0:7} (yet)"; continue; } + run_pr "$pr" "$sha" "$base" +done diff --git a/scripts/csp-headers.cjs b/scripts/csp-headers.cjs new file mode 100644 index 000000000..497a20c8a --- /dev/null +++ b/scripts/csp-headers.cjs @@ -0,0 +1,36 @@ +#!/usr/bin/env node +// 69 T3 — build/_headers: the response headers a static host should send with the build. +// +// The Content-Security-Policy itself is in every page's (svelte.config.js → kit.csp, with the +// hash of SvelteKit's inline boot script). A meta policy cannot carry `frame-ancestors`, so that one +// directive goes here, as a header, beside two plain hardening headers. Cloudflare Pages reads +// `_headers` as is; any other server can send the same lines (the docs' self-hosting page says how). +// The header holds ONLY frame-ancestors on purpose: policies are intersected, and repeating the meta +// policy here without the boot-script hash would block the app. +// +// node scripts/csp-headers.cjs [buildDir] (default ./build; runs in postbuild) +const fs = require('fs'); +const path = require('path'); + +const ROOT = path.resolve(__dirname, '..'); +const BUILD = path.resolve(process.argv[2] || path.join(ROOT, 'build')); + +(async () => { + const { loadEnv } = await import('vite'); + const { frameAncestors, cspText, cspDirectives, playOrigin } = await import(path.join(ROOT, 'csp.config.js')); + const env = { ...loadEnv(process.env.MODE || 'production', ROOT, 'VITE_'), ...process.env }; + const lines = ['/*', ' X-Content-Type-Options: nosniff', ' Referrer-Policy: strict-origin-when-cross-origin']; + if (cspDirectives(env)) { + lines.push(' Content-Security-Policy: ' + cspText({ 'frame-ancestors': frameAncestors(env) })); + // 69 T4: ONE build serves both origins. The play origin is what embeds frame, so there the + // app-origin rule above is detached (`!`) and any site may frame it. + const play = playOrigin(env); + if (play && !env.VITE_CSP_FRAME_ANCESTORS) + lines.push(play + '/*', ' ! Content-Security-Policy', ' Content-Security-Policy: ' + cspText({ 'frame-ancestors': ['*'] })); + } + fs.writeFileSync(path.join(BUILD, '_headers'), lines.join('\n') + '\n'); + console.log('csp-headers: wrote ' + path.relative(ROOT, path.join(BUILD, '_headers'))); +})().catch((error) => { + console.error('csp-headers:', error); + process.exit(1); +}); diff --git a/scripts/export-manifest.cjs b/scripts/export-manifest.cjs index 59c7db18d..d4e3341b9 100644 --- a/scripts/export-manifest.cjs +++ b/scripts/export-manifest.cjs @@ -27,6 +27,8 @@ function excluded(rel) { rel === 'manifest.webmanifest' || rel === 'version.json' || rel === '404.html' || + // 69 T3: the hosted build's response headers — an export's host has its own + rel === '_headers' || /^llms(-full)?\.txt$/.test(rel) || /^cloud-plugin.*\.js$/.test(rel) || rel.startsWith('templates/') || diff --git a/scripts/perf-games.cjs b/scripts/perf-games.cjs index 84ce57de0..4e62a8aad 100644 --- a/scripts/perf-games.cjs +++ b/scripts/perf-games.cjs @@ -54,6 +54,9 @@ const flag = (name) => argv.includes('--' + name); // 34 B2 `--check`: THE BUDGET GATE (see the header of perfBudget.cjs and perf/README.md) — // counts only, in ONE pinned profile, games AND levels, judged against perf/budgets.json; // exit 1 when red. `--games-only` / `--levels-only` narrow it, `--budgets ` swaps the file. +// deps-auto `--part k/n`: this run measures part k of n (deterministic, balanced by measured cost — a game +// ~100 s, a level ~60 s + ~80 s per viewpoint on a runner), so a workflow can run the gate as n parallel jobs. +// A part judges only what it measured (an allow entry for a target it skipped is not "stale"). const CHECK = flag('check'); const budgetLib = require('./perfBudget.cjs'); const BUDGETS = CHECK ? budgetLib.loadBudgets(arg('budgets', budgetLib.BUDGETS_FILE)) : null; @@ -314,7 +317,24 @@ async function runCheck() { const pick = (slug) => !ONLY.length || ONLY.includes(slug); const { checkLevel, VIEWS } = require('./perf-levels.cjs'); const games = levelsOnly ? [] : (index.games || []).filter((g) => pick(g.slug)).map((g) => ({ slug: g.slug, title: g.title, scene: g.scene, modules: g.modules || [] })); - const levels = gamesOnly ? [] : (index.templates || []).filter((t) => VIEWS[t.slug] && pick(t.slug)); + let levels = gamesOnly ? [] : (index.templates || []).filter((t) => VIEWS[t.slug] && pick(t.slug)); + const part = /^(\d+)\/(\d+)$/.exec(arg('part', '') || ''); + if (part) { + const [k, n] = [Number(part[1]), Number(part[2])]; + /** @type {{kind: string, slug: string, cost: number}[]} */ + const items = [...games.map((g) => ({ kind: 'game', slug: g.slug, cost: 100 })), ...levels.map((t) => ({ kind: 'level', slug: t.slug, cost: 60 + 80 * VIEWS[t.slug].length }))]; + items.sort((a, b) => b.cost - a.cost || (a.slug < b.slug ? -1 : 1)); + const load = Array(n).fill(0); + const mine = new Set(); + for (const it of items) { + const best = load.indexOf(Math.min(...load)); + load[best] += it.cost; + if (best === k - 1) mine.add(it.kind + ':' + it.slug); + } + games.splice(0, games.length, ...games.filter((g) => mine.has('game:' + g.slug))); + levels = levels.filter((t) => mine.has('level:' + t.slug)); + console.log(`part ${k}/${n}: ${games.length} games, ${levels.length} levels`); + } if (!games.length && !levels.length) { console.error('budget gate: nothing to measure (scenes ' + (SCENES_DIR || SCENES_REPO + '@' + SCENES_REF) + ')'); process.exit(2); diff --git a/scripts/serve-build.cjs b/scripts/serve-build.cjs index c71eedb7c..2730accf0 100644 --- a/scripts/serve-build.cjs +++ b/scripts/serve-build.cjs @@ -52,6 +52,22 @@ const MIME = { '.txt': 'text/plain; charset=utf-8' }; +/** 69 T3: the `/*` block of build/_headers (what Cloudflare Pages sends with every response) */ +const SITE_HEADERS = (() => { + /** @type {Record} */ + const out = {}; + try { + let inAll = false; + for (const line of fs.readFileSync(path.join(DIR, '_headers'), 'utf8').split('\n')) { + if (/^\S/.test(line)) inAll = line.trim() === '/*'; + else if (inAll && line.includes(':')) out[line.slice(0, line.indexOf(':')).trim()] = line.slice(line.indexOf(':') + 1).trim(); + } + } catch { + // no _headers: an older build + } + return out; +})(); + /** @param {any} req @param {any} res */ function handle(req, res) { let rel; @@ -73,6 +89,7 @@ function handle(req, res) { } const immutable = rel.startsWith('/_app/immutable/'); res.writeHead(200, { + ...SITE_HEADERS, 'Content-Type': MIME[path.extname(file)] || 'application/octet-stream', 'Cache-Control': immutable ? 'public, max-age=31536000, immutable' : 'no-cache' }); diff --git a/src/app.html b/src/app.html index 7c15c01c3..be9f977d6 100644 --- a/src/app.html +++ b/src/app.html @@ -20,28 +20,8 @@ - + + %sveltekit.head% diff --git a/src/components/Scene.svelte b/src/components/Scene.svelte index 7244f8b06..8d1ad2188 100644 --- a/src/components/Scene.svelte +++ b/src/components/Scene.svelte @@ -29,6 +29,7 @@ import { suspendAnimation, resumeAnimation, pumpFlowTick } from '$lib/flowRuntime'; import { holdBody, releaseBody } from '$lib/physics'; import { sculptObject, enterSculpt, beginStroke, strokeMove, endStroke as sculptEndStroke, showCursorAt, hideCursor } from '$lib/terrainSculpt'; + import { scatterSelectArmed, scatterSelectDown, scatterSelectMove, scatterSelectUp, onScatterProxyMoved, onScatterProxyDragChanged } from '$lib/scatterEdit'; import { sceneHits } from '$lib/scenePick'; import { pickStack, chooseInStack, altCycleIndex, primaryIndex } from '$lib/selectThrough'; import { editorPass } from '$lib/pickPass'; // 36 F22 @@ -485,6 +486,11 @@ onSplineProxyDragChanged(event.value); return; } + // 51 E4: scatter instances under the gizmo — ONE terrainScatter entry per drag + if (object.userData?.isScatterProxy) { + onScatterProxyDragChanged(event.value); + return; + } // 33: one LOD level's offset — ONE lod write per drag, never a move if (object.userData?.isLodLevelProxy) { onLodProxyDragChanged(event.value); @@ -871,9 +877,18 @@ } // T-2: sculpt mode — dragging brushes the terrain instead of orbiting if ($sculptObject && !$isLocked && !$isVRMode) { + // 51 E4: the Scatter Select tool — a press picks instances / starts a box; a + // press on a gizmo handle is the gizmo's (it sets `axis` on hover) + if (scatterSelectArmed()) { + if ($TControls?.axis) return; + scatterSelectDown($sculptObject, event); + sculptActive = true; + setOrbitEnabled(false); + return; + } const terrain = $objectsGroup?.getObjectByProperty('uuid', $sculptObject); setRayFromEvent(event); - const hit = terrain ? selectionRaycaster.intersectObject(terrain, false)[0] : null; + const hit = terrain ? selectionRaycaster.intersectObject(terrain, !terrain.isMesh)[0] : null; // 51-surface: a group host's children if (hit) { sculptActive = true; lastSculptAt = performance.now(); @@ -1017,14 +1032,22 @@ y1: Math.max(marqueeStart[1], event.clientY) }; } + // 51 E4: the Scatter Select tool draws a box, never a brush ring + if ($sculptObject && scatterSelectArmed()) { + hideCursor(); + if (sculptActive) { + scatterSelectMove(event); + return; + } + } // T-2: the brush cursor tracks the terrain; a held button keeps sculpting - if ($sculptObject) { + else if ($sculptObject) { const terrain = $objectsGroup?.getObjectByProperty('uuid', $sculptObject); setRayFromEvent(event); - const hit = terrain ? selectionRaycaster.intersectObject(terrain, false)[0] : null; + const hit = terrain ? selectionRaycaster.intersectObject(terrain, !terrain.isMesh)[0] : null; // 51-surface: a group host's children if (hit) { // mesh sculpt: orient the brush ring to the surface - const worldNormal = hit.face ? hit.face.normal.clone().transformDirection(terrain.matrixWorld) : null; + const worldNormal = hit.face ? hit.face.normal.clone().transformDirection(hit.object.matrixWorld) : null; showCursorAt(hit.point, worldNormal); if (sculptActive) { const now = performance.now(); @@ -1142,7 +1165,8 @@ if (sculptActive && event.button === 0) { sculptActive = false; setOrbitEnabled(true); - sculptEndStroke(); // flush the pending preview + ONE undoable snapshot + if ($sculptObject && scatterSelectArmed()) scatterSelectUp($sculptObject, event, camera.current); + else sculptEndStroke(); // flush the pending preview + ONE undoable snapshot return; } if (strokeActive && event.button === 0) { @@ -1705,6 +1729,12 @@ onSplineProxyMoved(); return; } + // 51 E4: the selected scatter instances follow the proxy (preview; release commits) + if ($TControls.object?.userData?.isScatterProxy) { + $TControls.visible = true; + onScatterProxyMoved(); + return; + } // 33: a LOD level's proxy — the level follows locally; the release commits if ($TControls.object?.userData?.isLodLevelProxy) { $TControls.visible = true; diff --git a/src/components/editors/Explorer.svelte b/src/components/editors/Explorer.svelte index e558af937..f97325c53 100644 --- a/src/components/editors/Explorer.svelte +++ b/src/components/editors/Explorer.svelte @@ -1,6 +1,7 @@ + +
+ {#if scatter.length} +
+ {#each scatter as s (s.id)} +
+
+ {setName(s)} + {countOf(s)} + + {#if terrain} + + + {/if} + +
+
    + {#each sourcesOfSet(s) as e, i (i)} +
  • +
    + {sourceLabel(e.source)} + {#if s.sources} +
    + (setBefore = scatterBlockNow(uuid))} + onchange={(/** @type {number} */ v) => setWeight(s, i, v)} + onscrubend={endWeight} + /> +
    + + {/if} +
    + {#if e.source.note} + {e.source.note} + {/if} +
  • + {/each} +
+ addModel(s, String(v))} + /> + +
+
+ (setBefore = scatterBlockNow(uuid))} + onchange={(/** @type {number} */ v) => setField(s, { tint: v })} + onscrubend={endField} + /> +
+
+ (setBefore = scatterBlockNow(uuid))} + onchange={(/** @type {number} */ v) => setField(s, { sink: v })} + onscrubend={endField} + /> +
+
+ +
+ Collider + editScatterSet(uuid, s.id, { collider: v })} + /> +
+ +
+ {/each} +
+

{total} of {MAX_INSTANCES} instances

+ {:else} +

+ {terrain ? 'No scattered models yet.' : 'Scatter models over this object’s surface — they move, turn and scale with it.'} +

+ {/if} +
+ + {scatter.length ? 'Add fill' : 'Add scatter'} + {terrain ? 'Scatter…' : 'Paint scatter…'} +
+
diff --git a/src/components/menu/inspector/TerrainPaintSection.svelte b/src/components/menu/inspector/TerrainPaintSection.svelte index ed4d6c95b..73255d2a7 100644 --- a/src/components/menu/inspector/TerrainPaintSection.svelte +++ b/src/components/menu/inspector/TerrainPaintSection.svelte @@ -1,20 +1,19 @@
@@ -123,6 +111,16 @@ {/if} {/each} +
+ Detail + setSplatResolution(uuid, Number(v))} + /> +
{#if coverage}

Painted: {coverage.map((c, i) => `${i + 1}: ${c}%`).join(' · ')} @@ -136,38 +134,4 @@

-
- {#if scatter.length} -
- {#each scatter as s (s.id)} -
-
- {setName(s)} - {s.count} - - -
- -
- Collider - editScatterSet(uuid, s.id, { collider: v })} - /> -
-
- {/each} -
-

{total} of {MAX_INSTANCES} instances

- {:else} -

No scattered models yet.

- {/if} - Scatter… -
+ diff --git a/src/components/menu/settings/ScatterDetailSetting.svelte b/src/components/menu/settings/ScatterDetailSetting.svelte new file mode 100644 index 000000000..b173c153f --- /dev/null +++ b/src/components/menu/settings/ScatterDetailSetting.svelte @@ -0,0 +1,39 @@ + + + + +
+ + scatterDetail.set(String(v))} /> + +
+ + diff --git a/src/components/menu/settings/SceneSettings.svelte b/src/components/menu/settings/SceneSettings.svelte index 087c8e504..e2b6d141e 100644 --- a/src/components/menu/settings/SceneSettings.svelte +++ b/src/components/menu/settings/SceneSettings.svelte @@ -49,6 +49,7 @@ import LoadingSettings from './LoadingSettings.svelte'; import PlacementSettings from './PlacementSettings.svelte'; // 39 P3 import WaterSettings from '../../water/WaterSettings.svelte'; + import ScatterDetailSetting from './ScatterDetailSetting.svelte'; // 51 import GpuFluidSetting from '../../sim/GpuFluidSetting.svelte'; // 49-fluid W2 import HdriSettings from '../../hdri/HdriSettings.svelte'; // 37-hdri import { showSimControls, duplicateCarriesAnimation, duplicateCarriesFlow, duplicateCarriesShader, showToast } from '../../../stores/appStore.js'; @@ -153,6 +154,7 @@ + diff --git a/src/components/ui/ToolIcon.svelte b/src/components/ui/ToolIcon.svelte index f6882ca2f..023c71ce4 100644 --- a/src/components/ui/ToolIcon.svelte +++ b/src/components/ui/ToolIcon.svelte @@ -289,6 +289,23 @@ 'scatter-erase': { base: ['M3 20h18'], accent: ['M7 16l6-9 4 3-6 9h-3z', 'M13 19h7'] + }, + // 51 E4: edit the scattered instances — turn them, grow them, even them out, pick them + 'scatter-rotate': { + base: ['M3 20h18', 'M12 16l-3 0 3-7 3 7z', 'M12 16v4'], + accent: ['M5.5 9.5a7 7 0 0 1 12.5-4.5', 'M18.5 2v3.5H15'] + }, + 'scatter-scale': { + base: ['M3 20h18', 'M6.5 17l-2 0 2-4.5 2 4.5z', 'M6.5 17v3'], + accent: ['M15.5 15l-3.5 0 3.5-9 3.5 9z', 'M15.5 15v5', 'M19 2.5h2.5V5', 'M21.5 2.5l-3 3'] + }, + 'scatter-relax': { + base: ['M3 20h18'], + accent: ['M5 14.5a1.5 1.5 0 1 0 3 0a1.5 1.5 0 1 0-3 0', 'M10.5 14.5a1.5 1.5 0 1 0 3 0a1.5 1.5 0 1 0-3 0', 'M16 14.5a1.5 1.5 0 1 0 3 0a1.5 1.5 0 1 0-3 0', 'M6.5 9l-2-2 2-2', 'M17.5 9l2-2-2-2', 'M4.5 7h15'] + }, + 'scatter-select': { + base: ['M12 15l-3 0 3-7 3 7z', 'M12 15v4'], + accent: ['M3 7V3h4', 'M17 3h4v4', 'M21 17v4h-4', 'M7 21H3v-4'] } }; diff --git a/src/lib/ai/meshProviders.js b/src/lib/ai/meshProviders.js index 18d96212c..daecc5a56 100644 --- a/src/lib/ai/meshProviders.js +++ b/src/lib/ai/meshProviders.js @@ -1,5 +1,6 @@ import { writable, get } from 'svelte/store'; import { safeStorage } from '../safeStorage'; +import { registerSecretSource } from '../diagnostics'; // Text/image -> 3D mesh generation providers (roadmap #11, G1). Mirrors // ai/providers.js (the LLM providers) but for mesh backends: a self-hosted ComfyUI @@ -74,6 +75,8 @@ function persist(list) { /** Saved mesh-provider configs. @type {import('svelte/store').Writable} */ export const meshProviders = writable(loadProviders()); +// 69 T2: the keys never reach a pasted diagnostics bundle +registerSecretSource(() => get(meshProviders).map((p) => p.apiKey)); /** @type {import('svelte/store').Writable} */ export const meshActiveProvider = writable( diff --git a/src/lib/ai/providers.js b/src/lib/ai/providers.js index 9a4bd6c9c..18093f3a2 100644 --- a/src/lib/ai/providers.js +++ b/src/lib/ai/providers.js @@ -1,5 +1,6 @@ import { writable, get } from 'svelte/store'; import { safeStorage } from '../safeStorage'; +import { registerSecretSource } from '../diagnostics'; // AI provider settings (roadmap #10, A1). A LOCAL per-device preference — the // only credentials the app stores. Keys live in PLAINTEXT localStorage (there is @@ -112,6 +113,8 @@ function persistProviders(list) { /** Saved provider configs (list — a user can keep Grok + several vLLM boxes). * @type {import('svelte/store').Writable} */ export const aiProviders = writable(loadProviders()); +// 69 T2: the keys never reach a pasted diagnostics bundle +registerSecretSource(() => get(aiProviders).map((p) => p.apiKey)); /** id of the active provider, or null. * @type {import('svelte/store').Writable} */ diff --git a/src/lib/ai/stt.js b/src/lib/ai/stt.js index 4bad4c7c9..90e11d3c0 100644 --- a/src/lib/ai/stt.js +++ b/src/lib/ai/stt.js @@ -1,5 +1,6 @@ import { writable, get } from 'svelte/store'; import { safeStorage } from '../safeStorage'; +import { registerSecretSource } from '../diagnostics'; import { AiHttpError, describeAiError } from './client.js'; // Speech to text (36-vr-ai, plan F2): voice typing for the AI assistant. ONE local per-device config, @@ -62,6 +63,8 @@ function loadConfig() { /** @type {import('svelte/store').Writable} */ export const sttConfig = writable(loadConfig()); +// 69 T2: the keys never reach a pasted diagnostics bundle +registerSecretSource(() => get(sttConfig).apiKey ? [get(sttConfig).apiKey] : []); /** Merge a patch into the config and persist it @param {Partial} patch */ export function setSttConfig(patch) { diff --git a/src/lib/behaviours/app.js b/src/lib/behaviours/app.js index f9a624cb8..2c731eb0c 100644 --- a/src/lib/behaviours/app.js +++ b/src/lib/behaviours/app.js @@ -6,9 +6,10 @@ // scene, replicates through node sync, joins late joiners through getnodes, undoes as // a node-data edit, and the Explorer lists it as `.js` (sceneAssets) — no new // persistence path. Every peer evaluates the SAME text. -// LOADING analyze (the lint stops a file that would make peers disagree), then the module -// loader's blob import (userModules.importModuleText — the path installed modules -// take; source.js puts the one-line scope prologue in front), then core.start. +// LOADING analyze (the lint stops a file that would make peers disagree), then the file is +// evaluated in the sandbox GUEST (69 T1, sandbox/behaviours.js — its own engine +// context: no DOM, storage or network; source.js's prologue + loop guard as before; +// `def` comes back as data + host stubs), then core.start. // T2 each behaviour is a lifecycle MODULE `behaviour:`: its kit face's // listeners, its owned kit entities and its scope are tracked in the registry // (sdk/lifecycle.js), and removing the node (or disabling it, or a scene clear) @@ -32,7 +33,7 @@ import { log } from '../diagnostics'; import { kit, kitAuthorityId } from '../kit/runtime.js'; import { track, disposeRegistrations, registrationsOf } from '../sdk/lifecycle.js'; import { createBehaviourRuntime, BHV } from './core.js'; -import { compileBehaviour } from './source.js'; +import { compileBehaviourInGuest } from '../sandbox/behaviours.js'; import { analyze } from './analyze.js'; import { globMatch } from './events.js'; // 36 (U10): module ENGINE pieces a game's rules call as `kit..*` (engines.js) @@ -134,12 +135,6 @@ function setStatus(id, patch) { behaviourStatus.update((all) => ({ ...all, [id]: { ...(all[id] ?? {}), ...patch } })); } -/** the module loader's evaluation (lazy: userModules reaches the SDK) @param {string} code */ -async function importText(code) { - const m = await import('../userModules'); - return m.importModuleText(code); -} - /** the owner object of a graph, as `this.object` @param {string | undefined} graphId */ function ownerRef(graphId) { if (!graphId || graphId === 'scene') return null; @@ -189,7 +184,7 @@ async function load(node) { /** @type {any} */ let compiled; try { - compiled = await compileBehaviour(code, importText); + compiled = await compileBehaviourInGuest(code); } catch (error) { if (seq !== entry.seq) return; runtime.stop(id); diff --git a/src/lib/budgetMeterCore.js b/src/lib/budgetMeterCore.js index 0feca4cec..6af2031ed 100644 --- a/src/lib/budgetMeterCore.js +++ b/src/lib/budgetMeterCore.js @@ -283,7 +283,8 @@ export function measureScene(input) { if (node.castShadow) row.shadowLights++; } if (node.isMesh || node.isPoints || node.isLine || node.isSprite) { - const tris = node.isMesh ? trianglesOf(node.geometry) * (node.isInstancedMesh ? Math.max(0, node.count ?? 0) : 1) : 0; + // 51: a thinned scatter mesh counts every instance (the content, not this device's draw) + const tris = node.isMesh ? trianglesOf(node.geometry) * (node.isInstancedMesh ? Math.max(0, node.userData?.instanceTotal ?? node.count ?? 0) : 1) : 0; row.triangles += tris; let calls = callsOf(node); if (kitInstancing && node.isMesh && !node.isInstancedMesh) { diff --git a/src/lib/cloudPlugin.js b/src/lib/cloudPlugin.js index 4b197f955..43bf1bc3b 100644 --- a/src/lib/cloudPlugin.js +++ b/src/lib/cloudPlugin.js @@ -24,6 +24,7 @@ import { // cloudPlugin path is in history's import subtree — App alone imports this module). import { currentLevel } from './levels'; import { myPlayMode, peerPlayModes } from './gamePresence'; +import { onPlayOrigin, playUrl } from './playOrigin.js'; import { safeStorage } from './safeStorage'; // v3.1 (roadmap 29 G-3): the hosted-AI preset seam. Both provider modules are LEAVES // (svelte/store + safeStorage), so a static edge from here closes nothing. @@ -260,6 +261,13 @@ export function makeCloudApi() { /** 29: this engine carries `cloudMeta` on dials and consults `authProvider.decide` * (admit / deny / knock-with-label). Absent on older engines — probe it. */ dialMeta: true, + /** 69 T4: this page is served from the separate PLAY origin (`VITE_PLAY_ORIGIN`): player-only, + * and nothing that signs anyone in belongs here (a token on this origin is what the split + * exists to avoid). Absent on older engines — probe it; false on the app origin. */ + playOrigin: onPlayOrigin, + /** 69 T4: an absolute play / embed URL — on the play origin when one is configured, else here. + * @param {string} path e.g. `/?s=&play=1&embed=1` */ + playUrl: (/** @type {string} */ path) => playUrl(String(path ?? '/')), // --- plugin message channel (replicate the plugin's own state) --- /** broadcast a cloud message to all peers (roles, room announces) */ diff --git a/src/lib/debugHooks.js b/src/lib/debugHooks.js index a2c059159..fff4b1620 100644 --- a/src/lib/debugHooks.js +++ b/src/lib/debugHooks.js @@ -25,6 +25,8 @@ export const DEBUG_HOOKS = [ ['kitEntities', () => import('./kit/entityApp.js')], // 34 R3 (D1/D2): behaviours (runtime, behaviourStatus, behavioursDebug) ['behaviours', () => import('./behaviours/app.js')], + // 69: the sandbox engine (sandboxReady / loadSandbox / sandboxLoadError) — probes on built apps + ['sandbox', () => import('./sandbox/engine.js')], ['meshEdit', () => import('./meshEdit')], ['perf', () => import('./perf/recorder')], ['perfDetailed', () => import('./perf/detailed')], @@ -111,8 +113,15 @@ export const DEBUG_HOOKS = [ ['terrainPaint', () => import('./terrainPaint')], ['terrainSurface', () => import('./terrainSurface')], ['terrainPaintCore', () => import('./terrainPaintCore')], + // 51 E4/E5: editing scatter instances (tools, selection, gizmo) and its pure half + ['scatterEdit', () => import('./scatterEdit')], + ['scatterEditCore', () => import('./scatterEditCore')], + ['scatterConvert', () => import('./scatterConvert')], ['scatterColliders', () => import('./scatterColliders')], ['terrainScatterCore', () => import('./terrainScatterCore')], + // 51 E1: the scatter source catalog (pack items, prefabs + objects as their look) + ['scatterSources', () => import('./scatterSources')], + ['scatterThin', () => import('./scatterThin')], ['userModules', () => import('./userModules')], ['environment', () => import('./environment')], // 37-hdri: the HDRI layer (hdriDebug, installHdriLayer) + its leaf stores (skyEnv, envToneMapping, hdriStatus) + the pure core diff --git a/src/lib/diagnostics.js b/src/lib/diagnostics.js index 840e91b6d..459bce835 100644 --- a/src/lib/diagnostics.js +++ b/src/lib/diagnostics.js @@ -175,13 +175,48 @@ export function bundle(extra = {}) { return out; } +/** @type {Set<() => string[]>} */ +const secretSources = new Set(); + +/** + * 69 T2: a module holding credentials (AI / mesh / speech keys) registers a reader of the + * CURRENT values, and every one of them is replaced by `[redacted]` in the bundle text — an + * error line or a section may quote a URL or a header, and the bundle is made to be pasted. + * @param {() => string[]} read @returns {() => void} unregister + */ +export function registerSecretSource(read) { + secretSources.add(read); + return () => secretSources.delete(read); +} + +/** Replace every registered secret (6+ chars) in `text`. @param {string} text */ +export function redactSecrets(text) { + /** @type {string[]} */ + const secrets = []; + for (const read of secretSources) { + try { + for (const value of read() ?? []) if (typeof value === 'string' && value.trim().length >= 6) secrets.push(value.trim()); + } catch { + // a source that throws redacts nothing; the bundle still goes out + } + } + let out = text; + for (const secret of secrets.sort((a, b) => b.length - a.length)) { + out = out.split(secret).join('[redacted]'); + // JSON.stringify escapes quotes and backslashes; cover the escaped spelling too + const escaped = JSON.stringify(secret).slice(1, -1); + if (escaped !== secret) out = out.split(escaped).join('[redacted]'); + } + return out; +} + /** The bundle as the text that goes on the clipboard. @param {Record} [extra] */ export function bundleText(extra = {}) { try { - return JSON.stringify(bundle(extra), null, 2); + return redactSecrets(JSON.stringify(bundle(extra), null, 2)); } catch (error) { // the bundle itself must never be the thing that fails - return 'diagnostics bundle failed: ' + String(error) + '\n' + lines().join('\n'); + return redactSecrets('diagnostics bundle failed: ' + String(error) + '\n' + lines().join('\n')); } } diff --git a/src/lib/dialBackQueue.js b/src/lib/dialBackQueue.js new file mode 100644 index 000000000..99bd83602 --- /dev/null +++ b/src/lib/dialBackQueue.js @@ -0,0 +1,71 @@ +// scatter-sync — WHAT THE HOST SAYS WHILE AN APPROVED JOINER'S CONN IS STILL OPENING. +// +// Approving a joiner DIALS BACK (`approveDialBack`), and that conn takes time to open: a +// few hundred ms on a fast box, 15 s measured with the CPU throttled to GitHub-runner +// speed. `broadcast` skips a conn that is not open, so every room edit made in that window +// was dropped for the joiner. Nothing else carried it either, unless the joiner's full-state +// request happened to be answered AFTER the edit — and that reply is the slow path (a 500 ms +// wait, then a GLTF of the whole scene): the four scatter suites' "B has the terrain" read +// 24.6 s on a throttled run, past their 20 s window, every time. +// +// So the host HOLDS its room-scoped broadcasts for a peer whose approval dial-back is in +// flight, and flushes them in order right after that conn's handshake — the joiner gets them +// exactly as a peer connected all along would have, and the full-state reply that follows is +// add-only by uuid, so nothing lands twice. A LEAF (no imports) so the bounds are unit-tested. +// +// BOUNDED both ways, because the full-state reply still follows and covers anything dropped +// here: past `cap` messages the whole queue is abandoned (a half queue would replay edits to +// objects whose creation was cut off), and entries older than `ttlMs` are not flushed. + +export const DIAL_BACK_QUEUE_CAP = 300; +export const DIAL_BACK_QUEUE_TTL_MS = 120_000; + +/** + * @param {{cap?: number, ttlMs?: number, now?: () => number}} [opts] + */ +export function createDialBackQueue(opts = {}) { + const cap = opts.cap ?? DIAL_BACK_QUEUE_CAP; + const ttlMs = opts.ttlMs ?? DIAL_BACK_QUEUE_TTL_MS; + const now = opts.now ?? (() => Date.now()); + /** @type {Map} */ + const queues = new Map(); + + return { + /** Hold one payload for `peerId`. @param {string} peerId @param {any} payload @returns {boolean} held */ + hold(peerId, payload) { + let q = queues.get(peerId); + if (!q) queues.set(peerId, (q = { items: [], overflowed: false })); + if (q.overflowed) return false; + if (q.items.length >= cap) { + q.items = []; + q.overflowed = true; + return false; + } + q.items.push({ at: now(), payload }); + return true; + }, + /** Everything held for `peerId`, oldest first, minus what expired; forgets the peer. @param {string} peerId */ + take(peerId) { + const q = queues.get(peerId); + queues.delete(peerId); + if (!q) return []; + const cutoff = now() - ttlMs; + return q.items.filter((i) => i.at >= cutoff).map((i) => i.payload); + }, + /** @param {string} peerId */ + drop(peerId) { + queues.delete(peerId); + }, + /** @param {string} peerId */ + size(peerId) { + return queues.get(peerId)?.items.length ?? 0; + }, + /** @param {string} peerId */ + overflowed(peerId) { + return !!queues.get(peerId)?.overflowed; + }, + clear() { + queues.clear(); + } + }; +} diff --git a/src/lib/fileHandler.svelte.js b/src/lib/fileHandler.svelte.js index d4b56c257..0436826db 100644 --- a/src/lib/fileHandler.svelte.js +++ b/src/lib/fileHandler.svelte.js @@ -780,7 +780,7 @@ function optimizeCheckNeeded(parsed, opts) { * @param {any} file @param {string=} name @param {string=} ext - explicit extension when the blob has no name (Library) * @param {number[]=} position - world drop point (Explorer drag-out, 96) * @param {any[]=} extras - companion files picked/dropped alongside (.mtl + its textures) - * @param {{reduce?: boolean | import('./importBudget').ReductionPlan, packRef?: import('./packRefs').PackRef | null, lod?: any, behavior?: any, quaternion?: number[] | null}} [opts] + * @param {{reduce?: boolean | import('./importBudget').ReductionPlan, packRef?: import('./packRefs').PackRef | null, lod?: any, behavior?: any, quaternion?: number[] | null, noOptimize?: boolean}} [opts] * `quaternion` (39): the placed root's rotation — a drag-to-place drop turned with R / the wheel * or aligned to a surface. Set BEFORE the add, so the create entry and the wire carry it. * `behavior` (33 P2): the pack item's functional spec — a door, a lid, a fan diff --git a/src/lib/inviteLinks.js b/src/lib/inviteLinks.js index 57e2a0f66..4ded67104 100644 --- a/src/lib/inviteLinks.js +++ b/src/lib/inviteLinks.js @@ -6,8 +6,8 @@ // · our own id → say so // · the host we are already with → say so // · a different server named → ask, naming the host (a `~srv=` link can point -// at ANY signaling server; the load-time path -// switches silently, the live one says where) +// at ANY signaling server; 69 T8: the load-time +// path asks the same question, confirmInviteServer) // · already in a session → ask to leave first (the user's rule) // · else → dial through requestConnect (its own guards) // The hash is CONSUMED first (replaceState, so no second hashchange and no trailing `#`): @@ -41,6 +41,44 @@ function peerLabel(peerId) { return name && name.toLowerCase() !== peerId ? name + ' (' + id + ')' : id; } +/** 69 T8: the one question a link naming another signaling server gets, at load or live + * @param {string} peerId @param {any} target @returns {Promise} 'join' or not */ +function askServer(peerId, target) { + return showChoice({ + title: 'Join on another server?', + message: + 'Join ' + peerLabel(peerId) + ' on ' + serverLabel(target) + '? This invite connects through that server instead of ' + + 'your usual one — it sees your IP address and relays how you connect. Only join if you trust who sent the link.', + choices: [{ value: 'join', label: 'Join' }], + cancelLabel: 'Stay on mine' + }); +} + +/** + * 69 T8: an invite opened IN A NEW TAB that names a signaling server. It used to be switched to + * silently at load; now it is asked, the way a link pasted into a running tab always was. The same + * server as ours is no question: wait for our own server, then dial as the bare link would. + * @param {any} peer the PeerConnection @param {string} peerId @param {any} target decoded `~srv` + * @returns {Promise<'dial' | 'stay' | 'failed'>} + */ +export async function confirmInviteServer(peer, peerId, target) { + const opened = () => + new Promise((resolve) => { + const started = Date.now(); + const tick = () => (peer.hasOpened || Date.now() - started > 20000 ? resolve(!!peer.hasOpened) : setTimeout(tick, 200)); + tick(); + }); + if (sameServer(target, get(peerServerStatus))) { + if (!(await opened())) return 'failed'; + await requestConnect(peerId); + return 'dial'; + } + if ((await askServer(peerId, target)) !== 'join') return 'stay'; + if (!(await peer.switchServer(target))) return 'failed'; // switchServer said why + await requestConnect(peerId); + return 'dial'; +} + /** Drop the hash without a navigation and without a second hashchange. */ function consumeHash() { if (typeof history === 'undefined' || typeof location === 'undefined') return; @@ -124,11 +162,7 @@ export async function handleInviteHash(hash) { } if (needSwitch) { // one extra tap only in the uncommon case, and the user sees where they are going - void showChoice({ - title: 'Join on another server?', - message: 'Join ' + peerLabel(peerId) + ' on ' + serverLabel(target) + '?', - choices: [{ value: 'join', label: 'Join' }] - }).then((answer) => { + void askServer(peerId, target).then((answer) => { if (answer === 'join') void proceed(); }); return 'ask-server'; diff --git a/src/lib/objectActions.js b/src/lib/objectActions.js index 987174348..4dc0b5eee 100644 --- a/src/lib/objectActions.js +++ b/src/lib/objectActions.js @@ -635,7 +635,7 @@ function stripSelectionTint(source, clone) { * the spawner's entire point: thirty-two crates would be thirty-two replicated graph * documents, into every peer and every save path, for objects that exist for one run. * @param {string=} uuid - defaults to the selected object - * @param {{select?: boolean, history?: boolean, transient?: boolean, at?: number[], carryCables?: boolean}=} options + * @param {{select?: boolean, history?: boolean, transient?: boolean, at?: number[], carryCables?: boolean, name?: string}=} options * select:false leaves the selection alone (duplicateSelection selects the whole clone * SET once, at the end) */ @@ -665,7 +665,9 @@ export function duplicateObject(uuid, options = {}) { if (shareMaterial) linkMaterials(source, clone); const cloneNodes = collectTree(clone); cloneNodes.forEach((node) => (node.uuid = crypto.randomUUID())); - clone.name = (source.name || source.type) + ' copy'; + // 51 union: a caller may name the copy (scatter's To objects keeps the model's own name) — set BEFORE the + // `duplicate` message, which carries the name to peers + clone.name = typeof options.name === 'string' && options.name ? options.name : (source.name || source.type) + ' copy'; if (Array.isArray(options.at)) clone.position.fromArray(options.at); else { clone.position.x += 0.5; diff --git a/src/lib/objectMenu.js b/src/lib/objectMenu.js index b365d1ae4..2985ce3d0 100644 --- a/src/lib/objectMenu.js +++ b/src/lib/objectMenu.js @@ -30,6 +30,15 @@ import { hasOriginal } from './decimate'; import { linkOf } from './prefabSync'; import { prefabById } from './prefabs'; +/** @param {any} object */ +function hasMesh(object) { + let found = false; + object?.traverse?.((/** @type {any} */ n) => { + if (n.isMesh) found = true; + }); + return found; +} + /** * 47 M2: the nine Align rows (X / Y / Z × min / centre / max) for a set. * @param {string[]} targets @param {'selection'|'active'} to @param {boolean} locked @@ -289,6 +298,13 @@ export function buildObjectMenuItems(uuid, opts = {}) { icon: 'group', tooltip: 'Move the selected objects into one new group', action: () => groupSelection() + }, + // 51 E5: many copies of one object on a terrain become instances of its scatter + { + label: 'Make scatter' + suffix, + icon: 'tool:scatter', + tooltip: 'Turn copies of one object standing on a Terrain into scatter instances (one undo turns them back)', + action: () => import('./scatterConvert').then((m) => m.objectsToScatter(selection)) } ] : []), @@ -594,7 +610,7 @@ export function buildObjectMenuItems(uuid, opts = {}) { : []) ]; - /** @type {any[]} Physics & effects ▸ — joints, particles, the object's flow */ + /** @type {any[]} Physics & effects ▸ — joints, particles, the object's flow, Use as scatter source */ const behaviour = [ // P-B: joints — attach exactly TWO objects (weld holds the pose, a hinge spins about the // FIRST-clicked object's chosen local axis, anchored at the second object's origin); @@ -698,6 +714,18 @@ export function buildObjectMenuItems(uuid, opts = {}) { } ) } + ]), + // 51 E1: this object's look becomes what the Scatter brush paints (a pristine kit piece as its pack item); + // one level down (51-int: the top level holds at 16 rows, the context-menu-redesign lock) + ...(multi || object?.userData?.terrain || !hasMesh(object) + ? [] + : [ + { + label: 'Use as scatter source', + icon: 'boxes', + tooltip: 'Scatter this model with a terrain\u2019s Scatter brush — its look only (model + materials), not its logic', + action: () => import('./scatterSources').then((m) => m.useAsScatterSource(uuid)) + } ]) ]; diff --git a/src/lib/peerHandler.svelte.js b/src/lib/peerHandler.svelte.js index e6a3fc4ef..3efe89ce5 100644 --- a/src/lib/peerHandler.svelte.js +++ b/src/lib/peerHandler.svelte.js @@ -4,6 +4,7 @@ import { sceneCommand, lockRestore, checkLocks, createObject, sendObjects, delet import { gateCreationBroadcast } from './objectPermissions'; import { createGeometry, createLight, createGroup, changeName, moveGeometry, lockGeometry, moveCamera } from '$lib/geometries.svelte'; import { sendNodes, applyNodesSnapshot, applyNodeSync, createFlowNode, moveFlowNode, updateFlowNodeData, deleteFlowNodes, createFlowEdge, deleteFlowEdges, applyFlowCursor } from '$lib/nodesHandler'; +import { preloadSandboxFor } from '$lib/sandbox/preload.js'; import { applyGraphCreate, applyGraphDelete } from '$lib/flowGraphs'; import { applyNodeTrigger } from '$lib/flowRuntime'; import { applyBurst } from '$lib/particleRuntime'; @@ -32,6 +33,7 @@ import { validateWireMessage } from '$lib/wireValidate'; import { holdForWireBatch } from '$lib/wireBatch'; import { routePerfLive, perfLivePeerGone } from '$lib/perf/liveWire.js'; // 34 PF: the live perf stream (an import-free leaf) import { noteWireError } from '$lib/wireErrors'; +import { createDialBackQueue } from '$lib/dialBackQueue'; // scatter-sync: room edits held for an opening approval dial-back import { noteWire } from '$lib/sceneBudget'; import { applyAnnotation, applyAnnotationsSnapshot, sendAnnotations } from '$lib/annotationsHandler'; import { applyPing } from '$lib/ping'; @@ -288,6 +290,9 @@ export class PeerConnection { /** 25-F: peers whose NEXT dial is an approval dial-back, so its handshake opens with * `joinresult: approved` @type {Set} */ this.approvedDialBacks = new Set(); + /** scatter-sync: room broadcasts HELD for a peer whose approval dial-back has not opened + * yet, flushed after its handshake (dialBackQueue.js says why) */ + this.dialBackQueue = createDialBackQueue(); // 37 R1: peers that advertised `wb: 1` (the `modules` handshake) and so understand a // `{type:'batch'}` envelope; everyone else gets the envelope's items one by one /** @type {Set} */ @@ -298,20 +303,23 @@ export class PeerConnection { /** 25-F: peers a refusal dial is out to — their `peer-unavailable` is not news @type {Set} */ this.refusalDials = new Set(); - // CN-3: an invite link can pin the signaling world (#A1B2C~srv=…). Parse it - // HERE, before resolvePeerOptions runs — the peer.on('open') hash flow below - // fires too late for server selection. Strip only the ~srv tail so the - // auto-connect flow keeps working on the bare id. + // CN-3: an invite link can name the signaling world (#A1B2C~srv=…). 69 T8: it is + // never switched to SILENTLY any more — a signaling server sees every peer's address + // and relays how they connect, and a link is something anybody can send. A link that + // names a server takes the hash whole (no auto-dial on our own server) and, once the + // app is up, asks with the host named (inviteLinks.confirmInviteServer — the same + // question a link pasted into a running tab gets). A link without one keeps the + // auto-connect flow on the bare id. if (typeof location !== 'undefined' && location.hash.includes('~')) { const { peerId: hashPeer, srv } = parseInviteHash(location.hash); - if (srv) { - const ov = decodeInviteServer(srv); - if (ov) { - applyInviteServerOverride(ov); - console.log('invite link pinned the signaling server:', srv); - } - } - location.hash = hashPeer ? '#' + hashPeer : ''; + const ov = srv ? decodeInviteServer(srv) : null; + if (ov && hashPeer) { + location.hash = ''; + const self = this; + setTimeout(() => { + import('./inviteLinks.js').then((m) => m.confirmInviteServer(self, hashPeer.toLowerCase(), ov)).catch(() => {}); + }, 0); + } else location.hash = hashPeer ? '#' + hashPeer : ''; } // A non-production hostname (localhost etc.) means the local dev signaling @@ -1022,6 +1030,7 @@ export class PeerConnection { } else if(data.type == 'huddelete') { applyRemoteHudDelete(data); } else if(data.type == 'huds') { + preloadSandboxFor(data); applyRemoteHuds(data); } else if(data.type == 'gethuds') { if (sameRoomOrUnknown(conn.peer)) sendHuds(data.sender); @@ -1216,6 +1225,7 @@ export class PeerConnection { mineName: myScene()?.scene ?? '' }); } else if(data.type == 'nodes') { + preloadSandboxFor(data); // 69: a joiner's scene with code starts the engine before its first tick applyNodesSnapshot(data.nodes, data.edges, data.graphs); } else if(data.type == 'nodesync') { applyNodeSync(data); @@ -1616,6 +1626,14 @@ export class PeerConnection { if (getobjects && !holdContent) conn.send({type: 'getnodedefs', sender: this.peer.id}) // join them into the voice mesh if our mic is live voicePeerConnected(peerId); + // scatter-sync: what we said while this approved joiner's conn was still opening, in + // order, re-gated NOW (their room row may have arrived since). After the handshake, so + // the joiner already knows where we stand; the full-state reply after it is add-only. + for (const payload of this.dialBackQueue.take(peerId)) { + if (!conn.open) break; + if (this.withholdFrom(peerId, payload)) continue; + this.sendTo(peerId, conn, payload); + } } /** @@ -1903,6 +1921,7 @@ export class PeerConnection { this.reconnecting.clear(); // pending retry timers see a cleared map and no-op this.gracefulLeft.clear(); this.wantsStateFrom.clear(); + this.dialBackQueue.clear(); for (const peerId of Object.keys(this.connections)) { const conn = this.connections[peerId]; delete this.connections[peerId]; @@ -1979,31 +1998,50 @@ export class PeerConnection { // scene it is being made in. ROOM_SCOPED only, exactly as the gate above: the pose // STREAMS keep flowing (private is not offline, and the far side draws nothing anyway // — its own `elsewhereThan` reads our private row as elsewhere). + const roomScoped = ROOM_SCOPED.has(payload?.type); + Object.keys(this.connections).forEach(peerId => { + const conn = this.connections[peerId]; + if (!conn) return; + if (!conn.open) { + // scatter-sync: an APPROVED joiner whose dial-back is still opening gets this after + // its handshake instead of never (dialBackQueue.js). Room content only — streams + // are re-derived every frame, and the rest has handshake replies of its own. + if (roomScoped && this.approvedDialBacks.has(peerId)) this.dialBackQueue.hold(peerId, payload); + return; + } + if (this.withholdFrom(peerId, payload)) return; + this.sendTo(peerId, conn, payload); + }); + } + + /** + * The per-peer half of `broadcast`'s gate (the reasons are written above it), shared with + * the dial-back flush so a held message is judged by the same rules when it finally goes. + * @param {string} peerId @param {any} payload @returns {boolean} true = do not send + */ + withholdFrom(peerId, payload) { const roomScoped = ROOM_SCOPED.has(payload?.type); const gated = STREAM_TYPES.has(payload?.type) || roomScoped; - const mine = gated ? (myScene()?.scene ?? '') : ''; - const where = gated ? get(peerScenes) : {}; + if (roomScoped && amPrivate()) return true; // R22 round 36 (rooms): THE HOST, because the unnamed world is a room with an // identity now and only the host's row can say which. `null` is a real answer here // (we are hosting, so the unnamed room is OURS) — it is OMITTING the argument that // would ask round 35's question, which is why it is passed unconditionally. - const host = gated ? get(sessionHost) : null; - const secret = roomScoped && amPrivate(); - Object.keys(this.connections).forEach(peerId => { - const conn = this.connections[peerId]; - if (!conn || !conn.open) return; - if (secret) return; - if (gated && elsewhereThan(where, mine, peerId, host)) return; - if (roomScoped && gateHolds(peerId)) return; - try { - // 37 R1: a peer that never said `wb: 1` gets the envelope's items one by one - if (payload?.type === 'batch' && !this.wireBatchPeers.has(peerId)) - for (const item of payload.items) conn.send(item); - else conn.send(payload); - } catch (err) { - log('error', 'net', 'send failed', { peer: peerId, error: String(err) }); - } - }); + if (gated && elsewhereThan(get(peerScenes), myScene()?.scene ?? '', peerId, get(sessionHost))) return true; + if (roomScoped && gateHolds(peerId)) return true; + return false; + } + + /** @param {string} peerId @param {any} conn @param {any} payload */ + sendTo(peerId, conn, payload) { + try { + // 37 R1: a peer that never said `wb: 1` gets the envelope's items one by one + if (payload?.type === 'batch' && !this.wireBatchPeers.has(peerId)) + for (const item of payload.items) conn.send(item); + else conn.send(payload); + } catch (err) { + log('error', 'net', 'send failed', { peer: peerId, error: String(err) }); + } } /** @param {string} message @param {string} [type] */ diff --git a/src/lib/playMode.js b/src/lib/playMode.js index 1f20006bd..e557e1c0d 100644 --- a/src/lib/playMode.js +++ b/src/lib/playMode.js @@ -11,6 +11,7 @@ import { showToast } from '../stores/appStore'; import { pushState } from '$app/navigation'; // 36-export: an exported game is an embed for its whole life too (a leaf, read once at eval) import { exportMode } from './export/exportBoot.js'; +import { onPlayOrigin, appUrl } from './playOrigin.js'; import { safeStorage } from './safeStorage'; // 50 R1: a LEAF (svelte/store only) — the AR table's session is not a Play press import { arTableActive } from './arTableState.js'; @@ -106,8 +107,9 @@ function readEmbedBoot() { } const embedBoot = readEmbedBoot(); /** true for the whole life of a page opened with `?embed=1` — or of an EXPORTED game (36-export), - * which hides the same editor chrome and draws the same player chrome */ -export const embedMode = writable(embedBoot.on || exportMode); + * which hides the same editor chrome and draws the same player chrome — or of any page on the + * separate play origin (69 T4: it is player-only, `$lib/playOrigin`) */ +export const embedMode = writable(embedBoot.on || exportMode || onPlayOrigin); /** the `?s=` the embed opened with ('' when none) — the corner link's target */ export const embedSceneId = embedBoot.scene; /** 36-community (C4): the visit source and published build the frame was opened with ('' when none) */ @@ -115,7 +117,8 @@ export const embedSource = embedBoot.src; export const embedBuild = embedBoot.build; /** the same scene in the full app: `/?s=`, or `/` when the embed carried no scene */ export function embedOpenUrl() { - return embedSceneId ? `/?s=${encodeURIComponent(embedSceneId)}` : '/'; + // 69 T4: from the play origin, the editor is on the app origin + return appUrl(embedSceneId ? `/?s=${encodeURIComponent(embedSceneId)}` : '/'); } /* ------------------------------------------------------------------ the exit --- */ diff --git a/src/lib/playOrigin.js b/src/lib/playOrigin.js new file mode 100644 index 000000000..55dcc761d --- /dev/null +++ b/src/lib/playOrigin.js @@ -0,0 +1,46 @@ +// 69 T4 — AN OPTIONAL SECOND ORIGIN FOR PLAYING. A LEAF (reads only build env and `location`). +// +// The editor's origin holds what a person signs in with and what they have made (the login token, +// AI keys, autosaves). A shared game does not need any of it, so a hosted deployment can serve the +// SAME build from a second origin — `VITE_PLAY_ORIGIN`, e.g. https://play.theprototype.app — and +// point play links and embeds there: storage is per origin, so whatever runs in a played scene +// finds nothing of the editor's on the page it runs in. +// +// Unset (the default, and every self-hosted `npm run dev`) means one origin for everything, exactly +// as before. On the play origin the page is player-only for its whole life (embed chrome, no editor) +// and the cloud plugin is told so (`cloudApi.playOrigin`) so it never asks anyone to sign in there. +// VITE_PLAY_ORIGIN where play / embed links point (an origin; a path is ignored) +// VITE_APP_ORIGIN the editor's origin, for the play origin's "open in the app" link +// (unset: the link stays on the play origin's own root) + +/** the origin of an absolute http(s) URL, else '' @param {unknown} url */ +export function originOf(url) { + try { + const u = new URL(String(url ?? '')); + return u.protocol === 'https:' || u.protocol === 'http:' ? u.origin : ''; + } catch { + return ''; + } +} + +const env = /** @type {Record} */ ((import.meta && import.meta.env) || {}); +/** the configured play origin ('' = none: play happens where the app is) */ +export const PLAY_ORIGIN = originOf(env.VITE_PLAY_ORIGIN); +/** the configured editor origin ('' = unknown) */ +export const APP_ORIGIN = originOf(env.VITE_APP_ORIGIN); + +const here = typeof location !== 'undefined' ? location.origin : ''; + +/** true when THIS page is served from the separate play origin */ +export const onPlayOrigin = !!PLAY_ORIGIN && here === PLAY_ORIGIN && PLAY_ORIGIN !== APP_ORIGIN; + +/** An absolute URL on the play origin (this origin when none is configured). @param {string} path e.g. `/?s=&play=1` */ +export function playUrl(path) { + return (PLAY_ORIGIN || here) + (path.startsWith('/') ? path : '/' + path); +} + +/** A URL in the editor: absolute to VITE_APP_ORIGIN from the play origin, else a plain path. @param {string} path */ +export function appUrl(path) { + const p = path.startsWith('/') ? path : '/' + path; + return onPlayOrigin && APP_ORIGIN ? APP_ORIGIN + p : p; +} diff --git a/src/lib/sandbox/behaviours.js b/src/lib/sandbox/behaviours.js new file mode 100644 index 000000000..b23dbc2f1 --- /dev/null +++ b/src/lib/sandbox/behaviours.js @@ -0,0 +1,314 @@ +// 69 T1 — BEHAVIOUR FILES IN THE GUEST. `compileBehaviourInGuest(source)` keeps the contract of +// behaviours/source.js `compileBehaviour` — it resolves to `{def, scope}` with `scope.bind(kit)`, +// `scope.scope.resetGuard` and `scope.dispose()` — so behaviours/app.js and behaviours/core.js (the +// wire, the documents, the timers, the authority rule) are unchanged. What moved is the file: it is +// evaluated as an ES module in its OWN guest context, with the same one-line prologue and loop +// guard (wrapSource), and `def` on the host is the definition's DATA (name, params, state, inputs, +// outputs) with every function replaced by a host stub that calls into the guest. +// +// THE GUEST `this`. A handler runs with a guest object mirroring the host context (core.js +// makeContext): `id`, `params`, `state`, `object` are copies refreshed on every entry; `kit` and +// every function member call the host's. STATE is the one thing written on both sides, so it is +// synced at the boundaries: host -> guest on every entry, guest -> host on every exit (a throw +// included — core.js compares it either way), and around every host call that may re-enter the +// guest (a kit call can fire a kit event a listener of this file hears; a method call is a +// dispatch) — the host answers with its state only when the re-entry changed it, so a handler +// holding a nested state object across an ordinary kit call keeps writing into the live one. + +import { loadSandbox, Guest, fromGuestJson } from './engine.js'; +import { wrapSource } from '../behaviours/source.js'; +import { behaviour as markBehaviour } from '../behaviours/define.js'; + +/** context members that only READ (no state sync around them) */ +const PURE = new Set(['rand', 'randInt', 'pick', 'now', 'log', 'isAuthority', 'me', 'find', 'findAll', 'cancel']); +/** context members that are data, refreshed on entry */ +const DATA = new Set(['id', 'params', 'state', 'object', 'kit']); + +let seq = 0; + +/** replace the contents of `target` with `next`, keeping the object (core.js holds it) @param {any} target @param {any} next */ +function adoptInto(target, next) { + if (!target || typeof target !== 'object') return; + for (const k of Object.keys(target)) delete target[k]; + if (next && typeof next === 'object' && !Array.isArray(next)) Object.assign(target, next); +} + +/** a host-side copy that is plain JSON (what the guest may be handed) @param {any} v */ +const json = (v) => (v === undefined ? null : JSON.parse(JSON.stringify(v))); + +/** + * The host half of one behaviour's guest. `hostCtx` is the core.js context of the instance, learnt + * from the `this` of the first stub call (core.js always calls handlers with it). + */ +class BehaviourGuest extends Guest { + /** @param {any} rt */ + constructor(rt) { + /** @type {any} */ + let self = null; + super( + rt, + { + // a context member (`this.(…)`), with or without the state sync + ctx: (/** @type {string} */ name, /** @type {any[]} */ args, /** @type {any} */ guestState) => self.ctxCall(name, args, guestState), + // `this.kit..(…)` and the free `kit` + kit: (/** @type {string} */ piece, /** @type {string} */ name, /** @type {any[]} */ args, /** @type {any} */ guestState) => self.kitCall(piece, name, args, guestState), + kitShape: () => self.kitShape() + }, + [BEHAVIOUR_PRELUDE] + ); + self = this; + /** @type {any} */ this.hostCtx = null; + /** @type {any} */ this.boundKit = null; + } + + /** @param {any} guestState */ + takeGuestState(guestState) { + const state = this.hostCtx?.state; + if (state && guestState && typeof guestState === 'object') adoptInto(state, guestState); + return state ? JSON.stringify(state) : ''; + } + + /** run `fn` around the state sync; the answer carries the host state when a re-entry moved it + * @param {any} guestState @param {() => any} fn */ + synced(guestState, fn) { + const sent = this.takeGuestState(guestState); + const v = fn(); + const state = this.hostCtx?.state; + const now = state ? JSON.stringify(state) : ''; + return now !== sent ? { v, s: json(state) } : { v }; + } + + /** @param {string} name @param {any[]} args @param {any} guestState */ + ctxCall(name, args, guestState) { + const ctx = this.hostCtx; + if (!ctx) throw new Error('this.' + name + ' is available inside handlers and methods'); + if (DATA.has(name)) throw new TypeError('this.' + name + ' is not a function'); + const fn = ctx[name]; + if (typeof fn !== 'function') throw new TypeError('this.' + name + ' is not a function'); + const list = Array.isArray(args) ? args : []; + if (PURE.has(name)) return { v: fn.apply(ctx, list) }; + return this.synced(guestState, () => fn.apply(ctx, list)); + } + + /** @param {string} piece @param {string} name @param {any[]} args @param {any} guestState */ + kitCall(piece, name, args, guestState) { + const face = this.boundKit ?? this.hostCtx?.kit; + if (!face) throw new Error('kit is available inside handlers and methods (not at the top of the file)'); + const api = face[piece]; + const fn = api?.[name]; + if (typeof fn !== 'function') throw new TypeError('kit.' + piece + '.' + name + ' is not a function'); + return this.synced(guestState, () => fn(...(Array.isArray(args) ? args : []))); + } + + /** the kit face's shape: `{piece: {name: 'fn' | value}}` */ + kitShape() { + const face = this.boundKit ?? this.hostCtx?.kit; + if (!face) return null; + /** @type {Record} */ + const shape = {}; + for (const [piece, api] of Object.entries(face)) { + if (!api || typeof api !== 'object') { + shape[piece] = { __value: json(api) }; + continue; + } + /** @type {Record} */ + const members = {}; + for (const [name, v] of Object.entries(api)) members[name] = typeof v === 'function' ? 'fn' : { __value: json(v) }; + shape[piece] = members; + } + return shape; + } + + /** what the guest `this` is refreshed from on every entry */ + entryData() { + const ctx = this.hostCtx; + if (!ctx) return null; + /** @type {Record} */ + const members = {}; + for (const key of Object.keys(ctx)) if (!DATA.has(key)) members[key] = PURE.has(key) ? 'pure' : 'sync'; + return { id: ctx.id, params: json(ctx.params), state: json(ctx.state), object: json(ctx.object), members }; + } + + /** the host stub of guest function `id`: an entry with the state sync @param {number} id */ + stubOf(id) { + const guest = this; + const stub = function (/** @type {any[]} */ ...args) { + // @ts-ignore core.js calls every handler, method and closure with the instance context + if (this && typeof this === 'object' && 'state' in this) guest.hostCtx = this; + const out = guest.callEntry('__enter', id, guest.entryData(), args); + if (out && typeof out === 'object' && 's' in out) guest.takeGuestState(out.s); + if (out?.e) { + const e = new Error(String(out.e.message ?? out.e)); + e.name = String(out.e.name ?? 'Error'); + if (out.e.stack) e.stack = e.name + ': ' + e.message + '\n' + String(out.e.stack); + throw e; + } + return out?.r; + }; + this.released.register(stub, id); + return stub; + } +} + +/** + * Evaluate a behaviour file in a fresh guest context. Same contract as `compileBehaviour`. + * @param {string} source + * @returns {Promise<{def: any, scope: {token: string, scope: any, bind: (kit: any) => void, dispose: () => void}}>} + */ +export async function compileBehaviourInGuest(source) { + const rt = await loadSandbox(); + const guest = new BehaviourGuest(rt); + const token = 'g' + ++seq; + const scope = { + token, + scope: { + resetGuard: () => { + if (!guest.disposed) guest.callEntry('__resetGuard'); + } + }, + /** @param {any} face */ + bind: (face) => { + guest.boundKit = face; + guest.callEntry('__bind', guest.kitShape()); + }, + dispose: () => guest.dispose() + }; + try { + const wrapped = wrapSource(source, token); + if ('error' in wrapped) throw new Error(wrapped.error); + const described = guest.evalModule(wrapped.code, 'behaviour.js', '__describe'); + const def = described?.def; + if (!def || typeof def !== 'object') throw new Error('the file must `export default behaviour({...})`'); + // define.js's own check + mark, on the host copy (the guest's `behaviour()` only marks) + return { def: markBehaviour(def), scope }; + } catch (error) { + guest.dispose(); + throw error; + } +} + +/** parse helper re-exported for tests @param {string} text */ +export const parseGuestJson = (text) => fromGuestJson(text); + +// ---- guest side ------------------------------------------------------------------------------ +// The scope the prologue destructures lives at globalThis.__tpBehaviourScope[token] (source.js's +// SCOPE_GLOBAL) — the guest's own global, one file per context, so any token resolves to it. +const BEHAVIOUR_PRELUDE = String.raw` +(function () { + 'use strict'; + const B = __bridge; + // source.js's helpers, verbatim in behaviour + const toVec = (p) => { + if (!p) return null; + if (Array.isArray(p)) return p.length >= 3 ? [Number(p[0]), Number(p[1]), Number(p[2])] : null; + if (typeof p === 'object') { + if (Number.isFinite(p.x) && Number.isFinite(p.y) && Number.isFinite(p.z)) return [p.x, p.y, p.z]; + return toVec(p.pos ?? p.point ?? p.position ?? null); + } + return null; + }; + const dist = (a, b) => { + const p = toVec(a), q = toVec(b); + if (!p || !q) return NaN; + return Math.hypot(p[0] - q[0], p[1] - q[1], p[2] - q[2]); + }; + const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v)); + const lerp = (a, b, t) => a + (b - a) * t; + const behaviour = (def) => def; + + // ---- the instance context (this) ---- + const ctx = {}; + const state = {}; + let params = {}; + let object = null; + let id = ''; + const adopt = (target, next) => { + for (const k of Object.keys(target)) delete target[k]; + if (next && typeof next === 'object' && !Array.isArray(next)) Object.assign(target, next); + }; + const stateCopy = () => JSON.parse(JSON.stringify(state)); + /** a host call that may re-enter: our state goes out, a changed one comes back */ + const synced = (op, ...args) => { + const out = B.call(op, ...args, stateCopy()); + if (out && Object.prototype.hasOwnProperty.call(out, 's')) adopt(state, out.s); + return out ? out.v : undefined; + }; + Object.defineProperties(ctx, { + id: { get: () => id, enumerable: true }, + params: { get: () => params, enumerable: true }, + state: { get: () => state, enumerable: true }, + object: { get: () => object, enumerable: true }, + kit: { get: () => kit, enumerable: true } + }); + const members = new Set(); + const syncMembers = (list) => { + for (const [name, how] of Object.entries(list || {})) { + if (members.has(name)) continue; + members.add(name); + ctx[name] = how === 'pure' ? (...a) => B.call('ctx', name, a, null).v : (...a) => synced('ctx', name, a); + } + }; + + // ---- the kit: bound when the behaviour starts ---- + let bound = null; + const pieces = new Map(); + const pieceOf = (piece) => { + let p = pieces.get(piece); + if (p) return p; + const shape = bound[piece]; + if (!shape) return undefined; + if (Object.prototype.hasOwnProperty.call(shape, '__value')) return shape.__value; + p = {}; + for (const [name, how] of Object.entries(shape)) { + p[name] = how === 'fn' ? (...a) => synced('kit', piece, name, a) : how.__value; + } + pieces.set(piece, Object.freeze(p)); + return pieces.get(piece); + }; + const kit = new Proxy({}, { + get(_t, key) { + if (!bound) throw new Error('kit is available inside handlers and methods (not at the top of the file)'); + return typeof key === 'string' ? pieceOf(key) : undefined; + }, + has: (_t, key) => !!bound && typeof key === 'string' && key in bound, + ownKeys: () => (bound ? Object.keys(bound) : []), + getOwnPropertyDescriptor: (_t, key) => (bound && typeof key === 'string' && key in bound ? { enumerable: true, configurable: true, value: pieceOf(key) } : undefined) + }); + + const scope = { behaviour, kit, dist, clamp, lerp, resetGuard: null }; + const tokens = new Proxy({}, { get: () => scope }); + Object.defineProperty(globalThis, '__tpBehaviourScope', { value: tokens }); + + const def = (name, fn) => Object.defineProperty(globalThis, name, { value: (json) => B.encode(fn(...B.decode(json))) }); + Object.defineProperty(globalThis, '__describe', { + value: (ns) => B.encode({ def: ns && ns.default }) + }); + def('__bind', (shape) => { + bound = shape || {}; + pieces.clear(); + return true; + }); + def('__resetGuard', () => { + if (typeof scope.resetGuard === 'function') scope.resetGuard(); + return true; + }); + // the host calls a function of this file (a handler, a method, a closure, a listener) + def('__enter', (fnId, data, args) => { + if (data) { + id = data.id; + params = data.params || {}; + object = data.object; + adopt(state, data.state); + syncMembers(data.members); + } + const fn = B.fn(fnId); + try { + const r = fn.apply(ctx, Array.isArray(args) ? args : []); + return { r, s: stateCopy() }; + } catch (error) { + const e = error && typeof error === 'object' ? { name: error.name, message: String(error.message), stack: error.stack } : { message: String(error) }; + return { e, s: stateCopy() }; + } + }); +})(); +`; diff --git a/src/lib/sandbox/engine.js b/src/lib/sandbox/engine.js new file mode 100644 index 000000000..ce4aa33a5 --- /dev/null +++ b/src/lib/sandbox/engine.js @@ -0,0 +1,372 @@ +// 69 T1 — THE GUEST ENGINE: scene-carried code (Script nodes, built-in code, behaviour files, HUD +// custom elements) runs in a separate JavaScript engine — QuickJS compiled to WebAssembly — instead +// of the app's own realm. A LEAF (no svelte, no three, no app state). +// +// WHY AN ENGINE AND NOT AN IFRAME. The guest has no window, document, storage, cookies, network +// (fetch / XHR / WebSocket / WebRTC), import() or a path to the app realm: it holds exactly the +// functions a host hands it, and every value crosses as a COPY (JSON). And it is SYNCHRONOUS, which +// is what the script API and the kit have always been — a value script feeds the same tick, a rule +// reads `kit.score.total()` right after `kit.score.add(1)` — so the games keep their semantics. +// An iframe or a Worker would have made all of that a message one frame late. +// +// THE BRIDGE. Each guest context gets ONE host function, `__host(op, json) -> json`, and the host +// calls into the guest through globals the prelude defines. Everything is JSON both ways: +// · `{v: value}` wraps a value (so `undefined` survives as an absent key); +// · a FUNCTION crossing either way becomes a handle — `{__fn: id}` for a guest function the host +// may call back (a kit listener, an `after` closure), `{__hfn: id}` for a host function the guest +// may call (the `off` a listener returns). A host stub of a guest function releases its id when +// the stub is garbage-collected; +// · every JSON the guest hands back is parsed with `__proto__` keys DROPPED — guest output is +// untrusted data and is applied to host objects only through allowlists. +// +// LIVENESS. A deadline per outermost entry (the runtime's interrupt handler) stops a guest that runs +// away however it does it — a loop the loop guard cannot see, a deep recursion, a huge string — and +// the runtime has a memory and a stack limit. The thrown error says what happened. + +/** the most a single outermost guest entry may run (ms) before it is interrupted */ +export const ENTRY_DEADLINE_MS = 250; +/** the guest heap limit (bytes) — shared by every context */ +const MEMORY_LIMIT = 256 * 1024 * 1024; +const STACK_LIMIT = 1024 * 1024; + +/** @type {any} */ +let engine = null; +/** @type {Promise | null} */ +let loading = null; +/** @type {string | null} */ +let loadError = null; + +/** the runtime every context lives in (one heap, one interrupt handler) @type {any} */ +let runtime = null; +let depth = 0; +let deadline = 0; + +const isNode = typeof window === 'undefined'; + +/** Load the engine (once). Resolves to the runtime; rejects only on a real load failure. */ +export function loadSandbox() { + if (runtime) return Promise.resolve(runtime); + loading ??= (async () => { + const core = await import('quickjs-emscripten-core'); + const variantModule = await import('@jitl/quickjs-wasmfile-release-sync'); + let variant = variantModule.default; + if (!isNode) { + // the .wasm as a Vite asset: same origin, cached with the app, no config for a self-hoster + const wasm = await import('@jitl/quickjs-wasmfile-release-sync/wasm?url'); + variant = core.newVariant(variant, { wasmLocation: wasm.default }); + } + engine = await core.newQuickJSWASMModuleFromVariant(variant); + const rt = engine.newRuntime(); + rt.setMemoryLimit(MEMORY_LIMIT); + rt.setMaxStackSize(STACK_LIMIT); + rt.setInterruptHandler(() => deadline > 0 && performance.now() > deadline); + runtime = rt; + return rt; + })().catch((error) => { + loadError = String(error?.message ?? error); + loading = null; + throw error; + }); + return loading; +} + +/** the runtime when loaded, else null (and the load is started) */ +export function sandboxNow() { + if (runtime) return runtime; + if (!loading) loadSandbox().catch(() => {}); + return null; +} + +/** true once the engine is loaded — never starts a load (preload checks, probes) */ +export function sandboxReady() { + return !!runtime; +} + +/** why the engine could not load (null = it did, or has not tried) */ +export function sandboxLoadError() { + return loadError; +} + +/** parse guest JSON: `__proto__` keys dropped, handles revived by `revive` @param {string} json @param {(v: any) => any} [revive] */ +export function fromGuestJson(json, revive) { + // the reviver is only needed when a key it acts on can be in the text (a \u escape can spell one) + if (json.indexOf('\\u') < 0 && json.indexOf('__proto__') < 0 && (!revive || json.indexOf('"__fn"') < 0)) return JSON.parse(json); + return JSON.parse(json, (key, value) => { + if (key === '__proto__') return undefined; + return revive ? revive(value) : value; + }); +} + +/** @param {any} error the dumped guest error */ +function hostError(error) { + if (error && typeof error === 'object') { + const interrupted = error.name === 'InternalError' && /interrupted/.test(String(error.message)); + const e = new Error(interrupted ? 'took longer than ' + ENTRY_DEADLINE_MS + ' ms (stopped)' : String(error.message ?? error)); + e.name = interrupted ? 'Error' : String(error.name ?? 'Error'); + if (interrupted) /** @type {any} */ (e).interrupted = true; + if (error.stack) e.stack = e.name + ': ' + e.message + '\n' + String(error.stack); + return e; + } + return new Error(String(error)); +} + +/** + * One guest context: its own globals, the shared heap. + * `ops` = what the guest may ask the host: `name -> (...args) => value`. + */ +export class Guest { + /** @param {any} rt @param {Record any>} ops @param {string[]} preludes guest source evaluated in order */ + constructor(rt, ops, preludes) { + this.vm = rt.newContext(); + this.ops = ops; + this.disposed = false; + /** host functions the guest holds handles to @type {Map} */ + this.hostFns = new Map(); + this.nextHostFn = 1; + /** cached handles of the prelude's entry points @type {Map} */ + this.entries = new Map(); + this.released = new FinalizationRegistry((/** @type {number} */ id) => { + if (!this.disposed) this.callEntry('__release', id); + }); + const vm = this.vm; + const host = vm.newFunction('__host', (/** @type {any} */ opH, /** @type {any} */ jsonH) => { + const op = vm.getString(opH); + let out; + try { + const args = this.decode(vm.getString(jsonH)); + const fn = op === 'hfn' ? this.hostFns.get(args?.[0]) : this.ops[op]; + if (typeof fn !== 'function') throw new Error('no such host call: ' + op); + const value = op === 'hfn' ? fn(...(Array.isArray(args[1]) ? args[1] : [])) : fn(...(Array.isArray(args) ? args : [])); + out = this.encode(value); + } catch (error) { + const e = /** @type {any} */ (error); + out = JSON.stringify({ e: String(e?.message ?? e), n: String(e?.name ?? 'Error') }); + } + return vm.newString(out); + }); + vm.setProp(vm.global, '__host', host); + host.dispose(); + for (const source of [BRIDGE_PRELUDE, ...preludes]) this.evalScript(source, 'prelude.js'); + } + + /** host value -> the JSON the guest decodes (functions become `{__hfn}` handles) @param {any} value */ + encode(value) { + return JSON.stringify({ v: value }, (_k, v) => { + if (typeof v === 'function') { + const id = this.nextHostFn++; + this.hostFns.set(id, v); + return { __hfn: id }; + } + return v; + }); + } + + /** guest JSON -> host value (`{__fn}` handles become host stubs) @param {string} json */ + decode(json) { + const parsed = fromGuestJson(json, (v) => { + if (v && typeof v === 'object' && typeof v.__fn === 'number' && Object.keys(v).length === 1) return this.stubOf(v.__fn); + return v; + }); + return parsed?.v; + } + + /** a host function that calls guest function `id` @param {number} id */ + stubOf(id) { + const stub = (/** @type {any[]} */ ...args) => this.callEntry('__invoke', id, args); + this.released.register(stub, id); + return stub; + } + + /** run guest code once (a prelude, a module body) @param {string} source @param {string} file */ + evalScript(source, file) { + this.enter(); + try { + const result = this.vm.evalCode(source, file); + if (result.error) { + const dumped = this.vm.dump(result.error); + result.error.dispose(); + throw hostError(dumped); + } + result.value.dispose(); + } finally { + this.exit(); + } + } + + /** + * Evaluate an ES module in the guest and call `entry(namespace)` (a prelude global) on it; returns + * what that entry returns (JSON-decoded). + * @param {string} source @param {string} file @param {string} entry + */ + evalModule(source, file, entry) { + const vm = this.vm; + this.enter(); + try { + const result = vm.evalCode(source, file, { type: 'module' }); + if (result.error) { + const dumped = vm.dump(result.error); + result.error.dispose(); + throw hostError(dumped); + } + const ns = result.value; + try { + const fn = this.entryHandle(entry); + const out = vm.callFunction(fn, vm.undefined, ns); + return this.unwrap(out); + } finally { + ns.dispose(); + } + } finally { + this.exit(); + } + } + + /** @param {string} name */ + entryHandle(name) { + let h = this.entries.get(name); + if (!h) { + h = this.vm.getProp(this.vm.global, name); + this.entries.set(name, h); + } + return h; + } + + /** @param {any} result a callFunction result @returns {string | null} the returned string */ + unwrapText(result) { + if (result.error) { + const dumped = this.vm.dump(result.error); + result.error.dispose(); + throw hostError(dumped); + } + const type = this.vm.typeof(result.value); + const text = type === 'string' ? this.vm.getString(result.value) : null; + result.value.dispose(); + return text; + } + + /** @param {any} result a callFunction result */ + unwrap(result) { + const text = this.unwrapText(result); + return text === null ? undefined : this.decode(text); + } + + /** + * The hot path: call a prelude function with plain STRING arguments and get its string back, no + * JSON envelope either way (a per-frame script call spends most of its time in the guest's JSON + * otherwise). The caller owns both formats. + * @param {string} name @param {string[]} args @returns {string | null} + */ + callRaw(name, args) { + if (this.disposed) throw new Error('the sandbox was disposed'); + const vm = this.vm; + const fn = this.entryHandle(name); + const handles = args.map((a) => vm.newString(a)); + this.enter(); + try { + return this.unwrapText(vm.callFunction(fn, vm.undefined, handles)); + } finally { + for (const h of handles) h.dispose(); + this.exit(); + } + } + + /** + * Call a prelude entry point with JSON-encoded args; returns its decoded result. Throws the guest's + * error as a host Error (name + message kept, so a badge reads as it always did). + * @param {string} name @param {...any} args + */ + callEntry(name, ...args) { + if (this.disposed) throw new Error('the sandbox was disposed'); + const vm = this.vm; + const fn = this.entryHandle(name); + const arg = vm.newString(this.encode(args)); + this.enter(); + try { + return this.unwrap(vm.callFunction(fn, vm.undefined, arg)); + } finally { + arg.dispose(); + this.exit(); + } + } + + enter() { + if (depth++ === 0) deadline = performance.now() + ENTRY_DEADLINE_MS; + } + + exit() { + if (--depth === 0) deadline = 0; + } + + dispose() { + if (this.disposed) return; + this.disposed = true; + for (const h of this.entries.values()) h.dispose(); + this.entries.clear(); + this.hostFns.clear(); + try { + this.vm.dispose(); + } catch (error) { + // a context still holding guest objects the host never saw (a leak inside the guest) refuses + // to free cleanly; the heap goes with the runtime + console.log('sandbox: context dispose', String(error)); + } + } +} + +/** a new guest context, or null while the engine is still loading @param {Record any>} ops @param {string[]} preludes */ +export function newGuest(ops, preludes) { + const rt = sandboxNow(); + return rt ? new Guest(rt, ops, preludes) : null; +} + +// ---- the guest side of the bridge (evaluated first in every context) --------------------------- +// `__bridge.call(op, ...args)` asks the host; `__invoke(json)` is how the host calls a guest +// function it holds a handle to. `__host` is taken off the global so user code reaches the host +// only through the API objects the preludes build (which is the same surface, by design). +const BRIDGE_PRELUDE = String.raw` +(function () { + 'use strict'; + const H = globalThis.__host; + delete globalThis.__host; + const fns = new Map(); + let nextFn = 1; + const register = (f) => { + const id = nextFn++; + fns.set(id, f); + return id; + }; + const encode = (value) => + JSON.stringify({ v: value }, (k, v) => (typeof v === 'function' ? { __fn: register(v) } : v)); + const hostFn = (id) => (...a) => call('hfn', id, a); + const revive = (k, v) => + v && typeof v === 'object' && typeof v.__hfn === 'number' && Object.keys(v).length === 1 ? hostFn(v.__hfn) : v; + // a reviver costs as much as the parse in this engine: only when a handle is in the text + const parse = (json) => (json.indexOf('"__hfn"') < 0 ? JSON.parse(json) : JSON.parse(json, revive)); + const decode = (json) => parse(json).v; + const ERRORS = { TypeError, RangeError, ReferenceError, SyntaxError }; + function call(op, ...args) { + const out = parse(H(op, encode(args))); + if (out && typeof out.e === 'string') throw new (ERRORS[out.n] || Error)(out.e); + return out.v; + } + Object.defineProperty(globalThis, '__bridge', { value: Object.freeze({ call, encode, decode, register, fn: (id) => { + const f = fns.get(id); + if (typeof f !== 'function') throw new Error('this function is gone'); + return f; + } }) }); + Object.defineProperty(globalThis, '__invoke', { + value: (json) => { + const [id, args] = decode(json); + const f = fns.get(id); + if (typeof f !== 'function') throw new Error('this function is gone'); + return encode(f(...(Array.isArray(args) ? args : []))); + } + }); + Object.defineProperty(globalThis, '__release', { + value: (json) => { + fns.delete(decode(json)[0]); + return encode(true); + } + }); +})(); +`; diff --git a/src/lib/sandbox/guestMath.js b/src/lib/sandbox/guestMath.js new file mode 100644 index 000000000..e391d7f3e --- /dev/null +++ b/src/lib/sandbox/guestMath.js @@ -0,0 +1,281 @@ +// 69 T1 — guest source: the three.js maths a script reaches through `object` (position, rotation, +// scale, quaternion, material colours), as small classes INSIDE the guest. Plain text, evaluated in +// a guest context; nothing here runs in the app realm. The method set is the one scripts use +// (set/copy/add/sub/multiplyScalar/lerp/clone/length/normalize/distanceTo/toArray, Color.set/ +// setHex/setRGB/setHSL/getHex, Quaternion.setFromEuler/setFromAxisAngle/multiply, Euler +// .setFromQuaternion) — ported from three r186 so the numbers match it. + +export const GUEST_MATH = String.raw` +(function () { + 'use strict'; + const fin = (v, d) => (typeof v === 'number' && Number.isFinite(v) ? v : d); + class Vector3 { + constructor(x = 0, y = 0, z = 0) { this.x = x; this.y = y; this.z = z; } + set(x, y, z) { this.x = x; this.y = y; this.z = z; return this; } + setScalar(s) { this.x = this.y = this.z = s; return this; } + setX(x) { this.x = x; return this; } + setY(y) { this.y = y; return this; } + setZ(z) { this.z = z; return this; } + copy(v) { this.x = v.x; this.y = v.y; this.z = v.z; return this; } + clone() { return new Vector3(this.x, this.y, this.z); } + add(v) { this.x += v.x; this.y += v.y; this.z += v.z; return this; } + addScalar(s) { this.x += s; this.y += s; this.z += s; return this; } + addScaledVector(v, s) { this.x += v.x * s; this.y += v.y * s; this.z += v.z * s; return this; } + addVectors(a, b) { this.x = a.x + b.x; this.y = a.y + b.y; this.z = a.z + b.z; return this; } + sub(v) { this.x -= v.x; this.y -= v.y; this.z -= v.z; return this; } + subVectors(a, b) { this.x = a.x - b.x; this.y = a.y - b.y; this.z = a.z - b.z; return this; } + multiply(v) { this.x *= v.x; this.y *= v.y; this.z *= v.z; return this; } + multiplyScalar(s) { this.x *= s; this.y *= s; this.z *= s; return this; } + divideScalar(s) { return this.multiplyScalar(1 / s); } + negate() { this.x = -this.x; this.y = -this.y; this.z = -this.z; return this; } + dot(v) { return this.x * v.x + this.y * v.y + this.z * v.z; } + cross(v) { return this.crossVectors(this, v); } + crossVectors(a, b) { + const ax = a.x, ay = a.y, az = a.z, bx = b.x, by = b.y, bz = b.z; + this.x = ay * bz - az * by; this.y = az * bx - ax * bz; this.z = ax * by - ay * bx; + return this; + } + lengthSq() { return this.x * this.x + this.y * this.y + this.z * this.z; } + length() { return Math.sqrt(this.lengthSq()); } + normalize() { return this.divideScalar(this.length() || 1); } + setLength(l) { return this.normalize().multiplyScalar(l); } + distanceTo(v) { return Math.sqrt(this.distanceToSquared(v)); } + distanceToSquared(v) { const dx = this.x - v.x, dy = this.y - v.y, dz = this.z - v.z; return dx * dx + dy * dy + dz * dz; } + lerp(v, t) { this.x += (v.x - this.x) * t; this.y += (v.y - this.y) * t; this.z += (v.z - this.z) * t; return this; } + lerpVectors(a, b, t) { this.x = a.x + (b.x - a.x) * t; this.y = a.y + (b.y - a.y) * t; this.z = a.z + (b.z - a.z) * t; return this; } + applyQuaternion(q) { + const vx = this.x, vy = this.y, vz = this.z, qx = q.x, qy = q.y, qz = q.z, qw = q.w; + const tx = 2 * (qy * vz - qz * vy), ty = 2 * (qz * vx - qx * vz), tz = 2 * (qx * vy - qy * vx); + this.x = vx + qw * tx + qy * tz - qz * ty; + this.y = vy + qw * ty + qz * tx - qx * tz; + this.z = vz + qw * tz + qx * ty - qy * tx; + return this; + } + applyEuler(e) { return this.applyQuaternion(new Quaternion().setFromEuler(e)); } + equals(v) { return v.x === this.x && v.y === this.y && v.z === this.z; } + fromArray(a, o = 0) { this.x = a[o]; this.y = a[o + 1]; this.z = a[o + 2]; return this; } + toArray(a = [], o = 0) { a[o] = this.x; a[o + 1] = this.y; a[o + 2] = this.z; return a; } + } + Vector3.prototype.isVector3 = true; + + class Quaternion { + constructor(x = 0, y = 0, z = 0, w = 1) { this.x = x; this.y = y; this.z = z; this.w = w; } + set(x, y, z, w) { this.x = x; this.y = y; this.z = z; this.w = w; return this; } + copy(q) { this.x = q.x; this.y = q.y; this.z = q.z; this.w = q.w; return this; } + clone() { return new Quaternion(this.x, this.y, this.z, this.w); } + identity() { return this.set(0, 0, 0, 1); } + setFromEuler(e) { + const c1 = Math.cos(e.x / 2), c2 = Math.cos(e.y / 2), c3 = Math.cos(e.z / 2); + const s1 = Math.sin(e.x / 2), s2 = Math.sin(e.y / 2), s3 = Math.sin(e.z / 2); + switch (e.order) { + case 'YXZ': return this.set(s1 * c2 * c3 + c1 * s2 * s3, c1 * s2 * c3 - s1 * c2 * s3, c1 * c2 * s3 - s1 * s2 * c3, c1 * c2 * c3 + s1 * s2 * s3); + case 'ZXY': return this.set(s1 * c2 * c3 - c1 * s2 * s3, c1 * s2 * c3 + s1 * c2 * s3, c1 * c2 * s3 + s1 * s2 * c3, c1 * c2 * c3 - s1 * s2 * s3); + case 'ZYX': return this.set(s1 * c2 * c3 - c1 * s2 * s3, c1 * s2 * c3 + s1 * c2 * s3, c1 * c2 * s3 - s1 * s2 * c3, c1 * c2 * c3 + s1 * s2 * s3); + case 'YZX': return this.set(s1 * c2 * c3 + c1 * s2 * s3, c1 * s2 * c3 + s1 * c2 * s3, c1 * c2 * s3 - s1 * s2 * c3, c1 * c2 * c3 - s1 * s2 * s3); + case 'XZY': return this.set(s1 * c2 * c3 - c1 * s2 * s3, c1 * s2 * c3 - s1 * c2 * s3, c1 * c2 * s3 + s1 * s2 * c3, c1 * c2 * c3 + s1 * s2 * s3); + default: return this.set(s1 * c2 * c3 + c1 * s2 * s3, c1 * s2 * c3 - s1 * c2 * s3, c1 * c2 * s3 + s1 * s2 * c3, c1 * c2 * c3 - s1 * s2 * s3); + } + } + setFromAxisAngle(axis, angle) { + const h = angle / 2, s = Math.sin(h); + return this.set(axis.x * s, axis.y * s, axis.z * s, Math.cos(h)); + } + setFromRotationMatrix(m) { + const [m11, m12, m13, m21, m22, m23, m31, m32, m33] = m; + const trace = m11 + m22 + m33; + if (trace > 0) { + const s = 0.5 / Math.sqrt(trace + 1.0); + return this.set((m32 - m23) * s, (m13 - m31) * s, (m21 - m12) * s, 0.25 / s); + } else if (m11 > m22 && m11 > m33) { + const s = 2.0 * Math.sqrt(1.0 + m11 - m22 - m33); + return this.set(0.25 * s, (m12 + m21) / s, (m13 + m31) / s, (m32 - m23) / s); + } else if (m22 > m33) { + const s = 2.0 * Math.sqrt(1.0 + m22 - m11 - m33); + return this.set((m12 + m21) / s, 0.25 * s, (m23 + m32) / s, (m13 - m31) / s); + } + const s = 2.0 * Math.sqrt(1.0 + m33 - m11 - m22); + return this.set((m13 + m31) / s, (m23 + m32) / s, 0.25 * s, (m21 - m12) / s); + } + multiply(q) { return this.multiplyQuaternions(this, q); } + premultiply(q) { return this.multiplyQuaternions(q, this); } + multiplyQuaternions(a, b) { + const qax = a.x, qay = a.y, qaz = a.z, qaw = a.w, qbx = b.x, qby = b.y, qbz = b.z, qbw = b.w; + return this.set( + qax * qbw + qaw * qbx + qay * qbz - qaz * qby, + qay * qbw + qaw * qby + qaz * qbx - qax * qbz, + qaz * qbw + qaw * qbz + qax * qby - qay * qbx, + qaw * qbw - qax * qbx - qay * qby - qaz * qbz + ); + } + length() { return Math.sqrt(this.x * this.x + this.y * this.y + this.z * this.z + this.w * this.w); } + normalize() { const l = this.length(); return l === 0 ? this.identity() : this.set(this.x / l, this.y / l, this.z / l, this.w / l); } + invert() { return this.set(-this.x, -this.y, -this.z, this.w); } + slerp(qb, t) { + if (t === 0) return this; + if (t === 1) return this.copy(qb); + const x = this.x, y = this.y, z = this.z, w = this.w; + let cos = w * qb.w + x * qb.x + y * qb.y + z * qb.z; + if (cos < 0) { this.set(-qb.x, -qb.y, -qb.z, -qb.w); cos = -cos; } else this.copy(qb); + if (cos >= 1.0) return this.set(x, y, z, w); + const sq = 1.0 - cos * cos; + if (sq <= Number.EPSILON) { + const s = 1 - t; + return this.set(s * x + t * this.x, s * y + t * this.y, s * z + t * this.z, s * w + t * this.w).normalize(); + } + const sin = Math.sqrt(sq), half = Math.atan2(sin, cos); + const ra = Math.sin((1 - t) * half) / sin, rb = Math.sin(t * half) / sin; + return this.set(x * ra + this.x * rb, y * ra + this.y * rb, z * ra + this.z * rb, w * ra + this.w * rb); + } + toArray(a = [], o = 0) { a[o] = this.x; a[o + 1] = this.y; a[o + 2] = this.z; a[o + 3] = this.w; return a; } + } + Quaternion.prototype.isQuaternion = true; + + const clamp1 = (v) => Math.max(-1, Math.min(1, v)); + /** the 3x3 rotation of a quaternion, row-major */ + const matOf = (q) => { + const x = q.x, y = q.y, z = q.z, w = q.w, x2 = x + x, y2 = y + y, z2 = z + z; + const xx = x * x2, xy = x * y2, xz = x * z2, yy = y * y2, yz = y * z2, zz = z * z2, wx = w * x2, wy = w * y2, wz = w * z2; + return [1 - (yy + zz), xy - wz, xz + wy, xy + wz, 1 - (xx + zz), yz - wx, xz - wy, yz + wx, 1 - (xx + yy)]; + }; + class Euler { + constructor(x = 0, y = 0, z = 0, order = 'XYZ') { this.x = x; this.y = y; this.z = z; this.order = order; } + set(x, y, z, order) { this.x = x; this.y = y; this.z = z; if (order) this.order = order; return this; } + copy(e) { this.x = e.x; this.y = e.y; this.z = e.z; this.order = e.order; return this; } + clone() { return new Euler(this.x, this.y, this.z, this.order); } + setFromQuaternion(q, order) { + const [m11, m12, m13, m21, m22, m23, m31, m32, m33] = matOf(q); + const o = order || this.order; + switch (o) { + case 'YXZ': + this.x = Math.asin(-clamp1(m23)); + if (Math.abs(m23) < 0.9999999) { this.y = Math.atan2(m13, m33); this.z = Math.atan2(m21, m22); } else { this.y = Math.atan2(-m31, m11); this.z = 0; } + break; + case 'ZXY': + this.x = Math.asin(clamp1(m32)); + if (Math.abs(m32) < 0.9999999) { this.y = Math.atan2(-m31, m33); this.z = Math.atan2(-m12, m22); } else { this.y = 0; this.z = Math.atan2(m21, m11); } + break; + case 'ZYX': + this.y = Math.asin(-clamp1(m31)); + if (Math.abs(m31) < 0.9999999) { this.x = Math.atan2(m32, m33); this.z = Math.atan2(m21, m11); } else { this.x = 0; this.z = Math.atan2(-m12, m22); } + break; + case 'YZX': + this.z = Math.asin(clamp1(m21)); + if (Math.abs(m21) < 0.9999999) { this.x = Math.atan2(-m23, m22); this.y = Math.atan2(-m31, m11); } else { this.x = 0; this.y = Math.atan2(m13, m33); } + break; + case 'XZY': + this.z = Math.asin(-clamp1(m12)); + if (Math.abs(m12) < 0.9999999) { this.x = Math.atan2(m32, m22); this.y = Math.atan2(m13, m11); } else { this.x = Math.atan2(-m23, m33); this.y = 0; } + break; + default: + this.y = Math.asin(clamp1(m13)); + if (Math.abs(m13) < 0.9999999) { this.x = Math.atan2(-m23, m33); this.z = Math.atan2(-m12, m11); } else { this.x = Math.atan2(m32, m22); this.z = 0; } + } + this.order = o; + return this; + } + toArray(a = [], o = 0) { a[o] = this.x; a[o + 1] = this.y; a[o + 2] = this.z; a[o + 3] = this.order; return a; } + } + Euler.prototype.isEuler = true; + + const NAMED = { white: 0xffffff, black: 0x000000, red: 0xff0000, green: 0x008000, lime: 0x00ff00, blue: 0x0000ff, yellow: 0xffff00, cyan: 0x00ffff, magenta: 0xff00ff, orange: 0xffa500, purple: 0x800080, gray: 0x808080, grey: 0x808080, pink: 0xffc0cb, gold: 0xffd700 }; + const hue2rgb = (p, q, t) => { + if (t < 0) t += 1; + if (t > 1) t -= 1; + if (t < 1 / 6) return p + (q - p) * 6 * t; + if (t < 1 / 2) return q; + if (t < 2 / 3) return p + (q - p) * 6 * (2 / 3 - t); + return p; + }; + // colours are kept in three's working space (linear) and converted at the hex boundary, as three does + const toLinear = (c) => (c < 0.04045 ? c * 0.0773993808 : Math.pow(c * 0.9478672986 + 0.0521327014, 2.4)); + const toSRGB = (c) => (c < 0.0031308 ? c * 12.92 : 1.055 * Math.pow(c, 0.41666) - 0.055); + class Color { + constructor(r, g, b) { this.r = 1; this.g = 1; this.b = 1; if (g === undefined && b === undefined) { if (r !== undefined) this.set(r); } else this.setRGB(r, g, b); } + set(v, g, b) { + if (g !== undefined) return this.setRGB(v, g, b); + if (v && typeof v === 'object') return this.copy(v); + if (typeof v === 'number') return this.setHex(v); + if (typeof v === 'string') return this.setStyle(v); + return this; + } + setStyle(s) { + const str = String(s).trim().toLowerCase(); + let m = /^#([0-9a-f]{6})$/.exec(str); + if (m) return this.setHex(parseInt(m[1], 16)); + m = /^#([0-9a-f])([0-9a-f])([0-9a-f])$/.exec(str); + if (m) return this.setHex(parseInt(m[1] + m[1] + m[2] + m[2] + m[3] + m[3], 16)); + m = /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/.exec(str); + if (m) return this.setHex((Math.min(255, +m[1]) << 16) | (Math.min(255, +m[2]) << 8) | Math.min(255, +m[3])); + m = /^hsla?\(\s*([\d.]+)\s*,\s*([\d.]+)%\s*,\s*([\d.]+)%/.exec(str); + if (m) return this.setHSL(((+m[1] % 360) + 360) % 360 / 360, +m[2] / 100, +m[3] / 100); + if (str in NAMED) return this.setHex(NAMED[str]); + return this; + } + setHex(hex) { + hex = Math.floor(hex); + this.r = toLinear(((hex >> 16) & 255) / 255); + this.g = toLinear(((hex >> 8) & 255) / 255); + this.b = toLinear((hex & 255) / 255); + return this; + } + setRGB(r, g, b) { this.r = r; this.g = g; this.b = b; return this; } + setScalar(s) { this.r = this.g = this.b = s; return this; } + setHSL(h, s, l) { + h = ((h % 1) + 1) % 1; s = Math.max(0, Math.min(1, s)); l = Math.max(0, Math.min(1, l)); + let r, g, b; + if (s === 0) r = g = b = l; + else { + const p = l <= 0.5 ? l * (1 + s) : l + s - l * s, q = 2 * l - p; + r = hue2rgb(q, p, h + 1 / 3); g = hue2rgb(q, p, h); b = hue2rgb(q, p, h - 1 / 3); + } + return this.setRGB(toLinear(r), toLinear(g), toLinear(b)); + } + getHSL(target = {}) { + const r = toSRGB(this.r), g = toSRGB(this.g), b = toSRGB(this.b); + const max = Math.max(r, g, b), min = Math.min(r, g, b); + let h = 0, s = 0; + const l = (min + max) / 2; + if (min !== max) { + const d = max - min; + s = l <= 0.5 ? d / (max + min) : d / (2 - max - min); + if (max === r) h = (g - b) / d + (g < b ? 6 : 0); + else if (max === g) h = (b - r) / d + 2; + else h = (r - g) / d + 4; + h /= 6; + } + target.h = h; target.s = s; target.l = l; + return target; + } + offsetHSL(h, s, l) { const c = this.getHSL(); return this.setHSL(c.h + h, c.s + s, c.l + l); } + getHex() { + const c = (v) => Math.round(Math.max(0, Math.min(1, toSRGB(v))) * 255); + return (c(this.r) << 16) | (c(this.g) << 8) | c(this.b); + } + getHexString() { return ('000000' + this.getHex().toString(16)).slice(-6); } + getStyle() { return '#' + this.getHexString(); } + copy(c) { this.r = fin(c.r, this.r); this.g = fin(c.g, this.g); this.b = fin(c.b, this.b); return this; } + clone() { return new Color().copy(this); } + multiplyScalar(s) { this.r *= s; this.g *= s; this.b *= s; return this; } + add(c) { this.r += c.r; this.g += c.g; this.b += c.b; return this; } + lerp(c, t) { this.r += (c.r - this.r) * t; this.g += (c.g - this.g) * t; this.b += (c.b - this.b) * t; return this; } + lerpColors(a, b, t) { this.r = a.r + (b.r - a.r) * t; this.g = a.g + (b.g - a.g) * t; this.b = a.b + (b.b - a.b) * t; return this; } + equals(c) { return c.r === this.r && c.g === this.g && c.b === this.b; } + toArray(a = [], o = 0) { a[o] = this.r; a[o + 1] = this.g; a[o + 2] = this.b; return a; } + } + Color.prototype.isColor = true; + + const MathUtils = { + clamp: (v, lo, hi) => Math.max(lo, Math.min(hi, v)), + lerp: (a, b, t) => (1 - t) * a + t * b, + degToRad: (d) => d * (Math.PI / 180), + radToDeg: (r) => r * (180 / Math.PI), + smoothstep: (x, a, b) => (x <= a ? 0 : x >= b ? 1 : ((x = (x - a) / (b - a)), x * x * (3 - 2 * x))), + euclideanModulo: (n, m) => ((n % m) + m) % m, + DEG2RAD: Math.PI / 180, + RAD2DEG: 180 / Math.PI + }; + const THREE = Object.freeze({ Vector3, Quaternion, Euler, Color, MathUtils }); + Object.defineProperty(globalThis, 'THREE', { value: THREE }); + Object.defineProperty(globalThis, '__guestMath', { value: { Vector3, Quaternion, Euler, Color, matOf, fin } }); +})(); +`; diff --git a/src/lib/sandbox/hud.js b/src/lib/sandbox/hud.js new file mode 100644 index 000000000..dfc70ab5e --- /dev/null +++ b/src/lib/sandbox/hud.js @@ -0,0 +1,267 @@ +// 69 T1 — THE HUD "Custom (code)" ELEMENT IN THE GUEST. Its render function `(el, runtime, +// container) -> void` runs inside the sandbox guest against a small DOM PROXY (document.createElement +// / createElementNS / createTextNode / createDocumentFragment, textContent, innerHTML, style, +// className / classList, attributes, dataset, append / insertBefore / remove / replaceChildren, and +// a whose 2D context RECORDS its calls). After the call the proxy tree is serialised and +// rebuilt as real DOM through the allowlist in sanitize.js, so whatever the code writes it cannot +// add a script, a handler, a link or anything that loads a URL. Not covered (documented): event +// listeners (accepted and ignored), timers, images by URL, reading layout. + +import { newGuest, loadSandbox } from './engine.js'; +import { buildSanitized, sanitizeCss } from './sanitize.js'; + +/** @type {import('./engine.js').Guest | null} */ +let guest = null; +/** @type {CanvasRenderingContext2D | null} */ +let measureCtx = null; + +const ops = { + /** ctx.measureText, answered by a real (detached) canvas @param {string} font @param {string} text */ + measure: (font, text) => { + if (typeof document === 'undefined') return String(text ?? '').length * 7; + measureCtx ??= document.createElement('canvas').getContext('2d'); + if (!measureCtx) return 0; + measureCtx.font = String(font ?? '10px sans-serif').slice(0, 200); + return measureCtx.measureText(String(text ?? '').slice(0, 2000)).width; + } +}; + +function hudGuest() { + if (guest && !guest.disposed) return guest; + guest = newGuest(ops, [HUD_PRELUDE]); + return guest; +} + +/** resolves once custom elements can render */ +export function hudSandboxReady() { + return loadSandbox().then(() => true); +} + +/** + * Run a custom element's code. `null` while the engine is loading (the caller waits on + * hudSandboxReady and re-runs), else `{nodes, style}` (sanitise-ready) or `{error}`. + * @param {string} code @param {any} element @param {any} runtime + */ +export function renderCustomInGuest(code, element, runtime) { + const g = hudGuest(); + if (!g) return null; + try { + const out = g.callEntry('__render', String(code ?? ''), element ?? null, runtime ?? null); + if (out?.error) return { error: String(out.error) }; + return { nodes: Array.isArray(out?.c) ? out.c : [], style: typeof out?.s === 'string' ? out.s : '' }; + } catch (error) { + return { error: String(/** @type {any} */ (error)?.message ?? error) }; + } +} + +/** + * Render into the slot: sanitised children, sanitised container style. + * @param {HTMLElement} slot @param {{nodes: any[], style: string}} result + */ +export function paintCustom(slot, result) { + slot.replaceChildren(...buildSanitized(slot.ownerDocument, result.nodes)); + const css = sanitizeCss(result.style); + if (css) slot.setAttribute('style', css); + else slot.removeAttribute('style'); +} + +// ---- guest side ------------------------------------------------------------------------------ +const HUD_PRELUDE = String.raw` +(function () { + 'use strict'; + const B = __bridge; + const kebab = (p) => p.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase()); + const camel = (p) => p.replace(/-([a-z])/g, (_m, c) => c.toUpperCase()); + function makeStyle() { + const map = new Map(); + const api = { + setProperty: (k, v) => { if (v === null || v === undefined || v === '') map.delete(String(k)); else map.set(String(k), String(v)); }, + removeProperty: (k) => { const v = map.get(String(k)) || ''; map.delete(String(k)); return v; }, + getPropertyValue: (k) => map.get(String(k)) || '' + }; + return new Proxy(api, { + get(t, prop) { + if (prop === 'cssText') return [...map].map(([k, v]) => k + ': ' + v).join('; '); + if (typeof prop !== 'string') return undefined; + if (prop in t) return t[prop]; + return map.get(kebab(prop)) || ''; + }, + set(t, prop, value) { + if (prop === 'cssText') { + map.clear(); + for (const d of String(value).split(';')) { + const i = d.indexOf(':'); + if (i > 0) map.set(d.slice(0, i).trim().toLowerCase(), d.slice(i + 1).trim()); + } + return true; + } + if (typeof prop === 'string') t.setProperty(prop.startsWith('--') ? prop : kebab(prop), value); + return true; + } + }); + } + const METHODS = ['fillRect', 'strokeRect', 'clearRect', 'beginPath', 'closePath', 'moveTo', 'lineTo', 'arc', 'arcTo', 'ellipse', 'rect', + 'roundRect', 'quadraticCurveTo', 'bezierCurveTo', 'fill', 'stroke', 'fillText', 'strokeText', 'save', 'restore', + 'translate', 'rotate', 'scale', 'setTransform', 'resetTransform', 'setLineDash', 'clip']; + function context2d(el) { + const props = { fillStyle: '#000000', strokeStyle: '#000000', lineWidth: 1, font: '10px sans-serif', globalAlpha: 1, textAlign: 'start', textBaseline: 'alphabetic' }; + const ctx = { canvas: el, measureText: (t) => ({ width: B.call('measure', props.font, String(t)) }), getLineDash: () => [] }; + for (const m of METHODS) ctx[m] = (...a) => { el.__cmd.push([m, a]); }; + return new Proxy(ctx, { + get: (t, p) => (p in t ? t[p] : props[p]), + set: (t, p, v) => { props[p] = v; el.__cmd.push(['=', p, v]); return true; } + }); + } + + class Node { + constructor(type) { this.nodeType = type; this.parentNode = null; this.childNodes = []; } + get parentElement() { return this.parentNode; } + get firstChild() { return this.childNodes[0] || null; } + get lastChild() { return this.childNodes[this.childNodes.length - 1] || null; } + get nextSibling() { const p = this.parentNode; return p ? p.childNodes[p.childNodes.indexOf(this) + 1] || null : null; } + get previousSibling() { const p = this.parentNode; return p ? p.childNodes[p.childNodes.indexOf(this) - 1] || null : null; } + remove() { if (this.parentNode) this.parentNode.removeChild(this); } + addEventListener() {} + removeEventListener() {} + } + class Text extends Node { + constructor(t) { super(3); this.data = String(t); } + get textContent() { return this.data; } + set textContent(v) { this.data = String(v); } + get nodeValue() { return this.data; } + set nodeValue(v) { this.data = String(v); } + } + class Raw extends Node { + constructor(h) { super(1001); this.html = h; } + get textContent() { return this.html.replace(/<[^>]*>/g, ''); } + } + const toNode = (n) => (n instanceof Node ? n : new Text(n)); + class Element extends Node { + constructor(tag, svg) { + super(tag === '#fragment' ? 11 : 1); + this.localName = String(tag).toLowerCase(); + this.tagName = svg ? this.localName : this.localName.toUpperCase(); + this.nodeName = this.tagName; + this.attrs = new Map(); + this.style = makeStyle(); + this.__cmd = []; + const self = this; + this.classList = { + add: (...c) => { const s = new Set(self.className.split(/\s+/).filter(Boolean)); c.forEach((x) => s.add(String(x))); self.className = [...s].join(' '); }, + remove: (...c) => { self.className = self.className.split(/\s+/).filter((x) => x && !c.includes(x)).join(' '); }, + toggle: (c, force) => { const has = self.classList.contains(c); const want = force === undefined ? !has : !!force; if (want) self.classList.add(c); else self.classList.remove(c); return want; }, + contains: (c) => self.className.split(/\s+/).includes(String(c)) + }; + this.dataset = new Proxy({}, { + get: (_t, k) => (typeof k === 'string' ? self.getAttribute('data-' + kebab(k)) ?? undefined : undefined), + set: (_t, k, v) => { self.setAttribute('data-' + kebab(String(k)), v); return true; } + }); + } + get children() { return this.childNodes.filter((n) => n.nodeType === 1); } + get childElementCount() { return this.children.length; } + get firstElementChild() { return this.children[0] || null; } + get lastElementChild() { const c = this.children; return c[c.length - 1] || null; } + get textContent() { return this.childNodes.map((n) => n.textContent).join(''); } + set textContent(v) { this.childNodes.forEach((n) => (n.parentNode = null)); this.childNodes = v === '' || v === null || v === undefined ? [] : [new Text(v)]; this.childNodes.forEach((n) => (n.parentNode = this)); } + get innerText() { return this.textContent; } + set innerText(v) { this.textContent = v; } + get innerHTML() { return this.childNodes.map((n) => (n.nodeType === 3 ? n.data.replace(/&/g, '&').replace(/ (n.parentNode = null)); const r = new Raw(String(v)); r.parentNode = this; this.childNodes = [r]; } + get outerHTML() { const a = [...this.attrs].map(([k, v]) => ' ' + k + '="' + String(v).replace(/"/g, '"') + '"').join(''); return '<' + this.localName + a + '>' + this.innerHTML + ''; } + get className() { return this.attrs.get('class') || ''; } + set className(v) { this.attrs.set('class', String(v)); } + get id() { return this.attrs.get('id') || ''; } + set id(v) { this.attrs.set('id', String(v)); } + get title() { return this.attrs.get('title') || ''; } + set title(v) { this.attrs.set('title', String(v)); } + get width() { return Number(this.attrs.get('width') ?? 300); } + set width(v) { this.attrs.set('width', String(v)); if (this.localName === 'canvas') this.__cmd = []; } + get height() { return Number(this.attrs.get('height') ?? 150); } + set height(v) { this.attrs.set('height', String(v)); if (this.localName === 'canvas') this.__cmd = []; } + get src() { return this.attrs.get('src') || ''; } + set src(v) { this.attrs.set('src', String(v)); } + get value() { return this.attrs.get('value') || ''; } + set value(v) { this.attrs.set('value', String(v)); } + setAttribute(k, v) { const key = String(k).toLowerCase(); if (key === 'style') this.style.cssText = String(v); else this.attrs.set(key, String(v)); } + getAttribute(k) { const key = String(k).toLowerCase(); return key === 'style' ? this.style.cssText : this.attrs.has(key) ? this.attrs.get(key) : null; } + hasAttribute(k) { return this.getAttribute(k) !== null; } + removeAttribute(k) { const key = String(k).toLowerCase(); if (key === 'style') this.style.cssText = ''; else this.attrs.delete(key); } + appendChild(n) { + if (n.nodeType === 11) { [...n.childNodes].forEach((c) => this.appendChild(c)); return n; } + if (n.parentNode) n.parentNode.removeChild(n); + n.parentNode = this; + this.childNodes.push(n); + return n; + } + append(...ns) { ns.forEach((n) => this.appendChild(toNode(n))); } + prepend(...ns) { const first = this.firstChild; ns.forEach((n) => this.insertBefore(toNode(n), first)); } + insertBefore(n, ref) { + if (!ref) return this.appendChild(n); + if (n.nodeType === 11) { [...n.childNodes].forEach((c) => this.insertBefore(c, ref)); return n; } + if (n.parentNode) n.parentNode.removeChild(n); + const i = this.childNodes.indexOf(ref); + n.parentNode = this; + this.childNodes.splice(i < 0 ? this.childNodes.length : i, 0, n); + return n; + } + removeChild(n) { const i = this.childNodes.indexOf(n); if (i >= 0) this.childNodes.splice(i, 1); n.parentNode = null; return n; } + replaceChild(n, old) { this.insertBefore(n, old); return this.removeChild(old); } + replaceChildren(...ns) { this.childNodes.forEach((n) => (n.parentNode = null)); this.childNodes = []; this.append(...ns); } + cloneNode(deep) { + const c = new Element(this.localName, this.tagName === this.localName); + this.attrs.forEach((v, k) => c.attrs.set(k, v)); + c.style.cssText = this.style.cssText; + if (deep) this.childNodes.forEach((n) => c.appendChild(n.cloneNode ? n.cloneNode(true) : n.nodeType === 3 ? new Text(n.data) : new Raw(n.html))); + return c; + } + querySelector() { return null; } + querySelectorAll() { return []; } + getContext(kind) { return this.localName === 'canvas' && kind === '2d' ? (this.__ctx ||= context2d(this)) : null; } + getBoundingClientRect() { return { x: 0, y: 0, top: 0, left: 0, width: 0, height: 0, right: 0, bottom: 0 }; } + } + Text.prototype.cloneNode = function () { return new Text(this.data); }; + const document = Object.freeze({ + createElement: (tag) => new Element(String(tag), false), + createElementNS: (_ns, tag) => new Element(String(tag), true), + createTextNode: (t) => new Text(t), + createDocumentFragment: () => new Element('#fragment', false) + }); + Object.defineProperty(globalThis, 'document', { value: document }); + + const ser = (n) => { + if (n.nodeType === 3) return { x: n.data }; + if (n.nodeType === 1001) return { h: n.html }; + const a = {}; + n.attrs.forEach((v, k) => (a[k] = v)); + const css = n.style.cssText; + if (css) a.style = css; + const out = { t: n.localName, a, c: n.childNodes.map(ser) }; + if (n.localName === 'canvas') out.cmd = n.__cmd; + return out; + }; + + const compiled = new Map(); + Object.defineProperty(globalThis, '__render', { + value: (json) => { + const [code, el, runtime] = B.decode(json); + let fn = compiled.get(code); + if (!fn) { + if (compiled.size > 100) compiled.clear(); + try { + fn = new Function('el', 'runtime', 'container', code); + } catch (error) { + return B.encode({ error: String(error && error.message || error) }); + } + compiled.set(code, fn); + } + const container = new Element('div', false); + try { + fn(el, runtime, container); + } catch (error) { + return B.encode({ error: String(error && error.message || error) }); + } + return B.encode({ c: container.childNodes.map(ser), s: container.style.cssText }); + } + }); +})(); +`; diff --git a/src/lib/sandbox/preload.js b/src/lib/sandbox/preload.js new file mode 100644 index 000000000..1423ff379 --- /dev/null +++ b/src/lib/sandbox/preload.js @@ -0,0 +1,52 @@ +// 69 — START THE GUEST ENGINE WITH THE SCENE THAT NEEDS IT. The engine (engine.js) loads lazily on the +// first script call, which put its whole download + compile AFTER the scene had finished building: +// the first script ran ~2.5 s after a game opened. A scene that carries code starts the load the moment +// it starts to arrive, so the engine loads while the objects build. A scene with no code loads nothing +// (no idle preload for every scene). A LEAF beside engine.js: it only reads the payload. + +import { sandboxNow, sandboxReady } from './engine.js'; +import { builtinCodeActive } from '../builtinCode.js'; + +const CODE_NODES = new Set(['script', 'behaviour', 'customnode']); + +/** @param {any} nodes */ +function nodesCarryCode(nodes) { + return ( + Array.isArray(nodes) && + nodes.some((node) => CODE_NODES.has(node?.type) || builtinCodeActive(node)) + ); +} + +/** a HUD "Custom (code)" element anywhere in a HUD documents map (screens → elements, bounded walk) + * @param {any} value @param {number} depth */ +function hudCarriesCode(value, depth = 0) { + if (!value || typeof value !== 'object' || depth > 6) return false; + if (value.kind === 'custom' && typeof value.code === 'string' && value.code.trim()) return true; + for (const child of Array.isArray(value) ? value : Object.values(value)) + if (hudCarriesCode(child, depth + 1)) return true; + return false; +} + +/** Does a scene payload (a session / .tpscene, or a peer's nodes / huds snapshot) carry code the guest runs? + * @param {any} payload a session payload, or a `nodes` / `huds` message */ +export function payloadCarriesCode(payload) { + if (!payload || typeof payload !== 'object') return false; + if (nodesCarryCode(payload.nodes)) return true; + if (payload.graphs && typeof payload.graphs === 'object') + for (const graph of Object.values(payload.graphs)) + if (nodesCarryCode(graph?.nodes)) return true; + return hudCarriesCode(payload.hud) || hudCarriesCode(payload.huds); +} + +/** Start loading the engine now if this payload will need it. Never throws, never waits. + * @param {any} payload @returns {boolean} whether a load was started (or the engine is already there) */ +export function preloadSandboxFor(payload) { + if (sandboxReady()) return true; + try { + if (!payloadCarriesCode(payload)) return false; + sandboxNow(); + return true; + } catch { + return false; + } +} diff --git a/src/lib/sandbox/sanitize.js b/src/lib/sandbox/sanitize.js new file mode 100644 index 000000000..da29f3b07 --- /dev/null +++ b/src/lib/sandbox/sanitize.js @@ -0,0 +1,227 @@ +// 69 T1 — THE ALLOWLIST that guest-made markup passes through before it becomes app DOM. Used by +// the HUD custom element (a serialised tree from the guest's DOM proxy, sandbox/hud.js) and by +// any HTML string the app did not write itself (`sanitizeHtmlInto`, parsed by an inert DOMParser +// document — nothing in it loads or runs). +// +// What gets through: layout and text elements, inline SVG shapes, a drawn from recorded +// calls, and `data:image` pictures. What never does: scripts, styles sheets, frames, forms and +// inputs, links, event handlers, element ids (they would collide with the app's), any URL except +// a `data:image` src, and any CSS that can fetch (`url(`, `image-set(`, `@import`, escapes) or +// escape its box (`position: fixed` becomes `absolute`; the host clips the slot). No network +// request can come out of a sanitised tree. + +const HTML_TAGS = new Set([ + 'div', 'span', 'p', 'b', 'i', 'u', 's', 'em', 'strong', 'small', 'big', 'sub', 'sup', 'mark', 'code', 'pre', 'kbd', + 'br', 'hr', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'dl', 'dt', 'dd', 'blockquote', 'figure', 'figcaption', + 'table', 'thead', 'tbody', 'tfoot', 'tr', 'td', 'th', 'caption', 'colgroup', 'col', 'section', 'article', 'header', 'footer', + 'progress', 'meter', 'img', 'canvas', 'label', 'abbr', 'time', 'center' +]); +const SVG_TAGS = new Set([ + 'svg', 'g', 'path', 'circle', 'ellipse', 'line', 'polyline', 'polygon', 'rect', 'text', 'tspan', 'title', 'defs', + 'lineargradient', 'radialgradient', 'stop' +]); +const SVG_NS = 'http://www.w3.org/2000/svg'; +const ATTRS = new Set([ + 'class', 'style', 'title', 'role', 'width', 'height', 'colspan', 'rowspan', 'dir', 'lang', 'value', 'max', 'min', + 'low', 'high', 'optimum', 'alt', 'src', 'datetime', + // svg presentation + geometry + 'viewbox', 'd', 'cx', 'cy', 'r', 'rx', 'ry', 'x', 'y', 'x1', 'y1', 'x2', 'y2', 'dx', 'dy', 'points', 'fill', 'stroke', + 'stroke-width', 'stroke-linecap', 'stroke-linejoin', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-opacity', + 'fill-opacity', 'fill-rule', 'opacity', 'transform', 'text-anchor', 'dominant-baseline', 'font-size', 'font-family', + 'font-weight', 'preserveaspectratio', 'offset', 'stop-color', 'stop-opacity', 'gradientunits', 'gradienttransform', + 'pathlength', 'vector-effect' +]); +/** camelCase SVG attribute names, as setAttribute needs them */ +const SVG_CASE = { viewbox: 'viewBox', preserveaspectratio: 'preserveAspectRatio', gradientunits: 'gradientUnits', gradienttransform: 'gradientTransform', pathlength: 'pathLength' }; +const BAD_VALUE = /url\s*\(|image-set|expression\s*\(|javascript:|vbscript:|@import|\\|<|-moz-binding|behavior\s*:/i; +const DATA_IMAGE = /^data:image\/(png|jpe?g|gif|webp|avif);base64,[a-z0-9+/=\s]+$/i; +const CSS_PROP = /^-?[a-z][a-z-]*$/; +const BLOCKED_CSS = new Set(['behavior', '-moz-binding', 'content']); + +/** the most nodes one tree may build (a runaway render is cut, not the tab) */ +export const MAX_NODES = 4000; +const MAX_TEXT = 20000; +const MAX_CANVAS_CMDS = 50000; +const MAX_CANVAS_SIDE = 4096; + +/** @param {string} css @returns {string} a declaration list that cannot fetch or escape its box */ +export function sanitizeCss(css) { + const out = []; + for (const decl of String(css ?? '').split(';')) { + const at = decl.indexOf(':'); + if (at < 0) continue; + const prop = decl.slice(0, at).trim().toLowerCase(); + let value = decl.slice(at + 1).trim(); + if (!CSS_PROP.test(prop) || BLOCKED_CSS.has(prop) || !value || value.length > 500 || BAD_VALUE.test(value)) continue; + if (prop === 'position' && /fixed|sticky/i.test(value)) value = 'absolute'; + out.push(prop + ': ' + value); + } + return out.join('; '); +} + +/** @param {string} tag @param {string} name @param {string} value */ +function attrAllowed(tag, name, value) { + if (name.startsWith('on')) return false; + if (name === 'src') return tag === 'img' && DATA_IMAGE.test(value); + if (name.startsWith('aria-') || name.startsWith('data-')) return /^[a-z0-9-]+$/.test(name) && !BAD_VALUE.test(value); + if (!ATTRS.has(name)) return false; + return !BAD_VALUE.test(value); +} + +/** @param {Element} el @param {string} name @param {string} value */ +function setAttr(el, name, value) { + const v = String(value).slice(0, MAX_TEXT); + if (name === 'style') { + const css = sanitizeCss(v); + if (css) el.setAttribute('style', css); + return; + } + el.setAttribute(el.namespaceURI === SVG_NS ? (/** @type {any} */ (SVG_CASE))[name] ?? name : name, v); +} + +const CANVAS_METHODS = new Set([ + 'fillRect', 'strokeRect', 'clearRect', 'beginPath', 'closePath', 'moveTo', 'lineTo', 'arc', 'arcTo', 'ellipse', 'rect', + 'roundRect', 'quadraticCurveTo', 'bezierCurveTo', 'fill', 'stroke', 'fillText', 'strokeText', 'save', 'restore', + 'translate', 'rotate', 'scale', 'setTransform', 'resetTransform', 'setLineDash', 'clip' +]); +const CANVAS_PROPS = new Set([ + 'fillStyle', 'strokeStyle', 'lineWidth', 'lineCap', 'lineJoin', 'miterLimit', 'font', 'textAlign', 'textBaseline', + 'globalAlpha', 'globalCompositeOperation', 'shadowBlur', 'shadowColor', 'shadowOffsetX', 'shadowOffsetY', + 'lineDashOffset', 'direction', 'imageSmoothingEnabled' +]); +/** a recorded call's argument: numbers, short strings, booleans, arrays of numbers @param {any} a */ +const argOk = (a) => + (typeof a === 'number' && Number.isFinite(a)) || + typeof a === 'boolean' || + (typeof a === 'string' && a.length <= 2000) || + (Array.isArray(a) && a.length <= 64 && a.every((x) => typeof x === 'number' && Number.isFinite(x))); + +/** replay recorded 2D calls onto a real canvas @param {HTMLCanvasElement} canvas @param {any[]} cmds */ +function replayCanvas(canvas, cmds) { + const ctx = canvas.getContext('2d'); + if (!ctx || !Array.isArray(cmds)) return; + for (const cmd of cmds.slice(0, MAX_CANVAS_CMDS)) { + if (!Array.isArray(cmd)) continue; + try { + if (cmd[0] === '=') { + if (CANVAS_PROPS.has(cmd[1]) && argOk(cmd[2]) && !Array.isArray(cmd[2])) /** @type {any} */ (ctx)[cmd[1]] = cmd[2]; + } else if (CANVAS_METHODS.has(cmd[0]) && Array.isArray(cmd[1]) && cmd[1].length <= 8 && cmd[1].every(argOk)) { + /** @type {any} */ (ctx)[cmd[0]](...cmd[1]); + } + } catch { + // a bad argument to one call is that call's problem + } + } +} + +/** `{links: true}` lets `` through (opened in a new tab, no opener) — for + * attribution notices; never for guest content @typedef {{links?: boolean}} SanitizeOptions */ +const HTTPS_URL = /^https?:\/\/[^\s"'<>\\]+$/i; + +/** + * Build a guest tree (`{t, a, c}` elements, `{x}` text, `{h}` raw HTML, `{t: 'canvas', cmd}`) as real + * DOM through the allowlist. + * @param {Document} doc @param {any[]} nodes @param {boolean} [svg] inside an + * @param {{n: number}} [budget] @param {SanitizeOptions} [opts] + * @returns {Node[]} + */ +export function buildSanitized(doc, nodes, svg = false, budget = { n: 0 }, opts = {}) { + /** @type {Node[]} */ + const out = []; + if (!Array.isArray(nodes)) return out; + for (const node of nodes) { + if (++budget.n > MAX_NODES) break; + if (!node || typeof node !== 'object') continue; + if (typeof node.x === 'string') { + out.push(doc.createTextNode(node.x.slice(0, MAX_TEXT))); + continue; + } + if (typeof node.h === 'string') { + out.push(...sanitizeHtmlNodes(doc, node.h, svg, budget, opts)); + continue; + } + const tag = String(node.t ?? '').toLowerCase(); + const inSvg = svg || tag === 'svg'; + if (tag === 'a' && opts.links && !inSvg) { + const href = String(node.a?.href ?? ''); + const a = doc.createElement('a'); + if (HTTPS_URL.test(href)) { + a.setAttribute('href', href); + a.setAttribute('target', '_blank'); + a.setAttribute('rel', 'noopener noreferrer'); + } + a.append(...buildSanitized(doc, node.c, false, budget, opts)); + out.push(a); + continue; + } + if (!(inSvg ? SVG_TAGS.has(tag) : HTML_TAGS.has(tag))) { + // an unknown element keeps its CONTENT (text is the point of most markup) — never a + // script's or a style sheet's + if (tag !== 'script' && tag !== 'style') out.push(...buildSanitized(doc, node.c, svg, budget, opts)); + continue; + } + const el = inSvg ? doc.createElementNS(SVG_NS, tag) : doc.createElement(tag); + if (node.a && typeof node.a === 'object') { + for (const [rawName, value] of Object.entries(node.a)) { + const name = String(rawName).toLowerCase(); + if (typeof value !== 'string' && typeof value !== 'number') continue; + if (attrAllowed(tag, name, String(value))) setAttr(el, name, String(value)); + } + } + if (tag === 'canvas') { + const w = Math.min(MAX_CANVAS_SIDE, Math.max(0, Math.round(Number(node.a?.width ?? 300)) || 0)); + const h = Math.min(MAX_CANVAS_SIDE, Math.max(0, Math.round(Number(node.a?.height ?? 150)) || 0)); + /** @type {HTMLCanvasElement} */ (el).width = w; + /** @type {HTMLCanvasElement} */ (el).height = h; + replayCanvas(/** @type {HTMLCanvasElement} */ (el), node.cmd); + } else el.append(...buildSanitized(doc, node.c, inSvg, budget, opts)); + out.push(el); + } + return out; +} + +/** @param {Element | DocumentFragment} root @returns {any[]} an inert parsed tree as guest-shaped nodes */ +function treeOf(root) { + const out = []; + for (const child of Array.from(root.childNodes)) { + if (child.nodeType === 3) out.push({ x: child.textContent ?? '' }); + else if (child.nodeType === 1) { + const el = /** @type {Element} */ (child); + /** @type {Record} */ + const a = {}; + for (const attr of Array.from(el.attributes)) a[attr.name] = attr.value; + out.push({ t: el.localName, a, c: treeOf(el.localName === 'template' ? /** @type {any} */ (el).content : el) }); + } + } + return out; +} + +/** + * An HTML string as sanitised nodes of `doc`. The parse happens in an inert document (no script + * runs, no image loads) and the result goes through the same allowlist as a guest tree. + * @param {Document} doc @param {string} html @param {boolean} [svg] @param {{n: number}} [budget] + * @param {SanitizeOptions} [opts] + */ +export function sanitizeHtmlNodes(doc, html, svg = false, budget = { n: 0 }, opts = {}) { + if (typeof DOMParser === 'undefined') return [doc.createTextNode(String(html))]; + const parsed = new DOMParser().parseFromString('' + String(html).slice(0, 200000), 'text/html'); + return buildSanitized(doc, treeOf(parsed.body), svg, budget, opts); +} + +/** replace `target`'s children with a sanitised HTML string @param {Element} target @param {string} html + * @param {SanitizeOptions} [opts] */ +export function sanitizeHtmlInto(target, html, opts = {}) { + target.replaceChildren(...sanitizeHtmlNodes(target.ownerDocument, html, false, { n: 0 }, opts)); +} + +/** a svelte action: `
` — sanitised, https links kept (attribution text) + * @param {HTMLElement} node @param {string} html */ +export function safeHtml(node, html) { + sanitizeHtmlInto(node, html ?? '', { links: true }); + return { + /** @param {string} next */ + update(next) { + sanitizeHtmlInto(node, next ?? '', { links: true }); + } + }; +} diff --git a/src/lib/sandbox/scripts.js b/src/lib/sandbox/scripts.js new file mode 100644 index 000000000..26a4b6a83 --- /dev/null +++ b/src/lib/sandbox/scripts.js @@ -0,0 +1,515 @@ +// 69 T1 — SCRIPT NODES IN THE GUEST. scriptRuntime keeps everything it owned (the lint, the loop +// guard, the per-node time budget, the badge); what changed is WHERE the compiled function lives: +// here it is compiled and called inside the sandbox guest (engine.js), and this module is the host +// half that marshals the same arguments the function always got. +// +// `object` is the one argument that was a LIVE THREE.Object3D, and a live object is the way out of +// any sandbox (its parent chain, its material's image, its constructor). The guest gets a VIEW +// instead — uuid, name, type, position, rotation, scale, quaternion, visible, plus `material` and +// `userData` fetched only when the script touches them — with three-alike maths classes, and the +// methods scripts use (lookAt, rotateX/Y/Z, translateX/Y/Z, getWorldPosition). After the call the +// guest diffs the view against what it was handed and the host applies ONLY the changed fields, +// each through an allowlist with type checks. Calls stay synchronous, so a script still sees the +// pose the animations before it in the chain produced, in the same frame. + +import { newGuest } from './engine.js'; +import { GUEST_MATH } from './guestMath.js'; + +/** userData keys a script may not write (prototype keys; everything else is the scene author's) */ +const BLOCKED_KEYS = new Set(['__proto__', 'constructor', 'prototype']); +/** a userData value larger than this (JSON chars) is not handed to the guest */ +const USERDATA_VALUE_CAP = 20000; +const ORDERS = new Set(['XYZ', 'YXZ', 'ZXY', 'ZYX', 'YZX', 'XZY']); + +/** @type {import('./engine.js').Guest | null} */ +let guest = null; +/** the object a running effect script is looking at @type {any} */ +let current = null; +/** the api of the running script (flowRuntime.scriptApi) @type {any} */ +let currentApi = null; + +const finite = (/** @type {any} */ v) => typeof v === 'number' && Number.isFinite(v); + +/** @param {any} material */ +function materialView(material) { + if (!material || Array.isArray(material)) return null; + /** @type {Record} */ + const view = {}; + if (material.color?.isColor) view.color = [material.color.r, material.color.g, material.color.b]; + if (material.emissive?.isColor) view.emissive = [material.emissive.r, material.emissive.g, material.emissive.b]; + for (const key of ['opacity', 'emissiveIntensity', 'roughness', 'metalness']) + if (finite(material[key])) view[key] = material[key]; + for (const key of ['transparent', 'wireframe', 'visible']) if (typeof material[key] === 'boolean') view[key] = material[key]; + return view; +} + +/** @param {any} userData */ +function userDataView(userData) { + /** @type {Record} */ + const out = {}; + if (!userData || typeof userData !== 'object') return out; + for (const key of Object.keys(userData)) { + if (BLOCKED_KEYS.has(key)) continue; + try { + const json = JSON.stringify(userData[key]); + if (json !== undefined && json.length <= USERDATA_VALUE_CAP) out[key] = JSON.parse(json); + } catch { + // a cycle / a BigInt: not script data + } + } + return out; +} + +const ops = { + ud: () => (current ? userDataView(current.userData) : null), + mat: () => (current ? materialView(current.material) : null), + /** @param {string} name @param {any[]} args */ + api: (name, args) => { + if (!currentApi) throw new Error('api is not available in this script'); + const fn = currentApi[String(name)]; + if (typeof fn !== 'function') throw new TypeError('api.' + name + ' is not a function'); + return fn(...(Array.isArray(args) ? args : [])); + } +}; + +/** the scripts guest (null while the engine loads) */ +function scriptGuest() { + if (guest && !guest.disposed) return guest; + guest = newGuest(ops, [GUEST_MATH, SCRIPT_PRELUDE]); + sent.clear(); + return guest; +} + +/** Is the guest ready (starts the load when it is not)? */ +export function scriptSandboxReady() { + return !!scriptGuest(); +} + +/** + * Compile `body` (already linted + loop-guarded) under `key` for one of scriptRuntime's shapes. + * Returns '' on success, the compile error's text otherwise, or null while the engine loads. + * @param {string} key @param {string} shape @param {string} body + */ +export function compileInGuest(key, shape, body) { + const g = scriptGuest(); + if (!g) return null; + return String(g.callEntry('__compile', key, shape, body) ?? ''); +} + +/** drop every compiled function (scriptRuntime's cache was cleared) */ +export function forgetCompiled() { + sent.clear(); + if (guest && !guest.disposed) guest.callEntry('__forgetAll'); +} + +const ORDER_LIST = ['XYZ', 'YXZ', 'ZXY', 'ZYX', 'YZX', 'XZY']; + +/** the per-call numbers __fx reads (see its comment for the layout) @param {any} object @param {number} time */ +function numbersOf(object, time) { + const p = object.position, r = object.rotation, s = object.scale, q = object.quaternion; + let wp = p; + if (object.getWorldPosition && object.parent) { + const v = p.clone(); + object.getWorldPosition(v); + wp = v; + } + return [p.x, p.y, p.z, r.x, r.y, r.z, Math.max(0, ORDER_LIST.indexOf(r.order)), s.x, s.y, s.z, q.x, q.y, q.z, q.w, object.visible ? 1 : 0, wp.x, wp.y, wp.z, time].join(','); +} + +/** what each slot last sent the guest, so an unchanged base / data / name travels as '' */ +const sent = new Map(); +/** @param {string} slot @param {string} json */ +function changedOrEmpty(slot, json) { + if (sent.get(slot) === json) return ''; + if (sent.size > 8192) sent.clear(); + sent.set(slot, json); + return json; +} + +/** a transform patch `mask,values…` back into applyObjectPatch's shape @param {string} text */ +function transformFromText(text) { + /** @type {Record} */ + const p = {}; + if (!text) return p; + const n = text.split(',').map(Number); + const mask = n[0]; + let i = 1; + if (mask & 1) p.p = [n[i++], n[i++], n[i++]]; + if (mask & 2) { + p.r = [n[i++], n[i++], n[i++], ORDER_LIST[n[i++]]]; + } + if (mask & 4) p.q = [n[i++], n[i++], n[i++], n[i++]]; + if (mask & 8) p.s = [n[i++], n[i++], n[i++]]; + if (mask & 16) p.v = n[i++] === 1; + return p; +} + +/** @param {any} target @param {any} arr @param {string[]} keys */ +function setComponents(target, arr, keys) { + if (!Array.isArray(arr)) return false; + let changed = false; + keys.forEach((k, i) => { + if (finite(arr[i]) && target[k] !== arr[i]) { + target[k] = arr[i]; + changed = true; + } + }); + return changed; +} + +/** apply a guest patch to the real object — allowlisted fields, type-checked values @param {any} object @param {any} p */ +export function applyObjectPatch(object, p) { + if (!p || typeof p !== 'object') return; + if (Array.isArray(p.p)) setComponents(object.position, p.p, ['x', 'y', 'z']); + if (Array.isArray(p.r)) { + const r = p.r; + const order = ORDERS.has(r[3]) ? r[3] : object.rotation.order; + object.rotation.set(finite(r[0]) ? r[0] : object.rotation.x, finite(r[1]) ? r[1] : object.rotation.y, finite(r[2]) ? r[2] : object.rotation.z, order); + } else if (Array.isArray(p.q) && p.q.every(finite)) object.quaternion.set(p.q[0], p.q[1], p.q[2], p.q[3]).normalize(); + if (Array.isArray(p.s)) setComponents(object.scale, p.s, ['x', 'y', 'z']); + if (typeof p.v === 'boolean') object.visible = p.v; + if (p.ud && typeof p.ud === 'object' && object.userData) { + for (const key of Object.keys(p.ud)) if (!BLOCKED_KEYS.has(key)) object.userData[key] = p.ud[key]; + } + if (Array.isArray(p.udDel) && object.userData) { + for (const key of p.udDel) if (typeof key === 'string' && !BLOCKED_KEYS.has(key)) delete object.userData[key]; + } + const m = p.m; + const material = object.material; + if (m && typeof m === 'object' && material && !Array.isArray(material)) { + if (material.color?.isColor) setComponents(material.color, m.color, ['r', 'g', 'b']); + if (material.emissive?.isColor) setComponents(material.emissive, m.emissive, ['r', 'g', 'b']); + if (finite(m.opacity)) material.opacity = Math.max(0, Math.min(1, m.opacity)); + for (const key of ['emissiveIntensity', 'roughness', 'metalness']) if (finite(m[key]) && key in material) material[key] = m[key]; + for (const key of ['wireframe', 'visible']) if (typeof m[key] === 'boolean') material[key] = m[key]; + // a render-program change: three must recompile the material + if (typeof m.transparent === 'boolean' && material.transparent !== m.transparent) { + material.transparent = m.transparent; + material.needsUpdate = true; + } + } +} + +/** + * Run a compiled EFFECT (v1 or v2) on `object`. + * @param {string} key @param {boolean} v2 @param {any} object @param {any} base @param {any} data + * @param {number} time @param {any} [inputs] @param {any} [api] + */ +export function runEffectInGuest(key, v2, object, base, data, time, inputs, api) { + const g = scriptGuest(); + if (!g) return undefined; + current = object && object.position ? object : null; + currentApi = api ?? null; + try { + const uuid = current ? String(current.uuid ?? '') : ''; + const slots = ['m\u0000' + uuid, 'b\u0000' + key + '\u0000' + uuid, 'd\u0000' + key]; + const meta = current ? changedOrEmpty(slots[0], JSON.stringify([String(current.name ?? ''), String(current.type ?? '')])) : ''; + let out; + try { + // one packed argument (U+0001 never appears raw in JSON or in a number list): every string + // handed to the engine is an allocation, and this runs per object per frame + out = g.callRaw('__fx', [ + key, + uuid, + [ + v2 ? '1' : '', + meta, + current ? numbersOf(current, time) : String(time), + changedOrEmpty(slots[1], JSON.stringify(base ?? null)), + changedOrEmpty(slots[2], JSON.stringify(data ?? null)), + v2 && inputs != null ? JSON.stringify(inputs) : '', + api ? '1' : '' + ].join('\u0001') + ]); + } catch (error) { + // the guest may not have taken what we sent: send it whole next time + for (const slot of slots) sent.delete(slot); + throw error; + } + if (!out) return undefined; + if (out.charCodeAt(0) !== 123) { + if (current) applyObjectPatch(current, transformFromText(out)); + return undefined; + } + const full = g.decode(out); + if (current) applyObjectPatch(current, { ...transformFromText(full?.t ?? ''), ...(full?.p ?? {}) }); + return full?.r; + } finally { + current = null; + currentApi = null; + } +} + +/** a compiled VALUE script @param {string} key @param {any} inputs @param {number} time @param {any} [api] */ +export function runValueInGuest(key, inputs, time, api) { + const g = scriptGuest(); + if (!g) return undefined; + currentApi = api ?? null; + try { + return g.callEntry('__runValue', key, inputs ?? null, time, !!api); + } finally { + currentApi = null; + } +} + +/** a compiled built-in "Player" body @param {string} key @param {any} settings @param {any} input @param {number} time */ +export function runPlayerInGuest(key, settings, input, time) { + const g = scriptGuest(); + if (!g) return undefined; + return g.callEntry('__runPlayer', key, settings ?? null, input ?? null, time); +} + +/** tests / debug: the guest itself (null until loaded) */ +export function scriptGuestForTest() { + return scriptGuest(); +} + +// ---- guest side ------------------------------------------------------------------------------ +const SCRIPT_PRELUDE = String.raw` +(function () { + 'use strict'; + const B = __bridge; + const { Vector3, Quaternion, Euler, Color, matOf, fin } = __guestMath; + const SIG = { + v1: ['object', 'base', 'data', 'time', 'params'], + effect: ['object', 'base', 'data', 'time', 'params', 'inputs', 'dist', 'lerp', 'clamp', 'api'], + value: ['inputs', 'time', 'dist', 'lerp', 'clamp', 'api'], + player: ['settings', 'input', 'time', 'dist', 'lerp', 'clamp'] + }; + // scriptIO's SCRIPT_HELPERS, verbatim in behaviour + const finite = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0); + const pointOf = (p) => { + if (Array.isArray(p)) return [finite(p[0]), finite(p[1]), finite(p[2])]; + if (p && Array.isArray(p.position)) return pointOf(p.position); + if (p && typeof p === 'object' && 'x' in p) return [finite(p.x), finite(p.y), finite(p.z)]; + return [0, 0, 0]; + }; + const dist = (a, b) => { + const p = pointOf(a), q = pointOf(b); + return Math.hypot(p[0] - q[0], p[1] - q[1], p[2] - q[2]); + }; + const lerp = (a, b, t) => a + (b - a) * t; + const clamp = (v, lo, hi) => Math.min(Math.max(v, lo), hi); + const api = Object.freeze({ + object: (ref) => B.call('api', 'object', [ref && typeof ref === 'object' ? { uuid: ref.uuid } : ref]), + raycast: (from, dir, max) => B.call('api', 'raycast', max === undefined ? [from, dir] : [from, dir, max]), + keys: () => B.call('api', 'keys', []), + spawn: (command) => B.call('api', 'spawn', [command]) + }); + + const fns = new Map(); + const def = (name, fn) => Object.defineProperty(globalThis, name, { value: (json) => B.encode(fn(...B.decode(json))) }); + def('__compile', (key, shape, body) => { + try { + fns.set(key, new Function(...SIG[shape], body)); + return ''; + } catch (error) { + return String(error); + } + }); + def('__forgetAll', () => { + fns.clear(); + baseCache.clear(); + dataCache.clear(); + views.clear(); + return true; + }); + + const Z = new Vector3(0, 0, 1), X = new Vector3(1, 0, 0), Y = new Vector3(0, 1, 0); + const ORD = ['XYZ', 'YXZ', 'ZXY', 'ZYX', 'YZX', 'XZY']; + const same = (x, y) => x === y || (x !== x && y !== y); + // the methods every view shares (one prototype, so a call builds no closures) + const VIEW = { + isObject3D: true, + rotateOnAxis(axis, angle) { + const q = new Quaternion().setFromEuler(this.rotation).multiply(new Quaternion().setFromAxisAngle(axis, angle)); + this.quaternion.copy(q); + this.rotation.setFromQuaternion(q); + return this; + }, + rotateX(a) { return this.rotateOnAxis(X, a); }, + rotateY(a) { return this.rotateOnAxis(Y, a); }, + rotateZ(a) { return this.rotateOnAxis(Z, a); }, + translateOnAxis(axis, d) { + const v = axis.clone().applyQuaternion(new Quaternion().setFromEuler(this.rotation)); + this.position.addScaledVector(v, d); + return this; + }, + translateX(d) { return this.translateOnAxis(X, d); }, + translateY(d) { return this.translateOnAxis(Y, d); }, + translateZ(d) { return this.translateOnAxis(Z, d); }, + lookAt(x, y, z) { + const t = x && typeof x === 'object' ? new Vector3(x.x, x.y, x.z) : new Vector3(x, y, z); + const zAxis = t.sub(this.position); + if (zAxis.lengthSq() === 0) zAxis.z = 1; + zAxis.normalize(); + let xAxis = new Vector3().crossVectors(this.up, zAxis); + if (xAxis.lengthSq() === 0) { + zAxis.x += Math.abs(this.up.z) === 1 ? 0.0001 : 0; + zAxis.z += Math.abs(this.up.z) === 1 ? 0 : 0.0001; + zAxis.normalize(); + xAxis = new Vector3().crossVectors(this.up, zAxis); + } + xAxis.normalize(); + const yAxis = new Vector3().crossVectors(zAxis, xAxis); + const q = new Quaternion().setFromRotationMatrix([xAxis.x, yAxis.x, zAxis.x, xAxis.y, yAxis.y, zAxis.y, xAxis.z, yAxis.z, zAxis.z]); + this.quaternion.copy(q); + this.rotation.setFromQuaternion(q); + return this; + }, + getWorldPosition(target) { + const v = target || new Vector3(); + const n = this.__n; + return v.set(+n[15] + this.position.x - +n[0], +n[16] + this.position.y - +n[1], +n[17] + this.position.z - +n[2]); + }, + getWorldQuaternion(target) { return (target || new Quaternion()).setFromEuler(this.rotation); }, + updateMatrix() {}, + updateMatrixWorld() {}, + updateWorldMatrix() {}, + get userData() { + if (this.__ud === null) { + this.__ud = B.call('ud') || {}; + this.__udJson = {}; + for (const k of Object.keys(this.__ud)) this.__udJson[k] = JSON.stringify(this.__ud[k]); + } + return this.__ud; + }, + get material() { + if (this.__mat === null) { + const m = B.call('mat'); + if (!m) return (this.__mat = undefined); + this.__matJson = JSON.stringify(m); + const mat = { ...m }; + if (m.color) mat.color = new Color().setRGB(m.color[0], m.color[1], m.color[2]); + if (m.emissive) mat.emissive = new Color().setRGB(m.emissive[0], m.emissive[1], m.emissive[2]); + this.__mat = mat; + } + return this.__mat; + } + }; + const hidden = (o, k, v) => Object.defineProperty(o, k, { value: v, writable: true, enumerable: false }); + const fixed = (o, k, v) => Object.defineProperty(o, k, { value: v, enumerable: true }); + // ONE view per object, kept across frames the way the live object was (a script that keeps a + // reference, or sets its own field on it, sees it next frame); position/rotation/scale/quaternion + // are not replaceable, as on three's Object3D, so updating them in place is safe + const views = new Map(); + function viewFor(uuid) { + let v = views.get(uuid); + if (v) return v; + if (views.size > 4096) views.clear(); + v = Object.create(VIEW); + fixed(v, 'uuid', uuid); + fixed(v, 'position', new Vector3()); + fixed(v, 'rotation', new Euler()); + fixed(v, 'scale', new Vector3(1, 1, 1)); + fixed(v, 'quaternion', new Quaternion()); + fixed(v, 'up', new Vector3(0, 1, 0)); + v.name = ''; + v.type = ''; + v.visible = true; + for (const k of ['__n', '__ud', '__udJson', '__mat', '__matJson']) hidden(v, k, null); + views.set(uuid, v); + return v; + } + /** the changed transform as "mask,values…" (1 position, 2 rotation, 4 quaternion, 8 scale, 16 visible) */ + function transformPatch(v) { + const n = v.__n; + const p = v.position, r = v.rotation, s = v.scale, q = v.quaternion; + let mask = 0; + const out = []; + if (!same(p.x, +n[0]) || !same(p.y, +n[1]) || !same(p.z, +n[2])) { mask |= 1; out.push(p.x, p.y, p.z); } + const order = ORD.indexOf(r.order); + if (!same(r.x, +n[3]) || !same(r.y, +n[4]) || !same(r.z, +n[5]) || order !== +n[6]) { mask |= 2; out.push(r.x, r.y, r.z, order); } + else if (!same(q.x, +n[10]) || !same(q.y, +n[11]) || !same(q.z, +n[12]) || !same(q.w, +n[13])) { mask |= 4; out.push(q.x, q.y, q.z, q.w); } + if (!same(s.x, +n[7]) || !same(s.y, +n[8]) || !same(s.z, +n[9])) { mask |= 8; out.push(s.x, s.y, s.z); } + if (!!v.visible !== (n[14] === '1')) { mask |= 16; out.push(v.visible ? 1 : 0); } + return mask ? mask + ',' + out.join(',') : ''; + } + /** userData / material changes, only when the script touched them */ + function extraPatch(v, p) { + const ud = v.__ud; + if (ud) { + const changed = {}; + let any = false; + for (const k of Object.keys(ud)) { + const j = JSON.stringify(ud[k]); + if (j !== v.__udJson[k]) { changed[k] = ud[k]; any = true; } + } + if (any) p.ud = changed; + const gone = Object.keys(v.__udJson).filter((k) => !(k in ud)); + if (gone.length) p.udDel = gone; + } + const mat = v.__mat; + if (mat) { + const now = {}; + for (const k of Object.keys(mat)) { + const x = mat[k]; + now[k] = x && x.isColor ? [x.r, x.g, x.b] : x; + } + if (JSON.stringify(now) !== v.__matJson) p.m = now; + } + return p; + } + + const fnOf = (key) => { + const fn = fns.get(key); + if (!fn) throw new Error('Script could not be compiled'); + return fn; + }; + // base / data arrive as JSON only when they changed since the last call for this slot ('' = same) + const baseCache = new Map(), dataCache = new Map(); + const cached = (map, slot, json) => { + if (json === '') return map.get(slot); + const v = JSON.parse(json); + if (map.size > 4096) map.clear(); + map.set(slot, v); + return v; + }; + /** + * one effect call: key, uuid ('' = no object), then v2 meta nums base data inputs api joined by + * U+0001. nums = position(3) rotation(3) order rotation-order-index(1) scale(3) + * quaternion(4) visible(1) world-position(3) time(1); meta = JSON [name, type] or '' (same). + * Returns '' (nothing changed), a transform patch, or '{…}' JSON {v: {t, p, r}} when there is a + * result or a userData / material change. + */ + Object.defineProperty(globalThis, '__fx', { + value: (key, uuid, packed) => { + const [v2, meta, nums, baseJ, dataJ, inputsJ, withApi] = packed.split('\u0001'); + const fn = fnOf(key); + let object = null; + let n = null; + if (uuid) { + object = viewFor(uuid); + n = nums.split(','); + if (meta) { + const m = JSON.parse(meta); + object.name = m[0]; + object.type = m[1]; + } + object.__n = n; + object.__ud = null; + object.__mat = null; + object.position.set(+n[0], +n[1], +n[2]); + object.rotation.set(+n[3], +n[4], +n[5], ORD[+n[6]] || 'XYZ'); + object.scale.set(+n[7], +n[8], +n[9]); + object.quaternion.set(+n[10], +n[11], +n[12], +n[13]); + object.visible = n[14] === '1'; + } else n = nums.split(','); + const time = +n[n.length - 1]; + const base = cached(baseCache, key + '\u0000' + uuid, baseJ); + const shared = cached(dataCache, key, dataJ); + // a fresh top level per call, as resolveInputs built one per frame + const data = shared && typeof shared === 'object' && !Array.isArray(shared) ? Object.assign({}, shared) : shared; + const result = v2 + ? fn(object, base, data, time, data, inputsJ ? JSON.parse(inputsJ) : undefined, dist, lerp, clamp, withApi ? api : undefined) + : fn(object, base, data, time, data); + const t = object ? transformPatch(object) : ''; + const extra = object ? extraPatch(object, {}) : {}; + if (result === undefined && !extra.ud && !extra.udDel && !extra.m) return t; + return JSON.stringify({ v: { t, p: extra, r: result } }); + } + }); + def('__runValue', (key, inputs, time, withApi) => fnOf(key)(inputs, time, dist, lerp, clamp, withApi ? api : undefined)); + def('__runPlayer', (key, settings, input, time) => fnOf(key)(settings, input, time, dist, lerp, clamp)); +})(); +`; diff --git a/src/lib/scatterConvert.js b/src/lib/scatterConvert.js new file mode 100644 index 000000000..6ef137420 --- /dev/null +++ b/src/lib/scatterConvert.js @@ -0,0 +1,432 @@ +// @ts-ignore - no bundled three type declarations (project-wide) +import * as THREE from 'three'; +import { get } from 'svelte/store'; +import { objectsGroup } from '../stores/sceneStore'; +import { peers, showToast } from '../stores/appStore'; +import { beginHistoryBatch, endHistoryBatch, historyBatchOpen, recordEntry, recordObjectPresence } from './history'; +import { canEditObject, warnViewerReadOnly } from './objectPermissions'; +import { applyTerrainScatter, scatterPrimitiveSpec } from './terrainSurface'; +import { buildHeightGrid, sampleHeight, SCATTER_PRIMITIVES } from './terrainScatterCore'; +import { readScatter, blockOf, newSet, sourceKey, totalOf, MAX_INSTANCES, MAX_SETS } from './scatterEditAdapter'; +import { deleteSelected, yawOfQuaternion, commonSource, CONVERT_CONFIRM_ABOVE } from './scatterEditCore'; +import { selectedInstanceGroups, clearScatterSelection } from './scatterEdit'; +import { showConfirm } from './confirmDialog'; + +// 51 E5 — CONVERT BOTH WAYS. +// +// INSTANCES -> OBJECTS: each picked instance (the Select tool's selection, or a whole set) +// becomes a real object at exactly the pose it was drawn with — same base on the ground, same +// turn, same tilt when the set aligns to slope, same size. The way every object is made is +// one the session already replicates. A source peers receive AT ONCE gets ONE template (a +// `/create` of the primitive, or — a prefab's look that remembers its prefab — `instantiatePrefab`, +// which brings the prefab WITH its logic), then `duplicateObject` per instance (the small +// `duplicate` message + a `move`), then the template goes. A Library model or a pack item +// reaches peers LATE (an import's meshes follow its root on a timer; a pack stub refills from +// the pack), so a duplicate could beat the template there: each of those objects is placed on +// its own (an import of the file, or a pack stub — the Explorer's own placements). The instances +// leave the set. All of it is ONE history batch, so one undo puts the instances back and takes +// the objects away on every peer. More than CONVERT_CONFIRM_ABOVE asks first. +// +// COPIES -> A SCATTER SET: several copies of ONE object (a primitive type, or one Library +// model) standing on a terrain become instances of that terrain's set for that source (made +// if absent), each at its object's ground position, turn and size; the objects are deleted. +// ONE batch again. Each copy becomes a painted record of that set (scatterEditAdapter.blockOf). + +const UP = new THREE.Vector3(0, 1, 0); + +/** @param {string} uuid */ +function objectOf(uuid) { + return /** @type {any} */ (get(objectsGroup))?.getObjectByProperty('uuid', uuid) ?? null; +} + +/** @param {any} message */ +function send(message) { + /** @type {any} */ + const peer = get(peers); + peer?.send?.(message); +} + +/** Commit a terrain's scatter inside the open batch. @param {string} uuid @param {any} before @param {any[]} sets @param {any} bounds */ +function commitScatter(uuid, before, sets, bounds) { + applyTerrainScatter(uuid, blockOf(sets, bounds)); + const after = objectOf(uuid)?.userData?.terrainScatter ?? null; + send({ type: 'objectParameters', parameter: 'terrainScatter', uuid, terrainScatter: after }); + recordEntry({ kind: 'terrainScatter', uuid, before: { block: before }, after: { block: after ? JSON.parse(JSON.stringify(after)) : null } }); +} + +/** The world matrix an instance is drawn with (terrainSurface.instanceMatrices, in world). + * @param {any} terrain @param {any} grid @param {{x: number, z: number, yaw: number, scale: number}} inst @param {boolean} align */ +function instanceWorldMatrix(terrain, grid, inst, align) { + const h = grid ? sampleHeight(grid, inst.x, inst.z) : { y: 0, nx: 0, ny: 1, nz: 0 }; + const qy = new THREE.Quaternion().setFromAxisAngle(UP, inst.yaw); + const q = align ? new THREE.Quaternion().setFromUnitVectors(UP, new THREE.Vector3(h.nx, h.ny, h.nz)).multiply(qy) : qy; + const local = new THREE.Matrix4().compose(new THREE.Vector3(inst.x, h.y, inst.z), q, new THREE.Vector3(inst.scale, inst.scale, inst.scale)); + terrain.updateWorldMatrix(true, false); + return new THREE.Matrix4().multiplyMatrices(terrain.matrixWorld, local); +} + +/** Pose an object (a child of objectsGroup) so it shows at `world` and tell peers. + * @param {any} object @param {any} world */ +function poseAt(object, world) { + const parent = object.parent ?? get(objectsGroup); + parent.updateWorldMatrix(true, false); + const local = new THREE.Matrix4().copy(parent.matrixWorld).invert().multiply(world); + local.decompose(object.position, object.quaternion, object.scale); + object.updateMatrix(); + send({ type: 'move', uuid: object.uuid, pos: object.position.toArray(), rot: object.rotation.toArray(), scale: object.scale.toArray() }); +} + +/** Make the ONE template object for a source that peers receive AT ONCE (a primitive's `/create`, a prefab's + * `object` message). Resolves {uuid, offset} (offset = where the + * object's own transform relative to the instance base, a matrix in the instance's frame), or null. + * @param {any} source */ +async function makeTemplate(source) { + const commands = await import('./commandsHandler.svelte'); + const group = /** @type {any} */ (get(objectsGroup)); + const before = new Set((group?.children ?? []).map((/** @type {any} */ c) => c.uuid)); + const fresh = () => (get(objectsGroup)?.children ?? []).find((/** @type {any} */ c) => !before.has(c.uuid)) ?? null; + if (source.kind === 'primitive') { + const spec = scatterPrimitiveSpec(source.type); + commands.sceneCommand(spec.command); + const object = fresh(); + if (!object) return null; + const materials = await import('./materialsHandler'); + materials.setObjectColor(object.uuid, spec.color); + materials.setMaterialParam(object.uuid, 'roughness', spec.roughness); + return { uuid: object.uuid, offset: new THREE.Matrix4().makeTranslation(0, spec.offset, 0) }; + } + if (source.kind === 'library' && source.prefab) { + // a logic prefab scatters its LOOK only (51-sources); a real object is the FULL prefab with + // its logic (the user's rule) — when this machine's prefab library still holds it (else `placer`) + const { prefabById, instantiatePrefab } = await import('./prefabs'); + const prefab = prefabById(source.prefab); + const object = prefab ? instantiatePrefab(prefab) : null; + if (object) return { uuid: object.uuid, offset: lookOffset(object) }; + } + return null; +} + +/** Does this source reach peers late (bytes or a pack refill), so it cannot be a `duplicate` template? + * A prefab look whose prefab is here is made from the prefab (an `object` message, applied at once). + * @param {any} source */ +async function reachesPeersLate(source) { + if (source.kind === 'pack') return true; + if (source.kind !== 'library') return false; + if (!source.prefab) return true; + const { prefabById } = await import('./prefabs'); + return !prefabById(source.prefab); +} + +/** A function that places ONE object of `source` at a world matrix (its base there) and resolves its uuid — + * a pack item as a pack STUB (tiny on the wire, every peer refills it from the pack, the Explorer's own + * placement), a Library model as an import of its file. Null when the model cannot be had. + * @param {any} source @returns {Promise<((world: any) => Promise) | null>} */ +async function placer(source) { + const { addImported, importFile } = await import('./fileHandler.svelte'); + if (source.kind === 'pack') { + const refs = await import('./packRefs'); + const url = refs.packRefUrl(source); + const name = source.item || String(source.path.split('/').pop() ?? 'model').replace(/\.\w+$/, ''); + const ref = refs.packRefFromUrl(url, { pack: source.pack, item: name }); + if (!ref) return null; + void import('./packCache').then((m) => m.fetchPackBuffer(url)).catch(() => {}); + return async (world) => { + const stub = new THREE.Group(); + stub.userData = { packRef: { ...ref }, packStub: true }; + localPose(stub, world); + addImported(stub, name, stub.position.toArray()); + poseAt(stub, world); // the `move` carries the turn and size whatever the stub element keeps + return stub.uuid; + }; + } + const explorer = await import('./explorer'); + const item = explorer.itemByHash(source.hash); + const blob = item ? await explorer.itemBlob(item.id) : null; + if (!item || !blob) return null; + const name = String(item.name).replace(/\.\w+$/, ''); + return async (world) => { + const at = new THREE.Object3D(); + localPose(at, world); + const uuid = await importFile(new File([blob], item.name), name, undefined, at.position.toArray(), undefined, { noOptimize: true, quaternion: at.quaternion.toArray() }); + const object = uuid ? objectOf(uuid) : null; + if (object) poseAt(object, world); + return uuid ?? null; + }; +} + +/** Set an object's local transform (a child of objectsGroup) so it shows at `world`, no message. @param {any} object @param {any} world */ +function localPose(object, world) { + const parent = /** @type {any} */ (get(objectsGroup)); + parent.updateWorldMatrix(true, false); + new THREE.Matrix4().copy(parent.matrixWorld).invert().multiply(world).decompose(object.position, object.quaternion, object.scale); +} + +/** Where an object sits relative to the base of its LOOK, as a matrix in the instance's frame: + * scatterSources.lookSource stands the look centred on x/z with its lowest point at y 0, unturned, + * with the root's own scale baked in — so the real object is that offset, then its own scale. + * @param {any} object */ +function lookOffset(object) { + const saved = { p: object.position.clone(), q: object.quaternion.clone() }; + object.position.set(0, 0, 0); + object.quaternion.identity(); + object.updateMatrixWorld(true); + const box = new THREE.Box3(); + object.traverse((/** @type {any} */ n) => { + if (n.isMesh) box.expandByObject(n); + }); + object.position.copy(saved.p); + object.quaternion.copy(saved.q); + object.updateMatrixWorld(true); + const at = box.isEmpty() ? new THREE.Vector3() : new THREE.Vector3(-(box.min.x + box.max.x) / 2, -box.min.y, -(box.min.z + box.max.z) / 2); + return new THREE.Matrix4().compose(at, new THREE.Quaternion(), object.scale.clone()); +} + +/** + * Instances → real objects. `scope` 'selection' = the Select tool's picks; 'set' = every + * instance of `setId` on terrain `uuid`. Resolves the new objects' uuids ([] when nothing + * was made or the person cancelled). + * @param {{scope: 'selection'} | {scope: 'set', uuid: string, setId: string}} what + * @param {{confirm?: boolean}} [opts] confirm:false skips the large-count ask (a suite) + */ +export async function instancesToObjects(what, opts = {}) { + /** @type {{uuid: string, data: any, items: {set: any, ids: Set}[]} | null} */ + let picked = null; + if (what.scope === 'selection') picked = selectedInstanceGroups(); + else { + const terrain = objectOf(what.uuid); + const data = terrain ? readScatter(terrain) : null; + const set = data?.sets.find((s) => s.id === what.setId); + if (data && set) picked = { uuid: what.uuid, data, items: [{ set, ids: new Set(set.list.map((/** @type {any} */ _, /** @type {number} */ i) => i)) }] }; + } + const terrain = picked ? objectOf(picked.uuid) : null; + if (!picked || !terrain || !picked.items.length) { + showToast('Select scattered instances first (Scatter ▸ Select)'); + return []; + } + if (!canEditObject(terrain)) { + warnViewerReadOnly(); + return []; + } + const count = picked.items.reduce((n, p) => n + p.ids.size, 0); + if (count > CONVERT_CONFIRM_ABOVE && opts.confirm !== false) { + const ok = await showConfirm({ + title: 'Make ' + count + ' objects?', + message: + count + + ' instances become ' + + count + + ' separate objects. Every one is synced, saved and drawn on its own — a scatter draws them together. One undo turns them back.', + confirmLabel: 'Make ' + count + ' objects' + }); + if (!ok) return []; + } + const terrainUuid = picked.uuid; + const before = terrain.userData.terrainScatter ? JSON.parse(JSON.stringify(terrain.userData.terrainScatter)) : null; + const grid = buildHeightGrid(terrain.geometry.attributes.position.array); + const own = !historyBatchOpen(); + if (own) beginHistoryBatch(); + /** @type {string[]} */ + const made = []; + try { + const { duplicateObject, deleteObjectsByUuid } = await import('./objectActions'); + for (const { set, ids } of picked.items) { + // 51-union: a MIXED set (51-sources) makes each instance from the model it drew + /** @type {Map} */ + const bySource = new Map(); + for (const i of ids) { + const src = set.list[i]?.src ?? 0; + bySource.set(src, [...(bySource.get(src) ?? []), i]); + } + for (const [src, list] of bySource) { + const source = set.sources?.[src]?.source ?? set.source; + // the set's sink pushes each instance down its own up axis (terrainSurface.instanceMatrices) + const sink = new THREE.Matrix4().makeTranslation(0, -(set.sink ?? 0), 0); + if (await reachesPeersLate(source)) { + // 51-int: a Library model or a pack item reaches peers LATE (an import's meshes follow its root + // after a timer; a pack stub refills from the pack), so a `duplicate` of a template could reach a + // peer before the template has anything to copy — each object is placed on its own instead + const one = await placer(source); + if (!one) { + showToast('Could not make ' + (source.name || source.item || 'the model') + (source.kind === 'pack' ? ' — its pack could not be reached' : ' — it is not in your Library')); + continue; + } + for (const i of list) { + const inst = set.list[i]; + if (!inst) continue; + const uuid = await one(instanceWorldMatrix(terrain, grid, inst, set.align).multiply(sink)); + if (uuid) made.push(uuid); + } + continue; + } + const template = await makeTemplate(source); + if (!template) { + showToast('Could not make ' + (source.name || source.type || source.item || 'the model') + (source.kind === 'pack' ? ' — its pack could not be reached' : ' — it is not in your Library')); + continue; + } + const lift = sink.multiply(template.offset); + for (const i of list) { + const inst = set.list[i]; + if (!inst) continue; + // the copy's name rides the `duplicate` message (renaming it afterwards stayed on this peer) + const clone = duplicateObject(template.uuid, { select: false, history: false, carryCables: false, name: objectOf(template.uuid)?.name }); + if (!clone) continue; + poseAt(clone, instanceWorldMatrix(terrain, grid, inst, set.align).multiply(lift)); + recordObjectPresence('create', clone); + made.push(clone.uuid); + } + deleteObjectsByUuid([template.uuid]); + } + } + // the converted instances leave their sets + const sets = picked.data.sets.map((/** @type {any} */ set) => { + const p = picked?.items.find((x) => x.set.id === set.id); + return p ? { ...set, list: deleteSelected(set.list, p.ids).list } : set; + }); + clearScatterSelection(); + commitScatter(terrainUuid, before, sets, picked.data.bounds); + } finally { + if (own) endHistoryBatch('Instances to objects'); + } + if (made.length) { + const { exitSculpt } = await import('./terrainSculpt'); + exitSculpt(); + const { applySelectionSet } = await import('./objectActions'); + applySelectionSet(made); + showToast(made.length === 1 ? 'Made 1 object' : 'Made ' + made.length + ' objects'); + } + return made; +} + +// ---- copies -> a scatter set -------------------------------------------------------------- + +const GEOMETRY_TYPE = /** @type {Record} */ ({ + BoxGeometry: 'Box', + SphereGeometry: 'Sphere', + ConeGeometry: 'Cone', + CylinderGeometry: 'Cylinder', + CapsuleGeometry: 'Capsule', + DodecahedronGeometry: 'Dodecahedron' +}); + +/** A Library model by the object's name (an import is named after its file; a duplicate + * adds " copy"). @param {any} object @param {any[]} items */ +function libraryItemOf(object, items) { + const base = String(object.name ?? '').replace(/( copy)+$/, ''); + const hits = items.filter((it) => it?.kind === 'object' && it.hash && String(it.name).replace(/\.\w+$/, '') === base); + return hits.length === 1 ? hits[0] : null; +} + +/** The scatter source an object would be, or null. @param {any} object @param {any[]} items */ +export function sourceOfObject(object, items) { + if (!object || object.userData?.terrain) return null; + if (object.isMesh && !Array.isArray(object.material)) { + const type = object.userData?.geometryParams?.gtype ?? GEOMETRY_TYPE[object.geometry?.type] ?? ''; + if (SCATTER_PRIMITIVES.includes(type)) return { kind: 'primitive', type }; + } + const item = libraryItemOf(object, items); + return item ? { kind: 'library', hash: item.hash, name: item.name } : null; +} + +/** The terrain the objects stand on: one in the list, else the one whose ground holds their + * centre. @param {any[]} objects */ +function terrainUnder(objects) { + const group = /** @type {any} */ (get(objectsGroup)); + const centre = new THREE.Vector3(); + for (const o of objects) centre.add(o.getWorldPosition(new THREE.Vector3())); + centre.divideScalar(Math.max(1, objects.length)); + /** @type {any} */ + let found = null; + group?.traverse((/** @type {any} */ t) => { + if (found || !t.userData?.terrain || !t.geometry) return; + const data = readScatter(t); + if (!data) return; + const p = t.worldToLocal(centre.clone()); + const b = data.bounds; + if (p.x >= b.minX && p.x <= b.minX + b.sizeX && p.z >= b.minZ && p.z <= b.minZ + b.sizeZ) found = t; + }); + return found; +} + +/** Can these objects become scatter? (the menu row asks; a reason when not) + * @param {string[]} uuids @param {any[]} items explorer items */ +export function scatterableSelection(uuids, items) { + const all = uuids.map(objectOf).filter(Boolean); + const terrains = all.filter((o) => o.userData?.terrain); + const objects = all.filter((o) => !o.userData?.terrain); + const verdict = commonSource(objects, (o) => { + const s = sourceOfObject(o, items); + return s ? sourceKey(s) : ''; + }); + if (!verdict.ok) return { ok: false, reason: verdict.reason }; + const terrain = terrains.length === 1 ? terrains[0] : terrainUnder(objects); + if (!terrain) return { ok: false, reason: 'The copies must stand on a Terrain' }; + return { ok: true, objects, terrain, source: /** @type {any} */ (sourceOfObject(objects[0], items)) }; +} + +/** Copies of one object → instances of that source's set on the terrain under them. + * Resolves the number of instances made. @param {string[]} uuids */ +export async function objectsToScatter(uuids) { + const explorer = await import('./explorer'); + const items = /** @type {any[]} */ (get(explorer.explorerItems)) ?? []; + const v = scatterableSelection(uuids, items); + if (!v.ok) { + showToast(/** @type {string} */ (v.reason)); + return 0; + } + const { objects, terrain, source } = /** @type {any} */ (v); + if (!canEditObject(terrain) || objects.some((/** @type {any} */ o) => !canEditObject(o))) { + warnViewerReadOnly(); + return 0; + } + const data = readScatter(terrain); + if (!data) return 0; + const key = sourceKey(source); + let set = data.sets.find((s) => sourceKey(s.source) === key) ?? null; + if (!set && data.sets.length >= MAX_SETS) { + showToast('A terrain holds up to ' + MAX_SETS + ' scatter models'); + return 0; + } + if (totalOf(data.sets) + objects.length > MAX_INSTANCES) { + showToast('That would pass ' + MAX_INSTANCES + ' instances on this terrain'); + return 0; + } + const before = terrain.userData.terrainScatter ? JSON.parse(JSON.stringify(terrain.userData.terrainScatter)) : null; + // a primitive's size relative to the scatter's unit primitive (an object made with + // /create Cone defaults is twice the scatter cone's radius): measure height in local units + const unit = source.kind === 'primitive' ? unitHeight(source.type) : 0; + terrain.updateWorldMatrix(true, false); + const tq = terrain.getWorldQuaternion(new THREE.Quaternion()).invert(); + const ts = terrain.getWorldScale(new THREE.Vector3()); + const list = objects.map((/** @type {any} */ o) => { + o.updateWorldMatrix(true, false); + const local = terrain.worldToLocal(o.getWorldPosition(new THREE.Vector3())); + const q = tq.clone().multiply(o.getWorldQuaternion(new THREE.Quaternion())); + const ws = o.getWorldScale(new THREE.Vector3()); + let scale = (ws.x + ws.y + ws.z) / 3 / ((ts.x + ts.y + ts.z) / 3); + if (unit && o.geometry) { + if (!o.geometry.boundingBox) o.geometry.computeBoundingBox(); + const bb = o.geometry.boundingBox; + scale *= (bb.max.y - bb.min.y) / unit; + } + return { x: local.x, z: local.z, yaw: yawOfQuaternion(q), scale }; + }); + const sets = set + ? data.sets.map((s) => (s === set ? { ...s, list: [...s.list, ...list] } : s)) + : [...data.sets, newSet(source, list)]; + const own = !historyBatchOpen(); + if (own) beginHistoryBatch(); + try { + commitScatter(terrain.uuid, before, sets, data.bounds); + const { deleteObjectsByUuid } = await import('./objectActions'); + deleteObjectsByUuid(objects.map((/** @type {any} */ o) => o.uuid)); + } finally { + if (own) endHistoryBatch('Objects to scatter'); + } + showToast(list.length + ' copies are now scatter on ' + (terrain.name || 'the terrain')); + return list.length; +} + +/** @param {string} type */ +function unitHeight(type) { + return 2 * scatterPrimitiveSpec(type).offset; +} diff --git a/src/lib/scatterEdit.js b/src/lib/scatterEdit.js new file mode 100644 index 000000000..4bd6831bc --- /dev/null +++ b/src/lib/scatterEdit.js @@ -0,0 +1,758 @@ +// @ts-ignore - no bundled three type declarations (project-wide) +import * as THREE from 'three'; +import { writable, get } from 'svelte/store'; +import { objectsGroup, globalScene, globalRenderer, TControls, marqueeRect, sceneRevision } from '../stores/sceneStore'; +import { peers, showToast } from '../stores/appStore'; +import { viewportHasKeys } from './keyScope'; +import { recordEntry } from './history'; +import { canEditObject, warnViewerReadOnly } from './objectPermissions'; +import { applyTerrainScatter, scatterHolderOf } from './terrainSurface'; +import { buildHeightGrid, sampleHeight, rng32 } from './terrainScatterCore'; +import { scatterScaleMin, scatterScaleMax, scatterRandomRotation } from './terrainPaint'; +import { readScatter, blockOf, totalOf, indexOfInstance } from './scatterEditAdapter'; +import { + indicesInRect, + rotateEach, + scaleEach, + transformGroup, + deleteSelected, + rerollSelected, + centroid, + rotateBrush, + scaleBrush, + relaxBrush, + eraseBrush, + yawOfQuaternion +} from './scatterEditCore'; + +// 51 E4 — EDIT SCATTER INSTANCES: the runtime half of scatterEditCore. It rides the Sculpt +// toolbox's Scatter brush (same session, lock, radius, strength), adding TOOLS beside Paint: +// +// erase / rotate / scale / relax brushes — one stroke = ONE undo entry + ONE commit +// message, previews streamed ~5/s (terrainPaint's shape) +// select click an instance (Shift/Ctrl adds), drag a box, Ctrl+A; +// the gizmo moves / turns / scales the selection, and the +// toolbox rotates, scales, deletes and re-rolls it +// +// "Affects" filters every tool to one model (a set) or all of them — the per-source erase. +// +// WIRE + UNDO + SAVE are the 1.38 ones verbatim: every op ends in `applyTerrainScatter`, the +// existing `objectParameters terrainScatter` message and a `terrainScatter` history entry +// (registered by terrainPaint), so peers, reload and undo need nothing new. HOW an edit is +// stored is scatterEditAdapter's business (51-sources' overrides + painted records since the +// 51 union), which is why nothing here touches the format. + +/** @typedef {import('./scatterEditCore').Instance} Instance */ +/** @typedef {'paint'|'erase'|'rotate'|'scale'|'relax'|'select'} ScatterTool */ + +/** which scatter tool is armed while the Sculpt brush is Scatter */ +export const scatterTool = writable(/** @type {ScatterTool} */ ('paint')); +/** '' = every model on the terrain, else one set id: what the tools may touch */ +export const scatterAffects = writable(''); +/** rotate brush turns the other way, scale brush shrinks */ +export const scatterBrushReverse = writable(false); +/** how many instances the select tool holds (the toolbox reads it) */ +export const scatterSelectionCount = writable(0); +/** the gizmo's mode on the selection */ +export const scatterGizmoMode = writable(/** @type {'translate'|'rotate'|'scale'} */ ('translate')); + +const BRUSH_TOOLS = new Set(['erase', 'rotate', 'scale', 'relax']); +const PREVIEW_MS = 200; + +/** @param {string} uuid */ +function objectOf(uuid) { + return /** @type {any} */ (get(objectsGroup))?.getObjectByProperty('uuid', uuid) ?? null; +} + +/** @param {any} message */ +function send(message) { + /** @type {any} */ + const peer = get(peers); + peer?.send?.(message); +} + +/** @param {string} uuid */ +function sendScatter(uuid) { + send({ type: 'objectParameters', parameter: 'terrainScatter', uuid, terrainScatter: objectOf(uuid)?.userData?.terrainScatter ?? null }); +} + +/** @param {any} block */ +const cloneBlock = (block) => (block ? JSON.parse(JSON.stringify(block)) : null); + +/** the block this module last wrote (a different one arriving = someone else's edit / an undo) */ +let lastWritten = /** @type {any} */ (null); + +/** Apply `sets` locally (a preview). @param {string} uuid @param {any[]} sets @param {any} bounds */ +function preview(uuid, sets, bounds) { + applyTerrainScatter(uuid, blockOf(sets, bounds)); + lastWritten = objectOf(uuid)?.userData?.terrainScatter ?? null; +} + +/** ONE commit: apply, send, ONE undo entry (nothing recorded when nothing changed). + * @param {string} uuid @param {any} before the block before the op @param {any[]} sets @param {any} bounds */ +function commit(uuid, before, sets, bounds) { + preview(uuid, sets, bounds); + const after = objectOf(uuid)?.userData?.terrainScatter ?? null; + if (JSON.stringify(before ?? null) === JSON.stringify(after ?? null)) return false; + sendScatter(uuid); + recordEntry({ kind: 'terrainScatter', uuid, before: { block: before }, after: { block: cloneBlock(after) } }); + return true; +} + +/** @param {any} object */ +function editable(object) { + if (!object?.userData?.terrain) { + showToast('Scatter editing works on a Terrain'); + return false; + } + if (!canEditObject(object)) { + warnViewerReadOnly(); + return false; + } + return true; +} + +/** @param {{id: string}} set */ +function affected(set) { + const only = get(scatterAffects); + return !only || set.id === only; +} + +// ---- brushes ---------------------------------------------------------------------------- + +/** @type {{uuid: string, before: any, data: NonNullable>, changed: boolean} | null} */ +let stroke = null; +let lastPreviewAt = 0; + +/** Is a brush tool armed (Scatter brush + erase/rotate/scale/relax)? terrainSculpt asks. */ +export function scatterBrushArmed() { + return BRUSH_TOOLS.has(get(scatterTool)); +} + +/** Is the select tool armed (Scatter brush, in a terrain sculpt session)? Scene asks before + * it treats a press as a stroke. */ +export function scatterSelectArmed() { + return get(scatterTool) === 'select' && scatterOpOn(); +} + +/** @param {string} uuid */ +export function beginEditStroke(uuid) { + const object = objectOf(uuid); + if (!editable(object)) return false; + const data = readScatter(object); + if (!data) return false; + stroke = { uuid, before: cloneBlock(object.userData.terrainScatter ?? null), data, changed: false }; + return true; +} + +/** + * One brush application at a terrain-LOCAL (x, z), exported for the suite. + * @param {string} uuid @param {number} x @param {number} z @param {number} radius + * @param {number} strength @param {number} dt @returns {number} instances touched + */ +export function editBrushAt(uuid, x, z, radius, strength, dt = 0.016) { + const st = stroke; + if (!st || st.uuid !== uuid) return 0; + const tool = get(scatterTool); + const reverse = get(scatterBrushReverse); + let touched = 0; + for (const set of st.data.sets) { + if (!affected(set)) continue; + let result; + if (tool === 'erase') result = eraseBrush(set.list, x, z, radius); + else if (tool === 'rotate') result = rotateBrush(set.list, x, z, radius, strength, dt, reverse); + else if (tool === 'scale') result = scaleBrush(set.list, x, z, radius, strength, dt, reverse); + else if (tool === 'relax') result = relaxBrush(set.list, x, z, radius, strength, dt, st.data.bounds); + else continue; + if (!result.changed) continue; + set.list = result.list; + touched += result.changed; + } + if (touched) { + st.changed = true; + preview(uuid, st.data.sets, st.data.bounds); + } + return touched; +} + +/** @param {string} uuid @param {number} x @param {number} z @param {number} radius @param {number} strength @param {number} dt */ +export function editStrokeMove(uuid, x, z, radius, strength, dt) { + if (!editBrushAt(uuid, x, z, radius, strength, dt)) return; + const now = performance.now(); + if (now - lastPreviewAt > PREVIEW_MS) { + lastPreviewAt = now; + sendScatter(uuid); + } +} + +export function endEditStroke() { + const st = stroke; + stroke = null; + if (!st || !st.changed) return; + commit(st.uuid, st.before, st.data.sets, st.data.bounds); +} + +/** True while a brush stroke is open (terrainSculpt's endStroke asks). */ +export function editStrokeOpen() { + return !!stroke; +} + +// ---- the selection ---------------------------------------------------------------------- + +/** @type {{uuid: string, picks: Map>} | null} */ +let sel = null; +/** @type {any} scene-root markers on the selected instances */ +let marks = null; +/** @type {any} the gizmo's target */ +let proxy = null; +/** @type {{x: number, y: number, add: boolean, moved: boolean} | null} */ +let press = null; +/** @type {{data: NonNullable>, before: any, local: any, yaw: number, pivot: {x: number, z: number}} | null} */ +let drag = null; +let dragPreviewAt = 0; + +const _v = new THREE.Vector3(); +const _q = new THREE.Quaternion(); +const _m = new THREE.Matrix4(); + +/** @type {WeakMap} */ +const grids = new WeakMap(); +/** The terrain's height grid, cached on its position attribute's identity + version. @param {any} terrain */ +function gridOf(terrain) { + const pos = terrain.geometry?.attributes?.position; + if (!pos) return null; + const key = pos.version + ':' + pos.count; + const cached = grids.get(pos); + if (cached && cached.key === key) return cached.grid; + const grid = buildHeightGrid(pos.array); + grids.set(pos, { key, grid }); + return grid; +} + +/** World position of an instance's base. @param {any} terrain @param {Instance} inst @param {any} out */ +function instanceWorld(terrain, inst, out) { + const grid = gridOf(terrain); + const y = grid ? sampleHeight(grid, inst.x, inst.z).y : 0; + terrain.updateWorldMatrix(true, false); + return out.set(inst.x, y, inst.z).applyMatrix4(terrain.matrixWorld); +} + +function selectionTotal() { + let n = 0; + for (const ids of sel?.picks.values() ?? []) n += ids.size; + return n; +} + +/** The selection as [{set, ids}] against freshly read data (stale indices dropped). + * @param {NonNullable>} data */ +function picksIn(data) { + /** @type {{set: any, ids: Set}[]} */ + const out = []; + for (const set of data.sets) { + const ids = sel?.picks.get(set.id); + if (!ids?.size) continue; + const valid = new Set([...ids].filter((i) => i < set.list.length)); + if (valid.size) out.push({ set, ids: valid }); + } + return out; +} + +/** Read-only view for the toolbox / suite: {setId: [indices]}. */ +export function scatterSelection() { + /** @type {Record} */ + const out = {}; + for (const [id, ids] of sel?.picks ?? []) if (ids.size) out[id] = [...ids].sort((a, b) => a - b); + return out; +} + +/** Replace (or with `add`, toggle into) the selection. @param {string} uuid + * @param {Map>} picks @param {boolean} [add] */ +export function setScatterSelection(uuid, picks, add = false) { + if (!sel || sel.uuid !== uuid || !add) sel = { uuid, picks: new Map() }; + // a selection is taken against the scatter AS IT IS NOW (after an undo or a peer's edit + // too), so that block is the one a later change is measured against + lastWritten = objectOf(uuid)?.userData?.terrainScatter ?? null; + for (const [id, ids] of picks) { + const cur = sel.picks.get(id) ?? new Set(); + for (const i of ids) { + if (add && cur.has(i)) cur.delete(i); + else cur.add(i); + } + sel.picks.set(id, cur); + } + refreshSelection(); +} + +export function clearScatterSelection() { + sel = null; + refreshSelection(); +} + +/** Re-draw the markers, re-seat the gizmo, publish the count. */ +function refreshSelection() { + const n = selectionTotal(); + scatterSelectionCount.set(n); + drawMarks(); + seatGizmo(); + if (n && !unsubRevision) unsubRevision = sceneRevision.subscribe(onRevision); +} + +/** @type {(() => void) | null} */ +let unsubRevision = null; +/** Somebody else changed the scatter under the selection (an undo, a peer): indices no + * longer name what was picked, so the selection is dropped rather than retargeted. */ +function onRevision() { + if (!sel) return; + if (drag) return; + const object = objectOf(sel.uuid); + if (!object) return void endScatterEditSession(); + if ((object.userData?.terrainScatter ?? null) !== lastWritten) { + sel = null; + scatterSelectionCount.set(0); + drawMarks(); + seatGizmo(); + } else drawMarks(); +} + +function drawMarks() { + const scene = /** @type {any} */ (get(globalScene)); + const object = sel ? objectOf(sel.uuid) : null; + const data = object ? readScatter(object) : null; + const items = data ? picksIn(data) : []; + const count = items.reduce((n, p) => n + p.ids.size, 0); + if (!count || !scene) { + if (marks) marks.visible = false; + return; + } + if (!marks || marks.count < count) { + if (marks) { + marks.parent?.remove(marks); + marks.dispose?.(); + } + const geo = new THREE.RingGeometry(0.42, 0.5, 24).rotateX(-Math.PI / 2); + const mat = new THREE.MeshBasicMaterial({ color: 0xffc44d, transparent: true, opacity: 0.9, depthTest: false, side: THREE.DoubleSide }); + marks = new THREE.InstancedMesh(geo, mat, Math.max(16, count * 2)); + marks.name = 'scatter-edit-marks'; + marks.renderOrder = 996; + marks.frustumCulled = false; + } + if (marks.parent !== scene) scene.add(marks); + let k = 0; + for (const { set, ids } of items) + for (const i of ids) { + const inst = set.list[i]; + instanceWorld(object, inst, _v); + _v.y += 0.03; + const s = Math.max(0.4, inst.scale) * 1.2; + marks.setMatrixAt(k++, _m.compose(_v, _q.identity(), new THREE.Vector3(s, s, s))); + } + marks.count = k; + marks.instanceMatrix.needsUpdate = true; + marks.visible = true; +} + +// ---- picking ---------------------------------------------------------------------------- + +/** @param {number} clientX @param {number} clientY @param {any} camera */ +function rayAt(clientX, clientY, camera) { + const canvas = /** @type {any} */ (get(globalRenderer))?.domElement; + const rect = canvas?.getBoundingClientRect?.(); + const ray = new THREE.Raycaster(); + if (!rect || !camera) return null; + ray.setFromCamera(new THREE.Vector2(((clientX - rect.left) / rect.width) * 2 - 1, -((clientY - rect.top) / rect.height) * 2 + 1), camera); + return ray; +} + +/** The instance under a screen point: {setId, index} or null. Exported for the suite. + * @param {string} uuid @param {number} clientX @param {number} clientY @param {any} camera */ +export function pickInstanceAt(uuid, clientX, clientY, camera) { + const object = objectOf(uuid); + const holder = object ? scatterHolderOf(object) : null; + const ray = rayAt(clientX, clientY, camera); + if (!holder || !ray) return null; + holder.updateMatrixWorld(true); + const hits = ray.intersectObjects(holder.children, false); + /** @type {ReturnType} */ + let data = null; + for (const hit of hits) { + const ud = hit.object?.userData; + if (!ud?.scatterSet || hit.instanceId == null) continue; + if (get(scatterAffects) && ud.scatterSet !== get(scatterAffects)) continue; + // 51-union: a mesh's slot i draws instance `instanceIds[i]` (thinning compacts and reorders + // slots per device, a LOD cell holds a subset) — map it to its index in the set's list + const id = ud.instanceIds?.[hit.instanceId]; + if (id == null) continue; + data ??= readScatter(object); + const set = data?.sets.find((s) => s.id === ud.scatterSet); + const index = set ? indexOfInstance(set, id) : -1; + if (index < 0) continue; + return { setId: /** @type {string} */ (ud.scatterSet), index: /** @type {number} */ (index) }; + } + return null; +} + +/** Every instance whose base projects inside the screen rect. Exported for the suite. + * @param {string} uuid @param {{x0: number, y0: number, x1: number, y1: number}} rect client px @param {any} camera */ +export function instancesInScreenRect(uuid, rect, camera) { + const object = objectOf(uuid); + const data = object ? readScatter(object) : null; + const canvas = /** @type {any} */ (get(globalRenderer))?.domElement; + const box = canvas?.getBoundingClientRect?.(); + /** @type {Map>} */ + const out = new Map(); + if (!data || !box || !camera) return out; + camera.updateMatrixWorld?.(); + for (const set of data.sets) { + if (!affected(set)) continue; + const projected = set.list.map((inst) => { + instanceWorld(object, inst, _v).project(camera); + return { + sx: box.left + ((_v.x + 1) / 2) * box.width, + sy: box.top + ((1 - _v.y) / 2) * box.height, + visible: _v.z > -1 && _v.z < 1 + }; + }); + const ids = indicesInRect(projected, rect); + if (ids.size) out.set(set.id, ids); + } + return out; +} + +/** Select every (affected) instance on the terrain — Ctrl+A in the select tool. @param {string} uuid */ +export function selectAllInstances(uuid) { + const object = objectOf(uuid); + const data = object ? readScatter(object) : null; + if (!data) return 0; + /** @type {Map>} */ + const picks = new Map(); + for (const set of data.sets) if (affected(set)) picks.set(set.id, new Set(set.list.map((_, i) => i))); + setScatterSelection(uuid, picks); + return selectionTotal(); +} + +/** Scene pointerdown in the select tool (a gizmo handle under the pointer is the gizmo's). + * @param {string} uuid @param {PointerEvent} event */ +export function scatterSelectDown(uuid, event) { + press = { x: event.clientX, y: event.clientY, add: event.shiftKey || event.ctrlKey || event.metaKey, moved: false }; + if (!sel || sel.uuid !== uuid) sel = { uuid, picks: new Map() }; +} + +/** @param {PointerEvent} event */ +export function scatterSelectMove(event) { + if (!press) return; + if (!press.moved && Math.hypot(event.clientX - press.x, event.clientY - press.y) > 4) press.moved = true; + if (press.moved) + marqueeRect.set({ + x0: Math.min(press.x, event.clientX), + y0: Math.min(press.y, event.clientY), + x1: Math.max(press.x, event.clientX), + y1: Math.max(press.y, event.clientY) + }); +} + +/** @param {string} uuid @param {PointerEvent} event @param {any} camera */ +export function scatterSelectUp(uuid, event, camera) { + const p = press; + press = null; + marqueeRect.set(null); + if (!p) return; + if (p.moved) { + const picks = instancesInScreenRect(uuid, { x0: p.x, y0: p.y, x1: event.clientX, y1: event.clientY }, camera); + if (p.add) { + // a box with Shift ADDS (never toggles off what it sweeps) + for (const [id, ids] of picks) for (const i of sel?.picks.get(id) ?? []) ids.delete(i); + } + setScatterSelection(uuid, picks, p.add); + return; + } + const hit = pickInstanceAt(uuid, event.clientX, event.clientY, camera); + if (!hit) { + if (!p.add) clearScatterSelection(); + return; + } + setScatterSelection(uuid, new Map([[hit.setId, new Set([hit.index])]]), p.add); +} + +// ---- the gizmo -------------------------------------------------------------------------- + +/** Seat (or drop) the gizmo on the selection. */ +function seatGizmo() { + /** @type {any} */ + const controls = get(TControls); + const scene = /** @type {any} */ (get(globalScene)); + const object = sel ? objectOf(sel.uuid) : null; + const data = object ? readScatter(object) : null; + const items = data ? picksIn(data) : []; + if (!items.length || !controls || !scene) { + if (proxy && controls?.object === proxy) controls.detach(); + return; + } + if (!proxy) { + proxy = new THREE.Object3D(); + proxy.name = 'scatter-edit-proxy'; + proxy.userData.isScatterProxy = true; + } + if (proxy.parent !== scene) scene.add(proxy); + const c = pivotOf(items); + instanceWorld(object, { x: c.x, z: c.z, yaw: 0, scale: 1 }, proxy.position); + // one instance: the gizmo shows ITS turn; several: the group's frame + const single = items.length === 1 && items[0].ids.size === 1 ? items[0].set.list[[...items[0].ids][0]] : null; + object.updateWorldMatrix(true, false); + object.getWorldQuaternion(proxy.quaternion); + if (single) proxy.quaternion.multiply(_q.setFromAxisAngle(new THREE.Vector3(0, 1, 0), single.yaw)); + proxy.scale.set(1, 1, 1); + proxy.updateMatrixWorld(true); + applyGizmoMode(controls); + if (controls.object !== proxy) controls.attach(proxy); +} + +/** @param {{set: any, ids: Set}[]} items */ +function pivotOf(items) { + let x = 0; + let z = 0; + let n = 0; + for (const { set, ids } of items) { + const c = centroid(set.list, ids); + if (!c) continue; + x += c.x * ids.size; + z += c.z * ids.size; + n += ids.size; + } + return n ? { x: x / n, z: z / n } : { x: 0, z: 0 }; +} + +/** Height is the terrain's, so the gizmo offers only what an instance can do: slide on the + * ground, turn about up, scale uniformly. @param {any} controls */ +function applyGizmoMode(controls) { + const mode = get(scatterGizmoMode); + controls.setMode(mode); + controls.showX = mode !== 'rotate'; + controls.showY = mode !== 'translate'; + controls.showZ = mode !== 'rotate'; +} + +/** The toolbox's Move / Rotate / Scale. @param {'translate'|'rotate'|'scale'} mode */ +export function setScatterGizmoMode(mode) { + scatterGizmoMode.set(mode); + /** @type {any} */ + const controls = get(TControls); + if (controls && proxy && controls.object === proxy) applyGizmoMode(controls); +} + +/** Scene.svelte's `dragging-changed` while the proxy is attached. @param {boolean} dragging */ +export function onScatterProxyDragChanged(dragging) { + const object = sel ? objectOf(sel.uuid) : null; + if (!object || !sel) return; + if (dragging) { + const data = readScatter(object); + if (!data || !editable(object)) return; + const items = picksIn(data); + object.updateWorldMatrix(true, false); + const local = object.worldToLocal(proxy.position.clone()); + drag = { + data, + before: cloneBlock(object.userData.terrainScatter ?? null), + local, + yaw: localYaw(object), + pivot: pivotOf(items) + }; + return; + } + const d = drag; + drag = null; + if (!d) return; + const sets = transformed(object, d); + commit(sel.uuid, d.before, sets, d.data.bounds); + refreshSelection(); +} + +/** The proxy's turn about the terrain's up axis. @param {any} terrain */ +function localYaw(terrain) { + terrain.getWorldQuaternion(_q); + const local = _q.invert().multiply(proxy.quaternion); + return yawOfQuaternion(local); +} + +/** The drag's lists: the drag-START lists moved by the proxy's change since then (absolute + * from the snapshot, so a long drag cannot drift). @param {any} terrain @param {NonNullable} d */ +function transformed(terrain, d) { + const now = terrain.worldToLocal(proxy.position.clone()); + const s = proxy.scale; + // the axis handle that moved furthest decides the uniform factor + const factor = [s.x, s.y, s.z].reduce((best, v) => (Math.abs(v - 1) > Math.abs(best - 1) ? v : best), 1); + let dyaw = localYaw(terrain) - d.yaw; + if (dyaw > Math.PI) dyaw -= Math.PI * 2; + if (dyaw < -Math.PI) dyaw += Math.PI * 2; + const t = { dx: now.x - d.local.x, dz: now.z - d.local.z, dyaw, factor: Math.max(0.01, factor), pivot: d.pivot }; + return d.data.sets.map((set) => { + const ids = sel?.picks.get(set.id); + if (!ids?.size) return set; + return { ...set, list: transformGroup(set.list, ids, t) }; + }); +} + +/** Scene.svelte's gizmo `onchange` while the proxy is attached: preview the move. */ +export function onScatterProxyMoved() { + const object = sel ? objectOf(sel.uuid) : null; + if (!object || !drag || !sel) return; + const sets = transformed(object, drag); + preview(sel.uuid, sets, drag.data.bounds); + drawMarks(); + const now = performance.now(); + if (now - dragPreviewAt > PREVIEW_MS) { + dragPreviewAt = now; + sendScatter(sel.uuid); + } +} + +// ---- selection ops (the toolbox; each ONE undo entry) ---------------------------------- + +/** @param {(list: Instance[], ids: Set) => Instance[]} op @returns {boolean} */ +function onSelection(op) { + const object = sel ? objectOf(sel.uuid) : null; + if (!object || !sel || !editable(object)) return false; + const data = readScatter(object); + if (!data) return false; + const before = cloneBlock(object.userData.terrainScatter ?? null); + const sets = data.sets.map((set) => { + const ids = sel?.picks.get(set.id); + return ids?.size ? { ...set, list: op(set.list, new Set([...ids].filter((i) => i < set.list.length))) } : set; + }); + const changed = commit(sel.uuid, before, sets, data.bounds); + refreshSelection(); + return changed; +} + +/** Turn every selected instance by `degrees` about its own up axis. @param {number} degrees */ +export function rotateSelectedInstances(degrees) { + return onSelection((list, ids) => rotateEach(list, ids, (degrees * Math.PI) / 180)); +} + +/** @param {number} factor */ +export function scaleSelectedInstances(factor) { + return onSelection((list, ids) => scaleEach(list, ids, factor)); +} + +/** New yaw + scale for the selection from the brush's own settings; everything else stays. */ +export function rerollSelectedInstances() { + const rng = rng32((Math.random() * 2 ** 32) >>> 0); + const o = { scaleMin: get(scatterScaleMin), scaleMax: get(scatterScaleMax), randomRotation: get(scatterRandomRotation) }; + return onSelection((list, ids) => rerollSelected(list, ids, rng, o)); +} + +export function deleteSelectedInstances() { + const object = sel ? objectOf(sel.uuid) : null; + if (!object || !sel || !editable(object)) return 0; + const data = readScatter(object); + if (!data) return 0; + const before = cloneBlock(object.userData.terrainScatter ?? null); + let removed = 0; + const sets = data.sets.map((set) => { + const ids = sel?.picks.get(set.id); + if (!ids?.size) return set; + const { list } = deleteSelected(set.list, ids); + removed += set.list.length - list.length; + return { ...set, list }; + }); + commit(sel.uuid, before, sets, data.bounds); + sel = null; + refreshSelection(); + return removed; +} + +/** The selected instances as [{set, list (all), ids}] — what convert-to-objects reads. */ +export function selectedInstanceGroups() { + const object = sel ? objectOf(sel.uuid) : null; + const data = object ? readScatter(object) : null; + return data ? { uuid: /** @type {string} */ (sel?.uuid), data, items: picksIn(data) } : null; +} + +// ---- the session ------------------------------------------------------------------------ + +/** @param {KeyboardEvent} event */ +function onKeydown(event) { + if (!scatterSelectArmed()) return; + if (!viewportHasKeys(event)) return; // a key in another pane is that pane's (36 U11) + const consume = () => { + event.preventDefault(); + event.stopImmediatePropagation(); + }; + if ((event.key === 'Delete' || event.key === 'Backspace') && selectionTotal()) { + consume(); + deleteSelectedInstances(); + } else if (event.key === 'Escape' && selectionTotal()) { + // first Escape drops the selection, the next one leaves the Sculpt session + consume(); + clearScatterSelection(); + } else if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'a') { + const uuid = sculptUuid(); + if (!uuid) return; + consume(); + selectAllInstances(uuid); + } +} + +/** @type {() => string | null} */ +let sculptUuid = () => null; +/** @type {() => boolean} */ +let scatterOpOn = () => false; +let listening = false; + +/** terrainSculpt hands over the session (its own stores, without an import cycle). + * @param {() => string | null} uuidOf the terrain being sculpted @param {() => boolean} scatterOn the Scatter brush is armed */ +export function startScatterEditSession(uuidOf, scatterOn) { + sculptUuid = uuidOf; + scatterOpOn = () => !!uuidOf() && scatterOn(); + if (!listening && typeof window !== 'undefined') { + window.addEventListener('keydown', onKeydown, true); + listening = true; + } +} + +/** Leave: commit a stroke in flight, drop the selection, the markers and the proxy. */ +export function endScatterEditSession() { + if (stroke) endEditStroke(); + sel = null; + press = null; + drag = null; + scatterSelectionCount.set(0); + unsubRevision?.(); + unsubRevision = null; + if (marks) marks.visible = false; + /** @type {any} */ + const controls = get(TControls); + if (proxy) { + if (controls?.object === proxy) controls.detach(); + proxy.parent?.remove(proxy); + if (controls) controls.showX = controls.showY = controls.showZ = true; + } + if (listening && typeof window !== 'undefined') { + window.removeEventListener('keydown', onKeydown, true); + listening = false; + } + sculptUuid = () => null; + scatterOpOn = () => false; +} + +/** Arm a tool. Leaving Select drops its selection (the gizmo belongs to it). @param {ScatterTool} tool */ +export function setScatterTool(tool) { + if (tool !== 'select' && sel) clearScatterSelection(); + scatterTool.set(tool); +} + +/** Debug / suite. */ +export function scatterEditState() { + return { + tool: get(scatterTool), + affects: get(scatterAffects), + selection: scatterSelection(), + count: selectionTotal(), + gizmo: !!(proxy && /** @type {any} */ (get(TControls))?.object === proxy), + stroke: !!stroke, + total: (() => { + const object = sel ? objectOf(sel.uuid) : null; + const data = object ? readScatter(object) : null; + return data ? totalOf(data.sets) : null; + })() + }; +} diff --git a/src/lib/scatterEditAdapter.js b/src/lib/scatterEditAdapter.js new file mode 100644 index 000000000..961ffd345 --- /dev/null +++ b/src/lib/scatterEditAdapter.js @@ -0,0 +1,138 @@ +// 51 E4/E5 — THE ONE PLACE 51-edit touches the scatter SET FORMAT (owner: 51-sources). +// +// Everything that edits instances (scatterEdit.js: picking, the gizmo, box select, the +// rotate/scale/relax/erase brushes, convert both ways) works on plain instance LISTS and +// reads and writes a terrain's scatter through the functions below (QUESTIONS-51-edit.md Q1): +// +// readScatter(object) -> {bounds, sets: [{id, source, align, collider, list, stored, base}]} +// blockOf(sets, bounds) -> the stored block (null = no scatter) +// instanceIdOf(set, index) -> the stable id an override is keyed by +// newSet(source, list, extra) -> a fresh set (convert-to-scatter) +// +// 51-union re-pointed it at 51-sources' format (params + seed + overrides, QUESTIONS-51-sources +// A1): a list is what the set DRAWS (terrainSurface.scatterSetInstances — overrides, masks and +// the shared cap applied), each entry carrying its string id ('g' a generated slot, 'p' a +// painted record). blockOf diffs each edited list against the one it was read as and writes the +// difference through `withOverrides` — a moved/turned/scaled slot becomes an override, a dropped +// one a `del`, a painted record is rewritten — so the seed, the fill params and every other set +// field ride through untouched. An entry with NO id (objects -> scatter) is a new painted record. +import { + normalizeScatter, + encodeInstances, + withOverrides, + blockVersion, + sourceKey, + MAX_INSTANCES, + MAX_SETS +} from './terrainScatterCore'; +import { base64ToBytes, bytesToBase64 } from './terrainPaintCore'; +import { terrainBounds, scatterSetInstances } from './terrainSurface'; +import { surfaceRecords } from './scatterSurfaceCore'; + +/** @typedef {import('./scatterEditCore').Instance & {id?: string, src?: number, tint?: number}} Instance */ +/** @typedef {{id: string, source: any, align: boolean, collider?: string, sources?: {source: any, w: number}[], + * sink?: number, list: Instance[], stored?: any, base?: Map}} EditSet */ + +export { MAX_INSTANCES, MAX_SETS, sourceKey }; + +/** A terrain's scatter as editable lists (null when it is not a terrain). @param {any} object + * @returns {{bounds: {minX: number, minZ: number, sizeX: number, sizeZ: number}, sets: EditSet[]} | null} */ +export function readScatter(object) { + if (!object?.userData?.terrain) return null; + const bounds = terrainBounds(object); + if (!bounds) return null; + const block = normalizeScatter(object.userData.terrainScatter, base64ToBytes); + const sets = (block?.sets ?? []).map((s) => { + /** @type {Instance[]} */ + const list = scatterSetInstances(object, s.id).map((/** @type {any} */ i) => ({ id: i.id, x: i.x, z: i.z, yaw: i.yaw, scale: i.scale, src: i.src ?? 0, tint: i.tint })); + return { + id: s.id, + source: s.source, + align: s.align, + ...(s.collider ? { collider: s.collider } : {}), + ...(s.sources ? { sources: s.sources } : {}), + ...(s.sink ? { sink: s.sink } : {}), + list, + stored: s, + base: new Map(list.map((i) => [/** @type {string} */ (i.id), i])) + }; + }); + return { bounds, sets }; +} + +const POSE = /** @type {const} */ (['x', 'z', 'yaw', 'scale']); + +/** One stored set written back from its edited list. @param {EditSet} s @param {any} bounds */ +function storedOf(s, bounds) { + const io = { bounds, decode: base64ToBytes, encode: bytesToBase64, records: surfaceRecords }; + if (!s.stored) { + // a NEW set (objects -> scatter): every instance a painted record + return { id: s.id, source: s.source, align: !!s.align, data: bytesToBase64(encodeInstances(s.list, bounds)), count: s.list.length, ...(s.collider && s.collider !== 'none' ? { collider: s.collider } : {}) }; + } + /** @type {Record} */ + const patch = {}; + const seen = new Set(); + /** @type {Instance[]} */ + const added = []; + for (const inst of s.list) { + const was = inst.id ? s.base?.get(inst.id) : null; + if (!was) { + added.push(inst); + continue; + } + seen.add(inst.id); + /** @type {Record} */ + const edit = {}; + for (const k of POSE) if (inst[k] !== was[k]) edit[k] = inst[k]; + if (Object.keys(edit).length) patch[/** @type {string} */ (inst.id)] = edit; + } + for (const id of s.base?.keys() ?? []) if (!seen.has(id)) patch[id] = { del: true }; + let next = Object.keys(patch).length ? withOverrides(s.stored, patch, io) : s.stored; + if (added.length) { + // objects -> scatter into an existing set: appended painted records (source 0 of a mix) + const old = next.count && next.data ? base64ToBytes(next.data) : null; + const bytes = encodeInstances(added, bounds); + const data = new Uint8Array((old?.length ?? 0) + bytes.length); + if (old) data.set(old); + data.set(bytes, old?.length ?? 0); + next = { ...next, data: bytesToBase64(data), count: next.count + added.length }; + if (next.pick) { + const pick = /** @type {Uint8Array} */ (base64ToBytes(next.pick)); + next.pick = bytesToBase64(new Uint8Array([...pick, ...new Array(added.length).fill(0)])); + } + } + return next; +} + +/** Lists back to the stored block. A set whose list was emptied is dropped (an erased-out model + * leaves no row); no sets at all = null, which clears the block. @param {EditSet[]} sets @param {any} bounds */ +export function blockOf(sets, bounds) { + const out = []; + for (const s of sets) { + if (!s.list.length) continue; + out.push(storedOf(s, bounds)); + } + return out.length ? { v: blockVersion(out), sets: out } : null; +} + +/** The id an instance's override is keyed by ('g' / 'p'). @param {EditSet} set @param {number} index */ +export function instanceIdOf(set, index) { + return set.list[index]?.id ?? null; +} + +/** The list index drawing instance `id` (what picking maps `mesh.userData.instanceIds` through). + * @param {EditSet} set @param {string} id */ +export function indexOfInstance(set, id) { + return set.list.findIndex((i) => i.id === id); +} + +/** A new set holding `list` (convert-to-scatter). @param {any} source @param {Instance[]} list + * @param {{align?: boolean, collider?: string}} [extra] @returns {EditSet} */ +export function newSet(source, list, extra = {}) { + return { id: sourceKey(source), source, align: !!extra.align, ...(extra.collider ? { collider: extra.collider } : {}), list }; +} + +/** Total instances across sets. @param {EditSet[]} sets */ +export function totalOf(sets) { + return sets.reduce((n, s) => n + s.list.length, 0); +} diff --git a/src/lib/scatterEditCore.js b/src/lib/scatterEditCore.js new file mode 100644 index 000000000..15984a5fb --- /dev/null +++ b/src/lib/scatterEditCore.js @@ -0,0 +1,313 @@ +// 51 E4/E5 — EDITING SCATTER INSTANCES, the pure half (imports NOTHING; tests/unit). +// +// Every function here takes an instance LIST ({x, z, yaw, scale} in the terrain's local +// frame — terrainScatterCore's Instance) and returns a NEW list; nothing is mutated in +// place, so a stroke can keep its stroke-start copy and undo is "put the old list back". +// Which instances an op touches is a Set of list INDICES: the adapter (scatterEditAdapter.js) +// owns how an index maps to the set format's stable instance id. +// +// Brushes are rate-based like the sculpt and paint brushes (strength × falloff × dt), with +// the same smoothstep falloff, so a slow stroke and a fast one end up in the same place +// for the same time held. Relax is deterministic (no random): each instance inside the +// circle moves away from neighbours closer than the local mean spacing, which evens a +// clump out without ever pushing anything past the brush edge. + +const TAU = Math.PI * 2; +export const EDIT_SCALE_MIN = 0.05; +export const EDIT_SCALE_MAX = 20; + +/** @typedef {{x: number, z: number, yaw: number, scale: number}} Instance */ +/** @typedef {{minX: number, minZ: number, sizeX: number, sizeZ: number}} Bounds */ + +/** @param {number} n */ +const clampScale = (n) => Math.min(EDIT_SCALE_MAX, Math.max(EDIT_SCALE_MIN, n)); +/** @param {number} a */ +export const wrapYaw = (a) => ((a % TAU) + TAU) % TAU; + +/** smoothstep falloff: 1 at the centre, 0 at the rim. @param {number} d distance @param {number} r radius */ +export function falloff(d, r) { + if (!(r > 0) || d >= r) return 0; + const t = 1 - d / r; + return t * t * (3 - 2 * t); +} + +/** @param {Instance} p @param {number} cx @param {number} cz */ +const dist = (p, cx, cz) => Math.hypot(p.x - cx, p.z - cz); + +// ---- selection ops ---------------------------------------------------------------------- + +/** Indices whose projected screen point lies in the rect (both corners inclusive, any order). + * @param {{sx: number, sy: number, visible: boolean}[]} projected one per instance, same order + * @param {{x0: number, y0: number, x1: number, y1: number}} rect + * @returns {Set} */ +export function indicesInRect(projected, rect) { + const x0 = Math.min(rect.x0, rect.x1); + const x1 = Math.max(rect.x0, rect.x1); + const y0 = Math.min(rect.y0, rect.y1); + const y1 = Math.max(rect.y0, rect.y1); + const out = new Set(); + projected.forEach((p, i) => { + if (p.visible && p.sx >= x0 && p.sx <= x1 && p.sy >= y0 && p.sy <= y1) out.add(i); + }); + return out; +} + +/** Turn each selected instance about its own up axis. @param {Instance[]} list + * @param {Set} ids @param {number} delta radians */ +export function rotateEach(list, ids, delta) { + return list.map((p, i) => (ids.has(i) ? { ...p, yaw: wrapYaw(p.yaw + delta) } : p)); +} + +/** Multiply each selected instance's scale. @param {Instance[]} list @param {Set} ids @param {number} factor */ +export function scaleEach(list, ids, factor) { + return list.map((p, i) => (ids.has(i) ? { ...p, scale: clampScale(p.scale * factor) } : p)); +} + +/** Set each selected instance's pose fields (the gizmo's absolute write). + * @param {Instance[]} list @param {Map>} poses */ +export function setPoses(list, poses) { + return list.map((p, i) => { + const q = poses.get(i); + if (!q) return p; + return { + ...p, // 51-union: an instance's id / source pick / tint ride along + x: Number.isFinite(q.x) ? /** @type {number} */ (q.x) : p.x, + z: Number.isFinite(q.z) ? /** @type {number} */ (q.z) : p.z, + yaw: Number.isFinite(q.yaw) ? wrapYaw(/** @type {number} */ (q.yaw)) : p.yaw, + scale: Number.isFinite(q.scale) ? clampScale(/** @type {number} */ (q.scale)) : p.scale + }; + }); +} + +/** Move the selection rigidly: a translation plus a turn of the whole group about `pivot` + * (each instance's own yaw turns with it) and a uniform scale about the pivot — what a + * gizmo on a multi-instance selection does. + * @param {Instance[]} list @param {Set} ids + * @param {{dx: number, dz: number, dyaw: number, factor: number, pivot: {x: number, z: number}}} t */ +export function transformGroup(list, ids, t) { + const c = Math.cos(t.dyaw); + const s = Math.sin(t.dyaw); + return list.map((p, i) => { + if (!ids.has(i)) return p; + // three's rotation.y turns +x toward -z: x' = x cos + z sin, z' = -x sin + z cos + const ox = (p.x - t.pivot.x) * t.factor; + const oz = (p.z - t.pivot.z) * t.factor; + return { + ...p, + x: t.pivot.x + ox * c + oz * s + t.dx, + z: t.pivot.z - ox * s + oz * c + t.dz, + yaw: wrapYaw(p.yaw + t.dyaw), + scale: clampScale(p.scale * t.factor) + }; + }); +} + +/** Drop the selected instances. Returns the kept list and old-index → new-index for the + * survivors (a selection held across the op is re-mapped through it). + * @param {Instance[]} list @param {Set} ids */ +export function deleteSelected(list, ids) { + /** @type {Instance[]} */ + const kept = []; + /** @type {Map} */ + const remap = new Map(); + list.forEach((p, i) => { + if (ids.has(i)) return; + remap.set(i, kept.length); + kept.push(p); + }); + return { list: kept, remap }; +} + +/** Re-roll the selected instances' look: a fresh yaw (when random rotation is on) and a + * fresh scale in the range. Positions and every UNselected instance are untouched — the + * user's "re-roll keeps the other hand edits". + * @param {Instance[]} list @param {Set} ids @param {() => number} rng + * @param {{scaleMin: number, scaleMax: number, randomRotation: boolean}} o */ +export function rerollSelected(list, ids, rng, o) { + const lo = Math.min(o.scaleMin, o.scaleMax); + const hi = Math.max(o.scaleMin, o.scaleMax); + return list.map((p, i) => { + if (!ids.has(i)) return p; + const yaw = o.randomRotation ? rng() * TAU : p.yaw; + return { ...p, yaw, scale: clampScale(lo + (hi - lo) * rng()) }; + }); +} + +/** The selection's centre (mean of positions) — the gizmo's seat. @param {Instance[]} list @param {Set} ids */ +export function centroid(list, ids) { + let x = 0; + let z = 0; + let n = 0; + for (const i of ids) { + const p = list[i]; + if (!p) continue; + x += p.x; + z += p.z; + n++; + } + return n ? { x: x / n, z: z / n } : null; +} + +// ---- brushes ---------------------------------------------------------------------------- + +/** Rotate brush: turns instances under the brush, fastest at the centre (half a turn per + * second at full strength). @param {Instance[]} list @param {number} cx @param {number} cz + * @param {number} radius @param {number} strength 0..1 @param {number} dt seconds @param {boolean} [reverse] + * @param {(i: number) => boolean} [only] which instances the brush may touch */ +export function rotateBrush(list, cx, cz, radius, strength, dt, reverse = false, only) { + const rate = Math.PI * Math.max(0, Math.min(1, strength)) * dt * (reverse ? -1 : 1); + let changed = 0; + const out = list.map((p, i) => { + if (only && !only(i)) return p; + const w = falloff(dist(p, cx, cz), radius); + if (!w) return p; + changed++; + return { ...p, yaw: wrapYaw(p.yaw + rate * w) }; + }); + return { list: out, changed }; +} + +/** Scale brush: grows (or with `reverse` shrinks) instances under the brush — a factor of + * e^(strength·dt) per second at the centre, so it is symmetric: grow then shrink for the + * same time held lands back where it started. Same shape as rotateBrush. */ +export function scaleBrush( + /** @type {Instance[]} */ list, + /** @type {number} */ cx, + /** @type {number} */ cz, + /** @type {number} */ radius, + /** @type {number} */ strength, + /** @type {number} */ dt, + reverse = false, + /** @type {((i: number) => boolean) | undefined} */ only = undefined +) { + const k = Math.max(0, Math.min(1, strength)) * dt * (reverse ? -1 : 1); + let changed = 0; + const out = list.map((p, i) => { + if (only && !only(i)) return p; + const w = falloff(dist(p, cx, cz), radius); + if (!w) return p; + const scale = clampScale(p.scale * Math.exp(k * w)); + if (scale === p.scale) return p; + changed++; + return { ...p, scale }; + }); + return { list: out, changed }; +} + +/** + * Relax brush: evens the spacing under the brush. The target gap is the mean nearest- + * neighbour distance of the instances inside the circle; each instance there is pushed + * away from every neighbour nearer than that gap, weighted by the falloff and capped so + * nothing jumps more than a quarter gap per step or leaves the brush or the terrain. + * Neighbours OUTSIDE the circle push but are never moved. Deterministic. + * @param {Instance[]} list @param {number} cx @param {number} cz @param {number} radius + * @param {number} strength 0..1 @param {number} dt @param {Bounds} bounds + * @param {(i: number) => boolean} [only] + */ +export function relaxBrush(list, cx, cz, radius, strength, dt, bounds, only) { + const inside = []; + const near = []; + for (let i = 0; i < list.length; i++) { + if (only && !only(i)) continue; + const d = dist(list[i], cx, cz); + if (d < radius) inside.push(i); + if (d < radius * 1.5) near.push(i); + } + if (inside.length < 2) return { list, changed: 0 }; + // the target gap: the mean nearest-neighbour distance among the instances inside + let sum = 0; + for (const i of inside) { + let best = Infinity; + for (const j of near) if (j !== i) best = Math.min(best, Math.hypot(list[i].x - list[j].x, list[i].z - list[j].z)); + sum += Number.isFinite(best) ? best : 0; + } + // a floor so a clump stacked on one spot (mean gap 0) still has somewhere to spread to + const gap = Math.max(radius * 0.05, (sum / inside.length) * 1.25); + const rate = Math.max(0, Math.min(1, strength)) * dt * 4; + const out = list.slice(); + let changed = 0; + for (const i of inside) { + const p = list[i]; + let fx = 0; + let fz = 0; + for (const j of near) { + if (j === i) continue; + const q = list[j]; + const dx = p.x - q.x; + const dz = p.z - q.z; + const d = Math.hypot(dx, dz); + if (d >= gap) continue; + if (d < 1e-6) { + // coincident: a fixed direction per PAIR, opposite for its two members, so two + // stacked rocks separate the same way on every peer + const a = ((Math.min(i, j) * 2654435761 + Math.max(i, j)) % 360) * (Math.PI / 180); + const sign = i < j ? 1 : -1; + fx += Math.cos(a) * sign; + fz += Math.sin(a) * sign; + continue; + } + const push = (gap - d) / gap; + fx += (dx / d) * push; + fz += (dz / d) * push; + } + const w = falloff(dist(p, cx, cz), radius); + let mx = fx * gap * rate * w; + let mz = fz * gap * rate * w; + const m = Math.hypot(mx, mz); + const cap = gap * 0.25; + if (m > cap) { + mx *= cap / m; + mz *= cap / m; + } + if (Math.hypot(mx, mz) < 1e-6) continue; + let x = p.x + mx; + let z = p.z + mz; + // never out of the brush (a relax must not leak instances into an area you left + // alone) and never off the terrain + const r = Math.hypot(x - cx, z - cz); + if (r > radius) { + x = cx + ((x - cx) / r) * radius; + z = cz + ((z - cz) / r) * radius; + } + x = Math.min(bounds.minX + bounds.sizeX, Math.max(bounds.minX, x)); + z = Math.min(bounds.minZ + bounds.sizeZ, Math.max(bounds.minZ, z)); + out[i] = { ...p, x, z }; + changed++; + } + return { list: out, changed }; +} + +/** Erase brush with a filter: drops instances inside the circle that `only` admits (all of + * them when it is absent). @param {Instance[]} list @param {number} cx @param {number} cz + * @param {number} radius @param {(i: number) => boolean} [only] */ +export function eraseBrush(list, cx, cz, radius, only) { + const out = list.filter((p, i) => dist(p, cx, cz) > radius || (only ? !only(i) : false)); + return { list: out, changed: list.length - out.length }; +} + +// ---- E5 helpers ------------------------------------------------------------------------- + +/** How many instances an "instances → objects" conversion may make without asking. */ +export const CONVERT_CONFIRM_ABOVE = 500; + +/** Yaw (rotation about +y) of a quaternion, the way three's rotation.y reads it for an + * upright object — what a converted object's turn becomes as an instance. + * @param {{x: number, y: number, z: number, w: number}} q */ +export function yawOfQuaternion(q) { + // the forward (+z) axis rotated by q, projected on the ground plane + const fx = 2 * (q.x * q.z + q.w * q.y); + const fz = 1 - 2 * (q.x * q.x + q.y * q.y); + return wrapYaw(Math.atan2(fx, fz)); +} + +/** Can a group of objects become one scatter set? They must share one source key + * (`keyOf` answers '' for something that cannot be a source). + * @param {any[]} objects @param {(o: any) => string} keyOf + * @returns {{ok: true, key: string} | {ok: false, reason: string}} */ +export function commonSource(objects, keyOf) { + if (objects.length < 2) return { ok: false, reason: 'Select at least two copies of one object' }; + const keys = new Set(objects.map(keyOf)); + if (keys.has('')) return { ok: false, reason: 'Only primitives and Library models can become scatter' }; + if (keys.size > 1) return { ok: false, reason: 'The selection holds different objects — select copies of ONE object' }; + return { ok: true, key: /** @type {string} */ ([...keys][0]) }; +} diff --git a/src/lib/scatterSources.js b/src/lib/scatterSources.js new file mode 100644 index 000000000..faad78abf --- /dev/null +++ b/src/lib/scatterSources.js @@ -0,0 +1,217 @@ +// @ts-ignore - no bundled three type declarations (project-wide) +import * as THREE from 'three'; +import { get, writable } from 'svelte/store'; +import { objectsGroup, selectedObjects } from '../stores/sceneStore'; +import { flowGraphs } from '../stores/flowStore'; +import { showToast } from '../stores/appStore'; +import { SCATTER_PRIMITIVES, sourceKey, sourceOf } from './terrainScatterCore'; + +// 51 E1 — WHAT A SCATTER SET CAN DRAW, as ONE catalog the brush toolbar, the Inspector's Scatter +// section and the object menu share: the primitives, Library models, PACK ITEMS (a kit's rock, by +// the pack path every peer fetches from the pack CDN — nothing travels but the path), PREFABS and +// "USE SELECTED OBJECT". +// +// A prefab or a scene object scatters as its LOOK ONLY (the user's ruling, 2026-10-21): model + +// materials, never its flow graph, physics, behaviour or scripts — an instance is a draw, not an +// object, so there is nothing for logic to run on. The look is exported to a GLB and filed in the +// Library under its content hash (the same path a Library model takes, so peers pull the bytes by +// hash and a reload finds them), with a NOTE the source row shows. "Convert to object" (51-edit) +// is the way back to one real copy with its logic. A placed pack piece that is still pristine +// scatters as the PACK ITEM itself — no byte copy at all. + +/** @typedef {{value: string, name: string}} Choice */ + +/** pack path -> {pack, item}: what a 'k:' choice resolves to (filled by `packChoices`) */ +/** @type {Map} */ +const packNames = new Map(); + +/** The pack items every peer can fetch: each installed CDN pack's list (cached by packs.js). + * Imported (zip) packs are Library items already and list there. @returns {Promise} */ +export async function packChoices() { + const packs = await import('./packs'); + const refs = await import('./packRefs'); + /** @type {Choice[]} */ + const out = []; + for (const pack of /** @type {any[]} */ (get(packs.packs))) { + if (pack?.source === 'imported' || !pack?.listUrl) continue; + let items = []; + try { + items = await packs.listPackItems(pack); + } catch { + continue; + } + for (const item of items) { + if (item?.behavior) continue; // a door or a lid scatters badly and loses what it is + const ref = item?.glbUrl ? refs.packRefFromUrl(item.glbUrl, { pack: pack.name, item: item.name }) : null; + if (!ref) continue; + packNames.set(ref.path, { pack: ref.pack, item: ref.item }); + out.push({ value: 'k:' + ref.path, name: (pack.title || pack.name) + ' ▸ ' + (item.label || item.name) }); + } + } + return out; +} + +/** pack choices, loaded once per session on first use (a list of lists over the CDN) */ +export const packChoiceList = writable(/** @type {Choice[]} */ ([])); +let packsAsked = false; +export function loadPackChoices() { + if (packsAsked) return; + packsAsked = true; + packChoices() + .then((list) => packChoiceList.set(list)) + .catch(() => (packsAsked = false)); +} + +/** + * The whole catalog as select items. `current` (a source already in use) is listed even when + * nothing else names it (a pack item from a peer's scene, a look exported on another machine). + * @param {{library?: any[], prefabs?: any[], packs?: Choice[], current?: any[]}} lists + * @returns {Choice[]} + */ +export function sourceChoices(lists) { + /** @type {Choice[]} */ + const out = SCATTER_PRIMITIVES.map((type) => ({ value: 'p:' + type, name: type })); + const seen = new Set(out.map((c) => c.value)); + const add = (/** @type {Choice} */ c) => { + if (!seen.has(c.value)) (seen.add(c.value), out.push(c)); + }; + for (const item of lists.library ?? []) if (item?.kind === 'object' && item.hash) add({ value: 'l:' + item.hash, name: item.name }); + for (const c of lists.packs ?? []) add(c); + for (const p of lists.prefabs ?? []) if (p?.id) add({ value: 'f:' + p.id, name: 'Prefab ▸ ' + (p.name || 'Prefab') + ' (look only)' }); + for (const s of lists.current ?? []) { + const key = sourceKey(s); + if (key) add({ value: key, name: sourceLabel(s) }); + } + return out; +} + +/** What a source row says. @param {any} s */ +export function sourceLabel(s) { + if (!s) return ''; + if (s.kind === 'primitive') return s.type; + if (s.kind === 'pack') return (s.pack ? s.pack + ' ▸ ' : '') + (s.item || s.path.split('/').pop()); + return s.name || 'Library model'; +} + +/** + * A choice value -> a source (async: a prefab is exported first). Null when it cannot be made. + * @param {string} value @param {any[]} [library] the Explorer items (for a Library model's name) + */ +export async function resolveSourceChoice(value, library = []) { + if (value.startsWith('p:')) return sourceOf({ kind: 'primitive', type: value.slice(2) }); + if (value.startsWith('l:')) { + const hash = value.slice(2); + const item = library.find((i) => i?.hash === hash); + return sourceOf({ kind: 'library', hash, name: item?.name ?? '' }); + } + if (value.startsWith('k:')) { + const path = value.slice(2); + const names = packNames.get(path) ?? { pack: '', item: (path.split('/').pop() ?? '').replace(/\.\w+$/, '') }; + return sourceOf({ kind: 'pack', path, ...names }); + } + if (value.startsWith('f:')) return sourceFromPrefab(value.slice(2)); + return null; +} + +/** Does this subtree carry LOGIC that a look-only scatter leaves behind? @param {any} root */ +export function carriesLogic(root) { + const graphs = /** @type {any} */ (get(flowGraphs)); + let logic = false; + root?.traverse?.((/** @type {any} */ n) => { + const ud = n.userData ?? {}; + if (graphs?.[n.uuid]?.nodes?.length || ud.physics || ud.behavior || ud.packRef?.behavior || ud.charControl || ud.camera) logic = true; + }); + return logic; +} + +export const LOOK_ONLY = 'Look only: model + materials (its logic is not scattered)'; + +/** + * A subtree's LOOK as a Library model: a clone with every userData dropped, standing with its + * lowest point at y 0 (an instance stands on the ground the way a primitive does), its own scale + * kept, its place and turn not — then a GLB, the Library, and a push to the peers. + * @param {any} root @param {string} name @param {string} note @param {{prefab?: string}} [extra] + * @returns {Promise} a library source + */ +export async function lookSource(root, name, note, extra = {}) { + const clone = root.clone(true); + clone.traverse((/** @type {any} */ n) => { + n.userData = {}; + }); + clone.position.set(0, 0, 0); + clone.quaternion.identity(); + clone.updateMatrixWorld(true); + const box = new THREE.Box3().setFromObject(clone); + if (box.isEmpty()) return null; + clone.position.set(-(box.min.x + box.max.x) / 2, -box.min.y, -(box.min.z + box.max.z) / 2); + const holder = new THREE.Group(); + holder.name = name; + holder.add(clone); + const { gltfBytesFor } = await import('./fileHandler.svelte.js'); + const bytes = await gltfBytesFor(holder, { binary: true }); + if (!bytes) return null; + const explorer = await import('./explorer'); + const file = String(name || 'Scatter model').replace(/[\\/:*?"<>|]+/g, ' ').slice(0, 80) + ' (scatter look).glb'; + const item = await explorer.addItemFromBytes(bytes, file, null, { kind: 'object' }); + if (!item?.hash) return null; + void import('./assetShare').then((m) => m.sendAsset(item.hash)).catch(() => {}); + return sourceOf({ kind: 'library', hash: item.hash, name: item.name ?? file, note, ...extra }); +} + +/** A prefab as a scatter source: its look only. @param {string} id */ +export async function sourceFromPrefab(id) { + const prefabs = await import('./prefabs'); + const entry = prefabs.prefabById(id); + const object = prefabs.prefabObject(id); + if (!entry || !object) { + showToast('That prefab is not in your library'); + return null; + } + return lookSource(object, entry.name || 'Prefab', 'Prefab "' + (entry.name || 'Prefab') + '" — ' + LOOK_ONLY.toLowerCase(), { prefab: id }); +} + +/** A scene object as a scatter source: a pristine pack piece as its pack item, anything else as + * its look (with the note when it carried logic). @param {string} uuid */ +export async function sourceFromObject(uuid) { + const object = /** @type {any} */ (get(objectsGroup))?.getObjectByProperty?.('uuid', uuid); + if (!object) return null; + if (object.userData?.terrain) { + showToast('A terrain cannot be scattered — pick the model you want to scatter on it'); + return null; + } + let hasMesh = false; + object.traverse((/** @type {any} */ n) => { + if (n.isMesh) hasMesh = true; + }); + if (!hasMesh) { + showToast('Only an object with a model can be scattered'); + return null; + } + try { + const refs = await import('./packRefs'); + const ref = refs.packRefOf(object); + if (ref && !object.userData?.behavior && refs.isPristinePackRef(object)) return sourceOf({ kind: 'pack', path: ref.path, pack: ref.pack, item: ref.item }); + } catch { + /* not a kit piece */ + } + const logic = carriesLogic(object); + return lookSource(object, object.name || 'Object', logic ? LOOK_ONLY : ''); +} + +/** + * Object menu / Inspector "Use selected object": the brush's source becomes this object's look. + * @param {string} [uuid] defaults to the selection (the set's first, never the sticky primary) + */ +export async function useAsScatterSource(uuid) { + const id = uuid ?? (/** @type {string[]} */ (get(selectedObjects)) ?? [])[0]; + if (!id) { + showToast('Select the object to scatter first'); + return null; + } + const source = await sourceFromObject(id); + if (!source) return null; + const paint = await import('./terrainPaint'); + paint.scatterSource.set(source); + showToast('Scatter source: ' + sourceLabel(source) + (source.note ? ' — ' + source.note : '') + '. Open a terrain’s Scatter brush to paint it.'); + return source; +} diff --git a/src/lib/scatterSurfaceCore.js b/src/lib/scatterSurfaceCore.js new file mode 100644 index 000000000..9c6e1f008 --- /dev/null +++ b/src/lib/scatterSurfaceCore.js @@ -0,0 +1,715 @@ +// 51-surface (E2 + E3) — SCATTER ON ANY SURFACE, the pure half (imports only the pure set format, +// terrainScatterCore, for its per-slot draws; vitest-covered). +// +// A terrain's scatter is (x, z) over its rectangle with the height read off its grid. A rock, +// a sphere or a house has neither, so a mesh host samples its own TRIANGLES: +// +// buildSurface(positions, index?, normals?) the host's local-space triangles + an area CDF +// placeOnSurface(S, u, v) two uniform draws -> a point + normal, uniform BY AREA +// (u picks the triangle, u's remainder + v pick the +// barycentric, so a slot's u/v mean the same thing as +// a terrain slot's u/v: where in the whole surface) +// generateSurfaceSlots(gen, S, opts) FILL: count or density over the whole surface, +// masks, minimum spacing — deterministic from the +// slot draws (the seed lives with the caller) +// surfaceDab(existing, S, index, o) the BRUSH: fill a sphere around the hit up to the +// density, honouring what is already there +// encode/decodeSurfaceRecords a painted instance as 16 bytes +// +// EVERYTHING IS IN THE HOST'S LOCAL FRAME — positions, normals, the height mask, the slope mask +// ("up" = local +Y). That is what "instances are stored relative to the host" means: move, turn or +// scale the object and they ride with it (the render holder copies its matrixWorld), and nothing a +// transform does can reshuffle a fill, because the masks never saw the transform. +// +// SPACING is Poisson-disk by DART THROWING in slot order: a candidate closer than `spacing` (3D) to +// an instance already accepted is rejected. Slot order is fixed by the draws, so the result is the +// same on every peer and after every reload. + +import { slotDraws, slotSeed, pickSource, ATTEMPTS_PER_SLOT } from './terrainScatterCore.js'; + +const TAU = Math.PI * 2; +const Q = 65535; +const DEG = 180 / Math.PI; + +/** caps the core enforces whatever the caller asks (the terrain core's numbers) */ +export const SURFACE_MAX_INSTANCES = 8000; +/** COUNT mode keeps drawing slots until `count` are placed, up to this many attempts per instance */ +export const ATTEMPTS_PER_INSTANCE = ATTEMPTS_PER_SLOT; +export const SLOPE_MAX = 180; +export const SPACING_MAX = 50; +/** bytes per painted instance on a mesh host */ +export const SURFACE_RECORD = 16; + +/** + * @typedef {{tris: number, v: Float32Array, n: Float32Array, vn: Float32Array | null, + * cdf: Float64Array, area: number, min: number[], max: number[]}} Surface + * @typedef {{x: number, y: number, z: number, nx: number, ny: number, nz: number}} SurfacePoint + * @typedef {{id?: string, slot?: number, x: number, y: number, z: number, nx: number, ny: number, nz: number, + * yaw: number, scale: number, src?: number, tint?: number}} SurfaceInstance + * @typedef {{slope?: number[], height?: number[], layer?: {i: number, min: number}}} Mask + * @typedef {{u: number, v: number, yaw: number, scale: number, pick?: number, tint?: number}} Draws + */ + +// ---- the surface ------------------------------------------------------------------------- + +/** + * The host's triangles as flat arrays, an area CDF for area-uniform sampling and the local + * bounds. Degenerate triangles get zero width in the CDF, so they are never drawn. + * @param {ArrayLike} pos xyz triples (local space) + * @param {ArrayLike | null} [index] triangle vertex indices (null = a triangle soup) + * @param {ArrayLike | null} [normals] per-vertex normals (smooth normals are interpolated; + * absent = each triangle's own face normal) + * @returns {Surface | null} null for a surface with no area + */ +export function buildSurface(pos, index = null, normals = null) { + const vertCount = Math.floor(pos.length / 3); + const tris = Math.floor((index ? index.length : vertCount) / 3); + if (!tris) return null; + const v = new Float32Array(tris * 9); + const n = new Float32Array(tris * 3); + const vn = normals && normals.length >= vertCount * 3 ? new Float32Array(tris * 9) : null; + const cdf = new Float64Array(tris); + const min = [Infinity, Infinity, Infinity]; + const max = [-Infinity, -Infinity, -Infinity]; + let area = 0; + for (let t = 0; t < tris; t++) { + for (let c = 0; c < 3; c++) { + const i = index ? index[t * 3 + c] : t * 3 + c; + for (let a = 0; a < 3; a++) { + const value = pos[i * 3 + a]; + v[t * 9 + c * 3 + a] = value; + if (value < min[a]) min[a] = value; + if (value > max[a]) max[a] = value; + if (vn && normals) vn[t * 9 + c * 3 + a] = normals[i * 3 + a]; + } + } + const o = t * 9; + const e1x = v[o + 3] - v[o], e1y = v[o + 4] - v[o + 1], e1z = v[o + 5] - v[o + 2]; + const e2x = v[o + 6] - v[o], e2y = v[o + 7] - v[o + 1], e2z = v[o + 8] - v[o + 2]; + const cx = e1y * e2z - e1z * e2y; + const cy = e1z * e2x - e1x * e2z; + const cz = e1x * e2y - e1y * e2x; + const len = Math.hypot(cx, cy, cz); + const a = Number.isFinite(len) ? len / 2 : 0; + if (len > 0) { + n[t * 3] = cx / len; + n[t * 3 + 1] = cy / len; + n[t * 3 + 2] = cz / len; + } else n[t * 3 + 1] = 1; + area += a; + cdf[t] = area; + } + if (!(area > 0)) return null; + return { tris, v, n, vn, cdf, area, min, max }; +} + +/** The triangle whose CDF interval holds `at` (binary search). @param {Float64Array} cdf @param {number} at */ +function triangleAt(cdf, at) { + let lo = 0; + let hi = cdf.length - 1; + while (lo < hi) { + const mid = (lo + hi) >> 1; + if (cdf[mid] <= at) lo = mid + 1; + else hi = mid; + } + return lo; +} + +/** + * A point on triangle `t` from its barycentric draws (r1, r2 uniform in [0, 1)) — uniform over the + * triangle (the square-root warp). @param {Surface} S @param {number} t @param {number} r1 @param {number} r2 + * @returns {SurfacePoint & {tri: number}} + */ +export function pointOnTriangle(S, t, r1, r2) { + const s = Math.sqrt(Math.min(1, Math.max(0, r1))); + const b0 = 1 - s; + const b1 = s * (1 - r2); + const b2 = s * r2; + const o = t * 9; + const v = S.v; + let nx = S.n[t * 3]; + let ny = S.n[t * 3 + 1]; + let nz = S.n[t * 3 + 2]; + if (S.vn) { + const w = S.vn; + const sx = w[o] * b0 + w[o + 3] * b1 + w[o + 6] * b2; + const sy = w[o + 1] * b0 + w[o + 4] * b1 + w[o + 7] * b2; + const sz = w[o + 2] * b0 + w[o + 5] * b1 + w[o + 8] * b2; + const len = Math.hypot(sx, sy, sz); + if (len > 1e-6) { + nx = sx / len; + ny = sy / len; + nz = sz / len; + } + } + return { + x: v[o] * b0 + v[o + 3] * b1 + v[o + 6] * b2, + y: v[o + 1] * b0 + v[o + 4] * b1 + v[o + 7] * b2, + z: v[o + 2] * b0 + v[o + 5] * b1 + v[o + 8] * b2, + nx, + ny, + nz, + tri: t + }; +} + +/** + * Two uniform draws -> a point uniform over the whole surface BY AREA. `u` picks the triangle + * through the CDF and its REMAINDER inside that triangle's interval is again uniform, so it serves + * as the first barycentric draw; `v` is the second. @param {Surface} S @param {number} u @param {number} v + */ +export function placeOnSurface(S, u, v) { + const at = Math.min(Math.max(u, 0), 1 - 1e-12) * S.area; + const t = triangleAt(S.cdf, at); + const start = t ? S.cdf[t - 1] : 0; + const width = S.cdf[t] - start; + const r1 = width > 0 ? (at - start) / width : 0.5; + return pointOnTriangle(S, t, r1, v); +} + +// ---- masks ------------------------------------------------------------------------------- + +/** @param {any} v @param {number} lo @param {number} hi */ +function clampNum(v, lo, hi) { + const n = Number(v); + return Number.isFinite(n) ? Math.min(hi, Math.max(lo, n)) : NaN; +} + +/** @param {any} r @param {number} lo @param {number} hi @returns {number[] | null} */ +function rangeOf(r, lo, hi) { + if (!Array.isArray(r) || r.length < 2) return null; + const a = clampNum(r[0], lo, hi); + const b = clampNum(r[1], lo, hi); + if (Number.isNaN(a) || Number.isNaN(b)) return null; + return a <= b ? [a, b] : [b, a]; +} + +/** + * The masks a set's generator carries (`gen.slope`, `gen.height`, `gen.layer`), validated. A mask + * that lets everything through is DROPPED, so a default set writes no mask field at all. + * slope = degrees between the surface normal and local up (0 flat, 90 a wall, 180 an underside); + * height = local Y, metres; layer = a terrain paint layer 0..3 and the weight it must reach. + * @param {any} gen @returns {Mask} + */ +export function maskOf(gen) { + /** @type {Mask} */ + const out = {}; + const slope = rangeOf(gen?.slope, 0, SLOPE_MAX); + if (slope && (slope[0] > 0 || slope[1] < SLOPE_MAX)) out.slope = slope; + const height = rangeOf(gen?.height, -1e5, 1e5); + if (height) out.height = height; + const layer = gen?.layer; + if (layer && typeof layer === 'object') { + const i = Math.round(Number(layer.i)); + const min = clampNum(layer.min ?? 0.5, 0, 1); + if (i >= 0 && i <= 3 && !Number.isNaN(min)) out.layer = { i, min }; + } + return out; +} + +/** @param {Mask} mask */ +export function maskIsEmpty(mask) { + return !mask.slope && !mask.height && !mask.layer; +} + +/** Slope of a normal against local up, degrees. @param {number} ny the normal's Y (unit normal) */ +export function slopeDeg(ny) { + return Math.acos(Math.min(1, Math.max(-1, ny))) * DEG; +} + +/** + * Does a point pass the mask? `layerWeight(i)` reads the terrain paint weight of layer i at this + * point (0..1); a host without paint has none, so a layer mask rejects everything there — which is + * what the user asked for ("only on layer 2") on a surface that has no layer 2. + * @param {Mask} mask @param {{y: number, ny: number}} p @param {((i: number) => number) | null} [layerWeight] + */ +export function passesMask(mask, p, layerWeight = null) { + if (mask.slope) { + const s = slopeDeg(p.ny); + if (s < mask.slope[0] - 1e-9 || s > mask.slope[1] + 1e-9) return false; + } + if (mask.height && (p.y < mask.height[0] || p.y > mask.height[1])) return false; + if (mask.layer) { + const w = layerWeight ? layerWeight(mask.layer.i) : 0; + if (!(w >= mask.layer.min)) return false; + } + return true; +} + +/** + * Splat weight of one layer at a local (x, z): the RGBA map the paint brush writes, sampled + * bilinearly and normalized the way the shader blends it. @param {Uint8Array} bytes RGBA + * @param {number} res @param {{minX: number, minZ: number, sizeX: number, sizeZ: number}} b + * @param {number} x @param {number} z @param {number} i layer 0..3 + */ +export function splatWeightAt(bytes, res, b, x, z, i) { + const fx = Math.min(res - 1, Math.max(0, ((x - b.minX) / b.sizeX) * res - 0.5)); + const fz = Math.min(res - 1, Math.max(0, ((z - b.minZ) / b.sizeZ) * res - 0.5)); + const x0 = Math.floor(fx); + const z0 = Math.floor(fz); + const x1 = Math.min(res - 1, x0 + 1); + const z1 = Math.min(res - 1, z0 + 1); + const tx = fx - x0; + const tz = fz - z0; + const w = (/** @type {number} */ px, /** @type {number} */ pz) => { + const o = (pz * res + px) * 4; + const sum = bytes[o] + bytes[o + 1] + bytes[o + 2] + bytes[o + 3]; + return sum > 0 ? bytes[o + i] / sum : i === 0 ? 1 : 0; + }; + const top = w(x0, z0) * (1 - tx) + w(x1, z0) * tx; + const bottom = w(x0, z1) * (1 - tx) + w(x1, z1) * tx; + return top * (1 - tz) + bottom * tz; +} + +// ---- spacing (a 3D hash) ------------------------------------------------------------------ + +/** A spatial hash for the minimum-spacing test. 2D (y ignored) when `flat` — the terrain's + * spacing is judged on the map, the way the brush always judged it. @param {number} spacing @param {boolean} [flat] */ +export function spacingGrid(spacing, flat = false) { + // numeric cell keys (a string key per probe was 80 % of a spaced 5,000 fill); a collision only + // costs an extra distance test, never a wrong answer + /** @type {Map} xyz triples per cell */ + const cells = new Map(); + const s = spacing; + const s2 = s * s; + const key = (/** @type {number} */ i, /** @type {number} */ j, /** @type {number} */ k) => + (Math.imul(i, 73856093) ^ Math.imul(j, 19349663) ^ Math.imul(k, 83492791)) & 0x3fffffff; // a small int: V8 keeps it a Smi + return { + /** @param {{x: number, y?: number, z: number}} p */ + free(p) { + if (!(s > 0)) return true; + const py = flat ? 0 : p.y ?? 0; + const ci = Math.floor(p.x / s); + const cj = Math.floor(py / s); + const ck = Math.floor(p.z / s); + const j0 = flat ? 0 : -1; + const j1 = flat ? 0 : 1; + for (let i = -1; i <= 1; i++) + for (let j = j0; j <= j1; j++) + for (let k = -1; k <= 1; k++) { + const list = cells.get(key(ci + i, cj + j, ck + k)); + if (!list) continue; + for (let q = 0; q < list.length; q += 3) { + const dx = list[q] - p.x; + const dy = list[q + 1] - py; + const dz = list[q + 2] - p.z; + if (dx * dx + dy * dy + dz * dz < s2) return false; + } + } + return true; + }, + /** @param {{x: number, y?: number, z: number}} p */ + add(p) { + if (!(s > 0)) return; + const py = flat ? 0 : p.y ?? 0; + const k = key(Math.floor(p.x / s), Math.floor(py / s), Math.floor(p.z / s)); + const list = cells.get(k); + if (list) list.push(p.x, py, p.z); + else cells.set(k, [p.x, py, p.z]); + } + }; +} + +// ---- FILL --------------------------------------------------------------------------------- + +/** + * How many slots a fill draws, and when it stops. COUNT mode = keep drawing until `count` are + * PLACED (a mask or the spacing rejects some), up to ATTEMPTS_PER_INSTANCE × count attempts. + * DENSITY mode = density × area attempts: a mask that rejects a slot is exactly what keeps the + * density right where the mask passes. @param {any} gen @param {number} area @param {number} room + */ +export function fillBudget(gen, area, room = SURFACE_MAX_INSTANCES) { + const cap = Math.max(0, Math.min(room, SURFACE_MAX_INSTANCES)); + if (gen?.count != null) { + const want = Math.max(0, Math.min(cap, Math.floor(Number(gen.count) || 0))); + return { want, attempts: want * ATTEMPTS_PER_INSTANCE }; + } + const attempts = Math.max(0, Math.floor((Number(gen?.density) || 0) * area)); + return { want: Math.min(cap, attempts), attempts }; +} + +/** + * FILL a whole surface: one instance per slot that survives the mask and the spacing, slot k drawn + * from `draws(k)` (the caller's seeded, per-slot stream — the same draws a terrain slot uses). + * @param {any} gen {count | density, scaleMin, scaleMax, rot, spacing, slope, height, layer} + * @param {Surface} S + * @param {{draws: (k: number) => Draws, room?: number, layerWeight?: ((p: SurfacePoint, i: number) => number) | null, + * accept?: ((p: SurfacePoint, k: number) => boolean) | null}} opts + * @returns {SurfaceInstance[]} + */ +export function generateSurfaceSlots(gen, S, opts) { + if (!gen || !S) return []; + const { want, attempts } = fillBudget(gen, S.area, opts.room); + if (!want) return []; + const mask = maskOf(gen); + const spacing = Math.min(SPACING_MAX, Math.max(0, Number(gen.spacing) || 0)); + const grid = spacingGrid(spacing); + const lo = Math.min(Number(gen.scaleMin ?? 1), Number(gen.scaleMax ?? 1)); + const hi = Math.max(Number(gen.scaleMin ?? 1), Number(gen.scaleMax ?? 1)); + const rot = gen.rot !== false; + const lw = opts.layerWeight ?? null; + /** @type {SurfaceInstance[]} */ + const out = []; + for (let k = 0; k < attempts && out.length < want; k++) { + const d = opts.draws(k); + const p = placeOnSurface(S, d.u, d.v); + if (!passesMask(mask, p, lw ? (i) => lw(p, i) : null)) continue; + if (opts.accept && !opts.accept(p, k)) continue; + if (!grid.free(p)) continue; + grid.add(p); + out.push({ + id: 'g' + k, + slot: k, + x: p.x, + y: p.y, + z: p.z, + nx: p.nx, + ny: p.ny, + nz: p.nz, + yaw: rot ? d.yaw * TAU : 0, + scale: lo + (hi - lo) * d.scale, + ...(d.pick != null ? { pick: d.pick } : {}), + ...(d.tint != null ? { tint: d.tint * 2 - 1 } : {}) + }); + } + return out; +} + +// ---- the BRUSH on a surface --------------------------------------------------------------- + +/** + * A grid over the surface's triangles for brush queries: a triangle is listed in every cell its + * bounding box touches. @param {Surface} S @param {number} [cells] cells along the longest side + */ +export function triangleIndex(S, cells = 48) { + const size = Math.max(S.max[0] - S.min[0], S.max[1] - S.min[1], S.max[2] - S.min[2], 1e-6); + const cell = size / cells; + /** @type {Map} */ + const map = new Map(); + const at = (/** @type {number} */ value, /** @type {number} */ a) => Math.floor((value - S.min[a]) / cell); + for (let t = 0; t < S.tris; t++) { + const o = t * 9; + const lo = [0, 1, 2].map((a) => at(Math.min(S.v[o + a], S.v[o + 3 + a], S.v[o + 6 + a]), a)); + const hi = [0, 1, 2].map((a) => at(Math.max(S.v[o + a], S.v[o + 3 + a], S.v[o + 6 + a]), a)); + // a giant triangle (a ground plane) would list itself in thousands of cells: cap the spread + // and let it sit in its corner cells only — the query still tests the triangle itself + const spread = (hi[0] - lo[0] + 1) * (hi[1] - lo[1] + 1) * (hi[2] - lo[2] + 1); + const step = spread > 512 ? Math.ceil(Math.cbrt(spread / 512)) : 1; + for (let i = lo[0]; i <= hi[0]; i += step) + for (let j = lo[1]; j <= hi[1]; j += step) + for (let k = lo[2]; k <= hi[2]; k += step) { + const key = i + ',' + j + ',' + k; + const list = map.get(key); + if (list) list.push(t); + else map.set(key, [t]); + } + if (step > 1) { + const key = hi[0] + ',' + hi[1] + ',' + hi[2]; + const list = map.get(key); + if (list) list.push(t); + else map.set(key, [t]); + } + } + return { + cell, + /** triangles that may come within `r` of a point @param {number[]} c @param {number} r */ + near(c, r) { + /** @type {Set} */ + const found = new Set(); + const lo = [0, 1, 2].map((a) => at(c[a] - r, a)); + const hi = [0, 1, 2].map((a) => at(c[a] + r, a)); + const big = (hi[0] - lo[0] + 1) * (hi[1] - lo[1] + 1) * (hi[2] - lo[2] + 1) > 4096; + if (big) { + for (let t = 0; t < S.tris; t++) found.add(t); + return found; + } + for (let i = lo[0]; i <= hi[0]; i++) + for (let j = lo[1]; j <= hi[1]; j++) + for (let k = lo[2]; k <= hi[2]; k++) for (const t of map.get(i + ',' + j + ',' + k) ?? []) found.add(t); + return found; + } + }; +} + +/** Squared distance from a point to triangle t (Ericson's closest-point). @param {Surface} S @param {number} t @param {number[]} p */ +function distSqToTriangle(S, t, p) { + const o = t * 9; + const v = S.v; + const ax = v[o], ay = v[o + 1], az = v[o + 2]; + const abx = v[o + 3] - ax, aby = v[o + 4] - ay, abz = v[o + 5] - az; + const acx = v[o + 6] - ax, acy = v[o + 7] - ay, acz = v[o + 8] - az; + const apx = p[0] - ax, apy = p[1] - ay, apz = p[2] - az; + const d1 = abx * apx + aby * apy + abz * apz; + const d2 = acx * apx + acy * apy + acz * apz; + /** @param {number} x @param {number} y @param {number} z */ + const at = (x, y, z) => (p[0] - x) ** 2 + (p[1] - y) ** 2 + (p[2] - z) ** 2; + if (d1 <= 0 && d2 <= 0) return at(ax, ay, az); + const bpx = p[0] - v[o + 3], bpy = p[1] - v[o + 4], bpz = p[2] - v[o + 5]; + const d3 = abx * bpx + aby * bpy + abz * bpz; + const d4 = acx * bpx + acy * bpy + acz * bpz; + if (d3 >= 0 && d4 <= d3) return at(v[o + 3], v[o + 4], v[o + 5]); + const vc = d1 * d4 - d3 * d2; + if (vc <= 0 && d1 >= 0 && d3 <= 0) { + const w = d1 / (d1 - d3); + return at(ax + abx * w, ay + aby * w, az + abz * w); + } + const cpx = p[0] - v[o + 6], cpy = p[1] - v[o + 7], cpz = p[2] - v[o + 8]; + const d5 = abx * cpx + aby * cpy + abz * cpz; + const d6 = acx * cpx + acy * cpy + acz * cpz; + if (d6 >= 0 && d5 <= d6) return at(v[o + 6], v[o + 7], v[o + 8]); + const vb = d5 * d2 - d1 * d6; + if (vb <= 0 && d2 >= 0 && d6 <= 0) { + const w = d2 / (d2 - d6); + return at(ax + acx * w, ay + acy * w, az + acz * w); + } + const va = d3 * d6 - d5 * d4; + if (va <= 0 && d4 - d3 >= 0 && d5 - d6 >= 0) { + const w = (d4 - d3) / (d4 - d3 + (d5 - d6)); + const bx = v[o + 3], by = v[o + 4], bz = v[o + 5]; + return at(bx + (v[o + 6] - bx) * w, by + (v[o + 7] - by) * w, bz + (v[o + 8] - bz) * w); + } + const denom = 1 / (va + vb + vc); + const w2 = vb * denom; + const w3 = vc * denom; + return at(ax + abx * w2 + acx * w3, ay + aby * w2 + acy * w3, az + abz * w2 + acz * w3); +} + +/** + * One brush dab on a surface: add instances inside the SPHERE around the hit until it holds + * `density × πr²` (the brush disc — on a surface that is roughly flat inside the brush that is the + * patch's area, and it is the exact rule the terrain brush keeps), never more; what is already there + * counts. Spacing = the set's minimum spacing, else 0.7 of the mean gap (the terrain brush's rule). + * @param {SurfaceInstance[]} existing (read only) + * @param {Surface} S @param {ReturnType} index + * @param {{cx: number, cy: number, cz: number, radius: number, density: number, scaleMin: number, scaleMax: number, + * randomRotation: boolean, rng: () => number, room?: number, spacing?: number, mask?: Mask}} o + * @returns {SurfaceInstance[]} the NEW instances only + */ +export function surfaceDab(existing, S, index, o) { + const r = o.radius; + const density = Math.min(4, Math.max(0.01, o.density)); + const target = Math.floor(density * Math.PI * r * r); + const spacing = o.spacing && o.spacing > 0 ? o.spacing : 0.7 / Math.sqrt(density); + const c = [o.cx, o.cy, o.cz]; + const reach = r + spacing; + const d2 = (/** @type {{x: number, y: number, z: number}} */ p) => (p.x - c[0]) ** 2 + (p.y - c[1]) ** 2 + (p.z - c[2]) ** 2; + const near = existing.filter((p) => d2(p) <= reach * reach); + let inside = near.filter((p) => d2(p) <= r * r).length; + let want = Math.min(target - inside, o.room ?? Infinity); + /** @type {SurfaceInstance[]} */ + const added = []; + if (want <= 0) return added; + // candidate triangles: those that come within r of the centre, with their areas as a local CDF + const tris = [...index.near(c, r)].filter((t) => distSqToTriangle(S, t, c) <= r * r); + if (!tris.length) return added; + const cdf = new Float64Array(tris.length); + let sum = 0; + tris.forEach((t, k) => { + sum += S.cdf[t] - (t ? S.cdf[t - 1] : 0); + cdf[k] = sum; + }); + if (!(sum > 0)) return added; + const grid = spacingGrid(spacing); + for (const p of near) grid.add(p); + const lo = Math.min(o.scaleMin, o.scaleMax); + const hi = Math.max(o.scaleMin, o.scaleMax); + const mask = o.mask ?? {}; + let attempts = want * 12; + while (want > 0 && attempts-- > 0) { + const k = triangleAt(cdf, o.rng() * sum); + const p = pointOnTriangle(S, tris[k], o.rng(), o.rng()); + if (d2(p) > r * r) continue; + if (!passesMask(mask, p, null)) continue; + if (!grid.free(p)) continue; + grid.add(p); + added.push({ + x: p.x, + y: p.y, + z: p.z, + nx: p.nx, + ny: p.ny, + nz: p.nz, + yaw: o.randomRotation ? o.rng() * TAU : 0, + scale: Math.min(20, Math.max(0.05, lo + (hi - lo) * o.rng())) + }); + inside++; + want--; + } + return added; +} + +/** Remove every instance inside the sphere. @param {SurfaceInstance[]} list + * @param {number} cx @param {number} cy @param {number} cz @param {number} radius */ +export function eraseInSphere(list, cx, cy, cz, radius) { + const r2 = radius * radius; + return list.filter((p) => (p.x - cx) ** 2 + (p.y - cy) ** 2 + (p.z - cz) ** 2 > r2); +} + +// ---- the 16-byte record -------------------------------------------------------------------- + +/** @typedef {number[]} Box [minX, minY, minZ, sizeX, sizeY, sizeZ] */ + +/** A box that holds every instance and the surface (stored on the set, so a later mesh edit never + * re-scales the records). @param {SurfaceInstance[]} list @param {{min: number[], max: number[]} | null} [S] @returns {Box} */ +export function boxOf(list, S = null) { + const min = S ? [...S.min] : [Infinity, Infinity, Infinity]; + const max = S ? [...S.max] : [-Infinity, -Infinity, -Infinity]; + for (const p of list) { + const xyz = [p.x, p.y, p.z]; + for (let a = 0; a < 3; a++) { + if (xyz[a] < min[a]) min[a] = xyz[a]; + if (xyz[a] > max[a]) max[a] = xyz[a]; + } + } + for (let a = 0; a < 3; a++) if (!Number.isFinite(min[a])) (min[a] = 0), (max[a] = 0); + // a flat axis (a plane) still needs a size, or every record on it divides by zero + return [min[0], min[1], min[2], ...[0, 1, 2].map((a) => Math.max(max[a] - min[a], 1e-3))]; +} + +/** @param {any} box @returns {Box | null} */ +export function boxFrom(box) { + if (!Array.isArray(box) || box.length !== 6 || !box.every((n) => Number.isFinite(n))) return null; + if (!(box[3] > 0 && box[4] > 0 && box[5] > 0)) return null; + return box.map(Number); +} + +/** Octahedral normal encoding to two [0, 1] values. @param {number} x @param {number} y @param {number} z */ +function octEncode(x, y, z) { + const l1 = Math.abs(x) + Math.abs(y) + Math.abs(z) || 1; + let u = x / l1; + let v = z / l1; + if (y < 0) { + const ou = u; + u = (1 - Math.abs(v)) * (ou >= 0 ? 1 : -1); + v = (1 - Math.abs(ou)) * (v >= 0 ? 1 : -1); + } + return [(u + 1) / 2, (v + 1) / 2]; +} + +/** @param {number} a @param {number} b [0, 1] each @returns {number[]} */ +function octDecode(a, b) { + let x = a * 2 - 1; + let z = b * 2 - 1; + const y = 1 - Math.abs(x) - Math.abs(z); + if (y < 0) { + const ox = x; + x = (1 - Math.abs(z)) * (ox >= 0 ? 1 : -1); + z = (1 - Math.abs(ox)) * (z >= 0 ? 1 : -1); + } + const len = Math.hypot(x, y, z) || 1; + return [x / len, y / len, z / len]; +} + +const clampQ = (/** @type {number} */ n) => Math.max(0, Math.min(Q, Math.round(n))); + +/** + * A mesh host's painted instances as 16 bytes each: x, y, z (u16 across the box), the normal + * (octahedral, 2 × u16), yaw (u16 over a full turn), scale (u16, thousandths), one u16 reserved. + * 2,000 instances = 32 KB raw. @param {SurfaceInstance[]} list @param {Box} box @returns {Uint8Array} + */ +export function encodeSurfaceRecords(list, box) { + const out = new Uint8Array(list.length * SURFACE_RECORD); + const view = new DataView(out.buffer); + list.forEach((p, k) => { + const o = k * SURFACE_RECORD; + view.setUint16(o, clampQ(((p.x - box[0]) / box[3]) * Q), true); + view.setUint16(o + 2, clampQ(((p.y - box[1]) / box[4]) * Q), true); + view.setUint16(o + 4, clampQ(((p.z - box[2]) / box[5]) * Q), true); + const [a, b] = octEncode(p.nx, p.ny, p.nz); + view.setUint16(o + 6, clampQ(a * Q), true); + view.setUint16(o + 8, clampQ(b * Q), true); + const yaw = ((p.yaw % TAU) + TAU) % TAU; + view.setUint16(o + 10, clampQ((yaw / TAU) * Q), true); + view.setUint16(o + 12, clampQ(p.scale * 1000), true); + view.setUint16(o + 14, 0, true); + }); + return out; +} + +/** @param {Uint8Array} bytes @param {Box} box @returns {SurfaceInstance[]} */ +export function decodeSurfaceRecords(bytes, box) { + const n = Math.floor(bytes.length / SURFACE_RECORD); + const view = new DataView(bytes.buffer, bytes.byteOffset, n * SURFACE_RECORD); + /** @type {SurfaceInstance[]} */ + const out = new Array(n); + for (let k = 0; k < n; k++) { + const o = k * SURFACE_RECORD; + const [nx, ny, nz] = octDecode(view.getUint16(o + 6, true) / Q, view.getUint16(o + 8, true) / Q); + out[k] = { + x: box[0] + (view.getUint16(o, true) / Q) * box[3], + y: box[1] + (view.getUint16(o + 2, true) / Q) * box[4], + z: box[2] + (view.getUint16(o + 4, true) / Q) * box[5], + nx, + ny, + nz, + yaw: (view.getUint16(o + 10, true) / Q) * TAU, + scale: view.getUint16(o + 12, true) / 1000 + }; + } + return out; +} + +// ---- a mesh host's SET: its one instance list, its record codec ------------------------------ + +/** The 16-byte record codec a `host: 'mesh'` set's painted instances use (the `io.records` that + * terrainScatterCore.withOverrides takes). */ +export const surfaceRecords = { + /** @param {Uint8Array} bytes @param {any} set */ + decode(bytes, set) { + const box = boxFrom(set?.box); + return box ? decodeSurfaceRecords(bytes, box) : []; + }, + /** @param {SurfaceInstance[]} list */ + encode(list) { + const box = boxOf(list); + return { bytes: encodeSurfaceRecords(list, box), box }; + } +}; + +/** A painted record's tint draw: a hash of its quantized position (stable under erase, the same on + * every peer). @param {DataView} view @param {number} o */ +function recordTint(view, o) { + const h = slotSeed(view.getUint32(o, true) ^ view.getUint16(o + 4, true), 0x51); + return (h / 4294967296) * 2 - 1; +} + +/** + * EVERY instance a `host: 'mesh'` set draws — the surface twin of terrainScatterCore.instancesOf, + * and the ONE list the renderer, the colliders, the brush and the edit tools read on a mesh: the + * generated slots (the set's own seeded per-slot stream, so slot ids mean what they mean on a + * terrain) with their hand edits applied, then the painted 16-byte records. + * @param {any} set a normalized set @param {Surface | null} S the host's surface (null = no fill) + * @param {(b64: string) => Uint8Array | null} decode + * @param {{room?: number, accept?: ((p: SurfacePoint, k: number) => boolean) | null}} [opts] + * @returns {(SurfaceInstance & {id: string, src: number, tint: number, index?: number})[]} + */ +export function surfaceInstancesOf(set, S, decode, opts = {}) { + /** @type {any[]} */ + const out = []; + if (set?.gen && S) { + const seed = set.seed ?? 0; + for (const inst of generateSurfaceSlots(set.gen, S, { draws: (k) => slotDraws(seed, k), room: opts.room, accept: opts.accept ?? null })) { + const o = set.ov?.[String(inst.slot)]; + if (o?.del) continue; + const { pick, ...rest } = /** @type {any} */ (inst); + const base = { ...rest, src: pickSource(set.sources, pick ?? 0), tint: inst.tint ?? 0 }; + out.push(o ? { ...base, ...o } : base); + } + } + const bytes = set?.count && set.data ? decode(set.data) : null; + const box = boxFrom(set?.box); + if (bytes && box) { + const pick = set.pick ? decode(set.pick) : null; + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + decodeSurfaceRecords(bytes, box).forEach((inst, j) => { + out.push({ + id: 'p' + j, + index: j, + ...inst, + src: pick && set.sources ? Math.min(pick[j] ?? 0, set.sources.length - 1) : 0, + tint: recordTint(view, j * SURFACE_RECORD) + }); + }); + } + return out; +} diff --git a/src/lib/scatterThin.js b/src/lib/scatterThin.js new file mode 100644 index 000000000..fab87a05f --- /dev/null +++ b/src/lib/scatterThin.js @@ -0,0 +1,232 @@ +import { writable, get } from 'svelte/store'; +import { globalCamera } from '../stores/sceneStore'; +import { safeStorage } from './safeStorage'; +import { defaults, targets } from '../../perf/budgets.json'; +import { resolveTargets } from './budgetMeterCore'; +import { thinPlan, fadeAt, scatterBudget, thinProfileOf, NEAR_KEEP } from './scatterThinCore'; + +// 51 — DEVICE THINNING, the runtime: every scatter InstancedMesh registers its full instance +// list here (terrainSurface.layoutScatter), and once per frame — throttled to a few re-plans a +// second, and only when the viewer moved — the planner (scatterThinCore) picks the distance past +// which this device stops drawing, and each mesh writes the instances it keeps (fading the edge) +// to the front of its buffer and lowers its `count`. +// +// LOCAL, all of it: what this device draws is a fact about this device. Nothing here is saved or +// sent, the layout is untouched, and a collider never reads it — scatterPhysicsSets builds every +// instance from the set itself, so a ball hits the same rock on a phone as on a desktop. +// `userData.instanceIds` is rewritten in DRAW order, so a pick maps `instanceId` to the right +// instance whatever was thinned. + +const KEY = 'scatterDetail'; +// declared ABOVE the subscribe below: it runs synchronously at module eval (the TDZ rule) +let dirty = true; +/** 'auto' (read the device) | 'quest' | 'phone' | 'desktop' | 'off' (draw everything). LOCAL. */ +export const scatterDetail = writable(readChoice()); +scatterDetail.subscribe((v) => { + try { + safeStorage.setItem(KEY, v); + } catch { + /* private mode: the pick lasts the session */ + } + dirty = true; +}); + +function readChoice() { + try { + const v = safeStorage.getItem(KEY); + return v && ['auto', 'quest', 'phone', 'desktop', 'off'].includes(v) ? v : 'auto'; + } catch { + return 'auto'; + } +} + +const TARGETS = resolveTargets({ defaults, targets }); + +/** @typedef {{base: Float32Array, colors: Float32Array | null, ids: string[], pos: Float32Array, tris: number, thinned: boolean}} ThinData */ + +/** every scatter mesh that can be thinned @type {Set} */ +const meshes = new Set(); +let lastFrame = -1; +let lastPlanAt = 0; +const lastCam = { x: NaN, y: NaN, z: NaN }; +/** @type {{profile: string | null, budget: number, cutoff: number, fadeStart: number, total: number, drawnTris: number, drawn: number, instances: number}} */ +let last = { profile: null, budget: Infinity, cutoff: Infinity, fadeStart: Infinity, total: 0, drawnTris: 0, drawn: 0, instances: 0 }; + +const REPLAN_MS = 150; +const MOVE_M = 0.75; + +/** + * Register a freshly laid-out mesh: its full matrices, colours and ids are kept beside it, and the + * per-frame hook drives the plan. @param {any} mesh @param {number} tris triangles per instance + */ +export function registerThinMesh(mesh, tris) { + const n = mesh.count; + const base = new Float32Array(mesh.instanceMatrix.array.subarray(0, n * 16)); + const pos = new Float32Array(n * 3); + for (let i = 0; i < n; i++) { + pos[i * 3] = base[i * 16 + 12]; + pos[i * 3 + 1] = base[i * 16 + 13]; + pos[i * 3 + 2] = base[i * 16 + 14]; + } + /** @type {ThinData} */ + const data = { + base, + colors: mesh.instanceColor ? new Float32Array(mesh.instanceColor.array.subarray(0, n * 3)) : null, + ids: [...(mesh.userData.instanceIds ?? [])], + pos, + tris, + thinned: false + }; + mesh.userData.thin = data; + // the meter's content estimate counts every instance, not what this device draws + mesh.userData.instanceTotal = n; + mesh.onBeforeRender = (/** @type {any} */ renderer) => thinFrame(renderer); + meshes.add(mesh); + dirty = true; +} + +/** @param {any} mesh */ +export function unregisterThinMesh(mesh) { + meshes.delete(mesh); + dirty = true; +} + +/** this device: in a headset? a touch-first screen? @param {any} renderer */ +function deviceOf(renderer) { + let coarse = false; + try { + coarse = typeof matchMedia !== 'undefined' && matchMedia('(pointer: coarse)').matches; + } catch { + /* no media queries */ + } + return { xr: !!renderer?.xr?.isPresenting, coarse }; +} + +const _cam = { x: 0, y: 0, z: 0 }; + +/** the viewer's world position: the XR camera in a headset, else the main camera + * @param {any} renderer @returns {boolean} */ +function viewer(renderer) { + const cam = renderer?.xr?.isPresenting ? renderer.xr.getCamera() : /** @type {any} */ (get(globalCamera)); + const e = cam?.matrixWorld?.elements; + if (!e) return false; + _cam.x = e[12]; + _cam.y = e[13]; + _cam.z = e[14]; + return true; +} + +/** Once per frame (the first scatter mesh drawn calls it; the shadow pass and the other meshes + * of the frame find it done). @param {any} renderer */ +export function thinFrame(renderer) { + const frame = renderer?.info?.render?.frame ?? -1; + if (frame === lastFrame) return; + lastFrame = frame; + const now = performance.now(); + if (!dirty && now - lastPlanAt < REPLAN_MS) return; + if (!viewer(renderer)) return; + const moved = Math.hypot(_cam.x - lastCam.x, _cam.y - lastCam.y, _cam.z - lastCam.z); + if (!dirty && !(moved > MOVE_M)) return; + lastPlanAt = now; + lastCam.x = _cam.x; + lastCam.y = _cam.y; + lastCam.z = _cam.z; + dirty = false; + replan(thinProfileOf(get(scatterDetail), deviceOf(renderer))); +} + +/** @type {Float32Array} */ let dists = new Float32Array(0); +/** @type {Float32Array} */ let tris = new Float32Array(0); + +/** @param {string | null} profile */ +function replan(profile) { + const list = [...meshes].filter((m) => m.parent && m.userData.thin); + let n = 0; + for (const m of list) n += m.userData.thin.ids.length; + if (dists.length < n) { + dists = new Float32Array(n); + tris = new Float32Array(n); + } + let k = 0; + for (const m of list) { + const t = /** @type {ThinData} */ (m.userData.thin); + const w = m.parent.matrixWorld.elements; + for (let i = 0; i < t.ids.length; i++) { + const x = t.pos[i * 3], y = t.pos[i * 3 + 1], z = t.pos[i * 3 + 2]; + const wx = w[0] * x + w[4] * y + w[8] * z + w[12]; + const wy = w[1] * x + w[5] * y + w[9] * z + w[13]; + const wz = w[2] * x + w[6] * y + w[10] * z + w[14]; + dists[k] = Math.hypot(wx - _cam.x, wy - _cam.y, wz - _cam.z); + tris[k] = t.tris; + k++; + } + } + const target = profile ? /** @type {any} */ (TARGETS)[profile] : null; + const budget = target ? scatterBudget(target.triangles) : Infinity; + const plan = thinPlan(dists.subarray(0, n), tris.subarray(0, n), budget, NEAR_KEEP); + let drawn = 0; + k = 0; + for (const m of list) { + const t = /** @type {ThinData} */ (m.userData.thin); + drawn += apply(m, t, plan, k); + k += t.ids.length; + } + last = { profile, budget, cutoff: plan.cutoff, fadeStart: plan.fadeStart, total: plan.total, drawnTris: plan.drawnTris, drawn, instances: n }; +} + +/** Write the kept instances of one mesh (dists from `offset`). @param {any} mesh @param {ThinData} t + * @param {{cutoff: number, fadeStart: number}} plan @param {number} offset @returns {number} drawn */ +function apply(mesh, t, plan, offset) { + const n = t.ids.length; + if (plan.cutoff === Infinity) { + if (!t.thinned) return n; + mesh.instanceMatrix.array.set(t.base); + if (t.colors && mesh.instanceColor) mesh.instanceColor.array.set(t.colors); + mesh.count = n; + mesh.userData.instanceIds = [...t.ids]; + mesh.instanceMatrix.needsUpdate = true; + if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true; + t.thinned = false; + return n; + } + const out = mesh.instanceMatrix.array; + const cols = t.colors && mesh.instanceColor ? mesh.instanceColor.array : null; + /** @type {string[]} */ + const ids = []; + let j = 0; + for (let i = 0; i < n; i++) { + const f = fadeAt(dists[offset + i], plan); + if (f <= 0) continue; + const b = i * 16; + const o = j * 16; + // scale the 3x3 part (about the instance's own origin, which stands on the ground) + for (let c = 0; c < 12; c++) out[o + c] = c % 4 === 3 ? t.base[b + c] : t.base[b + c] * f; + out[o + 12] = t.base[b + 12]; + out[o + 13] = t.base[b + 13]; + out[o + 14] = t.base[b + 14]; + out[o + 15] = t.base[b + 15]; + if (cols && t.colors) { + cols[j * 3] = t.colors[i * 3]; + cols[j * 3 + 1] = t.colors[i * 3 + 1]; + cols[j * 3 + 2] = t.colors[i * 3 + 2]; + } + ids.push(t.ids[i]); + j++; + } + mesh.count = j; + mesh.userData.instanceIds = ids; + mesh.instanceMatrix.needsUpdate = true; + if (cols) mesh.instanceColor.needsUpdate = true; + t.thinned = true; + return j; +} + +/** Debug / suite: the current plan and what is drawn. */ +export function scatterThinStats() { + return { ...last, meshes: meshes.size }; +} + +/** Suite: re-plan on the next frame whatever moved. */ +export function thinNow() { + dirty = true; +} diff --git a/src/lib/scatterThinCore.js b/src/lib/scatterThinCore.js new file mode 100644 index 000000000..bcdc740b3 --- /dev/null +++ b/src/lib/scatterThinCore.js @@ -0,0 +1,89 @@ +// 51 — DEVICE THINNING, the pure half (imports NOTHING; unit-tested in tests/unit/scatterThin.test.js). +// +// The user's ruling (2026-10-21): the SAME layout everywhere; a weaker device draws fewer FAR +// instances, faded out by distance, so the scatter stays inside the budget meter's line for that +// device — and the NEAR instances and every COLLIDER stay identical, so gameplay matches. +// +// THE PLAN is one distance: walk the instances nearest first, adding each one's triangles, and +// stop where the budget runs out — that distance is the CUTOFF. Everything inside NEAR_KEEP is +// drawn whatever it costs (what you can walk into is never thinned). The last FADE_BAND share of +// the way to the cutoff shrinks an instance smoothly to nothing, so the edge reads as a fade +// rather than a line of rocks popping as you walk. + +/** metres around the viewer that are never thinned */ +export const NEAR_KEEP = 30; +/** the share of the distance to the cutoff over which an instance shrinks away */ +export const FADE_BAND = 0.2; +/** the share of a device's triangle line scatter may take (the rest is the scene's own) */ +export const SCATTER_SHARE = 0.5; + +/** @typedef {'quest' | 'phone' | 'desktop'} ThinProfile */ + +/** + * The triangle budget scatter gets on a device: its share of the target's line, raised to what + * is left once the rest of the scene is counted when that is known (`others`, the scene's own + * triangles) — never below a quarter of the line, never above the whole of it. + * @param {number} line the target's triangle budget (budgets.json) @param {number} [others] + */ +export function scatterBudget(line, others) { + if (!Number.isFinite(line) || line <= 0) return Infinity; + if (!Number.isFinite(others) || /** @type {number} */ (others) < 0) return line * SCATTER_SHARE; + return Math.min(line, Math.max(line * 0.25, line - /** @type {number} */ (others))); +} + +/** + * Where the drawn scatter ends. `dists[i]` = instance i's distance to the viewer (metres), and + * `tris[i]` its triangles (every part it draws). Infinity = nothing to thin. + * @param {ArrayLike} dists @param {ArrayLike} tris @param {number} budget + * @param {number} [near] @returns {{cutoff: number, fadeStart: number, drawnTris: number, total: number}} + */ +export function thinPlan(dists, tris, budget, near = NEAR_KEEP) { + const n = dists.length; + let total = 0; + for (let i = 0; i < n; i++) total += tris[i]; + if (!(total > budget)) return { cutoff: Infinity, fadeStart: Infinity, drawnTris: total, total }; + const order = new Uint32Array(n); + for (let i = 0; i < n; i++) order[i] = i; + order.sort((a, b) => dists[a] - dists[b]); + let sum = 0; + let cutoff = near; + for (let k = 0; k < n; k++) { + const i = order[k]; + const d = dists[i]; + if (d > near && sum + tris[i] > budget) { + // the cutoff sits half way to the first instance that does not fit, so it is drawn + // by nobody and the last one that fits is inside + const prev = k ? dists[order[k - 1]] : 0; + cutoff = Math.max(near, (prev + d) / 2); + break; + } + sum += tris[i]; + cutoff = d + 1e-6; + if (k === n - 1) cutoff = Infinity; + } + // a fade shrinks what it draws, so the band costs no more than the plan already counted + const fadeStart = cutoff === Infinity ? Infinity : Math.max(near, cutoff * (1 - FADE_BAND)); + return { cutoff, fadeStart, drawnTris: sum, total }; +} + +/** An instance's scale at distance d under a plan: 1 inside the fade, 0 past the cutoff, a + * smoothstep in between. @param {number} d @param {{cutoff: number, fadeStart: number}} plan */ +export function fadeAt(d, plan) { + if (d <= plan.fadeStart) return 1; + if (d >= plan.cutoff) return 0; + const t = (plan.cutoff - d) / (plan.cutoff - plan.fadeStart); + return t * t * (3 - 2 * t); +} + +/** + * Which device profile a viewer is: a headset in an immersive session, a touch-first screen (the + * N6, folded or unfolded), else a desktop. `choice` is the LOCAL setting ('auto' reads the device; + * 'off' draws everything). + * @param {string} choice @param {{xr: boolean, coarse: boolean}} device + * @returns {ThinProfile | null} null = no thinning + */ +export function thinProfileOf(choice, device) { + if (choice === 'off') return null; + if (choice === 'quest' || choice === 'phone' || choice === 'desktop') return choice; + return device.xr ? 'quest' : device.coarse ? 'phone' : 'desktop'; +} diff --git a/src/lib/scriptRuntime.js b/src/lib/scriptRuntime.js index f1b4d6447..f82cc350d 100644 --- a/src/lib/scriptRuntime.js +++ b/src/lib/scriptRuntime.js @@ -4,11 +4,17 @@ import { showToast } from '../stores/appStore'; import { instrument } from './loopGuard'; import { lintScript } from './scriptLint'; import { SCRIPT_HELPERS, harvestOutputs } from './scriptIO'; +import { compileInGuest, forgetCompiled, runEffectInGuest, runValueInGuest, runPlayerInGuest } from './sandbox/scripts.js'; // Compiles and runs user script code for Script nodes and custom node defs. // Scripts run on EVERY peer independently — they must be pure functions of -// (object, base, data, time) to stay deterministic. Peers are already trusted -// (connection approval); this is collaborative prototyping, not a sandbox. +// (object, base, data, time) to stay deterministic. +// +// 69 T1: a script is scene content, and a scene reaches people who never approved its author (a +// published scene, a play link), so the code no longer runs in the app's realm: it is compiled and +// called inside the sandbox guest (sandbox/scripts.js — no DOM, no storage, no network; `object` is +// a view diffed back onto the real one). Until the guest engine has loaded (a lazy chunk, the first +// frames of the first scene with a script) a script simply does not run yet — no badge. // // 27-D (audit C1) adds the two LIVENESS guards that trust does not cover, because a // trusted author still writes an infinite loop by ACCIDENT — and this runs on every @@ -17,10 +23,13 @@ import { SCRIPT_HELPERS, harvestOutputs } from './scriptIO'; // 1. every loop is instrumented (`loopGuard`), so a runaway THROWS instead of hanging // 2. a node that merely runs LONG is timed and paused after a sustained run of slow // frames — the loop guard cannot see that one, since it returns between frames -// Neither is a sandbox. They stop a hang, not a hostile script. +// The guest adds a third: every call has a deadline (engine.js), whatever the code does. -/** @type {Map} */ +/** @type {Map} */ const compiled = new Map(); +/** the guest is still loading: nothing runs yet, nothing is cached, no badge */ +const PENDING = Object.freeze({ pending: true }); +let compileSeq = 0; // 34 D3: the three shapes a script compiles to. 'v1' is today's signature, byte-identical // (a v1 body declaring its own `inputs` must keep compiling, which is why the v2 names are @@ -43,7 +52,10 @@ function compile(code, shape = 'v1') { const key = shape === 'v1' ? code : shape + ':' + code; let entry = compiled.get(key); if (entry) return entry; - if (compiled.size > 100) compiled.clear(); // stale codes from live editing + if (compiled.size > 100) { + compiled.clear(); // stale codes from live editing + forgetCompiled(); + } // Guard the loops BEFORE the code becomes a function. Here rather than at the call // site because this map is keyed by the CODE STRING: each distinct script is // transformed exactly once, and an edit re-instruments it and clears the old badge. @@ -62,15 +74,28 @@ function compile(code, shape = 'v1') { compiled.set(key, entry); return entry; } + const id = 's' + ++compileSeq; + let failed; try { - entry = { fn: new Function(...SIGNATURES[shape], '"use strict";\n' + guarded.code) }; + failed = compileInGuest(id, shape, '"use strict";\n' + guarded.code); } catch (error) { - entry = { error: String(error) }; + failed = String(error); } + if (failed === null) return PENDING; + entry = failed ? { error: failed } : { fn: callerOf(id, shape) }; compiled.set(key, entry); return entry; } +/** A host function with the shape's positional signature that runs guest function `id` (the call + * sites below are unchanged: the helpers they pass are the guest's own copies). + * @param {string} id @param {'v1' | 'effect' | 'value' | 'player'} shape */ +function callerOf(id, shape) { + if (shape === 'value') return (/** @type {any} */ inputs, /** @type {number} */ time, /** @type {any} */ _d, /** @type {any} */ _l, /** @type {any} */ _c, /** @type {any} */ api) => runValueInGuest(id, inputs, time, api); + if (shape === 'player') return (/** @type {any} */ settings, /** @type {any} */ input, /** @type {number} */ time) => runPlayerInGuest(id, settings, input, time); + return (/** @type {any[]} */ ...a) => runEffectInGuest(id, shape === 'effect', a[0], a[1], a[2], a[3], a[5], a[9]); +} + /** One frame's fair share for ONE node: an eighth of a 60Hz frame, with the rest of the * tick, physics, the renderer and every other node still to run. */ const SLOW_MS = 8; @@ -104,10 +129,11 @@ function reportError(nodeId, error) { /** * Time one call against the node's budget; records/clears the node's error badge. * Returns the call's result, or undefined when it failed, was refused or is paused. - * @param {string} nodeId @param {string} code @param {{fn?: Function, error?: string}} entry + * @param {string} nodeId @param {string} code @param {{fn?: Function, error?: string, pending?: boolean}} entry * @param {(fn: Function) => any} call */ function timed(nodeId, code, entry, call) { + if (entry.pending) return undefined; if (entry.error) { reportError(nodeId, entry.error); return undefined; @@ -145,6 +171,13 @@ function timed(nodeId, code, entry, call) { reportError(nodeId, null); return result; } catch (error) { + // 69 T1: the guest's deadline stopped it — retrying every frame would spend the deadline + // every frame, so it is paused like a node that ran slow for half a second + if (/** @type {any} */ (error)?.interrupted) { + b.paused = true; + reportError(nodeId, PAUSED_BADGE); + return undefined; + } reportError(nodeId, String(error)); return undefined; } diff --git a/src/lib/sessions.js b/src/lib/sessions.js index 6d662b1eb..6e27a4e7e 100644 --- a/src/lib/sessions.js +++ b/src/lib/sessions.js @@ -24,6 +24,7 @@ import { } from './animatedImports'; import { animationsSnapshot, animationsRestore, copyAnimationsFrom } from './animationPreview'; import { shaderGraphsSnapshot, shaderGraphsRestore, copyShaderGraphFrom } from './shaderGraph'; +import { preloadSandboxFor } from './sandbox/preload.js'; import { peers, showToast, @@ -1032,6 +1033,8 @@ export async function readSessionZip(buffer) { showToast('This level needs a newer app version (format ' + format + ' > ' + SESSION_FORMAT + ')'); return null; } + // 69: the engine loads while the assets restore (applySession asks again — a no-op by then) + preloadSandboxFor(payload); await restoreZipAssets(entries, strFromU8); // R22 round 12: and the library's own files, which session.json carries only an index of await restoreLibraryBlobs(payload, entries); @@ -1062,6 +1065,7 @@ export async function importSessionZip(buffer) { // 21-I5: after the confirms (a cancelled import must not talk about the file it did // not read) and before the restore loops, which never touch `versions/` noteBundledVersions(entries); + preloadSandboxFor(payload); // 69: as in readSessionZip await restoreZipAssets(entries, strFromU8); // R22 round 12: a bundle written by this build carries its library as real files, so // an imported PROJECT entry arrives with its files rather than with `{}` where each @@ -1447,6 +1451,8 @@ export async function applySession(payload, opts = {}) { // still arriving is how a user cancels A, and it must not leave A's objects in B. // 36 F20: a person's click CLAIMED its job before downloading/asking (`claimLoad`); the // apply adopts that claim — and a claim a newer load has superseded applies NOTHING. + // 69: a scene that carries code starts the sandbox engine now, so it loads while the objects build + preloadSandboxFor(payload); const name = payload?.name ?? 'scene'; const total = (payload?.objects ?? []).length; /** @type {any} */ diff --git a/src/lib/settingsIndex.js b/src/lib/settingsIndex.js index e3f760447..a4fea900b 100644 --- a/src/lib/settingsIndex.js +++ b/src/lib/settingsIndex.js @@ -89,6 +89,7 @@ export const SETTINGS_ROWS = [ ["Scene", "Placement preview", "What an item dragged in from the Explorer shows before you drop it: the full model, the model within a triangle budget, or its box"], ["Scene", "Preview triangle budget", "Above this a loaded model previews as its box while you drag it in (50k on a desktop, 15k on a phone or headset)"], ["Scene", "Show dimensions", "Width × depth × height in metres, and the triangles and download size, beside the pointer while you drag an item in"], + ["Scene", "Distant scatter", "Far scattered models fade out to keep this device inside its budget; near ones and their colliders never change"], ["Scene", "Water quality", "High draws refraction, caustics, shoreline foam and planar mirrors at full resolution; Medium at half; Low…"], ["Scene", "Simulation controls", "Show the physics transport (play/pause/stop/reset) at bottom-right."], ["Scene", "Sync animations", "Node animations use wall-clock time so all peers see the same phase"], diff --git a/src/lib/terrainPaint.js b/src/lib/terrainPaint.js index 596842717..63a2565cb 100644 --- a/src/lib/terrainPaint.js +++ b/src/lib/terrainPaint.js @@ -3,13 +3,19 @@ import { objectsGroup } from '../stores/sceneStore'; import { peers, showToast } from '../stores/appStore'; import { recordEntry, registerHistoryKind } from './history'; import { canEditObject, warnViewerReadOnly } from './objectPermissions'; -import { createSplat, paintSplatAt, normalizeLayers, base64ToBytes, bytesToBase64, SPLAT_RES } from './terrainPaintCore'; +import { createSplat, paintSplatAt, normalizeLayers, base64ToBytes, bytesToBase64, SPLAT_RES, SPLAT_RES_CHOICES, resampleSplat } from './terrainPaintCore'; import { normalizeScatter, decodeInstances, encodeInstances, scatterInCircle, - eraseInCircle, + withOverrides, + withSeed, + withSourceAdded, + withSourceRemoved, + newSeed, + pickSource, + blockVersion, sourceKey, MAX_INSTANCES, MAX_SETS @@ -20,8 +26,11 @@ import { applyTerrainSplat, applyTerrainLayers, applyTerrainScatter, - terrainBounds + terrainBounds, + surfaceOf, + scatterSetInstances } from './terrainSurface'; +import { surfaceDab, eraseInSphere, triangleIndex, surfaceRecords } from './scatterSurfaceCore'; // 47 T1 + T2 — THE WRITE SIDE of terrain paint and scatter: the brushes, their strokes, // the undo kinds and the wire. The sculpt stroke pattern verbatim (terrainSculpt.js): @@ -73,10 +82,10 @@ function sendSplat(uuid, bytes, res) { send({ type: 'objectParameters', parameter: 'terrainSplat', uuid, res, bytes: bytes ? bytes.slice().buffer : null }); } -/** @param {any} object */ -function writable_(object) { - if (!object?.userData?.terrain) { - showToast('Paint and scatter work on a Terrain'); +/** @param {any} object @param {boolean} [anyMesh] 51-surface: scatter works on ANY mesh, paint stays terrain-only */ +function writable_(object, anyMesh = false) { + if (!object?.userData?.terrain && !(anyMesh && isMeshHost(object))) { + showToast(anyMesh ? 'Scatter works on a mesh object' : 'Paint and scatter work on a Terrain'); return false; } if (!canEditObject(object)) { @@ -86,6 +95,42 @@ function writable_(object) { return true; } +/** 51-surface: a non-terrain object that can carry scatter — a mesh (its own triangles are the + * surface) or a GROUP with meshes under it (a placed model: its children's surface, in its frame). + * @param {any} object */ +export function isMeshHost(object) { + if (!object || object.userData?.terrain || object.isLight || object.isCamera) return false; + if (object.isMesh) return !!object.geometry?.attributes?.position; + let found = false; + object.traverse?.((/** @type {any} */ m) => { + if (!found && m !== object && m.isMesh && !m.isInstancedMesh && m.geometry?.attributes?.position) found = true; + }); + return found; +} + +/** the host's largest world scale — the brush ring is in world metres, the instances live in the + * host's LOCAL frame @param {any} object */ +function hostScale(object) { + const e = object?.matrixWorld?.elements; + if (!e) return 1; + const s = Math.max(Math.hypot(e[0], e[1], e[2]), Math.hypot(e[4], e[5], e[6]), Math.hypot(e[8], e[9], e[10])); + return s > 1e-6 ? s : 1; +} + +/** object -> the brush's triangle grid over its surface (rebuilt with the surface) @type {WeakMap} */ +const brushIndex = new WeakMap(); +/** @param {any} object */ +function brushIndexOf(object) { + const surface = surfaceOf(object); + if (!surface) return null; + let entry = brushIndex.get(object); + if (!entry || entry.surface !== surface) { + entry = { surface, index: triangleIndex(surface) }; + brushIndex.set(object, entry); + } + return entry; +} + // ---- paint ----------------------------------------------------------------------------- /** @type {{uuid: string, before: Uint8Array | null, res: number, changed: boolean} | null} */ @@ -216,9 +261,39 @@ export function clearTerrainPaint(uuid) { return true; } +/** + * 51 E4: PER-TERRAIN splat resolution (Inspector ▸ Terrain paint). A painted map is resampled + * (weights re-normalised, coverage kept); an unpainted terrain gets a fresh map at that size, + * all layer 1 — nothing changes on screen, the next stroke paints at the new detail. ONE undo + * entry + ONE message, the `terrainSplat` kind and wire as they are. + * @param {string} uuid @param {number} res one of SPLAT_RES_CHOICES + */ +export function setSplatResolution(uuid, res) { + const object = objectOf(uuid); + if (!object?.userData?.terrain || !SPLAT_RES_CHOICES.includes(res)) return false; + if (!canEditObject(object)) { + warnViewerReadOnly(); + return false; + } + const s = liveSplat(object); + if (s && s.res === res) return false; + const before = s ? { bytes: s.bytes.slice(), res: s.res } : { bytes: null, res: SPLAT_RES }; + const bytes = s ? resampleSplat(s.bytes, s.res, res) : createSplat(res); + applyTerrainSplat(uuid, bytes, res); + sendSplat(uuid, bytes, res); + recordEntry({ kind: 'terrainSplat', uuid, before, after: { bytes: bytes.slice(), res } }); + return true; +} + +/** The splat resolution a terrain paints at (its map's, else the default). @param {string} uuid */ +export function splatResolutionOf(uuid) { + return liveSplat(objectOf(uuid))?.res ?? SPLAT_RES; +} + // ---- scatter ---------------------------------------------------------------------------- -/** @type {{uuid: string, before: any, key: string, list: any[], set: any, others: any[], changed: boolean, seed: number} | null} */ +/** @type {{uuid: string, before: any, key: string, list: any[], set: any, others: any[], changed: boolean, seed: number, + * generated: any[], erased: Set, mesh?: boolean} | null} */ let scatterStroke = null; let lastScatterPreview = 0; @@ -230,7 +305,8 @@ function scatterBlockOf(object) { /** @param {string} uuid */ export function beginScatterStroke(uuid) { const object = objectOf(uuid); - if (!writable_(object)) return false; + if (!writable_(object, true)) return false; + const mesh = isMeshHost(object); const b = terrainBounds(object); if (!b) return false; const block = scatterBlockOf(object); @@ -241,22 +317,37 @@ export function beginScatterStroke(uuid) { return false; } const sets = block?.sets ?? []; - const set = sets.find((s) => sourceKey(s.source) === key) ?? null; + const set = sets.find((s) => sourceKey(s.source) === key && !s.host === !mesh) ?? null; if (!set && sets.length >= MAX_SETS && !get(scatterErase)) { showToast('A terrain holds up to ' + MAX_SETS + ' scatter models'); return false; } + /** @type {any[]} */ + let list = []; + if (set?.count) { + // 51: a painted record keeps the source it picked (a mixed set) through every erase + const pick = set.pick ? base64ToBytes(set.pick) : null; + const bytes = /** @type {Uint8Array} */ (base64ToBytes(set.data)); + // 51-surface: a mesh host's records are 16 bytes (xyz + normal) over the set's stored box + list = (mesh ? surfaceRecords.decode(bytes, set) : decodeInstances(bytes, b)).map((inst, j) => ({ ...inst, src: pick?.[j] ?? 0 })); + } scatterStroke = { uuid, before: block && block.sets.length ? block : null, key, - list: set ? decodeInstances(/** @type {Uint8Array} */ (base64ToBytes(set.data)), b) : [], + list, // the align toggle is a property of the SET (heights are derived at render time), so // the brush's current setting re-stamps it - set: { ...(set ?? { id: key, source, data: '', count: 0, collider: get(scatterCollider) }), align: get(scatterAlign) }, + set: { ...(set ?? { id: key, source, data: '', count: 0, collider: get(scatterCollider), ...(mesh ? { host: 'mesh' } : {}) }), align: get(scatterAlign) }, others: sets.filter((s) => s !== set), changed: false, - seed: (Math.random() * 2 ** 32) >>> 0 + seed: (Math.random() * 2 ** 32) >>> 0, + // 51: the set's GENERATED instances count toward the brush's density (painting over a + // filled patch adds nothing) and an erase deletes them as hand edits + // (51-surface: read exactly as drawn — the fill's mask and the block's shared cap included) + generated: set?.gen ? scatterSetInstances(object, set.id).filter((i) => i.slot != null) : [], + erased: new Set(), + mesh }; return true; } @@ -264,8 +355,23 @@ export function beginScatterStroke(uuid) { /** The block the stroke has built so far. @param {NonNullable} st @param {any} b */ function strokeBlock(st, b) { const sets = [...st.others]; - if (st.list.length) sets.push({ ...st.set, data: bytesToBase64(encodeInstances(st.list, b)), count: st.list.length }); - return sets.length ? { v: 1, sets } : null; + /** @type {any} */ + let set = { ...st.set, data: '', count: st.list.length }; + if (st.mesh) { + const enc = surfaceRecords.encode(st.list); + if (st.list.length) (set.data = bytesToBase64(enc.bytes)), (set.box = enc.box); + else delete set.box; + } else if (st.list.length) set.data = bytesToBase64(encodeInstances(st.list, b)); + if (set.sources && st.list.length) set.pick = bytesToBase64(new Uint8Array(st.list.map((i) => i.src ?? 0))); + else delete set.pick; + if (st.erased.size) { + /** @type {Record} */ + const patch = {}; + for (const id of st.erased) patch[id] = { del: true }; + set = withOverrides(set, patch, { bounds: b, decode: base64ToBytes, encode: bytesToBase64, records: surfaceRecords }); + } + if (set.count || set.gen) sets.push(set); + return sets.length ? { v: blockVersion(sets), sets } : null; } /** A tiny seeded rng per stroke (Math.random would do; a seed makes a suite replayable). */ @@ -282,23 +388,34 @@ function strokeRng() { /** * One scatter application at a LOCAL (x, z), exported for the suite. Paint adds up to the - * density inside the circle; erase removes the active source's instances in it. - * @param {string} uuid @param {number} x @param {number} z @param {number} radius + * density inside the circle; erase removes the active source's instances in it (a generated + * one becomes a deleted hand edit, so a re-roll does not bring it back). + * 51-surface: on a MESH host the point is a local (x, y, z) on its surface and the brush is a sphere + * (meshScatterAt); `y` is ignored on a terrain. + * @param {string} uuid @param {number} x @param {number} z @param {number} radius @param {number} [y] * @returns {number} instances added (negative = removed) */ -export function scatterAt(uuid, x, z, radius) { +export function scatterAt(uuid, x, z, radius, y = 0) { const st = scatterStroke; const object = objectOf(uuid); const b = object ? terrainBounds(object) : null; if (!st || st.uuid !== uuid || !b) return 0; + if (st.mesh) return meshScatterAt(st, object, b, x, y, z, radius); let delta = 0; if (get(scatterErase)) { - const next = eraseInCircle(st.list, x, z, radius); + const next = st.list.filter((p) => Math.hypot(p.x - x, p.z - z) > radius); delta = next.length - st.list.length; st.list = next; + for (const g of st.generated) + if (!st.erased.has(g.id) && Math.hypot(g.x - x, g.z - z) <= radius) { + st.erased.add(g.id); + delta--; + } } else { - const othersCount = st.others.reduce((sum, s) => sum + s.count, 0); - const added = scatterInCircle(st.list, { + const othersCount = st.others.reduce((sum, s) => sum + s.count + (s.gen?.count ?? 0), 0); + const live = st.generated.filter((g) => !st.erased.has(g.id)); + const rng = strokeRng(); + const added = scatterInCircle([...st.list, ...live], { cx: x, cz: z, radius, @@ -307,9 +424,11 @@ export function scatterAt(uuid, x, z, radius) { scaleMax: get(scatterScaleMax), randomRotation: get(scatterRandomRotation), bounds: b, - rng: strokeRng(), - room: MAX_INSTANCES - othersCount - st.list.length + rng, + room: MAX_INSTANCES - othersCount - st.list.length - live.length }); + // 51 E1: a mixed set's new instance draws its source by weight + for (const inst of added) /** @type {any} */ (inst).src = pickSource(st.set.sources, rng()); st.list.push(...added); delta = added.length; } @@ -320,9 +439,60 @@ export function scatterAt(uuid, x, z, radius) { return delta; } -/** @param {string} uuid @param {number} x @param {number} z @param {number} radius */ -export function scatterStrokeMove(uuid, x, z, radius) { - if (!scatterAt(uuid, x, z, radius)) return; +/** + * 51-surface: the brush on a mesh — a SPHERE around the surface point (local), its radius the ring's + * world radius over the host's scale; paint fills the patch up to the density (what is already there, + * painted or generated, counts), erase removes inside the sphere (a generated one = a deleted edit). + * @param {NonNullable} st @param {any} object @param {any} b + * @param {number} x @param {number} y @param {number} z @param {number} worldRadius + */ +function meshScatterAt(st, object, b, x, y, z, worldRadius) { + const radius = worldRadius / hostScale(object); + const r2 = radius * radius; + const inside = (/** @type {any} */ p) => (p.x - x) ** 2 + (p.y - y) ** 2 + (p.z - z) ** 2 <= r2; + let delta = 0; + if (get(scatterErase)) { + const next = eraseInSphere(st.list, x, y, z, radius); + delta = next.length - st.list.length; + st.list = next; + for (const g of st.generated) + if (!st.erased.has(g.id) && inside(g)) { + st.erased.add(g.id); + delta--; + } + } else { + const brush = brushIndexOf(object); + if (!brush) return 0; + const othersCount = st.others.reduce((sum, s) => sum + s.count + (s.gen?.count ?? 0), 0); + const live = st.generated.filter((g) => !st.erased.has(g.id)); + const rng = strokeRng(); + const added = surfaceDab([...st.list, ...live], brush.surface, brush.index, { + cx: x, + cy: y, + cz: z, + radius, + density: get(scatterDensity), + scaleMin: get(scatterScaleMin), + scaleMax: get(scatterScaleMax), + randomRotation: get(scatterRandomRotation), + rng, + room: MAX_INSTANCES - othersCount - st.list.length - live.length, + spacing: st.set.gen?.spacing + }); + for (const inst of added) /** @type {any} */ (inst).src = pickSource(st.set.sources, rng()); + st.list.push(...added); + delta = added.length; + } + if (delta) { + st.changed = true; + applyTerrainScatter(st.uuid, strokeBlock(st, b)); + } + return delta; +} + +/** @param {string} uuid @param {number} x @param {number} z @param {number} radius @param {number} [y] the mesh host's local y */ +export function scatterStrokeMove(uuid, x, z, radius, y = 0) { + if (!scatterAt(uuid, x, z, radius, y)) return; const now = performance.now(); if (now - lastScatterPreview > PREVIEW_MS) { lastScatterPreview = now; @@ -350,23 +520,162 @@ registerHistoryKind('terrainScatter', (entry, state) => { }); /** - * Inspector: change one set (align toggle) or remove it (`patch` null). One undo entry. - * @param {string} uuid @param {string} setId @param {{align?: boolean, collider?: string} | null} patch + * THE ONE WRITE PATH for a terrain's scatter block outside a brush stroke: apply, send, ONE undo + * entry. A gesture previews with `{record: false}` (and `{send: false}` while it streams faster + * than the wire wants) and commits with `{before}` on release — one entry per drag. + * @param {string} uuid @param {(block: {v: number, sets: any[]}) => any[] | null} edit returns the new sets (null = no change) + * @param {{before?: any, record?: boolean, send?: boolean}} [opts] */ -export function editScatterSet(uuid, setId, patch) { +export function writeScatter(uuid, edit, opts = {}) { const object = objectOf(uuid); - if (!object || !canEditObject(object)) return false; - const block = scatterBlockOf(object); - if (!block) return false; - const sets = patch ? block.sets.map((s) => (s.id === setId ? { ...s, ...patch } : s)) : block.sets.filter((s) => s.id !== setId); - const next = sets.length ? { v: 1, sets } : null; - applyTerrainScatter(uuid, next); + if (!object) return false; + if (!canEditObject(object)) { + warnViewerReadOnly(); + return false; + } + const block = scatterBlockOf(object) ?? { v: 1, sets: [] }; + const sets = edit(block); + if (!sets) return false; + const before = opts.before !== undefined ? opts.before : block.sets.length ? block : null; + applyTerrainScatter(uuid, sets.length ? { v: blockVersion(sets), sets } : null); const after = object.userData.terrainScatter ?? null; - send({ type: 'objectParameters', parameter: 'terrainScatter', uuid, terrainScatter: after }); - recordEntry({ kind: 'terrainScatter', uuid, before: { block }, after: { block: after } }); + if (opts.send !== false) send({ type: 'objectParameters', parameter: 'terrainScatter', uuid, terrainScatter: after }); + if (opts.record !== false && JSON.stringify(before) !== JSON.stringify(after)) + recordEntry({ kind: 'terrainScatter', uuid, before: { block: before }, after: { block: after } }); return true; } +/** + * Inspector: change one set (align, collider, and since 51 any set field — gen, seed, sources, + * tint, sink; a null value clears it) or remove it (`patch` null). One undo entry. + * @param {string} uuid @param {string} setId @param {Record | null} patch + * @param {{before?: any, record?: boolean, send?: boolean}} [opts] + */ +export function editScatterSet(uuid, setId, patch, opts) { + return writeScatter( + uuid, + (block) => { + if (!block.sets.some((s) => s.id === setId)) return null; + return patch ? block.sets.map((s) => (s.id === setId ? { ...s, ...patch } : s)) : block.sets.filter((s) => s.id !== setId); + }, + opts + ); +} + +/** + * 51: hand edits on single instances — `patch` = {[id]: {x?, z?, yaw?, scale?, del?: true} | null} + * (ids 'g' / 'p' from `scatterInstancesOf`; null clears a generated slot's edit). The + * write path 51-edit's gizmo, box ops and brushes go through. One undo entry (see writeScatter). + * @param {string} uuid @param {string} setId @param {Record} patch + * @param {{before?: any, record?: boolean, send?: boolean}} [opts] + */ +export function editScatterInstances(uuid, setId, patch, opts) { + const b = terrainBounds(objectOf(uuid)); + if (!b) return false; + return writeScatter( + uuid, + (block) => { + const set = block.sets.find((s) => s.id === setId); + if (!set) return null; + const next = withOverrides(set, patch, { bounds: b, decode: base64ToBytes, encode: bytesToBase64, records: surfaceRecords }); + return block.sets.map((s) => (s === set ? next : s)).filter((s) => s.count || s.gen); + }, + opts + ); +} + +/** 51: re-roll a seeded set (a new seed; hand edits kept). @param {string} uuid @param {string} setId @param {number} [seed] */ +export function rerollScatterSet(uuid, setId, seed = newSeed()) { + const set = scatterBlockOf(objectOf(uuid))?.sets.find((s) => s.id === setId); + if (!set?.gen) return false; + return editScatterSet(uuid, setId, { seed: withSeed(set, seed).seed }); +} + +/** + * 51: add a set (a seeded fill, or a source picked from a pack / prefab / the selection). The id + * is made unique on this terrain; returns it (null when refused). + * @param {string} uuid @param {Record} set + */ +export function addScatterSet(uuid, set) { + /** @type {string | null} */ + let id = null; + // 51-surface: a set added to a non-terrain mesh lives on its triangles (and stands on their + // normals unless asked otherwise — upright rocks on a sphere's side look wrong) + const meshHost = isMeshHost(objectOf(uuid)); + if (!meshHost && !objectOf(uuid)?.userData?.terrain) { + showToast('Scatter works on a mesh object'); + return null; + } + const ok = writeScatter(uuid, (block) => { + if (block.sets.length >= MAX_SETS) { + showToast((meshHost ? 'An object' : 'A terrain') + ' holds up to ' + MAX_SETS + ' scatter sets'); + return null; + } + const base = String(set.id || sourceKey(set.source) || 'set').slice(0, 34); + id = base; + for (let n = 2; block.sets.some((s) => s.id === id); n++) id = base + '~' + n; + const next = { + data: '', + count: 0, + align: meshHost, + ...set, + id, + ...(meshHost ? { host: 'mesh' } : {}), + ...(set.gen && set.seed == null ? { seed: newSeed() } : {}) + }; + return [...block.sets, next]; + }); + return ok ? id : null; +} + +/** @param {string} uuid @param {string} setId @param {(set: any, io: any) => any} fn one set, rewritten */ +function rewriteSet(uuid, setId, fn) { + const b = terrainBounds(objectOf(uuid)); + if (!b) return false; + const io = { bounds: b, decode: base64ToBytes, encode: bytesToBase64, records: surfaceRecords }; + return writeScatter(uuid, (block) => { + const set = block.sets.find((s) => s.id === setId); + if (!set) return null; + const next = fn(set, io); + if (next === set) return null; + return block.sets.map((s) => (s === set ? next : s)).filter((s) => s.count || s.gen); + }); +} + +/** 51 E1: add a model to a set (it becomes a weighted mix). @param {string} uuid @param {string} setId @param {any} source @param {number} [w] */ +export function addScatterSource(uuid, setId, source, w = 1) { + return rewriteSet(uuid, setId, (set, io) => withSourceAdded(set, source, w, io)); +} + +/** 51 E1: drop model i from a mix (its painted instances go with it). @param {string} uuid @param {string} setId @param {number} i */ +export function removeScatterSource(uuid, setId, i) { + return rewriteSet(uuid, setId, (set, io) => withSourceRemoved(set, i, io)); +} + +/** 51 E1: one model's weight in a mix (a scrub previews with {record: false} and commits with {before}). + * @param {string} uuid @param {string} setId @param {number} i @param {number} w @param {{before?: any, record?: boolean, send?: boolean}} [opts] */ +export function setScatterSourceWeight(uuid, setId, i, w, opts) { + const set = scatterBlockOf(objectOf(uuid))?.sets.find((s) => s.id === setId); + if (!set?.sources?.[i]) return false; + const sources = set.sources.map((e, k) => (k === i ? { ...e, w: Math.max(0.01, w) } : e)); + return editScatterSet(uuid, setId, { sources }, opts); +} + +/** The block as it stands (a gesture's `before`). @param {string} uuid */ +export function scatterBlockNow(uuid) { + const block = scatterBlockOf(objectOf(uuid)); + return block?.sets.length ? block : null; +} + +/** 51: every instance a terrain's set draws, with ids — what an edit tool reads. + * @param {string} uuid @param {string} setId */ +export function scatterInstancesOf(uuid, setId) { + const object = objectOf(uuid); + const b = terrainBounds(object); + // 51-surface: the drawn list (a fill's mask, a mesh host's surface, the block's shared cap) + return b ? scatterSetInstances(object, setId) : []; +} + /** True while a paint or scatter stroke is open (terrainSculpt's endStroke asks). */ export function terrainStrokeOpen() { return !!(paintStroke || scatterStroke); diff --git a/src/lib/terrainPaintCore.js b/src/lib/terrainPaintCore.js index 9acf5f604..32303cefd 100644 --- a/src/lib/terrainPaintCore.js +++ b/src/lib/terrainPaintCore.js @@ -192,3 +192,58 @@ export function normalizeLayers(layers) { }; }); } + +// ---- 51 E4: per-terrain splat resolution -------------------------------------------------- + +/** The resolutions the Inspector offers. 512² × 4 = 1 MB raw is the ceiling: a stroke streams + * the whole map as a preview (~5/s), and 1024² would be 4 MB per preview. */ +export const SPLAT_RES_CHOICES = Object.freeze([64, 128, 256, 512]); + +/** + * Resample a splat map to another resolution: each new texel samples the old map + * bilinearly at its centre (texel centres line up, so a 128 → 256 → 128 round trip lands + * back within a byte or two), then the four weights are re-normalised to sum to exactly 255 + * — the invariant every reader relies on. + * @param {Uint8Array} bytes @param {number} res @param {number} next + * @returns {Uint8Array} + */ +export function resampleSplat(bytes, res, next) { + if (next === res) return bytes.slice(); + const out = new Uint8Array(next * next * 4); + const at = (/** @type {number} */ i, /** @type {number} */ j, /** @type {number} */ c) => + bytes[(Math.min(res - 1, Math.max(0, j)) * res + Math.min(res - 1, Math.max(0, i))) * 4 + c]; + const w = [0, 0, 0, 0]; + for (let j = 0; j < next; j++) { + const fy = ((j + 0.5) / next) * res - 0.5; + const j0 = Math.floor(fy); + const ty = fy - j0; + for (let i = 0; i < next; i++) { + const fx = ((i + 0.5) / next) * res - 0.5; + const i0 = Math.floor(fx); + const tx = fx - i0; + let sum = 0; + for (let c = 0; c < 4; c++) { + const top = at(i0, j0, c) * (1 - tx) + at(i0 + 1, j0, c) * tx; + const bot = at(i0, j0 + 1, c) * (1 - tx) + at(i0 + 1, j0 + 1, c) * tx; + w[c] = top * (1 - ty) + bot * ty; + sum += w[c]; + } + const o = (j * next + i) * 4; + if (!(sum > 0)) { + out[o] = 255; + continue; + } + // round three channels, give the remainder to the largest so the sum is exact + let big = 0; + for (let c = 1; c < 4; c++) if (w[c] > w[big]) big = c; + let acc = 0; + for (let c = 0; c < 4; c++) { + if (c === big) continue; + out[o + c] = Math.round((w[c] / sum) * 255); + acc += out[o + c]; + } + out[o + big] = 255 - acc; + } + } + return out; +} diff --git a/src/lib/terrainScatterCore.js b/src/lib/terrainScatterCore.js index 6200bfb6e..11c29d072 100644 --- a/src/lib/terrainScatterCore.js +++ b/src/lib/terrainScatterCore.js @@ -137,50 +137,542 @@ export function eraseInCircle(list, cx, cz, radius) { return list.filter((p) => Math.hypot(p.x - cx, p.z - cz) > radius); } -/** @param {any} s */ -function sourceOf(s) { +/** a Library model hash / a pack path: what a source may name */ +const HASH_RE = /^[0-9a-zA-Z_-]{1,128}$/; +const PATH_RE = /^(?:https?:\/\/)?[\w\-./%~:+@]{1,400}$/; + +/** 51 E1: the short note a source row shows (a prefab / an object scattered as its LOOK only) */ +const noteOf = (/** @type {any} */ s) => (typeof s.note === 'string' && s.note ? { note: s.note.slice(0, 160) } : {}); +// 51 union: a prefab's LOOK remembers which prefab it came from, so "To objects" can bring the +// whole prefab back WITH its logic where that prefab is in the library (the user's 2026-10-21 rule) +const prefabOf = (/** @type {any} */ s) => (typeof s.prefab === 'string' && HASH_RE.test(s.prefab) ? { prefab: s.prefab } : {}); + +/** @param {any} s @returns {any} */ +export function sourceOf(s) { if (!s || typeof s !== 'object') return null; if (s.kind === 'primitive' && SCATTER_PRIMITIVES.includes(s.type)) return { kind: 'primitive', type: s.type }; - if (s.kind === 'library' && typeof s.hash === 'string' && /^[0-9a-zA-Z_-]{1,128}$/.test(s.hash)) - return { kind: 'library', hash: s.hash, name: typeof s.name === 'string' ? s.name.slice(0, 120) : '' }; + if (s.kind === 'library' && typeof s.hash === 'string' && HASH_RE.test(s.hash)) + return { kind: 'library', hash: s.hash, name: typeof s.name === 'string' ? s.name.slice(0, 120) : '', ...noteOf(s), ...prefabOf(s) }; + // 51 E1: a pack item, by the same {pack, item, path} a placed kit piece's packRef carries — every + // peer fetches it from the pack CDN, nothing travels but the path + if (s.kind === 'pack' && typeof s.path === 'string' && PATH_RE.test(s.path) && !s.path.includes('..')) + return { + kind: 'pack', + path: s.path, + pack: typeof s.pack === 'string' ? s.pack.slice(0, 80) : '', + item: typeof s.item === 'string' ? s.item.slice(0, 120) : '', + ...noteOf(s) + }; return null; } /** A stable key for "the same source" (the brush paints into the set that matches it). @param {any} s */ export function sourceKey(s) { const n = sourceOf(s); - return !n ? '' : n.kind === 'primitive' ? 'p:' + n.type : 'l:' + n.hash; + return !n ? '' : n.kind === 'primitive' ? 'p:' + n.type : n.kind === 'pack' ? 'k:' + n.path : 'l:' + n.hash; } /** * Validate a stored/wire block. Sets with a bad source or bytes are DROPPED (never * guessed at); empty sets are dropped too, so "erase everything" leaves `sets: []`. + * + * 51 — THE SET FORMAT. A set is PARAMS + SEED + OVERRIDES, plus the painted instances 1.38 + * already stored. Every 51 field is OPTIONAL and written only when it says something, so a + * 1.38 set normalizes to exactly the object it did before (same keys, same order) and a scene + * that uses none of them saves byte-identical: + * data painted instances, the 1.38 8-byte records (the brush writes these) + * seed+gen GENERATED instances: `gen` {count | density, scaleMin, scaleMax, rot, spacing} + * drawn from the seeded generator below — slot k is always the same instance on + * every peer and after a reload; nothing per instance travels + * ov per-slot hand edits {[slot]: {x?, z?, yaw?, scale?, del?: 1}} — a re-roll (a new + * seed) keeps them, keyed by the slot + * sources a MIXED set [{source, w}] (2..8, weights > 0); `source` stays sources[0] + * pick one byte per PAINTED instance in a mixed set: which source it drew + * tint 0..1 colour variation, `sink` metres pushed into the ground (x the instance scale) + * host+box 51-surface: `host: 'mesh'` = the set lives on a NON-terrain mesh (any rock, sphere, + * house): its painted records are 16 bytes (scatterSurfaceCore — xyz over the stored + * `box` [minX, minY, minZ, sizeX, sizeY, sizeZ], a normal, yaw, scale) and its slots + * are placed on the mesh's own triangles. Absent on every terrain set. * @param {any} block @param {(b64: string) => Uint8Array | null} decode - * @returns {{v: number, sets: {id: string, source: any, align: boolean, data: string, count: number, collider?: string}[]} | null} + * @returns {{v: number, sets: ScatterSet[]} | null} */ export function normalizeScatter(block, decode) { if (!block || typeof block !== 'object' || !Array.isArray(block.sets)) return null; + /** @type {ScatterSet[]} */ const sets = []; let total = 0; for (const s of block.sets.slice(0, MAX_SETS)) { if (!s || typeof s !== 'object') continue; - const source = sourceOf(s.source); - const bytes = typeof s.data === 'string' ? decode(s.data) : null; - if (!source || !bytes || bytes.length % 8 !== 0 || !bytes.length) continue; - const count = bytes.length / 8; + const sources = sourcesOf(s); + const source = sources ? sources[0].source : sourceOf(s.source); + if (!source) continue; + const bytes = typeof s.data === 'string' && s.data ? decode(s.data) : null; + // 51-surface: a mesh host's painted record is 16 bytes, and it cannot be read without its box + const mesh = s.host === 'mesh'; + const rec = mesh ? 16 : 8; + if (bytes && bytes.length % rec !== 0) continue; + const count = bytes ? bytes.length / rec : 0; + const box = mesh && count ? boxOf(s.box) : null; + if (mesh && count && !box) continue; + const gen = genOf(s.gen); + if (!count && !gen) continue; if (total + count > MAX_INSTANCES) continue; total += count; const collider = scatterColliderOf(s.collider); - sets.push({ + /** @type {ScatterSet} */ + const set = { id: typeof s.id === 'string' && s.id ? s.id.slice(0, 40) : sourceKey(source), source, align: !!s.align, - data: s.data, + data: count ? s.data : '', count, - ...(collider === 'none' ? {} : { collider }) + ...(collider === 'none' ? {} : { collider }), + ...(mesh ? { host: 'mesh' } : {}), + ...(box ? { box } : {}) + }; + if (gen) { + if (gen.count != null) { + gen.count = Math.min(gen.count, MAX_INSTANCES - total); + total += gen.count; + } + set.seed = (Number(s.seed) >>> 0) || 0; + set.gen = gen; + const ov = overridesOf(s.ov); + if (ov) set.ov = ov; + } + if (sources) { + set.sources = sources; + const pick = count && typeof s.pick === 'string' ? decode(s.pick) : null; + if (pick && pick.length === count) set.pick = s.pick; + } + const tint = clampNum(s.tint, 0, 1); + if (tint) set.tint = tint; + const sink = clampNum(s.sink, SINK_MIN, SINK_MAX); + if (sink) set.sink = sink; + sets.push(set); + } + return { v: blockVersion(sets), sets }; +} + +/** @typedef {{id: string, source: any, align: boolean, data: string, count: number, collider?: string, + * seed?: number, gen?: Gen, ov?: Record, sources?: {source: any, w: number}[], + * pick?: string, tint?: number, sink?: number, host?: 'mesh', box?: number[]}} ScatterSet */ + +/** 51-surface: a stored record box [minX, minY, minZ, sizeX, sizeY, sizeZ] (sizes > 0), else null + * @param {any} box @returns {number[] | null} */ +function boxOf(box) { + if (!Array.isArray(box) || box.length !== 6) return null; + const out = box.map(Number); + return out.every((n) => Number.isFinite(n)) && out[3] > 0 && out[4] > 0 && out[5] > 0 ? out : null; +} +/** @typedef {{count?: number, density?: number, scaleMin: number, scaleMax: number, rot: boolean, + * spacing?: number}} Gen */ +/** @typedef {{x?: number, y?: number, z?: number, yaw?: number, scale?: number, del?: 1}} Override */ + +/** 1 while every set is the 1.38 shape (so an untouched scene saves byte-identical), else 2 + * @param {ScatterSet[]} sets */ +export function blockVersion(sets) { + return sets.some((s) => s.gen || s.sources || s.tint || s.sink || s.host) ? 2 : SCATTER_VERSION; +} + +export const SINK_MIN = -2; +export const SINK_MAX = 5; +export const SPACING_MAX = 50; + +/** @param {any} v @param {number} lo @param {number} hi */ +function clampNum(v, lo, hi) { + const n = Number(v); + return Number.isFinite(n) ? Math.min(hi, Math.max(lo, n)) : 0; +} + +/** @param {any} g @returns {Gen | null} */ +function genOf(g) { + if (!g || typeof g !== 'object') return null; + /** @type {Gen} */ + const out = { + scaleMin: clampNum(g.scaleMin ?? 1, SCALE_MIN, SCALE_MAX), + scaleMax: clampNum(g.scaleMax ?? 1, SCALE_MIN, SCALE_MAX), + rot: g.rot !== false + }; + if (Number.isFinite(Number(g.density)) && g.count == null) out.density = clampNum(g.density, DENSITY_MIN, DENSITY_MAX); + else out.count = Math.floor(clampNum(g.count, 0, MAX_INSTANCES)); + const spacing = clampNum(g.spacing, 0, SPACING_MAX); + if (spacing) out.spacing = spacing; + // 51-surface's masks and modes ride here as further fields; carried through untouched + for (const k of Object.keys(g)) + if (!(k in out) && !['count', 'density', 'scaleMin', 'scaleMax', 'rot', 'spacing'].includes(k) && isPlain(g[k])) /** @type {any} */ (out)[k] = g[k]; + return out; +} + +/** JSON-safe and small: what a carried-through field may hold @param {any} v */ +function isPlain(v) { + try { + const s = JSON.stringify(v); + return typeof s === 'string' && s.length <= 2000; + } catch { + return false; + } +} + +/** @param {any} ov @returns {Record | null} */ +function overridesOf(ov) { + if (!ov || typeof ov !== 'object') return null; + /** @type {Record} */ + const out = {}; + let n = 0; + for (const key of Object.keys(ov)) { + if (n >= MAX_INSTANCES) break; + if (!/^\d{1,5}$/.test(key) || Number(key) >= MAX_INSTANCES) continue; + const o = overrideOf(ov[key]); + if (o) (out[key] = o), n++; + } + return n ? out : null; +} + +/** @param {any} o @returns {Override | null} */ +function overrideOf(o) { + if (!o || typeof o !== 'object') return null; + if (o.del) return { del: 1 }; + /** @type {Override} */ + const out = {}; + // `y`: 51-surface — a slot on a mesh host has a height of its own (a terrain slot's comes from the grid) + for (const k of /** @type {const} */ (['x', 'y', 'z', 'yaw'])) if (Number.isFinite(o[k])) out[k] = o[k]; + if (Number.isFinite(o.scale)) out.scale = Math.min(SCALE_MAX, Math.max(SCALE_MIN, o.scale)); + return Object.keys(out).length ? out : null; +} + +/** @param {any} s @returns {{source: any, w: number}[] | null} 2..8 valid entries, else null */ +function sourcesOf(s) { + if (!Array.isArray(s.sources)) return null; + const out = []; + for (const e of s.sources.slice(0, MAX_MIX)) { + const source = sourceOf(e?.source); + const w = clampNum(e?.w, 0, 1000); + if (source && w > 0) out.push({ source, w }); + } + return out.length >= 2 ? out : null; +} + +/** sources one mixed set may draw (one pick byte keeps the index) */ +export const MAX_MIX = 8; + +// ---- 51: the generator, the overrides, and the ONE instance list -------------------------- +// +// SLOT k of a seeded set is drawn from its OWN stream (seed, k) — never from a stream shared +// with the slots before it — so raising the count adds instances without moving any, a mask +// that rejects slot 3 moves nothing else, and slot 7 is the same rock on every peer and after +// every reload. That is what makes a slot a stable ID that a hand edit can be keyed by. +// Spacing is judged on the RAW slots, before any override: deleting a rock must never make a +// rock it had crowded out appear beside it. +// +// IDS (strings, the JSON key shape): 'g' = generated slot k (edits live in `ov`, a re-roll +// keeps them) · 'p' = painted instance j, the j-th 8-byte record (an edit REWRITES the +// record, a delete drops it — so 'p' ids after j shift down by one; read the list again after +// a structural edit). + +/** @typedef {{id: string, slot?: number, index?: number, x: number, z: number, yaw: number, + * scale: number, src: number, tint: number}} ScatterInstance */ + +/** A 32-bit mix of (seed, k): the seed of slot k's own stream. @param {number} seed @param {number} k */ +export function slotSeed(seed, k) { + let h = ((seed >>> 0) ^ Math.imul(k + 1, 0x9e3779b1)) >>> 0; + h = Math.imul(h ^ (h >>> 16), 0x85ebca6b); + h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35); + return (h ^ (h >>> 16)) >>> 0; +} + +/** + * Slot k's six draws, in a FIXED order (a placer for another surface reads u/v the same way): + * u, v (where), yaw, scale, pick (which source), tint. @param {number} seed @param {number} k + */ +export function slotDraws(seed, k) { + const r = rng32(slotSeed(seed, k)); + return { u: r(), v: r(), yaw: r(), scale: r(), pick: r(), tint: r() }; +} + +/** Which source a draw in [0, 1) picks, by weight. @param {{w: number}[] | undefined} sources @param {number} r */ +export function pickSource(sources, r) { + if (!sources || sources.length < 2) return 0; + let sum = 0; + for (const s of sources) sum += s.w; + let at = r * sum; + for (let i = 0; i < sources.length; i++) { + at -= sources[i].w; + if (at < 0) return i; + } + return sources.length - 1; +} + +/** 51-surface: COUNT mode's attempt budget per wanted instance (scatterSurfaceCore keeps the same number) */ +export const ATTEMPTS_PER_SLOT = 30; + +/** How many slots a seeded set has on these bounds (`room` = what the terrain may still hold). + * @param {ScatterSet} set @param {Bounds} b @param {number} [room] */ +export function slotCount(set, b, room = MAX_INSTANCES) { + const g = set.gen; + if (!g) return 0; + const n = g.count != null ? g.count : Math.floor((g.density ?? 0) * b.sizeX * b.sizeZ); + return Math.max(0, Math.min(n, room, MAX_INSTANCES)); +} + +/** + * The RAW generated instances (no overrides): one per slot that survives `accept` and spacing. + * `place(u, v, k)` maps a slot's draw to local (x, z) — the terrain rectangle by default; another + * surface passes its own (51-surface). `accept(x, z, k)` is a mask (slope, height, layer). + * @param {ScatterSet} set @param {Bounds} b + * @param {{room?: number, place?: (u: number, v: number, k: number) => {x: number, z: number} | null, + * accept?: (x: number, z: number, k: number) => boolean}} [opts] + * @returns {ScatterInstance[]} + */ +export function generateSlots(set, b, opts = {}) { + const g = set.gen; + if (!g) return []; + const n = slotCount(set, b, opts.room); + const lo = Math.min(g.scaleMin, g.scaleMax); + const hi = Math.max(g.scaleMin, g.scaleMax); + const spacing = g.spacing ?? 0; + /** @type {Map} */ + const grid = new Map(); + const cell = (/** @type {number} */ x, /** @type {number} */ z) => Math.floor(x / spacing) + ',' + Math.floor(z / spacing); + /** @type {ScatterInstance[]} */ + const out = []; + // 51-surface: COUNT mode means count PLACED — while a mask or the spacing rejects slots, keep + // drawing (k is still the slot id, so raising the count still adds without moving) up to + // ATTEMPTS_PER_SLOT x count; DENSITY mode stays density x area draws, so a mask keeps the + // density right where it passes + const placing = g.count != null && (!!opts.accept || !!opts.place || spacing > 0); + const attempts = placing ? n * ATTEMPTS_PER_SLOT : n; + for (let k = 0; k < attempts && out.length < n; k++) { + const d = slotDraws(set.seed ?? 0, k); + const at = opts.place ? opts.place(d.u, d.v, k) : { x: b.minX + d.u * b.sizeX, z: b.minZ + d.v * b.sizeZ }; + if (!at) continue; + if (opts.accept && !opts.accept(at.x, at.z, k)) continue; + if (spacing) { + const cx = Math.floor(at.x / spacing); + const cz = Math.floor(at.z / spacing); + let ok = true; + for (let i = -1; i <= 1 && ok; i++) + for (let j = -1; j <= 1 && ok; j++) + for (const p of grid.get(cx + i + ',' + (cz + j)) ?? []) + if (Math.hypot(p.x - at.x, p.z - at.z) < spacing) { + ok = false; + break; + } + if (!ok) continue; + } + const inst = { + id: 'g' + k, + slot: k, + x: at.x, + z: at.z, + yaw: g.rot ? d.yaw * TAU : 0, + scale: lo + (hi - lo) * d.scale, + src: pickSource(set.sources, d.pick), + tint: d.tint * 2 - 1 + }; + if (spacing) { + const key = cell(at.x, at.z); + const list = grid.get(key); + if (list) list.push(inst); + else grid.set(key, [inst]); + } + out.push(inst); + } + return out; +} + +/** A painted record's tint draw: a hash of its quantized x/z (stable under erase, the same on every peer). + * @param {DataView} view @param {number} o */ +function paintedTint(view, o) { + const h = slotSeed(view.getUint32(o, true), 0x51); + return (h / 4294967296) * 2 - 1; +} + +/** + * EVERY instance a set draws: the generated slots with their hand edits applied (deleted ones + * left out), then the painted records. The ONE list the renderer, the colliders and the edit + * tools read. @param {ScatterSet} set @param {Bounds} b @param {(b64: string) => Uint8Array | null} decode + * @param {{room?: number, place?: any, accept?: any}} [opts] + * @returns {ScatterInstance[]} + */ +export function instancesOf(set, b, decode, opts = {}) { + /** @type {ScatterInstance[]} */ + const out = []; + for (const inst of generateSlots(set, b, opts)) { + const o = set.ov?.[String(inst.slot)]; + if (o?.del) continue; + out.push(o ? { ...inst, ...o } : inst); + } + const bytes = set.count && set.data ? decode(set.data) : null; + if (bytes) { + const pick = set.pick ? decode(set.pick) : null; + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + decodeInstances(bytes, b).forEach((inst, j) => { + out.push({ id: 'p' + j, index: j, ...inst, src: pick && set.sources ? Math.min(pick[j] ?? 0, set.sources.length - 1) : 0, tint: paintedTint(view, j * 8) }); }); } - return { v: SCATTER_VERSION, sets }; + return out; +} + +/** @param {string} id @returns {{kind: 'g' | 'p', n: number} | null} */ +export function parseInstanceId(id) { + const m = /^([gp])(\d{1,5})$/.exec(String(id)); + return m ? { kind: /** @type {'g' | 'p'} */ (m[1]), n: Number(m[2]) } : null; +} + +/** + * Apply hand edits. `patch` = {[id]: {x?, z?, yaw?, scale?, del?: true} | null}: a generated id + * writes its override (null clears it, back to the seed's pose); a painted id rewrites its + * record (null does nothing — a painted instance has no seed to fall back to; del drops it). + * Returns a NEW set; the input is untouched. + * @param {ScatterSet} set @param {Record & {del?: any} | null>} patch + * 51-surface: a `host: 'mesh'` set's painted records are 16 bytes — `io.records` is their codec + * (scatterSurfaceCore.surfaceRecords), and the re-encode writes the set's new `box` with them. + * @param {{bounds: Bounds, decode: (b64: string) => Uint8Array | null, encode: (bytes: Uint8Array) => string, + * records?: {decode: (bytes: Uint8Array, set: any) => any[], encode: (list: any[]) => {bytes: Uint8Array, box: number[]}}}} io + * @returns {ScatterSet} + */ +export function withOverrides(set, patch, io) { + /** @type {ScatterSet} */ + const next = { ...set }; + /** @type {Record} */ + const ov = { ...(set.ov ?? {}) }; + /** @type {Map} */ + const painted = new Map(); + for (const [id, edit] of Object.entries(patch ?? {})) { + const p = parseInstanceId(id); + if (!p) continue; + if (p.kind === 'g') { + if (!set.gen || p.n >= MAX_INSTANCES) continue; + const key = String(p.n); + if (edit === null) delete ov[key]; + else { + const o = overrideOf(edit.del ? { del: 1 } : { ...(ov[key]?.del ? {} : ov[key]), ...edit }); + if (o) ov[key] = o; + } + } else if (edit) painted.set(p.n, edit); + } + if (set.gen) { + if (Object.keys(ov).length) next.ov = ov; + else delete next.ov; + } + if (painted.size && set.count) { + const bytes = io.decode(set.data); + const codec = set.host === 'mesh' ? io.records : null; + if (bytes && (codec || set.host !== 'mesh')) { + const list = codec ? codec.decode(bytes, set) : decodeInstances(bytes, io.bounds); + const pick = set.pick ? io.decode(set.pick) : null; + /** @type {Instance[]} */ + const kept = []; + /** @type {number[]} */ + const keptPick = []; + list.forEach((inst, j) => { + const edit = painted.get(j); + if (edit?.del) return; + kept.push(edit ? { ...inst, ...pickPose(edit) } : inst); + if (pick) keptPick.push(pick[j] ?? 0); + }); + next.count = kept.length; + if (codec) { + const enc = codec.encode(kept); + next.data = kept.length ? io.encode(enc.bytes) : ''; + if (kept.length) next.box = enc.box; + else delete next.box; + } else next.data = kept.length ? io.encode(encodeInstances(kept, io.bounds)) : ''; + if (pick) { + if (kept.length) next.pick = io.encode(new Uint8Array(keptPick)); + else delete next.pick; + } + } + } + return next; +} + +/** @param {any} e */ +function pickPose(e) { + /** @type {any} */ + const out = {}; + for (const k of ['x', 'y', 'z', 'yaw', 'scale']) if (Number.isFinite(e[k])) out[k] = e[k]; + if (out.scale != null) out.scale = Math.min(SCALE_MAX, Math.max(SCALE_MIN, out.scale)); + return out; +} + +/** + * 51 E1: make a set MIXED by adding a source (weight `w`; a single-source set becomes a mix of + * its own source at weight 1 and this one). Painted records keep drawing what they drew (pick + * 0 = the original source). Refused (input returned) past MAX_MIX or for a source already in it. + * @param {ScatterSet} set @param {any} source @param {number} w + * @param {{decode: (b64: string) => Uint8Array | null, encode: (bytes: Uint8Array) => string}} io + * @returns {ScatterSet} + */ +export function withSourceAdded(set, source, w, io) { + const s = sourceOf(source); + const list = set.sources ?? [{ source: set.source, w: 1 }]; + if (!s || list.length >= MAX_MIX || list.some((e) => sourceKey(e.source) === sourceKey(s))) return set; + /** @type {ScatterSet} */ + const next = { ...set, sources: [...list, { source: s, w: Math.max(0.01, w || 1) }] }; + if (set.count && !set.pick) next.pick = io.encode(new Uint8Array(set.count)); + return next; +} + +/** + * 51 E1: drop source i from a mixed set. Its PAINTED records go with it (they were that model); + * later picks shift down; a mix left with one source becomes a plain set again. Generated slots + * simply re-pick among what is left (deterministic, the same on every peer). + * @param {ScatterSet} set @param {number} i + * 51-surface: a `host: 'mesh'` set's painted records go through `io.records` (as in withOverrides). + * @param {{bounds: Bounds, decode: (b64: string) => Uint8Array | null, encode: (bytes: Uint8Array) => string, + * records?: {decode: (bytes: Uint8Array, set: any) => any[], encode: (list: any[]) => {bytes: Uint8Array, box: number[]}}}} io + * @returns {ScatterSet} + */ +export function withSourceRemoved(set, i, io) { + if (!set.sources || i < 0 || i >= set.sources.length) return set; + const sources = set.sources.filter((_, k) => k !== i); + /** @type {ScatterSet} */ + const next = { ...set, source: sources[0].source }; + if (set.count) { + const bytes = io.decode(set.data); + const pick = set.pick ? io.decode(set.pick) : new Uint8Array(set.count); + const codec = set.host === 'mesh' ? io.records : null; + if (bytes && pick && (codec || set.host !== 'mesh')) { + const list = codec ? codec.decode(bytes, set) : decodeInstances(bytes, io.bounds); + /** @type {Instance[]} */ + const kept = []; + /** @type {number[]} */ + const keptPick = []; + list.forEach((inst, j) => { + const p = pick[j] ?? 0; + if (p === i) return; + kept.push(inst); + keptPick.push(p > i ? p - 1 : p); + }); + next.count = kept.length; + if (codec) { + const enc = codec.encode(kept); + next.data = kept.length ? io.encode(enc.bytes) : ''; + if (kept.length) next.box = enc.box; + else delete next.box; + } else next.data = kept.length ? io.encode(encodeInstances(kept, io.bounds)) : ''; + if (sources.length > 1 && kept.length) next.pick = io.encode(new Uint8Array(keptPick)); + else delete next.pick; + } + } + if (sources.length > 1) next.sources = sources; + else { + delete next.sources; + delete next.pick; + } + return next; +} + +/** The whole-set RE-ROLL: a new seed, every hand edit kept (keyed by slot). @param {ScatterSet} set @param {number} seed */ +export function withSeed(set, seed) { + return { ...set, seed: seed >>> 0 }; +} + +/** A fresh seed for a new set or a re-roll (Math.random is fine: the SEED is what travels). */ +export function newSeed() { + return (Math.random() * 4294967296) >>> 0; } // ---- heights: the terrain's regular grid, sampled exactly as it is triangulated -------- diff --git a/src/lib/terrainSculpt.js b/src/lib/terrainSculpt.js index ec34df680..73e183370 100644 --- a/src/lib/terrainSculpt.js +++ b/src/lib/terrainSculpt.js @@ -13,9 +13,21 @@ import { endPaintStroke, beginScatterStroke, scatterStrokeMove, + isMeshHost, endScatterStroke, terrainStrokeOpen } from './terrainPaint'; +// 51 E4: the Scatter brush's edit tools (erase / rotate / scale / relax brushes, select) +import { + scatterBrushArmed, + beginEditStroke, + editStrokeMove, + endEditStroke, + editStrokeOpen, + startScatterEditSession, + endScatterEditSession, + clearScatterSelection +} from './scatterEdit'; // Terrain sculpting (T-2): brush raise/lower/smooth/flatten on the Terrain // primitive, replicated through the EXISTING meshgeo channel — live strokes @@ -38,6 +50,10 @@ export const sculptMode = writable('terrain'); * strokes terrainPaint.js runs; the session, lock, radius and strength are shared. * @type {import('svelte/store').Writable<'raise'|'lower'|'smooth'|'flatten'|'paint'|'scatter'>} */ export const sculptOp = writable('raise'); +/** 51-surface: true while a SCATTER session runs on a plain mesh that was entered for scatter only — + * its geometry was left exactly as it is (no soup conversion, no size cap), so the shaping brushes + * are not offered (Sculpt from the object menu gives them). */ +export const sculptScatterOnly = writable(false); export const sculptRadius = writable(3); export const sculptStrength = writable(0.5); @@ -258,11 +274,15 @@ export function rebuildSculptCaches(object) { const MESH_SCULPT_MAX_FLOATS = MAX_SNAPSHOT; /** Enter sculpt mode: Terrain gets the column brush, any other mesh the - * normal brush (selects = locks it either way). @param {string} uuid */ -export function enterSculpt(uuid) { + * normal brush (selects = locks it either way). 51-surface: `{scatter: true}` on a plain mesh opens + * a SCATTER-ONLY session — the scatter brush paints on its surface and the geometry is never touched + * (so a 100k-triangle import is fine, and nothing is converted or sent). + * @param {string} uuid @param {{scatter?: boolean}} [opts] */ +export function enterSculpt(uuid, opts = {}) { const object = objectOf(uuid); if (!object) return false; const isTerrain = !!object.userData?.terrain; + if (!isTerrain && opts.scatter) return enterMeshScatter(uuid, object); if (!isTerrain && (!object.isMesh || !object.geometry?.attributes?.position)) { showToast('Sculpting works on meshes (Terrain gets the height brush)'); return false; @@ -306,14 +326,40 @@ export function enterSculpt(uuid) { if (isTerrain) rebuildWeldMap(object); else rebuildMeshWeldMap(object); sculptMode.set(isTerrain ? 'terrain' : 'mesh'); - // paint / scatter have no meaning on a plain mesh - if (!isTerrain && (get(sculptOp) === 'paint' || get(sculptOp) === 'scatter')) sculptOp.set('raise'); + sculptScatterOnly.set(false); + // paint has no meaning on a plain mesh (no splat map); scatter does since 51-surface + if (!isTerrain && get(sculptOp) === 'paint') sculptOp.set('raise'); + sculptObject.set(uuid); + if (isTerrain) startScatterEditSession(() => get(sculptObject), () => get(sculptOp) === 'scatter' && get(sculptMode) === 'terrain'); + return true; +} + +/** 51-surface: the scatter-only session on a plain mesh (see enterSculpt). @param {string} uuid @param {any} object */ +function enterMeshScatter(uuid, object) { + if (!isMeshHost(object)) { + showToast('Scatter works on a mesh object'); + return false; + } + const lock = get(lockedObjects).find((entry) => entry[1] === uuid); + if (lock) { + showToast('Locked by ' + nameOf(lock[0])); + return false; + } + gizmoSuppressed.set(true); + selectObject(uuid); + weld = null; + meshWeld = null; + sculptMode.set('mesh'); + sculptScatterOnly.set(true); + sculptOp.set('scatter'); sculptObject.set(uuid); return true; } export function exitSculpt() { - if (strokeBefore || terrainStrokeOpen()) endStroke(); // commit a stroke in flight + if (strokeBefore || terrainStrokeOpen() || editStrokeOpen()) endStroke(); // commit a stroke in flight + sculptScatterOnly.set(false); + endScatterEditSession(); sculptObject.set(null); weld = null; meshWeld = null; @@ -404,10 +450,13 @@ export function applyBrushAt(uuid, x, z, op, radius, strength, dt = 0.016) { return touched; } -/** 47: is the current op a terrainPaint stroke (and on a terrain)? */ +/** 47: is the current op a terrainPaint stroke? Paint = a terrain only; scatter = any mesh (51-surface). + * 51 E4: 'scatter-edit' = the Scatter brush with an edit tool armed (scatterEdit.js runs it) — + * on a terrain only (a mesh host keeps Paint + the 47 erase). */ function paintOp() { const op = get(sculptOp); - return get(sculptMode) === 'terrain' && (op === 'paint' || op === 'scatter') ? op : null; + if (op === 'scatter') return get(sculptMode) === 'terrain' && scatterBrushArmed() ? 'scatter-edit' : op; + return get(sculptMode) === 'terrain' && op === 'paint' ? op : null; } /** Stroke begin: snapshot for the ONE undo entry per stroke. @param {string} uuid */ @@ -415,6 +464,7 @@ export function beginStroke(uuid) { const op = paintOp(); if (op === 'paint') return void beginPaintStroke(uuid); if (op === 'scatter') return void beginScatterStroke(uuid); + if (op === 'scatter-edit') return void beginEditStroke(uuid); const object = objectOf(uuid); if (!object?.geometry?.attributes?.position) return; strokeBefore = object.geometry.attributes.position.array.slice(); @@ -426,7 +476,8 @@ export function beginStroke(uuid) { export function strokeMove(uuid, x, z, dt = 0.016, y = 0) { const paint = paintOp(); if (paint === 'paint') return paintStrokeMove(uuid, x, z, dt, get(sculptRadius), get(sculptStrength)); - if (paint === 'scatter') return scatterStrokeMove(uuid, x, z, get(sculptRadius)); + if (paint === 'scatter') return scatterStrokeMove(uuid, x, z, get(sculptRadius), y); + if (paint === 'scatter-edit') return editStrokeMove(uuid, x, z, get(sculptRadius), get(sculptStrength), dt); if (!strokeBefore) return; const op = /** @type {'raise'|'lower'|'smooth'|'flatten'} */ (get(sculptOp)); const changed = @@ -458,9 +509,10 @@ export function strokeMove(uuid, x, z, dt = 0.016, y = 0) { /** Stroke end: flush the pending preview + ONE snapshot commit + undo entry. */ export function endStroke() { - if (terrainStrokeOpen()) { + if (terrainStrokeOpen() || editStrokeOpen()) { endPaintStroke(); endScatterStroke(); + endEditStroke(); return; } const uuid = get(sculptObject); @@ -512,3 +564,9 @@ export function showCursorAt(/** @type {any} */ worldPoint, /** @type {any} */ w export function hideCursor() { if (cursor) cursor.visible = false; } + +// 51 E4: the instance selection belongs to the Scatter brush's Select tool — another brush +// drops it (sits LAST: a module-level subscribe runs at once, the TDZ rule) +sculptOp.subscribe((op) => { + if (op !== 'scatter') clearScatterSelection(); +}); diff --git a/src/lib/terrainSurface.js b/src/lib/terrainSurface.js index e9577b009..11bf72751 100644 --- a/src/lib/terrainSurface.js +++ b/src/lib/terrainSurface.js @@ -3,9 +3,11 @@ import * as THREE from 'three'; import { get } from 'svelte/store'; import { objectsGroup, globalScene, sceneRevision, pokeScene } from '../stores/sceneStore'; import { decodeSplatBlock, encodeSplatBlock, normalizeLayers, baseIsMaterial, base64ToBytes, LAYER_COUNT } from './terrainPaintCore'; -import { normalizeScatter, decodeInstances, buildHeightGrid, sampleHeight } from './terrainScatterCore'; +import { normalizeScatter, instancesOf, blockVersion, buildHeightGrid, sampleHeight, MAX_INSTANCES } from './terrainScatterCore'; +import { buildSurface, surfaceInstancesOf, maskOf, maskIsEmpty, passesMask, splatWeightAt } from './scatterSurfaceCore'; import { lodObject, releaseOwner } from './lod'; import { EXPLICIT_MIN_TRIANGLES } from './lodCore'; +import { registerThinMesh, unregisterThinMesh } from './scatterThin'; // 47 T1 + T2 — HOW A PAINTED / SCATTERED TERRAIN LOOKS, on every peer. A LEAF: three, the // scene store and the two pure cores; no history, no peers (terrainPaint.js is the write @@ -40,7 +42,7 @@ const splats = new WeakMap(); /** @type {WeakMap} */ const patched = new WeakMap(); /** object -> {holder, key, gridKey} */ -/** @type {Map} */ +/** @type {Map}>} */ const scatters = new Map(); /** a 1×1 white texture: the identity for colour × texture while an image is missing */ @@ -60,7 +62,13 @@ function objectOf(uuid) { /** Local XZ bounds of a terrain's geometry (the splat + scatter frame). @param {any} object */ export function terrainBounds(object) { const geo = object?.geometry; - if (!geo?.attributes?.position) return null; + if (!geo?.attributes?.position) { + // 51-surface: a GROUP host (a placed model) has no geometry of its own — its children's + // surface, in its frame, is the scatter frame + const S = object && !object.isMesh ? surfaceOf(object) : null; + if (!S) return null; + return { minX: S.min[0], minZ: S.min[2], sizeX: Math.max(S.max[0] - S.min[0], 1e-3), sizeZ: Math.max(S.max[2] - S.min[2], 1e-3) }; + } if (!geo.boundingBox) geo.computeBoundingBox(); const b = geo.boundingBox; const sizeX = b.max.x - b.min.x; @@ -330,6 +338,38 @@ function primitiveParts(type) { return [{ geometry: geo, material: mat }]; } +/** 51 E5: what an instance of a primitive becomes as a REAL object — the `/create` command + * that builds the SAME geometry as primitiveParts (centred, the way /create builds it), how + * far its centre sits above the instance's base, and the scatter look (colour, roughness). + * @param {string} type @returns {{command: string, offset: number, color: string, roughness: number}} */ +export function scatterPrimitiveSpec(type) { + const args = /** @type {Record} */ ({ + Box: '1 1 1', + Sphere: '0.5 12 8', + Cone: '0.5 1 10', + Cylinder: '0.5 0.5 1 12', + Capsule: '0.3 0.4 4 10', + Dodecahedron: '0.5' + }); + const t = args[type] ? type : 'Dodecahedron'; + const geo = + t === 'Box' + ? new THREE.BoxGeometry(1, 1, 1) + : t === 'Sphere' + ? new THREE.SphereGeometry(0.5, 12, 8) + : t === 'Cone' + ? new THREE.ConeGeometry(0.5, 1, 10) + : t === 'Cylinder' + ? new THREE.CylinderGeometry(0.5, 0.5, 1, 12) + : t === 'Capsule' + ? new THREE.CapsuleGeometry(0.3, 0.4, 4, 10) + : new THREE.DodecahedronGeometry(0.5); + geo.computeBoundingBox(); + const offset = -(geo.boundingBox?.min.y ?? -0.5); + geo.dispose(); + return { command: `/create ${t} ${args[t]}`, offset, color: '#' + new THREE.Color(PRIM_COLOR[t] ?? 0x8a8a84).getHexString(), roughness: 0.9 }; +} + /** hash -> parts (null while loading / missing) */ /** @type {Map} */ const models = new Map(); @@ -364,17 +404,7 @@ function libraryParts(hash) { if (!blob) throw new Error('no bytes'); const ext = String(item.name ?? '').split('.').pop()?.toLowerCase() ?? 'glb'; const root = await explorer.parseObjectFile(await blob.arrayBuffer(), ext); - root.updateMatrixWorld(true); - const inv = new THREE.Matrix4().copy(root.matrixWorld).invert(); - /** @type {{geometry: any, material: any}[]} */ - const parts = []; - root.traverse((/** @type {any} */ m) => { - if (!m.isMesh || !m.geometry?.attributes?.position || m.isSkinnedMesh) return; - const geometry = m.geometry.clone(); - geometry.applyMatrix4(new THREE.Matrix4().multiplyMatrices(inv, m.matrixWorld)); - parts.push({ geometry, material: Array.isArray(m.material) ? m.material[0] : m.material }); - }); - models.set(hash, { parts, state: 'ready' }); + models.set(hash, { parts: bakeParts(root), state: 'ready' }); } catch (error) { console.log('scatter: model ' + hash + ' failed to load', error); models.set(hash, { parts: [], state: 'ready' }); @@ -384,9 +414,53 @@ function libraryParts(hash) { return null; } +/** url -> parts (null while loading) — 51 E1: a pack item, fetched from the pack CDN by every peer + * @type {Map} */ +const packModels = new Map(); + +/** A pack item as instanceable parts (the same bake as a Library model). @param {any} source */ +function packParts(source) { + const key = source.path; + const entry = packModels.get(key); + if (entry) return entry.parts; + packModels.set(key, { parts: null, state: 'loading' }); + void (async () => { + /** @type {{geometry: any, material: any}[]} */ + let parts = []; + try { + const refs = await import('./packRefs'); + const { scene } = await refs.loadPackTemplate(refs.packRefUrl({ pack: source.pack, item: source.item, path: source.path })); + parts = bakeParts(scene); + } catch (error) { + console.log('scatter: pack item ' + key + ' failed to load', error); + } + packModels.set(key, { parts, state: 'ready' }); + scheduleReconcile(true); + })(); + return null; +} + +/** every mesh of a parsed model, its transform relative to the root baked into a geometry clone + * (the model's look; skinned meshes left out) @param {any} root */ +function bakeParts(root) { + root.updateMatrixWorld(true); + const inv = new THREE.Matrix4().copy(root.matrixWorld).invert(); + /** @type {{geometry: any, material: any}[]} */ + const parts = []; + root.traverse((/** @type {any} */ m) => { + if (!m.isMesh || !m.geometry?.attributes?.position || m.isSkinnedMesh) return; + const geometry = m.geometry.clone(); + geometry.applyMatrix4(new THREE.Matrix4().multiplyMatrices(inv, m.matrixWorld)); + parts.push({ geometry, material: Array.isArray(m.material) ? m.material[0] : m.material }); + }); + return parts; +} + /** @param {any} source */ function partsOf(source) { - return source.kind === 'primitive' ? primitiveParts(source.type) : libraryParts(source.hash); + if (source.kind === 'primitive') return primitiveParts(source.type); + if (source.kind === 'pack') return packParts(source); + return libraryParts(source.hash); } const _m = new THREE.Matrix4(); @@ -425,12 +499,14 @@ function makeHolder(terrain) { function clearHolder(holder) { releaseOwner(holder); // 47-int: the cells' LOD entries (lod.js holds them until their owner lets go) for (const child of [...holder.children]) { + unregisterThinMesh(child); holder.remove(child); child.dispose?.(); } } -/** Make an object's scatter holder match its `terrainScatter` block. @param {any} object */ +/** Make an object's scatter holder match its `terrainScatter` block. @param {any} object + * @returns {boolean} true when it re-laid the instances (51-surface: the Inspector's counts follow) */ function reconcileScatter(object) { const block = object.userData?.terrainScatter; let entry = scatters.get(object); @@ -440,23 +516,39 @@ function reconcileScatter(object) { clearHolder(entry.holder); entry.holder.parent?.remove(entry.holder); scatters.delete(object); + return true; } - return; + return false; } const scene = /** @type {any} */ (get(globalScene)); - if (!scene) return; + if (!scene) return false; if (!entry) { entry = { holder: makeHolder(object), key: '', layoutKey: '', terrain: object }; scatters.set(object, entry); } if (entry.holder.parent !== scene) scene.add(entry.holder); const pos = object.geometry?.attributes?.position; - const layoutKey = (pos ? pos.version + ':' + pos.count + ':' + (pos.array?.length ?? 0) : '') + ':' + uuidTag(pos); - const key = JSON.stringify(sets.map((s) => [s.id, s.data, s.align, sourceReady(s.source)])); - if (entry.key === key && entry.layoutKey === layoutKey) return; + // 51-surface: a group host re-lays when any child's geometry or pose changes (its surface key) + const layoutKey = pos + ? pos.version + ':' + pos.count + ':' + (pos.array?.length ?? 0) + ':' + uuidTag(pos) + : 'g:' + (surfaceOf(object) ? surfaces.get(object)?.key ?? '' : ''); + const key = JSON.stringify(sets.map((s) => [s.id, s.data, s.align, s.seed, s.gen, s.ov, s.sources, s.pick, s.tint, s.sink, s.host, s.box, setSources(s).map(sourceReady)])); + // 51-surface: a layer-masked fill follows the PAINT, so a new splat re-lays it (compared by + // reference — the block's data string is replaced on every commit, never mutated) + const splatRef = sets.some((s) => /** @type {any} */ (s.gen)?.layer) ? object.userData.terrainSplat?.data ?? '' : ''; + if (entry.key === key && entry.layoutKey === layoutKey && entry.splatRef === splatRef) return false; + entry.splatRef = splatRef; entry.key = key; entry.layoutKey = layoutKey; - layoutScatter(object, entry.holder, sets); + entry.counts = layoutScatter(object, entry.holder, sets); + return true; +} + +/** 51-surface: how many instances each set DRAWS (painted + generated, after masks, spacing and the + * shared cap) — the Inspector's numbers, read from the last layout. @param {any} object + * @returns {Record} */ +export function scatterCounts(object) { + return scatters.get(object)?.counts ?? {}; } /** attribute identity (a meshgeo swap replaces it with a fresh one at version 0) */ @@ -473,62 +565,298 @@ function uuidTag(attr) { /** @param {any} source */ function sourceReady(source) { - return source.kind === 'primitive' || models.get(source.hash)?.state === 'ready'; + if (source.kind === 'primitive') return true; + if (source.kind === 'pack') return packModels.get(source.path)?.state === 'ready'; + return models.get(source.hash)?.state === 'ready'; +} + +/** every source a set draws (a mixed set's list, else its one source) @param {any} set @returns {any[]} */ +function setSources(set) { + return set.sources ? set.sources.map((/** @type {any} */ e) => e.source) : [set.source]; +} + +/** Every set's instances, the MAX_INSTANCES cap shared across the block in set order (the + * same arithmetic on every peer). @param {any} object @param {any[]} sets @param {any} b */ +function blockInstances(object, sets, b) { + let room = MAX_INSTANCES; + for (const set of sets) room -= set.count; + return sets.map((set) => { + const list = setInstances(object, set, b, Math.max(0, room)); + room -= list.length - set.count; + return list; + }); } -/** @param {any} object @param {any} holder @param {any[]} sets */ +// ---- 51-surface: the one instance list per set, on a terrain OR any mesh ------------------ + +/** object -> its cached surface (a mesh host's triangles + area CDF), keyed by geometry identity + * @type {WeakMap} */ +const surfaces = new WeakMap(); + +/** The host's local surface for fills and the mesh brush, rebuilt only when its geometry + * changes (a 100k-triangle mesh builds in ~20 ms). @param {any} object */ +export function surfaceOf(object) { + const geo = object?.geometry; + const pos = geo?.attributes?.position; + if (!pos) return object ? groupSurfaceOf(object) : null; + const nrm = geo.attributes.normal ?? null; + const key = uuidTag(pos) + ':' + pos.version + ':' + (geo.index ? uuidTag(geo.index) + ':' + geo.index.version : '-') + ':' + (nrm ? uuidTag(nrm) + ':' + nrm.version : '-'); + let entry = surfaces.get(object); + if (!entry || entry.key !== key) { + entry = { key, surface: buildSurface(pos.array, geo.index?.array ?? null, nrm?.array ?? null), index: null }; + surfaces.set(object, entry); + } + return entry.surface; +} + +/** The meshes a GROUP host's surface is made of: every plain, visible-or-not descendant mesh in tree + * order (the order a model's file gives, the same on every peer) — never an InstancedMesh, a skinned + * mesh, or anything scatter/LOD made. @param {any} object */ +function surfaceMeshes(object) { + /** @type {any[]} */ + const out = []; + object.traverse((/** @type {any} */ m) => { + if (m === object || !m.isMesh || m.isInstancedMesh || m.isSkinnedMesh) return; + if (!m.geometry?.attributes?.position) return; + out.push(m); + }); + return out; +} + +const _rel = new THREE.Matrix4(); +const _inv = new THREE.Matrix4(); +const _nm = new THREE.Matrix3(); +const _v = new THREE.Vector3(); + +/** + * 51-surface: a GROUP host — a placed Library model, a kit piece — merges its child meshes into + * ONE surface in the group's own frame (each child's transform relative to the group baked in), so + * a fill covers the whole model and moving the group carries it. The key names every child's + * geometry and its relative pose, so a refilled kit piece or an edited child re-lays. + * @param {any} object + */ +function groupSurfaceOf(object) { + const meshes = surfaceMeshes(object); + if (!meshes.length) return null; + object.updateWorldMatrix(true, true); + _inv.copy(object.matrixWorld).invert(); + // ORDER: the merged triangle order decides which triangle a slot's draw lands on, so it must be + // the same on every peer — and a group's children are NOT in one order everywhere (a peer that + // built the group from messages adds them as they arrive). Sort by what every peer agrees on: + // the child's pose in the group, its vertex count, its name (never a uuid — a parsed model's + // child uuids are minted per peer). + const sorted = meshes + .map((m) => { + const rel = new THREE.Matrix4().multiplyMatrices(_inv, m.matrixWorld); + const pose = rel.elements.map((e) => Math.round(e * 1e4)); + return { m, rel, sortKey: pose.join(',') + '|' + m.geometry.attributes.position.count + '|' + (m.name ?? '') }; + }) + .sort((a, b) => (a.sortKey < b.sortKey ? -1 : a.sortKey > b.sortKey ? 1 : 0)); + meshes.length = 0; + /** @type {string[]} */ + const parts = []; + /** @type {any[]} */ + const rels = []; + for (const { m, rel, sortKey } of sorted) { + const g = m.geometry; + meshes.push(m); + rels.push(rel); + parts.push(uuidTag(g.attributes.position) + '.' + g.attributes.position.version + '.' + (g.index ? uuidTag(g.index) : '-') + '.' + sortKey); + } + const key = 'g|' + parts.join('|'); + let entry = surfaces.get(object); + if (entry && entry.key === key) return entry.surface; + let tris = 0; + for (const m of meshes) tris += (m.geometry.index ? m.geometry.index.count : m.geometry.attributes.position.count) / 3; + const pos = new Float32Array(Math.floor(tris) * 9); + const nrm = new Float32Array(Math.floor(tris) * 9); + let o = 0; + meshes.forEach((m, k) => { + const g = m.geometry; + const p = g.attributes.position; + const n = g.attributes.normal ?? null; + const idx = g.index; + _rel.copy(rels[k]); + _nm.getNormalMatrix(_rel); + const count = idx ? idx.count : p.count; + for (let i = 0; i < count - (count % 3); i++) { + const vi = idx ? idx.getX(i) : i; + _v.fromBufferAttribute(p, vi).applyMatrix4(_rel); + pos[o] = _v.x; + pos[o + 1] = _v.y; + pos[o + 2] = _v.z; + if (n) _v.fromBufferAttribute(n, vi).applyMatrix3(_nm).normalize(); + else _v.set(0, 0, 0); + nrm[o] = _v.x; + nrm[o + 1] = _v.y; + nrm[o + 2] = _v.z; + o += 3; + } + }); + // a child without normals: face normals for all (a zero normal would interpolate to nothing) + const anyMissing = meshes.some((m) => !m.geometry.attributes.normal); + entry = { key, surface: buildSurface(pos.subarray(0, o), null, anyMissing ? null : nrm.subarray(0, o)), index: null }; + surfaces.set(object, entry); + return entry.surface; +} + +/** object -> its terrain height grid, cached the same way @type {WeakMap} */ +const grids = new WeakMap(); +/** @param {any} object */ +function heightGridOf(object) { + const pos = object?.geometry?.attributes?.position; + if (!pos) return null; + const key = uuidTag(pos) + ':' + pos.version; + let entry = grids.get(object); + if (!entry || entry.key !== key) { + entry = { key, grid: buildHeightGrid(pos.array) }; + grids.set(object, entry); + } + return entry.grid; +} + +/** + * A terrain set's fill mask as the generator's `accept(x, z)`: slope + height from the terrain's + * own grid (the drawn surface), the paint layer from its splat map — so painting layer 2 moves the + * "only on layer 2" rocks with the paint, on every peer. undefined = no mask. + * @param {any} object @param {any} set @param {any} b + */ +function terrainAccept(object, set, b) { + const mask = maskOf(set.gen); + if (maskIsEmpty(mask)) return undefined; + const grid = heightGridOf(object); + const splat = mask.layer ? liveSplat(object) : null; + return (/** @type {number} */ x, /** @type {number} */ z) => { + const h = grid ? sampleHeight(grid, x, z) : { y: 0, ny: 1 }; + return passesMask(mask, h, mask.layer ? (i) => (splat ? splatWeightAt(splat.bytes, splat.res, b, x, z, i) : 0) : null); + }; +} + +/** + * EVERY instance one set draws on its host — a terrain's (instancesOf + its mask) or a mesh's + * (surfaceInstancesOf on the host's own triangles). The renderer, the colliders, the brush and the + * edit tools all read this one function, so they cannot disagree about where a rock is. + * @param {any} object @param {any} set @param {any} b @param {number} [room] + */ +function setInstances(object, set, b, room = MAX_INSTANCES) { + if (set.host === 'mesh') { + const S = set.gen ? surfaceOf(object) : null; + // a mesh's paint layer is not a thing (no splat map): a layer mask rejects everything there + const mask = set.gen ? maskOf(set.gen) : {}; + return surfaceInstancesOf(set, S, base64ToBytes, { room, accept: mask.layer ? () => false : null }); + } + return instancesOf(set, b, base64ToBytes, { room, accept: set.gen ? terrainAccept(object, set, b) : undefined }); +} + +/** + * The instances of one set on a host, exactly as they are drawn (the block's shared cap + * included) — what the brush and the edit tools read. @param {any} object @param {string} setId + */ +export function scatterSetInstances(object, setId) { + const b = terrainBounds(object); + const sets = normalizeScatter(object?.userData?.terrainScatter, base64ToBytes)?.sets ?? []; + const at = sets.findIndex((s) => s.id === setId); + if (!b || at < 0) return []; + return blockInstances(object, sets, b)[at]; +} + +/** @param {any} object @param {any} holder @param {any[]} sets @returns {Record} drawn per set */ function layoutScatter(object, holder, sets) { clearHolder(holder); const b = terrainBounds(object); - const pos = object.geometry?.attributes?.position; - const grid = pos ? buildHeightGrid(pos.array) : null; - if (!b) return; - for (const set of sets) { - const parts = partsOf(set.source); - if (!parts || !parts.length) continue; - const list = decodeInstances(/** @type {Uint8Array} */ (base64ToBytes(set.data)), b); - const matrices = instanceMatrices(list, set.align, grid); - for (const part of parts) { - // 47-int (46 LOD): a dense model takes levels. One centre for instances spread over a - // whole terrain means nothing, so a LOD'd part is split into cells — each cell picks - // its level by its NEAREST instance (lod.js) and is culled on its own. A light part - // (a primitive, a low-poly rock) stays ONE draw call per set, as the lane built it. - const lodded = trianglesOfGeo(part.geometry) >= EXPLICIT_MIN_TRIANGLES; - for (const ids of lodded ? cellsOf(list, b) : [list.map((_, k) => k)]) { - const mesh = new THREE.InstancedMesh(part.geometry, part.material, ids.length); - mesh.name = 'terrain-scatter:' + set.id; - mesh.castShadow = true; - mesh.receiveShadow = true; - mesh.userData.scatterSet = set.id; - ids.forEach((id, k) => mesh.setMatrixAt(k, matrices[id])); - mesh.instanceMatrix.needsUpdate = true; - mesh.computeBoundingSphere(); - holder.add(mesh); - if (lodded) lodObject(mesh, {}, holder); + const grid = object.userData?.terrain ? heightGridOf(object) : null; + /** @type {Record} */ + const counts = {}; + if (!b) return counts; + const lists = blockInstances(object, sets, b); + sets.forEach((set, si) => (counts[set.id] = lists[si].length)); + sets.forEach((set, si) => { + const all = lists[si]; + const matrices = instanceMatrices(all, set.align, grid, set.sink ?? 0); + const colors = set.tint ? all.map((inst) => tintColor(inst.tint, set.tint)) : null; + // 51 E1: a MIXED set draws each source from the instances that picked it + setSources(set).forEach((source, src) => { + const parts = partsOf(source); + if (!parts || !parts.length) return; + /** @type {number[]} */ + const mine = []; + all.forEach((inst, k) => { + if (inst.src === src) mine.push(k); + }); + if (!mine.length) return; + for (const part of parts) { + // 47-int (46 LOD): a dense model takes levels. One centre for instances spread over a + // whole terrain means nothing, so a LOD'd part is split into cells — each cell picks + // its level by its NEAREST instance (lod.js) and is culled on its own. A light part + // (a primitive, a low-poly rock) stays ONE draw call per source, as the lane built it. + const lodded = trianglesOfGeo(part.geometry) >= EXPLICIT_MIN_TRIANGLES; + for (const cell of lodded ? cellsOf(mine.map((k) => all[k]), b).map((ids) => ids.map((i) => mine[i])) : [mine]) { + const mesh = new THREE.InstancedMesh(part.geometry, part.material, cell.length); + mesh.name = 'terrain-scatter:' + set.id; + mesh.castShadow = true; + mesh.receiveShadow = true; + mesh.userData.scatterSet = set.id; + // 51: which instance each slot of this mesh draws ('g12' / 'p3') — the edit tools pick by it + mesh.userData.instanceIds = cell.map((k) => all[k].id); + cell.forEach((k, i) => { + mesh.setMatrixAt(i, matrices[k]); + if (colors) mesh.setColorAt(i, colors[k]); + }); + mesh.instanceMatrix.needsUpdate = true; + if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true; + mesh.computeBoundingSphere(); + holder.add(mesh); + if (lodded) lodObject(mesh, {}, holder); + // 51: far instances thin to this device's budget (draws only — colliders read the set) + registerThinMesh(mesh, trianglesOfGeo(part.geometry)); + } } - } - } + }); + }); + return counts; } -/** Terrain-LOCAL instance matrices — the drawn pose, and (48 B1) the collider pose. - * @param {{x: number, z: number, yaw: number, scale: number}[]} list @param {boolean} align - * @param {ReturnType} grid @returns {any[]} */ -function instanceMatrices(list, align, grid) { +/** 51 E1 tint variation: an instance's draw in [-1, 1] times the set's amount, as a multiplier on + * its material colour — lighter/darker with a slight warm/cool lean, never a new hue. + * @param {number} t @param {number} amount */ +function tintColor(t, amount) { + const a = t * amount; + const f = Math.max(0.3, 1 + a * 0.35); + return new THREE.Color(f * (1 + a * 0.06), f, f * (1 - a * 0.06)); +} + +/** Terrain-LOCAL instance matrices — the drawn pose, and (48 B1) the collider pose. `sink` (51) + * pushes each instance into the ground by sink x its scale. 51-surface: an instance that carries its + * own `y` (a mesh host's) stands where it says, on its own normal; a terrain's reads the grid. + * @param {{x: number, y?: number, z: number, nx?: number, ny?: number, nz?: number, yaw: number, scale: number}[]} list @param {boolean} align + * @param {ReturnType} grid @param {number} [sink] @returns {any[]} */ +function instanceMatrices(list, align, grid, sink = 0) { return list.map((inst) => { - const h = grid ? sampleHeight(grid, inst.x, inst.z) : { y: 0, nx: 0, ny: 1, nz: 0 }; + const h = + inst.y != null + ? { y: inst.y, nx: inst.nx ?? 0, ny: inst.ny ?? 1, nz: inst.nz ?? 0 } + : grid + ? sampleHeight(grid, inst.x, inst.z) + : { y: 0, nx: 0, ny: 1, nz: 0 }; _qy.setFromAxisAngle(UP, inst.yaw); if (align) _q.setFromUnitVectors(UP, _n.set(h.nx, h.ny, h.nz)).multiply(_qy); else _q.copy(_qy); - return new THREE.Matrix4().compose(_p.set(inst.x, h.y, inst.z), _q, _s.setScalar(inst.scale)); + const m = new THREE.Matrix4().compose(_p.set(inst.x, h.y, inst.z), _q, _s.setScalar(inst.scale)); + // down the instance's own up axis, so a tilted rock sinks into the slope it leans on + if (sink) m.multiplyMatrices(m, _sinkM.makeTranslation(0, -sink, 0)); + return m; }); } +const _sinkM = new THREE.Matrix4(); /** * 48 B1: what a simulation needs to give a terrain's scatter its colliders — every set that * asks for one (`collider` box / sphere / hull), with the parts it draws and its instances' - * TERRAIN-LOCAL matrices, computed exactly as the render does (same heights, same tilt), so - * a ball rests on the rock it sees. A set whose Library model is still loading is left out + * TERRAIN-LOCAL matrices, computed exactly as the render does (same heights, same tilt, same + * sink), so a ball rests on the rock it sees. A set whose model is still loading is left out * (it is not drawn either); `pending` says so, and the physics side rebuilds when it lands. + * 51: one entry per SOURCE of a mixed set, and EVERY instance — device thinning only ever + * hides far DRAWS, never a collider, so gameplay is the same on a phone and a desktop. * @param {any} object a terrain @returns {{sets: {id: string, collider: string, parts: {geometry: any}[], matrices: any[]}[], pending: boolean}} */ export function scatterPhysicsSets(object) { @@ -536,20 +864,25 @@ export function scatterPhysicsSets(object) { const block = object?.userData?.terrainScatter; const b = block ? terrainBounds(object) : null; if (!b) return out; - const wanted = (normalizeScatter(block, base64ToBytes)?.sets ?? []).filter((s) => s.collider); - if (!wanted.length) return out; - const pos = object.geometry?.attributes?.position; - const grid = pos ? buildHeightGrid(pos.array) : null; - for (const set of wanted) { - const parts = partsOf(set.source); - if (!parts) { - out.pending = true; - continue; - } - if (!parts.length) continue; - const list = decodeInstances(/** @type {Uint8Array} */ (base64ToBytes(set.data)), b); - out.sets.push({ id: set.id, collider: /** @type {string} */ (set.collider), parts, matrices: instanceMatrices(list, set.align, grid) }); - } + const sets = normalizeScatter(block, base64ToBytes)?.sets ?? []; + if (!sets.some((s) => s.collider)) return out; + const grid = object.userData?.terrain ? heightGridOf(object) : null; + const lists = blockInstances(object, sets, b); + sets.forEach((set, si) => { + if (!set.collider) return; + const all = lists[si]; + const matrices = instanceMatrices(all, set.align, grid, set.sink ?? 0); + setSources(set).forEach((source, src) => { + const parts = partsOf(source); + if (!parts) { + out.pending = true; + return; + } + if (!parts.length) return; + const mine = matrices.filter((_, k) => all[k].src === src); + if (mine.length) out.sets.push({ id: set.id, collider: /** @type {string} */ (set.collider), parts, matrices: mine }); + }); + }); return out; } @@ -577,12 +910,18 @@ function cellsOf(list, b) { return [...cells.values()]; } +/** 51 E4: the scene-root holder drawing a terrain's scatter (its children are the + * InstancedMeshes; null when nothing is drawn). Picking reads it. @param {any} object */ +export function scatterHolderOf(object) { + return scatters.get(object)?.holder ?? null; +} + /** @param {string} uuid @param {any} block null = no scatter */ export function applyTerrainScatter(uuid, block) { const object = objectOf(uuid); if (!object) return false; const sets = block ? normalizeScatter(block, base64ToBytes)?.sets ?? [] : []; - if (sets.length) object.userData.terrainScatter = { v: 1, sets }; + if (sets.length) object.userData.terrainScatter = { v: blockVersion(sets), sets }; else delete object.userData.terrainScatter; reconcileScatter(object); pokeScene(); @@ -624,15 +963,19 @@ function scheduleReconcile(force = false) { export function reconcileAll() { const group = /** @type {any} */ (get(objectsGroup)); const live = new Set(); + let laid = false; group?.traverse((/** @type {any} */ o) => { const ud = o.userData; if (!ud) return; if (ud.terrainSplat || splats.get(o)) reconcileSplat(o); if (ud.terrainScatter || scatters.has(o)) { - reconcileScatter(o); + if (reconcileScatter(o)) laid = true; live.add(o); } }); + // a layout that a load / a joiner's sync / a model arriving made: tell the Inspector (the next + // reconcile finds every key equal, so this settles in one round) + if (laid) pokeScene(); for (const [object, entry] of [...scatters]) { if (live.has(object)) continue; clearHolder(entry.holder); diff --git a/static/boot-prefs.js b/static/boot-prefs.js new file mode 100644 index 000000000..19b819a6b --- /dev/null +++ b/static/boot-prefs.js @@ -0,0 +1,22 @@ +// Applied before first paint (src/app.html loads this synchronously in ). A file and not an +// inline