diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0bf3540db..0cf2f2b18 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,6 +58,10 @@ jobs: # `tokens-ok: ` on its line; scripts/check-tokens.cjs says how. - name: redesign tokens run: node scripts/check-tokens.cjs + # 41 G3b: ONE z-index scale (src/styles/ui.css). A page-level literal z-index fails; + # `z-ok: ` on its line for a number code reads back. + - name: z-index scale + run: node scripts/check-zindex.cjs # 34 B3: scene-lint over every scene core ships (the seed) and every scene of the scenes ref # the perf budget is about (perf/budgets.json), with the packs checkout for the kit rules. diff --git a/CHANGELOG.md b/CHANGELOG.md index 7124e11c5..949f7f67c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,114 @@ per release, newest first. HTML comments like this one are stripped before rendering, so maintainer notes stay out of the user-facing window. --> +## 1.32.0 — Your 1.31 review, fixed: sheets that swipe away, tidy docks and a reef that swims 🐠 + +Everything you flagged in 1.31 and in the 1.32 preview, fixed — plus an Image editor and a real reef in the Aquarium. + +### 📱 On a phone + +- 👆 **Every sheet has one grab bar**: menus, the Inspector, Configure Scene, Add, the Explorer and every docked window, + the profile menu, notes and Customize character. Drag the bar to resize (each sheet remembers its height), swipe it + down to the bottom to close. The bar never scrolls away, and no sheet covers **Play** or the selection toolbar. +- 👤 **Customize character and Profile settings open again** from the profile menu. +- 🗂️ **Explorer: press and hold a file or folder** for its action sheet — no more browser image callout; hold and move + to pick it up. The rename field's selected text is readable, and the toolbar scrolls sideways so the project name + is always reachable. +- ➕ **The dock's "+" lists every window** with a show/hide switch. +- 🛠️ Fixed a "Something went wrong: ResizeObserver loop" message with the Explorer docked. + +### 🪟 Docked and floating windows + +- ✖️ **Close a docked window from its tab** — the window itself has no ✕ any more. **Undock** moves to the dock strip, + left of *Minimize the dock*, and undocks the window you are looking at (**Settings ▸ Interface ▸ Windows & chrome ▸ + Undock button** → *All as one tabbed group* takes them all out together). +- 📥 **A floating tab group has a "Dock all tabs" button**, and **dropping a docked tab onto a floating window adds it as + a tab** instead of opening another window. Tab groups no longer lose their header after switching tabs. +- 🏷️ **Long object and file names stay on one line** with "…" (hover or press and hold for the full name) and never + push a window's controls out of place. +- 🧭 The dock's tab strip stays painted behind every tab and **keeps its scroll** when you pick a tab. +- 🗔 **Workspace layouts** is a proper dialog on desktop and phone. + +### 🖼️ Images + +- 📷 **A big photo becomes a texture** instead of being refused — it is scaled to 1024 px. +- ✂️ **New Image editor** (Explorer ▸ Edit image…, the preview's pencil, or Edit beside a texture): crop, rotate, flip, + resize, brightness / contrast / saturation, **Save** or **Save as copy**, and a version history with **Restore + original**. Saving updates every texture made from the image, for everyone in the session. + +### 🌊 Smoother, and honest about it + +- 💧 **Water refraction (and every other automatic quality setting) no longer switches off and on by itself** on phones: + a setting that had to be lowered again right after coming back stays lowered for that scene. +- 🔔 **One notice when quality is lowered for you** — *"Lowered water quality to keep it smooth · Keep full quality"* — + replacing the "Simplified water" strip and the object list's quality toast. + +### 🐠 The Aquarium is a reef + +- 🐟 **Templates ▸ Examples ▸ Aquarium**: clownfish, regal blue tangs and emperor angelfish swim around a reef arch, + staghorn coral, eelgrass and Amazon swords from the new **Aquarium Kit** pack. Simple stand-ins show at once and turn + into the real fish as they download. +- 🧭 **Four new motion nodes for anything** (node editor ▸ Animation): **Follow Path** (speed, loop / ping-pong, + banking), **Wander**, **Orient to Velocity** and **Body Wave** — a wave travelling along a fish, snake, flag or tail. +- ✨ **Fish scales material preset**: an iridescent thin film over a scale relief, drawn lighter on phones and headsets. +- 🛠️ Fixed: LOD groups stuck on their first level in scenes with water. + +### 🔁 …and your review of the 1.32 preview + +- 🎛️ **Toolbar ▸ Edit positions** (right-click or press and hold any toolbar button): drag the toolbar and corner + buttons into place, remove one with the red **−**, add one with the green **+** (up to three in each corner, Play + always stays), then the green **✓** applies. A custom button order no longer shows the wrong icons after a reload. +- ✋ **Selection toolbar**: **Inspect** is an icon that opens *and closes* the properties, and only looks pressed when + they are open; **Select multiple** and **Interact** sit after Scale. Interact clears the selection and gives it back + when you leave it. On touch, **tap a selected object to deselect it**. Adding an object no longer opens the + properties (an open panel follows the new object). +- 🫳 **Carrying things in Interact** follows your finger or cursor from the point you grabbed — no more objects + flung at the camera — and lets go with a gentle throw. +- 📱 **Phone drawers behave like a phone's sheets**: lists scroll, and at the top of the list a pull down (or a + flick) closes the drawer, with resistance and snap points. The main menu closes when you press outside it, the + selection toolbar no longer floats over menus or drawers, and an open note steps through **all** notes (‹ ›) and + returns to where you opened it. +- 🪟 **Tabbed floating windows drag smoothly by touch** (tab names too); press and hold a tab for **Ungroup / Dock / + Close**. On desktop, **drop a window or tab onto the dock's tab bar** to dock it exactly where the marker shows. The + dock's **+** lists every window — **Node editor** and **Image editor** included — and the Image editor can be a + dock tab. +- 🧑‍🎨 **Customize character opens a studio** around your character (**Show in scene** previews it in the world), + keeps it fully framed when you fold or unfold the phone, and **Surprise me** deals a new look on every press; + presets show their defaults and your edits become a **Custom** entry. +- 🪜 **Dialogs cover the whole screen** (the logo included) and What's new sits above every button. Questions about + deleting, restoring or replacing things are **dialogs** instead of toasts (*Delete flow*, *Restore previous + session?*, …). **Settings rows fit their window**: a switch, a short + list, a colour, a button or a small set of choices stays beside its label, and only what cannot fit (wide choices, + text fields) moves underneath — on a phone, an unfolded phone and a narrow desktop window alike. +- 🟢 **The connection pill shows a coloured dot** (grey, pulsing yellow, green, red) and says **Connecting… / + Connected / Offline** beside it whenever there is room; when the top bar is crowded it shrinks to the dot alone — + and **reconnecting after switching tabs no longer toasts**. Only a connection that gives up leaves one notification. +- 👥 **The connected-people panel is wider**: one row per person (name, then where they are underneath, actions on the + right), nothing wraps or breaks; on a phone it is a drawer like the notifications. +- 🧩 **Node editor**: a one-line breadcrumb bar — tap a crumb to jump to its siblings, hold for the full name. +- 📈 **Profiler**: hideable sidebars, a **Details** + **Settings** sidebar on the right and a resizable split between + the graph and the list. +- 🗂️ On a phone the Explorer's press-and-hold menu stays open, and docked windows open with their sidebars hidden + until you show them. +- 📱 **On a phone, Properties, Objects, Notes, Layouts and Configure Scene are tabs of the bottom dock** (beside the + Explorer and the Node editor) instead of drawers behind it: **Inspect** brings Properties to the front, nothing + covers the Play button, and when you unfold the phone or switch to a desktop each panel goes back to its sidebar or + window — and returns when you fold it again. Menus, notifications, your profile and Add stay drawers. Tapping the + bottom-bar button of the view on screen minimizes it (with undocking on, it shows and hides that floating window), + and the main menu closes as Configure Scene opens on a phone (on a desktop it stays open). +- 🎮 **Phone bottom bar**: **More** can be taken off the bar (a one-time note tells you to press and hold any bar item + to get it back), the press-and-hold menu's header reads **More ▸ Edit bar…** and opens More, and **Play can move** + along the bar. Play starts as plain **Play**, like on a desktop; pick VR or AR from its press-and-hold menu and its + icon follows. The selection toolbar now follows a drawer as you resize it, without lagging behind, and the profile + drawer no longer repeats your picture. +- 📂 **Main menu ▸ Project** has a compact two-column layout — **New from template…**, **Load** | **Save ▾**, + **Import** | **Export…** — where **▾** opens one **Save options** dialog with every save setting. A phone keeps the + list; **Settings ▸ Interface ▸ Project actions** switches between Auto, Compact and List. +- 🧩 **Modules on a phone**: Enable/Download/Remove sit to the right of each module, a long description expands when + you tap it (tap again to fold it), and the dev-URL row fits the screen. In Browse, **Install** several modules at + once: each card keeps **Installing…** until it is done, none cancels another, and a failed one says why with + **Retry**. Core no longer opens on a long empty scrollbar after a long Browse list. + ## 1.31.0 — Drag to place: see the size and the spot before you let go 📦 Drag any object from the Explorer onto the scene and see exactly where it will land and how big it is — the way a diff --git a/CLAUDE.md b/CLAUDE.md index 6648365e1..6b1ba30c3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4611,14 +4611,17 @@ loadable play content. Everything a user does must be visible to connected peers - The camera PiP frame deliberately sits BELOW the tiers (z-index 2): it is a viewport overlay whose picture is drawn by the render loop, so panels and HUD must cover it (16-Q6). -- **z-index tiers** (`ui.css` `:root`): viewport 0 · drawer 30 · bottom 35 · window 40 · - hud 45 · **modal 1100 · toast 1200 · menu 1300**. The high modal/toast/menu values - clear the ad-hoc persistent chrome that lives OUTSIDE the scale — Users (avatar/peers) - ~996-998, Connect 300, ContextMenu 999-1001, ThemedSelect 9999. flowbite Modal - hardcodes its dialog z-50 + backdrop z-40, remapped onto `--z-modal` by an UNLAYERED - `[role='dialog'][aria-modal='true']` rule (unlayered beats Tailwind's layered utility - without !important). The logo/burger menu sits at `--z-menu` (top-most); opening a - modal from it calls `closeMenu.set(true)` so the menu can't cover the modal. +- **z-index: ONE SCALE (41 G3b), `ui.css` `:root`, lowest first** — `--z-canvas` 0 · `--z-canvas-overlay` 2 · + `--z-chrome` 30 (FABs, Controls pill, side drawers) · `--z-selection` 32 (phone strip, under every sheet) · + `--z-dock` 35 · `--z-sheet` 38 · `--z-window` 40..44 · `--z-phone-bar` 44 · `--z-hud` 45 · `--z-side-panel` 50 · + `--z-chrome-top` 300 (logo, undo/redo, Connect, the top-right corner) · `--z-popover` 1000 (main menu, profile, + Add, search) · `--z-toast-low` 1050 · `--z-modal` 1100 · `--z-onboarding` 1150 (What's new, Welcome, tours) · + `--z-portal` 1180 (selects, context menus, drag ghosts — above the modals that open them) · `--z-toast` 1200 + (approvals). Modals COVER the logo (the old logo-above-modals one-click trick is gone). Use `var(--z-…)` / + `z-(--z-…)` or `calc(var(--z-…) ± n)`; `npm run check:zindex` (CI) fails a page-level literal ≥ 30, a + number code reads back carries `// z-ok: `. Old names (`--z-drawer/-bottom/-viewport/-menu`) are + aliases. The camera PiP is `--z-canvas-overlay`. App modals are non-modal ``s on `--z-modal`; + `ConfirmModal`/`SessionPrompts` are true modals (top layer). - **flowbite 1.x Dropdowns are TOP-LAYER popovers** (`popover="manual"`, `:popover-open`) — they paint above the ENTIRE page whatever the z-index (measured: a panel at 996 covered the profile avatar at 2000, in the same stacking context). No z-index on an diff --git a/check-baseline.json b/check-baseline.json index a59e9f8b1..b7dfe7ec8 100644 --- a/check-baseline.json +++ b/check-baseline.json @@ -1,6 +1,6 @@ { "comment": "27-I: the svelte-check floor, read ONLY by scripts/check-ratchet.cjs. It used to be hardcoded in release.yml's shell block, where it went stale (362 while the tree measured 359). Ratchet it DOWN whenever a change legitimately removes errors - that is the project convention, and --update does it in one command.", - "errors": 300, - "warnings": 41, - "measured": "2026-10-07" + "errors": 299, + "warnings": 39, + "measured": "2026-10-11" } diff --git a/docs/settings-inventory.md b/docs/settings-inventory.md index bfe59362f..5e65e510d 100644 --- a/docs/settings-inventory.md +++ b/docs/settings-inventory.md @@ -88,6 +88,7 @@ The sticky footer with three big buttons is removed. | 19 | Windows & chrome | Window positions | btn "Reset" | — | `resetWindowLayout()` | | btn "Reset" | | | 20 | Windows & chrome | Touch tools | toggle | D | `touchTools` → `touchTools` | | toggle | | | 21 | Windows & chrome | Allow undocking (touch) | toggle | D | `mobileUndockAllowed` → `mobileUndockAllowed` | Allow undocking on touch screens | toggle | | +| 21b | Lists & menus | **NEW** (41b H14) | — | D | `projectActions` → `ui:projectActions` (absent = Auto) | Project actions | segmented Auto / Compact / List | badge *This device*. The burger menu's PROJECT section: Compact = a two-column grid (New from template… / Load · Save ▾ / Import · Export…), List = the 1.32 rows; Auto = List on the phone shell, Compact elsewhere. Keywords: burger, main menu, compact grid. | | 22 | Lists & menus | Advanced mode | check | D | `advancedMode` → `advancedMode` | Show system objects in the object list | **toggle** | old label kept as a keyword | | 23 | Lists & menus | Environment in list | check | D | `showEnvInList` → `showEnvInList` | Show the environment in the object list | **toggle** | | | 24 | Lists & menus | Object search in menu | check | D | `objectSearchEnabled` → `objectSearchEnabled` | Object search in the right-click menu | **toggle** | | @@ -406,7 +407,7 @@ behind a store's back. | Category | Keys (localStorage, via safeStorage) | |---|---| -| Interface | `allowTextSelection` `avatars:peersClassic` `theme` `customThemes`* `game:soundVolume` `game:musicVolume` `showWelcomeOnStart` `showWhatsNewNotice` `toastsInDrawerOnly` `toursAutoStart` `xrOfferSession` `showRoomsButton` `floatingToolbar` `toolbarOnTop` `touchTools` `mobileUndockAllowed` `advancedMode` `showEnvInList` `objectSearchEnabled` `perfStats:show` `perfReports:send` `viewPrefs.dockPushesViewport` `ui:density` (NEW) | +| Interface | `allowTextSelection` `avatars:peersClassic` `theme` `customThemes`* `game:soundVolume` `game:musicVolume` `showWelcomeOnStart` `showWhatsNewNotice` `toastsInDrawerOnly` `toursAutoStart` `xrOfferSession` `showRoomsButton` `floatingToolbar` `toolbarOnTop` `touchTools` `mobileUndockAllowed` `advancedMode` `showEnvInList` `objectSearchEnabled` `perfStats:show` `perfReports:send` `viewPrefs.dockPushesViewport` `ui:density` (NEW) `ui:projectActions` (NEW, 41b) | | Controls | `enableShiftAdd` `helpersInPlay` `noteDoubleClickToOpen` `trackpadMode` `trackpadPanEnabled` `trackpadReversePan` `trackpadPinchZoom` `allowBrowserZoom` | | Input | `gamepadPrefs` `flow:mouseBindings` `flow:opens` | | Touch controls | `touchControlsPrefs` `touchLookSpeed` `touchControlsTextures` `touchControlsLayouts` | diff --git a/package.json b/package.json index 8eafeb800..e6dc72907 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,8 @@ "sync-llms": "node scripts/sync-llms.cjs", "test:unit": "vitest run", "check:storage": "node scripts/check-storage.cjs", - "check:tokens": "node scripts/check-tokens.cjs" + "check:tokens": "node scripts/check-tokens.cjs", + "check:zindex": "node scripts/check-zindex.cjs" }, "devDependencies": { "@sveltejs/adapter-static": "^3.0.10", diff --git a/scripts/author-templates.cjs b/scripts/author-templates.cjs index 01295c18f..ea31d0624 100644 --- a/scripts/author-templates.cjs +++ b/scripts/author-templates.cjs @@ -116,6 +116,10 @@ // toon (MeshToonMaterial), physical (MeshPhysicalMaterial — also implied by any of: // clearcoat, clearcoatRoughness, transmission, thickness, ior, sheen, sheenColor, // sheenRoughness, iridescence, specularIntensity) +// LOD (any object): lod {fallback?, levels: [{source: 'self'|'pack'|…, ref?, screenSize?}, …]} — the +// object's userData.lod (lodGroupCore). 40 F14: `fallback: true` = the object's own primitives are a +// STAND-IN drawn only while its real model (the pack levels) is loading or unreachable; the card +// draws the real model (every level is built before the thumbnail) // FLAGS (any object): physics {mode, mass, restitution, friction, ...} (userData.physics) · // shadow false (no cast/receive) · pick 'through' (select-through shells: walls, glass) · // origin [x, y, z] (the local pivot a Door preset swings about) · anim '' | [..] @@ -760,6 +764,13 @@ const DEFS = require('./templates/index.cjs').loadDefs(moduleDef); } else if (o.bubbles) { object.userData.bubbles = { ...s.waterPresets.BUBBLE_DEFAULTS, enabled: true, ...o.bubbles }; } + // 40 F14: a LOD group block (lodGroupCore's userData.lod) — `fallback: true` makes the + // object's own primitives the stand-in for the pack model its levels name + if (o.lod) { + const block = s.lodGroupCore?.normalizeLodGroup?.(o.lod) ?? o.lod; + if (!block?.levels) throw new Error('object "' + o.name + '": its lod block has no usable levels'); + object.userData.lod = block; + } // animation presets are applied once the object has a uuid in the scene if (o.anim) animQueue.push({ object, anim: o.anim }); } @@ -1170,6 +1181,43 @@ const DEFS = require('./templates/index.cjs').loadDefs(moduleDef); // The camera is `thumb.camera`, else `view`, else a 3/4 framing of the CONTENT: the // objects' bounds without the camera markers and without floor-like slabs (a 30x40 m // ground framed whole leaves every game piece a speck — the 1690-byte Waves card). + // 40 F14: every level of a FALLBACK LOD group, built now (levels are built when a pick + // first wants one), so the card can draw each object's finest real model + /** @type {{uuid: string, tree: any, world: any}[]} */ + const fallbackSubs = []; + { + /** @type {any[]} */ + const roots = []; + group.traverse((/** @type {any} */ n) => { + if (n.userData?.lod?.fallback) roots.push(n); + }); + for (const r of roots) s.lodGroup.buildAllLevels(r.uuid); + const until = Date.now() + 45000; + while (roots.length && Date.now() < until) { + const pending = roots.some((r) => (s.lodGroup.lodGroupInfo(r.uuid)?.levels ?? []).some((/** @type {any} */ l, /** @type {number} */ i) => i > 0 && (l.status === 'loading' || l.status === 'idle'))); + if (!pending) break; + await new Promise((ok) => setTimeout(ok, 250)); + } + for (const r of roots) { + const trees = s.lodTrees.lodTreesOf(r); + if (!trees.length) { + console.log(' WARN lod fallback: no model built for "' + r.name + '" (the card shows its stand-in)'); + continue; + } + // the finest = the most triangles + /** @param {any} t */ + const tris = (t) => { + let n = 0; + t.traverse((/** @type {any} */ o) => { + if (o.isMesh) n += (o.geometry.index ? o.geometry.index.count : o.geometry.attributes.position.count) / 3; + }); + return n; + }; + const tree = trees.reduce((a, b) => (tris(b) > tris(a) ? b : a)); + r.updateWorldMatrix(true, false); + fallbackSubs.push({ uuid: r.uuid, tree, world: r.matrixWorld.clone() }); + } + } let thumb = null; /** @type {string | null} */ let thumbError = null; try { @@ -1212,6 +1260,20 @@ const DEFS = require('./templates/index.cjs').loadDefs(moduleDef); // the kit references exist to avoid. Every other def renders exactly as before. const clone = kitPacks(d.objects, new Set()).size ? group.clone(true) : new T.ObjectLoader().parse(group.toJSON()); scene.add(clone); + // 40 F14: a FALLBACK LOD group draws its real model (a scene-root substitute the live + // renderer swaps in per frame) — the card shows that model, not the stand-in + for (const sub of fallbackSubs) { + const twin = clone.getObjectByProperty('uuid', sub.uuid); + if (!twin) continue; + twin.traverse((/** @type {any} */ n) => { + if (n.isMesh) n.visible = false; + }); + const copy = sub.tree.clone(true); + copy.matrixAutoUpdate = false; + copy.matrix.copy(sub.world); + copy.visible = true; + scene.add(copy); + } // 21-C C6-b: a module's WORLD lives at the scene root (golden rule 5), so a // card rendered from objectsGroup alone shows a dungeon template as a lone // arch. `thumb.sceneGroups` names scene-root groups to include — cloned into diff --git a/scripts/check-zindex.cjs b/scripts/check-zindex.cjs new file mode 100644 index 000000000..1dad57919 --- /dev/null +++ b/scripts/check-zindex.cjs @@ -0,0 +1,105 @@ +#!/usr/bin/env node +// 41-modals G3b — `npm run check:zindex`: every PAGE-level z-index comes from the scale in +// src/styles/ui.css (`--z-canvas` … `--z-toast`), never a raw number. +// +// What counts as a violation (src/components, src/routes, src/styles, src/modules, src/lib): +// - a CSS / inline-style / JS-string `z-index: N` with N >= 30 +// - a JS `zIndex = 'N'` / `zIndex: N` / `String(N …)` with N >= 30 +// - a Tailwind utility `z-N` / `z-[N]` (any variant prefix) with N >= 30 +// A number BELOW 30 is stacking inside a component's own stacking context (a resize grip over its +// window's tab strip), which the page scale never sees — the lowest page tier (--z-chrome) is 30. +// Comments are stripped first. A value that must stay numeric (code reads it back as a number) says +// so on its line: `// z-ok: ` (the reason is required). +// +// node scripts/check-zindex.cjs report per file, exit 1 on any violation +// node scripts/check-zindex.cjs --json { total, files: {rel: [{line, match}]} } + +const fs = require('fs'); +const path = require('path'); + +const ROOT = path.resolve(__dirname, '..'); +const SCOPE = ['src/components', 'src/routes', 'src/styles', 'src/modules', 'src/lib'].map((d) => path.join(ROOT, d)); +const EXT = /\.(svelte|css|js|ts)$/; +const LOCAL_MAX = 29; + +const RULES = [ + /z-index\s*:\s*(-?\d+)/g, + /zIndex\s*[:=]\s*['"`]?(-?\d+)/g, + /zIndex\s*=\s*String\(\s*(\d+)/g, + /(? m.replace(/[^\n]/g, ' '); + return text + .replace(//g, blank) + .replace(/\/\*[\s\S]*?\*\//g, blank) + .replace(/(^|[\s;{}])\/\/[^\n]*/g, (m, lead) => lead + blank(m.slice(lead.length))); +} + +const PRAGMA = /z-ok\s*:\s*\S.{2,}/; + +/** + * @param {string} text @returns {{line: number, match: string}[]} + */ +function scanText(text) { + /** @type {{line: number, match: string}[]} */ + const out = []; + const raw = text.split('\n'); + stripComments(text) + .split('\n') + .forEach((line, i) => { + if (PRAGMA.test(raw[i])) return; + for (const re of RULES) { + re.lastIndex = 0; + let m; + while ((m = re.exec(line))) { + if (Number(m[1]) > LOCAL_MAX) out.push({ line: i + 1, match: m[0].trim() }); + } + } + }); + return out; +} + +/** @param {string} dir @param {string[]} acc @returns {string[]} */ +function walk(dir, acc) { + if (!fs.existsSync(dir)) return acc; + for (const ent of fs.readdirSync(dir, { withFileTypes: true })) { + const p = path.join(dir, ent.name); + if (ent.isDirectory()) walk(p, acc); + else if (EXT.test(ent.name)) acc.push(p); + } + return acc; +} + +if (require.main === module) { + const json = process.argv.includes('--json'); + /** @type {Record} */ + const files = {}; + let total = 0; + for (const dir of SCOPE) { + for (const file of walk(dir, [])) { + const hits = scanText(fs.readFileSync(file, 'utf8')); + if (!hits.length) continue; + files[path.relative(ROOT, file)] = hits; + total += hits.length; + } + } + if (json) { + console.log(JSON.stringify({ total, files }, null, 1)); + } else { + for (const [rel, hits] of Object.entries(files)) for (const h of hits) console.log(`${rel}:${h.line} ${h.match}`); + console.log( + total + ? `\n${total} page-level z-index literal(s). Use a token from the scale in src/styles/ui.css ` + + '(var(--z-…) / z-(--z-…), or calc(var(--z-…) ± n)); a number the code must read back says why with `z-ok: `.' + : 'check:zindex — every page-level z-index is on the scale.' + ); + } + process.exit(total ? 1 : 0); +} + +module.exports = { scanText, LOCAL_MAX }; diff --git a/scripts/templates/aquarium.cjs b/scripts/templates/aquarium.cjs index 8dd995dc2..e79f61032 100644 --- a/scripts/templates/aquarium.cjs +++ b/scripts/templates/aquarium.cjs @@ -1,59 +1,73 @@ -// Template def `aquarium` (36-water) — one file per template (34 R4 A3). A glass tank of water on a -// stand: sand, rocks, plants, an air stone and five fish swimming loops (Path patrol in each fish's -// own graph). Authored by scripts/author-templates.cjs; the schema is the comment block there. +// Template def `aquarium` (36-water; 40 F14-F16) — one file per template (34 R4 A3). A glass tank of +// water on a stand: sand, rocks, coral, plants, an air stone and six reef fish. Authored by +// scripts/author-templates.cjs; the schema is the comment block there. +// +// 40: the fish, the coral and the plants are REAL MODELS from the aquarium-kit pack (Meshy, CC0), +// each drawn through a FALLBACK LOD group: the object itself is the simple primitive model the +// scene always had, and it stays on screen while the pack file loads (or when it cannot be +// reached); the pack model and its LOD files are levels 1..n. The fish swim with core's +// GENERAL-PURPOSE motion nodes, no fish-specific code: clownfish WANDER inside the water volume +// and ORIENT TO VELOCITY, tangs and angelfish FOLLOW PATH loops with banking, and every fish +// carries a BODY WAVE whose beat follows its speed and bends into its turns. Their stand-ins wear +// the thin-film look of the "Fish scales" preset (iridescence), as the pack models do. const { graphBuilder } = require('./_builders.cjs'); // the tank: 4 × 2 × 1.6 m, its floor on the stand top (y 0.9), water to the brim const TANK = { w: 4, h: 2, d: 1.6, y: 0.9 }; const IN = TANK.y + 0.15; // the sand top -/** a fish: a rounded body, a tail fin and two eyes, facing +Z (Path patrol faces +Z along the path) */ -function fish(name, color, size, start, next) { - const s = size; - return { - type: 'group', - name, - // start on the path, facing along it (Path patrol takes over in the app) - pos: start, - rot: [0, Math.atan2(next[0] - start[0], next[2] - start[2]), 0], - children: [ - { - type: 'box', - name: name + ' body', - color, - size: [0.12 * s, 0.16 * s, 0.34 * s], - bevel: 0.05 * s, - roughness: 0.45 - }, - { - type: 'cone', - name: name + ' tail', - color, - r: 0.08 * s, - h: 0.14 * s, - pos: [0, 0, -0.22 * s], - rot: [-Math.PI / 2, 0, 0], - roughness: 0.5 - }, - { - type: 'sphere', - name: name + ' eye L', - color: 0x101418, - r: 0.018 * s, - pos: [0.058 * s, 0.03 * s, 0.1 * s] - }, - { - type: 'sphere', - name: name + ' eye R', - color: 0x101418, - r: 0.018 * s, - pos: [-0.058 * s, 0.03 * s, 0.1 * s] - } - ] - }; +/** the pack folder of an aquarium-kit item @param {string} item @param {string} file */ +const kitFile = (item, file) => 'aquarium-kit/' + item + '/glTF-Binary/' + file; + +/** + * A FALLBACK LOD group block: LOD0 = the object's own primitives (the stand-in), then the pack + * model (closest), then its LOD files. Thresholds are screen-height shares (lodGroupCore). + * @param {string} item @param {string} base file name without .glb @param {number} lods LOD files + * @param {number[]} [sizes] the screen sizes after which each real level gives way to the next + */ +function fallbackLod(item, base, lods, sizes = [0.05, 0.02, 0.008]) { + const levels = [{ source: 'self', screenSize: 0.9 }, { source: 'pack', ref: kitFile(item, base + '.glb'), screenSize: sizes[0] }]; + for (let i = 1; i <= lods; i++) levels.push({ source: 'pack', ref: kitFile(item, base + '.lod' + i + '.glb'), screenSize: sizes[i] ?? sizes[sizes.length - 1] / (i + 1) }); + return { fallback: true, levels }; +} + +/** the thin-film look of the "Fish scales" preset, on a stand-in part @param {number|string} color */ +const scales = (color) => ({ color, roughness: 0.32, metalness: 0.15, clearcoat: 0.5, clearcoatRoughness: 0.25, iridescence: 0.9 }); + +/** + * A stand-in fish, nose +Z (what Follow Path / Orient to Velocity / Body Wave expect): a rounded + * body, a tail fin, a dorsal fin, optional stripes and two eyes — sized to the pack model's body. + * @param {string} name @param {{body: number[], color: number, fin: number, stripe?: number, stripes?: number[]}} look + */ +function fishParts(name, look) { + const [w, h, l] = look.body; + /** @type {any[]} */ + const parts = [ + { type: 'box', name: name + ' body', size: [w, h, l * 0.78], bevel: Math.min(w, h) * 0.45, ...scales(look.color) }, + { type: 'cone', name: name + ' tail', r: h * 0.42, h: l * 0.26, pos: [0, 0, -l * 0.42], rot: [-Math.PI / 2, 0, 0], ...scales(look.fin), side: 'double' }, + { type: 'cone', name: name + ' dorsal', r: w * 0.25, h: h * 0.45, pos: [0, h * 0.55, -l * 0.05], ...scales(look.fin) }, + { type: 'sphere', name: name + ' eye L', color: 0x101418, r: h * 0.09, pos: [w * 0.45, h * 0.12, l * 0.28] }, + { type: 'sphere', name: name + ' eye R', color: 0x101418, r: h * 0.09, pos: [-w * 0.45, h * 0.12, l * 0.28] } + ]; + for (const [i, z] of (look.stripes ?? []).entries()) + parts.push({ type: 'box', name: name + ' stripe ' + (i + 1), size: [w * 1.04, h * 1.02, l * 0.06], bevel: Math.min(w, h) * 0.45, pos: [0, 0, z * l], color: look.stripe ?? 0xffffff, roughness: 0.4 }); + return parts; } -/** a closed loop inside the tank at height y (world points) */ +const SPECIES = { + clown: { item: 'FishClown', file: 'fish-clown', lods: 2, look: { body: [0.13, 0.14, 0.32], color: 0xff7a1a, fin: 0xff9a3a, stripe: 0xfafafa, stripes: [0.22, 0.02, -0.2] } }, + tang: { item: 'FishTang', file: 'fish-tang', lods: 1, look: { body: [0.09, 0.2, 0.42], color: 0x1f5bff, fin: 0xffd23a } }, + angel: { item: 'FishAngel', file: 'fish-angel', lods: 1, look: { body: [0.08, 0.24, 0.42], color: 0x2a3fb8, fin: 0xffc93a, stripe: 0xffd84a, stripes: [0.12, -0.02, -0.16] } } +}; + +/** a fish object: a top-level group of stand-in parts, its fallback LOD naming the pack model + * @param {string} name @param {keyof typeof SPECIES} kind @param {number[]} pos @param {number} [yaw] */ +function fish(name, kind, pos, yaw = 0) { + const sp = SPECIES[kind]; + return { type: 'group', name, pos, rot: [0, yaw, 0], children: fishParts(name, sp.look), lod: fallbackLod(sp.item, sp.file, sp.lods) }; +} + +/** a closed loop inside the tank (world points): an oval at height y, rising and falling */ function loop(cx, cz, rx, rz, y, wobble, n = 8) { const pts = []; for (let i = 0; i < n; i++) { @@ -63,58 +77,75 @@ function loop(cx, cz, rx, rz, y, wobble, n = 8) { return pts; } -const FISH = [ - { - name: 'Fish orange', - color: 0xff8a2a, - size: 1.1, - path: loop(0, 0, 1.45, 0.5, IN + 0.75, 0.12), - speed: 0.45 - }, - { - name: 'Fish blue', - color: 0x3a8dff, - size: 0.9, - path: loop(0.4, 0.05, 1.1, 0.42, IN + 1.15, 0.1, 10).reverse(), - speed: 0.55 - }, - { - name: 'Fish yellow', - color: 0xffd23a, - size: 0.8, - path: loop(-0.6, -0.1, 0.8, 0.35, IN + 0.45, 0.08), - speed: 0.35 - }, - { - name: 'Fish red', - color: 0xe84a5f, - size: 1.0, - path: loop(0.7, 0, 0.9, 0.4, IN + 1.35, 0.06, 9), - speed: 0.4 - }, - { - name: 'Fish striped', - color: 0xf2f2f2, - size: 1.25, - path: loop(-0.2, 0.1, 1.5, 0.48, IN + 0.95, 0.15, 12).reverse(), - speed: 0.3 +/** a figure of eight across the tank, dipping at the crossing */ +function eight(cx, cz, rx, rz, y, dip, n = 12) { + const pts = []; + for (let i = 0; i < n; i++) { + const a = (i / n) * Math.PI * 2; + pts.push([cx + Math.sin(a) * rx, y - Math.abs(Math.cos(a)) * dip, cz + Math.sin(a * 2) * rz]); } + return pts; +} + +const TANG_PATH = loop(0.1, 0.05, 1.45, 0.5, IN + 1.05, 0.15, 10); +const ANGEL_PATH = eight(-0.1, 0, 1.3, 0.42, IN + 1.45, 0.45); + +const FISH = [ + { name: 'Clownfish 1', kind: 'clown', pos: [-0.9, IN + 0.55, 0.2] }, + { name: 'Clownfish 2', kind: 'clown', pos: [-0.6, IN + 0.45, -0.1] }, + { name: 'Blue tang 1', kind: 'tang', pos: TANG_PATH[0] }, + { name: 'Blue tang 2', kind: 'tang', pos: TANG_PATH[5] }, + { name: 'Angelfish 1', kind: 'angel', pos: ANGEL_PATH[0] }, + { name: 'Angelfish 2', kind: 'angel', pos: ANGEL_PATH[6] } ]; -/** every fish swims its own loop: one Path patrol node in its own graph (targets its owner) */ +// every fish's own graph (its owner is the fish): movers, then the followers that read them const graphs = {}; for (const f of FISH) { const g = graphBuilder(); - g.N('swim', 'pathpatrol', 'Swim', 0, 0, { points: f.path, speed: f.speed, mode: 'loop' }); + if (f.kind === 'clown') { + // clownfish potter about inside the water volume, turning to face where they go + g.N('wander', 'wander', 'Wander', 0, 0, { area: 'Aquarium water', margin: 0.4, speed: 0.22, seed: 0 }); + g.N('face', 'orientvelocity', 'Orient to Velocity', 0, 160, { turnSpeed: 2.5, bank: 0.6, maxBank: 30, pitch: true, minSpeed: 0.02 }); + g.N('wave', 'bodywave', 'Body Wave', 0, 320, { amplitude: 0.07, wavelength: 1, frequency: 2.2, stiffness: 0.3, falloff: 2, speedGain: 4, ampGain: 1, turnBend: 0.35 }); + } else { + const tang = f.kind === 'tang'; + g.N('swim', 'followpath', 'Follow Path', 0, 0, { + points: tang ? TANG_PATH : ANGEL_PATH, + closed: true, + smooth: true, + speed: tang ? 0.42 : 0.3, + offset: f.name.endsWith('2') ? 0.5 : 0, + mode: 'loop', + align: true, + pitch: true, + bank: tang ? 0.7 : 0.5, + maxBank: tang ? 35 : 25 + }); + g.N('wave', 'bodywave', 'Body Wave', 0, 200, { + amplitude: tang ? 0.06 : 0.05, + wavelength: tang ? 1 : 1.2, + frequency: tang ? 1.8 : 1.2, + stiffness: tang ? 0.35 : 0.45, + falloff: 2, + speedGain: 3, + ampGain: 0.6, + turnBend: 0.4 + }); + } graphs[f.name] = g.done(); } +/** a plant or coral: a top-level group on the sand, its stand-in primitives inside, the pack model + * as its real level @param {string} name @param {number[]} pos @param {any[]} parts @param {any} lod @param {number} [yaw] */ +const planted = (name, pos, parts, lod, yaw = 0) => ({ type: 'group', name, pos, rot: [0, yaw, 0], children: parts, lod }); + module.exports = { kind: 'example', slug: 'aquarium', title: 'Aquarium', description: - 'A glass tank of water with fish swimming loops, plants, rocks and bubbles rising from an air stone', + 'A reef tank: clownfish, blue tangs and angelfish swimming with the motion nodes (Follow Path, Wander, Body Wave) among coral, plants and an air stone', license: 'CC0-1.0', author: 'theprototype', tags: ['water', 'showcase'], @@ -154,79 +185,23 @@ module.exports = { size: [TANK.w - 0.04, 0.15, TANK.d - 0.04], pos: [0, TANK.y + 0.075, 0] }, - { - type: 'dodecahedron', - name: 'Rock big', - color: 0x7a7f86, - r: 0.32, - pos: [-1.3, IN + 0.18, -0.35] - }, - { - type: 'dodecahedron', - name: 'Rock small', - color: 0x8d8478, - r: 0.18, - pos: [-0.95, IN + 0.1, -0.5] - }, - { - type: 'icosahedron', - name: 'Rock flat', - color: 0x6b6f75, - r: 0.22, - pos: [1.35, IN + 0.08, 0.35] - }, - { - type: 'capsule', - name: 'Plant 1', - color: 0x2e9b4f, - r: 0.05, - h: 0.9, - pos: [-1.6, IN + 0.5, -0.55], - rot: [0.08, 0, 0.12] - }, - { - type: 'capsule', - name: 'Plant 2', - color: 0x3cb85c, - r: 0.04, - h: 1.2, - pos: [-1.45, IN + 0.65, -0.62], - rot: [-0.06, 0, -0.1] - }, - { - type: 'capsule', - name: 'Plant 3', - color: 0x27864a, - r: 0.05, - h: 0.7, - pos: [1.6, IN + 0.4, -0.55], - rot: [0.05, 0, -0.15] - }, - { - type: 'cone', - name: 'Plant 4', - color: 0x4fbf6a, - r: 0.12, - h: 0.8, - pos: [1.25, IN + 0.4, -0.6] - }, - { - type: 'cone', - name: 'Plant 5', - color: 0x2e9b4f, - r: 0.1, - h: 1.05, - pos: [0.55, IN + 0.52, -0.62] - }, - { - type: 'torus', - name: 'Coral ring', - color: 0xff6f91, - r: 0.14, - tube: 0.05, - pos: [0.2, IN + 0.12, 0.45], - rot: [-Math.PI / 2, 0, 0] - }, + { type: 'dodecahedron', name: 'Rock big', color: 0x7a7f86, r: 0.32, pos: [-1.3, IN + 0.18, -0.35] }, + { type: 'dodecahedron', name: 'Rock small', color: 0x8d8478, r: 0.18, pos: [-0.95, IN + 0.1, -0.5] }, + { type: 'icosahedron', name: 'Rock flat', color: 0x6b6f75, r: 0.22, pos: [1.35, IN + 0.08, 0.35] }, + // the eelgrass along the back glass (was Plants 1-3: capsules) + planted('Eelgrass 1', [-1.6, IN, -0.5], [{ type: 'capsule', name: 'Eelgrass 1 stand-in', color: 0x2e9b4f, r: 0.05, h: 0.9, pos: [0, 0.5, 0], rot: [0.08, 0, 0.12] }], fallbackLod('PlantEelgrass', 'plant-eelgrass', 0, [0.02])), + planted('Eelgrass 2', [-1.2, IN, -0.6], [{ type: 'capsule', name: 'Eelgrass 2 stand-in', color: 0x3cb85c, r: 0.04, h: 1.2, pos: [0, 0.65, 0], rot: [-0.06, 0, -0.1] }], fallbackLod('PlantEelgrass', 'plant-eelgrass', 0, [0.02]), 1.3), + planted('Eelgrass 3', [1.65, IN, -0.5], [{ type: 'capsule', name: 'Eelgrass 3 stand-in', color: 0x27864a, r: 0.05, h: 0.7, pos: [0, 0.4, 0], rot: [0.05, 0, -0.15] }], fallbackLod('PlantEelgrass', 'plant-eelgrass', 0, [0.02]), 2.4), + // the Amazon swords (was Plants 4-5: cones) + planted('Amazon sword 1', [1.15, IN, -0.55], [{ type: 'cone', name: 'Amazon sword 1 stand-in', color: 0x4fbf6a, r: 0.12, h: 0.8, pos: [0, 0.4, 0] }], fallbackLod('PlantSword', 'plant-sword', 0, [0.02])), + planted('Amazon sword 2', [0.45, IN, -0.6], [{ type: 'cone', name: 'Amazon sword 2 stand-in', color: 0x2e9b4f, r: 0.1, h: 1.05, pos: [0, 0.52, 0] }], fallbackLod('PlantSword', 'plant-sword', 0, [0.02]), 0.7), + // the reef rock arch a fish can swim through (was the torus "Coral ring") + planted('Coral arch', [0.2, IN, 0.3], [{ type: 'torus', name: 'Coral arch stand-in', color: 0xd9a77e, r: 0.3, tube: 0.1, pos: [0, 0.3, 0] }], fallbackLod('CoralArch', 'coral-arch', 1, [0.04, 0.015])), + planted('Staghorn coral', [1.0, IN, 0.15], [ + { type: 'cone', name: 'Staghorn coral stand-in 1', color: 0xf3c9b5, r: 0.06, h: 0.45, pos: [0, 0.22, 0], rot: [0, 0, 0.25] }, + { type: 'cone', name: 'Staghorn coral stand-in 2', color: 0xf0b3c8, r: 0.05, h: 0.38, pos: [0.08, 0.19, 0.05], rot: [0.2, 0, -0.3] }, + { type: 'cone', name: 'Staghorn coral stand-in 3', color: 0xf6d6c4, r: 0.05, h: 0.32, pos: [-0.07, 0.16, -0.04], rot: [-0.25, 0, 0.1] } + ], fallbackLod('CoralBranch', 'coral-branch', 0, [0.02])), { type: 'cylinder', name: 'Air stone', @@ -244,7 +219,7 @@ module.exports = { wobble: 0.6 } }, - ...FISH.map((f) => fish(f.name, f.color, f.size, f.path[0], f.path[1])) + ...FISH.map((f) => fish(f.name, /** @type {any} */ (f.kind), f.pos)) ], graphs, view: { pos: [3.4, 2.7, 4.6], target: [0, 1.75, 0] } diff --git a/src/App.svelte b/src/App.svelte index 6cd1bc9f3..a092fc214 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -35,6 +35,7 @@ import Explorer from './components/editors/Explorer.svelte' import TextEditorWindow from './components/editors/TextEditorWindow.svelte' import FilePreviewWindow from './components/editors/FilePreviewWindow.svelte' + import ImageEditorWindow from './components/editors/ImageEditorWindow.svelte' import { previewWindows } from './lib/fileWindows' import ModelPreviewWindow from './components/editors/ModelPreviewWindow.svelte' import DungeonMinimap from './components/play/DungeonMinimap.svelte' @@ -125,6 +126,7 @@ import { startMusicToolbox } from './lib/musicToolbox' import { startCloudPlugin } from '$lib/cloudPlugin' import { startShaderGraphs } from '$lib/shaderGraph' import { startMaterialSharing } from '$lib/materialSharing' + import { startMaterialTiers } from '$lib/materialTiers.js' import { startShaderSync } from '$lib/shaderSync' import { startHudSync } from '$lib/hudSync' import { startHudImages } from '$lib/hudImages' @@ -139,7 +141,7 @@ import { startMusicToolbox } from './lib/musicToolbox' import { startSharedLibrary } from '$lib/sharedLibrary' import { startSceneIdentity } from '$lib/sceneIdentity' import GameChip from './components/hud/GameChip.svelte' - import SimplifiedWaterNotice from './components/water/SimplifiedWaterNotice.svelte' // 36-fb-water F27 + import AutoQualityNotice from './components/AutoQualityNotice.svelte' // 40 F12 (was SimplifiedWaterNotice) import ShortcutSheet from './components/menu/ShortcutSheet.svelte' // 36 I3 import HudLayer from './components/hud/HudLayer.svelte' import PickCycleHint from './components/menu/PickCycleHint.svelte' // 36 F22 / S6 / S12 @@ -298,6 +300,8 @@ import { startMusicToolbox } from './lib/musicToolbox' // (GLTF, a peer's per-object messages, undo) rebuilds materials, and the id survives // where the instance does not startMaterialSharing() + // 40 F16: draw physical materials at this device's look tier (transmission / thin film) + startMaterialTiers() startShaderSync() startHudSync() startHudImages() @@ -387,6 +391,8 @@ import { startMusicToolbox } from './lib/musicToolbox' {#await import('./components/editors/CodeWorkspace.svelte') then m}{/await} {/if} + + {#each $previewWindows as w, i (w.id)} @@ -452,7 +458,7 @@ import { startMusicToolbox } from './lib/musicToolbox' editor's stand-in for a game's screens, which HudLayer no longer draws outside Play. Editor-only (it hides itself in Play, in VR and in embed mode). --> - + @@ -468,7 +474,7 @@ import { startMusicToolbox } from './lib/musicToolbox' `bottom` is the open dock's height, which is what makes the dock a REGION (the DCC behaviour: the canvas ends where the panel begins) instead of an overlay drawn on top of a full-window canvas. No `z-index` — a stacking context here - would trap the PiP frame and the framing guide; the dock's --z-bottom (35) + would trap the PiP frame and the framing guide; the dock's --z-dock (35) already sits above the canvas at the default 0. No `transition` either: each step reallocates the composer's render targets, so animating the inset turns one realloc into one per frame. --> diff --git a/src/components/AutoQualityNotice.svelte b/src/components/AutoQualityNotice.svelte new file mode 100644 index 000000000..2cfec0197 --- /dev/null +++ b/src/components/AutoQualityNotice.svelte @@ -0,0 +1,93 @@ + diff --git a/src/components/ContextMenu.svelte b/src/components/ContextMenu.svelte index 4fed80151..ffa140164 100644 --- a/src/components/ContextMenu.svelte +++ b/src/components/ContextMenu.svelte @@ -6,6 +6,9 @@ import { minimalScroll } from '$lib/ui/minimalScroll.js'; import { autofocusOk, typeToFocus } from '$lib/inputDevice'; import { safeStorage } from '$lib/safeStorage'; + import SheetGrip from './ui/SheetGrip.svelte'; + import { readSheetH, saveSheetH } from '$lib/ui/sheetDrag.js'; + import { phoneShellActive, phoneSheetMaxH } from '$lib/ui/phoneShell.js'; // Generic context menu. items: [{ label, action?, disabled?, tooltip?, danger?, // icon?, hint?, checked?, keepOpen?, rowActions?, children?: items[] } | @@ -59,6 +62,18 @@ * Absent = byte-identical to before. */ export let startSearch: boolean = false; + // 40 F1: on the phone every menu is a bottom sheet (phone.css), and the Add list is the + // one opened from the bar — so it wears the shared grab bar: drag to resize (remembered, + // `menuSheetH`), swipe down to the end to close. The height is ONE root CSS var, so a + // submenu laid over its parent (drill in place) takes the same height. + let menuSheetH = readSheetH('menuSheetH', 0); + $: menuShownH = Math.min(menuSheetH || Math.round((typeof window === 'undefined' ? 800 : window.innerHeight) * 0.6), $phoneSheetMaxH || 9999); + $: if ($phoneShellActive && typeof document !== 'undefined') document.documentElement.style.setProperty('--ctx-sheet-h', menuShownH + 'px'); + function menuSheetResize(h: number, done: boolean) { + menuSheetH = Math.round(h); + if (done) saveSheetH('menuSheetH', menuSheetH); + } + const rawDispatch = createEventDispatcher(); const dispatch = (name: string) => { if (name === 'close') onclose?.(); @@ -392,15 +407,19 @@ landed on the backdrop: a long-press opens this menu while the finger is still down, so the backdrop mounts underneath it and the finger's own lift used to land here and shut the menu instantly. Requiring the pointerdown too means the - opening gesture cannot close what it just opened, with no timers involved. --> + opening gesture cannot close what it just opened, with no timers involved. + 41 G18: the same rule for `contextmenu`. Android fires its OWN contextmenu for a long press + at the finger; when that lands after our hold opened this menu (touchHold, 450 ms) its + target is this backdrop, and it shut the Explorer long-press menu the moment it showed. + A right-click outside still closes: its pointerdown lands here first. --> -
-
-
- {#each $dockTabs as tab (tab.key)} - - - - - {/each} + +
+
+
+
+ {#each $dockTabs as tab (tab.key)} + + + + + {/each} +
+ + {#if dragKey && !dropOut} +
+ {/if}
- - {#if dragKey && !dropOut} -
- {/if}
+ {/if}
+{#if sheetOpen} + +{/if} {#if addMenu} (addMenu = null)} /> {/if} @@ -270,4 +376,16 @@ .dt-drop { background: var(--accent); } + /* 40 F6: the bar paints; it shrinks to the row (min-width 0) while the tabs inside the bare + scroll box keep their full width (flex-shrink 0), so the paint is under every tab */ + .dt-bar { + display: flex; + min-width: 0; + } + .dt-tabs { + display: flex; + flex-shrink: 0; + align-items: center; + gap: 2px; + } diff --git a/src/components/DockViewsSheet.svelte b/src/components/DockViewsSheet.svelte new file mode 100644 index 000000000..63d04ad4d --- /dev/null +++ b/src/components/DockViewsSheet.svelte @@ -0,0 +1,101 @@ + + +
+ +
    + {#each rows as row (row.key)} +
  • + + + {row.title} + {row.tooltip} + + setDockViewShown(row.key, on)} + /> +
  • + {/each} +
+
+
+ + diff --git a/src/components/Flow.svelte b/src/components/Flow.svelte index 0d72d5252..3c4b5c36a 100644 --- a/src/components/Flow.svelte +++ b/src/components/Flow.svelte @@ -25,6 +25,7 @@ import { dockAddItems } from '$lib/dockMenu'; import { fly } from 'svelte/transition'; import { safeStorage } from '$lib/safeStorage'; + import { readPanelOpen } from '$lib/ui/handheldPanels.js'; const clampH = (h: number) => Math.min(Math.max(h || 320, 200), Math.round(window.innerHeight * 0.8)); // 18-B: floating-window size limits, shared with the clamp helpers @@ -33,9 +34,8 @@ let docked = $state(true); // mirrors Nodes' palette-open (bound below) so the docked content only insets above // the Controls HUD when the node palette is actually shown (overlapping the HUD) - let paletteOpen = $state( - typeof localStorage !== 'undefined' ? safeStorage.getItem('flowPaletteOpen') !== 'false' : true - ); + // 41 G18: the same per-device rule as the palette itself (hidden on a phone until opened) + let paletteOpen = $state(readPanelOpen('flowPaletteOpen', true)); let winW = $state(760); let winH = $state(480); // keep the floating window within the viewport (a persisted wide rect used to push @@ -110,9 +110,11 @@ // another dock view. They start docked, so they appear as dock tabs. Same list the // strip's "+" renders ($lib/dockMenu) — they used to be two copies that drifted. let addMenu: { x: number; y: number } | null = $state(null); - const addItems = dockAddItems(); + // 41 G22: built per open (it reads what is docked right now) and without this window's own row + let addItems: any[] = $state([]); function openAddMenu(e: MouseEvent) { const r = (e.currentTarget as HTMLElement).getBoundingClientRect(); + addItems = dockAddItems({ exclude: ['flow'] }); addMenu = { x: r.left, y: r.bottom + 4 }; } @@ -186,14 +188,14 @@ id="flow-list" transition:fly={{ y: 320, duration: 200 }} class="tp-ui tp-dock-panel fixed inset-x-0 bottom-0 p-2 {dockVisible ? '' : 'hidden'}" - style="z-index: var(--z-bottom); height: {$dockHeight}px" + style="z-index: var(--z-dock); height: {$dockHeight}px" data-key-scope="panel" role="region" aria-label="Node editor (docked)" >
-
diff --git a/src/components/Menu.svelte b/src/components/Menu.svelte index ca04013b2..62bf9a259 100644 --- a/src/components/Menu.svelte +++ b/src/components/Menu.svelte @@ -4,6 +4,7 @@ import MeshGenModal from './menu/MeshGenModal.svelte'; import MeshJobsCard from './menu/MeshJobsCard.svelte'; import Toasts from './menu/Toasts.svelte'; + import SessionPrompts from './menu/SessionPrompts.svelte'; import FramingGuide from './menu/FramingGuide.svelte'; import CameraPipWindow from './menu/CameraPipWindow.svelte'; import Connect from './menu/Connect.svelte'; @@ -45,6 +46,7 @@ // 38 R9: below 640px the decluttered phone shell (top bar, context strip, bottom bar, // sheets) is drawn over the same components — see PhoneShell.svelte import PhoneShell from './phone/PhoneShell.svelte' + import PhoneDockHost from './phone/PhoneDockHost.svelte' import { phoneShell } from '$lib/ui/phoneShell.js' @@ -81,7 +83,10 @@ + + + diff --git a/src/components/Outline.svelte b/src/components/Outline.svelte index 993e69734..2c28368f5 100644 --- a/src/components/Outline.svelte +++ b/src/components/Outline.svelte @@ -36,6 +36,8 @@ import { coarsePointer } from '$lib/inputDevice'; import { viewPrefs } from '$lib/viewPrefs'; import { recordingClean } from '$lib/helperLayer'; + // 41 G10: the character studio hides the scene, so the selection glare stands down with it + import { studioIsolating } from '$lib/avatars/avatarState'; import { shadowQuality } from '$lib/lightParams'; import { useTask, useThrelte } from '@threlte/core'; import { framesHeld, registerComposerWarm, schedule } from '$lib/sceneLoader'; @@ -578,7 +580,7 @@ // 30b P1: ...and in INTERACT, which is play's hands without play's lock: a // player-style view of the scene has no selection glare either // 36-share: a clean recording is a viewer's picture too - const playing = $isLocked || $editorMode === 'interact' || $recordingClean; + const playing = $isLocked || $editorMode === 'interact' || $recordingClean || $studioIsolating; if ($objectsGroup && !editing && !playing) for (const uuid of $selectedObjects) addMeshes(outlineEffectSelected.selection, uuid); outlineEffectLocked.selection.clear(); diff --git a/src/components/PingMarkers.svelte b/src/components/PingMarkers.svelte index e28c25a85..1ece37363 100644 --- a/src/components/PingMarkers.svelte +++ b/src/components/PingMarkers.svelte @@ -58,7 +58,8 @@ {#each $pings as ping (ping.id)} - + + diff --git a/src/components/Scene.svelte b/src/components/Scene.svelte index fbde6d4d4..b7577390e 100644 --- a/src/components/Scene.svelte +++ b/src/components/Scene.svelte @@ -9,7 +9,7 @@ import { peers, username, userdata, specatorMode, avatarConfig, viewportMenu, objectContextMenu, viewportMenuOpener, addMenu, addMenuOpener, showToast, multiSelectMode } from '../stores/appStore'; import { get } from 'svelte/store'; import { vrPostEnabled } from '$lib/viewportOverrides'; - import { isLocked, editorMode, editorCam, isVRMode, globalScene, objectsGroup, showGrid, TControls, selectedObject, selectedObjects, lockedObjects, marqueeRect, worldRig, vrOverride, specators, globalCamera, globalRenderer, orbitControls, passthroughActive, sessionCompositesOverRoom, vrObjectsPanelOpen, vrPaletteOpen, vrPropsPanelOpen, vrPrefabsPanelOpen, vrChatPanelOpen, vrEditMenuOpen, vrSnapMenuOpen, vrSettingsPanelOpen, vrApprovePanelOpen, vrToolMode, viewMode, contextLost } from '../stores/sceneStore'; + import { isLocked, editorMode, editorCam, isVRMode, globalScene, objectsGroup, showGrid, TControls, selectedObject, selectedObjects, lockedObjects, marqueeRect, worldRig, vrOverride, specators, globalCamera, globalRenderer, orbitControls, passthroughActive, sessionCompositesOverRoom, vrObjectsPanelOpen, vrPaletteOpen, vrPropsPanelOpen, vrPrefabsPanelOpen, vrChatPanelOpen, vrEditMenuOpen, vrSnapMenuOpen, vrSettingsPanelOpen, vrApprovePanelOpen, vrToolMode, viewMode, contextLost, readVrOverride } from '../stores/sceneStore'; import { selectObject, deselectObject, @@ -30,7 +30,7 @@ import { holdBody, releaseBody } from '$lib/physics'; import { sculptObject, enterSculpt, beginStroke, strokeMove, endStroke as sculptEndStroke, showCursorAt, hideCursor } from '$lib/terrainSculpt'; import { sceneHits } from '$lib/scenePick'; - import { pickStack, chooseInStack, altCycleIndex } from '$lib/selectThrough'; + import { pickStack, chooseInStack, altCycleIndex, primaryIndex } from '$lib/selectThrough'; import { editorPass } from '$lib/pickPass'; // 36 F22 import { showPickPreview, hidePickPreview, notePickCycled } from '$lib/pickCycle'; // 36 F22 / S6 import { tickModuleProxy, selectModuleGroup, moduleGroupOf } from '$lib/moduleContent'; @@ -189,7 +189,7 @@ $userdata = $userdata; $showGrid = safeStorage.getItem('showGrid') === 'false' ? false : true; - $vrOverride = safeStorage.getItem('vrOverride'); + $vrOverride = readVrOverride(); // 41b H7: a choice, or this device's default (a phone plays on its screen) camera.current.position.set(10.5, 7.57, 11.4); let fov = camera.current.fov let resetSettings = false; @@ -563,7 +563,17 @@ // module handler runs only if it registered for Edit (so a piano or a puzzle piece no // longer swallows the select) and On Click nodes do NOT fire — Interact is where the // scene reacts. null = VR's trigger, unchanged: every handler first, then select + pulse. - function raycastSelect(additive = false, mode: string | null = null, click: { x: number; y: number; t: number } | null = null, cycle = false) { + // 41 G14: `touch` = the press was a finger, which has no Shift: a tap on an object that is + // already selected LETS GO of it (a double-tap still does the double-click action, and in + // Multi-select a tap toggles as Shift does). `onGizmo` = the tap landed on the seated gizmo, + // where the only thing a still tap may do is that letting go — anything else is the gizmo's. + function raycastSelect( + additive = false, + mode: string | null = null, + click: { x: number; y: number; t: number } | null = null, + cycle = false, + { touch = false, onGizmo = false }: { touch?: boolean; onGizmo?: boolean } = {} + ) { // module-owned interactive groups live at the scene root (pong, dungeon, ...) // 30 P3: in EDIT the nearest module-content hit that no edit handler took is // remembered, so a click on a board or a dungeon selects its PROXY (the object list's @@ -585,11 +595,17 @@ // aside too; Alt+click walks the whole stack front to back instead const stack = pickStack(hits, topLevelObjectOf, editorPass()); const altPick = cycle && click ? altCycleIndex(stack, click, lastAltClick) : null; + // 41 G14: on a finger, a repeat tap on a SELECTED front object lets go of it rather than + // walking to what is behind (on a phone the Objects list reaches that) — in most scenes + // the floor is behind everything, so the walk would make letting go impossible + const front = stack.length ? stack[primaryIndex(stack)] : null; + const frontHeld = touch && !additive && !!front && $selectedObjects.includes(front.uuid); const choice = altPick ? { index: altPick.index, cycled: altPick.of > 1 } - : chooseInStack(stack, click ?? { x: -1e6, y: -1e6, t: 0 }, !additive && click ? lastSelectClick : null); + : chooseInStack(stack, click ?? { x: -1e6, y: -1e6, t: 0 }, !additive && click && !frontHeld ? lastSelectClick : null); const chosen = choice.index >= 0 ? stack[choice.index] : null; - if (mode === 'edit' && !additive && !altPick && moduleHit && moduleGroupOf(moduleHit.name) && (!chosen || moduleHit.distance < chosen.hit.distance)) { + if (onGizmo && !(chosen && $selectedObjects.includes(chosen.uuid))) return true; // the gizmo's tap + if (mode === 'edit' && !onGizmo && !additive && !altPick && moduleHit && moduleGroupOf(moduleHit.name) && (!chosen || moduleHit.distance < chosen.hit.distance)) { deselectObject(); selectModuleGroup(moduleHit.name); return true; @@ -619,6 +635,16 @@ const now = Date.now(); const isDouble = !additive && !altPick && lastPick.uuid === target.uuid && now - lastPick.t < 400; lastPick = { uuid: target.uuid, t: now }; + // 41 G14 (above): a finger's tap on a selected object takes it out of the selection + if (touch && !additive && !altPick && !isDouble && $selectedObjects.includes(target.uuid)) { + const rest = $selectedObjects.filter((uuid) => uuid !== target.uuid); + lastPick = { uuid: null, t: 0 }; + lastSelectClick = null; + if (rest.length) applySelectionSet(rest); + else deselectObject(); + return true; + } + if (onGizmo && !additive) return true; // a double-tap on the gizmo stays the gizmo's // shift-click toggles set membership (13) // 85: WHAT a double-click does is a preference now. 'properties' is // the default and the behaviour 15-O shipped; the other three exist @@ -783,6 +809,8 @@ }; // 30 P1: an Interact carry in progress (the camera controls stand down for it) let interactCarrying = false; + // 41 G4: the pointer that carries — a second finger (a pinch) must not drag the carry + let interactCarryPointer = -1; // the press that CAN start an Interact gesture: no editor session or tool holds it const interactPress = () => editorInteractActive() && !$specatorMode && !$editingObject && !$faceEditObject && !$splineEditObject && !$drawMode && !$sculptObject; @@ -854,6 +882,7 @@ setRayFromEvent(event); if (cursorGrabStart(selectionRaycaster, ndcOfEvent(event), camera.current)) { interactCarrying = true; + interactCarryPointer = event.pointerId; setOrbitEnabled(false); return; } @@ -953,7 +982,7 @@ updateAltPreview(event.clientX, event.clientY); } // 30 P1: an Interact carry follows the cursor - if (interactCarrying) cursorGrabMove(ndcOfEvent(event)); + if (interactCarrying && event.pointerId === interactCarryPointer) cursorGrabMove(ndcOfEvent(event)); // 57.3: a radius drag owns the gesture (thickness, not the camera) if (radiusDragActive()) { radiusDragMove(event.clientY); @@ -1026,7 +1055,7 @@ downPosition = null; return; } - if (interactCarrying && event.button === 0) { + if (interactCarrying && event.button === 0 && event.pointerId === interactCarryPointer) { interactCarrying = false; cursorGrabEnd(); // a throw (false if the carry was already cancelled) setOrbitEnabled(true); @@ -1144,7 +1173,14 @@ } // ignore clicks on the transform gizmo (axis is set while hovering a handle) — not an // Alt+click, which selects through it (36 F22; the gizmo is parked while Alt is held) - if ($TControls && ($TControls.dragging || ($TControls.axis && !event.altKey))) return; + // 41 G14: a FINGER's still tap on the gizmo may still let go of the object under it — + // a touch gizmo covers most of a small object, which is what the finger aims at + const touchTap = event.pointerType === 'touch'; + // (an object's gizmo only — a mesh / spline session's gizmo keeps every tap) + const onGizmo = + !!$TControls && !$TControls.dragging && !!$TControls.axis && !event.altKey && + !$editingObject && !$faceEditObject && !$splineEditObject && !$measureMode && !splineToolActive(); + if ($TControls && ($TControls.dragging || ($TControls.axis && !event.altKey)) && !(touchTap && onGizmo)) return; if (!$objectsGroup) return; const rect = element.getBoundingClientRect(); @@ -1255,7 +1291,7 @@ return; } // light pick-proxies select their light (lights have no raycastable geometry) - if ($lightProxiesGroup) { + if ($lightProxiesGroup && !onGizmo) { const proxyHits = selectionRaycaster.intersectObject($lightProxiesGroup, true); const proxyHit = proxyHits.find((hit) => hit.object.userData.lightUuid && hit.object.visible); if (proxyHit) { @@ -1265,7 +1301,7 @@ } // note pins take priority over object selection (H3: skipped entirely // while pins are hidden — never rely on raycaster-vs-invisible semantics) - if ($pinsGroup && $showNotePins) { + if ($pinsGroup && $showNotePins && !onGizmo) { const pinHits = selectionRaycaster.intersectObject($pinsGroup, true); let pinNode = pinHits[0]?.object; while (pinNode && !pinNode.name?.startsWith('pin-')) pinNode = pinNode.parent; @@ -1278,7 +1314,7 @@ // "click the background to get back" instinct as deselecting. const additive = event.shiftKey || $multiSelectMode; hidePickPreview(); - if (!raycastSelect(additive, 'edit', { x: event.clientX, y: event.clientY, t: Date.now() }, event.altKey && !additive) && !additive) { + if (!raycastSelect(additive, 'edit', { x: event.clientX, y: event.clientY, t: Date.now() }, event.altKey && !additive, { touch: touchTap, onGizmo: touchTap && onGizmo }) && !additive) { if (isIsolated()) clearIsolation(); deselectObject(); } @@ -1291,6 +1327,16 @@ // window, not canvas: the Canvas wrapper swallows pointer events mid-gesture window.addEventListener('pointermove', onPointerMove); window.addEventListener('pointerup', onPointerUp); + // 41 G4: a cancelled touch (the browser took the gesture, a call came in) lets the carry go + // where it is — a cancel is never a throw + const onPointerCancel = (event: PointerEvent) => { + if (!interactCarrying || event.pointerId !== interactCarryPointer) return; + interactCarrying = false; + cursorGrabEnd(false); + setOrbitEnabled(true); + downPosition = null; + }; + window.addEventListener('pointercancel', onPointerCancel); // right-click TAP opens the Add/object menu (77). Opening happens on the // contextmenu event (which trails pointerup) — opening on pointerup lets // that trailing event hit the fresh menu backdrop and close it instantly. @@ -1602,6 +1648,7 @@ element.removeEventListener('webglcontextlost', onContextLost); element.removeEventListener('webglcontextrestored', onContextRestored); window.removeEventListener('pointerup', onPointerUp); + window.removeEventListener('pointercancel', onPointerCancel); window.removeEventListener('keydown', onAltKey); window.removeEventListener('keyup', onAltKey); window.removeEventListener('blur', onAltBlur); diff --git a/src/components/editors/AnimationWindow.svelte b/src/components/editors/AnimationWindow.svelte index 60aa95d86..82c43d5c1 100644 --- a/src/components/editors/AnimationWindow.svelte +++ b/src/components/editors/AnimationWindow.svelte @@ -40,6 +40,7 @@ import DockTabs from '../DockTabs.svelte'; import WindowChrome from '../ui/WindowChrome.svelte'; import ScrollStrip from '../ui/ScrollStrip.svelte'; + import { fullName } from '$lib/ui/fullName.js'; import Segmented from '../ui/Segmented.svelte'; import { createGesture } from '$lib/modalGrab'; // W5: the BINDING for this pane's grab key lives in the shortcut registry (an @@ -1011,6 +1012,31 @@ // the viewport; 0 means "hidden behind another dock tab", which is not narrow. let bodyW = $state(0); const compact = $derived(bodyW > 0 && bodyW < 640); + /** + * 40-int (F5 on the preview): `compact` reshapes the HEADER above the body (40 F7 puts the + * name on its own row), which changes the body's own size — measured with bind:clientWidth + * that re-size landed inside the same ResizeObserver pass ("ResizeObserver loop completed + * with undelivered notifications" when every docked view mounts at once on a phone). The + * width is read here instead and applied on the next frame, so the header's change is a + * new notification of the next frame (where the width is unchanged and nothing moves). + * @param {HTMLElement} node + */ + function bodyWidth(node) { + let raf = 0; + const ro = new ResizeObserver(() => { + cancelAnimationFrame(raf); + raf = requestAnimationFrame(() => { + if (node.clientWidth !== bodyW) bodyW = node.clientWidth; + }); + }); + ro.observe(node); + return { + destroy() { + cancelAnimationFrame(raf); + ro.disconnect(); + } + }; + } /** which pane a compact editor shows @type {'list'|'plot'|'key'|'clip'} */ let phonePane = $state('plot'); // a coarse pointer (a finger) gets invisible hit areas around keys and easing handles: an @@ -1996,7 +2022,7 @@ {/if}
-
+
+ {#if compact && target} +
+ Animation + {target.name || 'object'} +
+
{@render paneSwitch()}
+ {:else} +
+ Animation + {target ? target.name || 'object' : 'no selection'} +
+ {/if}
{@render body()}
@@ -2707,14 +2741,14 @@ > {#snippet heading()} Animation - {target ? target.name || 'object' : 'no selection'} - {#if compact && target}{@render paneSwitch()}{/if} + {target ? target.name || 'object' : 'no selection'} {/snippet} {#snippet actions()} {/snippet} + {#if compact && target}
{@render paneSwitch()}
{/if} {@render body()}
registerDockCloser('code', requestCloseWorkspace)); async function closeSavingAll() { const failed = await saveAllCodeTabs(); if (failed.length) { @@ -710,7 +714,7 @@ /> {/if} {#if confirmCloseAll} -