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
173 changes: 148 additions & 25 deletions src/components/Scene.svelte

Large diffs are not rendered by default.

61 changes: 56 additions & 5 deletions src/components/menu/Controls.svelte
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<script lang="ts">
import { Clapperboard, Code, Cog, Eye, FolderOpen, Grid2x2, List, Maximize2, MessageSquare, Monitor, Move, Palette, Pin, Play, RectangleGoggles, RotateCcw, SquarePen, Sun, Workflow } from '@lucide/svelte';
import { Clapperboard, Code, Cog, Eye, FolderOpen, Grid2x2, Hand, List, Maximize2, MessageSquare, Monitor, Move, Palette, Pin, Play, RectangleGoggles, RotateCcw, SquarePen, Sun, Workflow } from '@lucide/svelte';
import { Listgroup } from 'flowbite-svelte';
import { objectsGroup, TControls, transformMode, isLocked, lockedObjects, globalScene, vrPassthrough, vrOverride, selectedObject, selectedObjects } from '../../stores/sceneStore';
import { objectsGroup, TControls, transformMode, editorMode, isLocked, lockedObjects, globalScene, vrPassthrough, vrOverride, selectedObject, selectedObjects } from '../../stores/sceneStore';
import { chatHidden, flowGraphClose, flowCodeClose, animationClose, uvEditorClose, shaderEditorClose, hudEditorClose, explorerClose, objectListClose, objectContextMenu, renamingObject, advancedMode, showEnvInList, showLocalObjects, floatingToolbar, toolbarAlwaysOnTop, showSimControls, expandedObjects } from '../../stores/appStore.js';
// 24-B2: keyboard navigation in the object list (the Explorer's gridKeydown shape)
import { visibleObjectRows, withExpanded, typeAheadIndex } from '$lib/objectListNav';
Expand All @@ -13,7 +13,7 @@
import { ENV_ROOT } from '$lib/environment';
import { flyTo } from '$lib/objectActions';
import { mutedFlowObjects } from '../../stores/flowStore';
import { focusObject, duplicateObject, toggleObjectVisibility, moveObjectToGroup, setTransformMode, selectObject } from '$lib/objectActions';
import { focusObject, duplicateObject, toggleObjectVisibility, moveObjectToGroup, setTransformMode, selectObject, toggleEditorMode } from '$lib/objectActions';
import { registerWindowReset } from '$lib/dragWindow';
import { enterEditMode } from '$lib/meshEdit';
import { addAnnotation } from '$lib/annotationsHandler';
Expand All @@ -28,6 +28,7 @@
import { shareObject } from '$lib/objectPermissions';
import Objects from './Objects.svelte';
import LocalObjects from './LocalObjects.svelte';
import ModuleContent from './ModuleContent.svelte';
import ContextMenu from '../ContextMenu.svelte';
import MobileAddButton from './MobileAddButton.svelte';
import AiHudButton from './AiHudButton.svelte';
Expand Down Expand Up @@ -952,7 +953,9 @@
collapsed: boolean;
posX: number | null;
};
type CellButton = { title: string; slot?: string; icon: any; tint: () => string; run: () => void };
// 30 P1: `pressed` makes the cell a TOGGLE — it renders as a real <button> carrying
// aria-pressed (a <p> cannot: the attribute is not supported on its role)
type CellButton = { title: string; slot?: string; icon: any; tint: () => string; run: () => void; pressed?: () => boolean };

/** the one PSEUDO-cell: the transparent well the play FAB sits in. It is not a
* roster entry (play is never hideable) but it IS a cell of the row, which is
Expand All @@ -963,7 +966,10 @@
* the FAB's own right-click menu (plus Settings' Reset window positions, which
* is the hatch for iOS Safari, where a long press fires no `contextmenu`). */
const SPACER = '__spacer';
const DEFAULT_ORDER = ['move', 'rotate', 'scale', 'objects', 'flow', 'explorer'];
// 30 P1: the Edit/Interact toggle ('mode') joins the default bar at the END — the same
// place loadLayout already appends a default id an older record has never heard of, so
// a fresh profile and an upgraded one agree, and the well keeps its slot after Scale
const DEFAULT_ORDER = ['move', 'rotate', 'scale', 'objects', 'flow', 'explorer', 'mode'];
const DEFAULT_SPACER = 3;

/** W8b: the roster is bigger than the bar. `DEFAULT_ORDER` is what a fresh profile
Expand Down Expand Up @@ -1005,6 +1011,14 @@
// reading `$transformMode` / the `$derived` flags inside it registers the
// dependency in the render effect exactly as the inline expressions used to.
const BUTTONS: Record<string, CellButton> = {
mode: {
title: 'Interact mode (I)',
slot: 'editor-mode-toggle',
icon: Hand,
tint: () => ($editorMode === 'interact' ? ICON_ON : ICON_OFF),
pressed: () => $editorMode === 'interact',
run: () => toggleEditorMode()
},
move: {
title: 'Move (1)',
icon: Move,
Expand Down Expand Up @@ -1060,6 +1074,22 @@
)
};

/** 30 P1: the toggle cell's click as a DIRECT listener — an `on:click` on a new element
* adds a deprecation warning in this runes-mode file (the openStats reasoning). */
function cellClick(node: HTMLElement, id: string) {
let current = id;
const click = () => runCell(current);
node.addEventListener('click', click);
return {
update(next: string) {
current = next;
},
destroy() {
node.removeEventListener('click', click);
}
};
}

function defaultLayout(): ControlsLayout {
return { order: [...DEFAULT_ORDER], hidden: [], spacerIndex: DEFAULT_SPACER, collapsed: false, posX: null };
}
Expand Down Expand Up @@ -2015,6 +2045,24 @@
{:else}
{@const btn = BUTTONS[cell.id]}
{@const Glyph = btn.icon}
{#if btn.pressed}
<!-- 30 P1: a TOGGLE cell is a real button, so it can say aria-pressed -->
<button
type="button"
id={btn.slot}
class={classActive +
' w-10' +
(i === 0 ? ' rounded-l-full' : '') +
(i === visibleCells.length - 1 ? ' rounded-r-full' : '')}
title={btn.title}
aria-label={btn.title}
aria-pressed={btn.pressed()}
use:cellClick={cell.id}
use:cellMenu={cell.id}
>
<Glyph size={18} class={btn.tint()} aria-hidden="true" />
</button>
{:else}
<!-- ONE template for every roster button: the six hand-written cells each
carried their own copy of this element, so each one also carried its own
copy of the same three a11y/deprecation warnings. -->
Expand All @@ -2030,6 +2078,7 @@
>
<Glyph size={18} class={btn.tint()} aria-hidden="true" />
</p>
{/if}
{/if}
{/each}
</div>
Expand Down Expand Up @@ -2360,6 +2409,8 @@
{/each}
{/if}
</div>
<!-- 30 P3: every module's scene-root content, listed read-only -->
<ModuleContent />
{/if}
{/if}
</div>
Expand Down
48 changes: 46 additions & 2 deletions src/components/menu/Inspector.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@
import { recordEntry, beginHistoryBatch, endHistoryBatch, recordTransformSet } from '$lib/history';
import { deviceOf, deviceSpec, isDeviceObject, setDeviceFor, previewDeviceParams } from '$lib/audioDevices';
import { MUSIC_TOOLBOX_ID, musicToolboxPick } from '$lib/musicToolbox';
import { openModuleToolbox } from '$lib/moduleToolboxes';
import { openModuleToolbox, moduleToolboxes } from '$lib/moduleToolboxes';
// 30 P3: a Module content row selected in the object list (a proxy, not an object)
import { moduleSelection } from '$lib/moduleContent';
import { canEditObject } from '$lib/objectPermissions';
import {
attachMultiPivot,
Expand All @@ -56,7 +58,7 @@
import { LIGHT_PARAMS, SHADOW_TYPES, SHADOW_SIZES, setShadowMapSize, cappedShadowSize } from '$lib/lightParams';
import { animatedObjects, setAnimationState } from '$lib/animatedImports';
import { captureAutoKey, playheadOf } from '$lib/animationPreview';
import { moveObjectToGroup, selectObject, flyTo } from '$lib/objectActions';
import { moveObjectToGroup, selectObject, flyTo, setPickThrough } from '$lib/objectActions';
import { listPhysicsObjects, enablePhysicsOnSelection, setPhysicsFor, PHYSICS_MATERIALS } from '$lib/physics';
import {
sceneGravity,
Expand Down Expand Up @@ -591,6 +593,16 @@
if (object?.uuid) captureAutoKey(object.uuid, playheadOf(object.uuid));
}
}
// 30 P3: the module toolbox a Module content card can open (null = the module has none)
const moduleToolbox = $derived(
$moduleSelection ? $moduleToolboxes.find((/** @type {any} */ box) => box.moduleId === $moduleSelection.moduleId) ?? null : null
);
// 30 P2: every member is click-through (a mixed set reads unchecked; ticking it marks them all)
const pickThroughAll = $derived.by(() => {
$selectedObject;
$objectsGroup;
return insTargets.length > 0 && insTargets.every((/** @type {any} */ object) => object?.userData?.pick === 'through');
});
/** @param {string} label @param {(object:any)=>void} fn */
function fan(label, fn) {
fanOn(insTargets, label, fn);
Expand Down Expand Up @@ -2655,6 +2667,25 @@
>
</Section>
</div>
{:else if $moduleSelection && !$selectedObjects.length}
<!-- 30 P3: a Module content row (a PROXY, not an object): whose it is, and where it
is edited — never transform rows the module would overwrite on its next rebuild -->
<div id="drawer-label" class="sticky top-0 z-10 -mx-4 rounded-tl-lg bg-gray-800 px-4">
<PanelHeader
title="Properties"
badge="Module content"
pinned={$inspectorPinned}
onpin={() => inspectorPinned.update((v) => !v)}
onclose={() => inspectorClose.set(true)}
/>
</div>
<div id="module-content-card" class="mt-2 flex flex-col gap-2 rounded-sm border border-gray-600/60 p-2 text-xs text-gray-300">
<p class="text-sm font-semibold text-gray-100">{$moduleSelection.label}</p>
<p>Made by the <strong>{$moduleSelection.moduleName}</strong> module — edit it with its toolbox or nodes.</p>
{#if moduleToolbox}
<button type="button" class="ui-button self-start" onclick={() => openModuleToolbox(moduleToolbox.id)}>Open {moduleToolbox.title}</button>
{/if}
</div>
{:else if $selectedObject?.name !== undefined}
<div id="drawer-label" class="sticky top-0 z-10 -mx-4 rounded-tl-lg bg-gray-800 px-4">
<PanelHeader
Expand Down Expand Up @@ -3094,6 +3125,19 @@
Frustum culled
</Checkbox>
<p class="text-[10px] text-gray-500">Higher render order draws later (over other objects). Disable culling for objects that vanish at screen edges.</p>
<!-- 30 P2: a wall, a ceiling or a glass case can stand aside for the editor's
click — it picks the next opaque thing behind (click again to cycle back) -->
<Checkbox
id="inspector-pick-through"
checked={pickThroughAll}
onchange={(/** @type {any} */ e) => {
const on = e.target.checked;
fan('Click-through', (/** @type {any} */ object) => setPickThrough(object.uuid, on));
selectedObject.update((v) => v);
}}
>
Click-through in the viewport
</Checkbox>
</Section>
{/if}

Expand Down
186 changes: 186 additions & 0 deletions src/components/menu/ModuleContent.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
<script>
// 30 P3: the object list's "Module content" section. One row per scene-root group a
// module registered (interactive / system / listed), expandable to its named children,
// READ-ONLY: the module owns that content and rebuilds it from its own state, so the
// list offers nothing a rebuild would undo (no rename, no delete, no drag). A click
// selects a PROXY (moduleContent.js) and frames it; the Inspector then says whose it
// is. Module content changes without a poke (a module adds children on its own clock),
// so the rows re-derive on every pokeScene AND once a second while the list is shown —
// the System view's precedent.
import { ChevronDown, ChevronRight, Boxes, Eye, EyeOff } from '@lucide/svelte';
import { get } from 'svelte/store';
import * as THREE from 'three';
import { globalScene, sceneRevision } from '../../stores/sceneStore';
import { showSidebar } from '../../stores/appStore';
import {
moduleContentRows,
moduleGroupsRevision,
moduleSelection,
selectModuleGroup
} from '$lib/moduleContent';
import { flyTo, deselectObject } from '$lib/objectActions';
import { moduleToolboxes, openModuleToolbox } from '$lib/moduleToolboxes';
import { safeStorage } from '$lib/safeStorage';
import ContextMenu from '../ContextMenu.svelte';

/** @type {any[]} */
let rows = $state([]);
let open = $state(safeStorage.getItem('objects:moduleContent') !== 'closed');
/** @type {Record<string, boolean>} */
let expanded = $state({});
/** @type {{x: number, y: number, row: any} | null} */
let menu = $state(null);

function refresh() {
rows = moduleContentRows(get(globalScene));
}
$effect(() => {
void $moduleGroupsRevision;
void $sceneRevision;
refresh();
});
$effect(() => {
const timer = setInterval(refresh, 1000);
return () => clearInterval(timer);
});

function toggleOpen() {
open = !open;
safeStorage.setItem('objects:moduleContent', open ? 'open' : 'closed');
}

/** frame a world box the way the System view frames its rows @param {any} box */
function frame(box) {
if (!box || box.isEmpty?.()) return;
const center = box.getCenter(new THREE.Vector3());
const size = Math.max(box.getSize(new THREE.Vector3()).length(), 2);
flyTo([center.x + size * 0.6, center.y + size * 0.45, center.z + size * 0.6], center.toArray());
}

/** @param {any} row */
function select(row) {
deselectObject(); // the proxy REPLACES the object selection, never joins it
const box = selectModuleGroup(row.name);
frame(box);
showSidebar('properties');
}

/** @param {any} row */
function toolboxOf(row) {
return $moduleToolboxes.find((/** @type {any} */ box) => box.moduleId === row.moduleId) ?? null;
}

/** Hide in the viewport, LOCALLY: a module rebuilding its group may show it again,
* and nothing here is sent or saved. @param {any} row */
function toggleHidden(row) {
row.object.visible = !row.object.visible;
refresh();
}

/** @param {any} row */
function menuItems(row) {
const toolbox = toolboxOf(row);
return [
{ label: 'Frame it', action: () => select(row) },
...(toolbox ? [{ label: 'Open ' + toolbox.title, action: () => openModuleToolbox(toolbox.id) }] : []),
{
label: row.visible ? 'Hide in viewport (this device)' : 'Show in viewport',
action: () => toggleHidden(row)
}
];
}

/** a direct listener (panel chrome swallows delegated handlers) @param {HTMLElement} node @param {any} row */
function rowMenu(node, row) {
let current = row;
/** @param {MouseEvent} event */
const onMenu = (event) => {
event.preventDefault();
event.stopPropagation();
menu = { x: event.clientX, y: event.clientY, row: current };
};
node.addEventListener('contextmenu', onMenu);
return {
/** @param {any} next */
update(next) {
current = next;
},
destroy() {
node.removeEventListener('contextmenu', onMenu);
}
};
}
</script>

{#if rows.length}
<div id="module-content" class="mt-1 border-t border-gray-300 pt-1 dark:border-gray-600">
<button
id="module-content-head"
type="button"
class="flex w-full items-center gap-1 px-1 py-0.5 text-left text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"
title="Content built by modules — listed read-only; the module owns it"
aria-expanded={open}
onclick={toggleOpen}
>
{#if open}<ChevronDown size={14} aria-hidden="true" />{:else}<ChevronRight size={14} aria-hidden="true" />{/if}
Module content
<span class="ml-auto font-normal normal-case text-gray-400">{rows.length}</span>
</button>
{#if open}
{#each rows as row (row.name)}
<div class="module-content-row" data-group={row.name} data-module={row.moduleId} use:rowMenu={row}>
<div
class={'flex items-center gap-1 rounded-sm px-1 py-0.5 text-sm text-gray-800 dark:text-gray-200 ' +
($moduleSelection?.name === row.name ? 'bg-primary-600/30' : 'hover:bg-gray-200 dark:hover:bg-gray-600')}
>
<button
type="button"
class="w-4 shrink-0 text-gray-400"
aria-label={expanded[row.name] ? 'Hide children' : 'Show children'}
onclick={() => (expanded = { ...expanded, [row.name]: !expanded[row.name] })}
>
{#if expanded[row.name]}<ChevronDown size={14} aria-hidden="true" />{:else}<ChevronRight size={14} aria-hidden="true" />{/if}
</button>
<Boxes size={14} class="shrink-0 text-gray-400" aria-hidden="true" />
<button
type="button"
class="module-content-name min-w-0 flex-1 truncate text-left"
class:opacity-50={!row.visible}
title={'Made by the ' + row.moduleName + ' module — click to select and frame it'}
onclick={() => select(row)}
>
{row.label}
</button>
<span class="module-content-badge shrink-0 rounded-sm bg-gray-200 px-1 text-[10px] text-gray-600 dark:bg-gray-600 dark:text-gray-200">{row.moduleName}</span>
<button
type="button"
class="shrink-0 text-gray-400"
title={row.visible ? 'Hide in the viewport (this device only)' : 'Show in the viewport'}
aria-label={row.visible ? 'Hide in the viewport' : 'Show in the viewport'}
onclick={() => toggleHidden(row)}
>
{#if row.visible}<Eye size={14} aria-hidden="true" />{:else}<EyeOff size={14} aria-hidden="true" />{/if}
</button>
</div>
{#if expanded[row.name]}
{#each row.children as child (child.uuid)}
<p class="module-content-child truncate text-xs text-gray-400" style={'padding-left:' + (child.depth * 12 + 20) + 'px'}>
{child.name}
</p>
{/each}
{#if row.more}
<p class="module-content-more pl-8 text-xs italic text-gray-400">+{row.more} more</p>
{/if}
{#if !row.children.length && !row.more}
<p class="pl-8 text-xs italic text-gray-400">No named parts</p>
{/if}
{/if}
</div>
{/each}
{/if}
</div>
{/if}

{#if menu}
<ContextMenu x={menu.x} y={menu.y} items={menuItems(menu.row)} sizeKey="object" onclose={() => (menu = null)} />
{/if}
Loading
Loading