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:
154
web/js/components/portal-app.js
Normal file
154
web/js/components/portal-app.js
Normal 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);
|
||||
185
web/js/components/portal-controls.js
Normal file
185
web/js/components/portal-controls.js
Normal 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);
|
||||
90
web/js/components/portal-panel.js
Normal file
90
web/js/components/portal-panel.js
Normal 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);
|
||||
169
web/js/components/portal-schematic.js
Normal file
169
web/js/components/portal-schematic.js
Normal 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);
|
||||
244
web/js/components/portal-viewport.js
Normal file
244
web/js/components/portal-viewport.js
Normal 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);
|
||||
Reference in New Issue
Block a user