{floorLabel && (
diff --git a/packages/viewer/src/ui/index.ts b/packages/viewer/src/ui/index.ts
index a96c6ace83..d6777c9c60 100644
--- a/packages/viewer/src/ui/index.ts
+++ b/packages/viewer/src/ui/index.ts
@@ -1,3 +1,8 @@
+export {
+ type FirstPersonHudState,
+ useFirstPersonHud,
+ type WalkthroughInteract,
+} from '../store/use-first-person-hud'
export { ActionButton } from './components/action-button'
export {
type ActionMenuPlacement,
@@ -187,9 +192,4 @@ export {
publishNavigationSyncPose,
subscribeNavigationSyncPose,
} from './store/navigation-sync-pose-store'
-export {
- type FirstPersonHudState,
- useFirstPersonHud,
- type WalkthroughInteract,
-} from './store/use-first-person-hud'
export { FOLLOW_PASCAL_STORAGE_KEY, useFollowPascal } from './store/use-follow-pascal'
diff --git a/scripts/check-render-bundle.ts b/scripts/check-render-bundle.ts
index cf6d314a58..e227e032bf 100644
--- a/scripts/check-render-bundle.ts
+++ b/scripts/check-render-bundle.ts
@@ -73,6 +73,7 @@ function rejectUninspectedLoads(path: string, contents: string) {
for (const source of [
"const dep = globalThis.dep ?? '@pascal-app/editor'; export const lazy = () => import(dep)",
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: source text for the computed-import boundary probe
'export const lazy = (name) => import(`@pascal-app/${name}`)',
"const dep = '@pascal-app/editor'; export const load = () => require(dep)",
"export const modules = import.meta.glob('../editor/src/**/*.ts')",
@@ -154,9 +155,9 @@ const result = await build({
'render-consumer',
`
import { loadPlugin, useScene } from '@pascal-app/core';
- import { Viewer } from '@pascal-app/viewer';
+ import { LiveWalkthroughController, useFirstPersonHud, Viewer } from '@pascal-app/viewer';
import { renderPlugin } from '@pascal-app/nodes/render';
- export { Viewer, renderPlugin };
+ export { LiveWalkthroughController, useFirstPersonHud, Viewer, renderPlugin };
export async function hydrate(scene) {
await loadPlugin(renderPlugin);
useScene.getState().setScene(scene);
@@ -230,6 +231,9 @@ console.log(`Render bundle including all lazy chunks: ${bytes} bytes across ${fi
const consumer = resolve(root, 'packages/nodes/render-consumer.ts')
const consumerSource = `
import type { Plugin } from '@pascal-app/core';
+ import { LiveWalkthroughController, useFirstPersonHud, type LiveWalkthroughControllerProps } from '@pascal-app/viewer';
+ const walkProps: LiveWalkthroughControllerProps = { paused: false };
+ export const walkthrough = { LiveWalkthroughController, useFirstPersonHud, walkProps };
import { renderPlugin } from '@pascal-app/nodes/render';
import { playSound, initInteractionSounds, useAudioSettings, type SoundName } from '@pascal-app/viewer/sfx';
const sound: SoundName = 'menuClick';
@@ -280,10 +284,20 @@ if (diagnostics.length) {
}),
)
}
+if (
+ !program
+ .getSourceFiles()
+ .some((file) =>
+ file.fileName.endsWith('/viewer/dist/components/viewer/live-walkthrough-controller.d.ts'),
+ )
+)
+ throw new Error('Declaration consumer must resolve the built live walkthrough controller')
if (!program.getSourceFiles().some((file) => file.fileName.endsWith('/nodes/dist/render.d.ts')))
throw new Error('Declaration consumer must resolve the built render export')
if (!program.getSourceFiles().some((file) => file.fileName.endsWith('/viewer/dist/sfx/index.d.ts')))
throw new Error('Declaration consumer must resolve the built SFX export')
if (!program.getSourceFiles().some((file) => file.fileName.endsWith('/viewer/dist/ui/index.d.ts')))
throw new Error('Declaration consumer must resolve the built UI export')
-console.log('Render, SFX and UI declaration consumer: passed with editor/Next resolution forbidden')
+console.log(
+ 'Render, live walkthrough, SFX and UI declaration consumer: passed with editor/Next resolution forbidden',
+)
diff --git a/scripts/check-viewer-live-fixture.ts b/scripts/check-viewer-live-fixture.ts
index 2e0c60265a..279e602f08 100644
--- a/scripts/check-viewer-live-fixture.ts
+++ b/scripts/check-viewer-live-fixture.ts
@@ -214,13 +214,6 @@ try {
if (errors.length) throw new Error(errors.join('\n'))
if ((await page.evaluate(() => typeof (globalThis as any).process)) !== 'undefined')
throw new Error('Fixture must run without a process polyfill')
- await page.evaluate(() => (window as any).playFixtureSound())
- for (let i = 0; i < 50 && !soundRequests.includes('/fixture-sfx/door_open.mp3'); i++)
- await page.waitForTimeout(100)
- if (!soundRequests.includes('/fixture-sfx/door_open.mp3'))
- throw new Error(
- `Sound files did not load from sfxBaseUrl: ${soundRequests.join(', ')}`,
- )
if (!iconRequests.includes('/icons/topview.webp'))
throw new Error(
`Viewer UI icons did not load from iconsBaseUrl: ${iconRequests.join(', ')}`,
@@ -244,6 +237,57 @@ try {
overlayBefore,
{ timeout: 10_000 },
)
+ const documentBefore = await page.evaluate(() => (window as any).viewerFixture.document)
+ await page.click('[data-enter-walkthrough]')
+ await page.waitForFunction(() => {
+ const probe = (window as any).viewerFixture
+ return (
+ probe?.walking &&
+ probe.camera[1] > 1 &&
+ probe.camera[1] < 2 &&
+ document.pointerLockElement === document.querySelector('canvas')
+ )
+ })
+ await page.waitForSelector('[data-walkthrough-hud]', { state: 'visible' })
+ await page.waitForSelector('[data-walkthrough-crosshair]', { state: 'visible' })
+ // Wait for the initial capsule/eye placement before measuring keyboard movement.
+ const start: number[] = await page.evaluate(() => (window as any).viewerFixture.camera)
+ await page.keyboard.down('w')
+ try {
+ await page.waitForFunction(
+ (position) => {
+ const camera = (window as any).viewerFixture.camera
+ return Math.hypot(camera[0] - position[0], camera[2] - position[2]) > 0.3
+ },
+ start,
+ { timeout: 10_000 },
+ )
+ } finally {
+ await page.keyboard.up('w')
+ }
+ // The placed spawn faces the fixture's south-wall door. This must be the controller's
+ // own centre-ray target and E handler, not a synthetic sound request from the host.
+ await page.waitForFunction(() =>
+ document.querySelector('[data-walkthrough-hud]')?.textContent?.includes('open door'),
+ )
+ await page.keyboard.press('e')
+ await page.waitForFunction(() =>
+ (window as any).viewerFixture.interactionSounds.includes('door-open'),
+ )
+ for (let i = 0; i < 50 && !soundRequests.includes('/fixture-sfx/door_open.mp3'); i++)
+ await page.waitForTimeout(100)
+ if (!soundRequests.includes('/fixture-sfx/door_open.mp3'))
+ throw new Error(
+ `Door sound did not load from sfxBaseUrl: ${soundRequests.join(', ')}`,
+ )
+ const documentAfter = await page.evaluate(() => (window as any).viewerFixture.document)
+ if (documentBefore !== documentAfter)
+ throw new Error(
+ 'Walkthrough movement/door interaction changed the read-only document',
+ )
+ await page.keyboard.press('Escape')
+ await page.waitForFunction(() => !(window as any).viewerFixture.walking)
+ await page.waitForSelector('[data-walkthrough-hud]', { state: 'detached' })
// The packed stage: its switcher shows the read-only plan, drawn by renderPlugin's
// floor-plan builders alone, then the plan beside the 3D view.
const planShapes: Record = {}
@@ -295,8 +339,8 @@ try {
console.log(
`${mode}, storage ${deniedStorage ? 'denied' : 'available'}: scene ready, ` +
`non-empty node geometry, ${probe.drawCalls} draw calls, room pill, highlight ` +
- `and callout, 2D and split plan (${JSON.stringify(planShapes)}), no errors ` +
- `(${elapsed()})`,
+ `and callout, live walkthrough movement, HUD, door sound, unchanged document, ` +
+ `2D and split plan (${JSON.stringify(planShapes)}), no errors (${elapsed()})`,
)
} catch (error) {
console.error(
diff --git a/wiki/architecture/viewer-isolation.md b/wiki/architecture/viewer-isolation.md
index 45f9bbb317..1df031cd12 100644
--- a/wiki/architecture/viewer-isolation.md
+++ b/wiki/architecture/viewer-isolation.md
@@ -49,13 +49,39 @@ The read-only chrome (the 3D / 2D / Split stage and its floor plan, controls bar
HUDs, loader, room pills and highlight, the viewer selection model and the primitives they use) is
the `@pascal-app/viewer/ui` entry, styled with Tailwind and its shipped `theme.css`. It is
editor-agnostic like the rest of the package: the editor imports its stores and controls from there
-(one camera-pose, 2D↔3D navigation-pose, first-person HUD and Follow Pascal store), never the
-reverse. The plan's SVG geometry renderer is the one the editor's floor plan draws with; the editor
-layers its editing on top (hit targets, handles, drags, snapping) and turns on the typeable
-dimension hit rects with `FloorplanDimensionHitsProvider`, which read-only plans leave off.
+(one camera-pose, 2D↔3D navigation-pose and Follow Pascal store), never the reverse. The first-person
+HUD store lives in the main viewer entry and UI re-exports it, so controllers never import UI chrome.
+The plan's SVG geometry renderer is the one the editor's floor plan draws with; the editor layers
+its editing on top (hit targets, handles, drags, snapping) and turns on the typeable dimension hit
+rects with `FloorplanDimensionHitsProvider`, which read-only plans leave off.
Router pieces come in as props (`linkComponent`); Biome bans `next` in the package and the bundle
gate builds the entry with editor/Next resolution forbidden.
+## Live walkthrough ownership
+
+`LiveWalkthroughController` belongs to the main `@pascal-app/viewer` entry alongside
+`GlbWalkthroughController`: it is a 3D controller mounted inside `Viewer`, not UI chrome. It reads
+`useViewer.walkthroughMode` and `walkthroughMovement`, builds collision geometry from the live
+registry, and operates transient door/window/item/mechanism/elevator state. The render-only
+`renderPlugin` supplies everything it needs. Walking never writes per-frame scene documents
+(E-007) or changes their schema (E-003).
+
+The main entry owns `useFirstPersonHud`; `viewer/ui` re-exports that same store and renders
+`WalkthroughHud`/`DroneHud` outside the canvas. Controllers update the store without importing UI.
+Interaction sounds still travel through core `interaction:sound` to the optional `viewer/sfx` entry.
+Graph-only XZ bounds live in core; the DOM typing-target predicate lives in viewer.
+
+Editor `FirstPersonControls` is a thin adapter: capture shutter hold, FOV ownership, reserved
+pointer clicks, unlock suspension and editor exit/tool toggles stay there. The shared controller
+accepts neutral `paused`, `externalFov`, `pointerInteraction`, `suspendOnUnlock` and `onExit`
+props. There is one live implementation of movement, collision and interactions. Hosts disable
+other camera controls while walkthrough is active and choose their own entry/pointer-lock UI.
+
+The render-bundle gate imports the built controller in both runtime and declaration consumers
+with editor/Next resolution forbidden. The packed live fixture enters walkthrough with only
+`renderPlugin`, checks keyboard camera movement, HUD/crosshair, a door's sound cue and unchanged
+scene nodes in dev/production with normal/denied storage.
+
## Live graph bootstrap and ownership
```tsx