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>
This commit is contained in:
2026-07-30 14:54:51 +12:00
parent d5cab2efdf
commit 5094c7bcee
78 changed files with 62251 additions and 832 deletions

56
web/js/api.js Normal file
View File

@@ -0,0 +1,56 @@
/** @typedef {{ index: number, width: number, height: number, position: string, label?: string }} PanelConfig */
/** @typedef {{ frame: number, animation: string, panels: Array<{ index: number, width: number, height: number, rgb: number[] }> }} FramePayload */
/** @typedef {{ panels: PanelConfig[], animations: string[], defaultFps: Record<string, number>, defaultBrightness: number }} PortalConfig */
/**
* @param {string} path
* @returns {Promise<PortalConfig>}
*/
export async function fetchConfig(path = "/api/config") {
const res = await fetch(path);
if (!res.ok) throw new Error(`config ${res.status}`);
return res.json();
}
export class FrameStream {
/** @param {() => void} onFrame */
constructor(onFrame) {
this._onFrame = onFrame;
this._source = null;
this._retry = null;
/** @type {(() => void) | null} */
this.onDisconnect = null;
}
/** @param {{ animation: string, brightness: number, fps: number }} opts */
start(opts) {
this.stop();
const params = new URLSearchParams({
animation: opts.animation,
brightness: String(opts.brightness),
fps: String(opts.fps),
});
this._source = new EventSource(`/api/stream?${params}`);
this._source.onmessage = (event) => {
this._onFrame(JSON.parse(event.data));
};
this._source.onerror = () => {
this.onDisconnect?.();
this.stop();
this._retry = setTimeout(() => this.start(opts), 1500);
};
}
stop() {
if (this._retry) {
clearTimeout(this._retry);
this._retry = null;
}
if (this._source) {
this._source.close();
this._source = null;
}
}
}

View File

@@ -0,0 +1,154 @@
import { fetchConfig, FrameStream } from "../api.js";
import "./portal-controls.js";
import "./portal-viewport.js";
import "./portal-schematic.js";
const APP_STYLES = `
:host {
display: grid;
grid-template-columns: 280px 1fr;
height: 100vh;
--border: #2a3044;
--panel: #12151f;
--text: #e8ecf7;
--muted: #8b93a8;
--accent: #5b8cff;
}
main {
position: relative;
min-width: 0;
height: 100%;
}
portal-viewport {
width: 100%;
height: 100%;
}
portal-viewport[hidden] {
display: none;
}
.status {
position: absolute;
left: 1rem;
bottom: 1rem;
padding: 0.35rem 0.6rem;
background: rgba(0, 0, 0, 0.55);
border: 1px solid var(--border);
border-radius: 6px;
font-size: 0.8rem;
color: var(--muted);
pointer-events: none;
font-family: system-ui, sans-serif;
}
@media (max-width: 800px) {
:host {
grid-template-columns: 1fr;
grid-template-rows: auto 1fr;
}
portal-controls {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
`;
export class PortalApp extends HTMLElement {
constructor() {
super();
/** @type {import('../api.js').PortalConfig | null} */
this._config = null;
/** @type {FrameStream | null} */
this._stream = null;
/** @type {import('./portal-controls.js').PortalControls | null} */
this._controls = null;
/** @type {import('./portal-viewport.js').PortalViewport | null} */
this._viewport = null;
/** @type {import('./portal-schematic.js').PortalSchematic | null} */
this._schematic = null;
this._settings = {
animation: "rainbow",
brightness: 0.35,
fps: 25,
paused: false,
identify: false,
view3d: true,
};
}
connectedCallback() {
if (this.shadowRoot) return;
const root = this.attachShadow({ mode: "open" });
root.innerHTML = `
<style>${APP_STYLES}</style>
<portal-controls></portal-controls>
<main>
<portal-viewport></portal-viewport>
<portal-schematic></portal-schematic>
<div class="status">Loading…</div>
</main>
`;
this._controls = root.querySelector("portal-controls");
this._viewport = root.querySelector("portal-viewport");
this._schematic = root.querySelector("portal-schematic");
this._status = root.querySelector(".status");
this.addEventListener("portal-settings", (e) => {
this._settings = e.detail;
this._viewport.hidden = !this._settings.view3d;
this._schematic.visible = !this._settings.view3d;
if (this._settings.paused && this._settings.identify && this._config) {
for (const panel of this._config.panels) {
this._viewport.updatePanel(panel.index, [], panel.width, panel.height, true);
this._schematic.updatePanel(panel.index, [], panel.width, panel.height, true);
}
}
this._syncStream();
});
this._init();
}
disconnectedCallback() {
this._stream?.stop();
}
async _init() {
try {
this._config = await fetchConfig();
this._controls.setConfig(this._config);
this._viewport.setPanels(this._config.panels);
this._schematic.setPanels(this._config.panels);
this._status.textContent = "Ready";
this._syncStream();
} catch (err) {
this._status.textContent = `Error: ${err.message}`;
}
}
_syncStream() {
this._stream?.stop();
if (this._settings.paused) return;
this._stream = new FrameStream((payload) => this._onFrame(payload));
this._stream.onDisconnect = () => {
this._status.textContent = "Stream disconnected — retrying…";
};
this._stream.start({
animation: this._settings.animation,
brightness: this._settings.brightness,
fps: this._settings.fps,
});
}
/** @param {import('../api.js').FramePayload} payload */
_onFrame(payload) {
const { identify } = this._settings;
for (const panel of payload.panels) {
this._viewport.updatePanel(panel.index, panel.rgb, panel.width, panel.height, identify);
this._schematic.updatePanel(panel.index, panel.rgb, panel.width, panel.height, identify);
}
this._status.textContent = `${payload.animation} · frame ${payload.frame}`;
}
}
customElements.define("portal-app", PortalApp);

View File

@@ -0,0 +1,185 @@
const CONTROL_STYLES = `
:host {
display: block;
border-right: 1px solid var(--border, #2a3044);
background: var(--panel, #12151f);
padding: 1rem;
overflow-y: auto;
color: var(--text, #e8ecf7);
font-family: system-ui, sans-serif;
}
h1 {
margin: 0 0 0.25rem;
font-size: 1.15rem;
}
p {
margin: 0 0 1rem;
color: var(--muted, #8b93a8);
font-size: 0.85rem;
line-height: 1.4;
}
a {
color: var(--accent, #5b8cff);
}
label {
display: block;
margin: 0.75rem 0 0.35rem;
font-size: 0.8rem;
color: var(--muted, #8b93a8);
}
select, input[type="range"], button {
width: 100%;
}
select, button {
background: #1a1f2e;
color: inherit;
border: 1px solid var(--border, #2a3044);
border-radius: 6px;
padding: 0.45rem 0.6rem;
font-size: 0.9rem;
}
button {
margin-top: 0.75rem;
cursor: pointer;
background: #243049;
}
button:hover { border-color: var(--accent, #5b8cff); }
button.active {
background: #2a3f6e;
border-color: var(--accent, #5b8cff);
}
.view-toggle {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
}
`;
export class PortalControls extends HTMLElement {
constructor() {
super();
/** @type {import('../api.js').PortalConfig | null} */
this._config = null;
this._animation = "rainbow";
this._brightness = 0.35;
this._fps = 25;
this._paused = false;
this._identify = false;
this._view3d = true;
}
connectedCallback() {
if (this.shadowRoot) return;
const root = this.attachShadow({ mode: "open" });
root.innerHTML = `
<style>${CONTROL_STYLES}</style>
<h1>Portal Simulator</h1>
<p>Hexagonal LED arch (see <a href="https://technical.kiwi/images/portal/IMG_20241029_220222.jpg" target="_blank" rel="noopener">photo</a>). Panel 2 top (Pi); 0, 1, 3, 4 walls; floor bare.</p>
<label for="animation">Animation</label>
<select id="animation"></select>
<label for="brightness">Brightness</label>
<input id="brightness" type="range" min="0.05" max="1" step="0.05" value="0.35" />
<label for="fps">FPS</label>
<input id="fps" type="range" min="5" max="40" step="1" value="25" />
<div class="view-toggle">
<button type="button" data-view="3d" class="active">3D view</button>
<button type="button" data-view="flat">Flat map</button>
</div>
<button type="button" data-action="identify">Identify panels</button>
<button type="button" data-action="pause">Pause</button>
`;
this._animationEl = root.querySelector("#animation");
this._brightnessEl = root.querySelector("#brightness");
this._fpsEl = root.querySelector("#fps");
this._animationEl.addEventListener("change", () => {
this._animation = this._animationEl.value;
if (this._config?.defaultFps[this._animation]) {
this._fps = Math.min(40, Math.round(this._config.defaultFps[this._animation]));
this._fpsEl.value = String(this._fps);
}
this._emitChange();
});
this._brightnessEl.addEventListener("input", () => {
this._brightness = Number(this._brightnessEl.value);
this._emitChange();
});
this._fpsEl.addEventListener("input", () => {
this._fps = Number(this._fpsEl.value);
this._emitChange();
});
root.querySelector('[data-view="3d"]').addEventListener("click", () => {
this._view3d = true;
this._syncViewButtons();
this._emitChange();
});
root.querySelector('[data-view="flat"]').addEventListener("click", () => {
this._view3d = false;
this._syncViewButtons();
this._emitChange();
});
root.querySelector('[data-action="identify"]').addEventListener("click", (e) => {
this._identify = !this._identify;
e.currentTarget.classList.toggle("active", this._identify);
this._emitChange();
});
root.querySelector('[data-action="pause"]').addEventListener("click", (e) => {
this._paused = !this._paused;
e.currentTarget.textContent = this._paused ? "Resume" : "Pause";
this._emitChange();
});
}
/** @param {import('../api.js').PortalConfig} config */
setConfig(config) {
this._config = config;
if (!this.shadowRoot) this.connectedCallback();
this._animationEl.innerHTML = "";
for (const name of config.animations) {
if (name === "solid") continue;
const opt = document.createElement("option");
opt.value = name;
opt.textContent = name;
this._animationEl.appendChild(opt);
}
this._animationEl.value = "rainbow";
this._animation = "rainbow";
this._brightness = config.defaultBrightness ?? 0.35;
this._brightnessEl.value = String(this._brightness);
this._fps = Math.min(40, Math.round(config.defaultFps.rainbow ?? 25));
this._fpsEl.value = String(this._fps);
this._emitChange();
}
_syncViewButtons() {
const root = this.shadowRoot;
root.querySelector('[data-view="3d"]').classList.toggle("active", this._view3d);
root.querySelector('[data-view="flat"]').classList.toggle("active", !this._view3d);
}
_emitChange() {
this.dispatchEvent(
new CustomEvent("portal-settings", {
bubbles: true,
composed: true,
detail: {
animation: this._animation,
brightness: this._brightness,
fps: this._fps,
paused: this._paused,
identify: this._identify,
view3d: this._view3d,
},
}),
);
}
/** @returns {import('../api.js').PortalConfig | null} */
get config() {
return this._config;
}
}
customElements.define("portal-controls", PortalControls);

View File

@@ -0,0 +1,90 @@
import { paintIdentify, paintRgb } from "../panel-paint.js";
const PANEL_STYLES = `
:host {
display: block;
background: #000;
border: 1px solid var(--border, #2a3044);
border-radius: 4px;
image-rendering: pixelated;
}
canvas {
display: block;
width: 100%;
height: auto;
}
.label {
text-align: center;
font-size: 0.75rem;
color: var(--muted, #8b93a8);
margin: 0.35rem 0;
font-family: system-ui, sans-serif;
}
`;
export class PortalPanel extends HTMLElement {
static observedAttributes = ["index", "position", "width", "height"];
constructor() {
super();
this._index = 0;
this._pixelWidth = 45;
this._pixelHeight = 9;
}
connectedCallback() {
if (this.shadowRoot) return;
const root = this.attachShadow({ mode: "open" });
root.innerHTML = `
<style>${PANEL_STYLES}</style>
<canvas part="canvas"></canvas>
<div class="label" part="label"></div>
`;
this._canvas = /** @type {HTMLCanvasElement} */ (root.querySelector("canvas"));
this._ctx = /** @type {CanvasRenderingContext2D} */ (this._canvas.getContext("2d"));
this._label = root.querySelector(".label");
this._syncFromAttributes();
}
attributeChangedCallback(name) {
if (!this.shadowRoot) return;
this._syncFromAttributes();
}
_syncFromAttributes() {
this._index = Number(this.getAttribute("index") ?? 0);
this._pixelWidth = Number(this.getAttribute("width") ?? 45);
this._pixelHeight = Number(this.getAttribute("height") ?? 9);
const position = this.getAttribute("position") ?? "";
if (this._label) {
this._label.textContent = `${this._index} · ${position}`;
}
if (this._canvas) {
this._canvas.width = this._pixelWidth;
this._canvas.height = this._pixelHeight;
}
}
/** @returns {HTMLCanvasElement} */
get canvas() {
if (!this._canvas) this.connectedCallback();
return this._canvas;
}
/**
* @param {number[] | Uint8Array} rgb
* @param {number} width
* @param {number} height
*/
setFrame(rgb, width, height) {
if (!this._ctx) this.connectedCallback();
paintRgb(this._ctx, this._canvas, rgb, width, height);
}
showIdentify() {
if (!this._ctx) this.connectedCallback();
paintIdentify(this._ctx, this._canvas, this._index, this._pixelWidth, this._pixelHeight);
}
}
customElements.define("portal-panel", PortalPanel);

View File

@@ -0,0 +1,169 @@
import {
SCHEMATIC_FLOOR_LINE,
SCHEMATIC_HEX_PATH,
SCHEMATIC_PANEL_LAYOUT,
} from "../portal-layout.js";
import "./portal-panel.js";
const SCHEMATIC_STYLES = `
:host {
display: none;
width: 100%;
height: 100%;
align-items: center;
justify-content: center;
padding: 1rem;
box-sizing: border-box;
background: radial-gradient(ellipse at 50% 45%, #141820 0%, #0a0c12 70%);
}
:host([visible]) {
display: flex;
}
.hex-stage {
position: relative;
width: min(88vw, 420px);
aspect-ratio: 100 / 118;
}
.hex-svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.hex-fill {
fill: rgba(0, 0, 0, 0.35);
}
.hex-stroke {
fill: none;
stroke: #3a4458;
stroke-width: 1.2;
}
.hex-floor {
fill: none;
stroke: #2a3044;
stroke-width: 0.8;
stroke-dasharray: 3 3;
}
.floor-label {
position: absolute;
left: 50%;
top: 91%;
transform: translate(-50%, -50%);
font-size: 0.65rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: #5a6278;
font-family: system-ui, sans-serif;
pointer-events: none;
}
.ground {
position: absolute;
left: 5%;
right: 5%;
bottom: -2%;
height: 2px;
background: #3a4050;
}
.slot {
position: absolute;
transform: translate(-50%, -50%);
z-index: 1;
}
.slot portal-panel {
display: block;
transform-origin: center center;
box-shadow: 0 0 12px rgba(255, 60, 40, 0.15);
}
`;
export class PortalSchematic extends HTMLElement {
constructor() {
super();
/** @type {Map<number, import('./portal-panel.js').PortalPanel>} */
this._panels = new Map();
}
connectedCallback() {
if (this.shadowRoot) return;
const root = this.attachShadow({ mode: "open" });
root.innerHTML = `
<style>${SCHEMATIC_STYLES}</style>
<div class="hex-stage">
<svg class="hex-svg" viewBox="0 0 100 108" preserveAspectRatio="xMidYMid meet">
<path class="hex-fill" d="${SCHEMATIC_HEX_PATH}" />
<path class="hex-stroke" d="${SCHEMATIC_HEX_PATH}" />
<line class="hex-floor"
x1="${SCHEMATIC_FLOOR_LINE.x1}" y1="${SCHEMATIC_FLOOR_LINE.y1}"
x2="${SCHEMATIC_FLOOR_LINE.x2}" y2="${SCHEMATIC_FLOOR_LINE.y2}" />
</svg>
<div class="floor-label">floor · walk through</div>
<div class="ground"></div>
<div class="slots"></div>
</div>
`;
this._slotsEl = root.querySelector(".slots");
}
_ensureSlot(position) {
let slot = this._slotsEl.querySelector(`[data-position="${position}"]`);
if (slot) return slot;
const layout = SCHEMATIC_PANEL_LAYOUT[position];
if (!layout) return null;
slot = document.createElement("div");
slot.className = "slot";
slot.dataset.position = position;
slot.style.left = `${layout.x}%`;
slot.style.top = `${layout.y}%`;
slot.style.maxWidth = layout.maxW;
this._slotsEl.appendChild(slot);
return slot;
}
/** @param {import('../api.js').PanelConfig[]} panels */
setPanels(panels) {
if (!this.shadowRoot) this.connectedCallback();
this._panels.clear();
this._slotsEl.querySelectorAll(".slot").forEach((s) => {
s.innerHTML = "";
});
for (const panel of panels) {
const slot = this._ensureSlot(panel.position);
if (!slot) continue;
const layout = SCHEMATIC_PANEL_LAYOUT[panel.position];
const el = document.createElement("portal-panel");
el.setAttribute("index", String(panel.index));
el.setAttribute("position", panel.position);
el.setAttribute("width", String(panel.width));
el.setAttribute("height", String(panel.height));
el.style.transform = `rotate(${layout.rot}deg)`;
slot.appendChild(el);
this._panels.set(panel.index, el);
}
}
/**
* @param {number} index
* @param {number[] | Uint8Array} rgb
* @param {number} width
* @param {number} height
* @param {boolean} identify
*/
updatePanel(index, rgb, width, height, identify) {
const panel = this._panels.get(index);
if (!panel) return;
if (identify) panel.showIdentify();
else panel.setFrame(rgb, width, height);
}
set visible(on) {
if (on) this.setAttribute("visible", "");
else this.removeAttribute("visible");
}
}
customElements.define("portal-schematic", PortalSchematic);

View File

@@ -0,0 +1,244 @@
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);

17
web/js/dev-reload.js Normal file
View File

@@ -0,0 +1,17 @@
/** Live reload when web/ files change (dev server only). */
function connect() {
const source = new EventSource("/api/dev/reload");
source.onmessage = (event) => {
if (event.data === "reload") {
console.log("[portal] reloading…");
location.reload();
}
};
source.onerror = () => {
source.close();
setTimeout(connect, 1500);
};
}
connect();

1
web/js/main.js Normal file
View File

@@ -0,0 +1 @@
import "./components/portal-app.js";

51
web/js/panel-paint.js Normal file
View File

@@ -0,0 +1,51 @@
export const PANEL_COLORS = [
0xff5050, 0x50ff50, 0x50a0ff, 0xffc850, 0xc850ff,
];
/**
* Paint a panel frame. `rgb` is logical [R,G,B,R,G,B,…] from the Python simulator
* (same byte order as UDP panel frames). Canvas ImageData uses RGBA.
*
* @param {CanvasRenderingContext2D} ctx
* @param {HTMLCanvasElement} canvas
* @param {number[] | Uint8Array} rgb
* @param {number} width
* @param {number} height
*/
export function paintRgb(ctx, canvas, rgb, width, height) {
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
}
const image = ctx.createImageData(width, height);
const data = image.data;
for (let i = 0, p = 0; i < rgb.length; i += 3, p += 4) {
data[p] = rgb[i];
data[p + 1] = rgb[i + 1];
data[p + 2] = rgb[i + 2];
data[p + 3] = 255;
}
ctx.putImageData(image, 0, 0);
}
/**
* @param {CanvasRenderingContext2D} ctx
* @param {HTMLCanvasElement} canvas
* @param {number} panelIndex
* @param {number} width
* @param {number} height
*/
export function paintIdentify(ctx, canvas, panelIndex, width, height) {
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
}
ctx.fillStyle = "#000";
ctx.fillRect(0, 0, width, height);
const hex = PANEL_COLORS[panelIndex % PANEL_COLORS.length].toString(16).padStart(6, "0");
ctx.fillStyle = `#${hex}`;
ctx.font = `bold ${Math.floor(height * 0.75)}px monospace`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(String(panelIndex), width / 2, height / 2);
}

168
web/js/portal-geometry.js Normal file
View File

@@ -0,0 +1,168 @@
/** Hexagonal portal geometry — matches physical flat-top hex arch. */
/** Scene scale (~2.5 m tall portal). */
export const PORTAL = {
/** Center to flat-edge (apothem) of inner opening. */
apothem: 1.05,
height: 1.85,
frameDepth: 0.14,
frameWall: 0.09,
baseThickness: 0.07,
baseExtend: 0.18,
ledInset: 0.02,
};
const CELL = 0.078;
/**
* Flat-top hex vertex ring in XZ (y = 0).
* @param {number} apothem
* @returns {[number, number][]}
*/
export function hexVerticesXZ(apothem) {
const R = (apothem * 2) / Math.sqrt(3);
const verts = [];
for (let i = 0; i < 6; i++) {
const angle = Math.PI / 6 + (i * Math.PI) / 3;
verts.push([R * Math.cos(angle), R * Math.sin(angle)]);
}
return verts;
}
/**
* @param {[number, number]} a
* @param {[number, number]} b
*/
function edgeMid(a, b) {
return [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
}
/**
* Inward normal for flat-top hex edge (XZ plane).
* @param {number} edgeIndex 0..5 clockwise from lower-right sloped edge
*/
function edgeInwardNormal(verts, edgeIndex) {
const a = verts[edgeIndex];
const b = verts[(edgeIndex + 1) % 6];
const dx = b[0] - a[0];
const dz = b[1] - a[1];
const len = Math.hypot(dx, dz) || 1;
const nx = dz / len;
const nz = -dx / len;
const mx = (a[0] + b[0]) / 2;
const mz = (a[1] + b[1]) / 2;
if (mx * nx + mz * nz < 0) {
return [-nx, -nz];
}
return [nx, nz];
}
/**
* Wall panel placement on inner hex face.
* Edge mapping (clockwise from bottom-right sloped edge):
* 0 bottom-right → panel 4
* 1 bottom (floor, no LEDs)
* 2 bottom-left → panel 0
* 3 top-left → panel 1
* 4 top (panel 2 is horizontal ceiling, not this edge)
* 5 top-right → panel 3
* @param {string} position
* @param {number} pixelWidth
*/
export function panelPlacement(position, pixelWidth = 45) {
const { apothem, height, frameDepth, ledInset } = PORTAL;
const verts = hexVerticesXZ(apothem);
const h = 9 * CELL;
const w = pixelWidth * CELL;
const wallY = height * 0.42;
const topY = height - frameDepth * 0.5;
/** @type {Record<string, { edge: number, y: number, rotYExtra?: number }>} */
const wallMap = {
"bottom-right": { edge: 0, y: wallY * 0.72 },
"bottom-left": { edge: 2, y: wallY * 0.72 },
"top-left": { edge: 3, y: wallY * 1.38 },
"top-right": { edge: 5, y: wallY * 1.38 },
};
if (position === "top") {
return {
pos: [0, topY, 0],
rot: [-Math.PI / 2, 0, 0],
width: 45 * CELL,
height: h,
};
}
const spec = wallMap[position];
if (!spec) {
return { pos: [0, 0, 0], rot: [0, 0, 0], width: w, height: h };
}
const a = verts[spec.edge];
const b = verts[(spec.edge + 1) % 6];
const mid = edgeMid(a, b);
const [nx, nz] = edgeInwardNormal(verts, spec.edge);
const inset = frameDepth * 0.5 + ledInset;
const px = mid[0] + nx * inset;
const pz = mid[1] + nz * inset;
const rotY = Math.atan2(nx, nz);
return {
pos: [px, spec.y, pz],
rot: [0, rotY, 0],
width: w,
height: h,
};
}
/**
* Build dark frame beam meshes data for each hex edge.
* @returns {Array<{ from: [number,number], to: [number,number], isFloor: boolean }>}
*/
export function hexFrameEdges() {
const outer = hexVerticesXZ(PORTAL.apothem + PORTAL.frameWall);
const inner = hexVerticesXZ(PORTAL.apothem);
const edges = [];
for (let i = 0; i < 6; i++) {
edges.push({
outerA: outer[i],
outerB: outer[(i + 1) % 6],
innerA: inner[i],
innerB: inner[(i + 1) % 6],
isFloor: i === 1,
});
}
return edges;
}
export { CELL };
/** @type {Record<string, string>} */
export const SCHEMATIC_SLOTS = {
"top-left": "slot-tl",
top: "slot-top",
"top-right": "slot-tr",
"bottom-left": "slot-bl",
"bottom-right": "slot-br",
};
/** Front elevation of upright flat-top hex arch (viewBox 0 0 100 108). */
export const SCHEMATIC_HEX_PATH =
"M 20 4 L 80 4 L 96 48 L 80 96 L 20 96 L 4 48 Z";
/** Bottom opening — floor, no LEDs. */
export const SCHEMATIC_FLOOR_LINE = { x1: 20, y1: 96, x2: 80, y2: 96 };
/**
* Panel placement on standing front view (% of stage, rotation deg).
* @type {Record<string, { x: number, y: number, rot: number, maxW: string }>}
*/
export const SCHEMATIC_PANEL_LAYOUT = {
top: { x: 50, y: 7, rot: 0, maxW: "52%" },
"top-left": { x: 15, y: 30, rot: -58, maxW: "34%" },
"top-right": { x: 85, y: 30, rot: 58, maxW: "34%" },
"bottom-left": { x: 15, y: 76, rot: 58, maxW: "32%" },
"bottom-right": { x: 85, y: 76, rot: -58, maxW: "32%" },
};

9
web/js/portal-layout.js Normal file
View File

@@ -0,0 +1,9 @@
export {
panelPlacement,
SCHEMATIC_SLOTS,
SCHEMATIC_HEX_PATH,
SCHEMATIC_FLOOR_LINE,
SCHEMATIC_PANEL_LAYOUT,
PORTAL,
CELL,
} from "./portal-geometry.js";