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>
245 lines
7.1 KiB
JavaScript
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);
|