diff --git a/packages/polycss/src/elements/PolyPerspectiveCameraElement.test.ts b/packages/polycss/src/elements/PolyPerspectiveCameraElement.test.ts
new file mode 100644
index 00000000..6f1647ce
--- /dev/null
+++ b/packages/polycss/src/elements/PolyPerspectiveCameraElement.test.ts
@@ -0,0 +1,75 @@
+/**
+ * Tests for .
+ *
+ * Regression coverage: updating the `perspective` attribute at runtime must
+ * NOT recreate the camera handle, because captures the handle
+ * by identity at mount time and would be orphaned by a swap — leaving every
+ * later rot-x/rot-y/zoom/target update silently no-op'd. The fix mutates the
+ * wrapper's CSS perspective in place instead.
+ */
+import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
+import { PolyPerspectiveCameraElement } from "./PolyPerspectiveCameraElement";
+
+beforeAll(() => {
+ if (!customElements.get("poly-perspective-camera")) {
+ customElements.define("poly-perspective-camera", PolyPerspectiveCameraElement);
+ }
+});
+
+describe("", () => {
+ let host: HTMLElement;
+
+ beforeEach(() => {
+ host = document.createElement("div");
+ document.body.appendChild(host);
+ });
+
+ afterEach(() => {
+ if (host.parentNode) host.parentNode.removeChild(host);
+ });
+
+ it("creates a camera handle on connect", () => {
+ const el = document.createElement("poly-perspective-camera") as PolyPerspectiveCameraElement;
+ host.appendChild(el);
+ expect(el.getCamera()).not.toBeNull();
+ });
+
+ it("preserves the same camera handle when `perspective` attribute changes", () => {
+ const el = document.createElement("poly-perspective-camera") as PolyPerspectiveCameraElement;
+ el.setAttribute("perspective", "32000");
+ host.appendChild(el);
+ const handleBefore = el.getCamera();
+ el.setAttribute("perspective", "2000");
+ const handleAfter = el.getCamera();
+ expect(handleAfter).toBe(handleBefore);
+ });
+
+ it("updates the wrapper's CSS perspective when `perspective` changes", () => {
+ const el = document.createElement("poly-perspective-camera") as PolyPerspectiveCameraElement;
+ el.setAttribute("perspective", "32000");
+ host.appendChild(el);
+ const wrapper = el.querySelector(".polycss-camera") as HTMLElement;
+ expect(wrapper.style.perspective).toBe("32000px");
+ el.setAttribute("perspective", "2000");
+ expect(wrapper.style.perspective).toBe("2000px");
+ });
+
+ it("forwards rot-x updates to the live camera handle", () => {
+ const el = document.createElement("poly-perspective-camera") as PolyPerspectiveCameraElement;
+ el.setAttribute("rot-x", "65");
+ host.appendChild(el);
+ const handle = el.getCamera()!;
+ el.setAttribute("rot-x", "90");
+ expect(handle.state.rotX).toBe(90);
+ });
+
+ it("forwards rot-x updates even after a `perspective` change", () => {
+ const el = document.createElement("poly-perspective-camera") as PolyPerspectiveCameraElement;
+ el.setAttribute("rot-x", "65");
+ host.appendChild(el);
+ const handle = el.getCamera()!;
+ el.setAttribute("perspective", "2000");
+ el.setAttribute("rot-x", "90");
+ expect(handle.state.rotX).toBe(90);
+ });
+});
diff --git a/packages/polycss/src/elements/PolyPerspectiveCameraElement.ts b/packages/polycss/src/elements/PolyPerspectiveCameraElement.ts
index ee942022..647c9a88 100644
--- a/packages/polycss/src/elements/PolyPerspectiveCameraElement.ts
+++ b/packages/polycss/src/elements/PolyPerspectiveCameraElement.ts
@@ -103,11 +103,11 @@ export class PolyPerspectiveCameraElement extends ELEMENT_BASE {
if (!this._camera || !this._wrapper) return;
const opts = this._readOptions();
if (name === "perspective") {
- // Re-creating the handle is required for perspective — it's baked
- // into `perspectiveStyle` on the wrapper. Everything else uses
- // update() so the SCENE keeps its reference to the same handle.
- this._camera = createPolyPerspectiveCamera(opts);
- this._wrapper.style.perspective = this._camera.perspectiveStyle;
+ // Update the wrapper's CSS perspective in place. Recreating the camera
+ // handle here would orphan the scene's captured reference (it would
+ // keep pointing at the old handle and ignore every later update).
+ const px = opts.perspective !== undefined ? `${opts.perspective}px` : "32000px";
+ this._wrapper.style.perspective = px;
return;
}
// Mutate the existing handle in place — the scene captured this object
diff --git a/website/src/pages/tv.astro b/website/src/pages/tv.astro
index 6870a7b6..1a9eb912 100644
--- a/website/src/pages/tv.astro
+++ b/website/src/pages/tv.astro
@@ -80,6 +80,10 @@ const TVS = [
},
],
cam: { rotX: 70, rotY: 215, zoom: 8 },
+ // FPV player shrinks for the smaller presets so the TVs read as larger
+ // without actually scaling the DOM — large CSS scales cause compositor
+ // flicker on these many-polygon meshes.
+ fpvPlayerScale: 1 / 2,
},
{
id: 'tv-obj',
@@ -88,6 +92,7 @@ const TVS = [
mtl: '/tv/materials.mtl',
screens: [{ polyIndices: [70] }],
cam: { rotX: 70, rotY: 245, zoom: 10 },
+ fpvPlayerScale: 1 / 3,
},
] as const;
---
@@ -187,8 +192,8 @@ const TVS = [
directional-intensity="6.5"
ambient-intensity="0.4"
>
-
-
+
+
{/* Ground plane for shadow reception. Z position is set
per-preset by the script (= TV's bbox minZ) so the floor
always sits flush with the TV's bottom regardless of how
@@ -208,6 +213,11 @@ const TVS = [
Retro Stack can light up every CRT independently. */}
+
+
+
+ WASD · mouse · space
+
@@ -231,6 +241,7 @@ const TVS = [
mtl?: string;
screens: readonly TvScreen[];
cam: { rotX: number; rotY: number; zoom: number };
+ fpvPlayerScale?: number;
}
interface PolygonLike { vertices: Array<[number, number, number]>; }
@@ -461,6 +472,138 @@ const TVS = [
const sceneHandle = (sceneEl as unknown as { getScene?: () => { setOptions: (o: Record) => void } | null }).getScene?.();
sceneHandle?.setOptions({ shadow: { color: "#000000", opacity: 0.95, lift: 0.1 } });
+ // ── Camera-mode toggle (Orbit ⇄ FPV) ───────────────────────────────
+ // Bottom-centre pill swaps the orbit controls for first-person
+ // controls. FPV gives WASD movement + mouse look; click anywhere
+ // in the stage to lock the pointer.
+ //
+ // On the way INTO FPV: disable scene auto-center, switch the
+ // camera to a wider FOV (perspective 2000, matching the gallery's
+ // FPV_PERSPECTIVE), and spawn the player a few mesh-spans behind
+ // the TV along the current rotY look direction so W walks toward
+ // it (matches useFpvSpawn from the gallery).
+ const orbitControls = document.getElementById("tv-orbit-controls");
+ const fpvHint = document.querySelector("[data-fpv-hint]");
+ let fpvControls: HTMLElement | null = null;
+ let savedAutoCenter = true;
+ let savedRotX = "70";
+ let savedZoom = "8";
+ let currentMode: "orbit" | "fpv" = "orbit";
+
+ // Derive FPV camera + controls config from the current mesh's bbox
+ // and apply it. Used both on initial mode switch and on every mesh
+ // swap while FPV is active (so changing TVs in FPV doesn't snap back
+ // to the orbit-tuned zoom/rotation from applyPreset).
+ function applyFpvSpawn(): void {
+ if (!camera || !mesh) return;
+ const handle = mesh.getMeshHandle?.();
+ let eyeHeight = 6;
+ let groundZ = 0;
+ if (handle) {
+ // Per-preset player scale: smaller = TV reads as bigger from the
+ // player's POV, without actually scaling the mesh DOM (which
+ // causes compositor flicker on large CSS transforms).
+ const playerScale = (activePreset as { fpvPlayerScale?: number }).fpvPlayerScale ?? 1;
+ let minX = Infinity, maxX = -Infinity;
+ let minY = Infinity, maxY = -Infinity;
+ let minZ = Infinity, maxZ = -Infinity;
+ for (const p of handle.polygons) {
+ for (const v of p.vertices) {
+ if (v[0] < minX) minX = v[0]; if (v[0] > maxX) maxX = v[0];
+ if (v[1] < minY) minY = v[1]; if (v[1] > maxY) maxY = v[1];
+ if (v[2] < minZ) minZ = v[2]; if (v[2] > maxZ) maxZ = v[2];
+ }
+ }
+ if (Number.isFinite(minZ)) {
+ const cx = (minX + maxX) / 2;
+ const cy = (minY + maxY) / 2;
+ const midZ = (minZ + maxZ) / 2;
+ const meshHeight = Math.max(maxZ - minZ, 1);
+ const horizSpan = Math.max(maxX - minX, maxY - minY, 1);
+ const rotY = parseFloat(camera.getAttribute("rot-y") ?? "215");
+ const r = (rotY * Math.PI) / 180;
+ // Same back-distance regardless of player size. A smaller
+ // player at the same distance sees the TV loom larger in FOV
+ // — that's the whole point of shrinking the player.
+ const back = horizSpan * 3;
+ const ox = cx + Math.cos(r) * back;
+ const oy = cy + Math.sin(r) * back;
+ camera.setAttribute("target", `${ox},${oy},0`);
+ groundZ = minZ;
+ // Cabinet-anchored eye height (96), shrunk by playerScale.
+ eyeHeight = Math.max(meshHeight * 1.6, meshHeight + 6) * playerScale;
+ // Aim the camera at the TV's vertical center. Computing pitch
+ // from look geometry instead of hard-coding 75° means small
+ // players (low camera) look horizontally and tall players
+ // tilt down sharply to see the TV below them.
+ const cameraZ = groundZ + eyeHeight;
+ const dz = midZ - cameraZ;
+ const rotXDeg = 90 + (Math.atan2(dz, back) * 180) / Math.PI;
+ camera.setAttribute("rot-x", String(rotXDeg));
+ // FPV zoom inversely tracks player size.
+ const fpvZoom = Math.max(0.6, 60 / meshHeight) / playerScale;
+ camera.setAttribute("zoom", String(fpvZoom));
+ }
+ }
+ // Detach any prior FPV controls element. The internal handle seeds
+ // `cameraOrigin` from the scene's target once, on attach — so to
+ // teleport between model switches we throw it away and recreate it
+ // *after* the new target attribute is in place.
+ if (fpvControls && fpvControls.isConnected) fpvControls.remove();
+ fpvControls = document.createElement("poly-first-person-controls");
+ fpvControls.setAttribute("jump-velocity", "25");
+ fpvControls.setAttribute("gravity", "60");
+ fpvControls.setAttribute("look-sensitivity", "0.15");
+ // Constant player-scale tuning — move-speed and crouch-height are
+ // anchored to the cabinet-TV-sized eye height (96) so traversal
+ // and ducking feel identical across all presets.
+ fpvControls.setAttribute("move-speed", String(eyeHeight * 2));
+ fpvControls.setAttribute("eye-height", String(eyeHeight));
+ // Crouch at 70% of eye height — duck-walk, not turn-into-mouse.
+ fpvControls.setAttribute("crouch-height", String(eyeHeight * 0.7));
+ fpvControls.setAttribute("ground-z", String(groundZ));
+ sceneEl!.appendChild(fpvControls);
+ }
+
+ function setCameraMode(mode: "orbit" | "fpv"): void {
+ if (!sceneEl || !camera || !mesh) return;
+ currentMode = mode;
+ root!.querySelectorAll(".tv-camera-mode__btn").forEach((b) => {
+ b.classList.toggle("is-active", b.dataset.mode === mode);
+ });
+ if (fpvHint) fpvHint.hidden = mode !== "fpv";
+ camera.setAttribute("perspective", mode === "fpv" ? "2000" : "32000");
+ if (mode === "fpv") {
+ savedAutoCenter = sceneEl.hasAttribute("auto-center");
+ savedRotX = camera.getAttribute("rot-x") ?? "70";
+ savedZoom = camera.getAttribute("zoom") ?? "8";
+ sceneEl.removeAttribute("auto-center");
+ if (orbitControls) orbitControls.remove();
+ applyFpvSpawn();
+ } else {
+ if (savedAutoCenter) sceneEl.setAttribute("auto-center", "");
+ camera.setAttribute("rot-x", savedRotX);
+ camera.setAttribute("zoom", savedZoom);
+ // Removing the attribute doesn't clear the camera handle's
+ // internal target — parseVec3(null) returns undefined and the
+ // handle ignores undefined updates. Force the orbit recenter
+ // by writing target back to world origin explicitly. Combined
+ // with the restored auto-center this puts the TV back in the
+ // middle of the view.
+ camera.setAttribute("target", "0,0,0");
+ if (fpvControls) fpvControls.remove();
+ if (orbitControls && !orbitControls.isConnected) {
+ sceneEl.appendChild(orbitControls);
+ }
+ }
+ }
+ root.querySelectorAll(".tv-camera-mode__btn").forEach((btn) => {
+ btn.addEventListener("click", () => {
+ const mode = btn.dataset.mode;
+ if (mode === "orbit" || mode === "fpv") setCameraMode(mode);
+ });
+ });
+
let activePreset: TvPreset = presets[0];
let activeIframes: HTMLElement[] = [];
@@ -545,6 +688,11 @@ const TVS = [
// That's our cue to read polygons and derive every screen's placement.
mesh.addEventListener("polycss:loaded", () => {
applyPlacementsFromMesh();
+ // While in FPV, applyPreset (which fires on every model switch)
+ // writes the orbit-tuned rot-x/rot-y/zoom from TvPreset.cam into
+ // the camera. Re-spawn from the freshly-loaded mesh's bbox so the
+ // FPV view stays player-scale instead of snapping to orbit zoom.
+ if (currentMode === "fpv") applyFpvSpawn();
});
function applyPreset(p: TvPreset, opts: { pushUrl?: boolean } = {}): void {
@@ -717,6 +865,45 @@ const TVS = [
position: relative;
overflow: hidden;
}
+ /* Floating Orbit/FPV pill — bottom-centre of the viewport. Same shape
+ as the builder's camera-mode pill. */
+ .tv-camera-mode {
+ position: absolute;
+ left: 50%;
+ bottom: 16px;
+ transform: translateX(-50%);
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 4px;
+ background: #111316;
+ border: 1px solid #252b36;
+ border-radius: 999px;
+ user-select: none;
+ z-index: 5;
+ }
+ .tv-camera-mode__btn {
+ appearance: none;
+ border: 0;
+ background: #111316;
+ color: #8c98a6;
+ padding: 4px 12px;
+ font: inherit;
+ font-size: 12px;
+ border-radius: 999px;
+ cursor: pointer;
+ }
+ .tv-camera-mode__btn.is-active {
+ color: #0a0b0d;
+ background: #22d3ee;
+ }
+ .tv-camera-mode__hint {
+ color: #707b87;
+ font-size: 11px;
+ padding: 0 10px 0 6px;
+ border-left: 1px solid #2a3039;
+ margin-left: 2px;
+ }
@media (max-width: 38rem) {
.tv-page { grid-template-columns: 1fr; grid-template-rows: auto 1fr; }
.tv-rail {