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
11 changes: 11 additions & 0 deletions src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@
import SculptToolbar from './components/menu/SculptToolbar.svelte'
import SplineToolbar from './components/menu/SplineToolbar.svelte'
import MeasureToolbar from './components/menu/MeasureToolbar.svelte' // 47 M1
import UnderlayWindow from './components/menu/UnderlayWindow.svelte' // 60 Q5
import { underlayDropMenu } from '$lib/underlays'
import ModuleToolboxLayer from './components/ui/ModuleToolboxLayer.svelte'
// 48 D1: the node graph debugger's panel (loaded the first time it opens)
import { debugOpen } from '$lib/flowDebug.js'
Expand Down Expand Up @@ -388,6 +390,13 @@ import { startMusicToolbox } from './lib/musicToolbox'
// an unsupported file — importFile pairs them up.
const companionExt = /\.(mtl|png|jpe?g|webp|bmp|gif)$/i
const companions = files.filter((file) => companionExt.test(file.name))
// 60 Q5: a drop of nothing but images is not a model's companions — they used to be
// dropped silently. Ask what they are for: a reference image to build over, or the Explorer.
const imageExt = /\.(png|jpe?g|webp|bmp|gif)$/i
if (files.every((file) => imageExt.test(file.name))) {
underlayDropMenu.set({ x: event.clientX, y: event.clientY, files })
return
}
files.forEach((file) => {
const name = file.name.toLowerCase()
if (modelExt.test(name)) importFile(file, file.name.replace(modelExt, ''), undefined, undefined, companions)
Expand Down Expand Up @@ -451,6 +460,8 @@ import { startMusicToolbox } from './lib/musicToolbox'
<SculptToolbar />
<SplineToolbar />
<MeasureToolbar />
<!-- 60 Q5: Reference images (+ the image-drop "Use as reference" menu) -->
<UnderlayWindow />
<!-- A5: module toolboxes. OUTSIDE the {#if !$isLocked} block — a toolbox that opted
into playMode has to survive Play mode, and the layer decides per box. -->
<ModuleToolboxLayer />
Expand Down
5 changes: 5 additions & 0 deletions src/components/Scene.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@
// 36 A2: the headset browser's own "Enter VR" offer — decided by us, not threlte's default
import { xrOfferMode } from '$lib/xrOffer';
import MeasureOverlay from './MeasureOverlay.svelte';
import UnderlayLayer from './UnderlayLayer.svelte'; // 60 Q5
import AnnotationPins from './AnnotationPins.svelte';
import PingMarkers from './PingMarkers.svelte';
import PingHighlights from './PingHighlights.svelte';
Expand Down Expand Up @@ -1811,6 +1812,10 @@
<T.Group bind:ref={$worldRig} name="world-grab-rig">
<T.Group bind:ref={$objectsGroup} name="sceneObjects" />

<!-- 60 Q5: reference images — under the world rig (a VR world grab carries them), never in
sceneObjects (never picked, synced, saved or exported) -->
<UnderlayLayer />

<CameraPreview />

<!-- CO4: no virtual ground in AR — the grid stands down over passthrough
Expand Down
101 changes: 101 additions & 0 deletions src/components/UnderlayLayer.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
<script>
// 60 Q5: THE REFERENCE IMAGES IN THE VIEWPORT. Mounted inside the world rig (so a VR world grab
// carries them with the content) but OUTSIDE `sceneObjects` — nothing under here is picked,
// selected, synced, saved, exported or published (the underlays.js header). Each plane:
// · sits on the editor HELPER layer (helperLayer.js), so only the editor camera ever draws it —
// the play camera, camera previews, the PiP, gallery captures and clean recordings never do;
// · is ALSO switched off in Play and Interact, whatever the "helpers in Play" debug toggle says;
// · has a no-op raycast, so even a ray cast over the whole scene cannot land on it.
import { T } from '@threlte/core';
import * as THREE from 'three';
import { onDestroy } from 'svelte';
import { underlays, setUnderlayScene, loadUnderlays, underlayHeight } from '$lib/underlays';
import { markHelper } from '$lib/helperLayer';
import { currentLevel } from '$lib/levels';
import { isLocked, editorMode, pokeScene } from '../stores/sceneStore';

const root = new THREE.Group();
root.name = 'reference-underlays';
root.userData.underlay = true;

/** @type {Map<string, {mesh: any, src: string, texture: any}>} */
const live = new Map();
const loader = new THREE.TextureLoader();
const noRaycast = () => {};

$effect(() => {
setUnderlayScene($currentLevel?.name ?? '');
});
void loadUnderlays();

const playing = $derived($isLocked === true || $editorMode === 'interact');

function build(/** @type {any} */ u) {
const texture = loader.load(u.src, () => pokeScene());
texture.colorSpace = THREE.SRGBColorSpace;
const material = new THREE.MeshBasicMaterial({
map: texture,
transparent: true,
opacity: u.opacity,
side: THREE.DoubleSide,
toneMapped: false,
// a ground underlay must not fight the grid or a floor at the same height
polygonOffset: true,
polygonOffsetFactor: -1,
polygonOffsetUnits: -4
});
const mesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), material);
mesh.name = 'underlay:' + u.id;
mesh.userData.underlay = u.id;
mesh.raycast = noRaycast;
mesh.renderOrder = -1;
markHelper(mesh);
return { mesh, src: u.src, texture };
}

function dispose(/** @type {{mesh: any, texture: any}} */ entry) {
root.remove(entry.mesh);
entry.mesh.geometry.dispose();
entry.mesh.material.dispose();
entry.texture.dispose();
}

$effect(() => {
const list = $underlays;
const hide = playing;
const seen = new Set();
for (const u of list) {
seen.add(u.id);
let entry = live.get(u.id);
if (entry && entry.src !== u.src) {
dispose(entry);
entry = undefined;
}
if (!entry) {
entry = build(u);
live.set(u.id, entry);
root.add(entry.mesh);
}
const m = entry.mesh;
m.position.fromArray(u.position);
m.quaternion.fromArray(u.quaternion);
m.scale.set(u.width, underlayHeight(u), 1);
m.material.opacity = u.opacity;
m.visible = u.visible && !hide;
m.userData.owner = u.owner ?? '';
}
for (const [id, entry] of live) {
if (seen.has(id)) continue;
dispose(entry);
live.delete(id);
}
pokeScene();
});

onDestroy(() => {
for (const entry of live.values()) dispose(entry);
live.clear();
});
</script>

<T is={root} />
20 changes: 19 additions & 1 deletion src/components/menu/Controls.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@
import { dockShownKeys, dockOccupants, bottomInset, FLOW_FAMILY, armDockMode, DOCK_TITLES } from '$lib/bottomDock';
import { togglePanel } from '$lib/panelToggles';
import { requestPlay, willEnterXR, willEnterAR, xrSessionFailed, playAsItems } from '$lib/playMode';
// 60 Q1: Play here — a roster button (Customize), a row in Play's own menu, Ctrl+Shift+Enter
import { playHere, playHereItems, playingHere } from '$lib/playHere';
// 30 P2: Test play — the play button's right-click row beside the game chip's button
import { testPlay } from '$lib/gamePresence';
import { hudIsGame } from '$lib/hudDocs';
Expand Down Expand Up @@ -1035,6 +1037,8 @@
// 41b H7: the "Play as" rows live in $lib/playMode (`playAsItems`) — the phone bar's Play
// opens the same ones, so the two buttons write the same choice the same way
...playAsItems(),
// 60 Q1: Play here (the phone's Play menu carries the same row)
...playHereItems(),
// 30 P2: TEST PLAY — back to the menu, into Play, the Start screen. Offered only
// where it means something: a scene with no game shell has no menu to start from.
...($hudIsGame
Expand Down Expand Up @@ -1344,6 +1348,18 @@
tint: () => ICON_OFF,
run: () => redo()
},
// 60 Q1: PLAY HERE — an OPTIONAL roster button (Customize toolbar / the "+" list place it):
// Play with the player where the editor camera is, and the view comes back on Stop. Not on
// the default bar, so a profile that never asks for it is byte-identical; the same act is
// Play's right-click row and Ctrl+Shift+Enter.
playhere: {
title: 'Play here — start from this view (Ctrl+Shift+Enter)',
label: 'Play here',
slot: 'play-here-button',
icon: 'map-pin',
tint: () => ($playingHere ? ICON_ON : ICON_OFF),
run: () => playHere()
},
multiselect: {
get title() {
return $multiSelectMode
Expand All @@ -1366,7 +1382,9 @@
/** 42 I9: the bell and the peers trigger open their panels from the top-right corner, so they
* may only live in the top-right group (moved, removed, re-added there — never elsewhere) */
const HOME_ONLY: Record<string, Region> = { notifications: 'top', peers: 'top' };
const ROSTER_IDS = [...DEFAULT_ORDER, ...OPTIONAL_VIEWS, ...CORNER_LEFT, ...CORNER_RIGHT, ...TOP_DEFAULT, ...TOUCH_DEFAULT];
/** 60 Q1: optional tool buttons (listed in Customize unchecked, like the optional views) */
const OPTIONAL_TOOLS = ['playhere'];
const ROSTER_IDS = [...DEFAULT_ORDER, ...OPTIONAL_VIEWS, ...OPTIONAL_TOOLS, ...CORNER_LEFT, ...CORNER_RIGHT, ...TOP_DEFAULT, ...TOUCH_DEFAULT];
/** 42 I7: the name a MENU prints for a button (its short label, else its title) */
function labelOf(id: string) {
return id === SPACER ? 'Play' : (BUTTONS[id]?.label ?? BUTTONS[id]?.title ?? id);
Expand Down
Loading
Loading