diff --git a/src/components/Outline.svelte b/src/components/Outline.svelte
index 350eab75..a67a4d05 100644
--- a/src/components/Outline.svelte
+++ b/src/components/Outline.svelte
@@ -50,8 +50,14 @@
composer.addPass(new EffectPass(camera.current, outlineEffectLocked));
composer.addPass(new EffectPass(camera.current, outlineEffectSelected));
$effect(() => {
+ // B2: size the AO pass to the PHYSICAL drawing buffer. postprocessing's
+ // composer.setSize sizes each pass to width*devicePixelRatio; passing the
+ // LOGICAL CSS size here (the old code) under-sized the N8AO buffer on HiDPI
+ // displays, so its output was upsampled and read as a shifted "ghost" of the
+ // shading offset from the objects. Match the composer's physical resolution.
+ const dpr = renderer.getPixelRatio ? renderer.getPixelRatio() : 1;
composer.setSize($size.width, $size.height);
- aoPass.setSize($size.width, $size.height);
+ aoPass.setSize(Math.round($size.width * dpr), Math.round($size.height * dpr));
});
// AO on/off + quality follow the local prefs (one perf knob = shadowQuality)
$effect(() => {
diff --git a/src/components/Scene.svelte b/src/components/Scene.svelte
index f7111932..1f9efbee 100644
--- a/src/components/Scene.svelte
+++ b/src/components/Scene.svelte
@@ -897,7 +897,7 @@
})
-
+
{#if !$specatorMode}
{/if}
diff --git a/src/components/menu/Inspector.svelte b/src/components/menu/Inspector.svelte
index 6f445164..1fd77f9a 100644
--- a/src/components/menu/Inspector.svelte
+++ b/src/components/menu/Inspector.svelte
@@ -73,6 +73,17 @@
const hexColor = /^#[0-9A-F]{6}$/i;
const RAD_SNAP = Math.PI / 12; // Ctrl-snap rotations to 15°
+ // B3 (roadmap #13): camera lens presets. Labels use the familiar full-frame
+ // focal-length vocabulary; the value set is three's VERTICAL fov in degrees
+ // (vfov = 2·atan(12mm / focal)). Default camera fov is 40° (~33mm) — a natural,
+ // low-distortion product-viz look, just wider than a classic 35mm.
+ const LENS_PRESETS = [
+ { label: 'Wide', mm: 24, fov: 53 },
+ { label: 'Classic', mm: 35, fov: 38 },
+ { label: 'Natural', mm: 50, fov: 27 },
+ { label: 'Portrait', mm: 85, fov: 16 }
+ ];
+
let transitionParamsRight = { x: 320, duration: 200, easing: sineIn };
// side drawers live on the --z-drawer tier (68); chat floats on its own now.
@@ -631,6 +642,26 @@
Local render mode (ambient occlusion + wireframe are desktop-only; not shown to peers).
Show light helpers
+ Camera lens
+
+ {#each LENS_PRESETS as p (p.label)}
+
+ {/each}
+
import { Grid } from '@threlte/extras'
+ import { useThrelte, useTask } from '@threlte/core'
let { showGrid } = $props()
+
+ // B1 (roadmap #13): the infinite grid fades out beyond `fadeDistance` world
+ // units from the camera. A fixed 100 made the grid vanish on far dolly-out
+ // (cameraClip grows the far plane with the scene, but the grid didn't follow).
+ // Scale the fade with the camera's distance from the origin (a proxy for zoom)
+ // so the grid stays visible as you zoom out, eased so it doesn't pop.
+ const { camera } = useThrelte()
+ let fade = $state(100)
+ useTask(() => {
+ const cam = camera.current
+ if (!cam) return
+ const target = Math.min(Math.max(100, cam.position.length() * 1.6), 5000)
+ fade += (target - fade) * 0.2
+ })
-
+
{#if showGrid}
{/if}
diff --git a/tests/e2e/roadmap-13-viewport-camera.test.cjs b/tests/e2e/roadmap-13-viewport-camera.test.cjs
new file mode 100644
index 00000000..542fe052
--- /dev/null
+++ b/tests/e2e/roadmap-13-viewport-camera.test.cjs
@@ -0,0 +1,42 @@
+// Roadmap #13 Batch B — viewport & camera.
+// B3 default camera FOV is 40° (was an extreme-telephoto 15°); the Configure
+// Scene "Camera lens" presets set the matching vertical FOV.
+// B1 (grid fade scales with camera distance) and B2 (N8AO half-size ghost on HiDPI)
+// are verified visually (before/after screenshots at devicePixelRatio 2) — not
+// asserted here.
+const h = require('./helpers.cjs');
+
+const fovOf = (peer) =>
+ peer.page.evaluate(
+ () => new Promise((r) => window.__stores.globalCamera.subscribe((c) => r(c ? Math.round(c.fov) : null))())
+ );
+
+h.run(async () => {
+ const browser = await h.launch();
+ const A = await h.setupPage(browser, 'A');
+
+ // --- B3: default FOV ------------------------------------------------------
+ h.check((await fovOf(A)) === 40, 'B3: default camera FOV is 40 degrees');
+
+ // --- B3: lens presets in Configure Scene ---------------------------------
+ await A.page.evaluate(() => {
+ window.__stores.inspectorKind.set('scene');
+ window.__stores.inspectorClose.set(false);
+ });
+ await A.page.waitForTimeout(400);
+ h.check(await A.page.locator('#lens-presets').first().isVisible(), 'B3: lens presets row renders in Configure Scene');
+
+ await A.page.locator('#lens-presets button', { hasText: 'Natural' }).click();
+ await A.page.waitForTimeout(200);
+ h.check((await fovOf(A)) === 27, 'B3: "Natural" preset sets ~50mm (27 deg vertical FOV)');
+
+ await A.page.locator('#lens-presets button', { hasText: 'Wide' }).click();
+ await A.page.waitForTimeout(200);
+ h.check((await fovOf(A)) === 53, 'B3: "Wide" preset sets ~24mm (53 deg vertical FOV)');
+
+ await A.page.locator('#lens-presets button', { hasText: 'Portrait' }).click();
+ await A.page.waitForTimeout(200);
+ h.check((await fovOf(A)) === 16, 'B3: "Portrait" preset sets ~85mm (16 deg vertical FOV)');
+
+ await h.finish(browser);
+});