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
4 changes: 2 additions & 2 deletions check-baseline.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"comment": "27-I: the svelte-check floor, read ONLY by scripts/check-ratchet.cjs. It used to be hardcoded in release.yml's shell block, where it went stale (362 while the tree measured 359). Ratchet it DOWN whenever a change legitimately removes errors - that is the project convention, and --update does it in one command.",
"errors": 357,
"errors": 352,
"warnings": 47,
"measured": "2026-09-12"
"measured": "2026-09-16"
}
24 changes: 21 additions & 3 deletions src/App.svelte

Large diffs are not rendered by default.

12 changes: 11 additions & 1 deletion src/components/Outline.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@
OutlineEffect,
RenderPass
} from 'postprocessing';
import { onMount, untrack } from 'svelte';
import { onMount, onDestroy, untrack } from 'svelte';
import { renderPaused } from '$lib/overloadGuard';
// 16-Q4: the camera preview window renders as an inset viewport of THIS renderer
import { pipRect, pipTarget, glRect } from '$lib/cameraPip';
import { buildCamera } from '$lib/cameraObjects';
Expand Down Expand Up @@ -321,8 +322,17 @@
autoRender.set(before);
};
});
// 26-G (roadmap 26 Stage 4): THE ONE PLACE A FRAME IS DRAWN, so the one place a
// pause can be real — no GPU work at all while it holds, which is what a device that
// cannot keep up needs. Read through a subscription, never get() per frame. NEVER in
// a headset: the XR compositor needs frames, and a paused XR session shows the user a
// frozen world strapped to their face with no overlay (DOM is invisible in VR).
let renderIsPaused = false;
const stopPauseWatch = renderPaused.subscribe((value) => (renderIsPaused = !!value));
onDestroy(stopPauseWatch);
useTask(
(delta) => {
if (renderIsPaused && !renderer.xr.isPresenting) return;
// In WebXR the EffectComposer can't be used: its passes render to canvas-sized
// targets, not the XR framebuffer, so blitting them mismatches sizes
// (GL_INVALID_FRAMEBUFFER_OPERATION) and nothing reaches the headset (dark
Expand Down
106 changes: 106 additions & 0 deletions src/components/RenderPausedOverlay.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
<script>
// 26-G (roadmap 26 section 4, Stage 4) — THE LAST RESORT.
//
// When frame after frame takes a quarter of a second, the window has stopped answering
// and anything we draw into it only makes that worse. So the drawing STOPS, and this
// says why and offers the three things worth doing — none of which throws anything
// away: save the scene, set the newest objects aside so the rest can run, or try again.
//
// It stands down whenever the context-LOST overlay is up (27-G). That one sits above
// it and describes a different failure; showing both would be two cards arguing.
import { contextLost } from '../stores/sceneStore';
import { save } from '$lib/fileHandler.svelte';
import { renderPaused, resumeRendering, reduceScene, restoreReduced, reducedObjects } from '$lib/overloadGuard';
import { showToast } from '../stores/appStore';

const saveScene = () => save('tpscene');
function reduce() {
const n = reduceScene();
resumeRendering();
showToast(
n
? 'Set aside the ' + n + ' newest object' + (n === 1 ? '' : 's') + ' on this device only — nothing was deleted, and peers still see them.'
: 'Nothing to set aside — the scene is already inside the budget. Rendering resumed.',
n ? [{ label: 'Show them again', action: () => restoreReduced() }] : undefined
);
}
</script>

{#if $renderPaused && !$contextLost}
<div id="render-paused" class="rp-overlay" role="alertdialog" aria-modal="true" aria-labelledby="rp-title">
<div class="rp-card">
<h2 id="rp-title">Rendering paused</h2>
<p>
The scene is too heavy for this device — the last frames each took longer than a
quarter of a second, so drawing has stopped to give the window back.
<strong>Nothing is lost</strong>, and autosave keeps running while this is open.
</p>
{#if $reducedObjects}
<p class="rp-note">{$reducedObjects} object{$reducedObjects === 1 ? ' is' : 's are'} already set aside on this device.</p>
{/if}
<div class="rp-actions">
<button id="render-paused-save" class="rp-btn" onclick={saveScene}>Save now</button>
<button id="render-paused-reduce" class="rp-btn" title="Stop drawing the newest objects on THIS device until the scene fits the budget. Peers are unaffected; nothing is deleted." onclick={reduce}>Reduce</button>
<button id="render-paused-resume" class="rp-btn rp-primary" onclick={resumeRendering}>Resume</button>
</div>
</div>
</div>
{/if}

<style>
/* one tier BELOW the context-lost overlay, which describes a worse failure */
.rp-overlay {
position: fixed;
inset: 0;
z-index: calc(var(--z-toast, 1200) + 5);
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.6);
}
.rp-card {
max-width: 440px;
margin: 16px;
padding: 20px 22px;
border-radius: 10px;
background: var(--surface, #1f2937);
color: #fff;
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.5);
}
.rp-card h2 {
margin: 0 0 8px;
font-size: 16px;
font-weight: 600;
}
.rp-card p {
margin: 0 0 12px;
font-size: 13px;
line-height: 1.5;
color: #d1d5db;
}
.rp-note {
font-size: 12px !important;
color: #fbbf24 !important;
}
.rp-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
flex-wrap: wrap;
}
.rp-btn {
padding: 6px 14px;
border: 0;
border-radius: 7px;
font-size: 13px;
background: #374151;
color: #fff;
cursor: pointer;
}
.rp-btn:hover {
filter: brightness(1.2);
}
.rp-primary {
background: var(--accent, #2563eb);
}
</style>
133 changes: 128 additions & 5 deletions src/components/menu/Controls.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
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';
import { sceneMetrics, statsOpen, worstTier, budgetRows } from '$lib/sceneBudget';
import { keyOf } from '$lib/keyOf';
import { systemGroupNames } from '$lib/moduleSDK';
import { ENV_ROOT } from '$lib/environment';
Expand Down Expand Up @@ -411,6 +412,114 @@
};
}

// --- 26-B: LIST VIRTUALISATION (roadmap 26 Stage 0, audit M6) -------------------
// The tree rendered EVERY visible row, recursively, and re-reconciled all of them on
// every scene poke. At 3,000 objects that is 3,000 component instances each carrying
// nine handlers and a Tooltip — the object list alone was several hundred ms of the
// reported freeze, and it is why deleting one object in a big scene felt worse than
// the delete itself.
//
// Above the threshold the SAME row component renders in `flat` mode over the
// flattened `visibleObjectRows` — the one array the keyboard walker, Ctrl+A and the
// type-ahead already read their order from — with two spacer divs standing in for
// what is off screen. Sharing that array is what keeps the arrows and the window
// agreeing by construction; deriving a second order would be a copy guaranteed to
// drift (the Explorer's `gridEntries` ruling, one panel over).
//
// Below the threshold NOTHING changes: the recursive tree renders exactly as it did,
// indent borders and all, so the common case is byte-identical.
const VIRTUAL_MIN = 500;
/** rows drawn beyond each edge, so a fast flick does not show blank space */
const OVERSCAN = 12;
let rowH = $state(24);
let scrollTop = $state(0);
let viewportH = $state(0);
let treeScroller: HTMLElement | null = null;
const treeRows = $derived(viewMode ? [] : visibleObjectRows($objectsGroup, $expandedObjects, $objectFilter as any));
const virtualising = $derived(treeRows.length > VIRTUAL_MIN);
const windowStart = $derived(virtualising ? Math.max(0, Math.floor(scrollTop / rowH) - OVERSCAN) : 0);
const windowEnd = $derived(
virtualising
? Math.min(treeRows.length, Math.ceil((scrollTop + (viewportH || 400)) / rowH) + OVERSCAN)
: 0
);
const windowRows = $derived(virtualising ? treeRows.slice(windowStart, windowEnd) : []);

/** Find the real scrolling ancestor by SCROLLABILITY, never by class name — the
* scroller is flowbite's `Listgroup`, whose element we do not own (the deep-link
* ruling in Section.svelte, same reason). */
function trackTreeScroll(node: HTMLElement) {
let ro: any = null;
const read = () => {
if (!treeScroller) return;
scrollTop = treeScroller.scrollTop;
viewportH = treeScroller.clientHeight;
// measure ONE real row rather than trusting a constant: the spacers are in
// pixels, so a wrong height makes the window drift away from the scrollbar
const first = node.querySelector('[role="treeitem"] > div') as HTMLElement | null;
const h = first?.offsetHeight ?? 0;
if (h > 8 && Math.abs(h - rowH) > 0.5) rowH = h;
};
let el: HTMLElement | null = node.parentElement;
while (el) {
const style = getComputedStyle(el);
if (/(auto|scroll)/.test(style.overflowY)) break;
el = el.parentElement;
}
treeScroller = el;
if (treeScroller) {
treeScroller.addEventListener('scroll', read, { passive: true });
ro = new ResizeObserver(read);
ro.observe(treeScroller);
ro.observe(node);
}
read();
return {
destroy() {
treeScroller?.removeEventListener('scroll', read);
ro?.disconnect();
treeScroller = null;
}
};
}

// keyboard follow: in the window a selected row that is off screen has no element to
// scroll itself into view, so the WINDOW moves instead (the arrows would otherwise
// walk silently into nothing).
let lastFollowed = '';
$effect(() => {
const uuid = $selectedObjects.length ? $selectedObjects[$selectedObjects.length - 1] : '';
if (!virtualising || !uuid || uuid === lastFollowed) { lastFollowed = uuid; return; }
lastFollowed = uuid;
const index = treeRows.findIndex((r) => r.uuid === uuid);
if (index < 0 || !treeScroller) return;
const top = index * rowH;
const view = treeScroller.clientHeight;
if (top < treeScroller.scrollTop) treeScroller.scrollTop = top;
else if (top + rowH > treeScroller.scrollTop + view) treeScroller.scrollTop = top + rowH - view;
});

// 26-A: the meter's dot and its tooltip. The reading is the sampler's; this only
// picks the worst tier and spells out what is over budget, so the tooltip answers
// "over budget on WHAT" without opening anything.
const budgetProfileNow = $derived($sceneMetrics.profile === 'vr' ? 'vr' : 'desktop');
const budgetTier = $derived(worstTier($sceneMetrics, budgetProfileNow));
/** A DIRECT listener, not `on:click`/`onclick`: this file is written in the `on:`
* style throughout, so an attribute handler here is a hard "mixing syntaxes" error,
* and the `on:` form is deprecated in runes mode — the action is the way out of both,
* and it is what the panel-chrome rule asks for anyway (a delegated handler inside a
* panel can be swallowed on its way up). */
function openStats(node: HTMLElement) {
const open = () => statsOpen.set(true);
node.addEventListener('click', open);
return { destroy() { node.removeEventListener('click', open); } };
}
const budgetTitle = $derived.by(() => {
const over = budgetRows($sceneMetrics, budgetProfileNow).filter((r) => r.tier === 'amber' || r.tier === 'red');
if (!over.length) return 'Scene budget — within the ' + (budgetProfileNow === 'vr' ? 'VR / mobile' : 'desktop') + ' budget. Click for statistics.';
return 'Scene budget: over on ' + over.map((r) => r.label.toLowerCase()).join(', ') + '. Click for statistics.';
});

// bottom status line: totals across the whole tree (N objects · M hidden)
let objectCount = $state(0);
let hiddenCount = $state(0);
Expand Down Expand Up @@ -2199,8 +2308,14 @@
{#if $objectsGroup}
<LocalObjects />
<!-- drop a local object anywhere here to SHARE it to the scene root -->
<div class="min-h-8 rounded-sm transition-colors" use:shareDropZone>
{#if $objectsGroup.children.length > 0}
<div class="min-h-8 rounded-sm transition-colors" data-object-rows={virtualising ? 'window' : 'tree'} use:shareDropZone use:trackTreeScroll>
{#if virtualising}
<div style={'height:' + windowStart * rowH + 'px'} aria-hidden="true"></div>
{#each windowRows as row (row.uuid)}
<Objects element={row.object} flat depth={row.depth} />
{/each}
<div style={'height:' + (treeRows.length - windowEnd) * rowH + 'px'} aria-hidden="true"></div>
{:else if $objectsGroup.children.length > 0}
{#each $objectsGroup.children.filter((/** @type {any} */ c) => !c.userData?.__localOnly) as element}
<Objects {element} />
{/each}
Expand All @@ -2210,9 +2325,17 @@
{/if}
</div>
</Listgroup>
<div id="object-count" class="shrink-0 rounded-bl rounded-br bg-gray-100 px-2 py-0.5 text-[10px] text-gray-500 dark:bg-gray-700 dark:text-gray-300">
{objectCount} object{objectCount === 1 ? '' : 's'}{hiddenCount ? ' · ' + hiddenCount + ' hidden' : ''}
</div>
<!-- 26-A: THE BUDGET METER. One dot beside the count that a person can learn in a
second, next to the one number that already says how big the scene is. It opens
the Statistics window, because a warning you cannot act on is a decoration. -->
<button
id="object-count"
class="shrink-0 rounded-bl rounded-br bg-gray-100 px-2 py-0.5 text-left text-[10px] text-gray-500 dark:bg-gray-700 dark:text-gray-300"
title={budgetTitle}
use:openStats
>
<span id="object-budget-dot" class="budget-dot mr-1" data-tier={budgetTier}></span>{objectCount} object{objectCount === 1 ? '' : 's'}{hiddenCount ? ' · ' + hiddenCount + ' hidden' : ''}
</button>
<!-- corner grip INSIDE the window (was parked 38px below the box and unreachable, 92) -->
<div
class="resize-handle resize-cue"
Expand Down
15 changes: 7 additions & 8 deletions src/components/menu/Inspector.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -165,8 +165,7 @@
backgroundColor,
globalCamera,
viewMode,
showGrid
} from '../../stores/sceneStore';
showGrid, pokeScene } from '../../stores/sceneStore';
// 16-P3: grid + snapping prefs (LOCAL, like the clip planes)
import { gridSettings, setGrid, resetGrid, effectiveCell } from '$lib/gridSettings';
import { snapEnabled, snapSettings, surfaceSnap, snapTargets } from '$lib/snapping';
Expand Down Expand Up @@ -499,7 +498,7 @@
setShaderGraphFor(object.uuid, null);
detachFrom(object);
}
objectsGroup.update((v) => v);
pokeScene();
showToast(own.length === 1 ? 'Shader removed from this object' : 'Shader removed from ' + own.length + ' objects');
}

Expand Down Expand Up @@ -1185,7 +1184,7 @@
}
}
selectedObject.update((s) => s);
objectsGroup.update((v) => v);
pokeScene();
}

/** A picked image file → every selected material, decoded once. @param {File} file */
Expand All @@ -1199,7 +1198,7 @@
if (uuids.length > 1) endHistoryBatch(`Texture (${uuids.length})`);
}
selectedObject.update((s) => s);
objectsGroup.update((v) => v);
pokeScene();
}

/** CL-A A4: which material preset matches the current values (else 'custom') @param {any} p */
Expand All @@ -1221,7 +1220,7 @@
}

function sendName() {
objectsGroup.update((value) => value); // refresh the object list
pokeScene(); // refresh the object list
$peers.send({ type: 'name', name: $selectedObject.name, uuid: $selectedObject.uuid });
}

Expand Down Expand Up @@ -2578,7 +2577,7 @@
$selectedObject.uuid,
selected?.name === 'Level Up' ? 'up' : val
);
objectsGroup.update((v) => v);
pokeScene();
rerenderSelectGroup = !rerenderSelectGroup;
}}
/>
Expand Down Expand Up @@ -3353,7 +3352,7 @@
object.material.needsUpdate = true;
$peers.send({ type: 'color', uuid: object.uuid, color: c.hex });
}
objectsGroup.update((v) => v);
pokeScene();
}}
/>
{/if}
Expand Down
11 changes: 8 additions & 3 deletions src/components/menu/Objects.svelte
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
<script>
import { Box, ChevronDown, ChevronRight, Eye, EyeOff, Layers, Lock, PersonStanding, Settings, Share2, Sun, UserLock } from '@lucide/svelte';
/** @type {{ element: any }} */
let { element } = $props();
/** 26-B: `flat` renders ONE row and no recursion — the virtualised list in
* Controls draws the flattened `visibleObjectRows` itself and supplies the
* indent, so the same component (and the same nine handlers) serve both the
* recursive tree and the window. `depth` is the indent in tree levels.
* @type {{ element: any, flat?: boolean, depth?: number }} */
let { element, flat = false, depth = 0 } = $props();
// 24-B2: expansion lives in the `expandedObjects` store (appStore) so the keyboard
// walker can see the visible order and it survives a re-mount; `setExpanded` is
// the one writer
Expand Down Expand Up @@ -240,6 +244,7 @@
(isSelected
? 'bg-primary-900/50 text-primary-100'
: 'text-gray-800 hover:bg-gray-200 dark:text-gray-200 dark:hover:bg-gray-600/50')}
style={depth ? 'padding-left:' + (depth * 12 + 4) + 'px' : ''}
role="presentation"
onclick={(e) => { select(element.uuid, e.shiftKey); }}
>
Expand Down Expand Up @@ -326,7 +331,7 @@
</div>
</div>

{#if isExpanded}
{#if isExpanded && !flat}
<div class="ml-3 border-l border-gray-600/40 pl-1" role="group">
{#each kids as item (item.uuid)}
<Objects element={item} />
Expand Down
Loading
Loading