From 7d08659c8a71580a5c6dd21d0dd339f277cd86f3 Mon Sep 17 00:00:00 2001
From: AlexZ005
Date: Tue, 21 Jul 2026 00:29:01 +0300
Subject: [PATCH] [feat] roadmap 13 batch B - viewport & camera
- B1: the infinite grid fadeDistance now scales with the camera distance from the
origin (eased) so the grid no longer vanishes on far zoom-out; the fixed 100 units
capped it regardless of the far plane
- B2: fix the N8AO ghost - the AO pass was sized to the LOGICAL CSS size while the
composer sizes its passes to the physical drawing buffer (width x devicePixelRatio),
so on HiDPI displays the AO buffer was half-size, upsampled and offset into a
shifted dark copy of the shading; size it to the physical buffer (before/after
verified at devicePixelRatio 2)
- B3: Configure Scene gains a Camera lens preset row (Wide 24mm / Classic 35mm /
Natural 50mm / Portrait 85mm, set as vertical FOV) and the default camera FOV is
now 40 degrees (was an extreme-telephoto 15 that flattened the first impression)
- e2e: roadmap-13-viewport-camera (5 checks, all pass); build green; svelte-check
501/77 held
Co-Authored-By: Claude Fable 5
---
src/components/Outline.svelte | 8 +++-
src/components/Scene.svelte | 2 +-
src/components/menu/Inspector.svelte | 31 ++++++++++++++
src/extensions/Grid.svelte | 19 ++++++++-
tests/e2e/roadmap-13-viewport-camera.test.cjs | 42 +++++++++++++++++++
5 files changed, 98 insertions(+), 4 deletions(-)
create mode 100644 tests/e2e/roadmap-13-viewport-camera.test.cjs
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);
+});