Files
portal/web/js/components/portal-viewport.js
Jimmy 5094c7bcee Add portal web simulator, SPI bridges, and Pico firmware updates.
Bring the five-panel hex portal online with a browser 3D/schematic preview, Pi SPI backends, and renamed multi-panel Pico UDP firmware.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 14:54:51 +12:00

245 lines
7.1 KiB
JavaScript

import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import { EffectComposer } from "three/addons/postprocessing/EffectComposer.js";
import { RenderPass } from "three/addons/postprocessing/RenderPass.js";
import { UnrealBloomPass } from "three/addons/postprocessing/UnrealBloomPass.js";
import { paintIdentify, paintRgb } from "../panel-paint.js";
import { hexFrameEdges, panelPlacement, PORTAL } from "../portal-geometry.js";
const HOST_STYLES = `
:host {
display: block;
width: 100%;
height: 100%;
}
.mount {
width: 100%;
height: 100%;
}
`;
const FRAME_MAT = new THREE.MeshStandardMaterial({
color: 0x0a0a0a,
roughness: 0.92,
metalness: 0.05,
});
const BASE_MAT = new THREE.MeshStandardMaterial({
color: 0x050505,
roughness: 0.95,
metalness: 0.02,
});
export class PortalViewport extends HTMLElement {
constructor() {
super();
/** @type {Map<number, object>} */
this._panels = new Map();
this._raf = 0;
}
connectedCallback() {
if (this.shadowRoot) return;
const root = this.attachShadow({ mode: "open" });
root.innerHTML = `<style>${HOST_STYLES}</style><div class="mount"></div>`;
this._mount = root.querySelector(".mount");
this._scene = new THREE.Scene();
this._scene.background = new THREE.Color(0x1a1a1e);
this._scene.fog = new THREE.Fog(0x1a1a1e, 8, 22);
this._camera = new THREE.PerspectiveCamera(50, 1, 0.1, 50);
this._camera.position.set(0, 1.0, 3.8);
this._renderer = new THREE.WebGLRenderer({ antialias: true });
this._renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
this._renderer.toneMapping = THREE.ReinhardToneMapping;
this._renderer.toneMappingExposure = 1.15;
this._mount.appendChild(this._renderer.domElement);
this._composer = new EffectComposer(this._renderer);
this._composer.addPass(new RenderPass(this._scene, this._camera));
this._bloom = new UnrealBloomPass(
new THREE.Vector2(1, 1),
0.85,
0.35,
0.15,
);
this._composer.addPass(this._bloom);
this._controls = new OrbitControls(this._camera, this._renderer.domElement);
this._controls.enableDamping = true;
this._controls.target.set(0, 0.95, 0);
this._controls.minDistance = 2.2;
this._controls.maxDistance = 8;
this._scene.add(new THREE.AmbientLight(0x332222, 0.25));
const key = new THREE.DirectionalLight(0xffeedd, 0.35);
key.position.set(2, 5, 4);
this._scene.add(key);
const rim = new THREE.DirectionalLight(0x445566, 0.2);
rim.position.set(-3, 2, -2);
this._scene.add(rim);
this._buildEnvironment();
this._buildFrame();
this._buildBase();
this._onResize = () => this._resize();
window.addEventListener("resize", this._onResize);
this._resize();
this._tick();
}
disconnectedCallback() {
window.removeEventListener("resize", this._onResize);
cancelAnimationFrame(this._raf);
this._renderer?.dispose();
this._composer?.dispose();
}
_buildEnvironment() {
const floor = new THREE.Mesh(
new THREE.PlaneGeometry(14, 14),
new THREE.MeshStandardMaterial({ color: 0x2a2a30, roughness: 0.9, metalness: 0 }),
);
floor.rotation.x = -Math.PI / 2;
floor.position.y = 0;
this._scene.add(floor);
const back = new THREE.Mesh(
new THREE.PlaneGeometry(12, 6),
new THREE.MeshStandardMaterial({ color: 0x3a3a42, roughness: 0.95 }),
);
back.position.set(0, 2.5, -4);
this._scene.add(back);
}
_buildFrame() {
const { height, frameDepth } = PORTAL;
const group = new THREE.Group();
for (const edge of hexFrameEdges()) {
if (edge.isFloor) continue;
const ax = edge.outerA[0];
const az = edge.outerA[1];
const bx = edge.outerB[0];
const bz = edge.outerB[1];
const dx = bx - ax;
const dz = bz - az;
const len = Math.hypot(dx, dz);
const beam = new THREE.Mesh(
new THREE.BoxGeometry(len, height, frameDepth),
FRAME_MAT,
);
beam.position.set((ax + bx) / 2, height / 2, (az + bz) / 2);
beam.rotation.y = Math.atan2(dx, dz);
group.add(beam);
const lip = new THREE.Mesh(
new THREE.BoxGeometry(len * 0.98, frameDepth * 0.6, frameDepth * 0.35),
FRAME_MAT,
);
lip.position.set((ax + bx) / 2, height - frameDepth * 0.25, (az + bz) / 2);
lip.rotation.y = Math.atan2(dx, dz);
group.add(lip);
}
this._scene.add(group);
}
_buildBase() {
const { apothem, baseThickness, baseExtend } = PORTAL;
const w = apothem * 2 + baseExtend * 2;
const base = new THREE.Mesh(
new THREE.BoxGeometry(w, baseThickness, w * 0.85),
BASE_MAT,
);
base.position.set(0, baseThickness / 2, 0.05);
this._scene.add(base);
}
_resize() {
const w = this.clientWidth || 1;
const h = this.clientHeight || 1;
this._camera.aspect = w / h;
this._camera.updateProjectionMatrix();
this._renderer.setSize(w, h);
this._composer.setSize(w, h);
this._bloom.resolution.set(w, h);
}
_tick() {
this._raf = requestAnimationFrame(() => this._tick());
this._controls.update();
this._composer.render();
}
/** @param {import('../api.js').PanelConfig[]} panels */
setPanels(panels) {
if (!this.shadowRoot) this.connectedCallback();
for (const entry of this._panels.values()) {
this._scene.remove(entry.mesh);
entry.texture.dispose();
entry.material.dispose();
entry.geometry.dispose();
}
this._panels.clear();
for (const panel of panels) {
const place = panelPlacement(panel.position, panel.width);
const geometry = new THREE.PlaneGeometry(place.width, place.height);
const canvas = document.createElement("canvas");
canvas.width = panel.width;
canvas.height = panel.height;
const ctx = canvas.getContext("2d");
const texture = new THREE.CanvasTexture(canvas);
texture.magFilter = THREE.NearestFilter;
texture.minFilter = THREE.NearestFilter;
texture.colorSpace = THREE.SRGBColorSpace;
const material = new THREE.MeshBasicMaterial({
map: texture,
side: THREE.FrontSide,
toneMapped: false,
});
const mesh = new THREE.Mesh(geometry, material);
mesh.position.set(place.pos[0], place.pos[1], place.pos[2]);
mesh.rotation.set(place.rot[0], place.rot[1], place.rot[2]);
this._scene.add(mesh);
this._panels.set(panel.index, {
mesh,
geometry,
material,
canvas,
ctx,
texture,
panel,
});
}
}
/**
* @param {number} index
* @param {number[] | Uint8Array} rgb
* @param {number} width
* @param {number} height
* @param {boolean} identify
*/
updatePanel(index, rgb, width, height, identify) {
const entry = this._panels.get(index);
if (!entry) return;
if (identify) {
paintIdentify(entry.ctx, entry.canvas, index, entry.panel.width, entry.panel.height);
} else {
paintRgb(entry.ctx, entry.canvas, rgb, width, height);
}
entry.texture.needsUpdate = true;
}
}
customElements.define("portal-viewport", PortalViewport);