diff --git a/src/components/Scene.svelte b/src/components/Scene.svelte index 8a36b46d..dde54080 100644 --- a/src/components/Scene.svelte +++ b/src/components/Scene.svelte @@ -9,7 +9,7 @@ import { peers, username, userdata, specatorMode, avatarConfig, viewportMenu, objectContextMenu, viewportMenuOpener, addMenu, addMenuOpener, showToast, multiSelectMode } from '../stores/appStore'; import { get } from 'svelte/store'; import { vrPostEnabled } from '$lib/viewportOverrides'; - import { isLocked, editorCam, isVRMode, globalScene, objectsGroup, showGrid, TControls, selectedObject, selectedObjects, lockedObjects, marqueeRect, worldRig, vrOverride, specators, globalCamera, globalRenderer, orbitControls, passthroughActive, sessionCompositesOverRoom, vrObjectsPanelOpen, vrPaletteOpen, vrPropsPanelOpen, vrPrefabsPanelOpen, vrChatPanelOpen, vrEditMenuOpen, vrSnapMenuOpen, vrSettingsPanelOpen, vrApprovePanelOpen, vrToolMode, viewMode, contextLost } from '../stores/sceneStore'; + import { isLocked, editorMode, editorCam, isVRMode, globalScene, objectsGroup, showGrid, TControls, selectedObject, selectedObjects, lockedObjects, marqueeRect, worldRig, vrOverride, specators, globalCamera, globalRenderer, orbitControls, passthroughActive, sessionCompositesOverRoom, vrObjectsPanelOpen, vrPaletteOpen, vrPropsPanelOpen, vrPrefabsPanelOpen, vrChatPanelOpen, vrEditMenuOpen, vrSnapMenuOpen, vrSettingsPanelOpen, vrApprovePanelOpen, vrToolMode, viewMode, contextLost } from '../stores/sceneStore'; import { selectObject, deselectObject, @@ -29,10 +29,13 @@ import { holdBody, releaseBody } from '$lib/physics'; import { sculptObject, enterSculpt, beginStroke, strokeMove, endStroke as sculptEndStroke, showCursorAt, hideCursor } from '$lib/terrainSculpt'; import { sceneHits } from '$lib/scenePick'; - import { startPlayInteract, tickPlayInteract, stopPlayInteract, carriedUuid } from '$lib/playInteract'; + import { pickStack, chooseInStack } from '$lib/selectThrough'; + import { tickModuleProxy, selectModuleGroup, moduleGroupOf } from '$lib/moduleContent'; + import { startPlayInteract, tickPlayInteract, stopPlayInteract, carriedUuid, editorInteractActive, cursorGrabStart, cursorGrabMove, cursorGrabEnd, interactClick } from '$lib/playInteract'; + import { registerKeySessionProbe } from '$lib/shortcuts'; import { startKnock, tickKnock, stopKnock } from '$lib/knock'; import { tickMoveSmoothing } from '$lib/moveSmoothing'; - import { moduleClickHandlers, moduleInteractiveGroups, fireClickMiss } from '$lib/moduleSDK'; + import { moduleInteractiveGroups, fireClickMiss, runClickHandlers } from '$lib/moduleSDK'; import { updateSpatialAudio } from '$lib/voiceChat'; import { tickAnimatedMixers } from '$lib/animatedImports'; import { tickAnimationPreview, captureAutoKey, playheadOf } from '$lib/animationPreview'; @@ -95,7 +98,51 @@ import { Mesh, Vector3 } from 'three' - let { scene, camera, renderer, dpr } = useThrelte(); + let { scene, camera, renderer, dpr, dom } = useThrelte(); + + // 30 P0: THE POINTER-CAPTURE THROW — ~100 errors on a Quest were one per press. + // three 0.185's OrbitControls.onPointerDown calls `domElement.setPointerCapture` with + // no guard (its TransformControls sibling guards exactly this), and Chromium refuses a + // capture with InvalidStateError while the page holds a pointer lock — which is every + // press in play, since the editor controls stay mounted there. The throw lands before + // `_addPointer`, so it fired once per press and surfaced as "Something went wrong". + // Two halves: the controls stand down while playing (syncOrbitForPlay, per frame), and + // the capture itself is guarded on the element they listen on — the smaller of the two + // ways to reach it (a subclass would mean replacing threlte's with a + // hand-built ), and it covers the camera preview's OrbitControls too, which + // listens on the same element. It calls the PROTOTYPE method at call time rather than + // a captured copy, so nothing else on the element is shadowed for good. + const pointerCaptureGuard = (el: any) => { + if (!el || typeof el.setPointerCapture !== 'function' || el.__captureGuarded) return () => {}; + el.__captureGuarded = true; + el.setPointerCapture = function (this: any, pointerId: number) { + if (this.ownerDocument?.pointerLockElement) return; // the lock already owns the pointer + return Element.prototype.setPointerCapture.call(this, pointerId); + }; + return () => { + delete el.setPointerCapture; + delete el.__captureGuarded; + }; + }; + onDestroy(pointerCaptureGuard(dom)); + // the first half: while playing, every OrbitControls that drives the view stands + // down. Per FRAME rather than on the isLocked edge, because + // unmounts in play and its auto-pause CLEANUP writes `enabled = true` + // unconditionally a tick later (the enableZoom note on the mount below) — a write on + // the edge would be stomped. Whatever this stood down comes back when play ends. + const orbitStoodDown = new Set(); + function syncOrbitForPlay() { + const controls: any = $activeOrbit; + if ($isLocked === true) { + if (controls && controls.enabled !== false) { + controls.enabled = false; + orbitStoodDown.add(controls); + } + } else if (orbitStoodDown.size) { + for (const stood of orbitStoodDown) stood.enabled = true; + orbitStoodDown.clear(); + } + } // 26-D: the quality governor's two knobs that live here. Resolution goes through // threlte's OWN dpr (renderer.setPixelRatio directly would be undone by threlte's resize @@ -276,6 +323,7 @@ useTask((delta) => { rotation += 0.25 * delta; + syncOrbitForPlay(); // 30 P0: the editor camera controls stand down while playing // 21-B B3: play-mode grab/carry. The ray is NDC (0,0) every frame, so it // belongs in the frame loop rather than on a pointer event. tickPlayInteract(delta, camera.current); @@ -346,6 +394,7 @@ updateCables(); // 23-A4: patch cables follow their plugs; the routing diff runs here too updateOnionSkin(); // 17-E F6: ghosts at the neighbouring keys (local, off by default) updateTinyMarkers(); // R2: a dot to aim at when an object has no size left + tickModuleProxy(); // 30 P3: a selected module group's proxy box follows its content if (!renderer.xr.isPresenting) updateEditorNavigation(delta, camera.current, $activeOrbit); }); @@ -443,35 +492,66 @@ return sceneHits(selectionRaycaster, { tinyProxies: true }); } - function runModuleClickHandlers(hit) { - for (const handler of moduleClickHandlers) { - try { - if (handler(hit)) return true; - } catch (error) { - console.log('module click handler failed', error); - } - } - return false; + // 30 P1: ONE dispatch, filtered by the mode the click came from — 'edit' (the editor + // pick: only handlers that asked for Edit), 'play' (the play tap), or null (VR's + // trigger, which has no editor mode yet and still offers every handler first) + function runModuleClickHandlers(hit: any, mode: string | null = null) { + return runClickHandlers(hit, mode); } // 15-O: double-click detection for "open the properties panel" — reuses the // existing click path instead of a separate dblclick listener. Declared at // COMPONENT scope: raycastSelect lives here, not in the pointer-handler block. let lastPick: { uuid: string | null; t: number } = { uuid: null, t: 0 }; + // 30 P2: the last plain editor click — where, when, and what it picked — so a repeat on + // the same spot can walk DOWN the stack instead of re-picking the same object + let lastSelectClick: { x: number; y: number; t: number; uuid: string | null } | null = null; + let selectBehindHinted = safeStorage.getItem('hint:selectBehind') === 'true'; - function raycastSelect(additive = false) { + // 30 P1: `mode` is where the click came from. 'edit' = the desktop editor pick, where a + // module handler runs only if it registered for Edit (so a piano or a puzzle piece no + // longer swallows the select) and On Click nodes do NOT fire — Interact is where the + // scene reacts. null = VR's trigger, unchanged: every handler first, then select + pulse. + function raycastSelect(additive = false, mode: string | null = null, click: { x: number; y: number; t: number } | null = null) { // module-owned interactive groups live at the scene root (pong, dungeon, ...) + // 30 P3: in EDIT the nearest module-content hit that no edit handler took is + // remembered, so a click on a board or a dungeon selects its PROXY (the object list's + // Module content row) instead of passing through it to the floor behind + let moduleHit: { name: string; distance: number } | null = null; for (const name of moduleInteractiveGroups) { const root = scene.getObjectByName(name); if (!root) continue; const moduleHits = selectionRaycaster.intersectObject(root, true); - if (moduleHits.length > 0 && runModuleClickHandlers(moduleHits[0].object)) return true; + if (moduleHits.length > 0 && runModuleClickHandlers(moduleHits[0].object, mode)) return true; + if (moduleHits.length > 0 && (!moduleHit || moduleHits[0].distance < moduleHit.distance)) + moduleHit = { name, distance: moduleHits[0].distance }; } const hits = pickSceneObjects(); - if (hits.length > 0) { - // modules may consume the click (buttons, instruments, ...) - if (runModuleClickHandlers(hits[0].object)) return true; - const target = topLevelObjectOf(hits[0].object); + // 30 P2: SELECT-THROUGH — collapse the hits into a stack of top-level targets and + // prefer the first OPAQUE one (a 0.12-opacity wall, a flagged ceiling or a hidden + // node no longer wins the click); a plain repeat on the same spot walks down it. + const stack = pickStack(hits, topLevelObjectOf); + const choice = chooseInStack(stack, click ?? { x: -1e6, y: -1e6, t: 0 }, !additive && click ? lastSelectClick : null); + const chosen = choice.index >= 0 ? stack[choice.index] : null; + if (mode === 'edit' && !additive && moduleHit && moduleGroupOf(moduleHit.name) && (!chosen || moduleHit.distance < chosen.hit.distance)) { + deselectObject(); + selectModuleGroup(moduleHit.name); + return true; + } + if (chosen) { + // modules may consume the click (buttons, instruments, ...) — in Edit only the + // ones that asked for it (an editor tool), everything else in VR + if (runModuleClickHandlers(chosen.hit.object, mode)) return true; + const target = chosen.target; + if (click && !additive) { + lastSelectClick = { ...click, uuid: target.uuid }; + // the first time a click lands on a stack, say how to reach what is behind + if (stack.length > 1 && !selectBehindHinted) { + selectBehindHinted = true; + safeStorage.setItem('hint:selectBehind', 'true'); + showToast('Click again to select behind'); + } + } if (target) { // 15-O: a plain click SELECTS; the properties panel opens on a // DOUBLE-click (or via the context menu / object list / a pinned @@ -494,7 +574,9 @@ focusObject(target.uuid); isolateObjects([target.uuid]); } - fireObjectClick(target.uuid); // 134: pulse any OnClick node targeting it + // 134: pulse any OnClick node targeting it — not from the EDIT pick (30 P1: + // selecting a button in the editor must not press it; Interact does that) + if (mode !== 'edit') fireObjectClick(target.uuid); return true; } } @@ -625,14 +707,21 @@ let lastSplineClick = 0; // 57.2: a second click here finishes the spline let lastPointerXY: number[] | null = null; // last cursor position, for keyboard-opened menus - const setRayFromEvent = (event) => { + const ndcOfEvent = (event: any) => { const rect = element.getBoundingClientRect(); - const ndc = new THREE.Vector2( + return new THREE.Vector2( ((event.clientX - rect.left) / rect.width) * 2 - 1, -((event.clientY - rect.top) / rect.height) * 2 + 1 ); - selectionRaycaster.setFromCamera(ndc, camera.current); }; + const setRayFromEvent = (event) => { + selectionRaycaster.setFromCamera(ndcOfEvent(event), camera.current); + }; + // 30 P1: an Interact carry in progress (the camera controls stand down for it) + let interactCarrying = false; + // the press that CAN start an Interact gesture: no editor session or tool holds it + const interactPress = () => + editorInteractActive() && !$specatorMode && !$editingObject && !$faceEditObject && !$splineEditObject && !$drawMode && !$sculptObject; const onPointerDown = (event) => { if (event.button === 2) { @@ -673,6 +762,20 @@ } return; } + // 30 P1: INTERACT — a press on a dynamic body of a running sim CARRIES it along the + // cursor (play's hold, unforked); anything else is a click-or-orbit, and never a + // marquee (a box SELECTS, which Interact does not do) + if (interactPress()) { + setRayFromEvent(event); + if (cursorGrabStart(selectionRaycaster, ndcOfEvent(event), camera.current)) { + interactCarrying = true; + setOrbitEnabled(false); + return; + } + downPosition = [event.clientX, event.clientY]; + downTime = Date.now(); + return; + } // Shift+drag = marquee select (13) — orbit pauses for the gesture // #20: inside a MESH SESSION the same gesture boxes ELEMENTS instead of objects. // A separate branch, not a loosened gate: the object marquee must never run in a @@ -703,6 +806,8 @@ }; const onPointerMove = (event) => { + // 30 P1: an Interact carry follows the cursor + if (interactCarrying) cursorGrabMove(ndcOfEvent(event)); // 57.3: a radius drag owns the gesture (thickness, not the camera) if (radiusDragActive()) { radiusDragMove(event.clientY); @@ -767,6 +872,13 @@ }; const onPointerUp = (event) => { + if (interactCarrying && event.button === 0) { + interactCarrying = false; + cursorGrabEnd(); // a throw (false if the carry was already cancelled) + setOrbitEnabled(true); + downPosition = null; + return; + } if (radiusDragActive() && event.button === 0) { endRadiusDrag(); // final broadcast + one undo entry setOrbitEnabled(true); @@ -988,6 +1100,13 @@ } return; } + // 30 P1: INTERACT — the click plays with the scene and selects nothing (the + // editor tools above — pings, spline/measure/path picks, mesh sessions — keep + // their clicks in either mode; they were armed on purpose) + if (interactPress()) { + interactClick(selectionRaycaster); + return; + } // light pick-proxies select their light (lights have no raycastable geometry) if ($lightProxiesGroup) { const proxyHits = selectionRaycaster.intersectObject($lightProxiesGroup, true); @@ -1011,7 +1130,7 @@ // 85: a click on nothing is also the way out of an isolation — the same // "click the background to get back" instinct as deselecting. const additive = event.shiftKey || $multiSelectMode; - if (!raycastSelect(additive) && !additive) { + if (!raycastSelect(additive, 'edit', { x: event.clientX, y: event.clientY, t: Date.now() }) && !additive) { if (isIsolated()) clearIsolation(); deselectObject(); } @@ -1292,7 +1411,10 @@ const onDisc = (e: any) => { e.target.userData.handedness = null; }; // 21-B B3: play mode's own input path. Registered HERE, below every `let` // its closure reads (runModuleClickHandlers among them) — the TDZ rule. - startPlayInteract({ moduleHitTest: runModuleClickHandlers }); + startPlayInteract({ moduleHitTest: (object: any) => runModuleClickHandlers(object, 'play') }); + // 30 P1: the sessions that own the letter keys, so the I (Edit/Interact) key stands + // down while one is open — shortcuts cannot import them (history's cycle family) + const offKeyProbe = registerKeySessionProbe(() => !!get(sculptObject) || !!get(splineEditObject) || !!get(drawMode) || splineToolActive()); // 24-A A1: the knock's feeds. The VR hand poses come from vrControls through // this seam rather than an import (knock.js stays off vrControls' 3500 lines), // and the two "what am I holding" reads keep a probe off its own carried object. @@ -1309,6 +1431,7 @@ return () => { offEditResume(); // #20 P5 + offKeyProbe(); // 30 P1 stopPlayInteract(); // 21-B B3 (releases any carried body with zero velocity) stopKnock(); // 24-A A1 element.removeEventListener('pointerdown', onPointerDown); diff --git a/src/components/menu/Controls.svelte b/src/components/menu/Controls.svelte index 9adc16ed..dec13424 100644 --- a/src/components/menu/Controls.svelte +++ b/src/components/menu/Controls.svelte @@ -1,7 +1,7 @@ + +{#if rows.length} +
+ + {#if open} + {#each rows as row (row.name)} +
+
+ +
+ {#if expanded[row.name]} + {#each row.children as child (child.uuid)} +

+ {child.name} +

+ {/each} + {#if row.more} +

+{row.more} more

+ {/if} + {#if !row.children.length && !row.more} +

No named parts

+ {/if} + {/if} +
+ {/each} + {/if} +
+{/if} + +{#if menu} + (menu = null)} /> +{/if} diff --git a/src/lib/commandsHandler.svelte.js b/src/lib/commandsHandler.svelte.js index 6b8f1332..f98f7d4c 100644 --- a/src/lib/commandsHandler.svelte.js +++ b/src/lib/commandsHandler.svelte.js @@ -601,6 +601,14 @@ export async function objectParameters(data) { pokeScene(); // collider viz re-syncs physicsShapeChanged(data.uuid); // CL-A A2: live mid-sim rebuild } + } else if (data.parameter == 'pick') { + // 30 P2: click-through in the viewport. null = cleared (the default). + let mesh = sceneObjects.getObjectByProperty('uuid', data.uuid); + if (mesh) { + if (data.pick === 'through') mesh.userData.pick = 'through'; + else delete mesh.userData.pick; + pokeScene(); + } } else if (data.parameter == 'origin') { // 17-D: userData.origin is the per-object transform ORIGIN — a local-space // pivot offset the tools transform around. null = the object's own zero. diff --git a/src/lib/moduleContent.js b/src/lib/moduleContent.js new file mode 100644 index 00000000..457e8afc --- /dev/null +++ b/src/lib/moduleContent.js @@ -0,0 +1,220 @@ +// 30 P3: MODULE CONTENT in the object list — a LEAF (THREE + svelte/store + sceneStore + +// helperLayer). moduleSDK writes the registry, the object list reads it, objectActions +// clears the selection; none of them may import each other through here, which is why +// it is its own file (moduleSDK primes objectActions DYNAMICALLY to dodge that cycle, and +// a registry living in either one would close it). +// +// THE FINDING (roadmap 30, "Selection and the object list", cause 3): scene-root module +// content — the dungeon, the untangle board, the piano, the sabers — lives outside +// `objectsGroup` by golden rule 5, so it was never picked and never LISTED. Only the +// advanced System filter polled `systemGroupNames`, and it named no module. Every module +// group is listed now, READ-ONLY: the module owns it and regenerates it from its own +// state, so a rename, a delete or a reparent here would be undone on the next rebuild +// (or, worse, not undone and never replicated). +// +// Selecting one selects a PROXY, never the group: a scene-root box on the helper layer +// sized to the group's bounds. It is not in objectsGroup, so it never replicates, never +// saves and never enters the selection SET (applySelectionSet filters uuids it cannot +// find there) — the selection outline, the gizmo and every "act on the selection" +// command stay exactly what they were. +import * as THREE from 'three'; +import { writable, get } from 'svelte/store'; +import { globalScene } from '../stores/sceneStore'; +import { markHelper } from './helperLayer'; + +/** named children listed per group, and how deep */ +export const LIST_CAP = 200; +export const LIST_DEPTH = 2; + +/** + * @typedef {{ name: string, moduleId: string, moduleName: string, label: string, + * icon: string | null, kinds: Set }} ModuleGroup + */ + +/** @type {Map} scene-root group name -> who registered it */ +const groups = new Map(); + +/** bumps on every registry change so the list re-derives */ +export const moduleGroupsRevision = writable(0); + +/** + * Record a module's scene-root group. `kind` is which register* call named it + * ('interactive' | 'system' | 'listed'); the row lives while any kind still holds it. + * @param {string} name @param {{id: string, name?: string}} owner @param {string} kind + * @param {{label?: string, icon?: string}} [opts] + */ +export function noteModuleGroup(name, owner, kind, opts = {}) { + if (!name || !owner?.id) return; + const entry = groups.get(name) ?? { + name, + moduleId: owner.id, + moduleName: owner.name || owner.id, + label: '', + icon: null, + kinds: new Set() + }; + entry.kinds.add(kind); + if (opts.label) entry.label = String(opts.label).slice(0, 80); + if (opts.icon) entry.icon = String(opts.icon); + groups.set(name, entry); + moduleGroupsRevision.update((n) => n + 1); +} + +/** @param {string} name @param {string} kind */ +export function forgetModuleGroup(name, kind) { + const entry = groups.get(name); + if (!entry) return; + entry.kinds.delete(kind); + if (kind === 'listed') { + entry.label = ''; + entry.icon = null; + } + if (!entry.kinds.size) groups.delete(name); + if (get(moduleSelection)?.name === name && !groups.has(name)) clearModuleSelection(); + moduleGroupsRevision.update((n) => n + 1); +} + +/** every registered module group, in a stable order (module, then name) */ +export function moduleGroupList() { + return [...groups.values()].sort( + (a, b) => a.moduleName.localeCompare(b.moduleName) || a.name.localeCompare(b.name) + ); +} + +/** @param {string} name */ +export function moduleGroupOf(name) { + return groups.get(name) ?? null; +} + +/** A group name is an id ('untangle-module'); until the module names it with + * registerListedGroup, show it the way a person would write it ('Untangle module'). + * @param {string} name */ +export function humanize(name) { + const words = String(name).replace(/[-_]+/g, ' ').trim(); + return words ? words[0].toUpperCase() + words.slice(1) : String(name); +} + +/** + * The rows the list draws: one per registered group PRESENT in the scene, with its named + * descendants down to LIST_DEPTH, capped at LIST_CAP (the rest counted, not dropped + * silently). A group a module registered but has not built yet is not listed — there is + * nothing to frame or select. + * @param {any} scene + */ +export function moduleContentRows(scene) { + if (!scene) return []; + /** @type {any[]} */ + const rows = []; + for (const entry of moduleGroupList()) { + const root = scene.getObjectByName(entry.name); + if (!root || root.parent !== scene) continue; + /** @type {{name: string, depth: number, uuid: string}[]} */ + const children = []; + let total = 0; + /** @param {any} node @param {number} depth */ + const walk = (node, depth) => { + for (const child of node.children ?? []) { + if (child.userData?.isModuleProxy) continue; + if (child.name) { + total++; + if (children.length < LIST_CAP) children.push({ name: child.name, depth, uuid: child.uuid }); + } + if (depth < LIST_DEPTH) walk(child, depth + 1); + } + }; + walk(root, 1); + rows.push({ + name: entry.name, + label: entry.label || humanize(entry.name), + moduleId: entry.moduleId, + moduleName: entry.moduleName, + icon: entry.icon, + object: root, + visible: root.visible !== false, + children, + more: Math.max(0, total - children.length) + }); + } + return rows; +} + +// --- the selection: a proxy, never the group ---------------------------------------- + +/** @type {import('svelte/store').Writable<{name: string, moduleId: string, moduleName: string, label: string} | null>} */ +export const moduleSelection = writable(null); + +/** @type {any} */ +let proxy = null; +const box = new THREE.Box3(); +let lastFit = 0; + +/** @param {any} root */ +function fitProxy(root) { + if (!proxy || !root) return false; + box.makeEmpty(); + // bounds of the GROUP only — the proxy is a scene-root sibling, never inside it + box.setFromObject(root); + if (box.isEmpty()) { + const at = root.getWorldPosition(new THREE.Vector3()); + box.setFromCenterAndSize(at, new THREE.Vector3(0.5, 0.5, 0.5)); + } + proxy.box.copy(box); + proxy.updateMatrixWorld(true); + return true; +} + +/** + * Select a module group: park the proxy around it and publish the selection. Returns the + * group's world bounds (the caller frames them), or null when the group is not in the scene. + * @param {string} name + */ +export function selectModuleGroup(name) { + const scene = /** @type {any} */ (get(globalScene)); + const root = scene?.getObjectByName(name); + const entry = groups.get(name); + if (!root || !entry) return null; + if (!proxy) { + proxy = new THREE.Box3Helper(new THREE.Box3(), new THREE.Color(0xf59e0b)); + proxy.name = 'module-content-proxy'; + proxy.userData.isModuleProxy = true; + proxy.material.depthTest = false; + proxy.material.transparent = true; + proxy.renderOrder = 999; + markHelper(proxy); + } + if (proxy.parent !== scene) scene.add(proxy); + fitProxy(root); + moduleSelection.set({ name, moduleId: entry.moduleId, moduleName: entry.moduleName, label: entry.label || humanize(name) }); + return box.clone(); +} + +export function clearModuleSelection() { + if (proxy?.parent) proxy.parent.remove(proxy); + if (get(moduleSelection)) moduleSelection.set(null); +} + +/** Per frame (Scene's task): module content moves on its own, so the box follows it — + * at a few Hz, because setFromObject walks the whole group. */ +export function tickModuleProxy() { + const selection = get(moduleSelection); + if (!selection || !proxy?.parent) return; + const now = performance.now(); + if (now - lastFit < 250) return; + lastFit = now; + const root = proxy.parent.getObjectByName(selection.name); + if (!root) { + clearModuleSelection(); + return; + } + fitProxy(root); +} + +/** test/debug view */ +export function moduleContentDebug() { + return { + groups: moduleGroupList().map((g) => ({ name: g.name, moduleId: g.moduleId, label: g.label, kinds: [...g.kinds] })), + selection: get(moduleSelection), + proxyInScene: !!proxy?.parent, + proxyBox: proxy?.parent ? { min: proxy.box.min.toArray(), max: proxy.box.max.toArray() } : null + }; +} diff --git a/src/lib/moduleSDK.js b/src/lib/moduleSDK.js index c81f3631..7a22dcc3 100644 --- a/src/lib/moduleSDK.js +++ b/src/lib/moduleSDK.js @@ -2,7 +2,9 @@ import { keyOf, letterOf } from './keyOf'; import { sessionNow } from './sessionClock'; // 25-E: stamps another peer compares import * as THREE from 'three'; import { writable, get } from 'svelte/store'; -import { globalScene, objectsGroup, selectedObject, selectedObjects, globalCamera, isVRMode, isLocked } from '../stores/sceneStore'; +import { globalScene, objectsGroup, selectedObject, selectedObjects, globalCamera, isVRMode, isLocked, playPointerFree } from '../stores/sceneStore'; +// 30 P4: the scene's play block decides whether play aims with a crosshair (a leaf chain) +import { resolvePlaySettings } from './playSettings'; import { peers, showToast, modulesOpen, userdata } from '../stores/appStore'; import { syncedAnimations, flowGraphs, flowValues, flowTriggers, allNodes, findNodeAnyGraph, SCENE_GRAPH } from '../stores/flowStore'; import { customGeometryBuilders } from './customGeometries'; @@ -42,6 +44,9 @@ import { APP_VERSION } from './version.js'; import { ndcFromClient } from './canvasRect'; // 27-B: recovery paths report through the diagnostics ring (hardening audit H4) import { log } from './diagnostics'; +// 30 P3: the object list's Module content registry — a LEAF, so no cycle +import { noteModuleGroup, forgetModuleGroup } from './moduleContent'; +export { moduleContentDebug } from './moduleContent'; import { safeStorage } from './safeStorage'; // Module SDK v1 — in-repo modules under src/modules// register through @@ -71,6 +76,59 @@ export const moduleEffects = {}; export const moduleNodeComponents = {}; /** @type {((object: any) => boolean)[]} */ export const moduleClickHandlers = []; + +/** 30 P1: the three places a viewport click can come from. */ +export const CLICK_MODES = ['edit', 'interact', 'play']; +/** What an SDK handler hears when it names no modes: Interact and Play, NOT Edit — + * the behaviour change the user asked for (a piano or a puzzle piece used to swallow + * every EDITOR click, so it could never be selected). */ +export const DEFAULT_CLICK_MODES = ['interact', 'play']; +/** handler -> the modes it runs in. A handler with NO entry runs everywhere: that is a + * core handler pushed straight into the array (vrPatch's plug click — patching a cable + * is authoring, so it must keep working in Edit), never an SDK one. + * @type {WeakMap} */ +const clickHandlerModes = new WeakMap(); + +/** Normalise a `{modes}` option: known names only, the default when nothing usable is + * left. @param {any} modes @returns {string[]} */ +export function normalizeClickModes(modes) { + const list = Array.isArray(modes) ? modes : typeof modes === 'string' ? [modes] : []; + const known = [...new Set(list.filter((mode) => CLICK_MODES.includes(mode)))]; + return known.length ? known : [...DEFAULT_CLICK_MODES]; +} + +/** Does this handler run for a click in `mode`? A null mode is VR's trigger, which has + * no editor mode of its own yet and keeps offering every handler, as it always has. + * @param {Function} fn @param {string | null} mode */ +export function clickHandlerRunsIn(fn, mode) { + if (!mode) return true; + const modes = clickHandlerModes.get(fn); + return !modes || modes.includes(mode); +} + +/** + * Offer a clicked mesh to every handler that runs in `mode`, in registration order; the + * first to return true consumes the click. ONE dispatch for the editor's pick, Interact + * and Play's tap, so the three can never disagree about who hears what. + * @param {any} object @param {string | null} mode @returns {boolean} + */ +export function runClickHandlers(object, mode) { + for (const handler of [...moduleClickHandlers]) { + if (!clickHandlerRunsIn(handler, mode)) continue; + try { + if (handler(object)) return true; + } catch (error) { + log('warn', 'module', 'click handler failed', String(error)); + } + } + return false; +} + +/** The modes a handler was registered with (tests / the debug view). + * @param {Function} fn @returns {string[] | null} */ +export function clickHandlerModesOf(fn) { + return clickHandlerModes.get(fn) ?? null; +} /** 23-B1: a viewport click that hit NOTHING. `moduleClickHandlers` is only ever handed a * MESH, so a gesture armed by a plug click had no way to hear "the user clicked the sky": * the wire stayed armed for the rest of the session and a picked-up cable stayed HIDDEN @@ -228,11 +286,38 @@ if (typeof window !== 'undefined') { pointerClient.seen = true; }); } +/** 30 P4: the CROSSHAIR ray, a fresh Raycaster through the centre of the view */ +const SCREEN_CENTRE = new THREE.Vector2(0, 0); + +/** + * 30 P4: does play aim with a CROSSHAIR right now? Under a pointer lock the cursor is + * pinned and its last client position is where the mouse happened to be when the lock + * began — a STALE ray that never moves again (untangle's carried dot followed it, so a + * drag in play went nowhere). So while playing, the pointer locked, and not in the menu + * substate (the pointer is free there, over the HUD), the ray is the view's centre — + * play mode's own NDC (0,0), the one playInteract aims with. + */ +function crosshairAims() { + if (get(isLocked) !== true || get(playPointerFree)) return false; + if (typeof document === 'undefined' || !document.pointerLockElement) return false; + // 30-core-flow: free cursor — a scene whose play block says `cursor: 'free'` plays with + // the real cursor and no lock, so its ray IS the mouse ray. Read defensively: the field + // lands with that lane, and absent means 'locked' (today's play). + const settings = /** @type {any} */ (resolvePlaySettings(get(globalScene))); + return settings?.cursor !== 'free'; +} + function pointerRayNow() { if (get(isVRMode)) return vrControlsRef?.pointerHandRay?.() ?? null; /** @type {any} */ const camera = get(globalCamera); - if (!camera || !pointerClient.seen) return null; + if (!camera) return null; + if (crosshairAims()) { + const centre = new THREE.Raycaster(); + centre.setFromCamera(SCREEN_CENTRE, camera); + return centre; + } + if (!pointerClient.seen) return null; const fresh = new THREE.Raycaster(); const ndc = ndcFromClient(pointerClient.x, pointerClient.y); fresh.setFromCamera(new THREE.Vector2(ndc.x, ndc.y), camera); @@ -463,9 +548,17 @@ function makeApi(moduleId, moduleName = moduleId) { /** * Intercept viewport clicks (desktop click + VR trigger). Receives the * exact mesh hit; return true to consume the click (no selection). + * + * 30 P1: `{modes}` says WHERE it runs — any of 'edit' | 'interact' | 'play'. + * Absent means ['interact', 'play']: a handler that is part of the GAME (a key, + * a pad, a puzzle piece) no longer eats the editor's select click. A handler + * that is an editor TOOL (a toolbox pick) passes {modes: ['edit']}, or all three. + * In Edit an 'edit' handler still runs BEFORE the selection, so it can consume. * @param {(object: any) => boolean} fn + * @param {{modes?: string[]}} [options] */ - registerClickHandler(fn) { + registerClickHandler(fn, options = {}) { + clickHandlerModes.set(fn, normalizeClickModes(options?.modes)); moduleClickHandlers.push(fn); onDispose(() => arrayRemove(moduleClickHandlers, fn)); }, @@ -493,20 +586,37 @@ function makeApi(moduleId, moduleName = moduleId) { registerInteractiveGroup(name) { moduleInteractiveGroups.push(name); registerSystemGroup(name); // clickable module content is also listable + noteModuleGroup(name, { id: moduleId, name: moduleName }, 'interactive'); // 30 P3 onDispose(() => { arrayRemove(moduleInteractiveGroups, name); arrayRemove(systemGroupNames, name); + forgetModuleGroup(name, 'interactive'); removeSceneRootGroup(name); // module-owned viewport content goes with the module }); }, /** List a scene-root group under the object list's System filter @param {string} name */ registerSystemGroup(name) { registerSystemGroup(name); + noteModuleGroup(name, { id: moduleId, name: moduleName }, 'system'); // 30 P3 onDispose(() => { arrayRemove(systemGroupNames, name); + forgetModuleGroup(name, 'system'); removeSceneRootGroup(name); }); }, + /** + * 30 P3: list a scene-root group in the object list's "Module content" section under + * a label a person can read (the group's own name is usually an id). Groups passed to + * registerInteractiveGroup / registerSystemGroup are listed anyway; this names them, + * or lists one that is neither. Read-only there: a click selects a PROXY and frames + * it, and the Inspector points at your module's toolbox and nodes. + * @param {string} name the scene-root group's object name + * @param {{label?: string, icon?: string}} [options] + */ + registerListedGroup(name, options = {}) { + noteModuleGroup(name, { id: moduleId, name: moduleName }, 'listed', options ?? {}); + onDispose(() => forgetModuleGroup(name, 'listed')); + }, /** * Runs when the scene is cleared (locally or by a peer) — remove your * viewport content and reset module state here. @@ -520,6 +630,8 @@ function makeApi(moduleId, moduleName = moduleId) { * Where the user is POINTING, as a THREE.Raycaster in world space — * desktop mouse over the viewport, or the VR pointer hand's ray. A fresh * instance per call (safe to keep). Null before the first pointer event. + * 30 P4: in play under a pointer lock it is the CROSSHAIR ray (the view's + * centre) — the mouse ray is frozen there; a free-cursor game keeps the mouse. * The drag recipe (190/untangle): click to pick, follow pointerRay() in a * frame task, click to drop. (190) */ diff --git a/src/lib/objectActions.js b/src/lib/objectActions.js index 48c84686..a6e3ab43 100644 --- a/src/lib/objectActions.js +++ b/src/lib/objectActions.js @@ -16,6 +16,7 @@ import { orbitControls, isVRMode, gizmoSuppressed, + editorMode, cameraClaim, pokeScene } from '../stores/sceneStore'; import { attachMultiPivot, releaseMultiPivot, hasCustomOrigin, pivotPose, setPivotOrigin } from './multiTransform'; import { focusTargetFace, faceEditObject, hideElementSelection, restoreElementSelection } from './faceEdit'; @@ -35,6 +36,8 @@ import { canEditObject, warnViewerReadOnly } from './objectPermissions'; import { stripEditOverlays, isEditOverlay } from './editOverlays'; // B7: the transient marker (a LEAF — two stores only, so no cycle back through history) import { markTransient } from './transientObjects'; +// 30 P3: a LEAF (THREE + stores), so a static import here closes no cycle +import { clearModuleSelection } from './moduleContent'; // D2: a LEAF (svelte stores + THREE), so a static import here closes no cycle import { shareDuplicatedMaterials, linkMaterials } from './materialSharing'; import { @@ -164,6 +167,7 @@ export function applySelectionSet(uuids, openProperties = false) { !locked.find((lockedUuid) => lockedUuid[1] === uuid) ); applyMemberTints(group, clean); + if (clean.length) clearModuleSelection(); // 30 P3: an object selection replaces the proxy const previous = get(selectedObjects); selectedObjects.set(clean); if (clean.length) lastSelection = { uuids: [...clean], origin: null }; // 24-B1 @@ -180,7 +184,13 @@ export function applySelectionSet(uuids, openProperties = false) { // move gizmo unless every object in the set is editable by the local user // (their own local-only objects, or anything for editors/admins). const editable = clean.every((/** @type {any} */ uuid) => canEditObject(group.getObjectByProperty('uuid', uuid))); - if (get(gizmoSuppressed)) { + if (get(editorMode) === 'interact') { + // 30 P1: INTERACT hands the scene to the player-style input, so a selection + // made from the object list (or kept from Edit) stands with no gizmo — a + // seated gizmo would take the next press on the object and move it instead + releaseMultiPivot(); + controls.detach(); + } else if (get(gizmoSuppressed)) { // sculpt mode: selection (and its lock) stand, but no gizmo — the // SculptToolbar toggle re-attaches explicitly when the user opts in releaseMultiPivot(); @@ -211,6 +221,35 @@ export function applySelectionSet(uuids, openProperties = false) { } } +/** + * 30 P1: switch the editor between EDIT (a click selects, the gizmo attaches) and + * INTERACT (a click plays with the scene: module handlers, On Click nodes, the cursor + * grab). LOCAL — the store is never sent or saved. Entering Interact puts the gizmo away + * without dropping the selection, and coming back re-seats it on whatever is still + * selected, so a round trip through Interact costs the user nothing. + * @param {'edit' | 'interact'} mode @returns {'edit' | 'interact'} + */ +export function setEditorMode(mode) { + /** @type {'edit' | 'interact'} */ + const next = mode === 'interact' ? 'interact' : 'edit'; + if (get(editorMode) === next) return next; + editorMode.set(next); + /** @type {any} */ + const controls = get(TControls); + if (next === 'interact') { + releaseMultiPivot(); + if (controls && !get(isVRMode)) controls.detach(); + } else if (get(selectedObjects).length) { + applySelectionSet([...get(selectedObjects)]); + } + return next; +} + +/** The toolbar cell and the I key. */ +export function toggleEditorMode() { + return setEditorMode(get(editorMode) === 'interact' ? 'edit' : 'interact'); +} + /** @param {string} uuid @param {boolean} openProperties @param {boolean=} additive - shift-click toggles set membership */ export function selectObject(uuid, openProperties = false, additive = false) { const group = get(objectsGroup); @@ -271,6 +310,7 @@ export function deselectObject() { applyMemberTints(get(objectsGroup), []); broadcastSelectionRelease(get(selectedObjects)); selectedObjects.set([]); + clearModuleSelection(); // 30 P3: the Module content proxy goes with any deselect if (controls && !get(isVRMode)) controls.detach(); // selectedObject keeps the last object on purpose — the open inspector binds // to $selectedObject.position/material and would crash on an empty value @@ -298,6 +338,14 @@ export function setTransformMode(mode) { const controls = get(TControls); const object = controls?.object; const vr = get(isVRMode); + // 30 P1: a transform tool IS editing — picking one in Interact goes back to Edit (which + // re-seats the gizmo on the selection) in that tool, rather than doing nothing visible + if (get(editorMode) === 'interact' && !vr) { + setEditorMode('edit'); + get(TControls)?.setMode(mode); + transformMode.set(mode); + return; + } const same = get(transformMode) === mode; const isEditProxy = !!(object?.userData?.isFaceProxy || object?.userData?.isVertexProxy); if (same && object && !vr) { @@ -683,6 +731,12 @@ registerHistoryKind('props', (entry, state) => { if (peer) peer.send({ type: 'objectParameters', parameter: 'device', uuid: entry.uuid, device: state.device }); } + if ('pick' in state) { + // 30 P2: click-through in the viewport ("the next opaque thing behind me") + if (state.pick) object.userData.pick = state.pick; + else delete object.userData.pick; + if (peer) peer.send({ type: 'objectParameters', parameter: 'pick', uuid: entry.uuid, pick: state.pick ?? null }); + } if ('origin' in state) { // 17-D: the per-object transform origin (pivot offset) is scene data, so // moving it is undoable and replicated like any other userData write @@ -706,6 +760,30 @@ registerHistoryKind('group', (entry, state) => { return true; }); +/** + * 30 P2: mark an object CLICK-THROUGH in the viewport — an editor click passes it to the + * next opaque thing behind (a game's walls, a glass case, a ceiling). Scene data like + * `userData.physics`: one `props` undo entry, the existing `objectParameters` message, + * and it rides toJSON / GLTF extras into every save. Absent is the default, so an object + * never flagged serialises byte-identically. + * @param {string} uuid @param {boolean} on @returns {boolean} whether anything changed + */ +export function setPickThrough(uuid, on) { + const object = get(objectsGroup)?.getObjectByProperty('uuid', uuid); + if (!object) return false; + const before = object.userData.pick ?? null; + const next = on ? 'through' : null; + if (before === next) return false; + if (next) object.userData.pick = next; + else delete object.userData.pick; + recordEntry({ kind: 'props', uuid, before: { pick: before }, after: { pick: next } }); + /** @type {any} */ + const peer = get(peers); + peer?.send({ type: 'objectParameters', parameter: 'pick', uuid, pick: next }); + pokeScene(); + return true; +} + /** Toggle visibility and replicate (same message Properties uses) @param {string} uuid */ export function toggleObjectVisibility(uuid) { const group = get(objectsGroup); diff --git a/src/lib/playInteract.js b/src/lib/playInteract.js index 88102534..edc51459 100644 --- a/src/lib/playInteract.js +++ b/src/lib/playInteract.js @@ -1,6 +1,6 @@ import * as THREE from 'three'; import { writable, get } from 'svelte/store'; -import { isLocked, isVRMode, playPointerFree, objectsGroup, globalScene, lockedObjects, pokeScene } from '../stores/sceneStore'; +import { isLocked, isVRMode, playPointerFree, objectsGroup, globalScene, lockedObjects, pokeScene, editorMode } from '../stores/sceneStore'; import { peers } from '../stores/appStore'; import { sceneHits } from './scenePick'; import { topLevelObjectOf } from './objectActions'; @@ -16,8 +16,9 @@ import { import { suspendAnimation, resumeAnimation, fireObjectClick } from './flowRuntime'; import { velocityFromSamples } from './throwVelocity'; import { resolvePlaySettings } from './playSettings'; +import { pickStack, primaryIndex } from './selectThrough'; import { nameOf } from './lockControl'; -import { moduleClickHandlers, moduleInteractiveGroups, fireClickMiss } from './moduleSDK'; +import { moduleInteractiveGroups, fireClickMiss, runClickHandlers } from './moduleSDK'; // 21-B B3: play mode becomes INTERACT mode — a crosshair grab at distance, // scroll to push and pull, and a release that throws with the velocity you @@ -76,9 +77,14 @@ const yawQuat = new THREE.Quaternion(); const desiredQuat = new THREE.Quaternion(); const euler = new THREE.Euler(0, 0, 0, 'YXZ'); -/** @type {{object: any, relQuat: THREE.Quaternion, mass: number, held: boolean, - * samples: {t: number, pos: THREE.Vector3, quat: THREE.Quaternion}[], lastSent: number}|null} */ +/** `cursor`: 30 P1 — the carry follows the editor's CURSOR ray (Interact), not the + * crosshair. Everything else about the hold is the play-mode hold, unforked. + * @type {{object: any, relQuat: THREE.Quaternion, mass: number, held: boolean, + * samples: {t: number, pos: THREE.Vector3, quat: THREE.Quaternion}[], lastSent: number, + * cursor?: boolean}|null} */ let grab = null; +/** 30 P1: the cursor's NDC while an Interact carry runs (fed by Scene's pointermove) */ +const cursorNdc = new THREE.Vector2(); let carryDistance = CARRY_DEFAULT; /** @type {{t: number, uuid: string|null, hit: any}|null} */ let press = null; let started = false; @@ -134,6 +140,89 @@ function moduleTap() { return false; } +// --- 30 P1: INTERACT, the editor's play-style mode ---------------------------------- +// The editor's second click mode borrows play's hands without play's lock: the cursor +// aims (not the crosshair), a press on a dynamic body while a sim runs CARRIES it with the +// exact hold above, and a short click is play's TAP — module groups, module handlers +// registered for 'interact', On Click nodes, the miss. Scene owns the canvas events and +// calls in here, because the editor's own gestures (sessions, pings, pins) sit in front. + +/** Is the editor in INTERACT right now (outside play and VR)? */ +export function editorInteractActive() { + return get(editorMode) === 'interact' && get(isLocked) !== true && !get(isVRMode); +} + +/** + * A press in Interact. Starts a cursor carry when the ray's first hit is a dynamic body + * of a running sim that nobody else holds; returns whether it did (Scene then stands the + * camera controls down for the gesture, or lets the press orbit as usual). + * @param {any} ray a THREE.Raycaster aimed through the cursor + * @param {{x: number, y: number}} ndc the cursor in NDC + * @param {any} camera + */ +export function cursorGrabStart(ray, ndc, camera) { + if (!editorInteractActive() || grab || !camera || !simRunning()) return false; + const hit = sceneHits(ray, {})[0]; + const target = hit ? topLevelObjectOf(hit.object) : null; + if (!target || !dynamicUuids().has(target.uuid)) return false; + if (get(lockedObjects).some((/** @type {any} */ entry) => entry[1] === target.uuid)) return false; + if (!canEditObject(target)) { + warnViewerReadOnly(); + return false; + } + activeCamera = camera; + cursorNdc.set(ndc.x, ndc.y); + camera.getWorldPosition(camPos); + beginGrab(target, camera); + // (TS narrowed `grab` to null at the guard above; beginGrab assigned it since) + const started = /** @type {any} */ (grab); + if (started) started.cursor = true; + return !!started; +} + +/** The cursor moved while carrying. @param {{x: number, y: number}} ndc */ +export function cursorGrabMove(ndc) { + if (grab?.cursor) cursorNdc.set(ndc.x, ndc.y); +} + +/** Release: a throw, like play's. @returns {boolean} whether a cursor carry ended */ +export function cursorGrabEnd() { + if (!grab?.cursor) return false; + endGrab(true); + return true; +} + +/** + * A short click in Interact: play's tap, aimed by the cursor. Module scene-root groups + * first (as the editor pick always did), then the first scene hit offered to the module + * handlers that run in 'interact', then its On Click nodes; a click on nothing is the miss. + * Selects nothing, ever. + * @param {any} ray a THREE.Raycaster aimed through the cursor + * @returns {'module-group' | 'module-handler' | 'click' | 'miss'} + */ +export function interactClick(ray) { + const scene = /** @type {any} */ (get(globalScene)); + for (const name of moduleInteractiveGroups) { + const root = scene?.getObjectByName(name); + if (!root) continue; + const hits = ray.intersectObject(root, true); + if (hits.length > 0 && runClickHandlers(hits[0].object, 'interact')) return 'module-group'; + } + // 30 P2: the same see-through rule as the editor's pick — a 0.12-opacity wall in + // front of a star must not take the star's click here either + const stack = pickStack(sceneHits(ray, { tinyProxies: true }), topLevelObjectOf); + const entry = stack.length ? stack[primaryIndex(stack)] : null; + const hit = entry?.hit ?? null; + if (hit && runClickHandlers(hit.object, 'interact')) return 'module-handler'; + const target = entry?.target ?? null; + if (target) { + fireObjectClick(target.uuid); + return 'click'; + } + fireClickMiss(); + return 'miss'; +} + /** @param {any} object */ function massOf(object) { const mass = object?.userData?.physics?.mass; @@ -321,7 +410,15 @@ function onWheel(event) { */ export function tickPlayInteract(delta, camera) { activeCamera = camera ?? activeCamera; - const mode = interactionMode(); + // 30 P1: an Interact carry lives OUTSIDE play, so play's 'off' must not cancel it — + // leaving Interact (or entering play/VR) is what ends it, through the same + // zero-velocity cancel every other path uses + const cursorCarry = !!grab?.cursor; + if (cursorCarry && (!editorInteractActive() || !camera)) { + endGrab(false); + return; + } + const mode = cursorCarry ? 'grab' : interactionMode(); if (mode === 'off' || !camera) { if (grab) endGrab(false); if (get(playInteractState).mode !== 'off') @@ -337,8 +434,15 @@ export function tickPlayInteract(delta, camera) { endGrab(false); return; } - camera.getWorldPosition(camPos); - camera.getWorldDirection(camDir); + if (grab.cursor) { + // the same carry point, along the CURSOR's ray instead of the view axis + raycaster.setFromCamera(cursorNdc, camera); + camPos.copy(raycaster.ray.origin); + camDir.copy(raycaster.ray.direction); + } else { + camera.getWorldPosition(camPos); + camera.getWorldDirection(camDir); + } targetPos.copy(camPos).addScaledVector(camDir, carryDistance); // dt-based, so a throttled tab does not change the feel const k = Math.min(SPRING_K_MAX, Math.max(SPRING_K_MIN, SPRING_K / Math.sqrt(Math.max(grab.mass, 1)))); @@ -438,6 +542,7 @@ export function playInteractDebug() { started, mode: interactionMode(), carrying: grab?.object?.uuid ?? null, + cursor: !!grab?.cursor, held: !!grab?.held, distance: carryDistance, lastUp, diff --git a/src/lib/selectThrough.js b/src/lib/selectThrough.js new file mode 100644 index 00000000..6989413d --- /dev/null +++ b/src/lib/selectThrough.js @@ -0,0 +1,135 @@ +// 30 P2: SELECT-THROUGH and CLICK-CYCLE — the pure part. Imports NOTHING (the +// objectListNav / inputDevice shape), so the editor pick, the suite and vitest share it. +// +// Two measured reasons it exists (roadmap 30, "Selection and the object list"): +// - a near-invisible SHELL wins the nearest-hit pick. Stars Room's 0.12-opacity walls +// took every click on a star, a pad or a planet (16 of 16 selected "Wall south"), +// and Football's 0.06 ceiling ate the lamps and the bars. +// - with nothing but the nearest hit there was no way at all to reach what sits +// BEHIND an opaque object, short of the object list. +// +// So a plain click prefers the first OPAQUE target down the ray, and a REPEATED click on +// the same spot walks down the stack (the DCC convention: Blender and Maya both cycle). +// +// THE TIMING IS A DECISION, and it deviates from the plan's "< 600 ms": the editor's +// double-click (15-O / 85's configurable action) is a second click on the SAME object +// inside 400 ms, so a cycle inside that window would hand the second click to the object +// behind and double-click-to-open-properties would die wherever anything sits behind the +// cursor, i.e. almost everywhere. A repeat therefore cycles from the END of the +// double-click window, and 600 ms would leave a 200 ms window nobody can hit on purpose. + +/** below this effective opacity a surface is glass, not a thing to click */ +export const SEE_THROUGH_OPACITY = 0.25; +/** how far (css px) a second click may land from the first and still be "the same spot" */ +export const CYCLE_SLOP_PX = 4; +/** the editor's double-click window (raycastSelect's lastPick) — a repeat inside it is a double-click */ +export const DOUBLE_CLICK_MS = 400; +/** after this, a click on the same spot is a fresh pick again, not "the next one down" */ +export const CYCLE_WINDOW_MS = 1500; + +/** + * The effective opacity of the surface a raycast hit landed on: the material of the + * hit's own FACE on a multi-material mesh, 0 for a material switched off. + * @param {any} hit + * @returns {number} + */ +export function hitOpacity(hit) { + const object = hit?.object; + if (!object) return 1; + let material = object.material; + if (Array.isArray(material)) { + const index = hit.face?.materialIndex ?? 0; + material = material[index] ?? material[0]; + } + if (!material) return 1; + if (material.visible === false) return 0; + if (material.transparent && typeof material.opacity === 'number') return material.opacity; + return 1; +} + +/** + * Is this hit something a click should pass through to whatever is behind it? + * The flag on the TOP-LEVEL object wins (a template marks its walls and ceilings + * `userData.pick = 'through'` whatever their opacity), then the surface's own opacity, + * then a hidden node on the way up (three's raycaster does not test `visible`). + * @param {any} hit @param {any} target the hit's top-level object + */ +export function isSeeThrough(hit, target) { + if (target?.userData?.pick === 'through') return true; + if (hitOpacity(hit) < SEE_THROUGH_OPACITY) return true; + for (let node = hit?.object; node; node = node.parent) { + if (node.visible === false) return true; + if (node === target) break; + } + return false; +} + +/** + * @typedef {{ uuid: string, target: any, hit: any, through: boolean }} StackEntry + */ + +/** + * Collapse raw hits (nearest first) into the STACK of distinct top-level targets. + * A target counts as opaque if ANY of its hits is — a glass case around a solid core + * is a thing you can click, the core is just behind its own glass. + * @param {any[]} hits + * @param {(object: any) => any} topOf resolves a hit mesh to its top-level object (null = skip) + * @returns {StackEntry[]} + */ +export function pickStack(hits, topOf) { + /** @type {StackEntry[]} */ + const stack = []; + /** @type {Map} */ + const at = new Map(); + for (const hit of hits ?? []) { + const target = topOf(hit.object); + if (!target) continue; + const through = isSeeThrough(hit, target); + const index = at.get(target.uuid); + if (index === undefined) { + at.set(target.uuid, stack.length); + stack.push({ uuid: target.uuid, target, hit, through }); + } else if (stack[index].through && !through) { + stack[index].through = false; + } + } + return stack; +} + +/** The entry a plain click selects: the first opaque target, else the nearest. + * @param {StackEntry[]} stack */ +export function primaryIndex(stack) { + const index = stack.findIndex((entry) => !entry.through); + return index < 0 ? 0 : index; +} + +/** + * Is `click` a deliberate repeat of `last` — the same spot, after the double-click window + * and before the cycle window closes? + * @param {{x: number, y: number, t: number}} click + * @param {{x: number, y: number, t: number} | null | undefined} last + */ +export function isRepeatClick(click, last) { + if (!last) return false; + const dt = click.t - last.t; + if (!(dt >= DOUBLE_CLICK_MS && dt <= CYCLE_WINDOW_MS)) return false; + return Math.hypot(click.x - last.x, click.y - last.y) <= CYCLE_SLOP_PX; +} + +/** + * Which stack entry this click selects. A repeat of the last click whose pick is still in + * the stack takes the NEXT entry down (wrapping, so the see-through shell in front is + * reachable too); anything else takes the primary. + * @param {StackEntry[]} stack + * @param {{x: number, y: number, t: number}} click + * @param {{x: number, y: number, t: number, uuid: string | null} | null | undefined} last + * @returns {{index: number, cycled: boolean}} index -1 = nothing to pick + */ +export function chooseInStack(stack, click, last) { + if (!stack.length) return { index: -1, cycled: false }; + const primary = primaryIndex(stack); + if (!last?.uuid || !isRepeatClick(click, last)) return { index: primary, cycled: false }; + const current = stack.findIndex((entry) => entry.uuid === last.uuid); + if (current < 0) return { index: primary, cycled: false }; + return { index: (current + 1) % stack.length, cycled: stack.length > 1 }; +} diff --git a/src/lib/shortcuts.js b/src/lib/shortcuts.js index d871b61b..8a770537 100644 --- a/src/lib/shortcuts.js +++ b/src/lib/shortcuts.js @@ -23,7 +23,8 @@ import { setTransformMode, selectAllObjects, clearIsolation, - isIsolated + isIsolated, + toggleEditorMode } from './objectActions'; import { undo, redo } from './history'; import { editingObject, enterEditMode, exitEditMode } from './meshEdit'; @@ -269,6 +270,18 @@ export const shortcuts = [ else if (get(selectedObject)?.uuid) enterEditMode(get(selectedObject).uuid); } }, + { + // 30 P1: Edit / Interact. Free outside a mesh session (I is MESH_EDIT_KEYS' inset + // there, which is why the registry already stands down for it); `when` also stands + // it down while sculpt, spline or draw own the letter keys (their probes), so a + // stray I never flips the mode under a session. + id: 'editor.interact-mode', + keys: 'I', + group: 'Objects', + label: 'Edit / Interact mode (Interact: clicks play with the scene instead of selecting)', + when: () => !keySessionOpen(), + action: () => toggleEditorMode() + }, { id: 'panels.object-list', keys: 'O', @@ -633,6 +646,35 @@ function slug(text) { .replace(/^-+|-+$/g, ''); } +/** + * 30 P1: editor SESSIONS that own the plain letter keys register a probe here — sculpt, + * spline edit and draw (Scene, which already imports all three) — so a mode key can stand + * down while one is open without this module importing them: shortcuts sits inside + * history's import family, and splineEdit reaches vrControls, which would close a cycle. + * @type {(() => boolean)[]} */ +const keySessionProbes = []; + +/** @param {() => boolean} probe @returns {() => void} unregister */ +export function registerKeySessionProbe(probe) { + keySessionProbes.push(probe); + return () => { + const index = keySessionProbes.indexOf(probe); + if (index >= 0) keySessionProbes.splice(index, 1); + }; +} + +/** Is any session holding the letter keys right now (mesh edit included)? */ +export function keySessionOpen() { + if (get(editingObject) || get(faceEditObject)) return true; + return keySessionProbes.some((probe) => { + try { + return !!probe(); + } catch { + return false; + } + }); +} + /** * @param {ShortcutInput} shortcut */ diff --git a/src/stores/sceneStore.js b/src/stores/sceneStore.js index 4ffec3b0..ed4d0f7c 100644 --- a/src/stores/sceneStore.js +++ b/src/stores/sceneStore.js @@ -41,6 +41,16 @@ export const isLocked = writable(null); * @type {import("svelte/store").Writable} */ export const playPointerFree = writable(false); +/** + * 30 P1: the editor's CLICK MODE, beside Play rather than inside it. 'edit' (the + * default): a viewport click SELECTS and the gizmo attaches. 'interact': a click reaches + * module click handlers, On Click nodes and the cursor grab — play-style, without the + * pointer lock and without starting the game — and selects nothing. LOCAL per peer: + * never replicated, never saved (the view-mode rule), so a reload is back in Edit. + * Written through objectActions.setEditorMode, which also puts the gizmo away. + * @type {import("svelte/store").Writable<'edit' | 'interact'>} + */ +export const editorMode = writable('edit'); export const isVRMode = writable(false); export const vrOverride = writable(false); export const playerCam = writable(false); diff --git a/tests/e2e/controls-roster.test.cjs b/tests/e2e/controls-roster.test.cjs index 2a1e894c..cbd373cb 100644 --- a/tests/e2e/controls-roster.test.cjs +++ b/tests/e2e/controls-roster.test.cjs @@ -52,7 +52,8 @@ const closeMenus = async (page) => { /** right-click a toolbar cell by its title and wait for its menu */ async function cellMenu(page, title) { - await page.locator(`#controls-pill p[title="${title}"]`).click({ button: 'right' }); + // 30 P1: a toggle cell is a