diff --git a/CHANGELOG.md b/CHANGELOG.md index 78cddff68..7124e11c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,37 @@ per release, newest first. HTML comments like this one are stripped before rendering, so maintainer notes stay out of the user-facing window. --> +## 1.31.0 — Drag to place: see the size and the spot before you let go 📦 + +Drag any object from the Explorer onto the scene and see exactly where it will land and how big it is — the way a +content browser works in a desktop 3D tool. + +- 🖱️ **Drag from Explorer ▸ Packs or ▸ Library onto the viewport** (pack items, your Library models, prefabs). A + ghost sits on whatever is under the pointer — the floor, a table, the side of a wall — and follows the grid when + snapping is on (hold **Shift** to move it freely). Let go and it is placed right there, as **one undo step**, and + everyone in the session sees it. A click (no drag) still places in front of the camera. +- 🔄 **R** (or the mouse wheel) turns it 15° while you drag, **Shift+R** turns it back; hold **Alt** as you let go to + drop it at the camera's focus point; drag several selected cards to drop them side by side in a row. +- 📏 **Its size beside the pointer: width × depth × height in metres**, plus triangles and download size. An item + that is already loaded previews as a see-through copy of the real model; a heavy or not-yet-downloaded one as its + box (hovering the viewport starts the download, so the drop is quick). A placed pack item shows your loading + Placeholder Style until its file arrives. +- 🛑 **Changed your mind?** Esc, dropping back on the Explorer or letting go anywhere else places nothing and leaves + no undo entry. The ghost turns **amber** when it would sit mostly inside another object (you can still place it) + and **red** where there is nothing to stand on. +- 📱 **On a phone or tablet**: press and hold a card until it lifts, then drag — the Explorer tucks itself away so + you can see where you are dropping. +- ⚙️ **Settings ▸ Scene ▸ Performance ▸ Placement preview**: Full model / Within budget (default) / Box only, a + triangle budget (50,000 on a computer, 15,000 on a phone or headset) and Show dimensions. +- ✅ **See what is downloaded**: Explorer ▸ Packs marks downloaded items with a ✓ (size in the tooltip) and the filter + has **Downloaded**. A pack item's **Delete is now "Delete cache (size)"** — it frees the download and the item stays + in the pack; a pack's menu has **Delete downloads**, and **Storage** lists Pack downloads with **Clear all pack + downloads**. +- 🧩 For pack authors: every pack item now carries its size, bounds, triangle count and download size in the + manifest (`kit-build dims`, checked by the packs CI) — see PACKS.md "Item dimensions". + +In VR the radial Add menu still places in front of you; drag-to-place in VR comes later. + ## 1.30.0 — A calm new look: one interface on a desk and in a pocket ✨ Every window, menu, modal, the main HUD and the phone layout move onto one small kit of parts. Everything you diff --git a/PACKS.md b/PACKS.md index 1427e5146..655a3d71e 100644 --- a/PACKS.md +++ b/PACKS.md @@ -176,8 +176,10 @@ packs so new ones appear without redeploying the app: "file": "assets/duck/model.glb", "thumb": "assets/duck/thumb.webp", "name": "Duck", - "size": 123456, + "size": [1.655, 1.541, 1.152], // [width, height, depth] in metres (39 P4) + "box": [-0.693, 0.099, -0.613, 0.962, 1.64, 0.539], // optional exact bounds "tris": 4212, + "bytes": 120484, "license": "SPDX-or-inherit", "author": "…", "source": "https://…", @@ -187,6 +189,26 @@ packs so new ones appear without redeploying the app: } ``` +### Item dimensions: `size`, `box`, `tris`, `bytes`, `animated` (roadmap 39) + +Every item row (a default pack's `default.json` row, or an imported pack's `manifest.json` +item) MAY say how big the item is, so the app can show it **before downloading it** — dragging +an item from the Explorer into the viewport draws a ghost box with its W × D × H in metres: + +- `size` — `[width, height, depth]` in metres, the GLB as shipped (x, y, z extents). +- `box` — `[minX, minY, minZ, maxX, maxY, maxZ]`, the exact bounds around the item's own + origin (so a top-centre or wall pivot draws right; without it the ghost assumes the + bottom-centre pivot). +- `tris` — LOD0's triangles (the placement preview decides "real model or box" by it). +- `bytes` — LOD0's file size (the Explorer's download size). +- `animated: true` — the file carries clips (it places as bytes, not as a kit reference). + +The packs repo writes them for you: `node tools/kit-build/kit-build.mjs dims --write`, +and its CI fails a row whose dims are missing or stale. A row without them still works: the +app measures the file the first time it loads and keeps the result on the device; until then +the ghost is a 1 m box labelled "size unknown". **A NUMBER in `size` is read as the byte count** +(manifests written before roadmap 39 used `size` for bytes). + ## Conventions - **Licenses are [SPDX](https://spdx.org/licenses/) ids** (`CC-BY-4.0`, `CC0-1.0`, 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..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. @@ -356,6 +356,9 @@ import { fly } from 'svelte/transition'; 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)); @@ -554,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. */ @@ -617,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 @@ -970,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) }); @@ -994,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 @@ -1324,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 @@ -4852,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', @@ -4875,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 @@ -5175,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 }; @@ -5509,7 +5571,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 +5620,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 +5902,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 +5920,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 +5948,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 +5961,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 +6014,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 @@ -7760,6 +7865,14 @@ data-behavior={item.behavior.type} >