Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 23 additions & 1 deletion PACKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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://…",
Expand All @@ -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 <pack> --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`,
Expand Down
5 changes: 5 additions & 0 deletions src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -391,6 +395,7 @@ import { startMusicToolbox } from './lib/musicToolbox'
<ModelPreviewWindow />
{/if}
<Menu />
<PlaceDragOverlay />
<!-- 29-E + 36-export: the player's chrome on a play link / embed / exported game (start card,
fullscreen, the open-in-app link) and the runtime-drawn "Made with ThePrototype" badge -->
<EmbedChrome />
Expand Down
129 changes: 121 additions & 8 deletions src/components/editors/Explorer.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) });
Expand All @@ -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 <img> onerror cursor, else a placeholder icon
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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',
Expand All @@ -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
Expand Down Expand Up @@ -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 };
Expand Down Expand Up @@ -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 } : {})
};
}
/**
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -7760,6 +7865,14 @@
data-behavior={item.behavior.type}
><Icon name="play" size={16} aria-hidden="true" /></span>
{/if}
{#if item.packEntry && item.glbUrl && $packCacheIndex.has(item.glbUrl)}
<!-- 39 P5: DOWNLOADED. Bottom RIGHT: a pack card carries no roster or share dot. -->
<span
class="explorer-cached absolute bottom-1 right-1 flex h-4 w-4 items-center justify-center rounded-full bg-app/80 text-ink-good"
title={'Downloaded · ' + bytesLabel(cachedBytesOf(packItemUrls(item), $packCacheIndex)) + ' on this device'}
data-cached-bytes={cachedBytesOf(packItemUrls(item), $packCacheIndex)}
><Icon name="circle-check" size={16} aria-hidden="true" /></span>
{/if}
{#if item.packEntry}
{#if packThumb(item)}
<!-- N6: lazily resolve webp -> png -> screenshot via onerror; P2: cache the winner -->
Expand Down
Loading
Loading