<!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>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='24' height='24'%3E%3Cpath fill='%2386A881' d='M5 3h14v2H5zm0 16h14v2H5zM3 5h2v14H3zm16 0h2v14h-2zM7 7h10v2H7zm0 8h10v2H7zm0-6h2v6H7zm8 0h2v6h-2zm-4-8h2v2h-2zm0 20h2v2h-2zM1 11h2v2H1zm20 0h2v2h-2zm0-4h2v2h-2zm0 8h2v2h-2zM1 15h2v2H1zm0-8h2v2H1zm6-6h2v2H7zm8 0h2v2h-2zm0 20h2v2h-2zm-8 0h2v2H7z'/%3E%3C/svg%3E">
<script type="module" src="/studio.bundle.js"></script>
<style>
.tscircuit-schematic {
background-color: #0d1117 !important;
}
.tscircuit-schematic .boundary {
fill: #0d1117 !important;
}
.tscircuit-schematic text {
fill: #f1f5f9 !important;
font-family: 'Undefined', monospace !important;
}
.tscircuit-schematic .component-name,
.tscircuit-schematic .port-label,
.tscircuit-schematic .reference {
fill: #86A881 !important;
font-weight: bold !important;
}
.tscircuit-schematic .net-label,
.tscircuit-schematic .sch-net-label {
fill: #1F1F1F !important;
stroke: #f59e0b !important;
}
.tscircuit-schematic .sch-net-label-text,
.tscircuit-schematic text.net-label {
fill: #f59e0b !important;
}
#pcb-container,
#pcb-container > div,
#pcb-container canvas,
#schematic-container,
#schematic-container > div {
width: 100% !important;
height: 100% !important;
display: block;
}
@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: #1F1F1F;
--surface-hover: #2B2B2B;
--border: #353535;
--border-subtle: #222222;
--green: #86A881;
--green-dark: #229147;
--olive: #A8B365;
--text: #FFFFFF;
--text-body: #FFFFFF;
--text-subtle: rgba(255, 255, 255, 0.55);
--alert: #C55454;
--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;
}
#sidebar, #sidebar *, .sidebar-pane, .sidebar-pane *, .check-item, .check-item *, code, pre {
user-select: text !important;
-webkit-user-select: text !important;
}
header {
height: 56px;
background: var(--surface);
border-bottom: none;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
z-index: 20;
}
.brand {
display: flex;
align-items: center;
gap: 16px;
}
.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: none;
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: none;
}
.fuse-intact {
background: rgba(34, 145, 71, 0.15);
color: var(--green);
}
.fuse-blown {
background: rgba(244, 63, 94, 0.15);
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: none;
gap: 4px;
}
.tab-btn {
background: transparent;
border: none;
color: var(--text-subtle);
padding: 8px 16px;
font-family: 'Undefined', monospace;
font-size: 11px;
letter-spacing: 0.05em;
cursor: pointer;
transition: color 0.15s ease, background 0.15s ease;
display: inline-flex;
align-items: center;
gap: 8px;
}
.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: 8px;
}
.btn {
background: var(--surface);
border: none;
color: var(--text-body);
font-family: 'Undefined', monospace;
padding: 7px 14px;
font-size: 11px;
letter-spacing: 0.05em;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
}
.btn:hover {
background: var(--surface-hover);
color: var(--text);
}
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: none;
display: flex;
flex-direction: column;
z-index: 10;
}
.sidebar-tabs {
display: flex;
border-bottom: none;
background: var(--bg);
}
.sidebar-tab-btn {
flex: 1;
padding: 10px 4px;
background: transparent;
border: none;
color: var(--text-subtle);
font-family: 'Undefined', monospace;
font-size: 10px;
letter-spacing: 0.05em;
cursor: pointer;
border-right: none;
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: none;
}
.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: none;
}
.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: none;
}
.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: none;
}
.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: none;
margin-bottom: 3px;
cursor: pointer;
}
.pin-item:hover {
color: var(--blue);
}
.pin-net {
color: var(--blue);
}
.comp-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 16px;
border-bottom: none;
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: none;
}
.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: none;
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: none;
}
.diag-card {
background: rgba(244, 63, 94, 0.06);
border: none;
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(31, 31, 31, 0.95);
border: none;
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(31, 31, 31, 0.95);
border: none;
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;
}
iconify-icon {
display: inline-flex;
align-items: center;
justify-content: center;
vertical-align: middle;
font-size: 13px;
}
.brand-logo {
font-size: 20px;
color: var(--green);
display: inline-flex;
align-items: center;
}
[role="menu"],
[role="menuitem"],
div[role="menu"],
div[aria-label*="context menu"],
div[aria-label*="Visibility"],
.sv-sheet-item,
[data-radix-popper-content-wrapper] > div {
background: rgba(31, 31, 31, 0.96) !important;
color: var(--text) !important;
border: none !important;
border-radius: 2px !important;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.8) !important;
font-family: 'Undefined', monospace !important;
font-size: 11px !important;
backdrop-filter: blur(8px) !important;
}
[role="menuitem"],
button[role="menuitem"],
div[role="menu"] button {
background: transparent !important;
color: var(--text-body) !important;
font-family: 'Undefined', monospace !important;
font-size: 11px !important;
padding: 6px 10px !important;
border: none !important;
border-radius: 0 !important;
transition: background 0.1s, color 0.1s !important;
}
[role="menuitem"]:hover,
[role="menuitem"]:focus,
[role="menuitem"]:focus-visible,
button[role="menuitem"]:hover,
div[role="menu"] button:hover,
.sv-sheet-item:hover,
.sv-sheet-item[data-highlighted] {
background: var(--surface-hover) !important;
color: var(--green) !important;
}
#schematic-container div[style*="pointer-events: none"],
#schematic-container div[style*="pointerEvents: none"] {
background: transparent !important;
background-color: transparent !important;
box-shadow: none !important;
}
#schematic-container div[style*="rgba(255, 255, 255"],
#schematic-container div[style*="background-color: rgba(255, 255, 255"],
#schematic-container div[style*="backgroundColor: rgba(255, 255, 255"] {
background-color: rgba(31, 31, 31, 0.96) !important;
color: var(--text) !important;
border: none !important;
border-radius: 2px !important;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.8) !important;
font-family: 'Undefined', monospace !important;
backdrop-filter: blur(8px) !important;
}
#schematic-container dl,
#schematic-container dl * {
font-family: 'Undefined', monospace !important;
color: var(--text-body) !important;
}
#schematic-container dt {
color: var(--text-subtle) !important;
font-size: 10px !important;
}
#schematic-container dd {
color: var(--green) !important;
font-size: 11px !important;
font-weight: bold !important;
}
#schematic-container button,
#schematic-container input,
.schematic-viewer-toolbar button {
font-family: 'Undefined', monospace !important;
border-radius: 2px !important;
background: var(--surface) !important;
border: 1px solid var(--border) !important;
color: var(--text) !important;
box-shadow: none !important;
outline: none !important;
}
#schematic-container button:hover,
.schematic-viewer-toolbar button:hover {
background: var(--surface-hover) !important;
color: var(--green) !important;
border-color: var(--green) !important;
}
[data-schematic-search] {
font-family: 'Undefined', monospace !important;
}
[data-schematic-search] > div {
background: var(--surface) !important;
border: 1px solid var(--border) !important;
border-radius: 2px !important;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.8) !important;
color: var(--text) !important;
}
[data-schematic-search] input {
background: var(--bg) !important;
color: var(--text) !important;
border: 1px solid var(--border) !important;
border-radius: 2px !important;
font-family: 'Undefined', monospace !important;
}
[data-schematic-search] button,
[data-schematic-search] [role="button"] {
border: none !important;
border-radius: 2px !important;
background: transparent !important;
color: var(--text) !important;
}
[data-schematic-search] button:hover,
[data-schematic-search] [role="button"]:hover {
background: var(--surface-hover) !important;
color: var(--green) !important;
}
[data-schematic-search] span {
font-family: 'Undefined', monospace !important;
}
[data-schematic-warnings] {
font-family: 'Undefined', monospace !important;
background: var(--surface) !important;
color: var(--alert) !important;
border: 1px solid var(--border) !important;
border-radius: 2px !important;
box-shadow: none !important;
}
[data-schematic-warnings]:hover {
background: var(--surface-hover) !important;
border-color: var(--alert) !important;
color: var(--alert) !important;
}
[data-schematic-warnings][aria-pressed="true"] {
background: rgba(197, 84, 84, 0.2) !important;
border-color: var(--alert) !important;
}
.schematic-warning,
.schematic-warning * {
font-family: 'Undefined', monospace !important;
}
.schematic-warning [data-warning-reference="callout"] {
background: var(--surface) !important;
color: var(--text) !important;
border: 1px solid var(--alert) !important;
border-radius: 2px !important;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.8) !important;
}
#pcb-container [data-toolbar-overlay],
[data-toolbar-overlay] {
font-family: 'Undefined', monospace !important;
gap: 6px !important;
}
#pcb-container [data-toolbar-overlay] > div,
[data-toolbar-overlay] > div,
[data-toolbar-overlay] button,
#pcb-container button,
#pcb-container select,
#pcb-container input,
#pcb-container [data-toolbar-overlay] div[style*="cursor: pointer"] {
font-family: 'Undefined', monospace !important;
font-size: 11px !important;
letter-spacing: 0.05em !important;
background: var(--surface) !important;
background-color: var(--surface) !important;
border: 1px solid var(--border) !important;
border-radius: 2px !important;
color: var(--text) !important;
box-shadow: none !important;
outline: none !important;
transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease !important;
}
#pcb-container [data-toolbar-overlay] > div:hover,
[data-toolbar-overlay] > div:hover,
[data-toolbar-overlay] button:hover,
#pcb-container button:hover,
#pcb-container [data-toolbar-overlay] div[style*="cursor: pointer"]:hover {
background: var(--surface-hover) !important;
background-color: var(--surface-hover) !important;
color: var(--green) !important;
border-color: var(--green) !important;
}
[data-toolbar-overlay] div[style*="minWidth: 120"],
[data-toolbar-overlay] div[style*="min-width: 120"],
[data-toolbar-overlay] div[style*="position: absolute"] {
background: rgba(31, 31, 31, 0.96) !important;
background-color: rgba(31, 31, 31, 0.96) !important;
border: 1px solid var(--border) !important;
border-radius: 2px !important;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.8) !important;
backdrop-filter: blur(8px) !important;
}
[data-toolbar-overlay] div[style*="minWidth: 120"] > div,
[data-toolbar-overlay] div[style*="min-width: 120"] > div {
font-family: 'Undefined', monospace !important;
font-size: 11px !important;
padding: 6px 10px !important;
color: var(--text) !important;
border: none !important;
}
[data-toolbar-overlay] div[style*="minWidth: 120"] > div:hover,
[data-toolbar-overlay] div[style*="min-width: 120"] > div:hover {
background: var(--surface-hover) !important;
color: var(--green) !important;
}
#three-cad div[style*="background-color: white"],
#three-cad div[style*="background-color: rgb(255, 255, 255)"],
#three-cad div[style*="background: white"],
#three-cad div[style*="background:white"] {
background-color: rgba(31, 31, 31, 0.95) !important;
color: var(--text) !important;
border: none !important;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.7) !important;
font-family: 'Undefined', monospace !important;
font-size: 11px !important;
padding: 6px 10px !important;
border-radius: 2px !important;
backdrop-filter: blur(4px) !important;
}
#three-cad div[style*="background-color: white"] *,
#three-cad div[style*="background-color: rgb(255, 255, 255)"] * {
color: var(--text) !important;
}
</style>
</head>
<body>
<header>
<div class="brand">
<iconify-icon icon="pixelarticons:cpu-sharp" class="brand-logo"></iconify-icon>
<span class="board-name" id="board-name">BOARD PREVIEW</span>
<div id="fuse-status" class="fuse-badge fuse-intact">
<span id="fuse-icon"><iconify-icon icon="pixelarticons:zap"></iconify-icon></span>
<span id="fuse-text">[OK] FUSE INTACT</span>
</div>
</div>
<div class="tabs">
<button class="tab-btn active" data-view="schematic" onclick="switchView('schematic')"><iconify-icon icon="pixelarticons:git-commit"></iconify-icon><span>SCHEMATIC</span></button>
<button class="tab-btn" data-view="pcb" onclick="switchView('pcb')"><iconify-icon icon="pixelarticons:grid"></iconify-icon><span>PCB LAYOUT</span></button>
<button class="tab-btn" data-view="3d" onclick="switchView('3d')"><iconify-icon icon="pixelarticons:cube"></iconify-icon><span>3D VIEW</span></button>
</div>
<div class="controls" id="controls-2d">
<button class="btn" id="btn-pcb-mode" style="display: none;" onclick="togglePcbMode()"><iconify-icon icon="pixelarticons:switch"></iconify-icon> <span id="pcb-mode-label">VIEW: INSPECTOR</span></button>
<button class="btn" onclick="zoomIn()"><iconify-icon icon="pixelarticons:zoom-in"></iconify-icon> ZOOM +</button>
<button class="btn" onclick="zoomOut()"><iconify-icon icon="pixelarticons:zoom-out"></iconify-icon> ZOOM -</button>
<button class="btn" onclick="resetZoom()"><iconify-icon icon="pixelarticons:undo"></iconify-icon> RESET</button>
</div>
<div class="controls" id="controls-3d" style="display: none;">
<button class="btn" onclick="setCameraPreset('TOP')"><iconify-icon icon="pixelarticons:arrow-up"></iconify-icon> TOP</button>
<button class="btn" onclick="setCameraPreset('ISO')"><iconify-icon icon="pixelarticons:corner-up-right"></iconify-icon> ISO</button>
<button class="btn" onclick="setCameraPreset('FRONT')"><iconify-icon icon="pixelarticons:arrow-down"></iconify-icon> FRONT</button>
<button class="btn" onclick="setCameraPreset('RESET')"><iconify-icon icon="pixelarticons:reload"></iconify-icon> RESET</button>
<button class="btn" id="btn-engine-toggle" onclick="toggle3dEngine()"><iconify-icon icon="pixelarticons:switch"></iconify-icon> <span id="engine-label">ENGINE: CAD</span></button>
</div>
</header>
<main>
<div id="viewport-container">
<div id="viewport-grid"></div>
<div id="pcb-container" style="position:absolute;inset:0;width:100%;height:100%;z-index:2;display:none;"></div>
<div id="schematic-container" style="position:absolute;inset:0;width:100%;height:100%;z-index:2;display:none;"></div>
<div id="canvas-content" style="display:none;"></div>
<div id="three-container">
<div id="three-native" style="width:100%;height:100%;position:absolute;inset:0;"></div>
<div id="three-cad" style="width:100%;height:100%;position:absolute;inset:0;display:none;"></div>
</div>
<div class="hud-toast">
<div class="status-dot" id="live-dot"></div>
<span id="live-status">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 = 'schematic';
let pcbMode = 'inspector';
let pcbSvg = '';
let schematicSvg = '';
let lastCircuitJson = null;
let tscircuitFallback = false;
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 isInteractive2dActive() {
if (currentView === 'pcb') {
return pcbMode === 'inspector' && !!window.Tscircuit;
}
return currentView === 'schematic' && !!window.Tscircuit;
}
function zoomIn() {
if (isInteractive2dActive()) {
const container = currentView === 'pcb' ? document.getElementById('pcb-container') : document.getElementById('schematic-container');
if (container) {
const rect = container.getBoundingClientRect();
const target = container.querySelector('canvas') || container.querySelector('svg') || container;
target.dispatchEvent(new WheelEvent('wheel', {
deltaY: -120,
clientX: rect.left + rect.width / 2,
clientY: rect.top + rect.height / 2,
bubbles: true,
}));
return;
}
}
zoomLevel *= 1.25;
applyTransform();
}
function zoomOut() {
if (isInteractive2dActive()) {
const container = currentView === 'pcb' ? document.getElementById('pcb-container') : document.getElementById('schematic-container');
if (container) {
const rect = container.getBoundingClientRect();
const target = container.querySelector('canvas') || container.querySelector('svg') || container;
target.dispatchEvent(new WheelEvent('wheel', {
deltaY: 120,
clientX: rect.left + rect.width / 2,
clientY: rect.top + rect.height / 2,
bubbles: true,
}));
return;
}
}
zoomLevel /= 1.25;
applyTransform();
}
function resetZoom() {
zoomLevel = 1.0;
panX = 0;
panY = 0;
applyTransform();
if (isInteractive2dActive()) {
renderCurrentView();
}
}
viewport.addEventListener('wheel', (e) => {
if (currentView === '3d' || isInteractive2dActive()) return;
e.preventDefault();
const zoomFactor = e.deltaY < 0 ? 1.15 : 0.85;
zoomLevel *= zoomFactor;
applyTransform();
}, { passive: false });
viewport.addEventListener('mousedown', (e) => {
if (currentView === '3d' || isInteractive2dActive()) return;
if (e.button === 0) {
isDragging = true;
startX = e.clientX - panX;
startY = e.clientY - panY;
}
});
window.addEventListener('mousemove', (e) => {
if (currentView === '3d' || isInteractive2dActive()) return;
if (isDragging) {
panX = e.clientX - startX;
panY = e.clientY - startY;
applyTransform();
}
});
window.addEventListener('mouseup', () => {
isDragging = false;
});
let engine3d = 'cad';
function toggle3dEngine() {
engine3d = engine3d === 'cad' ? 'native' : 'cad';
updateEngineUI();
}
function updateEngineUI() {
const label = document.getElementById('engine-label');
if (label) label.textContent = engine3d === 'cad' ? 'ENGINE: CAD' : 'ENGINE: NATIVE';
const nativeEl = document.getElementById('three-native');
const cadEl = document.getElementById('three-cad');
if (engine3d === 'cad' && window.Tscircuit && window.Tscircuit.render3dViewer && lastCircuitJson) {
if (cadEl) cadEl.style.display = 'block';
if (nativeEl) nativeEl.style.display = 'none';
try {
window.Tscircuit.render3dViewer(cadEl, lastCircuitJson, (ref) => selectComponent(ref));
} catch (err) {
if (cadEl) cadEl.style.display = 'none';
if (nativeEl) nativeEl.style.display = 'block';
initThree();
build3DScene();
setTimeout(onThreeResize, 10);
}
} else {
if (cadEl) cadEl.style.display = 'none';
if (nativeEl) nativeEl.style.display = 'block';
initThree();
build3DScene();
setTimeout(onThreeResize, 10);
}
}
function switchView(view) {
currentView = view;
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.classList.toggle('active', btn.getAttribute('data-view') === view);
});
const is3d = view === '3d';
const pcbEl = document.getElementById('pcb-container');
const schEl = document.getElementById('schematic-container');
const threeEl = document.getElementById('three-container');
const canvasEl = document.getElementById('canvas-content');
document.getElementById('controls-2d').style.display = is3d ? 'none' : 'flex';
document.getElementById('controls-3d').style.display = is3d ? 'flex' : 'none';
const btnPcbMode = document.getElementById('btn-pcb-mode');
if (btnPcbMode) btnPcbMode.style.display = view === 'pcb' ? 'inline-flex' : 'none';
hideTooltip();
if (is3d) {
if (pcbEl) pcbEl.style.display = 'none';
if (schEl) schEl.style.display = 'none';
if (canvasEl) canvasEl.style.display = 'none';
if (threeEl) threeEl.style.display = 'block';
initThree();
build3DScene();
updateEngineUI();
setTimeout(onThreeResize, 10);
setCameraPreset('ISO');
} else {
if (threeEl) threeEl.style.display = 'none';
renderCurrentView();
if (view === 'schematic') {
window.dispatchEvent(new Event('resize'));
setTimeout(() => window.dispatchEvent(new Event('resize')), 10);
}
}
}
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 togglePcbMode() {
pcbMode = pcbMode === 'inspector' ? '3d' : 'inspector';
const label = document.getElementById('pcb-mode-label');
if (label) {
label.textContent = pcbMode === 'inspector' ? 'VIEW: INSPECTOR' : 'VIEW: 3D';
}
renderCurrentView();
}
function renderCurrentView() {
const pcbEl = document.getElementById('pcb-container');
const schEl = document.getElementById('schematic-container');
const canvasEl = document.getElementById('canvas-content');
if (currentView === 'pcb') {
if (schEl) schEl.style.display = 'none';
if (pcbMode === '3d') {
if (pcbEl) pcbEl.style.display = 'none';
if (canvasEl) {
canvasEl.style.display = 'inline-block';
canvasEl.innerHTML = pcbSvg || '<div class="empty-state">NO PCB LAYOUT AVAILABLE</div>';
setup2DInteractivity();
}
} else {
if (window.Tscircuit && window.Tscircuit.renderPcbViewer && lastCircuitJson) {
if (canvasEl) canvasEl.style.display = 'none';
if (pcbEl) {
pcbEl.style.display = 'block';
window.Tscircuit.renderPcbViewer(pcbEl, lastCircuitJson, (ref) => selectComponent(ref));
}
} else if (tscircuitFallback) {
if (pcbEl) pcbEl.style.display = 'none';
if (canvasEl) {
canvasEl.style.display = 'inline-block';
canvasEl.innerHTML = pcbSvg || '<div class="empty-state">NO PCB LAYOUT AVAILABLE</div>';
setup2DInteractivity();
}
} else {
if (pcbEl) pcbEl.style.display = 'none';
if (canvasEl) canvasEl.style.display = 'none';
}
}
} else if (currentView === 'schematic') {
if (pcbEl) pcbEl.style.display = 'none';
if (window.Tscircuit && window.Tscircuit.renderSchematicViewer && lastCircuitJson) {
if (canvasEl) canvasEl.style.display = 'none';
if (schEl) {
schEl.style.display = 'block';
window.Tscircuit.renderSchematicViewer(schEl, lastCircuitJson, (ref) => selectComponent(ref));
}
} else if (tscircuitFallback) {
if (schEl) schEl.style.display = 'none';
if (canvasEl) {
canvasEl.style.display = 'inline-block';
canvasEl.innerHTML = schematicSvg || '<div class="empty-state">NO SCHEMATIC AVAILABLE</div>';
setup2DInteractivity();
}
} else {
if (schEl) schEl.style.display = 'none';
if (canvasEl) canvasEl.style.display = 'none';
}
}
}
function toggleLayer(layerName, visible) {
let selector = `g[id="${layerName}"]`;
if (layerName === 'top_copper') {
selector += `, .pcb-trace[data-pcb-layer*="top"], [data-pcb-layer*="top"] .pcb-trace, [data-type*="trace"][data-pcb-layer*="top"], [data-pcb-layer*="top"] [data-type*="trace"], [data-type*="copper_pour"][data-pcb-layer*="top"], [data-pcb-layer*="top"] [data-type*="copper_pour"], g.pcb-trace[data-pcb-layer*="top"]`;
} else if (layerName === 'bottom_copper') {
selector += `, .pcb-trace[data-pcb-layer*="bottom"], [data-pcb-layer*="bottom"] .pcb-trace, [data-type*="trace"][data-pcb-layer*="bottom"], [data-pcb-layer*="bottom"] [data-type*="trace"], [data-type*="copper_pour"][data-pcb-layer*="bottom"], [data-pcb-layer*="bottom"] [data-type*="copper_pour"], g.pcb-trace[data-pcb-layer*="bottom"]`;
} else if (layerName === 'pads') {
selector += `, .pcb-pad, .pcb-smtpad, [class*="pcb-pad"], [data-type*="pad"]`;
} else if (layerName === 'silkscreen') {
selector += `, [class*="pcb-silkscreen"], [data-type*="silkscreen"], [data-pcb-layer*="silk"], [data-pcb-layer*="silkscreen"]`;
} else if (layerName === 'holes_and_vias') {
selector += `, .pcb-via, .pcb-hole, [class*="pcb-hole"], [class*="pcb-via"], [data-type*="hole"], [data-type*="via"], [data-pcb-layer="drill"], [data-pcb-layer="through"]`;
}
const elements = canvas.querySelectorAll(selector);
elements.forEach(el => {
el.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 || data.mpn) html += `<div class="tooltip-row"><span>MPN/PART:</span><span class="tooltip-val">${escapeHtml(data.part || data.mpn)}</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.pins !== undefined && data.pins !== null) html += `<div class="tooltip-row"><span>PINS:</span><span class="tooltip-val">${data.pins}</span></div>`;
if (data.extra) html += `<div class="tooltip-row"><span>INFO:</span><span class="tooltip-val">${escapeHtml(String(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,
sourceComponentId: pc.source_component_id,
pcbComponentId: pc.pcb_component_id,
schematicComponentId: null,
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,
sourceComponentId: id,
pcbComponentId: id,
schematicComponentId: null,
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]) {
componentsMap[name].schematicComponentId = sc.schematic_component_id;
if (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('');
}
let pinnedComponent = null;
function findComponent(key) {
if (!key) return null;
if (componentsMap[key]) return componentsMap[key];
const normalized = String(key).trim();
if (componentsMap[normalized]) return componentsMap[normalized];
for (const c of Object.values(componentsMap)) {
if (
c.name === normalized ||
c.id === normalized ||
c.pcbComponentId === normalized ||
c.sourceComponentId === normalized ||
c.schematicComponentId === normalized
) {
return c;
}
}
return null;
}
function resetInspector() {
if (pinnedComponent) return;
const panel = document.getElementById('inspector-content');
if (panel) {
panel.innerHTML = '<div class="empty-state"><span>SELECT OR HOVER A COMPONENT OR PAD TO INSPECT</span></div>';
}
}
function inspectComponent(compOrName, isPinned = true) {
const comp = typeof compOrName === 'string' ? findComponent(compOrName) : compOrName;
if (!comp) {
if (!isPinned && !pinnedComponent) resetInspector();
return;
}
if (isPinned) {
pinnedComponent = comp.name;
selectedComponent = comp.name;
renderComponentsList();
}
const panel = document.getElementById('inspector-content');
if (!panel) return;
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">
<div>
<span class="inspector-name">${escapeHtml(comp.name)}</span>
${!isPinned ? '<span style="font-size:9px;color:var(--olive);margin-left:6px;vertical-align:middle;">[PREVIEW]</span>' : ''}
</div>
<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>
${comp.w && comp.h ? `<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 || 'top').toUpperCase())}</span></div>
${pinsHtml}
`;
if (isPinned && currentView === '3d' && boardGroup) {
boardGroup.children.forEach(obj => {
if (obj.userData && obj.userData.name === comp.name) {
obj.traverse(child => {
if (child.isMesh && child.material && child.material.emissive) {
child.material.emissive = new THREE.Color(0x86A881);
child.material.emissiveIntensity = 0.8;
}
});
} else {
obj.traverse(child => {
if (child.isMesh && child.material && child.material.emissive) {
child.material.emissive.setHex(child.userData.__origEmissive !== undefined ? child.userData.__origEmissive : 0x000000);
child.material.emissiveIntensity = child.userData.__origEmissiveIntensity !== undefined ? child.userData.__origEmissiveIntensity : 0.0;
}
});
}
});
}
}
function selectComponent(idOrName) {
const comp = findComponent(idOrName);
if (!comp) return;
switchSidebarTab('inspector');
inspectComponent(comp, true);
}
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 iconEl = document.getElementById('fuse-icon');
const diagPanel = document.getElementById('diagnostics-panel');
if (fuseBlown) {
statusEl.className = 'fuse-badge fuse-blown';
textEl.textContent = `[BLOWN] FUSE BLOWN (${diagnostics.length})`;
if (iconEl) iconEl.innerHTML = '<iconify-icon icon="pixelarticons:alert"></iconify-icon>';
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';
if (iconEl) iconEl.innerHTML = '<iconify-icon icon="pixelarticons:zap"></iconify-icon>';
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-native');
const w = container.clientWidth || window.innerWidth;
const h = container.clientHeight || window.innerHeight;
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-native');
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, 80, 100);
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setSize(width, height);
renderer.setPixelRatio(window.devicePixelRatio || 1);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.05;
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.25);
scene.add(ambientLight);
const hemiLight = new THREE.HemisphereLight(0xffffff, 0x18181b, 0.6);
scene.add(hemiLight);
const dirLight1 = new THREE.DirectionalLight(0xffffff, 1.25);
dirLight1.position.set(70, 130, 80);
scene.add(dirLight1);
const dirLight2 = new THREE.DirectionalLight(0x90cdf4, 0.5);
dirLight2.position.set(-80, 50, -70);
scene.add(dirLight2);
const bottomLight = new THREE.DirectionalLight(0xffffff, 0.85);
bottomLight.position.set(40, -110, 50);
scene.add(bottomLight);
const headlight = new THREE.DirectionalLight(0xffffff, 0.7);
camera.add(headlight);
scene.add(camera);
const grid = new THREE.GridHelper(200, 40, 0x353535, 0x1F1F1F);
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-native');
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);
let target = null;
for (const hit of intersects) {
let cur = hit.object;
while (cur && cur !== boardGroup && cur !== scene) {
if (cur.userData && (cur.userData.name || cur.userData.type)) {
target = cur;
break;
}
cur = cur.parent;
}
if (target) break;
}
if (target) {
if (hoveredObject !== target) {
clearThreeHighlight();
hoveredObject = target;
hoveredObject.traverse(child => {
if (child.isMesh && child.material && child.material.emissive) {
if (child.userData.__origEmissive === undefined) {
child.userData.__origEmissive = child.material.emissive.getHex();
child.userData.__origEmissiveIntensity = child.material.emissiveIntensity;
}
child.material.emissive.setHex(0x38bdf8);
child.material.emissiveIntensity = 0.8;
}
});
}
showTooltip(e.clientX, e.clientY, target.userData);
if (!pinnedComponent) {
const comp = findComponent(target.userData.name);
if (comp) {
inspectComponent(comp, false);
} else if (target.userData.type) {
inspectComponent({
name: target.userData.name || target.userData.type.toUpperCase(),
mpn: target.userData.part || '',
ftype: target.userData.type.toUpperCase(),
x: parseFloat(target.userData.pos?.match(/[\d.-]+/g)?.[0] || 0),
y: parseFloat(target.userData.pos?.match(/[\d.-]+/g)?.[1] || 0),
w: 0,
h: 0,
layer: target.userData.layer || 'top',
pins: target.userData.net ? [{ pin: target.userData.name || '1', net: target.userData.net }] : []
}, false);
}
}
container.style.cursor = 'pointer';
} else {
clearThreeHighlight();
hideTooltip();
container.style.cursor = 'default';
if (!pinnedComponent) {
resetInspector();
}
}
}
function onThreeClick(e) {
if (currentView !== '3d' || !renderer || !camera || !boardGroup) return;
const container = document.getElementById('three-native');
if (e) {
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);
let target = null;
for (const hit of intersects) {
let cur = hit.object;
while (cur && cur !== boardGroup && cur !== scene) {
if (cur.userData && (cur.userData.name || cur.userData.type)) {
target = cur;
break;
}
cur = cur.parent;
}
if (target) break;
}
if (!target && hoveredObject) {
target = hoveredObject;
}
if (target && target.userData) {
switchSidebarTab('inspector');
const comp = findComponent(target.userData.name);
if (comp) {
inspectComponent(comp, true);
} else {
inspectComponent({
name: target.userData.name || target.userData.type.toUpperCase(),
mpn: target.userData.part || '',
ftype: target.userData.type.toUpperCase(),
x: parseFloat(target.userData.pos?.match(/[\d.-]+/g)?.[0] || 0),
y: parseFloat(target.userData.pos?.match(/[\d.-]+/g)?.[1] || 0),
w: 0,
h: 0,
layer: target.userData.layer || 'top',
pins: target.userData.net ? [{ pin: target.userData.name || '1', net: target.userData.net }] : []
}, true);
}
}
}
function clearThreeHighlight() {
if (hoveredObject) {
hoveredObject.traverse(child => {
if (child.isMesh && child.material && child.material.emissive) {
if (pinnedComponent && (hoveredObject.userData?.name === pinnedComponent || child.userData?.name === pinnedComponent)) {
child.material.emissive.setHex(0x86A881);
child.material.emissiveIntensity = 0.8;
} else {
child.material.emissive.setHex(child.userData.__origEmissive !== undefined ? child.userData.__origEmissive : 0x000000);
child.material.emissiveIntensity = child.userData.__origEmissiveIntensity !== undefined ? child.userData.__origEmissiveIntensity : 0.0;
}
}
});
}
hoveredObject = null;
}
let cameraAnimId = null;
function smoothAnimateCamera(targetPos, targetLookAt, targetUp, duration = 600) {
if (!camera || !controls) return;
if (cameraAnimId) cancelAnimationFrame(cameraAnimId);
const startPos = camera.position.clone();
const startLookAt = controls.target.clone();
const startUp = camera.up.clone();
const startTime = performance.now();
function step() {
const elapsed = performance.now() - startTime;
const t = Math.min(1, elapsed / duration);
const ease = t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
camera.position.lerpVectors(startPos, targetPos, ease);
controls.target.lerpVectors(startLookAt, targetLookAt, ease);
if (targetUp) {
camera.up.lerpVectors(startUp, targetUp, ease).normalize();
}
controls.update();
if (t < 1) {
cameraAnimId = requestAnimationFrame(step);
} else {
camera.position.copy(targetPos);
controls.target.copy(targetLookAt);
if (targetUp) camera.up.copy(targetUp);
controls.update();
cameraAnimId = null;
}
}
cameraAnimId = requestAnimationFrame(step);
}
function setCameraPreset(preset) {
if (engine3d === 'cad' && window.cadCameraController) {
if (preset === 'TOP') {
window.cadCameraController.animateToPreset('Top Down');
} else if (preset === 'FRONT') {
window.cadCameraController.animateToPreset('Front');
} else if (preset === 'ISO' || preset === 'RESET') {
window.cadCameraController.animateToPreset('Top Left Corner');
}
return;
}
if (!controls || !camera) return;
const dist = Math.max(boardWidth, boardHeight) * 1.8;
const tx = boardCenterX;
const tz = boardCenterZ;
const targetVec = new THREE.Vector3(tx, 0, tz);
if (preset === 'TOP') {
smoothAnimateCamera(new THREE.Vector3(tx, dist, tz + 0.001), targetVec, new THREE.Vector3(0, 0, -1));
} else if (preset === 'FRONT') {
smoothAnimateCamera(new THREE.Vector3(tx, dist * 0.1, tz + dist), targetVec, new THREE.Vector3(0, 1, 0));
} else if (preset === 'ISO' || preset === 'RESET') {
smoothAnimateCamera(new THREE.Vector3(tx + dist * 0.7, dist * 0.8, tz + dist * 0.7), targetVec, new THREE.Vector3(0, 1, 0));
}
}
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);
const parentComp = Object.values(componentsMap).find(c => c.id === pad.pcb_component_id || c.pcbComponentId === pad.pcb_component_id);
const pinHint = pad.port_hints?.[0] || '';
const pinInfo = parentComp?.pins?.find(p => p.pin === String(pinHint) || (pad.port_hints && pad.port_hints.includes(p.pin)));
const padNet = pad.net || pinInfo?.net || pad.subcircuit_id || '';
const padName = parentComp ? `${parentComp.name}.${pinHint || 'PAD'}` : (pinHint ? `PAD ${pinHint}` : 'PAD');
padMesh.userData = {
type: 'smtpad',
name: padName,
part: parentComp?.mpn || parentComp?.ftype || 'PAD',
mpn: parentComp?.mpn || '',
pos: `(${px.toFixed(2)}, ${pz.toFixed(2)}) mm`,
layer: pad.layer || 'top',
pins: parentComp?.pins?.length || 1,
extra: padNet ? `NET: ${padNet}` : (parentComp ? `${parentComp.name}` : ''),
net: padNet
};
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 connectedTrace = elements.find(e => e.type === 'pcb_trace' && (e.route || []).some(pt => Math.hypot(pt.x - vx, pt.y - vz) < 0.2));
const viaNet = via.connection_name || via.net || connectedTrace?.connection_name || '';
const viaUserData = {
type: via.type === 'pcb_via' ? 'via' : 'plated_hole',
name: via.name || (via.type === 'pcb_via' ? (viaNet ? `VIA [${viaNet}]` : 'VIA') : (viaNet ? `HOLE [${viaNet}]` : 'PLATED HOLE')),
part: via.type === 'pcb_via' ? `VIA dia=${via.outer_diameter || 0.6}mm` : `HOLE dia=${via.hole_diameter || 0.3}mm`,
mpn: '',
pos: `(${vx.toFixed(2)}, ${vz.toFixed(2)}) mm`,
layer: via.layers?.join('/') || 'through',
pins: 1,
extra: viaNet ? `NET: ${viaNet}` : 'THROUGH-HOLE',
net: viaNet
};
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 = viaUserData;
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);
holeMesh.userData = viaUserData;
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 midX = (p1.x + p2.x) / 2;
const midZ = (p1.y + p2.y) / 2;
const traceLayer = isBottom ? 'bottom' : 'top';
const traceNet = trace.connection_name || 'SIGNAL';
const mesh = new THREE.Mesh(geo, traceMat);
mesh.position.set(midX, py, midZ);
mesh.userData = {
type: 'trace',
name: `TRACE [${traceNet}]`,
part: `WIDTH ${w.toFixed(2)}mm`,
mpn: '',
pos: `(${midX.toFixed(2)}, ${midZ.toFixed(2)}) mm`,
layer: traceLayer,
pins: 0,
extra: `LEN ${len.toFixed(2)}mm`,
net: traceNet
};
boardGroup.add(mesh);
}
});
const silverMat = new THREE.MeshStandardMaterial({
color: 0xdce4ec,
roughness: 0.2,
metalness: 0.95
});
const goldMat = new THREE.MeshStandardMaterial({
color: 0xf59e0b,
roughness: 0.25,
metalness: 0.9
});
const darkEpoxyMat = new THREE.MeshStandardMaterial({
color: 0x18181b,
roughness: 0.6,
metalness: 0.15
});
const ceramicCapMat = new THREE.MeshStandardMaterial({
color: 0x9b7653,
roughness: 0.45,
metalness: 0.05
});
const ceramicResMat = new THREE.MeshStandardMaterial({
color: 0x222225,
roughness: 0.65,
metalness: 0.1
});
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 = Math.max(comp.width || 4, 0.8);
const ch = Math.max(comp.height || 4, 0.8);
const isBottom = comp.layer === 'bottom';
const compName = Object.keys(componentsMap).find(k => componentsMap[k].id === comp.pcb_component_id) || comp.pcb_component_id || 'U';
const compData = componentsMap[compName] || {};
const ftype = (compData.ftype || '').toLowerCase();
const nameUpper = compName.toUpperCase();
const pinsCount = compData.pins?.length || 0;
const compGroup = new THREE.Group();
const userData = {
type: 'component',
name: compName,
part: compData.mpn || compData.ftype || 'IC',
mpn: compData.mpn || '',
pos: `(${cx.toFixed(2)}, ${cz.toFixed(2)}) mm`,
layer: comp.layer || 'top',
pins: pinsCount,
extra: `${pinsCount} pins`,
net: compData.pins?.[0]?.net || ''
};
compGroup.userData = userData;
if (ftype.includes('resistor') || nameUpper.startsWith('R')) {
const thick = 0.55;
const cy = isBottom ? -(substrateThickness / 2 + thick / 2 + 0.03) : (substrateThickness / 2 + thick / 2 + 0.03);
const isVert = ch > cw;
const len = isVert ? ch : cw;
const wid = isVert ? cw : ch;
const capLen = Math.max(len * 0.22, 0.2);
const bodyLen = Math.max(len - capLen * 2, 0.2);
const bodyGeo = isVert ? new THREE.BoxGeometry(wid, thick, bodyLen) : new THREE.BoxGeometry(bodyLen, thick, wid);
const bodyMesh = new THREE.Mesh(bodyGeo, ceramicResMat.clone());
bodyMesh.userData = userData;
compGroup.add(bodyMesh);
const capGeo = isVert ? new THREE.BoxGeometry(wid * 1.02, thick * 1.02, capLen) : new THREE.BoxGeometry(capLen, thick * 1.02, wid * 1.02);
const capOffset = (bodyLen + capLen) / 2;
const cap1 = new THREE.Mesh(capGeo, silverMat);
const cap2 = new THREE.Mesh(capGeo, silverMat);
if (isVert) {
cap1.position.set(0, 0, -capOffset);
cap2.position.set(0, 0, capOffset);
} else {
cap1.position.set(-capOffset, 0, 0);
cap2.position.set(capOffset, 0, 0);
}
cap1.userData = userData;
cap2.userData = userData;
compGroup.add(cap1);
compGroup.add(cap2);
compGroup.position.set(cx, cy, cz);
} else if (ftype.includes('capacitor') || nameUpper.startsWith('C')) {
const thick = 0.65;
const cy = isBottom ? -(substrateThickness / 2 + thick / 2 + 0.03) : (substrateThickness / 2 + thick / 2 + 0.03);
const isVert = ch > cw;
const len = isVert ? ch : cw;
const wid = isVert ? cw : ch;
const capLen = Math.max(len * 0.22, 0.2);
const bodyLen = Math.max(len - capLen * 2, 0.2);
const bodyGeo = isVert ? new THREE.BoxGeometry(wid, thick, bodyLen) : new THREE.BoxGeometry(bodyLen, thick, wid);
const bodyMesh = new THREE.Mesh(bodyGeo, ceramicCapMat.clone());
bodyMesh.userData = userData;
compGroup.add(bodyMesh);
const capGeo = isVert ? new THREE.BoxGeometry(wid * 1.02, thick * 1.02, capLen) : new THREE.BoxGeometry(capLen, thick * 1.02, wid * 1.02);
const capOffset = (bodyLen + capLen) / 2;
const cap1 = new THREE.Mesh(capGeo, silverMat);
const cap2 = new THREE.Mesh(capGeo, silverMat);
if (isVert) {
cap1.position.set(0, 0, -capOffset);
cap2.position.set(0, 0, capOffset);
} else {
cap1.position.set(-capOffset, 0, 0);
cap2.position.set(capOffset, 0, 0);
}
cap1.userData = userData;
cap2.userData = userData;
compGroup.add(cap1);
compGroup.add(cap2);
compGroup.position.set(cx, cy, cz);
} else if (ftype.includes('led') || nameUpper.startsWith('LED')) {
const thick = 0.85;
const cy = isBottom ? -(substrateThickness / 2 + thick / 2 + 0.03) : (substrateThickness / 2 + thick / 2 + 0.03);
const baseMesh = new THREE.Mesh(new THREE.BoxGeometry(cw, 0.3, ch), silverMat);
baseMesh.position.y = -thick / 2 + 0.15;
baseMesh.userData = userData;
compGroup.add(baseMesh);
const ledColor = nameUpper.includes('RED') ? 0xc55454 : nameUpper.includes('BLUE') ? 0x38bdf8 : 0x86A881;
const ledMat = new THREE.MeshStandardMaterial({
color: ledColor,
emissive: ledColor,
emissiveIntensity: 0.6,
roughness: 0.2,
metalness: 0.1,
transparent: true,
opacity: 0.85
});
const domeMesh = new THREE.Mesh(new THREE.BoxGeometry(cw * 0.8, thick - 0.3, ch * 0.8), ledMat);
domeMesh.position.y = 0.15;
domeMesh.userData = userData;
compGroup.add(domeMesh);
compGroup.position.set(cx, cy, cz);
} else if (ftype.includes('header') || ftype.includes('connector') || nameUpper.startsWith('J') || nameUpper.startsWith('HDR') || nameUpper.startsWith('CON') || nameUpper.startsWith('P')) {
const thick = 2.5;
const cy = isBottom ? -(substrateThickness / 2 + thick / 2 + 0.03) : (substrateThickness / 2 + thick / 2 + 0.03);
const baseMesh = new THREE.Mesh(new THREE.BoxGeometry(cw, thick, ch), darkEpoxyMat.clone());
baseMesh.userData = userData;
compGroup.add(baseMesh);
const pinH = 6.0;
const pinW = 0.64;
const pinGeo = new THREE.BoxGeometry(pinW, pinH, pinW);
const nPins = Math.max(pinsCount || 2, Math.round(Math.max(cw, ch) / 2.54) || 2);
const isVert = ch > cw;
for (let p = 0; p < nPins; p++) {
const pinMesh = new THREE.Mesh(pinGeo, goldMat);
const offset = (p - (nPins - 1) / 2) * (Math.max(cw, ch) / nPins);
const px = isVert ? 0 : offset;
const pz = isVert ? offset : 0;
const py = isBottom ? -pinH / 2 : pinH / 2;
pinMesh.position.set(px, py, pz);
pinMesh.userData = userData;
compGroup.add(pinMesh);
}
compGroup.position.set(cx, cy, cz);
} else if (ftype.includes('switch') || ftype.includes('button') || nameUpper.startsWith('SW') || nameUpper.startsWith('BTN')) {
const thick = 1.6;
const cy = isBottom ? -(substrateThickness / 2 + thick / 2 + 0.03) : (substrateThickness / 2 + thick / 2 + 0.03);
const baseMesh = new THREE.Mesh(new THREE.BoxGeometry(cw, thick, ch), silverMat);
baseMesh.userData = userData;
compGroup.add(baseMesh);
const btnMat = new THREE.MeshStandardMaterial({ color: 0x0f172a, roughness: 0.8 });
const btnRadius = Math.min(cw, ch) * 0.3;
const btnMesh = new THREE.Mesh(new THREE.CylinderGeometry(btnRadius, btnRadius, 0.8, 16), btnMat);
btnMesh.position.y = thick / 2 + 0.4;
btnMesh.userData = userData;
compGroup.add(btnMesh);
compGroup.position.set(cx, cy, cz);
} else if (ftype.includes('test') || nameUpper.startsWith('TP')) {
const cy = isBottom ? -(substrateThickness / 2 + 0.4) : (substrateThickness / 2 + 0.4);
const ringMesh = new THREE.Mesh(new THREE.CylinderGeometry(0.8, 0.8, 0.1, 16), goldMat);
ringMesh.userData = userData;
compGroup.add(ringMesh);
const beadMesh = new THREE.Mesh(new THREE.SphereGeometry(0.4, 12, 12), goldMat);
beadMesh.position.y = 0.3;
beadMesh.userData = userData;
compGroup.add(beadMesh);
compGroup.position.set(cx, cy, cz);
} else {
const thick = 1.4;
const cy = isBottom ? -(substrateThickness / 2 + thick / 2 + 0.05) : (substrateThickness / 2 + thick / 2 + 0.05);
const chipMesh = new THREE.Mesh(new THREE.BoxGeometry(cw, thick, ch), darkEpoxyMat.clone());
chipMesh.userData = userData;
compGroup.add(chipMesh);
const dotMat = new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 0.3 });
const dotMesh = new THREE.Mesh(new THREE.CylinderGeometry(0.2, 0.2, 0.05, 12), dotMat);
dotMesh.position.set(-cw / 2 + 0.6, thick / 2 + 0.03, -ch / 2 + 0.6);
dotMesh.userData = userData;
compGroup.add(dotMesh);
const totalPins = pinsCount || (cw > 8 ? 16 : 8);
const sidePins = Math.max(2, Math.floor(totalPins / 2));
const pinW = 0.4;
const pinLen = 0.8;
const pinH = 0.2;
const pinY = isBottom ? thick / 2 - 0.1 : -thick / 2 + 0.1;
for (let p = 0; p < sidePins; p++) {
const offset = (p - (sidePins - 1) / 2) * (ch / sidePins);
const leftPin = new THREE.Mesh(new THREE.BoxGeometry(pinLen, pinH, pinW), silverMat);
leftPin.position.set(-cw / 2 - pinLen / 2 + 0.1, pinY, offset);
leftPin.userData = userData;
compGroup.add(leftPin);
const rightPin = new THREE.Mesh(new THREE.BoxGeometry(pinLen, pinH, pinW), silverMat);
rightPin.position.set(cw / 2 + pinLen / 2 - 0.1, pinY, offset);
rightPin.userData = userData;
compGroup.add(rightPin);
}
compGroup.position.set(cx, cy, cz);
}
boardGroup.add(compGroup);
const sprite = makeTextSprite(compName);
const spriteY = isBottom ? -(substrateThickness / 2 + 1.8) : (substrateThickness / 2 + 1.8);
sprite.position.set(cx, spriteY, cz);
sprite.userData = userData;
boardGroup.add(sprite);
});
const pcbComponents = elements.filter(e => e.type === 'pcb_component');
elements.filter(e => e.type === 'pcb_silkscreen_text').forEach(st => {
const text = st.text || '';
if (!text) return;
const sx = st.x ?? st.center?.x ?? st.anchor_position?.x ?? 0;
const sz = st.y ?? st.center?.y ?? st.anchor_position?.y ?? 0;
const isBottom = st.layer === 'bottom';
let compThick = 0;
pcbComponents.forEach(c => {
const ccx = c.center?.x ?? c.x ?? 0;
const ccz = c.center?.y ?? c.y ?? 0;
const ccw = Math.max(c.width || 4, 1.0) / 2;
const cch = Math.max(c.height || 4, 1.0) / 2;
if (Math.abs(sx - ccx) <= ccw && Math.abs(sz - ccz) <= cch) {
compThick = Math.max(compThick, 1.45);
}
});
const sy = isBottom ? -(substrateThickness / 2 + compThick + 0.04) : (substrateThickness / 2 + compThick + 0.04);
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const fontSize = Math.max(16, Math.round((st.font_size || 1.0) * 24));
ctx.font = `bold ${fontSize}px sans-serif`;
const metrics = ctx.measureText(text);
const textW = Math.max(16, Math.ceil(metrics.width) + 8);
const textH = Math.max(16, Math.ceil(fontSize * 1.3) + 4);
canvas.width = textW;
canvas.height = textH;
ctx.font = `bold ${fontSize}px sans-serif`;
ctx.fillStyle = '#FFFFFF';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(text, textW / 2, textH / 2);
const tex = new THREE.CanvasTexture(canvas);
tex.minFilter = THREE.LinearFilter;
const planeW = (st.font_size || 1.0) * (textW / fontSize);
const planeH = (st.font_size || 1.0) * 1.3;
const planeGeo = new THREE.PlaneGeometry(planeW, planeH);
const planeMat = new THREE.MeshBasicMaterial({ map: tex, transparent: true, side: THREE.DoubleSide });
const planeMesh = new THREE.Mesh(planeGeo, planeMat);
planeMesh.rotation.x = isBottom ? Math.PI / 2 : -Math.PI / 2;
if (st.ccw_rotation) {
const rotZ = ((st.ccw_rotation || 0) * Math.PI) / 180;
planeMesh.rotation.z = isBottom ? -rotZ : rotZ;
}
planeMesh.position.set(sx, sy, sz);
boardGroup.add(planeMesh);
});
}
function updateStudio(data) {
if (!data) return;
if (data.board_name) {
document.getElementById('board-name').textContent = data.board_name.toUpperCase();
}
if (window.Tscircuit && data.circuit_json) {
try {
schematicSvg = window.Tscircuit.convertCircuitJsonToSchematicSvg(data.circuit_json);
if (!schematicSvg && data.schematic_svg) schematicSvg = data.schematic_svg;
} catch (err) {
if (data.schematic_svg) schematicSvg = data.schematic_svg;
}
try {
pcbSvg = window.Tscircuit.convertCircuitJsonToPcbSvg(data.circuit_json);
if (!pcbSvg && data.pcb_svg) pcbSvg = data.pcb_svg;
} catch (err) {
if (data.pcb_svg) pcbSvg = data.pcb_svg;
}
} else {
if (data.schematic_svg) schematicSvg = data.schematic_svg;
if (data.pcb_svg) pcbSvg = data.pcb_svg;
}
if (data.circuit_json) {
lastCircuitJson = data.circuit_json;
parseCircuitGraph();
if (currentView === '3d') {
build3DScene();
updateEngineUI();
}
}
updateDiagnostics(data.fuse_blown, data.diagnostics || []);
if (currentView !== '3d') {
renderCurrentView();
}
}
window.addEventListener('tscircuit-ready', () => {
if (lastCircuitJson) {
if (currentView === '3d') {
updateEngineUI();
} else {
renderCurrentView();
}
}
});
setTimeout(() => {
if (!window.Tscircuit) {
tscircuitFallback = true;
renderCurrentView();
}
}, 2000);
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 = '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 = 'RECONNECTING...';
};
}
const schEl = document.getElementById('schematic-container');
if (schEl) {
schEl.addEventListener('mousemove', (e) => {
const compEl = e.target.closest('[data-schematic-component-id]');
if (compEl) {
const id = compEl.getAttribute('data-schematic-component-id');
const comp = findComponent(id);
if (comp) {
inspectComponent(comp, false);
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 portEl = e.target.closest('[data-schematic-port-id]');
if (portEl) {
const portId = portEl.getAttribute('data-schematic-port-id');
showTooltip(e.clientX, e.clientY, {
name: `PORT: ${portId}`,
type: 'PORT'
});
return;
}
hideTooltip();
if (!pinnedComponent) resetInspector();
});
schEl.addEventListener('mouseleave', () => {
hideTooltip();
if (!pinnedComponent) resetInspector();
});
schEl.addEventListener('click', (e) => {
const compEl = e.target.closest('[data-schematic-component-id]');
if (compEl) {
const id = compEl.getAttribute('data-schematic-component-id');
const comp = findComponent(id);
if (comp) selectComponent(comp.name);
}
});
}
const pcbEl = document.getElementById('pcb-container');
if (pcbEl) {
pcbEl.addEventListener('mousemove', (e) => {
const compEl = e.target.closest('[data-pcb-component-id]') || e.target.closest('[data-component-id]');
if (compEl) {
const id = compEl.getAttribute('data-pcb-component-id') || compEl.getAttribute('data-component-id');
const comp = findComponent(id);
if (comp) {
inspectComponent(comp, false);
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;
}
}
});
pcbEl.addEventListener('mouseleave', () => {
hideTooltip();
if (!pinnedComponent) resetInspector();
});
}
fetchInitialData();
connectSSE();
</script>
</body>
</html>