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:
168
web/js/portal-geometry.js
Normal file
168
web/js/portal-geometry.js
Normal 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%" },
|
||||
};
|
||||
Reference in New Issue
Block a user