From 2a0977c30af950676625191ac95e44d1689dd8a9 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Thu, 8 Oct 2026 05:55:37 +0300 Subject: [PATCH 1/9] [feat] pack item dims + the pack download cache (39 P4 + P5 data) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - placementDims.js (leaf): one Dims shape {size [w,h,d], box, tris, bytes, animated, known, source}; read from a pack row / manifest item (a NUMERIC `size` is the old manifest's byte count), else what this device measured on first load (ONE idb record `pack:dims-v1`, writes debounced), else the 1 m "size unknown" box. Labels W x D x H + cost. - packs.js normalizes `dims` onto every default-pack item and imported manifest item. - packRefs.js: loadPackTemplate measures + remembers dims for a row that had none (also for an animated file, before it refuses to reference it — AnimatedRefError), keeps the FINISHED templates for a sync peek (peekPackTemplate = "decoded in memory"), and reads/writes the pack cache in fetchWithProgress and the LOD file fetch. - packCache.js (leaf): ONE CacheStorage cache `tp-pack-files-v1` with a size index store, so downloaded items can be listed, sized and deleted; degrades to plain fetch without caches. - explorer.js: a new library model records `dims` from the same parse its thumbnail makes. - static/library/default/default.json: the bundled starter rows carry dims (copied from the packs repo's measured rows; identical files). Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/explorer.js | 41 +++-- src/lib/packCache.js | 180 +++++++++++++++++++++ src/lib/packRefs.js | 48 +++++- src/lib/packs.js | 10 +- src/lib/placementDims.js | 234 ++++++++++++++++++++++++++++ static/library/default/default.json | 12 +- 6 files changed, 506 insertions(+), 19 deletions(-) create mode 100644 src/lib/packCache.js create mode 100644 src/lib/placementDims.js diff --git a/src/lib/explorer.js b/src/lib/explorer.js index 9c99af086..58f078aef 100644 --- a/src/lib/explorer.js +++ b/src/lib/explorer.js @@ -11,6 +11,7 @@ import { STLLoader } from 'three/addons/loaders/STLLoader.js'; import { FBXLoader } from 'three/addons/loaders/FBXLoader.js'; import { idbGet, idbPut, idbDelete } from './idb'; import { showToast } from '../stores/appStore'; +import { measureDims } from './placementDims'; // Explorer (phase 95): a LOCAL asset library (like prefabs — nothing here // replicates until an asset is USED). Folder tree + items live as one small @@ -409,17 +410,30 @@ export async function parseObjectFile(buffer, ext) { /** @param {Blob} blob @param {string} name @param {string} kind */ async function thumbnailFor(blob, name, kind) { + return (await itemFacts(blob, name, kind)).thumbnail; +} + +/** + * The picture AND (39 P4) the size of a new library item, from ONE parse: a model's bounds in + * its root frame + its triangles ride the record as `dims`, so the drag-to-place ghost knows the + * size before the file is ever decoded again. + * @param {Blob} blob @param {string} name @param {string} kind + * @returns {Promise<{thumbnail: string | null, dims: any}>} + */ +async function itemFacts(blob, name, kind) { try { - if (kind === 'image') return await imageThumbnail(blob); + if (kind === 'image') return { thumbnail: await imageThumbnail(blob), dims: null }; if (kind === 'object') { const ext = name.split('.').pop()?.toLowerCase() ?? ''; const object = await parseObjectFile(await blob.arrayBuffer(), ext); - return renderObjectThumbnail(object); + const measured = measureDims(object, { bytes: blob.size, animated: !!object.animations?.length }); + const dims = measured ? { size: measured.size, box: measured.box, tris: measured.tris, bytes: measured.bytes, ...(measured.animated ? { animated: true } : {}) } : null; + return { thumbnail: renderObjectThumbnail(object), dims }; } } catch (error) { console.log('thumbnail skipped for ' + name, error); } - return null; // audio/text get icon cards + return { thumbnail: null, dims: null }; // audio/text get icon cards } /** @@ -569,13 +583,17 @@ async function writeItemNow(buffer, name, folderId, meta) { // the thumbnail is DECORATIVE — never let a wedged loader/renderer block // storing the bytes (a hung GLB parse used to silently swallow shared // assets on the receiving peer, R-3); the card falls back to an icon - const thumbnail = + /** @type {{thumbnail: string | null, dims: any}} */ + const facts = meta.thumbnail !== undefined - ? meta.thumbnail - : await Promise.race([ - thumbnailFor(blob, name, kind), - new Promise((resolve) => setTimeout(() => resolve(null), 4000)) - ]); + ? { thumbnail: meta.thumbnail, dims: null } + : /** @type {any} */ ( + await Promise.race([ + itemFacts(blob, name, kind), + new Promise((resolve) => setTimeout(() => resolve({ thumbnail: null, dims: null }), 4000)) + ]) + ); + const thumbnail = facts.thumbnail; const item = { id: String(meta.id ?? '').trim() || crypto.randomUUID(), name, @@ -593,7 +611,10 @@ async function writeItemNow(buffer, name, folderId, meta) { // means "this app minted it", so every item written before today keeps folding. ...(meta.imported ? { imported: true } : {}), ...(meta.share ? { share: meta.share } : {}), - ...(meta.owner ? { owner: meta.owner } : {}) + ...(meta.owner ? { owner: meta.owner } : {}), + // 39 P4: a model's size (LOCAL — never published in the shared index, which carries + // hash/name/kind/folderId only) + ...(facts.dims ? { dims: facts.dims } : {}) }; await idbPut(BLOB_KEY + item.id, blob); explorerItems.update((list) => [...list, item]); diff --git a/src/lib/packCache.js b/src/lib/packCache.js new file mode 100644 index 000000000..72f41132c --- /dev/null +++ b/src/lib/packCache.js @@ -0,0 +1,180 @@ +import { writable, get } from 'svelte/store'; + +// 39 P5 — PACK DOWNLOADS: the files of pack items this device has fetched, kept in ONE +// CacheStorage cache so the Explorer can say which items are downloaded (and how big they +// are), "Delete cache" can free one item's bytes while the item stays in its pack, and +// Storage can clear them all. +// +// Before this every pack file went through the browser's HTTP cache alone — invisible to +// the page, evictable at any time, and impossible to list or delete. CacheStorage is the +// one store a page can both enumerate and size. The HTTP cache still sits underneath (we +// cannot clear it), so "Delete cache" frees OUR copy: the next fetch goes to the network +// (or whatever the browser still holds) and is cached here again. +// +// A LEAF: svelte/store only. LOCAL by construction — what a device has downloaded is a fact +// about this machine; nothing here travels, saves or undoes. Every call degrades to a plain +// network fetch where CacheStorage is missing (an exported game on file://, a non-secure +// origin, a private window that refuses it), so no caller ever has to ask. + +export const PACK_CACHE_NAME = 'tp-pack-files-v1'; +/** the size header written beside each cached response (a Response's body cannot be sized + * without reading it, and the index must not read every blob on boot) */ +const BYTES_HEADER = 'x-tp-bytes'; + +/** url -> cached bytes, for every file in the cache. The Explorer's badges and the Storage + * row read it; `loadPackCacheIndex` fills it once, every put/delete keeps it current. + * @type {import('svelte/store').Writable>} */ +export const packCacheIndex = writable(new Map()); +let indexLoaded = false; +/** @type {Promise | null} */ +let indexJob = null; + +/** Only real network files are cached — a blob:/data: url is already in memory. @param {string} url */ +export function cacheableUrl(url) { + return typeof url === 'string' && /^https?:\/\//.test(url); +} + +/** @returns {Promise} */ +async function openCache() { + try { + if (typeof caches === 'undefined') return null; + return await caches.open(PACK_CACHE_NAME); + } catch { + return null; + } +} + +/** Read the cache's keys (and sizes) into `packCacheIndex`, once. */ +export function loadPackCacheIndex() { + if (indexLoaded) return Promise.resolve(); + if (indexJob) return indexJob; + indexJob = (async () => { + const cache = await openCache(); + /** @type {Map} */ + const map = new Map(); + if (cache) { + try { + for (const req of await cache.keys()) { + const res = await cache.match(req); + const bytes = Number(res?.headers.get(BYTES_HEADER)) || 0; + map.set(req.url, bytes); + } + } catch {} + } + // a put that raced the scan is already in the store — keep it + packCacheIndex.update((live) => new Map([...map, ...live])); + indexLoaded = true; + })(); + return indexJob; +} + +/** @param {string} url @returns {boolean} */ +export function isPackCached(url) { + return get(packCacheIndex).has(url); +} + +/** The cached bytes of a SET of urls (an item = its LOD0 + its LOD files). @param {string[]} urls @param {Map} [index] */ +export function cachedBytesOf(urls, index = get(packCacheIndex)) { + let n = 0; + for (const u of urls) n += index.get(u) ?? 0; + return n; +} + +/** Is ANY of an item's files downloaded? (LOD0 decides: it is what a placement fetches first.) @param {string} url @param {Map} [index] */ +export function cachedState(url, index = get(packCacheIndex)) { + return index.has(url); +} + +/** + * The cached bytes of `url`, or null when it is not downloaded (or the cache is unusable). + * @param {string} url @returns {Promise} + */ +export async function cachedPackBuffer(url) { + if (!cacheableUrl(url)) return null; + const cache = await openCache(); + if (!cache) return null; + try { + const res = await cache.match(url); + if (!res) { + // a stale index row (cleared in another tab) — forget it + if (get(packCacheIndex).has(url)) packCacheIndex.update((m) => (m.delete(url), new Map(m))); + return null; + } + return await res.arrayBuffer(); + } catch { + return null; + } +} + +/** + * Keep `buffer` as the downloaded copy of `url`. Best effort: a full quota or a refused + * cache leaves the app exactly as it was (the bytes are already in memory). + * @param {string} url @param {ArrayBuffer} buffer @param {string} [type] + */ +export async function putPackBuffer(url, buffer, type = 'model/gltf-binary') { + if (!cacheableUrl(url) || !buffer) return false; + const cache = await openCache(); + if (!cache) return false; + try { + await cache.put( + url, + new Response(buffer.slice(0), { + headers: { 'content-type': type, [BYTES_HEADER]: String(buffer.byteLength), 'x-tp-cached-at': String(Date.now()) } + }) + ); + packCacheIndex.update((m) => new Map(m).set(url, buffer.byteLength)); + return true; + } catch { + return false; + } +} + +/** + * The bytes of a pack file: the downloaded copy when there is one, else the network (and + * the result is kept). The one fetch every placement path shares. + * @param {string} url @param {RequestInit} [init] @returns {Promise} + */ +export async function fetchPackBuffer(url, init) { + const held = await cachedPackBuffer(url); + if (held) return held; + const res = await fetch(url, init); + if (!res.ok) throw Object.assign(new Error('HTTP ' + res.status), { status: res.status }); + const buffer = await res.arrayBuffer(); + void putPackBuffer(url, buffer, res.headers.get('content-type') || undefined); + return buffer; +} + +/** + * Forget the downloaded copies of `urls`. Resolves the bytes freed (by the index's count). + * @param {string[]} urls @returns {Promise} + */ +export async function deletePackCache(urls) { + const cache = await openCache(); + let freed = 0; + const index = get(packCacheIndex); + for (const url of urls) { + const bytes = index.get(url) ?? 0; + let gone = false; + try { + gone = cache ? await cache.delete(url) : false; + } catch {} + if (gone || index.has(url)) freed += bytes; + } + packCacheIndex.update((m) => { + const next = new Map(m); + for (const url of urls) next.delete(url); + return next; + }); + return freed; +} + +/** "Clear all pack downloads" (Storage). @returns {Promise} the bytes freed */ +export async function clearPackCache() { + await loadPackCacheIndex(); + const freed = cachedBytesOf([...get(packCacheIndex).keys()]); + try { + if (typeof caches !== 'undefined') await caches.delete(PACK_CACHE_NAME); + } catch {} + packCacheIndex.set(new Map()); + return freed; +} diff --git a/src/lib/packRefs.js b/src/lib/packRefs.js index 472bf9686..aedd5de15 100644 --- a/src/lib/packRefs.js +++ b/src/lib/packRefs.js @@ -22,6 +22,8 @@ import { STALL_ABORT_FACTOR } from './loadStates'; import { drawPlaceholders as drawPlaceholderBoxes, placeholderBoxOf, setRetryAllHook } from './placeholders'; +import { cachedPackBuffer, putPackBuffer, fetchPackBuffer } from './packCache'; +import { measureDims, rememberDims } from './placementDims'; // 30c — KIT REFERENCES: a pack piece in a scene is a REFERENCE, not a copy. // @@ -197,6 +199,13 @@ async function fetchWithProgress(url, control) { }, stallMs()); }; control.abort = (reason) => controller.abort(reason); + // 39 P5: a downloaded pack file is read from the pack cache — no network, no watchdog + const held = await cachedPackBuffer(url); + if (held) { + control.abort = null; + noteBytes(url, held.byteLength, held.byteLength); + return held; + } arm(); try { const res = await fetch(url, { signal: controller.signal }); @@ -206,6 +215,7 @@ async function fetchWithProgress(url, control) { if (!res.body || typeof res.body.getReader !== 'function') { const buffer = await res.arrayBuffer(); noteBytes(url, buffer.byteLength, total || buffer.byteLength); + void putPackBuffer(url, buffer, res.headers.get('content-type') || undefined); return buffer; } const reader = res.body.getReader(); @@ -226,6 +236,7 @@ async function fetchWithProgress(url, control) { out.set(chunk, at); at += chunk.byteLength; } + void putPackBuffer(url, out.buffer, res.headers.get('content-type') || undefined); return out.buffer; } catch (error) { if (abandoned) throw new StallError(stallMs()); @@ -253,6 +264,29 @@ function backoff(ms, control) { }); } +/** 39: the templates that have FINISHED parsing — "decoded in memory" for the placement ghost + * (a sync read, so the ghost never waits on a promise to pick its tier) + * @type {Map} */ +const readyTemplates = new Map(); + +/** The parsed template of `url` if it is already in memory, else null. @param {string} url */ +export function peekPackTemplate(url) { + return readyTemplates.get(url) ?? null; +} + +/** Is a parse of `url` in flight or done? @param {string} url */ +export function packTemplateStarted(url) { + return templates.has(url); +} + +/** Thrown by loadPackTemplate for a file with clips (it replicates as bytes, never a reference). */ +export class AnimatedRefError extends Error { + constructor() { + super('animated models are not referenced'); + this.animated = true; + } +} + /** * Fetch + parse a pack file ONCE per url, and register the fingerprint of a pristine copy * under the hash of the bytes actually fetched. 36 U9: a failed fetch is retried three times @@ -297,14 +331,19 @@ export function loadPackTemplate(url) { const hash = await hashBytes(buffer); /** @type {any} */ const gltf = await new Promise((resolve, reject) => createLoader().parse(buffer, '', resolve, reject)); + // 39 P4: what the file IS, for a row that did not say (an old pack, a Khronos row) + const animated = !!gltf.animations?.length; + const dims = measureDims(gltf.scene, { bytes: buffer.byteLength, animated }); + if (dims) void rememberDims(url, dims); // an animated rig replicates as its BYTES (animatedImports) — never a reference - if (gltf.animations?.length) throw new Error('animated models are not referenced'); + if (animated) throw new AnimatedRefError(); await shareTextures(gltf, buffer); const scene = gltf.scene; scene.updateMatrixWorld(true); fingerprints.set(hash, fingerprintOf(scene)); await warmTemplate(scene); noteDone(url); + readyTemplates.set(url, { hash, scene }); return { hash, scene }; } catch (error) { noteFailed(url, describeLoadError(error)); @@ -313,7 +352,7 @@ export function loadPackTemplate(url) { } })(); templates.set(url, job); - job.catch(() => templates.delete(url)).finally(() => { + job.catch(() => (templates.delete(url), readyTemplates.delete(url))).finally(() => { if (controls.get(url) === control) controls.delete(url); }); } @@ -335,9 +374,8 @@ export function loadPackFile(url) { let job = levelFiles.get(url); if (!job) { job = (async () => { - const res = await fetch(url); - if (!res.ok) throw new Error('HTTP ' + res.status); - const buffer = await res.arrayBuffer(); + // 39 P5: an item's LOD files are part of its download (the pack cache) + const buffer = await fetchPackBuffer(url); /** @type {any} */ const gltf = await new Promise((resolve, reject) => createLoader().parse(buffer, '', resolve, reject)); await shareTextures(gltf, buffer); diff --git a/src/lib/packs.js b/src/lib/packs.js index 7c7111b9e..3f590607e 100644 --- a/src/lib/packs.js +++ b/src/lib/packs.js @@ -4,6 +4,7 @@ import { addItemFromBytes, createFolder, explorerFolders } from './explorer'; import { safeStorage } from './safeStorage'; import { exportPacksBase } from './export/exportBoot.js'; import { normalizeBehavior } from './behaviorCore'; +import { dimsFromRow } from './placementDims'; // N6 (roadmap 7 / ship-qa D1): object packs. Two sources, one normalized model: // - DEFAULT packs from static/libraryList.json (bundled today; the model bytes @@ -322,7 +323,10 @@ export async function listPackItems(pack) { packName: pack.name, // 33 P2: a functional item (door, lid, lever, fan) — null for a plain prop. // Normalized HERE so the Explorer badge and the placement agree on one spec. - behavior: normalizeBehavior(o.behavior) + behavior: normalizeBehavior(o.behavior), + // 39 P4: the item's size before it loads (`size`/`box`/`tris`/`bytes` on the row) — + // the drag-to-place ghost and the cached badge read it; null = measured on first load + dims: dimsFromRow(o) }; }); } @@ -383,7 +387,9 @@ export async function importPackZip(file) { thumbnail: stored.thumbnail, license: decl.license || manifest.license || '', author: decl.author || manifest.author || '', - source: decl.source || '' + source: decl.source || '', + // 39 P4: a manifest item's declared size (a numeric `size` is the old byte count) + dims: dimsFromRow(decl) }); } const pack = { diff --git a/src/lib/placementDims.js b/src/lib/placementDims.js new file mode 100644 index 000000000..9b898a6b5 --- /dev/null +++ b/src/lib/placementDims.js @@ -0,0 +1,234 @@ +// @ts-ignore - no bundled three type declarations (project-wide) +import * as THREE from 'three'; +import { writable, get } from 'svelte/store'; +import { idbGet, idbPut } from './idb'; + +// 39 P4 — HOW BIG A PACK ITEM IS, BEFORE IT IS LOADED. The drag-to-place ghost shows a +// not-yet-loaded item as its bounding box with W × D × H in metres, so the size has to be +// known without the file. Three sources, best first: +// 1. the pack's own row (`size: [w, h, d]`, `box`, `tris`, `bytes`, `animated`) — written +// by the packs repo's build tool (`kit-build dims`) for every shipped item; +// 2. what this device measured the first time the file loaded (an old / imported pack, a +// Khronos row), kept in ONE IndexedDB record so it survives a reload; +// 3. nothing: a neutral 1 m box with a "size unknown" label. +// +// `size` is [x, y, z] = width, height, depth in metres (after the pack's own scale — the GLB +// as shipped). PACKS.md's manifest example used to put the BYTE count in `size`; a NUMBER +// there is still read as bytes, so an old manifest keeps working. +// +// A LEAF: three, svelte/store and idb. Nothing here travels — a measurement is a fact about +// a file, and every peer measures its own copy. + +/** + * @typedef {{size: number[], box: number[], tris: number | null, bytes: number | null, + * animated: boolean, known: boolean, source: 'row' | 'measured' | 'unknown'}} Dims + */ + +/** a 1 m block standing on the origin — the unknown-size ghost (the placeholders' DEFAULT_BOX) */ +export const UNKNOWN_BOX = [-0.5, 0, -0.5, 0.5, 1, 0.5]; + +const KEY = 'pack:dims-v1'; +/** url -> measured dims, loaded once @type {Map | null} */ +let memo = null; +/** @type {Promise | null} */ +let loading = null; +/** bumps when a measurement lands, so a ghost waiting on an unknown size can swap */ +export const dimsRevision = writable(0); + +/** @param {any} v */ +const finite = (v) => typeof v === 'number' && Number.isFinite(v); +/** @param {number} v */ +const mm = (v) => Math.round(v * 1000) / 1000; + +/** A box from a size alone: the pack pivot rule (bottom-centre) @param {number[]} size */ +export function boxFromSize(size) { + const [w, h, d] = size; + return [mm(-w / 2), 0, mm(-d / 2), mm(w / 2), mm(h), mm(d / 2)]; +} + +/** @param {number[]} box */ +export function sizeOfBox(box) { + return [mm(box[3] - box[0]), mm(box[4] - box[1]), mm(box[5] - box[2])]; +} + +/** @param {any} v @returns {number[] | null} */ +function sizeArray(v) { + return Array.isArray(v) && v.length === 3 && v.every(finite) && v.every((/** @type {number} */ n) => n >= 0) ? v.map(Number) : null; +} +/** @param {any} v @returns {number[] | null} */ +function boxArray(v) { + if (!Array.isArray(v) || v.length !== 6 || !v.every(finite)) return null; + if (v[3] < v[0] || v[4] < v[1] || v[5] < v[2]) return null; + return v.map(Number); +} + +/** + * The dims a pack row / manifest item DECLARES, or null when it declares none. + * @param {any} row @returns {Dims | null} + */ +export function dimsFromRow(row) { + if (!row || typeof row !== 'object') return null; + const box = boxArray(row.box); + const size = sizeArray(row.size) ?? (box ? sizeOfBox(box) : null); + if (!size) return null; + // a numeric `size` is the OLD manifest's byte count (PACKS.md before 39) + const bytes = finite(row.bytes) ? row.bytes : finite(row.size) ? row.size : null; + return { + size, + box: box ?? boxFromSize(size), + tris: finite(row.tris) ? Math.round(row.tris) : null, + bytes, + animated: row.animated === true || !!row.behavior, + known: true, + source: 'row' + }; +} + +/** The unknown-size answer. @returns {Dims} */ +export function unknownDims() { + return { size: sizeOfBox(UNKNOWN_BOX), box: UNKNOWN_BOX.slice(), tris: null, bytes: null, animated: false, known: false, source: 'unknown' }; +} + +/** + * Measure a parsed model: its bounds in its ROOT's own frame and the triangles it draws. + * @param {any} root @param {{bytes?: number | null, animated?: boolean}} [extra] @returns {Dims | null} + */ +export function measureDims(root, extra = {}) { + if (!root) return null; + root.updateMatrixWorld(true); + const toRoot = new THREE.Matrix4().copy(root.matrixWorld).invert(); + const m = new THREE.Matrix4(); + const box = new THREE.Box3(); + const part = new THREE.Box3(); + let tris = 0; + root.traverse((/** @type {any} */ node) => { + if (!node.isMesh || !node.geometry) return; + const g = node.geometry; + if (!g.boundingBox) g.computeBoundingBox(); + part.copy(g.boundingBox).applyMatrix4(m.multiplyMatrices(toRoot, node.matrixWorld)); + box.union(part); + const count = g.index ? g.index.count : g.attributes?.position?.count ?? 0; + tris += (node.isInstancedMesh ? node.count : 1) * Math.floor(count / 3); + }); + if (box.isEmpty()) return null; + const b = [mm(box.min.x), mm(box.min.y), mm(box.min.z), mm(box.max.x), mm(box.max.y), mm(box.max.z)]; + return { + size: sizeOfBox(b), + box: b, + tris, + bytes: finite(extra.bytes) ? /** @type {number} */ (extra.bytes) : null, + animated: !!extra.animated, + known: true, + source: 'measured' + }; +} + +/** Load the measured-dims record once. */ +export function loadMeasuredDims() { + if (memo) return Promise.resolve(); + if (loading) return loading; + loading = (async () => { + /** @type {Map} */ + const map = new Map(); + try { + const raw = await idbGet(KEY); + if (raw && typeof raw === 'object') + for (const [url, d] of Object.entries(raw)) { + const dims = dimsFromRow(d); + if (dims) map.set(url, { ...dims, source: 'measured' }); + } + } catch {} + memo = map; + })(); + return loading; +} + +/** What this device measured for `url`, if anything (sync; null until loaded). @param {string} url */ +export function measuredDims(url) { + return memo?.get(url) ?? null; +} + +/** + * Keep a measurement (first load of a pack file with no dims in its row). + * @param {string} url @param {Dims} dims + */ +export async function rememberDims(url, dims) { + if (!url || !dims?.known) return; + await loadMeasuredDims(); + const map = /** @type {Map} */ (memo); + const prev = map.get(url); + if (prev && prev.tris === dims.tris && prev.box.every((v, i) => v === dims.box[i])) return; + map.set(url, { ...dims, source: 'measured' }); + dimsRevision.update((n) => n + 1); + // a level load measures a hundred pieces in a burst — ONE write for the lot + clearTimeout(writeTimer); + writeTimer = setTimeout(writeMeasured, 400); +} + +/** @type {any} */ +let writeTimer = null; +async function writeMeasured() { + if (!memo) return; + /** @type {Record} */ + const out = {}; + for (const [u, d] of memo) out[u] = { size: d.size, box: d.box, tris: d.tris, bytes: d.bytes, animated: d.animated || undefined }; + try { + await idbPut(KEY, out); + } catch {} +} + +/** + * The best dims for a placeable item (a normalized pack item, a library record, a prefab + * card). Never null: an item with nothing known is the 1 m unknown box. + * @param {any} item @returns {Dims} + */ +export function dimsOf(item) { + if (item?.dims?.known) return item.dims; + const declared = dimsFromRow(item?.dims ?? item); + if (declared) return declared; + const url = item?.glbUrl ?? item?.url; + const measured = url ? measuredDims(url) : null; + if (measured) return measured; + return unknownDims(); +} + +/** Forget every measurement (Storage's reclaim, the suites). */ +export async function clearMeasuredDims() { + memo = new Map(); + dimsRevision.update((n) => n + 1); + try { + await idbPut(KEY, {}); + } catch {} +} + +/** @returns {number} how many urls carry a measurement */ +export function measuredCount() { + return memo?.size ?? 0; +} + +/** A W × D × H label, metres: "1.20 × 0.80 × 2.10 m" @param {number[]} size */ +export function dimsLabel(size) { + /** @param {number} v */ + const f = (v) => (v >= 10 ? v.toFixed(1) : v.toFixed(2)); + return `${f(size[0])} × ${f(size[2])} × ${f(size[1])} m`; +} + +/** "12.3k tris · 1.4 MB" (either half may be absent) @param {Dims} d */ +export function costLabel(d) { + const parts = []; + if (d.tris != null) parts.push((d.tris >= 1000 ? (d.tris / 1000).toFixed(d.tris >= 10000 ? 0 : 1) + 'k' : String(d.tris)) + ' tris'); + if (d.bytes != null) parts.push(bytesLabel(d.bytes)); + return parts.join(' · '); +} + +/** @param {number} n */ +export function bytesLabel(n) { + if (n >= 1024 * 1024) return (n / (1024 * 1024)).toFixed(1) + ' MB'; + if (n >= 1024) return Math.round(n / 1024) + ' KB'; + return n + ' B'; +} + +/** for get() users @returns {number} */ +export function dimsRev() { + return get(dimsRevision); +} diff --git a/static/library/default/default.json b/static/library/default/default.json index 9e39687e1..9743e3cca 100644 --- a/static/library/default/default.json +++ b/static/library/default/default.json @@ -5,7 +5,11 @@ "label": "The Prototype Logo", "variants": { "glTF-Binary": "ThePrototype-Logo.glb" - } + }, + "size": [6.046, 7.732, 5.197], + "box": [-5.046, -3.141, -4.197, 1, 4.591, 1], + "tris": 112, + "bytes": 7008 }, { "name": "Duck", @@ -13,6 +17,10 @@ "label": "Duck", "variants": { "glTF-Binary": "Duck.glb" - } + }, + "size": [1.655, 1.541, 1.152], + "box": [-0.693, 0.099, -0.613, 0.962, 1.64, 0.539], + "tris": 4212, + "bytes": 120484 } ] From b12abccf8ddb47c59b3e12794c874e11b389e2ce Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Thu, 8 Oct 2026 05:56:42 +0300 Subject: [PATCH 2/9] [feat] drag to place from the Explorer: ghost, rotate, cancel, one-step spawn (39 P1 + P2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - placeDrag.js: a POINTER gesture (a native drag delivers no keydown/wheel, so R / the wheel / Esc could not work on it). Explorer cancels a TRUSTED dragstart on a placeable card (pack object, library model, prefab) and hands over; a touch long-press does the same with the Explorer collapsed. Over any non-viewport element the payload is re-dispatched as synthetic dragenter/over/leave/drop (+ dragend on the source), flagged x-tp-place-bridge, so every existing HTML5 target keeps working and App's window drop ignores it. Raycast onto surfaces (ground fallback), the box RESTS on the surface along its normal, grid snap on the surface's two axes (Shift = free), R / Shift+R / wheel = 15° steps, align-to-surface via snapTargets.alignNormal, Alt = the camera focus point, a row for a multi-selection, amber over 50% inside another object, red (no drop) off the world. - placeGhost.js: scene-root, never saved/sent/picked. BOX tier (wire box + fill + footprint) first; the MODEL tier (the template's own geometry, one translucent material, no shadows) swaps in on a later frame when decoded + allowed by the preview setting (placementPrefs.js: full / within budget (default) / box, 50k tris, 15k on phones and headsets). Pending boxes hold a drop's spot while an import path parses. - explorerDrop.placeFromGhost: a STATIC pack item spawns as a pack STUB through addImported (one create entry, the stub element on the wire, Placeholder Style until it fills, peers refill with derived uuids); animated / behaviour / over-budget items, library models and prefabs take their import paths at the ghost pose (importFile gains `quaternion`). Several items share one history batch. Hovering the viewport prefetches (template parse / cache / library or prefab decode). - PlaceDragOverlay.svelte (kit tokens + Icon): the chip, W x D x H / size unknown, tris and size, the turn and the keys. - the two older pack fetches go through the pack cache; importGate.wouldAskFor; debug rows. - suite place-drag (33, two peers, real mouse, the CDN path served from static/library). Co-Authored-By: Claude Opus 5.5 (1M context) --- src/App.svelte | 5 + src/components/editors/Explorer.svelte | 52 +- src/components/menu/PlaceDragOverlay.svelte | 76 +++ src/lib/debugHooks.js | 5 + src/lib/explorerDrop.js | 145 ++++- src/lib/fileHandler.svelte.js | 11 +- src/lib/importGate.js | 14 +- src/lib/placeDrag.js | 632 ++++++++++++++++++++ src/lib/placeGhost.js | 288 +++++++++ src/lib/placementPrefs.js | 86 +++ tests/e2e/place-drag.test.cjs | 223 +++++++ 11 files changed, 1525 insertions(+), 12 deletions(-) create mode 100644 src/components/menu/PlaceDragOverlay.svelte create mode 100644 src/lib/placeDrag.js create mode 100644 src/lib/placeGhost.js create mode 100644 src/lib/placementPrefs.js create mode 100644 tests/e2e/place-drag.test.cjs diff --git a/src/App.svelte b/src/App.svelte index a6c7be5c1..6cd1bc9f3 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -162,6 +162,7 @@ import { startMusicToolbox } from './lib/musicToolbox' import { initModules, disabledModules } from '$lib/moduleSDK' import { coreModules } from './modules/index.js' import ModulesManager from './components/menu/ModulesManager.svelte' + import PlaceDragOverlay from './components/menu/PlaceDragOverlay.svelte' // W9: the pref that decides whether the bottom dock RESIZES the viewport (default) // or overlays it. A local view preference — see the .viewport style below. import { viewPrefs } from '$lib/viewPrefs' @@ -341,6 +342,9 @@ import { startMusicToolbox } from './lib/musicToolbox' function handleDrop(event) { // panels with their own drag&drop handle theirs (flow palette, Explorer) if (event.target?.closest && (event.target.closest('#flow-list') || event.target.closest('#explorer-list') || event.target.closest('#explorer-window'))) return + // 39: a drag-to-place bridge drop (placeDrag over a non-viewport element) never places — + // releasing anywhere but the viewport is a cancel, and over the viewport placeDrag spawns itself + if (event.dataTransfer?.types?.includes('application/x-tp-place-bridge')) return // Explorer cards dropped on the viewport place/texture at the point (96) const explorerPayload = event.dataTransfer?.getData('application/x-explorer-item') if (explorerPayload) { @@ -391,6 +395,7 @@ import { startMusicToolbox } from './lib/musicToolbox' {/if} + diff --git a/src/components/editors/Explorer.svelte b/src/components/editors/Explorer.svelte index 85e92b16e..22e59c9ce 100644 --- a/src/components/editors/Explorer.svelte +++ b/src/components/editors/Explorer.svelte @@ -356,6 +356,7 @@ import { fly } from 'svelte/transition'; import { safeStorage } from '$lib/safeStorage'; import { minimalScroll } from '$lib/ui/minimalScroll.js'; + import { beginPlaceDrag, isPlaceable } from '$lib/placeDrag'; const clampH = (h: number) => Math.min(Math.max(h || 300, 200), Math.round(window.innerHeight * 0.8)); @@ -5509,7 +5510,11 @@ // 33: a pack item's LOD files travel with the drag so the drop places the group ...(item.lods ? { lods: item.lods } : {}), // 33 P2: a functional pack item's spec rides the drag to the drop - ...(item.behavior ? { behavior: item.behavior } : {}) + ...(item.behavior ? { behavior: item.behavior } : {}), + // 39: the item's size travels with the drag (the ghost draws it before any download), + // and a pack item names its pack (the placed reference records it) + ...(item.dims ? { dims: item.dims } : {}), + ...(item.glbUrl && item.packName ? { packName: item.packName } : {}) }; } /** @@ -5554,6 +5559,16 @@ return out; } function onItemDragStart(e: DragEvent, item: any) { + // 39 P1: a REAL drag of something that places as an object becomes a pointer drag — the + // ghost, R / wheel rotation and Esc need input a native drag never delivers. Every HTML5 + // target the card used to reach still gets it (placeDrag bridges it); a synthetic + // dragstart (the suites) keeps the native path below. + if (e.isTrusted && isPlaceable(item)) { + e.preventDefault(); + libraryDragging = true; + beginPlaceDrag({ payload: dragPayloadFor(item), x: e.clientX, y: e.clientY, pointerType: 'mouse', source: e.currentTarget as HTMLElement }); + return; + } // 96 consumes these payloads (viewport placement / texture drop). N6: a // default-pack item carries a `url` so the drop can fetch+place it without // first storing it in the Explorer library. @@ -5826,9 +5841,12 @@ let tStartX = 0; let tStartY = 0; let tSuppressClick = false; + /** 39: this touch drag belongs to placeDrag (it reads the pointer from the window) */ + let tPlace = false; function onCardPointerDown(e: PointerEvent, item: any) { if (e.pointerType === 'mouse') return; // desktop uses native HTML5 drag + tPlace = false; // a cancelled place drag (the browser took the pan) left nothing behind tStartX = e.clientX; tStartY = e.clientY; const target = e.currentTarget as HTMLElement; @@ -5841,6 +5859,20 @@ const label = carried > 1 ? `${item.name} + ${carried - 1} more` : item.name; clearTimeout(tPressTimer); tPressTimer = window.setTimeout(() => { + // 39 P1: an object card picked up by touch is a PLACE drag — the ghost on the viewport, + // the sheet collapsed out of the way; the controller owns the gesture from here + if (isPlaceable(item)) { + tDragging = true; + tPlace = true; + try { + target.setPointerCapture?.(pid); + } catch {} + try { + navigator.vibrate?.(15); + } catch {} + beginPlaceDrag({ payload, x: tStartX, y: tStartY, pointerType: e.pointerType || 'touch', pointerId: pid, collapse: true }); + return; + } tDrag = { payload, label }; tDragging = true; tGhostX = tStartX; @@ -5855,6 +5887,7 @@ } function onCardPointerMove(e: PointerEvent) { if (e.pointerType === 'mouse') return; + if (tPlace) return void e.preventDefault(); if (!tDragging) { // moved before the long-press fired -> it's a scroll, not a pick-up if (Math.abs(e.clientX - tStartX) > 10 || Math.abs(e.clientY - tStartY) > 10) @@ -5867,6 +5900,13 @@ } function onCardPointerUp(e: PointerEvent) { clearTimeout(tPressTimer); + if (tPlace) { + // placeDrag already took the release (its window listener runs first) + tPlace = false; + tDragging = false; + tSuppressClick = true; + return; + } if (tDragging && tDrag) { const el = document.elementFromPoint(e.clientX, e.clientY); // drop only when released over the viewport, not back onto the Explorer @@ -5913,14 +5953,18 @@ const { holdLoadingToast } = await import('$lib/explorerDrop'); const dismiss = holdLoadingToast(String(item.name || 'model')); try { - const res = await fetch(item.glbUrl); - if (!res.ok) { + // 39 P5: through the pack cache, so the item reads as downloaded afterwards + const { fetchPackBuffer } = await import('$lib/packCache'); + let buffer: ArrayBuffer; + try { + buffer = await fetchPackBuffer(item.glbUrl); + } catch { dismiss(); return showToast('Could not fetch the pack item'); } // 30c: the placed piece carries its pack reference (packRefs.js) const { placementGroupFor } = await import('$lib/lodGroup'); - await importFile(new File([await res.blob()], item.name + '.glb'), item.name, 'glb', undefined, undefined, { + await importFile(new File([buffer], item.name + '.glb'), item.name, 'glb', undefined, undefined, { packRef: packRefFromUrl(item.glbUrl, { pack: item.packName, item: item.name }), lod: placementGroupFor(item.glbUrl, item.lods), behavior: item.behavior ?? null // 33 P2 diff --git a/src/components/menu/PlaceDragOverlay.svelte b/src/components/menu/PlaceDragOverlay.svelte new file mode 100644 index 000000000..f76592e65 --- /dev/null +++ b/src/components/menu/PlaceDragOverlay.svelte @@ -0,0 +1,76 @@ + + +{#if s} +
+ + 1 ? 'layers' : 'box'} size={16} aria-hidden="true" /> + {s.label} + + {#if onViewport} + {#if s.dims} + {s.dims}{s.cost ? ' · ' + s.cost : ''} + {/if} + {#if s.state === 'bad'} + No place to put it here + {:else if s.state === 'warn'} + Inside another object + {/if} + + {#if s.rotation}{s.rotation}° · {/if}{s.alt ? 'At the camera focus' : 'R / wheel turn · Shift free · Alt focus · Esc cancel'} + + {:else if s.over === 'explorer'} + Drop on the viewport to place it + {/if} +
+{/if} + + diff --git a/src/lib/debugHooks.js b/src/lib/debugHooks.js index d3bdd273b..6160ac29e 100644 --- a/src/lib/debugHooks.js +++ b/src/lib/debugHooks.js @@ -38,6 +38,11 @@ export const DEBUG_HOOKS = [ // 36 U3b/I5: the tour engine (tours, activeTour, toursDebug) + the built-in tours ['tours', () => import('./tours/index.js')], ['toursBuiltin', () => import('./tours/builtin.js')], + // 39: drag to place — the preview setting, the measured dims, the pack downloads, the ghost + ['placementPrefs', () => import('./placementPrefs.js')], + ['placementDims', () => import('./placementDims.js')], + ['packCache', () => import('./packCache.js')], + ['placeGhost', () => import('./placeGhost.js')], ['tourVR', () => import('./tours/vrPanel.js')], // 36-share (B13): the recorder + its stores ['recording', () => import('./recording/recorder.js')], diff --git a/src/lib/explorerDrop.js b/src/lib/explorerDrop.js index c89546b37..d62d402f4 100644 --- a/src/lib/explorerDrop.js +++ b/src/lib/explorerDrop.js @@ -5,8 +5,13 @@ import { globalCamera, selectedObject } from '../stores/sceneStore'; import { peers, showToast, toastStore, stackOnDrop } from '../stores/appStore'; import { explorerItems, itemBlob } from './explorer'; import { prefabs, instantiatePrefab } from './prefabs'; -import { importFile } from './fileHandler.svelte'; -import { packRefFromUrl } from './packRefs'; +import { importFile, addImported } from './fileHandler.svelte'; +import { packRefFromUrl, peekPackTemplate, loadPackTemplate } from './packRefs'; +import { fetchPackBuffer } from './packCache'; +import { holdPendingGhost } from './placeGhost'; +import { wouldAskFor } from './importGate'; +import { beginHistoryBatch, endHistoryBatch } from './history'; +import { selectedObjects } from '../stores/sceneStore'; import { setObjectTexture } from './materialsHandler'; import { topLevelObjectOf } from './objectActions'; import { sceneHits, hitWorldNormal } from './scenePick'; @@ -201,15 +206,18 @@ async function placeExplorerPayload(payload, target) { const name = String(payload.name || 'model').replace(/\.\w+$/, ''); const dismiss = holdLoadingToast(name); try { - const res = await fetch(payload.url); - if (!res.ok) { + // 39 P5: through the pack cache (a downloaded item reads no network, and a fresh one is kept) + let buffer; + try { + buffer = await fetchPackBuffer(payload.url); + } catch { dismiss(); return showToast('Could not fetch the pack item'); } // 30c: the placed piece carries its pack reference, so a save and the wire write it // as a small stub instead of the whole model (packRefs.js) const { placementGroupFor } = await import('./lodGroup'); - importFile(new File([await res.blob()], name + '.glb'), name, undefined, target.point ?? undefined, undefined, { + importFile(new File([buffer], name + '.glb'), name, undefined, target.point ?? undefined, undefined, { packRef: packRefFromUrl(payload.url, { item: name }), lod: placementGroupFor(payload.url, payload.lods), behavior: payload.behavior ?? null // 33 P2 @@ -254,3 +262,130 @@ async function placeExplorerPayload(payload, target) { showToast('Audio/text items are used where they plug in (sound nodes, scripts)'); } } + +// ---- 39 P1/P2: SPAWN AT THE GHOST ------------------------------------------------------- +// +// A drag-to-place release (placeDrag.js) hands over each item with the exact pose its ghost +// showed. Two ways an item becomes an object, both through paths that already replicate: +// · a STATIC PACK ITEM spawns as a pack STUB (30c's reference) — an empty group carrying +// `packRef` + its box, added through `addImported` (ONE `create` undo step, the ordinary +// add broadcast, which writes a stub as the tiny stub element). It is in the scene the moment +// the pointer lifts, drawn in the Placeholder Style while its file loads, selectable and +// movable like any placeholder, and every peer refills it from the pack (the children take +// uuids derived from the root's, so peers agree). A prefetched file fills it on the next frame. +// · everything else (an ANIMATED pack item — it travels as bytes, never a reference — a +// library model, a prefab, a pack item that would trip the import budget) goes through its +// own import path at the same pose, with a local pending box held at the spot until it lands. +// Several items dropped at once spawn their stubs and prefabs in ONE history batch. + +/** @param {any} payload @param {any} dims */ +function stubbable(payload, dims) { + if (!payload?.url || payload.behavior || dims?.animated) return false; + if (dims?.tris != null && wouldAskFor(dims.tris)) return false; + return true; +} + +/** + * The one spawn a drag-to-place release makes. + * @param {{payload: any, dims: any}[]} items @param {{position: number[], quaternion: number[]}[]} placements + * @returns {Promise} the uuids placed (stubs at once; imports once they land) + */ +export async function placeFromGhost(items, placements) { + const { placementGroupFor } = await import('./lodGroup'); + /** @type {string[]} */ + const placed = []; + /** @type {{item: any, at: any}[]} */ + const later = []; + // an item of unknown animation status whose file is not parsed yet: settle it first, so a + // door is never referenced (its template parse is what knows). A prefetched one is instant. + for (let i = 0; i < items.length; i++) { + const { payload, dims } = items[i]; + if (payload?.url && !payload.behavior && !dims?.known && !peekPackTemplate(payload.url)) { + const release = holdPendingGhost(dims?.box ?? [-0.5, 0, -0.5, 0.5, 1, 0.5], placements[i].position, placements[i].quaternion); + try { + await loadPackTemplate(payload.url); + } catch (e) { + if (/** @type {any} */ (e)?.animated) items[i] = { payload, dims: { ...(dims ?? {}), animated: true } }; + } + release(); + } + } + const many = items.length > 1; + if (many) beginHistoryBatch(); + try { + items.forEach(({ payload, dims }, i) => { + const at = placements[i]; + if (!at) return; + if (payload.prefabId) { + const prefab = get(prefabs).find((entry) => entry.id === payload.prefabId); + const object = prefab ? instantiatePrefab(prefab, at.position) : null; + if (object) { + object.quaternion.fromArray(at.quaternion); + placeAt(object, at.position); + placed.push(object.uuid); + } + return; + } + if (stubbable(payload, dims)) { + const name = String(payload.name || 'model').replace(/\.\w+$/, ''); + const ref = packRefFromUrl(payload.url, { pack: payload.packName, item: name }); + if (!ref) return later.push({ item: { payload, dims }, at }); + const stub = new THREE.Group(); + stub.quaternion.fromArray(at.quaternion); + const lod = placementGroupFor(payload.url, payload.lods); + stub.userData = { + packRef: { ...ref, ...(dims?.known ? { box: dims.box } : {}) }, + packStub: true, + ...(lod ? { lod } : {}) + }; + addImported(stub, name, at.position); + placed.push(stub.uuid); + return; + } + later.push({ item: { payload, dims }, at }); + }); + } finally { + if (many) endHistoryBatch(`Place ${items.length} objects`); + } + if (placed.length > 1) selectedObjects.set([...placed]); + for (const { item, at } of later) { + const uuid = await importAtGhost(item.payload, item.dims, at); + if (uuid) placed.push(uuid); + } + return placed; +} + +/** + * The import path at a ghost's pose (a pending box holds the spot meanwhile). + * @param {any} payload @param {any} dims @param {{position: number[], quaternion: number[]}} at + * @returns {Promise} + */ +async function importAtGhost(payload, dims, at) { + const release = holdPendingGhost(dims?.box ?? [-0.5, 0, -0.5, 0.5, 1, 0.5], at.position, at.quaternion); + try { + if (payload.url) { + const name = String(payload.name || 'model').replace(/\.\w+$/, ''); + let buffer; + try { + buffer = await fetchPackBuffer(payload.url); + } catch { + showToast('Could not fetch the pack item'); + return null; + } + const { placementGroupFor } = await import('./lodGroup'); + return await importFile(new File([buffer], name + '.glb'), name, 'glb', at.position, undefined, { + packRef: packRefFromUrl(payload.url, { pack: payload.packName, item: name }), + lod: placementGroupFor(payload.url, payload.lods), + behavior: payload.behavior ?? null, + quaternion: at.quaternion + }); + } + const item = get(explorerItems).find((entry) => entry.id === payload.id); + if (!item || item.kind !== 'object') return null; + const blob = await itemBlob(item.id); + if (!blob) return null; + return await importFile(new File([blob], item.name), item.name.replace(/\.\w+$/, ''), undefined, at.position, undefined, { quaternion: at.quaternion }); + } finally { + release(); + } +} diff --git a/src/lib/fileHandler.svelte.js b/src/lib/fileHandler.svelte.js index 7514fd5d0..fceb5603b 100644 --- a/src/lib/fileHandler.svelte.js +++ b/src/lib/fileHandler.svelte.js @@ -362,7 +362,7 @@ function addAnimatedImport(result, buffer, name, kind, position) { /** Shared tail for every import format: add to the scene, select, replicate. * @param {any} imported @param {string=} name @param {number[]=} position drop point (96) */ -function addImported(imported, name, position) { +export function addImported(imported, name, position) { if (name) imported.name = name; // position BEFORE the sync so peers receive the placed transform (96) if (position) imported.position.fromArray(position); @@ -680,7 +680,9 @@ function defaultImportName(extension, name) { * @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}} [opts] + * @param {{reduce?: boolean | import('./importBudget').ReductionPlan, packRef?: import('./packRefs').PackRef | null, lod?: any, behavior?: any, quaternion?: number[] | null}} [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 * 26-F: `reduce` imports REDUCED. 30c: `packRef` names the PACK ITEM this file is — the * placed root then carries the reference (packRefs.js), so a save and the wire write it @@ -721,6 +723,11 @@ export async function importFile(file, name, ext, position, extras, opts = {}) { return null; } try { + // 39: the drop's rotation rides the first sync (one undo step, one replicated add) + if (Array.isArray(opts.quaternion) && opts.quaternion.length === 4) { + parsed.root?.quaternion?.fromArray(opts.quaternion); + parsed.animated?.result?.scene?.quaternion?.fromArray(opts.quaternion); + } // 30c: stamp the pack reference BEFORE addImported, which is what replicates it if (opts.packRef && !parsed.animated && typeof file?.arrayBuffer === 'function') stampPackRef(parsed.root, opts.packRef, await hashBytes(await file.arrayBuffer())); diff --git a/src/lib/importGate.js b/src/lib/importGate.js index 366b4dfd5..0ed80c2cf 100644 --- a/src/lib/importGate.js +++ b/src/lib/importGate.js @@ -1,7 +1,7 @@ import { writable, get } from 'svelte/store'; import { objectsGroup, globalRenderer } from '../stores/sceneStore'; import { profileFor } from './sceneBudget'; -import { modelCost, importVerdict, describeRow, shortCount } from './importBudget'; +import { modelCost, importVerdict, describeRow, shortCount, emptyCost } from './importBudget'; import { showChoice } from './confirmDialog'; // 26-F — THE IMPORT HALF OF THE INGEST GATE (roadmap 26 section 4, Stage 2). @@ -39,6 +39,18 @@ export function verdictFor(root) { return verdict; } +/** + * 39: would a model of `tris` triangles ask before it is let in? For a drop that spawns a pack + * REFERENCE (no parsed tree to measure yet): the item's declared triangle count stands in for the + * whole cost. A model that would ask takes the import path instead, where the dialog asks. + * @param {number} tris @returns {boolean} + */ +export function wouldAskFor(tris) { + if (!Number.isFinite(tris) || tris <= 0) return false; + const cost = { ...emptyCost(), meshes: 1, draws: 1, triangles: tris, vertices: tris, maxMeshTriangles: tris, maxMeshVertices: tris }; + return importVerdict(sceneCost(), cost, profileFor(get(globalRenderer))).gate; +} + /** The sentence the dialog says. @param {any} verdict @param {string} name */ export function gateMessage(verdict, name) { const reasons = verdict.asking.map(describeRow); diff --git a/src/lib/placeDrag.js b/src/lib/placeDrag.js new file mode 100644 index 000000000..3306f2370 --- /dev/null +++ b/src/lib/placeDrag.js @@ -0,0 +1,632 @@ +// @ts-ignore - no bundled three type declarations (project-wide) +import * as THREE from 'three'; +import { writable, get } from 'svelte/store'; +import { globalRenderer, globalCamera, orbitControls, objectsGroup } from '../stores/sceneStore'; +import { snapEnabled, snapSettings, snapTargets } from './snapping'; +import { dimsOf, dimsRevision, loadMeasuredDims, measureDims, dimsLabel, costLabel } from './placementDims'; +import { placementPreview, placementTriBudget, placementShowDims, currentGhostTier } from './placementPrefs'; +import { showGhost, poseGhost, hideGhost, setGhostModel, ghostInfo } from './placeGhost'; +import { registerDebugHook } from './debugHooks'; + +// 39 P1 — DRAG TO PLACE. An Explorer card dragged onto the viewport shows a ghost where the +// item will land, and releasing there spawns it — the content-browser drag of Unity / Unreal / +// Blender's asset browser. +// +// THIS IS A POINTER GESTURE, NOT HTML5 DnD. A native drag delivers no keydown and no wheel +// while it runs (the OS drag loop owns the input), so R / the wheel could never rotate the ghost +// and Esc could only be the browser's own cancel. Explorer cancels a TRUSTED dragstart on a +// placeable card and hands the gesture here; everything that used to receive that card's HTML5 +// drag still does, because over any element that is NOT the viewport canvas this module +// dispatches the same `application/x-explorer-item` payload as synthetic dragenter / dragover / +// dragleave / drop events (marked `application/x-tp-place-bridge`, which App's window drop +// handler ignores so a release over a panel can never place). So Explorer folders, packs, the +// bin, the Inspector's texture slot and the LOD panel keep working with no change. +// +// THE RULES: release over the viewport = spawn at the ghost (ONE undo step, replicated like any +// add — the spawn itself is explorerDrop.placeFromGhost). Esc, releasing anywhere else, or +// dropping back on the Explorer = nothing spawns and nothing enters the undo stack. R / Shift+R +// and the wheel turn the ghost in 15° steps; Shift = free (no grid snap); Alt at release = drop at +// the camera's focus point; `snapTargets.alignNormal` turns the item onto the surface. A touch +// long-press drag works the same with the Explorer sheet collapsed out of the way. +// +// LOCAL: the ghost and this state never leave the machine. + +const STEP_DEG = 15; +/** gap between items dropped as a row (metres) */ +const ROW_GAP = 0.25; +/** a spot further than this from the origin is "off the world" */ +const WORLD_LIMIT = 5000; +/** share of the ghost's volume inside another object that turns it amber */ +const OVERLAP_WARN = 0.5; + +/** + * @typedef {{payload: any, dims: import('./placementDims').Dims, url: string | null, + * libId: string | null, prefabId: string | null, label: string}} DragItem + */ + +/** + * What the overlay draws. null when no drag is running. + * @type {import('svelte/store').Writable} + */ +export const placeDrag = writable(null); + +/** the current gesture (module state; the store is its view) @type {any} */ +let drag = null; +/** @type {any} */ +let dropRef = null; +/** @type {any} */ +let packRefsRef = null; +/** @type {any} */ +let explorerRef = null; +/** @type {any} */ +let prefabsRef = null; +const primed = Promise.all([ + import('./explorerDrop').then((m) => (dropRef = m)), + import('./packRefs').then((m) => (packRefsRef = m)), + import('./explorer').then((m) => (explorerRef = m)), + import('./prefabs').then((m) => (prefabsRef = m)) +]).catch(() => {}); + +/** library / prefab models decoded for a ghost, newest last (a small LRU) @type {Map} */ +const decoded = new Map(); +const DECODED_MAX = 4; +/** @param {string} key @param {any} root */ +function keepDecoded(key, root) { + decoded.delete(key); + decoded.set(key, root); + while (decoded.size > DECODED_MAX) decoded.delete(/** @type {string} */ (decoded.keys().next().value)); +} + +/** Is an Explorer card something that places as an object? @param {any} item */ +export function isPlaceable(item) { + if (!item) return false; + if (item.prefabId || item.kind === 'prefab') return true; + if (item.kind !== 'object') return false; + if (item.deletedEntry || item.remoteScene || item.volumeItem) return false; + return !!(item.glbUrl || item.id); +} + +/** @returns {boolean} */ +export function placeDragActive() { + return !!drag; +} + +/** @param {any} p @returns {DragItem} */ +function toItem(p) { + const library = !p.url && !p.prefabId && p.id ? get(explorerRef?.explorerItems ?? writable([])).find((/** @type {any} */ r) => r.id === p.id) : null; + const src = p.dims ? p : library ?? p; + return { + payload: p, + dims: dimsOf({ ...src, glbUrl: p.url ?? null }), + url: p.url ?? null, + libId: !p.url && !p.prefabId ? p.id ?? null : null, + prefabId: p.prefabId ?? null, + label: String(p.name || 'item') + }; +} + +/** + * Begin a place drag. `payload` is Explorer's drag payload (a single card, or one carrying an + * `items` array for a multi-selection). + * @param {{payload: any, x: number, y: number, pointerType?: string, pointerId?: number, source?: HTMLElement | null, collapse?: boolean}} opts + */ +export function beginPlaceDrag(opts) { + if (drag) cancelPlaceDrag('restart'); + void loadMeasuredDims(); + const list = Array.isArray(opts.payload?.items) && opts.payload.items.length > 1 ? opts.payload.items : [opts.payload]; + const items = list.map(toItem); + drag = { + payload: opts.payload, + items, + x: opts.x, + y: opts.y, + pointerType: opts.pointerType ?? 'mouse', + pointerId: opts.pointerId ?? -1, + source: opts.source ?? null, + collapse: !!opts.collapse, + rotation: 0, + shift: false, + alt: false, + over: /** @type {'viewport'|'explorer'|'other'} */ ('other'), + state: /** @type {'ok'|'warn'|'bad'} */ ('ok'), + placements: /** @type {any[]} */ ([]), + bridgeEl: /** @type {Element | null} */ (null), + prefetched: false, + ghostUp: false, + tiers: items.map(() => 'box'), + obstacles: /** @type {any[] | null} */ (null), + unsubDims: /** @type {(() => void) | null} */ (null) + }; + drag.unsubDims = dimsRevision.subscribe(() => drag && refreshDims()); + window.addEventListener('pointermove', onMove, true); + window.addEventListener('pointerup', onUp, true); + window.addEventListener('pointercancel', onCancelEvent, true); + window.addEventListener('keydown', onKey, true); + window.addEventListener('keyup', onKeyUp, true); + window.addEventListener('wheel', onWheel, { capture: true, passive: false }); + window.addEventListener('blur', onBlur); + document.documentElement.classList.add('tp-place-dragging'); + if (opts.collapse) document.documentElement.classList.add('tp-place-collapse'); + update(opts.x, opts.y); + return true; +} + +function refreshDims() { + if (!drag) return; + drag.items = drag.items.map((/** @type {DragItem} */ it) => ({ ...it, dims: it.dims.known ? it.dims : dimsOf({ ...it.payload, glbUrl: it.url }) })); + if (drag.ghostUp) { + // sizes changed: rebuild the boxes (the model tiers re-apply below) + showGhost(drag.items.map((/** @type {DragItem} */ it) => ({ box: it.dims.box }))); + drag.tiers = drag.items.map(() => 'box'); + applyTiers(); + } + update(drag.x, drag.y); +} + +/** @param {PointerEvent} e */ +function onMove(e) { + if (!drag || (drag.pointerId >= 0 && e.pointerId !== drag.pointerId && drag.pointerType !== 'mouse')) return; + drag.shift = e.shiftKey; + drag.alt = e.altKey; + if (drag.pointerType !== 'mouse') e.preventDefault(); + update(e.clientX, e.clientY); +} + +/** @param {PointerEvent} e */ +function onUp(e) { + if (!drag) return; + if (drag.pointerType !== 'mouse' && drag.pointerId >= 0 && e.pointerId !== drag.pointerId) return; + drag.shift = e.shiftKey; + drag.alt = e.altKey; + update(e.clientX, e.clientY); + if (drag.over === 'viewport') { + // the release is ours — the canvas must not read it as a click + e.stopPropagation(); + e.preventDefault(); + commit(); + return; + } + if (drag.pointerType === 'mouse' && drag.bridgeEl) bridgeDrop(e.clientX, e.clientY); + end('released-elsewhere'); +} + +function onCancelEvent() { + cancelPlaceDrag('pointercancel'); +} +function onBlur() { + cancelPlaceDrag('blur'); +} + +/** @param {KeyboardEvent} e */ +function onKey(e) { + if (!drag) return; + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + cancelPlaceDrag('escape'); + return; + } + drag.shift = e.shiftKey; + drag.alt = e.altKey; + if ((e.key === 'r' || e.key === 'R') && !e.ctrlKey && !e.metaKey) { + e.preventDefault(); + e.stopPropagation(); + rotateBy(e.shiftKey ? -1 : 1); + return; + } + if (e.key === 'Alt' || e.key === 'Shift') { + e.preventDefault(); + update(drag.x, drag.y); + } +} +/** @param {KeyboardEvent} e */ +function onKeyUp(e) { + if (!drag) return; + drag.shift = e.shiftKey; + drag.alt = e.altKey; + if (e.key === 'Alt' || e.key === 'Shift') update(drag.x, drag.y); +} + +/** @param {WheelEvent} e */ +function onWheel(e) { + if (!drag || drag.over !== 'viewport' || !e.deltaY) return; + // the wheel turns the ghost — the camera must not zoom underneath it + e.preventDefault(); + e.stopPropagation(); + rotateBy(e.deltaY > 0 ? 1 : -1); +} + +/** @param {number} steps */ +export function rotateBy(steps) { + if (!drag) return; + drag.rotation = (((drag.rotation + steps * STEP_DEG) % 360) + 360) % 360; + update(drag.x, drag.y); +} + +/** the viewport canvas, if the point is over it @param {number} x @param {number} y */ +function viewportAt(x, y) { + /** @type {any} */ + const renderer = get(globalRenderer); + const canvas = renderer?.domElement; + if (!canvas) return { over: /** @type {const} */ ('other'), el: null }; + const el = document.elementFromPoint(x, y); + if (el === canvas) return { over: /** @type {const} */ ('viewport'), el }; + if (el?.closest?.('#explorer-window, #explorer-list')) return { over: /** @type {const} */ ('explorer'), el }; + return { over: /** @type {const} */ ('other'), el }; +} + +/** @param {number} x @param {number} y */ +function update(x, y) { + if (!drag) return; + drag.x = x; + drag.y = y; + const { over, el } = viewportAt(x, y); + drag.over = over; + if (over === 'viewport') { + bridgeLeave(); + if (!drag.ghostUp) { + drag.ghostUp = showGhost(drag.items.map((/** @type {DragItem} */ it) => ({ box: it.dims.box }))); + applyTiers(); + } + if (!drag.prefetched) prefetch(); + place(x, y); + poseGhost(drag.placements, drag.state, drag.placements.length > 0); + } else { + poseGhost([], drag.state, false); + if (drag.pointerType === 'mouse') bridgeOver(el, x, y); + } + publish(); +} + +function publish() { + if (!drag) return placeDrag.set(null); + const first = drag.items[0]; + const many = drag.items.length; + placeDrag.set({ + x: drag.x, + y: drag.y, + label: many > 1 ? `${first.label} + ${many - 1} more` : first.label, + count: many, + over: drag.over, + state: drag.state, + dims: get(placementShowDims) ? (first.dims.known ? dimsLabel(first.dims.size) : 'size unknown') : '', + cost: get(placementShowDims) ? costLabel(first.dims) : '', + unknown: !first.dims.known, + rotation: drag.rotation, + pointerType: drag.pointerType, + alt: drag.alt, + collapse: drag.collapse + }); +} + +// ---- where it lands ------------------------------------------------------------------ + +const UP = new THREE.Vector3(0, 1, 0); +const _n = new THREE.Vector3(); +const _nl = new THREE.Vector3(); +const _qi = new THREE.Quaternion(); +const _yaw = new THREE.Quaternion(); +const _align = new THREE.Quaternion(); +const _right = new THREE.Vector3(); +const _box = new THREE.Box3(); + +/** world AABBs of every top-level object, read once per drag (the scene does not move under a drag) */ +function obstacles() { + if (drag.obstacles) return drag.obstacles; + /** @type {any} */ + const group = get(objectsGroup); + /** @type {any[]} */ + const out = []; + for (const child of group?.children ?? []) { + if (!child.visible) continue; + const b = new THREE.Box3().setFromObject(child); + if (b.isEmpty() || !Number.isFinite(b.min.x)) continue; + out.push(b); + } + drag.obstacles = out; + return out; +} + +/** + * The lowest point of a box (in its own frame) along `nl`, i.e. min over the box of dot(nl, p). + * @param {number[]} box @param {any} nl + */ +function supportMin(box, nl) { + return (nl.x >= 0 ? box[0] : box[3]) * nl.x + (nl.y >= 0 ? box[1] : box[4]) * nl.y + (nl.z >= 0 ? box[2] : box[5]) * nl.z; +} + +/** @param {number} v @param {number} step */ +function snapTo(v, step) { + return step > 0 ? Math.round(v / step) * step : v; +} + +/** @param {number} x @param {number} y */ +function place(x, y) { + if (!dropRef) return (drag.placements = []); + let point = null; + let normal = UP.clone(); + /** @type {any} */ + const controls = get(orbitControls); + if (drag.alt && controls?.target) { + point = controls.target.clone(); + } else { + const t = dropRef.dropTarget(x, y); + if (t.point) { + point = new THREE.Vector3().fromArray(t.point); + if (t.normal) normal = new THREE.Vector3().fromArray(t.normal).normalize(); + } + } + if (!point || point.length() > WORLD_LIMIT) { + drag.state = 'bad'; + drag.placements = point ? drag.items.map(() => ({ position: point.clone(), quaternion: new THREE.Quaternion() })) : []; + drag.target = null; + return; + } + // grid snap on the two world axes the surface spans (Shift = free) + if (get(snapEnabled) && !drag.shift) { + const step = Number(get(snapSettings)?.translate) || 0; + const ax = Math.abs(normal.x); + const ay = Math.abs(normal.y); + const az = Math.abs(normal.z); + const along = ay >= ax && ay >= az ? 'y' : ax >= az ? 'x' : 'z'; + for (const a of ['x', 'y', 'z']) if (a !== along) point[a] = snapTo(point[a], step); + } + _yaw.setFromAxisAngle(UP, THREE.MathUtils.degToRad(drag.rotation)); + const align = !!get(snapTargets)?.alignNormal && normal.distanceTo(UP) > 1e-6; + if (align) _align.setFromUnitVectors(UP, normal); + else _align.identity(); + const q = _align.clone().multiply(_yaw); + // a row of several items, laid out along the camera's right (on the surface) + /** @type {any} */ + const camera = get(globalCamera); + _right.set(1, 0, 0); + if (camera) _right.setFromMatrixColumn(camera.matrixWorld, 0); + _right.addScaledVector(normal, -_right.dot(normal)); + if (_right.lengthSq() < 1e-8) _right.set(1, 0, 0).addScaledVector(normal, -normal.x); + _right.normalize(); + _qi.copy(q).invert(); + _nl.copy(normal).applyQuaternion(_qi); + const widths = drag.items.map((/** @type {DragItem} */ it) => { + const b = it.dims.box; + const half = new THREE.Vector3((b[3] - b[0]) / 2, (b[4] - b[1]) / 2, (b[5] - b[2]) / 2); + const r = _right.clone().applyQuaternion(_qi); + return 2 * (Math.abs(r.x) * half.x + Math.abs(r.y) * half.y + Math.abs(r.z) * half.z); + }); + const total = widths.reduce((/** @type {number} */ s, /** @type {number} */ w) => s + w, 0) + ROW_GAP * (widths.length - 1); + let cursor = -total / 2; + /** @type {any[]} */ + const placements = []; + let worst = 0; + drag.items.forEach((/** @type {DragItem} */ it, /** @type {number} */ i) => { + const b = it.dims.box; + // the row slot is measured to the box CENTRE; shift so the box (not the pivot) sits there + const centre = new THREE.Vector3((b[0] + b[3]) / 2, (b[1] + b[4]) / 2, (b[2] + b[5]) / 2).applyQuaternion(q); + const slot = drag.items.length > 1 ? cursor + widths[i] / 2 : 0; + cursor += widths[i] + ROW_GAP; + const pos = point.clone().addScaledVector(_right, slot); + if (drag.items.length > 1) pos.addScaledVector(_right, -centre.dot(_right)); + // rest the box ON the surface: its lowest point along the normal touches the hit plane + pos.addScaledVector(normal, -supportMin(b, _nl)); + placements.push({ position: pos, quaternion: q.clone() }); + // overlap with other objects (amber, still placeable) + const world = boxWorld(b, pos, q); + const vol = Math.max(volume(world), 1e-9); + for (const ob of obstacles()) { + const inter = world.clone().intersect(ob); + if (inter.isEmpty()) continue; + worst = Math.max(worst, volume(inter) / vol); + } + }); + drag.placements = placements; + drag.target = { point: point.toArray(), normal: normal.toArray() }; + drag.state = worst > OVERLAP_WARN ? 'warn' : 'ok'; +} + +/** @param {number[]} b @param {any} pos @param {any} q */ +function boxWorld(b, pos, q) { + _box.makeEmpty(); + const m = new THREE.Matrix4().compose(pos, q, new THREE.Vector3(1, 1, 1)); + const shrink = 0.02; // a box resting ON a surface is not inside it + for (const x of [b[0] + shrink, b[3] - shrink]) + for (const y of [b[1] + shrink, b[4] - shrink]) + for (const z of [b[2] + shrink, b[5] - shrink]) _box.expandByPoint(new THREE.Vector3(x, y, z).applyMatrix4(m)); + return _box.clone(); +} +/** @param {any} b */ +function volume(b) { + if (b.isEmpty()) return 0; + return (b.max.x - b.min.x) * (b.max.y - b.min.y) * (b.max.z - b.min.z); +} + +// ---- tiers + prefetch ----------------------------------------------------------------- + +/** the decoded model of an item, if one is in memory (sync) @param {DragItem} it */ +function decodedOf(it) { + if (it.url) { + if (it.dims.animated || it.payload.behavior) return null; + return packRefsRef?.peekPackTemplate?.(it.url)?.scene ?? null; + } + if (it.libId) return decoded.get('lib:' + it.libId) ?? null; + if (it.prefabId) return decoded.get('prefab:' + it.prefabId) ?? null; + return null; +} + +function applyTiers() { + if (!drag?.ghostUp) return; + drag.items.forEach((/** @type {DragItem} */ it, /** @type {number} */ i) => { + const scene = decodedOf(it); + // the budget is checked BEFORE anything is built + const tier = currentGhostTier({ decoded: !!scene, tris: it.dims.tris ?? (scene ? measureDims(scene)?.tris ?? null : null) }); + if (tier === drag.tiers[i]) return; + drag.tiers[i] = tier; + setGhostModel(i, tier === 'model' ? scene : null, tier === 'model' ? 'model' : 'box'); + }); +} + +/** Hovering the viewport starts the downloads (and decodes) a drop will need. */ +function prefetch() { + drag.prefetched = true; + const host = drag; + for (const it of /** @type {DragItem[]} */ (drag.items)) { + if (it.url) { + if (it.dims.animated || it.payload.behavior) { + const url = it.url; + import('./packCache').then((m) => m.fetchPackBuffer(url).catch(() => {})); + continue; + } + const job = packRefsRef?.loadPackTemplate?.(it.url); + job?.then(() => drag === host && (refreshDims(), applyTiers())).catch(() => {}); + continue; + } + const wantModel = get(placementPreview) !== 'box'; + if (it.libId && wantModel && !decoded.has('lib:' + it.libId)) { + // a library model whose size is known to be over the budget is not decoded for a box + if (get(placementPreview) === 'budget' && it.dims.tris != null && it.dims.tris > get(placementTriBudget)) continue; + decodeLibrary(it.libId).then(() => drag === host && (refreshDims(), applyTiers())); + } + if (it.prefabId && wantModel && !decoded.has('prefab:' + it.prefabId)) { + decodePrefab(it.prefabId); + if (drag === host) (refreshDims(), applyTiers()); + } + } +} + +/** @param {string} id */ +async function decodeLibrary(id) { + try { + const record = get(explorerRef.explorerItems).find((/** @type {any} */ r) => r.id === id); + const blob = await explorerRef.itemBlob(id); + if (!record || !blob) return; + const ext = String(record.name).split('.').pop()?.toLowerCase() ?? 'glb'; + const root = await explorerRef.parseObjectFile(await blob.arrayBuffer(), ext); + keepDecoded('lib:' + id, root); + // P4: a library record learns its size the first time it is decoded + if (!record.dims) { + const dims = measureDims(root, { bytes: record.size ?? null, animated: !!root.animations?.length }); + if (dims) explorerRef.patchRecord(id, { dims: { size: dims.size, box: dims.box, tris: dims.tris, bytes: dims.bytes } }); + } + } catch {} +} + +/** @param {string} id */ +function decodePrefab(id) { + try { + const prefab = get(prefabsRef.prefabs).find((/** @type {any} */ p) => p.id === id); + if (!prefab?.element) return; + const root = new THREE.ObjectLoader().parse(prefab.element); + keepDecoded('prefab:' + id, root); + } catch {} +} + +// ---- the HTML5 bridge (over anything that is not the viewport) ------------------------- + +/** a DataTransfer carrying the card's payload, flagged as ours */ +function bridgeData() { + const dt = new DataTransfer(); + dt.setData('application/x-explorer-item', JSON.stringify(drag.payload)); + dt.setData('application/x-tp-place-bridge', '1'); + return dt; +} + +/** @param {Element | null} el @param {string} type @param {number} x @param {number} y */ +function fire(el, type, x, y) { + if (!el) return; + try { + el.dispatchEvent(new DragEvent(type, { bubbles: true, cancelable: true, composed: true, clientX: x, clientY: y, dataTransfer: bridgeData() })); + } catch {} +} + +/** @param {Element | null} el @param {number} x @param {number} y */ +function bridgeOver(el, x, y) { + if (el !== drag.bridgeEl) { + if (drag.bridgeEl) fire(drag.bridgeEl, 'dragleave', x, y); + drag.bridgeEl = el; + if (el) fire(el, 'dragenter', x, y); + } + if (el) fire(el, 'dragover', x, y); +} + +function bridgeLeave() { + if (!drag?.bridgeEl) return; + fire(drag.bridgeEl, 'dragleave', drag.x, drag.y); + drag.bridgeEl = null; +} + +/** @param {number} x @param {number} y */ +function bridgeDrop(x, y) { + const el = drag.bridgeEl; + if (!el) return; + fire(el, 'drop', x, y); + drag.bridgeEl = null; +} + +// ---- the end ---------------------------------------------------------------------------- + +function commit() { + const host = drag; + if (host.state === 'bad' || !host.placements.length) return end('invalid'); + const placements = host.placements.map((/** @type {any} */ p) => ({ position: p.position.toArray(), quaternion: p.quaternion.toArray() })); + const items = host.items.map((/** @type {DragItem} */ it) => ({ payload: it.payload, dims: it.dims })); + end('committed'); + lastCommit = { at: Date.now(), count: items.length, placements }; + primed.then(() => dropRef?.placeFromGhost(items, placements)); +} + +/** Cancel the running drag: nothing spawns, nothing enters the undo stack. @param {string} [why] */ +export function cancelPlaceDrag(why = 'cancel') { + if (!drag) return; + end(why); +} + +/** @param {string} why */ +function end(why) { + if (!drag) return; + const host = drag; + if (host.bridgeEl) fire(host.bridgeEl, 'dragleave', host.x, host.y); + // the HTML5 consumers clear their highlight state on the SOURCE's dragend + if (host.pointerType === 'mouse') fire(host.source, 'dragend', host.x, host.y); + host.unsubDims?.(); + window.removeEventListener('pointermove', onMove, true); + window.removeEventListener('pointerup', onUp, true); + window.removeEventListener('pointercancel', onCancelEvent, true); + window.removeEventListener('keydown', onKey, true); + window.removeEventListener('keyup', onKeyUp, true); + window.removeEventListener('wheel', onWheel, true); + window.removeEventListener('blur', onBlur); + document.documentElement.classList.remove('tp-place-dragging', 'tp-place-collapse'); + hideGhost(); + drag = null; + lastEnd = why; + placeDrag.set(null); +} + +/** for the suites @type {string | null} */ +let lastEnd = null; +/** @type {any} */ +let lastCommit = null; + +registerDebugHook('placeDrag', { + placeDrag, + placeDragActive, + beginPlaceDrag, + cancelPlaceDrag, + rotateBy, + ghostInfo, + isPlaceable, + state: () => + drag + ? { + over: drag.over, + state: drag.state, + rotation: drag.rotation, + tiers: [...drag.tiers], + items: drag.items.map((/** @type {DragItem} */ it) => ({ label: it.label, dims: it.dims })), + placements: drag.placements.map((/** @type {any} */ p) => ({ position: p.position.toArray(), quaternion: p.quaternion.toArray() })), + target: drag.target ?? null + } + : null, + last: () => ({ end: lastEnd, commit: lastCommit }), + decodedKeys: () => [...decoded.keys()] +}); diff --git a/src/lib/placeGhost.js b/src/lib/placeGhost.js new file mode 100644 index 000000000..c7a382dd1 --- /dev/null +++ b/src/lib/placeGhost.js @@ -0,0 +1,288 @@ +// @ts-ignore - no bundled three type declarations (project-wide) +import * as THREE from 'three'; +import { get } from 'svelte/store'; +import { globalScene } from '../stores/sceneStore'; + +// 39 P2 — THE PLACEMENT GHOST: what an item being dragged into the viewport will look like +// where it would land. Two tiers per item: +// BOX a wire box with a faint fill and a ground footprint, sized from the item's dims +// (the pack row, the library record, a measurement — or the 1 m "size unknown" box); +// MODEL the real model, translucent: the template's OWN geometry under ONE shared ghost +// material — no clone of a buffer, no physics, no scripts, no shadows. Only built when +// the item is already decoded in memory and the preview setting allows it. +// The box is always drawn first; a model swaps in on a later frame (never inside the pointer +// event that asked for it), so a drag never waits on a build. +// +// LOCAL by construction (golden rule 5): one group at the SCENE ROOT beside objectsGroup, so it +// is never saved, sent, undone or picked (every node's raycast is a no-op as well — the drop +// raycast must see the surface under the ghost, not the ghost). + +const ROOT_NAME = 'place-ghost'; +/** fallbacks when a theme token is missing (a token is read at build time — a canvas/GL colour + * cannot take var()) */ +const FALLBACK = { ok: '#3ba7ff', warn: '#fbbf24', bad: '#f87171' }; // tokens-ok: GL needs literals; the live values come from --accent/--ink-warn/--ink-bad + +/** @param {string} token @param {string} fallback */ +function tokenColor(token, fallback) { + try { + const v = getComputedStyle(document.documentElement).getPropertyValue(token).trim(); + return new THREE.Color(v || fallback); + } catch { + return new THREE.Color(fallback); + } +} + +const noRaycast = () => {}; +const unitEdges = new THREE.EdgesGeometry(new THREE.BoxGeometry(1, 1, 1)); +const unitBox = new THREE.BoxGeometry(1, 1, 1); +const unitPlane = new THREE.PlaneGeometry(1, 1).rotateX(-Math.PI / 2); + +/** @type {any} */ +let root = null; +/** per-state materials, shared by every item @type {Record | null} */ +let mats = null; +/** @type {{group: any, box: any, model: any, modelKey: string | null}[]} */ +let items = []; +/** the state the materials currently show */ +let shownState = 'ok'; +/** pending model swaps (cancelled when the ghost goes) @type {number[]} */ +let swapTimers = []; + +function materials() { + if (mats) return mats; + /** @param {any} color */ + const set = (color) => ({ + line: new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.95, depthTest: false }), + fill: new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.1, depthWrite: false }), + foot: new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.28, depthWrite: false, polygonOffset: true, polygonOffsetFactor: -2, polygonOffsetUnits: -2 }), + model: new THREE.MeshLambertMaterial({ color, transparent: true, opacity: 0.55, emissive: color, emissiveIntensity: 0.25 }) + }); + mats = { + ok: set(tokenColor('--accent', FALLBACK.ok)), + warn: set(tokenColor('--ink-warn', FALLBACK.warn)), + bad: set(tokenColor('--ink-bad', FALLBACK.bad)) + }; + return mats; +} + +/** re-read the theme (a theme switch between two drags) */ +function refreshMaterials() { + if (!mats) return; + const pairs = /** @type {const} */ ([['ok', '--accent', FALLBACK.ok], ['warn', '--ink-warn', FALLBACK.warn], ['bad', '--ink-bad', FALLBACK.bad]]); + for (const [k, token, fb] of pairs) { + const c = tokenColor(token, fb); + for (const m of Object.values(mats[k])) { + m.color.copy(c); + if (m.emissive) m.emissive.copy(c); + } + } +} + +/** @param {any} obj */ +function quiet(obj) { + obj.traverse((/** @type {any} */ n) => { + n.raycast = noRaycast; + n.castShadow = false; + n.receiveShadow = false; + n.matrixAutoUpdate = true; + }); + return obj; +} + +/** @param {number[]} box */ +function boxGhost(box) { + const m = materials()[shownState]; + const g = new THREE.Group(); + g.name = 'place-ghost-box'; + const w = Math.max(box[3] - box[0], 0.02); + const h = Math.max(box[4] - box[1], 0.02); + const d = Math.max(box[5] - box[2], 0.02); + const cx = (box[0] + box[3]) / 2; + const cy = (box[1] + box[4]) / 2; + const cz = (box[2] + box[5]) / 2; + const edges = new THREE.LineSegments(unitEdges, m.line); + edges.renderOrder = 4990; + const fill = new THREE.Mesh(unitBox, m.fill); + for (const o of [edges, fill]) { + o.scale.set(w, h, d); + o.position.set(cx, cy, cz); + g.add(o); + } + const foot = new THREE.Mesh(unitPlane, m.foot); + foot.scale.set(w, 1, d); + foot.position.set(cx, box[1] + 0.002, cz); + foot.name = 'place-ghost-footprint'; + g.add(foot); + return quiet(g); +} + +/** + * A translucent copy of a parsed template that SHARES its geometry (nothing is uploaded twice). + * @param {any} template + */ +function modelGhost(template) { + const m = materials()[shownState]; + const g = new THREE.Group(); + g.name = 'place-ghost-model'; + template.updateMatrixWorld(true); + const inv = new THREE.Matrix4().copy(template.matrixWorld).invert(); + template.traverse((/** @type {any} */ node) => { + if (!node.isMesh || !node.geometry || node.isSkinnedMesh) return; + /** @type {any} */ + const copy = node.isInstancedMesh ? new THREE.InstancedMesh(node.geometry, m.model, node.count) : new THREE.Mesh(node.geometry, m.model); + if (node.isInstancedMesh) copy.instanceMatrix = node.instanceMatrix; + new THREE.Matrix4().multiplyMatrices(inv, node.matrixWorld).decompose(copy.position, copy.quaternion, copy.scale); + copy.frustumCulled = false; + g.add(copy); + }); + return quiet(g); +} + +/** + * Show a ghost for `specs` (one per dragged item). Replaces any ghost already up. + * @param {{box: number[], template?: any, key?: string}[]} specs + */ +export function showGhost(specs) { + hideGhost(); + /** @type {any} */ + const scene = get(globalScene); + if (!scene) return false; + materials(); + refreshMaterials(); + root = new THREE.Group(); + root.name = ROOT_NAME; + root.userData.local = true; + for (const spec of specs) { + const group = new THREE.Group(); + const box = boxGhost(spec.box); + group.add(box); + root.add(group); + items.push({ group, box, model: null, modelKey: null }); + } + quiet(root); + root.visible = false; + scene.add(root); + return true; +} + +/** + * Swap item `i` to its real model on a LATER frame (the build never runs inside the pointer + * event that asked for it). `null` puts the box back. + * @param {number} i @param {any} template @param {string} key + */ +export function setGhostModel(i, template, key) { + const item = items[i]; + if (!item || item.modelKey === key) return; + const host = root; + const build = () => { + if (root !== host || !items[i]) return; + if (item.model) { + item.group.remove(item.model); + item.model = null; + } + item.modelKey = key; + if (!template) { + item.box.visible = true; + return; + } + item.model = modelGhost(template); + item.group.add(item.model); + // the model is the preview now; keep only the footprint of the box under it + item.box.children.forEach((/** @type {any} */ c) => (c.visible = c.name === 'place-ghost-footprint')); + }; + swapTimers.push(window.setTimeout(build, 0)); +} + +/** + * Pose the ghost: one placement per item (world position + quaternion), its state colour, + * and whether it is drawn at all (hidden while the pointer is off the viewport). + * @param {{position: any, quaternion: any}[]} placements @param {'ok'|'warn'|'bad'} state @param {boolean} visible + */ +export function poseGhost(placements, state, visible) { + if (!root) return; + root.visible = visible; + if (!visible) return; + if (state !== shownState) { + shownState = state; + const m = materials()[state]; + root.traverse((/** @type {any} */ n) => { + if (n.isLineSegments) n.material = m.line; + else if (n.isMesh && n.name === 'place-ghost-footprint') n.material = m.foot; + else if (n.isMesh && n.parent?.name === 'place-ghost-box') n.material = m.fill; + else if (n.isMesh) n.material = m.model; + }); + } + placements.forEach((p, i) => { + const g = items[i]?.group; + if (!g || !p) return; + g.position.copy(p.position); + g.quaternion.copy(p.quaternion); + }); +} + +/** Take the ghost down (and drop its object references — the geometry is the template's). */ +export function hideGhost() { + for (const t of swapTimers) clearTimeout(t); + swapTimers = []; + if (root) root.parent?.remove(root); + root = null; + items = []; + shownState = 'ok'; +} + +/** @returns {any} the ghost root, for the suites (null when none) */ +export function ghostRoot() { + return root; +} + +/** what the ghost shows, for the suites: per item 'box' | 'model', and the state */ +export function ghostInfo() { + return { + visible: !!root?.visible, + state: shownState, + items: items.map((it) => ({ + tier: it.model ? 'model' : 'box', + key: it.modelKey, + position: it.group.position.toArray(), + quaternion: it.group.quaternion.toArray(), + boxScale: it.box.children[0]?.scale.toArray() ?? null, + meshes: it.model ? it.model.children.length : 0 + })) + }; +} + +/** the boxes held where a drop is still arriving (an import path parses before it adds) @type {Set} */ +const pending = new Set(); + +/** + * Hold a box ghost at a drop's spot until the object lands — the import paths (an animated pack + * item, a library model, a prefab) parse before they add, and the spot must not look empty while + * they do. LOCAL like the ghost. Returns the release. A pending box gives up after 30 s. + * @param {number[]} box @param {number[]} position @param {number[]} quaternion @returns {() => void} + */ +export function holdPendingGhost(box, position, quaternion) { + /** @type {any} */ + const scene = get(globalScene); + if (!scene) return () => {}; + const prev = shownState; + shownState = 'ok'; + const g = boxGhost(box); + shownState = prev; + g.name = 'place-ghost-pending'; + g.position.fromArray(position); + g.quaternion.fromArray(quaternion); + scene.add(g); + pending.add(g); + const release = () => { + clearTimeout(timer); + g.parent?.remove(g); + pending.delete(g); + }; + const timer = setTimeout(release, 30000); + return release; +} + +/** how many drops are still arriving (the suites) */ +export function pendingGhostCount() { + return pending.size; +} diff --git a/src/lib/placementPrefs.js b/src/lib/placementPrefs.js new file mode 100644 index 000000000..c4782cc08 --- /dev/null +++ b/src/lib/placementPrefs.js @@ -0,0 +1,86 @@ +import { writable, get } from 'svelte/store'; +import * as safeStorage from './safeStorage'; +import { coarsePointer } from './inputDevice'; + +// 39 P3 — Settings ▸ Scene ▸ Performance ▸ "Placement preview": how complex the ghost of an item +// being dragged into the scene may be. LOCAL (a fact about this device), persisted through +// safeStorage, read by placeGhost before it builds anything: +// 'full' the real model whenever it is already decoded in memory +// 'budget' the real model only when it is decoded AND under the triangle budget (default) +// 'box' always the bounding box +// The triangle budget defaults to 50k on a desktop and 15k on a phone or a headset, where one +// translucent copy of a dense model would cost the frame the drag is supposed to keep smooth. + +/** @typedef {'full' | 'budget' | 'box'} PreviewMode */ + +export const PREVIEW_MODES = /** @type {const} */ (['full', 'budget', 'box']); +export const DESKTOP_TRI_BUDGET = 50000; +export const LIGHT_TRI_BUDGET = 15000; + +/** is this a phone or a headset (the lighter default budget)? */ +export function lightDevice() { + if (typeof navigator !== 'undefined' && /OculusBrowser|Quest|Pico/i.test(navigator.userAgent || '')) return true; + return coarsePointer(); +} + +/** the default triangle budget for THIS device */ +export function defaultTriBudget() { + return lightDevice() ? LIGHT_TRI_BUDGET : DESKTOP_TRI_BUDGET; +} + +/** @param {any} v @returns {PreviewMode} */ +export function normalizePreviewMode(v) { + return v === 'full' || v === 'box' || v === 'budget' ? v : 'budget'; +} + +/** @param {any} v @returns {number} */ +export function normalizeTriBudget(v) { + const n = Math.round(Number(v)); + return Number.isFinite(n) && n >= 0 ? Math.min(n, 5_000_000) : defaultTriBudget(); +} + +/** + * @template T @param {string} key @param {T} fallback @param {(raw: any) => T} normalize + * @returns {import('svelte/store').Writable} + */ +function persisted(key, fallback, normalize) { + /** @type {T} */ + let initial = fallback; + try { + const raw = safeStorage.getItem(key); + if (raw !== null) initial = normalize(JSON.parse(raw)); + } catch { + initial = fallback; + } + const store = writable(initial); + let first = true; + store.subscribe((value) => { + if (first) { + first = false; + return; + } + safeStorage.setItem(key, JSON.stringify(normalize(value))); + }); + return store; +} + +export const placementPreview = persisted('placement:preview', /** @type {PreviewMode} */ ('budget'), normalizePreviewMode); +/** null in storage = "the device default", so a phone and a desktop sharing a profile each get theirs */ +export const placementTriBudget = persisted('placement:triBudget', defaultTriBudget(), normalizeTriBudget); +export const placementShowDims = persisted('placement:showDims', true, (v) => v !== false); + +/** + * May the ghost be the real model? (the P2 tier rule, pure so the suites can read it) + * @param {{decoded: boolean, tris: number | null}} item @param {PreviewMode} mode @param {number} budget + * @returns {'model' | 'box'} + */ +export function ghostTier(item, mode, budget) { + if (!item.decoded || mode === 'box') return 'box'; + if (mode === 'full') return 'model'; + return item.tris != null && item.tris <= budget ? 'model' : 'box'; +} + +/** the live tier rule against the stores @param {{decoded: boolean, tris: number | null}} item */ +export function currentGhostTier(item) { + return ghostTier(item, get(placementPreview), get(placementTriBudget)); +} diff --git a/tests/e2e/place-drag.test.cjs b/tests/e2e/place-drag.test.cjs new file mode 100644 index 000000000..54528e13d --- /dev/null +++ b/tests/e2e/place-drag.test.cjs @@ -0,0 +1,223 @@ +// Roadmap 39 P1/P2 — DRAG TO PLACE from the Explorer. Real mouse gestures on a real pack card: +// · a box ghost with the W × D × H the pack ROW declares (the bundled starter Duck carries dims) +// · the real model swaps in once the file is decoded and under the budget; a box above it +// · R / the wheel turn it in 15° steps, Shift is free, the ghost rests ON the surface +// · Esc cancels, dragging back to the Explorer cancels: no object, no undo entry +// · releasing over the viewport spawns ONE object (one undo step) exactly at the ghost, as a +// pack stub in the Placeholder Style, replicated to a second peer (same uuid, same pose) +// · the card's other HTML5 targets still work (a drop on a Library folder card is a bridge drop) +// +// APP_URL=https://theprototype.app:5390/ node tests/e2e/place-drag.test.cjs +const h = require('./helpers.cjs'); + +const fs = require('fs'); +const path = require('path'); +const STATIC = path.join(__dirname, '../../static/library'); + +/** THE CDN PATH, served locally: the pack index names one pack (the starter, whose rows carry + * dims — 39 P4) and every file under PACKS_BASE is answered from static/library, so a placed + * Duck is a real PACKS_BASE-relative reference exactly as in production, with no network. */ +async function routeStarterPack(peer) { + await peer.ctx.route(/cdn\.jsdelivr\.net\/gh\/theprototype-app\/packs@[^/]+\/(.*)$/, (route) => { + const rel = decodeURIComponent(new URL(route.request().url()).pathname.replace(/^.*packs@[^/]+\//, '')); + if (rel === 'index.json') + return route.fulfill({ contentType: 'application/json', body: JSON.stringify([{ name: 'default', title: 'The Prototype', value: 'default/default.json', attribution: 'default/attribution.html', copyright: '', license: '', source: 'https://github.com/theprototype-app/packs' }]) }); + const file = path.join(STATIC, rel); + if (!file.startsWith(STATIC) || !fs.existsSync(file)) return route.fulfill({ status: 404, body: '' }); + const type = /\.json$/.test(file) ? 'application/json' : /\.glb$/.test(file) ? 'model/gltf-binary' : /\.png$/.test(file) ? 'image/png' : 'text/html'; + return route.fulfill({ contentType: type, body: fs.readFileSync(file) }); + }); + await peer.page.evaluate(() => window.__stores.packs.loadPacks()); +} + +/** open the Explorer on the starter pack and return the Duck card's centre */ +async function openStarterPack(peer) { + const { page } = peer; + await page.evaluate(() => { + window.__stores.explorerClose?.set?.(false); + }); + if (!(await page.locator('#explorer-window, #explorer-list').count())) await page.locator('#explorer-slot').click(); + await page.waitForTimeout(400); + await page.evaluate(() => window.__stores.explorer.activeFolder.set('pack:default')); + const card = page.locator('.explorer-card', { hasText: 'Duck' }).first(); + await card.waitFor({ timeout: 15000 }); + return card; +} + +/** the viewport point at a fraction of the canvas, plus the canvas rect */ +async function canvasPoint(page, fx = 0.5, fy = 0.6) { + return page.evaluate( + ({ fx, fy }) => { + let renderer; + window.__stores.globalRenderer.subscribe((v) => (renderer = v))(); + const r = renderer.domElement.getBoundingClientRect(); + return { x: Math.round(r.left + r.width * fx), y: Math.round(r.top + r.height * fy) }; + }, + { fx, fy } + ); +} + +/** a free spot on the canvas: elementFromPoint must be the canvas itself (not a window over it) */ +async function freeCanvasPoint(page) { + for (const [fx, fy] of [[0.5, 0.62], [0.42, 0.55], [0.6, 0.5], [0.35, 0.7], [0.65, 0.68], [0.5, 0.4]]) { + const p = await canvasPoint(page, fx, fy); + const ok = await page.evaluate(({ x, y }) => { + let renderer; + window.__stores.globalRenderer.subscribe((v) => (renderer = v))(); + return document.elementFromPoint(x, y) === renderer.domElement; + }, p); + if (ok) return p; + } + return canvasPoint(page); +} + +const pd = (page) => page.evaluate(() => window.__stores.placeDrag.state()); +const last = (page) => page.evaluate(() => window.__stores.placeDrag.last()); +const ghost = (page) => page.evaluate(() => window.__stores.placeDrag.ghostInfo()); +const counts = (page) => + page.evaluate(() => { + const s = window.__stores; + let g, undo; + s.objectsGroup.subscribe((v) => (g = v))(); + s.history.undoStack.subscribe((v) => (undo = v))(); + return { objects: g.children.length, undo: undo.length, uuids: g.children.map((c) => c.uuid) }; + }); + +/** press on a card and travel far enough for a real dragstart, then go to (x, y) in steps */ +async function pickUp(page, card, to) { + const box = await card.boundingBox(); + const sx = box.x + box.width / 2; + const sy = box.y + box.height / 2; + await page.mouse.move(sx, sy); + await page.mouse.down(); + await page.mouse.move(sx + 12, sy + 6, { steps: 4 }); + await page.mouse.move(sx + 30, sy + 10, { steps: 4 }); + if (to) await page.mouse.move(to.x, to.y, { steps: 12 }); + return { sx, sy }; +} + +h.run(async () => { + const browser = await h.launch({ args: h.GPU_ARGS }); + const A = await h.setupPage(browser, 'A'); + const B = await h.setupPage(browser, 'B'); + await routeStarterPack(A); + await routeStarterPack(B); + await h.connect(B, A); + const card = await openStarterPack(A); + const spot = await freeCanvasPoint(A.page); + const before = await counts(A.page); + + // ---- 1. the box ghost from the row's dims ----------------------------------------------- + await A.page.evaluate(() => window.__stores.placementPrefs?.placementTriBudget?.set?.(50000)); + await pickUp(A.page, card, spot); + await A.page.waitForTimeout(300); + let s = await pd(A.page); + h.check(!!s, 'a real drag of a pack card starts a place drag (the native dragstart was handed over)'); + h.check(s?.over === 'viewport', 'over the canvas the drag reads "viewport" (got ' + s?.over + ')'); + const dims = s?.items?.[0]?.dims; + h.check(dims?.source === 'row' && JSON.stringify(dims?.size) === JSON.stringify([1.655, 1.541, 1.152]), 'the Duck ghost is sized from its ROW: [1.655, 1.541, 1.152] m (got ' + JSON.stringify(dims?.size) + ' from ' + dims?.source + ')'); + const chip = await A.page.locator('#place-drag-dims').textContent().catch(() => ''); + h.check(/1\.66 × 1\.15 × 1\.54 m/.test(chip || ''), 'the chip says W × D × H: "' + chip + '"'); + h.check(/4\.2k tris/.test(chip || ''), 'and the triangle count from the row'); + let g = await ghost(A.page); + h.check(g.visible && g.items.length === 1, 'one ghost is drawn in the viewport'); + const sc = g.items[0]?.boxScale ?? []; + h.check(Math.abs(sc[0] - 1.655) < 0.01 && Math.abs(sc[1] - 1.541) < 0.01 && Math.abs(sc[2] - 1.152) < 0.01, 'the wire box IS that size (' + sc.map((v) => v.toFixed(3)) + ')'); + // rest on the surface: the box bottom (minY 0.099) touches the ground plane + const p0 = s.placements[0].position; + h.check(Math.abs(p0[1] - -0.099) < 0.01, 'the ghost rests ON the ground: origin y = -minY (' + p0[1].toFixed(3) + ')'); + + // ---- 2. the real model once decoded and under the budget ---------------------------------- + await h.eventually(() => ghost(A.page), (gi) => gi.items[0]?.tier === 'model', 'hovering the viewport decodes the file and the ghost becomes the real model (4212 tris < 50k)', 15000); + g = await ghost(A.page); + h.check(g.items[0]?.meshes > 0, 'the model ghost holds the template meshes (' + g.items[0]?.meshes + ')'); + + // ---- 3. rotation ---------------------------------------------------------------------- + await A.page.keyboard.press('r'); + await A.page.keyboard.press('r'); + s = await pd(A.page); + h.check(s.rotation === 30, 'R twice turns the ghost 30° (got ' + s.rotation + ')'); + await A.page.mouse.wheel(0, 100); + await A.page.waitForTimeout(100); + s = await pd(A.page); + h.check(s.rotation === 45, 'a wheel notch turns it 15° more, and does not zoom the camera (got ' + s.rotation + ')'); + await A.page.keyboard.press('Shift+R'); + s = await pd(A.page); + h.check(s.rotation === 30, 'Shift+R turns it back (got ' + s.rotation + ')'); + + // ---- 4. Esc cancels --------------------------------------------------------------------- + await A.page.keyboard.press('Escape'); + await A.page.waitForTimeout(200); + s = await pd(A.page); + h.check(s === null && (await last(A.page)).end === 'escape', 'Esc ends the drag'); + await A.page.mouse.up(); + await A.page.waitForTimeout(600); + let after = await counts(A.page); + h.check(after.objects === before.objects && after.undo === before.undo, 'Esc: nothing spawned and no undo entry (' + JSON.stringify(after) + ')'); + h.check(!(await ghost(A.page)).visible && (await ghost(A.page)).items.length === 0, 'and the ghost is gone'); + + // ---- 5. back to the Explorer = cancel --------------------------------------------------- + const { sx, sy } = await pickUp(A.page, card, spot); + await A.page.waitForTimeout(250); + h.check((await pd(A.page))?.over === 'viewport', 'premise: the second drag reached the viewport'); + await A.page.mouse.move(sx + 4, sy + 4, { steps: 10 }); + await A.page.waitForTimeout(150); + h.check((await pd(A.page))?.over === 'explorer', 'back over the Explorer the drag reads "explorer"'); + await A.page.mouse.up(); + await A.page.waitForTimeout(600); + after = await counts(A.page); + h.check(after.objects === before.objects && after.undo === before.undo, 'released on the Explorer: nothing spawned, no undo entry'); + h.check((await last(A.page)).end === 'released-elsewhere', 'the drag ended as "released elsewhere"'); + + // ---- 6. the drop ----------------------------------------------------------------------- + await pickUp(A.page, card, spot); + await A.page.waitForTimeout(250); + await A.page.keyboard.press('r'); + await A.page.waitForTimeout(100); + const want = (await pd(A.page)).placements[0]; + await A.page.mouse.up(); + await h.eventually(() => counts(A.page), (c) => c.objects === before.objects + 1, 'releasing over the viewport spawns one object'); + after = await counts(A.page); + h.check(after.undo === before.undo + 1, 'ONE undo step (' + before.undo + ' → ' + after.undo + ')'); + const fresh = after.uuids.find((u) => !before.uuids.includes(u)); + const facts = await A.page.evaluate((uuid) => { + let g; + window.__stores.objectsGroup.subscribe((v) => (g = v))(); + const o = g.getObjectByProperty('uuid', uuid); + return { pos: o.position.toArray(), quat: o.quaternion.toArray(), ref: o.userData.packRef ?? null, name: o.name }; + }, fresh); + h.check(facts.pos.every((v, i) => Math.abs(v - want.position[i]) < 1e-4), 'it stands exactly where the ghost stood'); + h.check(facts.quat.every((v, i) => Math.abs(v - want.quaternion[i]) < 1e-4), 'turned the way the ghost was turned'); + h.check(facts.ref?.item === 'Duck' && Array.isArray(facts.ref?.box), 'it is a pack reference carrying the row box (' + JSON.stringify(facts.ref) + ')'); + await h.eventually( + () => A.page.evaluate((uuid) => { + let g; + window.__stores.objectsGroup.subscribe((v) => (g = v))(); + const o = g.getObjectByProperty('uuid', uuid); + return o ? o.children.length : -1; + }, fresh), + (n) => n > 0, + 'the stub fills with the model (the prefetched template)' + ); + // ---- 7. replicated to the second peer ------------------------------------------------- + const onB = (uuid) => + B.page.evaluate((uuid) => { + let g; + window.__stores.objectsGroup.subscribe((v) => (g = v))(); + const o = g.getObjectByProperty('uuid', uuid); + return o ? { pos: o.position.toArray(), quat: o.quaternion.toArray(), kids: o.children.length, ref: o.userData.packRef ?? null } : null; + }, uuid); + const b = await h.eventually(() => onB(fresh), (o) => !!o, 'peer B receives the placed object under the SAME uuid', 15000); + const bo = await onB(fresh); + h.check(!!bo && bo.pos.every((v, i) => Math.abs(v - want.position[i]) < 1e-3) && bo.quat.every((v, i) => Math.abs(v - want.quaternion[i]) < 1e-3), 'at the same pose on B'); + h.check(bo?.ref?.item === 'Duck', 'B holds it as the same pack reference (it travelled as a stub)'); + await h.eventually(() => onB(fresh), (o) => (o?.kids ?? 0) > 0, 'and B refills it from the pack itself', 15000); + + await A.page.keyboard.press('Control+z'); + await A.page.waitForTimeout(600); + after = await counts(A.page); + h.check(after.objects === before.objects, 'one Ctrl+Z removes the placed object'); + await h.eventually(() => onB(fresh), (o) => o === null, 'and the undo reaches B', 10000); + + await h.finish(browser); +}); From ace040c6f002c5e1f7952dac5a25a5a7972e8eac Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Thu, 8 Oct 2026 06:10:13 +0300 Subject: [PATCH 3/9] [feat] placement preview setting, downloaded pack items, Delete cache, touch drag (39 P3 + P5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Settings ▸ Scene ▸ Performance ▸ Placement preview (PlacementSettings.svelte, its own file, keywords + settingsIndex rows): Full model / Within budget (default) / Box only, the triangle budget (50k desktop, 15k phone/headset) and Show dimensions. LOCAL, safeStorage. - Explorer ▸ Packs: a Downloaded badge (bottom right, size in its title) on every pack item this device holds, a "Downloaded" axis in the filter menu inside the Packs, the item menu's Delete -> "Delete cache (size)" (disabled when nothing is held; the item stays in the pack), and the pack row's "Delete downloads (size)". - Storage: a "Pack downloads" category (one row per pack, CacheStorage — reclaimed through packCache) + "Clear all pack downloads"; the measured-sizes key is claimed there too. - placing a pack item re-downloads it when its cache was deleted (a file still parsed in memory). - touch: the place drag blocks the browser's pan with a non-passive touchmove (touch-action is decided at touchstart, before the long-press) — without it every finger drag ended in a pointercancel (measured). - suites: place-drag 39 (+ release over chrome places nothing — proven red with App's bridge guard removed, 0 -> 1 —, a library model's import-time size, and the HTML5 bridge still moving a card into a folder), place-drag-cache 33 (downloads, filter, Delete cache, Storage, the preview tiers, the Settings rows, a long-press drag at 390 x 844). Co-Authored-By: Claude Opus 5.5 (1M context) --- src/components/editors/Explorer.svelte | 77 +++++- src/components/menu/StorageModal.svelte | 19 ++ .../menu/settings/PlacementSettings.svelte | 56 +++++ .../menu/settings/SceneSettings.svelte | 2 + src/lib/explorerDrop.js | 5 +- src/lib/packs.js | 13 + src/lib/placeDrag.js | 9 + src/lib/settingsIndex.js | 3 + src/lib/storageUsage.js | 64 +++++ tests/e2e/place-drag-cache.test.cjs | 235 ++++++++++++++++++ tests/e2e/place-drag.test.cjs | 60 +++++ 11 files changed, 538 insertions(+), 5 deletions(-) create mode 100644 src/components/menu/settings/PlacementSettings.svelte create mode 100644 tests/e2e/place-drag-cache.test.cjs diff --git a/src/components/editors/Explorer.svelte b/src/components/editors/Explorer.svelte index 22e59c9ce..27a2467cb 100644 --- a/src/components/editors/Explorer.svelte +++ b/src/components/editors/Explorer.svelte @@ -66,7 +66,7 @@ import { prefabFormatOf, prefabFileName, saveBytes } from '$lib/saveAs'; // R22 round 11: a pack you make yourself — see packs.js for why it is an "imported" // pack with no zip behind it rather than a fourth kind. - import { createPack, addToPack } from '$lib/packs'; + import { createPack, addToPack, packItemUrls } from '$lib/packs'; // R22 round 11: the preview window's arrows walk THIS folder, in the order THIS grid is // showing it — a question only the Explorer can answer (filters, search, view mode, // sort), so it is published rather than derived over there. The noteMarkers shape. @@ -357,6 +357,8 @@ import { safeStorage } from '$lib/safeStorage'; import { minimalScroll } from '$lib/ui/minimalScroll.js'; import { beginPlaceDrag, isPlaceable } from '$lib/placeDrag'; + import { packCacheIndex, loadPackCacheIndex, cachedBytesOf, deletePackCache } from '$lib/packCache'; + import { bytesLabel } from '$lib/placementDims'; const clampH = (h: number) => Math.min(Math.max(h || 300, 200), Math.round(window.innerHeight * 0.8)); @@ -555,6 +557,8 @@ * grid. One switch, one question: show me what is NOT in the project yet. */ let localOnly = $state(false); + /** 39 P5: show only the pack items this device has downloaded (LOCAL, like the others) */ + let downloadedOnly = $state(false); /** R22-R8: is the Logs pane showing? LOCAL and session-only — it is a debugging * view, and one that came back on every reload would be clutter. */ @@ -618,7 +622,7 @@ // 38 R6: WindowChrome draws the floating header now; the width probe rides on it const headerAttrs = { [createAttachmentKey()]: fromAction(headerWidth) }; const hideIdentity = $derived(headerW < 340); - const filtering = $derived(kindFilter.size > 0 || localOnly); + const filtering = $derived(kindFilter.size > 0 || localOnly || downloadedOnly); /** R22-R2: the share state of a card, and the ONE place the vocabulary is read. A * derived remote row is shared BY DEFINITION — it is in the index and that is the @@ -971,6 +975,21 @@ ]; if (packSourceUrl(pack)) items.push({ label: 'Open source', action: () => window.open(packSourceUrl(pack), '_blank', 'noopener') }); + // 39 P5: free every download of this pack (its items stay listed) + if (pack.source === 'default') { + const prefix = (pack.base ?? '') + '/'; + const urls = [...$packCacheIndex.keys()].filter((u) => u.startsWith(prefix)); + if (urls.length) + items.push({ + label: 'Delete downloads (' + bytesLabel(cachedBytesOf(urls, $packCacheIndex)) + ')', + icon: 'trash-2', + tooltip: 'Frees what this device downloaded from this pack. Every item stays in the pack.', + action: async () => { + const freed = await deletePackCache(urls); + showToast('Freed ' + bytesLabel(freed) + ' from ' + pack.title); + } + }); + } // M-2: a default-list .zip pack (e.g. audio/SFX) installs on demand if (pack.source === 'default' && pack.zip) items.push({ label: 'Install pack', action: () => installZipPack(pack) }); @@ -995,6 +1014,8 @@ thumbIdx = {}; loadPackItems(packByName(a.slice(5))); } + // 39 P5: the cached badges and the pack rows' "Delete downloads" + if (typeof a === 'string' && (a === 'packs' || a.startsWith('pack:'))) void loadPackCacheIndex(); }); // pack-item thumbnail: imported items carry a dataURL; default items resolve // webp -> png -> screenshot via the onerror cursor, else a placeholder icon @@ -1325,7 +1346,11 @@ // Give each a stable unique id — default items have none, and the keyed {#each} // needs one (duplicate undefined keys crash the block). if (typeof $activeFolder === 'string' && $activeFolder.startsWith('pack:')) { - return $openPackItems.map((it) => ({ ...it, packEntry: true, id: it.id ?? `pack:${it.packName}:${it.name}` })); + // 39 P5: the ONE filter axis a pack view answers is "Downloaded" (the type / Local only + // axes have always left pack items alone — a pack item has no share state) + return $openPackItems + .filter((it) => !downloadedOnly || !it.glbUrl || $packCacheIndex.has(it.glbUrl)) + .map((it) => ({ ...it, packEntry: true, id: it.id ?? `pack:${it.packName}:${it.name}` })); } // the Scene manifest (108): a derived, always-shared view — never editable // R22 round 4: THE RECYCLE BIN, a derived view like the Scene manifest — the log is @@ -4853,7 +4878,11 @@ // R22 round 4: deleting a SHARED file removes it from the project for everyone, // and every peer's copy goes to their recycle bin rather than being destroyed. // A LOCAL file keeps the plain delete — there is nobody else to tell. - isShared(item) + // 39 P5: a PACK item is not ours to delete — what this device holds of it is a + // download, so its Delete frees that (and the item stays in the pack). + item.packEntry && item.glbUrl + ? packCacheEntry(item) + : isShared(item) ? { label: 'Delete for everyone', icon: 'trash-2', @@ -4876,6 +4905,26 @@ }; } + /** 39 P5: the pack item's "Delete cache" row — the download's size, or why there is none */ + function packCacheEntry(item: any) { + const urls = packItemUrls(item); + const bytes = cachedBytesOf(urls, $packCacheIndex); + const held = $packCacheIndex.has(item.glbUrl); + return { + label: held ? 'Delete cache (' + bytesLabel(bytes) + ')' : 'Delete cache', + icon: 'trash-2', + danger: held, + disabled: !held, + tooltip: held + ? 'Frees this download on this device. The item stays in the pack and downloads again when it is placed.' + : 'Not downloaded on this device — nothing to free', + action: async () => { + const freed = await deletePackCache(urls); + showToast('Freed ' + bytesLabel(freed) + ' — ' + (item.label || item.name) + ' stays in the pack'); + } + }; + } + // right-click on the grid background = new folder HERE (106.7) /** * R22-R7. Kinds come from what the library ACTUALLY HOLDS rather than from the full @@ -5176,12 +5225,24 @@ tooltip: 'Show only the files nobody else can see yet — folders included', action: () => (localOnly = !localOnly) }); + // 39 P5: inside the Packs, which items are already on this device + if (typeof $activeFolder === 'string' && ($activeFolder === 'packs' || $activeFolder.startsWith('pack:'))) { + items.push({ section: 'Packs' }); + items.push({ + label: 'Downloaded', + checked: downloadedOnly, + icon: 'circle-check', + tooltip: 'Show only the pack items this device has already downloaded', + action: () => (downloadedOnly = !downloadedOnly) + }); + } if (filtering) items.push({ label: 'Clear filters', action: () => { kindFilter = new Set(); localOnly = false; + downloadedOnly = false; } }); menu = { x: e.clientX, y: e.clientY, items }; @@ -7804,6 +7865,14 @@ data-behavior={item.behavior.type} >