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

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);