<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Fusible Studio</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script>
<style>
@font-face {
font-family: 'Undefined';
src: url('/undefined-medium.otf') format('opentype');
font-weight: normal;
font-style: normal;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--bg: #000000;
--surface: #131313;
--surface-hover: #1a1a1a;
--border: #353535;
--border-subtle: #222222;
--green: #86A881;
--green-dark: #229147;
--olive: #A8B365;
--text: #FFFFFF;
--text-body: rgba(255, 255, 255, 0.80);
--text-subtle: rgba(255, 255, 255, 0.45);
--alert: #f43f5e;
--copper: #c87d32;
--gold: #d4af37;
--blue: #38bdf8;
}
body {
font-family: 'Undefined', monospace;
background: var(--bg);
color: var(--text);
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
user-select: none;
}
header {
height: 52px;
background: var(--surface);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
z-index: 20;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
}
.brand-title {
font-size: 14px;
letter-spacing: 0.05em;
color: var(--text);
font-weight: bold;
}
.brand-sub {
color: var(--olive);
font-size: 13px;
}
.board-name {
color: var(--text-subtle);
font-size: 11px;
padding: 3px 8px;
background: var(--surface-hover);
border: 1px solid var(--border);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.fuse-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 10px;
font-size: 11px;
letter-spacing: 0.05em;
border: 1px solid;
}
.fuse-intact {
background: rgba(34, 145, 71, 0.15);
color: var(--green);
border-color: var(--green-dark);
}
.fuse-blown {
background: rgba(244, 63, 94, 0.15);
color: var(--alert);
border-color: var(--alert);
animation: fusePulse 1.5s infinite;
}
@keyframes fusePulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
.tabs {
display: flex;
background: var(--bg);
border: 1px solid var(--border);
}
.tab-btn {
background: transparent;
border: none;
color: var(--text-subtle);
padding: 6px 14px;
font-family: 'Undefined', monospace;
font-size: 11px;
letter-spacing: 0.05em;
cursor: pointer;
border-right: 1px solid var(--border);
transition: color 0.15s ease, background 0.15s ease;
}
.tab-btn:last-child {
border-right: none;
}
.tab-btn:hover {
color: var(--text);
background: var(--surface-hover);
}
.tab-btn.active {
background: var(--surface-hover);
color: var(--green);
border-bottom: 2px solid var(--green);
}
.controls {
display: flex;
align-items: center;
gap: 6px;
}
.btn {
background: var(--surface);
border: 1px solid var(--border);
color: var(--text-body);
font-family: 'Undefined', monospace;
padding: 5px 10px;
font-size: 11px;
letter-spacing: 0.05em;
cursor: pointer;
}
.btn:hover {
background: var(--surface-hover);
color: var(--text);
border-color: var(--olive);
}
main {
flex: 1;
display: flex;
position: relative;
overflow: hidden;
}
#viewport-container {
flex: 1;
position: relative;
background: var(--bg);
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
cursor: grab;
}
#viewport-container:active {
cursor: grabbing;
}
#viewport-grid {
position: absolute;
inset: 0;
background-image:
linear-gradient(to right, rgba(53, 53, 53, 0.25) 1px, transparent 1px),
linear-gradient(to bottom, rgba(53, 53, 53, 0.25) 1px, transparent 1px);
background-size: 32px 32px;
pointer-events: none;
}
#canvas-content {
transform-origin: 0 0;
display: inline-block;
transition: transform 0.05s linear;
will-change: transform;
z-index: 2;
}
#canvas-content svg {
display: block;
filter: drop-shadow(0 10px 25px rgba(0,0,0,0.8));
}
#three-container {
position: absolute;
inset: 0;
z-index: 3;
display: none;
cursor: default;
}
#sidebar {
width: 340px;
background: var(--surface);
border-left: 1px solid var(--border);
display: flex;
flex-direction: column;
z-index: 10;
}
.sidebar-tabs {
display: flex;
border-bottom: 1px solid var(--border);
background: var(--bg);
}
.sidebar-tab-btn {
flex: 1;
padding: 8px 4px;
background: transparent;
border: none;
color: var(--text-subtle);
font-family: 'Undefined', monospace;
font-size: 10px;
letter-spacing: 0.05em;
cursor: pointer;
border-right: 1px solid var(--border);
transition: color 0.15s, background 0.15s;
}
.sidebar-tab-btn:last-child {
border-right: none;
}
.sidebar-tab-btn:hover {
color: var(--text);
background: var(--surface-hover);
}
.sidebar-tab-btn.active {
color: var(--green);
background: var(--surface);
border-bottom: 2px solid var(--green);
}
.sidebar-pane {
display: none;
flex: 1;
overflow-y: auto;
flex-direction: column;
}
.sidebar-pane.active {
display: flex;
}
.sidebar-section {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
}
.section-title {
font-size: 11px;
letter-spacing: 0.1em;
color: var(--olive);
margin-bottom: 8px;
}
.layer-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 5px 0;
font-size: 11px;
letter-spacing: 0.05em;
color: var(--text-body);
}
.layer-label {
display: flex;
align-items: center;
gap: 8px;
}
.layer-color-dot {
width: 8px;
height: 8px;
border: 1px solid rgba(255,255,255,0.2);
}
.toggle-checkbox {
accent-color: var(--green);
cursor: pointer;
}
.inspector-card {
padding: 14px 16px;
}
.inspector-header {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border);
}
.inspector-name {
font-size: 14px;
font-weight: bold;
color: var(--green);
letter-spacing: 0.05em;
}
.inspector-type {
font-size: 10px;
color: var(--olive);
text-transform: uppercase;
}
.prop-row {
display: flex;
justify-content: space-between;
padding: 4px 0;
font-size: 11px;
border-bottom: 1px solid rgba(53, 53, 53, 0.3);
}
.prop-key {
color: var(--text-subtle);
}
.prop-val {
color: var(--text-body);
font-family: monospace;
}
.pins-list {
margin-top: 12px;
}
.pins-title {
font-size: 10px;
color: var(--olive);
letter-spacing: 0.05em;
margin-bottom: 6px;
}
.pin-item {
display: flex;
justify-content: space-between;
padding: 3px 6px;
font-size: 10px;
background: var(--surface-hover);
border: 1px solid var(--border-subtle);
margin-bottom: 3px;
cursor: pointer;
}
.pin-item:hover {
border-color: var(--blue);
color: var(--blue);
}
.pin-net {
color: var(--blue);
}
.comp-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 16px;
border-bottom: 1px solid var(--border-subtle);
font-size: 11px;
cursor: pointer;
transition: background 0.1s;
}
.comp-item:hover {
background: var(--surface-hover);
color: var(--green);
}
.comp-item.selected {
background: rgba(134, 168, 129, 0.12);
border-left: 3px solid var(--green);
}
.comp-sub {
color: var(--text-subtle);
font-size: 10px;
}
.net-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 16px;
border-bottom: 1px solid var(--border-subtle);
font-size: 11px;
cursor: pointer;
color: var(--text-body);
}
.net-item:hover {
background: var(--surface-hover);
color: var(--blue);
}
.net-item.selected {
background: rgba(56, 189, 248, 0.12);
border-left: 3px solid var(--blue);
}
.diag-card {
background: rgba(244, 63, 94, 0.06);
border: 1px solid rgba(244, 63, 94, 0.3);
padding: 10px;
margin: 12px 16px;
font-size: 11px;
}
.diag-title {
color: var(--alert);
margin-bottom: 4px;
display: flex;
align-items: center;
gap: 6px;
font-weight: bold;
}
.diag-body {
color: var(--text-body);
line-height: 1.4;
white-space: pre-wrap;
}
.diag-help {
color: var(--olive);
margin-top: 4px;
line-height: 1.3;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24px 8px;
color: var(--text-subtle);
font-size: 11px;
letter-spacing: 0.05em;
text-align: center;
}
.hud-toast {
position: absolute;
bottom: 16px;
left: 16px;
background: rgba(19, 19, 19, 0.9);
border: 1px solid var(--border);
padding: 6px 12px;
font-size: 11px;
color: var(--text-subtle);
display: flex;
align-items: center;
gap: 8px;
pointer-events: none;
z-index: 10;
}
.status-dot {
width: 6px;
height: 6px;
background: var(--green);
}
.hover-tooltip {
position: fixed;
z-index: 1000;
pointer-events: none;
background: rgba(19, 19, 19, 0.95);
border: 1px solid var(--border);
padding: 8px 12px;
font-size: 11px;
line-height: 1.4;
color: var(--text);
max-width: 280px;
box-shadow: 0 4px 16px rgba(0,0,0,0.7);
backdrop-filter: blur(4px);
display: none;
}
.tooltip-title {
font-weight: bold;
color: var(--green);
margin-bottom: 2px;
letter-spacing: 0.05em;
}
.tooltip-meta {
color: var(--olive);
font-size: 10px;
margin-bottom: 4px;
}
.tooltip-row {
display: flex;
justify-content: space-between;
gap: 12px;
color: var(--text-subtle);
font-size: 10px;
margin-top: 2px;
}
.tooltip-val {
color: var(--text-body);
font-family: monospace;
}
</style>
</head>
<body>
<header>
<div class="brand">
<span class="brand-title">FUSIBLE</span>
<span class="brand-sub">// STUDIO</span>
<span class="board-name" id="board-name">BOARD PREVIEW</span>
<div id="fuse-status" class="fuse-badge fuse-intact">
<span id="fuse-text">[OK] FUSE INTACT</span>
</div>
</div>
<div class="tabs">
<button class="tab-btn active" data-view="pcb" onclick="switchView('pcb')">01 // PCB LAYOUT</button>
<button class="tab-btn" data-view="schematic" onclick="switchView('schematic')">02 // SCHEMATIC</button>
<button class="tab-btn" data-view="3d" onclick="switchView('3d')">03 // 3D VIEW</button>
</div>
<div class="controls" id="controls-2d">
<button class="btn" onclick="zoomIn()">[ZOOM +]</button>
<button class="btn" onclick="zoomOut()">[ZOOM -]</button>
<button class="btn" onclick="resetZoom()">[RESET]</button>
</div>
<div class="controls" id="controls-3d" style="display: none;">
<button class="btn" onclick="setCameraPreset('TOP')">[TOP]</button>
<button class="btn" onclick="setCameraPreset('ISO')">[ISO]</button>
<button class="btn" onclick="setCameraPreset('FRONT')">[FRONT]</button>
<button class="btn" onclick="setCameraPreset('RESET')">[RESET]</button>
</div>
</header>
<main>
<div id="viewport-container">
<div id="viewport-grid"></div>
<div id="canvas-content"></div>
<div id="three-container"></div>
<div class="hud-toast">
<div class="status-dot" id="live-dot"></div>
<span id="live-status">[LIVE] SSE CONNECTED</span>
</div>
<div id="hover-tooltip" class="hover-tooltip"></div>
</div>
<aside id="sidebar">
<div class="sidebar-tabs">
<button class="sidebar-tab-btn active" onclick="switchSidebarTab('inspector')">INSPECT</button>
<button class="sidebar-tab-btn" onclick="switchSidebarTab('components')">PARTS</button>
<button class="sidebar-tab-btn" onclick="switchSidebarTab('nets')">NETS</button>
<button class="sidebar-tab-btn" onclick="switchSidebarTab('layers')">LAYERS</button>
<button class="sidebar-tab-btn" onclick="switchSidebarTab('checks')">CHECKS</button>
</div>
<div class="sidebar-pane active" id="pane-inspector">
<div id="inspector-content" class="inspector-card">
<div class="empty-state">
<span>SELECT OR HOVER A COMPONENT OR PAD TO INSPECT</span>
</div>
</div>
</div>
<div class="sidebar-pane" id="pane-components">
<div id="components-list">
<div class="empty-state"><span>NO COMPONENTS PARSED</span></div>
</div>
</div>
<div class="sidebar-pane" id="pane-nets">
<div id="nets-list">
<div class="empty-state"><span>NO NETS DETECTED</span></div>
</div>
</div>
<div class="sidebar-pane" id="pane-layers">
<div class="sidebar-section">
<div class="section-title">PCB COPPER & MASKS</div>
<div class="layer-item">
<div class="layer-label">
<span class="layer-color-dot" style="background: var(--copper);"></span>
<span>TOP COPPER</span>
</div>
<input type="checkbox" class="toggle-checkbox" checked onchange="toggleLayer('top_copper', this.checked)">
</div>
<div class="layer-item">
<div class="layer-label">
<span class="layer-color-dot" style="background: var(--blue);"></span>
<span>BOTTOM COPPER</span>
</div>
<input type="checkbox" class="toggle-checkbox" checked onchange="toggleLayer('bottom_copper', this.checked)">
</div>
<div class="layer-item">
<div class="layer-label">
<span class="layer-color-dot" style="background: var(--gold);"></span>
<span>PADS (ENIG GOLD)</span>
</div>
<input type="checkbox" class="toggle-checkbox" checked onchange="toggleLayer('pads', this.checked)">
</div>
<div class="layer-item">
<div class="layer-label">
<span class="layer-color-dot" style="background: #ffffff;"></span>
<span>SILKSCREEN</span>
</div>
<input type="checkbox" class="toggle-checkbox" checked onchange="toggleLayer('silkscreen', this.checked)">
</div>
<div class="layer-item">
<div class="layer-label">
<span class="layer-color-dot" style="background: var(--border);"></span>
<span>HOLES & VIAS</span>
</div>
<input type="checkbox" class="toggle-checkbox" checked onchange="toggleLayer('holes_and_vias', this.checked)">
</div>
</div>
</div>
<div class="sidebar-pane" id="pane-checks">
<div id="diagnostics-panel">
<div class="empty-state">
<span>NO ELECTRICAL VIOLATIONS DETECTED.</span>
</div>
</div>
</div>
</aside>
</main>
<script>
let currentView = 'pcb';
let pcbSvg = '';
let schematicSvg = '';
let lastCircuitJson = null;
let zoomLevel = 1.0;
let panX = 0;
let panY = 0;
let isDragging = false;
let startX = 0;
let startY = 0;
const viewport = document.getElementById('viewport-container');
const canvas = document.getElementById('canvas-content');
const tooltip = document.getElementById('hover-tooltip');
let scene = null;
let camera = null;
let renderer = null;
let controls = null;
let boardGroup = null;
let threeInitialized = false;
let boardWidth = 60;
let boardHeight = 40;
let boardCenterX = 0;
let boardCenterZ = 0;
let componentsMap = {};
let netsMap = {};
let selectedComponent = null;
function applyTransform() {
canvas.style.transform = `translate(${panX}px, ${panY}px) scale(${zoomLevel})`;
}
function zoomIn() {
zoomLevel *= 1.25;
applyTransform();
}
function zoomOut() {
zoomLevel /= 1.25;
applyTransform();
}
function resetZoom() {
zoomLevel = 1.0;
panX = 0;
panY = 0;
applyTransform();
}
viewport.addEventListener('wheel', (e) => {
if (currentView === '3d') return;
e.preventDefault();
const zoomFactor = e.deltaY < 0 ? 1.15 : 0.85;
zoomLevel *= zoomFactor;
applyTransform();
}, { passive: false });
viewport.addEventListener('mousedown', (e) => {
if (currentView === '3d') return;
if (e.button === 0) {
isDragging = true;
startX = e.clientX - panX;
startY = e.clientY - panY;
}
});
window.addEventListener('mousemove', (e) => {
if (currentView === '3d') return;
if (isDragging) {
panX = e.clientX - startX;
panY = e.clientY - startY;
applyTransform();
}
});
window.addEventListener('mouseup', () => {
isDragging = false;
});
function switchView(view) {
currentView = view;
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.classList.toggle('active', btn.getAttribute('data-view') === view);
});
const is3d = view === '3d';
document.getElementById('canvas-content').style.display = is3d ? 'none' : 'inline-block';
document.getElementById('three-container').style.display = is3d ? 'block' : 'none';
document.getElementById('controls-2d').style.display = is3d ? 'none' : 'flex';
document.getElementById('controls-3d').style.display = is3d ? 'flex' : 'none';
hideTooltip();
if (is3d) {
initThree();
build3DScene();
setTimeout(onThreeResize, 10);
setCameraPreset('ISO');
} else {
renderCurrentView();
}
}
function switchSidebarTab(tabId) {
document.querySelectorAll('.sidebar-tab-btn').forEach(btn => {
btn.classList.toggle('active', btn.getAttribute('onclick').includes(tabId));
});
document.querySelectorAll('.sidebar-pane').forEach(p => p.classList.remove('active'));
const activePane = document.getElementById(`pane-${tabId}`);
if (activePane) activePane.classList.add('active');
}
function renderCurrentView() {
if (currentView === 'pcb') {
canvas.innerHTML = pcbSvg || '<div class="empty-state">NO PCB LAYOUT AVAILABLE</div>';
setup2DInteractivity();
} else if (currentView === 'schematic') {
canvas.innerHTML = schematicSvg || '<div class="empty-state">NO SCHEMATIC AVAILABLE</div>';
setup2DInteractivity();
}
}
function toggleLayer(layerName, visible) {
const groups = canvas.querySelectorAll(`g[id="${layerName}"]`);
groups.forEach(g => {
g.style.display = visible ? '' : 'none';
});
}
function showTooltip(x, y, data) {
if (!data) return;
let html = `<div class="tooltip-title">${escapeHtml(data.name || data.ref || 'ELEMENT')}</div>`;
if (data.type) html += `<div class="tooltip-meta">${escapeHtml(data.type.toUpperCase())}</div>`;
if (data.part) html += `<div class="tooltip-row"><span>MPN:</span><span class="tooltip-val">${escapeHtml(data.part)}</span></div>`;
if (data.net) html += `<div class="tooltip-row"><span>NET:</span><span class="tooltip-val" style="color:var(--blue)">${escapeHtml(data.net)}</span></div>`;
if (data.pos) html += `<div class="tooltip-row"><span>POS:</span><span class="tooltip-val">${data.pos}</span></div>`;
if (data.layer) html += `<div class="tooltip-row"><span>LAYER:</span><span class="tooltip-val">${escapeHtml(data.layer)}</span></div>`;
if (data.extra) html += `<div class="tooltip-row"><span>PINS:</span><span class="tooltip-val">${data.extra}</span></div>`;
tooltip.innerHTML = html;
tooltip.style.left = `${x + 14}px`;
tooltip.style.top = `${y + 14}px`;
tooltip.style.display = 'block';
}
function hideTooltip() {
tooltip.style.display = 'none';
}
function setup2DInteractivity() {
const svg = canvas.querySelector('svg');
if (!svg) return;
svg.addEventListener('mousemove', (e) => {
if (isDragging) {
hideTooltip();
return;
}
const rect = svg.getBoundingClientRect();
const pt = svg.createSVGPoint();
pt.x = e.clientX;
pt.y = e.clientY;
const svgP = pt.matrixTransform(svg.getScreenCTM().inverse());
const circX = svgP.x;
const circY = -svgP.y;
const comp = findClosestComponent(circX, circY);
if (comp) {
showTooltip(e.clientX, e.clientY, {
name: comp.name,
type: comp.ftype || 'component',
part: comp.mpn,
pos: `(${comp.x.toFixed(2)}, ${comp.y.toFixed(2)}) mm`,
layer: comp.layer || 'top',
extra: `${comp.pins.length} pins`
});
return;
}
const pad = findClosestPad(circX, circY);
if (pad) {
showTooltip(e.clientX, e.clientY, {
name: `PAD ${pad.id}`,
type: 'smtpad',
net: pad.net,
pos: `(${pad.x.toFixed(2)}, ${pad.y.toFixed(2)}) mm`,
layer: pad.layer
});
return;
}
hideTooltip();
});
svg.addEventListener('mouseleave', hideTooltip);
svg.addEventListener('click', (e) => {
const pt = svg.createSVGPoint();
pt.x = e.clientX;
pt.y = e.clientY;
const svgP = pt.matrixTransform(svg.getScreenCTM().inverse());
const comp = findClosestComponent(svgP.x, -svgP.y);
if (comp) {
selectComponent(comp.name);
}
});
}
function findClosestComponent(x, y) {
for (const name in componentsMap) {
const c = componentsMap[name];
const halfW = (c.w || 4) / 2;
const halfH = (c.h || 4) / 2;
if (x >= c.x - halfW && x <= c.x + halfW && y >= c.y - halfH && y <= c.y + halfH) {
return c;
}
}
return null;
}
function findClosestPad(x, y) {
const elements = getElementsArray();
for (const e of elements) {
if (e.type === 'pcb_smtpad') {
const px = e.x ?? e.center?.x ?? 0;
const py = e.y ?? e.center?.y ?? 0;
const pw = e.width || 1.0;
const ph = e.height || 1.0;
if (Math.abs(x - px) <= pw / 2 && Math.abs(y - py) <= ph / 2) {
return {
id: e.pcb_smtpad_id || e.port_hints?.[0] || '1',
x: px,
y: py,
layer: e.layer || 'top',
net: e.net || e.subcircuit_id || 'SIGNAL'
};
}
}
}
return null;
}
function getElementsArray() {
if (!lastCircuitJson) return [];
if (Array.isArray(lastCircuitJson)) return lastCircuitJson;
return Array.isArray(lastCircuitJson.elements) ? lastCircuitJson.elements : [];
}
function parseCircuitGraph() {
componentsMap = {};
netsMap = {};
const elements = getElementsArray();
const sourceCompMap = {};
elements.filter(e => e.type === 'source_component').forEach(sc => {
sourceCompMap[sc.source_component_id || sc.component_id] = {
name: sc.name || sc.component_id || 'U',
mpn: sc.manufacturer_part_number || '',
ftype: sc.ftype || sc.kind || ''
};
});
const portNetMap = {};
elements.filter(e => e.type === 'source_port').forEach(sp => {
portNetMap[sp.source_port_id || sp.port_id] = {
name: sp.name || '',
pin: sp.pin_number || ''
};
});
elements.filter(e => e.type === 'source_net').forEach(sn => {
const netName = sn.name || sn.net_id || 'NET';
netsMap[netName] = {
name: netName,
count: 0
};
});
elements.filter(e => e.type === 'pcb_trace').forEach(t => {
const netName = t.connection_name;
if (netName) {
if (!netsMap[netName]) netsMap[netName] = { name: netName, count: 0 };
netsMap[netName].count++;
}
});
elements.filter(e => e.type === 'pcb_component').forEach(pc => {
const src = sourceCompMap[pc.source_component_id] || {};
const name = src.name || pc.pcb_component_id || 'COMP';
const cx = pc.center?.x ?? pc.x ?? 0;
const cy = pc.center?.y ?? pc.y ?? 0;
const cw = pc.width || 4;
const ch = pc.height || 4;
componentsMap[name] = {
name,
mpn: src.mpn,
ftype: src.ftype,
x: cx,
y: cy,
w: cw,
h: ch,
layer: pc.layer || 'top',
id: pc.pcb_component_id,
pins: []
};
});
if (Object.keys(componentsMap).length === 0) {
Object.entries(sourceCompMap).forEach(([id, src]) => {
const name = src.name || id;
componentsMap[name] = {
name,
mpn: src.mpn,
ftype: src.ftype,
x: 0,
y: 0,
w: 4,
h: 4,
layer: 'top',
id,
pins: []
};
});
}
elements.filter(e => e.type === 'schematic_component').forEach(sc => {
const src = sourceCompMap[sc.source_component_id] || {};
const name = src.name;
if (name && componentsMap[name] && sc.port_labels) {
for (const pin in sc.port_labels) {
componentsMap[name].pins.push({
pin,
net: sc.port_labels[pin]
});
}
}
});
renderComponentsList();
renderNetsList();
}
function renderComponentsList() {
const container = document.getElementById('components-list');
const names = Object.keys(componentsMap).sort();
if (names.length === 0) {
container.innerHTML = '<div class="empty-state"><span>NO COMPONENTS PARSED</span></div>';
return;
}
container.innerHTML = names.map(name => {
const c = componentsMap[name];
return `
<div class="comp-item ${selectedComponent === name ? 'selected' : ''}" onclick="selectComponent('${escapeHtml(name)}')">
<div>
<div style="font-weight:bold">${escapeHtml(name)}</div>
<div class="comp-sub">${escapeHtml(c.mpn || c.ftype || '')}</div>
</div>
<div class="comp-sub">(${c.x.toFixed(1)}, ${c.y.toFixed(1)})</div>
</div>
`;
}).join('');
}
function renderNetsList() {
const container = document.getElementById('nets-list');
const names = Object.keys(netsMap).sort();
if (names.length === 0) {
container.innerHTML = '<div class="empty-state"><span>NO NETS DETECTED</span></div>';
return;
}
container.innerHTML = names.map(name => `
<div class="net-item" onclick="selectNet('${escapeHtml(name)}')">
<span style="font-weight:bold">${escapeHtml(name)}</span>
<span style="color:var(--text-subtle);font-size:10px">${netsMap[name].count} segments</span>
</div>
`).join('');
}
function selectComponent(name) {
selectedComponent = name;
renderComponentsList();
const comp = componentsMap[name];
if (!comp) return;
switchSidebarTab('inspector');
const panel = document.getElementById('inspector-content');
let pinsHtml = '';
if (comp.pins && comp.pins.length > 0) {
pinsHtml = `
<div class="pins-list">
<div class="pins-title">CONNECTED PINS & SIGNALS</div>
${comp.pins.map(p => `
<div class="pin-item" onclick="selectNet('${escapeHtml(p.net)}')">
<span>${escapeHtml(p.pin)}</span>
<span class="pin-net">${escapeHtml(p.net)}</span>
</div>
`).join('')}
</div>
`;
}
panel.innerHTML = `
<div class="inspector-header">
<span class="inspector-name">${escapeHtml(comp.name)}</span>
<span class="inspector-type">${escapeHtml(comp.ftype || 'COMPONENT')}</span>
</div>
${comp.mpn ? `<div class="prop-row"><span class="prop-key">MPN</span><span class="prop-val">${escapeHtml(comp.mpn)}</span></div>` : ''}
<div class="prop-row"><span class="prop-key">CENTER</span><span class="prop-val">(${comp.x.toFixed(2)}, ${comp.y.toFixed(2)}) mm</span></div>
<div class="prop-row"><span class="prop-key">FOOTPRINT</span><span class="prop-val">${comp.w.toFixed(1)} x ${comp.h.toFixed(1)} mm</span></div>
<div class="prop-row"><span class="prop-key">LAYER</span><span class="prop-val">${escapeHtml(comp.layer.toUpperCase())}</span></div>
${pinsHtml}
`;
if (currentView === '3d' && boardGroup) {
boardGroup.children.forEach(obj => {
if (obj.userData && obj.userData.name === name && obj.material) {
obj.material.emissive = new THREE.Color(0x86A881);
obj.material.emissiveIntensity = 0.8;
}
});
}
}
function selectNet(netName) {
document.querySelectorAll('.net-item').forEach(el => {
el.classList.toggle('selected', el.textContent.includes(netName));
});
showTooltip(window.innerWidth / 2, 80, {
name: `NET: ${netName}`,
type: 'SIGNAL TRACE HIGHLIGHT',
net: netName
});
setTimeout(hideTooltip, 2000);
}
function updateDiagnostics(fuseBlown, diagnostics) {
const statusEl = document.getElementById('fuse-status');
const textEl = document.getElementById('fuse-text');
const diagPanel = document.getElementById('diagnostics-panel');
if (fuseBlown) {
statusEl.className = 'fuse-badge fuse-blown';
textEl.textContent = `[BLOWN] FUSE BLOWN (${diagnostics.length})`;
if (diagnostics.length > 0) {
diagPanel.innerHTML = diagnostics.map(d => `
<div class="diag-card">
<div class="diag-title">[!] ${escapeHtml((d.rule || 'VIOLATION').toUpperCase())}</div>
<div class="diag-body">${escapeHtml(d.message || '')}</div>
${d.help ? `<div class="diag-help">> ${escapeHtml(d.help)}</div>` : ''}
</div>
`).join('');
}
} else {
statusEl.className = 'fuse-badge fuse-intact';
textEl.textContent = '[OK] FUSE INTACT';
diagPanel.innerHTML = '<div class="empty-state"><span>NO ELECTRICAL VIOLATIONS DETECTED.</span></div>';
}
}
function escapeHtml(str) {
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
}
function onThreeResize() {
if (!renderer || !camera) return;
const container = document.getElementById('three-container');
const w = container.clientWidth;
const h = container.clientHeight;
if (w === 0 || h === 0) return;
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h);
}
let raycaster = null;
let mouseVector = null;
let hoveredObject = null;
let originalColor = null;
function initThree() {
if (threeInitialized) return;
const container = document.getElementById('three-container');
const width = container.clientWidth || window.innerWidth;
const height = container.clientHeight || window.innerHeight;
scene = new THREE.Scene();
scene.background = new THREE.Color(0x000000);
camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 2000);
camera.position.set(0, 100, 100);
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setSize(width, height);
renderer.setPixelRatio(window.devicePixelRatio || 1);
container.appendChild(renderer.domElement);
controls = new THREE.OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
const ambientLight = new THREE.AmbientLight(0xffffff, 0.75);
scene.add(ambientLight);
const dirLight1 = new THREE.DirectionalLight(0xffffff, 0.9);
dirLight1.position.set(60, 120, 80);
scene.add(dirLight1);
const dirLight2 = new THREE.DirectionalLight(0x8899aa, 0.5);
dirLight2.position.set(-60, -80, -60);
scene.add(dirLight2);
const grid = new THREE.GridHelper(200, 40, 0x353535, 0x131313);
grid.position.y = -1.0;
scene.add(grid);
boardGroup = new THREE.Group();
scene.add(boardGroup);
raycaster = new THREE.Raycaster();
mouseVector = new THREE.Vector2();
container.addEventListener('mousemove', onThreeMouseMove);
container.addEventListener('click', onThreeClick);
function animate() {
requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
}
animate();
window.addEventListener('resize', onThreeResize);
threeInitialized = true;
}
function onThreeMouseMove(e) {
if (currentView !== '3d' || !renderer || !camera || !boardGroup) return;
const container = document.getElementById('three-container');
const rect = container.getBoundingClientRect();
mouseVector.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
mouseVector.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(mouseVector, camera);
const intersects = raycaster.intersectObjects(boardGroup.children, true);
const hit = intersects.find(i => i.object.userData && i.object.userData.type);
if (hit) {
if (hoveredObject !== hit.object) {
clearThreeHighlight();
hoveredObject = hit.object;
if (hoveredObject.material && hoveredObject.material.emissive) {
originalColor = hoveredObject.material.emissive.getHex();
hoveredObject.material.emissive.setHex(0x38bdf8);
hoveredObject.material.emissiveIntensity = 0.8;
}
}
showTooltip(e.clientX, e.clientY, hoveredObject.userData);
container.style.cursor = 'pointer';
} else {
clearThreeHighlight();
hideTooltip();
container.style.cursor = 'default';
}
}
function onThreeClick() {
if (hoveredObject && hoveredObject.userData && hoveredObject.userData.name) {
selectComponent(hoveredObject.userData.name);
}
}
function clearThreeHighlight() {
if (hoveredObject && hoveredObject.material && hoveredObject.material.emissive) {
hoveredObject.material.emissive.setHex(originalColor || 0x000000);
hoveredObject.material.emissiveIntensity = 0.0;
}
hoveredObject = null;
}
function setCameraPreset(preset) {
if (!controls || !camera) return;
const dist = Math.max(boardWidth, boardHeight) * 1.8;
const tx = boardCenterX;
const tz = boardCenterZ;
controls.target.set(tx, 0, tz);
if (preset === 'TOP') {
camera.position.set(tx, dist, tz + 0.001);
camera.up.set(0, 0, -1);
} else if (preset === 'FRONT') {
camera.position.set(tx, dist * 0.1, tz + dist);
camera.up.set(0, 1, 0);
} else if (preset === 'ISO' || preset === 'RESET') {
camera.position.set(tx + dist * 0.7, dist * 0.8, tz + dist * 0.7);
camera.up.set(0, 1, 0);
}
controls.update();
}
function makeTextSprite(message) {
const c = document.createElement('canvas');
c.width = 256;
c.height = 64;
const ctx = c.getContext('2d');
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 32px monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(message, 128, 32);
const tex = new THREE.CanvasTexture(c);
tex.minFilter = THREE.LinearFilter;
const mat = new THREE.SpriteMaterial({ map: tex, transparent: true });
const sprite = new THREE.Sprite(mat);
const aspect = c.width / c.height;
sprite.scale.set(1.5 * aspect, 1.5, 1);
return sprite;
}
function build3DScene() {
if (!boardGroup) return;
while (boardGroup.children.length > 0) {
const obj = boardGroup.children[0];
boardGroup.remove(obj);
if (obj.geometry) obj.geometry.dispose();
}
const elements = getElementsArray();
const boards = elements.filter(e => e.type === 'pcb_board');
boardWidth = 60;
boardHeight = 40;
boardCenterX = 0;
boardCenterZ = 0;
if (boards.length > 0) {
const b = boards[0];
boardWidth = b.width || 60;
boardHeight = b.height || 40;
boardCenterX = b.center?.x || 0;
boardCenterZ = b.center?.y || 0;
} else {
let minX = Infinity, maxX = -Infinity, minZ = Infinity, maxZ = -Infinity;
elements.forEach(e => {
const x = e.x ?? e.center?.x;
const z = e.y ?? e.center?.y;
if (x !== undefined && z !== undefined) {
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (z < minZ) minZ = z;
if (z > maxZ) maxZ = z;
}
});
if (minX !== Infinity) {
boardWidth = Math.max(20, (maxX - minX) + 20);
boardHeight = Math.max(20, (maxZ - minZ) + 20);
boardCenterX = (minX + maxX) / 2;
boardCenterZ = (minZ + maxZ) / 2;
}
}
const substrateThickness = 1.6;
const boardMat = new THREE.MeshStandardMaterial({
color: 0x112417,
roughness: 0.5,
metalness: 0.1
});
const boardGeo = new THREE.BoxGeometry(boardWidth, substrateThickness, boardHeight);
const boardMesh = new THREE.Mesh(boardGeo, boardMat);
boardMesh.position.set(boardCenterX, 0, boardCenterZ);
boardGroup.add(boardMesh);
const padMat = new THREE.MeshStandardMaterial({
color: 0xd4af37,
metalness: 0.85,
roughness: 0.25
});
elements.filter(e => e.type === 'pcb_smtpad').forEach(pad => {
const px = pad.x ?? pad.center?.x ?? 0;
const pz = pad.y ?? pad.center?.y ?? 0;
const isBottom = pad.layer === 'bottom';
const py = isBottom ? -(substrateThickness / 2 + 0.02) : (substrateThickness / 2 + 0.02);
const shape = pad.shape || (pad.radius ? 'circle' : 'rect');
let padMesh;
if (shape === 'circle') {
const r = pad.radius || ((pad.width || 1.0) / 2);
const geo = new THREE.CylinderGeometry(r, r, 0.04, 24);
padMesh = new THREE.Mesh(geo, padMat.clone());
} else {
const pw = pad.width || 1.2;
const ph = pad.height || 1.2;
const geo = new THREE.BoxGeometry(pw, 0.04, ph);
padMesh = new THREE.Mesh(geo, padMat.clone());
}
padMesh.position.set(px, py, pz);
padMesh.userData = {
type: 'smtpad',
name: pad.port_hints?.[0] ? `PAD ${pad.port_hints[0]}` : 'PAD',
pos: `(${px.toFixed(2)}, ${pz.toFixed(2)}) mm`,
layer: pad.layer || 'top'
};
boardGroup.add(padMesh);
});
const viaMat = new THREE.MeshStandardMaterial({
color: 0xc87d32,
metalness: 0.8,
roughness: 0.3
});
const holeMat = new THREE.MeshBasicMaterial({ color: 0x050505 });
elements.filter(e => e.type === 'pcb_via' || e.type === 'pcb_plated_hole').forEach(via => {
const vx = via.x ?? via.center?.x ?? 0;
const vz = via.y ?? via.center?.y ?? 0;
const outerR = ((via.outer_diameter || 0.6) / 2);
const holeR = ((via.hole_diameter || 0.3) / 2);
const viaGeo = new THREE.CylinderGeometry(outerR, outerR, substrateThickness + 0.06, 24);
const viaMesh = new THREE.Mesh(viaGeo, viaMat.clone());
viaMesh.position.set(vx, 0, vz);
viaMesh.userData = {
type: 'via',
name: via.type === 'pcb_via' ? 'VIA' : 'PLATED HOLE',
pos: `(${vx.toFixed(2)}, ${vz.toFixed(2)}) mm`
};
boardGroup.add(viaMesh);
const holeGeo = new THREE.CylinderGeometry(holeR, holeR, substrateThickness + 0.08, 16);
const holeMesh = new THREE.Mesh(holeGeo, holeMat);
holeMesh.position.set(vx, 0, vz);
boardGroup.add(holeMesh);
});
elements.filter(e => e.type === 'pcb_trace').forEach(trace => {
const route = trace.route || [];
if (route.length < 2) return;
const isBottom = route[0].layer === 'bottom' || trace.layer === 'bottom';
const color = isBottom ? 0x38bdf8 : 0xc87d32;
const py = isBottom ? -(substrateThickness / 2 + 0.03) : (substrateThickness / 2 + 0.03);
const traceMat = new THREE.MeshStandardMaterial({
color: color,
metalness: 0.8,
roughness: 0.35,
side: THREE.DoubleSide
});
for (let i = 0; i < route.length - 1; i++) {
const p1 = route[i];
const p2 = route[i + 1];
const w = p1.width || trace.width || 0.3;
const dx = p2.x - p1.x;
const dz = p2.y - p1.y;
const len = Math.sqrt(dx * dx + dz * dz);
if (len < 0.001) continue;
const angle = Math.atan2(dz, dx);
const geo = new THREE.PlaneGeometry(len, w);
geo.rotateX(-Math.PI / 2);
geo.rotateY(-angle);
const mesh = new THREE.Mesh(geo, traceMat);
mesh.position.set((p1.x + p2.x) / 2, py, (p1.y + p2.y) / 2);
mesh.userData = {
type: 'trace',
net: trace.connection_name || 'SIGNAL'
};
boardGroup.add(mesh);
}
});
const chipMat = new THREE.MeshStandardMaterial({
color: 0x1a1a1a,
roughness: 0.6,
metalness: 0.2
});
const pinMat = new THREE.MeshStandardMaterial({
color: 0xe2e8f0,
roughness: 0.2,
metalness: 0.9
});
elements.filter(e => e.type === 'pcb_component').forEach(comp => {
const cx = comp.center?.x ?? comp.x ?? 0;
const cz = comp.center?.y ?? comp.y ?? 0;
const cw = comp.width || 6;
const ch = comp.height || 6;
const cThick = 1.6;
const isBottom = comp.layer === 'bottom';
const cy = isBottom ? -(substrateThickness / 2 + cThick / 2 + 0.05) : (substrateThickness / 2 + cThick / 2 + 0.05);
const compName = Object.keys(componentsMap).find(k => componentsMap[k].id === comp.pcb_component_id) || comp.pcb_component_id || 'U';
const compData = componentsMap[compName] || {};
const chipGeo = new THREE.BoxGeometry(cw, cThick, ch);
const chipMesh = new THREE.Mesh(chipGeo, chipMat.clone());
chipMesh.position.set(cx, cy, cz);
chipMesh.userData = {
type: 'component',
name: compName,
part: compData.mpn || compData.ftype || 'IC',
pos: `(${cx.toFixed(2)}, ${cz.toFixed(2)}) mm`,
layer: comp.layer || 'top',
extra: `${compData.pins?.length || 0} pins`
};
boardGroup.add(chipMesh);
const sprite = makeTextSprite(compName);
sprite.position.set(cx, cy + (isBottom ? -cThick / 2 - 0.4 : cThick / 2 + 0.4), cz);
boardGroup.add(sprite);
const pinW = 0.6;
const pinLen = 1.0;
const pinH = 0.2;
const pinY = isBottom ? -(substrateThickness / 2 + 0.05) : (substrateThickness / 2 + 0.05);
const numPins = 4;
for (let p = 0; p < numPins; p++) {
const offset = (p - (numPins - 1) / 2) * (ch / numPins);
const leftPin = new THREE.Mesh(new THREE.BoxGeometry(pinLen, pinH, pinW), pinMat);
leftPin.position.set(cx - cw / 2, pinY, cz + offset);
boardGroup.add(leftPin);
const rightPin = new THREE.Mesh(new THREE.BoxGeometry(pinLen, pinH, pinW), pinMat);
rightPin.position.set(cx + cw / 2, pinY, cz + offset);
boardGroup.add(rightPin);
}
});
}
function updateStudio(data) {
if (!data) return;
if (data.board_name) {
document.getElementById('board-name').textContent = data.board_name.toUpperCase();
}
if (data.pcb_svg) pcbSvg = data.pcb_svg;
if (data.schematic_svg) schematicSvg = data.schematic_svg;
if (data.circuit_json) {
lastCircuitJson = data.circuit_json;
parseCircuitGraph();
if (currentView === '3d') {
build3DScene();
}
}
updateDiagnostics(data.fuse_blown, data.diagnostics || []);
if (currentView !== '3d') {
renderCurrentView();
}
}
function fetchInitialData() {
fetch('/api/circuit')
.then(res => {
if (!res.ok) throw new Error('Network error');
return res.json();
})
.then(data => updateStudio(data))
.catch(() => {});
}
function connectSSE() {
const eventSource = new EventSource('/events');
const liveDot = document.getElementById('live-dot');
const liveStatus = document.getElementById('live-status');
eventSource.onopen = () => {
liveDot.style.background = 'var(--green)';
liveStatus.textContent = '[LIVE] SSE CONNECTED';
};
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
updateStudio(data);
} catch (err) {
console.error(err);
}
};
eventSource.onerror = () => {
liveDot.style.background = 'var(--alert)';
liveStatus.textContent = '[WAIT] RECONNECTING...';
};
}
fetchInitialData();
connectSSE();
</script>
</body>
</html>