#!/usr/bin/env python3
"""
kicad_sch_to_3d.py — Parse a real KiCad .kicad_sch file and generate
an interactive Three.js 3D PCB visualization.
Usage:
python kicad_sch_to_3d.py <input.kicad_sch> [output.html]
Supports KiCad 7/8 S-expression format.
"""
import sys
import re
import json
import math
from pathlib import Path
from dataclasses import dataclass, field
from typing import Optional
# ============================================================
# S-EXPRESSION TOKENIZER & PARSER
# ============================================================
class SExprParser:
"""Parse KiCad S-expression format into nested Python lists."""
def __init__(self, text: str):
self.text = text
self.pos = 0
self.length = len(text)
def parse(self):
self._skip_ws()
return self._parse_expr()
def _skip_ws(self):
while self.pos < self.length:
c = self.text[self.pos]
if c in ' \t\n\r':
self.pos += 1
elif c == ';': # comment to end of line
while self.pos < self.length and self.text[self.pos] != '\n':
self.pos += 1
else:
break
def _parse_expr(self):
self._skip_ws()
if self.pos >= self.length:
return None
c = self.text[self.pos]
if c == '(':
return self._parse_list()
elif c == '"':
return self._parse_string()
else:
return self._parse_atom()
def _parse_list(self):
self.pos += 1 # skip '('
result = []
while True:
self._skip_ws()
if self.pos >= self.length:
break
if self.text[self.pos] == ')':
self.pos += 1
break
item = self._parse_expr()
if item is not None:
result.append(item)
return result
def _parse_string(self):
self.pos += 1 # skip opening "
start = self.pos
while self.pos < self.length and self.text[self.pos] != '"':
if self.text[self.pos] == '\\':
self.pos += 1 # skip escaped char
self.pos += 1
s = self.text[start:self.pos]
if self.pos < self.length:
self.pos += 1 # skip closing "
return s
def _parse_atom(self):
start = self.pos
while self.pos < self.length and self.text[self.pos] not in ' \t\n\r()':
self.pos += 1
token = self.text[start:self.pos]
# Try numeric conversion
try:
return int(token)
except ValueError:
try:
return float(token)
except ValueError:
return token
# ============================================================
# KiCad SCHEMATIC DATA MODEL
# ============================================================
@dataclass
class KiPin:
name: str = ""
number: str = ""
x: float = 0
y: float = 0
angle: float = 0
length: float = 2.54
pin_type: str = ""
@dataclass
class KiLibSymbol:
name: str = ""
pins: list = field(default_factory=list)
rectangles: list = field(default_factory=list) # [(x1,y1,x2,y2)]
is_power: bool = False
@dataclass
class KiComponent:
uuid: str = ""
lib_id: str = ""
ref: str = ""
value: str = ""
footprint: str = ""
description: str = ""
x: float = 0
y: float = 0
angle: float = 0 # degrees
is_power: bool = False
pin_uuids: dict = field(default_factory=dict) # pin_number -> uuid
@dataclass
class KiWire:
x1: float = 0
y1: float = 0
x2: float = 0
y2: float = 0
uuid: str = ""
@dataclass
class KiJunction:
x: float = 0
y: float = 0
@dataclass
class KiLabel:
text: str = ""
x: float = 0
y: float = 0
angle: float = 0
label_type: str = "local" # local, global, hierarchical
@dataclass
class KiSchematic:
title: str = ""
date: str = ""
rev: str = ""
company: str = ""
lib_symbols: dict = field(default_factory=dict)
components: list = field(default_factory=list)
wires: list = field(default_factory=list)
junctions: list = field(default_factory=list)
labels: list = field(default_factory=list)
# ============================================================
# KiCad SCHEMATIC EXTRACTOR
# ============================================================
class KiCadSchExtractor:
"""Extract schematic data from parsed S-expressions."""
def __init__(self, sexpr):
self.sexpr = sexpr
self.sch = KiSchematic()
def extract(self) -> KiSchematic:
if not isinstance(self.sexpr, list) or self.sexpr[0] != 'kicad_sch':
raise ValueError("Not a valid kicad_sch file")
for item in self.sexpr[1:]:
if not isinstance(item, list):
continue
tag = item[0]
if tag == 'title_block':
self._extract_title_block(item)
elif tag == 'lib_symbols':
self._extract_lib_symbols(item)
elif tag == 'symbol':
self._extract_component(item)
elif tag == 'wire':
self._extract_wire(item)
elif tag == 'junction':
self._extract_junction(item)
elif tag == 'label':
self._extract_label(item, 'local')
elif tag == 'global_label':
self._extract_label(item, 'global')
elif tag == 'hierarchical_label':
self._extract_label(item, 'hierarchical')
return self.sch
def _find(self, lst, key, default=None):
"""Find a sub-list starting with key."""
for item in lst:
if isinstance(item, list) and len(item) > 0 and item[0] == key:
return item
return default
def _find_all(self, lst, key):
"""Find all sub-lists starting with key."""
return [item for item in lst if isinstance(item, list) and len(item) > 0 and item[0] == key]
def _find_val(self, lst, key, default=""):
"""Find value from (key value) pattern."""
node = self._find(lst, key)
if node and len(node) > 1:
return node[1]
return default
def _extract_title_block(self, tb):
self.sch.title = str(self._find_val(tb, 'title'))
self.sch.date = str(self._find_val(tb, 'date'))
self.sch.rev = str(self._find_val(tb, 'rev'))
self.sch.company = str(self._find_val(tb, 'company'))
def _extract_lib_symbols(self, ls):
for sym_node in self._find_all(ls, 'symbol'):
lib_sym = KiLibSymbol()
lib_sym.name = str(sym_node[1]) if len(sym_node) > 1 else ""
# Check if power symbol
for attr in sym_node[2:]:
if isinstance(attr, list) and attr[0] == 'power':
lib_sym.is_power = True
elif attr == 'power':
pass # handled above with list check
# Check for (power) as standalone atom
if 'power' in sym_node:
lib_sym.is_power = True
# Extract pins from sub-symbols
for sub in self._find_all(sym_node, 'symbol'):
for pin_node in self._find_all(sub, 'pin'):
pin = self._parse_pin(pin_node)
lib_sym.pins.append(pin)
# Extract rectangles
for rect_node in self._find_all(sub, 'rectangle'):
start = self._find(rect_node, 'start')
end = self._find(rect_node, 'end')
if start and end:
lib_sym.rectangles.append((
float(start[1]), float(start[2]),
float(end[1]), float(end[2])
))
self.sch.lib_symbols[lib_sym.name] = lib_sym
def _parse_pin(self, pin_node) -> KiPin:
pin = KiPin()
# (pin type style (at x y angle) (length l) (name "n" ...) (number "n" ...))
if len(pin_node) > 1:
pin.pin_type = str(pin_node[1])
at = self._find(pin_node, 'at')
if at:
pin.x = float(at[1])
pin.y = float(at[2])
pin.angle = float(at[3]) if len(at) > 3 else 0
length_node = self._find(pin_node, 'length')
if length_node and len(length_node) > 1:
pin.length = float(length_node[1])
name_node = self._find(pin_node, 'name')
if name_node and len(name_node) > 1:
pin.name = str(name_node[1])
num_node = self._find(pin_node, 'number')
if num_node and len(num_node) > 1:
pin.number = str(num_node[1])
return pin
def _extract_component(self, sym):
comp = KiComponent()
comp.lib_id = str(self._find_val(sym, 'lib_id'))
at = self._find(sym, 'at')
if at:
comp.x = float(at[1])
comp.y = float(at[2])
comp.angle = float(at[3]) if len(at) > 3 else 0
uuid_node = self._find(sym, 'uuid')
if uuid_node and len(uuid_node) > 1:
comp.uuid = str(uuid_node[1])
# Properties
for prop in self._find_all(sym, 'property'):
if len(prop) < 3:
continue
pname = str(prop[1])
pval = str(prop[2])
if pname == 'Reference':
comp.ref = pval
elif pname == 'Value':
comp.value = pval
elif pname == 'Footprint':
comp.footprint = pval
elif pname == 'Description':
comp.description = pval
# Pin UUIDs
for pin_node in self._find_all(sym, 'pin'):
if len(pin_node) >= 2:
pin_num = str(pin_node[1])
pin_uuid_node = self._find(pin_node, 'uuid')
if pin_uuid_node and len(pin_uuid_node) > 1:
comp.pin_uuids[pin_num] = str(pin_uuid_node[1])
# Determine if power symbol
lib_sym = self.sch.lib_symbols.get(comp.lib_id)
if lib_sym and lib_sym.is_power:
comp.is_power = True
if comp.ref.startswith('#PWR') or comp.ref.startswith('#FLG'):
comp.is_power = True
self.sch.components.append(comp)
def _extract_wire(self, wire_node):
pts = self._find(wire_node, 'pts')
if not pts:
return
xy_list = self._find_all(pts, 'xy')
if len(xy_list) >= 2:
w = KiWire()
w.x1 = float(xy_list[0][1])
w.y1 = float(xy_list[0][2])
w.x2 = float(xy_list[1][1])
w.y2 = float(xy_list[1][2])
uuid_node = self._find(wire_node, 'uuid')
if uuid_node and len(uuid_node) > 1:
w.uuid = str(uuid_node[1])
self.sch.wires.append(w)
def _extract_junction(self, junc_node):
at = self._find(junc_node, 'at')
if at:
j = KiJunction()
j.x = float(at[1])
j.y = float(at[2])
self.sch.junctions.append(j)
def _extract_label(self, label_node, label_type):
lbl = KiLabel()
lbl.label_type = label_type
if len(label_node) > 1 and isinstance(label_node[1], str):
lbl.text = label_node[1]
at = self._find(label_node, 'at')
if at:
lbl.x = float(at[1])
lbl.y = float(at[2])
lbl.angle = float(at[3]) if len(at) > 3 else 0
self.sch.labels.append(lbl)
# ============================================================
# NET RESOLVER — assign net names to wires
# ============================================================
class NetResolver:
"""Build a connectivity graph and assign net names to wires."""
def __init__(self, sch: KiSchematic):
self.sch = sch
self.point_to_nets = {}
self.wire_nets = {}
self.SNAP = 0.5
def _snap(self, x, y):
return (round(x / self.SNAP) * self.SNAP, round(y / self.SNAP) * self.SNAP)
def _dist(self, x1, y1, x2, y2):
return math.sqrt((x1-x2)**2 + (y1-y2)**2)
def _nearest_wire_endpoint(self, px, py, max_dist=3.0):
"""Find the nearest wire endpoint to a point, within max_dist."""
best_pt = None
best_d = max_dist
for w in self.sch.wires:
for (wx, wy) in [(w.x1, w.y1), (w.x2, w.y2)]:
d = self._dist(px, py, wx, wy)
if d < best_d:
best_d = d
best_pt = (wx, wy)
return best_pt
def _inject_net(self, x, y, net_name):
"""Inject a net name at a snapped coordinate."""
pt = self._snap(x, y)
if pt not in self.point_to_nets:
self.point_to_nets[pt] = set()
self.point_to_nets[pt].add(net_name)
def resolve(self):
# 1. For each net label, find the nearest wire endpoint and inject the name there
for lbl in self.sch.labels:
nearest = self._nearest_wire_endpoint(lbl.x, lbl.y, max_dist=5.0)
if nearest:
self._inject_net(nearest[0], nearest[1], lbl.text)
# Also inject at exact position (for endpoint matches)
self._inject_net(lbl.x, lbl.y, lbl.text)
# 2. Power symbols connect at their exact position (endpoint match only)
for comp in self.sch.components:
if comp.is_power:
# Find nearest endpoint within tight tolerance
nearest = self._nearest_wire_endpoint(comp.x, comp.y, max_dist=2.0)
if nearest:
self._inject_net(nearest[0], nearest[1], comp.value)
self._inject_net(comp.x, comp.y, comp.value)
# 3. Build adjacency from wire endpoints
point_to_wires = {}
for i, w in enumerate(self.sch.wires):
for pt in [self._snap(w.x1, w.y1), self._snap(w.x2, w.y2)]:
if pt not in point_to_wires:
point_to_wires[pt] = []
point_to_wires[pt].append(i)
# 4. Flood-fill to propagate net names through connected wires
visited = set()
for i, w in enumerate(self.sch.wires):
if i in visited:
continue
queue = [i]
cluster = []
cluster_nets = set()
while queue:
wi = queue.pop(0)
if wi in visited:
continue
visited.add(wi)
cluster.append(wi)
wire = self.sch.wires[wi]
for pt in [self._snap(wire.x1, wire.y1), self._snap(wire.x2, wire.y2)]:
if pt in self.point_to_nets:
cluster_nets.update(self.point_to_nets[pt])
for neighbor_wi in point_to_wires.get(pt, []):
if neighbor_wi not in visited:
queue.append(neighbor_wi)
net_name = list(cluster_nets)[0] if cluster_nets else f"NET_{i}"
# If multiple net names, prefer named ones over GND for non-GND clusters
if len(cluster_nets) > 1:
# Priority: specific signal names over power nets
named = [n for n in cluster_nets if n not in ('GND',)]
net_name = named[0] if named else 'GND'
for wi in cluster:
self.wire_nets[wi] = net_name
return self.wire_nets
# ============================================================
# COMPONENT CLASSIFIER
# ============================================================
def classify_component(comp: KiComponent, lib_symbols: dict) -> str:
"""Return type: IC, C, R, L, PWR, UNKNOWN"""
if comp.is_power:
return 'PWR'
lid = comp.lib_id
if lid in ('C', 'C_Polarized', 'C_Small'):
return 'C'
if lid in ('R', 'R_Small', 'R_US'):
return 'R'
if lid in ('L', 'L_Small', 'L_Core_Ferrite'):
return 'L'
# Check by reference prefix
if comp.ref.startswith('C'):
return 'C'
if comp.ref.startswith('R'):
return 'R'
if comp.ref.startswith('L'):
return 'L'
if comp.ref.startswith('U') or comp.ref.startswith('IC'):
return 'IC'
return 'UNKNOWN'
# ============================================================
# THREE.JS HTML GENERATOR
# ============================================================
def generate_threejs_html(sch: KiSchematic, wire_nets: dict) -> str:
"""Generate complete HTML with Three.js 3D visualization."""
# Collect non-power components
real_components = [c for c in sch.components if not c.is_power]
power_symbols = [c for c in sch.components if c.is_power]
# Compute bounding box for centering
all_x = [c.x for c in real_components] + [w.x1 for w in sch.wires] + [w.x2 for w in sch.wires]
all_y = [c.y for c in real_components] + [w.y1 for w in sch.wires] + [w.y2 for w in sch.wires]
if not all_x:
all_x = [0]
all_y = [0]
cx = (min(all_x) + max(all_x)) / 2
cy = (min(all_y) + max(all_y)) / 2
span_x = max(all_x) - min(all_x)
span_y = max(all_y) - min(all_y)
SCALE = 0.18 # mm to 3D units
# Build component JSON
comp_data = []
for comp in real_components:
ctype = classify_component(comp, sch.lib_symbols)
lib_sym = sch.lib_symbols.get(comp.lib_id, None)
pins_info = []
if lib_sym:
for pin in lib_sym.pins:
pins_info.append({'name': pin.name, 'num': pin.number})
comp_data.append({
'ref': comp.ref,
'value': comp.value,
'footprint': comp.footprint,
'description': comp.description,
'x': (comp.x - cx) * SCALE,
'z': (comp.y - cy) * SCALE,
'angle': comp.angle,
'type': ctype,
'lib_id': comp.lib_id,
'pins': pins_info,
})
# Build wire JSON with net names
net_set = set()
wire_data = []
for i, w in enumerate(sch.wires):
net = wire_nets.get(i, 'UNKNOWN')
net_set.add(net)
wire_data.append({
'x1': (w.x1 - cx) * SCALE,
'z1': (w.y1 - cy) * SCALE,
'x2': (w.x2 - cx) * SCALE,
'z2': (w.y2 - cy) * SCALE,
'net': net,
})
# Build junction JSON
junc_data = [{'x': (j.x - cx) * SCALE, 'z': (j.y - cy) * SCALE} for j in sch.junctions]
# Build label JSON
label_data = []
for lbl in sch.labels:
label_data.append({
'text': lbl.text,
'x': (lbl.x - cx) * SCALE,
'z': (lbl.y - cy) * SCALE,
'type': lbl.label_type,
})
# Power symbols
pwr_data = []
for ps in power_symbols:
pwr_data.append({
'value': ps.value,
'x': (ps.x - cx) * SCALE,
'z': (ps.y - cy) * SCALE,
})
# Collect unique net list
net_list = sorted([n for n in net_set if not n.startswith('NET_')])
# Board size
board_w = max(span_x * SCALE + 6, 20)
board_h = max(span_y * SCALE + 6, 14)
# ---- Build HTML ----
html = f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{_esc(sch.title or 'KiCad Schematic')} — 3D View</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Oxanium:wght@300;400;600;700&family=Share+Tech+Mono&display=swap');
*{{margin:0;padding:0;box-sizing:border-box}}
body{{background:#080a12;color:#c8d0e0;font-family:'Oxanium',sans-serif;height:100vh;overflow:hidden;display:flex;flex-direction:column}}
#c3d{{flex:1;display:block;cursor:grab}}
#c3d:active{{cursor:grabbing}}
.hud-top{{position:fixed;top:0;left:0;right:0;height:44px;background:linear-gradient(180deg,rgba(8,10,18,0.96) 60%,transparent);display:flex;align-items:center;padding:0 16px;gap:12px;z-index:10;pointer-events:none}}
.hud-top>*{{pointer-events:auto}}
.logo{{display:flex;align-items:center;gap:8px;font-family:'Share Tech Mono',monospace;font-size:12px;color:#5be0a8;letter-spacing:1.2px}}
.hsep{{width:1px;height:18px;background:rgba(91,224,168,0.12)}}
.hbtn{{font-family:'Share Tech Mono',monospace;font-size:10px;padding:4px 10px;border:1px solid rgba(91,224,168,0.18);background:transparent;color:rgba(200,208,224,0.45);border-radius:2px;cursor:pointer;transition:all .2s;text-transform:uppercase;letter-spacing:1px}}
.hbtn:hover{{border-color:#5be0a8;color:#5be0a8;background:rgba(91,224,168,0.05)}}
.hbtn.on{{border-color:#5be0a8;color:#5be0a8;background:rgba(91,224,168,0.08)}}
.hinfo{{margin-left:auto;font-family:'Share Tech Mono',monospace;font-size:10px;color:rgba(200,208,224,0.25);letter-spacing:1px}}
.hud-bot{{position:fixed;bottom:0;left:0;right:0;height:32px;background:linear-gradient(0deg,rgba(8,10,18,0.96) 60%,transparent);display:flex;align-items:flex-end;padding:0 16px 6px;gap:20px;z-index:10;pointer-events:none}}
.hud-bot span{{font-family:'Share Tech Mono',monospace;font-size:10px;color:rgba(200,208,224,0.25);letter-spacing:1px}}
.hud-bot .v{{color:rgba(91,224,168,0.55)}}
.sp{{position:fixed;right:0;top:44px;bottom:32px;width:210px;background:rgba(8,10,18,0.88);border-left:1px solid rgba(91,224,168,0.07);backdrop-filter:blur(10px);padding:14px 0;overflow-y:auto;z-index:10;transform:translateX(0);transition:transform .3s}}
.sp.hide{{transform:translateX(100%)}}
.sp-t{{font-family:'Share Tech Mono',monospace;font-size:9px;letter-spacing:2px;text-transform:uppercase;color:rgba(91,224,168,0.35);padding:0 14px;margin-bottom:6px}}
.sp-i{{display:flex;align-items:center;gap:7px;padding:5px 14px;cursor:pointer;font-family:'Share Tech Mono',monospace;font-size:11px;color:rgba(200,208,224,0.45);transition:all .12s;border-left:2px solid transparent}}
.sp-i:hover{{color:#c8d0e0;background:rgba(91,224,168,0.03);border-left-color:rgba(91,224,168,0.25)}}
.sp-i.on{{color:#5be0a8;border-left-color:#5be0a8}}
.sp-d{{width:6px;height:6px;border-radius:50%;flex-shrink:0}}
.sp-sep{{height:1px;background:rgba(91,224,168,0.05);margin:8px 14px}}
.tt{{position:fixed;pointer-events:none;z-index:20;background:rgba(8,10,18,0.93);border:1px solid rgba(91,224,168,0.18);border-radius:4px;padding:9px 12px;backdrop-filter:blur(8px);box-shadow:0 4px 20px rgba(0,0,0,0.5);opacity:0;transition:opacity .12s;max-width:270px;font-family:'Share Tech Mono',monospace}}
.tt.show{{opacity:1}}
.tt-r{{font-size:13px;color:#5be0a8;font-weight:600}}
.tt-v{{font-size:11px;color:#c8d0e0;margin-top:2px}}
.tt-d{{font-size:9px;color:rgba(200,208,224,0.35);margin-top:3px}}
.tt-p{{font-size:9px;color:rgba(200,208,224,0.25);margin-top:5px;padding-top:5px;border-top:1px solid rgba(91,224,168,0.08)}}
.vig{{position:fixed;inset:0;pointer-events:none;z-index:5;background:radial-gradient(ellipse at center,transparent 45%,rgba(8,10,18,0.45) 100%)}}
.scan{{position:fixed;inset:0;pointer-events:none;z-index:6;background:repeating-linear-gradient(0deg,transparent,transparent 2px,rgba(0,0,0,0.025) 2px,rgba(0,0,0,0.025) 4px);opacity:.4}}
</style>
</head>
<body>
<canvas id="c3d"></canvas>
<div class="vig"></div>
<div class="scan"></div>
<div class="hud-top">
<div class="logo">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none"><rect x="1" y="1" width="14" height="14" rx="2" stroke="#5be0a8" stroke-width="1.2"/><circle cx="5" cy="8" r="1.3" fill="#5be0a8" opacity=".5"/><circle cx="11" cy="8" r="1.3" fill="#5be0a8" opacity=".5"/><line x1="6.3" y1="8" x2="9.7" y2="8" stroke="#5be0a8" stroke-width=".7"/></svg>
{_esc(sch.title or 'KiCad Schematic')}
</div>
<div class="hsep"></div>
<button class="hbtn" onclick="resetCam()">RESET</button>
<button class="hbtn on" id="bGlow" onclick="togGlow()">GLOW</button>
<button class="hbtn on" id="bLbl" onclick="togLbl()">LABELS</button>
<button class="hbtn" id="bPnl" onclick="togPnl()">PANEL</button>
<div class="hinfo">Parsed from .kicad_sch • Three.js r128</div>
</div>
<div class="hud-bot">
<span>DESIGN <span class="v">{_esc(sch.title)}</span></span>
<span>REV <span class="v">{_esc(sch.rev)}</span></span>
<span>DATE <span class="v">{_esc(sch.date)}</span></span>
<span>COMPONENTS <span class="v">{len(real_components)}</span></span>
<span>NETS <span class="v">{len(net_list)}</span></span>
<span style="margin-left:auto">GENERATED FROM .kicad_sch</span>
</div>
<div class="sp hide" id="sp">
<div class="sp-t">NETS ({len(net_list)})</div>
{_build_net_panel(net_list)}
<div class="sp-i" onclick="hlNet('')"><div class="sp-d" style="background:#333"></div><i>Clear</i></div>
<div class="sp-sep"></div>
<div class="sp-t">COMPONENTS ({len(real_components)})</div>
{_build_comp_panel(real_components)}
</div>
<div class="tt" id="tt">
<div class="tt-r"></div><div class="tt-v"></div><div class="tt-d"></div><div class="tt-p"></div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script>
// ============================================================
// DATA FROM PARSER
// ============================================================
const COMPS = {json.dumps(comp_data, ensure_ascii=False)};
const WIRES = {json.dumps(wire_data, ensure_ascii=False)};
const JUNCS = {json.dumps(junc_data, ensure_ascii=False)};
const LABELS = {json.dumps(label_data, ensure_ascii=False)};
const PWR = {json.dumps(pwr_data, ensure_ascii=False)};
const BOARD_W = {board_w:.2f};
const BOARD_H = {board_h:.2f};
const NET_COLORS = {{
VIN:0xff4444, SW:0xff8800, VOUT:0x5be0a8, GND:0x556677,
BOOT:0x4488ff, FB:0xcc44ff, EN:0xccaa00
}};
const DEFAULT_NET_COLOR = 0x5be0a8;
function netColor(n) {{ return NET_COLORS[n] || DEFAULT_NET_COLOR; }}
// ============================================================
// SCENE
// ============================================================
const cv = document.getElementById('c3d');
const R = new THREE.WebGLRenderer({{canvas:cv,antialias:true}});
R.setPixelRatio(Math.min(devicePixelRatio,2));
R.setSize(innerWidth,innerHeight);
R.setClearColor(0x080a12);
R.shadowMap.enabled=true;
R.shadowMap.type=THREE.PCFSoftShadowMap;
R.toneMapping=THREE.ACESFilmicToneMapping;
R.toneMappingExposure=1.1;
const S = new THREE.Scene();
S.fog = new THREE.FogExp2(0x080a12,0.01);
const cam = new THREE.PerspectiveCamera(45,innerWidth/innerHeight,0.1,500);
cam.position.set(10,18,22);
// Orbit
let orb=false, oStart={{x:0,y:0}};
let sph={{t:0.65,p:1.0,r:Math.max(BOARD_W,BOARD_H)*1.5}};
let pan=new THREE.Vector3();
function updCam(){{
cam.position.set(
sph.r*Math.sin(sph.p)*Math.cos(sph.t)+pan.x,
sph.r*Math.cos(sph.p)+pan.y,
sph.r*Math.sin(sph.p)*Math.sin(sph.t)+pan.z
);
cam.lookAt(pan);
}}
cv.addEventListener('mousedown',e=>{{orb=true;oStart={{x:e.clientX,y:e.clientY}}}});
addEventListener('mousemove',e=>{{
if(!orb)return;
sph.t-=(e.clientX-oStart.x)*0.005;
sph.p=Math.max(0.15,Math.min(Math.PI-0.15,sph.p+(e.clientY-oStart.y)*0.005));
oStart={{x:e.clientX,y:e.clientY}};
}});
addEventListener('mouseup',()=>{{orb=false}});
cv.addEventListener('wheel',e=>{{e.preventDefault();sph.r=Math.max(5,Math.min(80,sph.r*(e.deltaY>0?1.08:0.92)))}},{{passive:false}});
// Touch
let tst=null,tld=0;
cv.addEventListener('touchstart',e=>{{
if(e.touches.length===1)tst={{x:e.touches[0].clientX,y:e.touches[0].clientY}};
else if(e.touches.length===2){{
const dx=e.touches[0].clientX-e.touches[1].clientX,dy=e.touches[0].clientY-e.touches[1].clientY;
tld=Math.sqrt(dx*dx+dy*dy);tst=null;
}}
}});
cv.addEventListener('touchmove',e=>{{
e.preventDefault();
if(e.touches.length===1&&tst){{
sph.t-=(e.touches[0].clientX-tst.x)*0.005;
sph.p=Math.max(0.15,Math.min(Math.PI-0.15,sph.p+(e.touches[0].clientY-tst.y)*0.005));
tst={{x:e.touches[0].clientX,y:e.touches[0].clientY}};
}} else if(e.touches.length===2){{
const dx=e.touches[0].clientX-e.touches[1].clientX,dy=e.touches[0].clientY-e.touches[1].clientY;
const d=Math.sqrt(dx*dx+dy*dy);
sph.r=Math.max(5,Math.min(80,sph.r*tld/d));tld=d;
}}
}},{{passive:false}});
// Lights
S.add(new THREE.AmbientLight(0x1a2040,0.6));
const dL=new THREE.DirectionalLight(0xddeeff,0.8);
dL.position.set(10,20,8);dL.castShadow=true;dL.shadow.mapSize.set(1024,1024);S.add(dL);
S.add(new THREE.DirectionalLight(0x5be0a8,0.12).translateX(-8).translateY(5).translateZ(-10));
const pL=new THREE.PointLight(0x4488ff,0.25,50);pL.position.set(-10,8,5);S.add(pL);
// PCB board
const pcbG=new THREE.BoxGeometry(BOARD_W,0.3,BOARD_H);
const pcbM=new THREE.MeshStandardMaterial({{color:0x0a3020,roughness:.7,metalness:.1}});
const pcb=new THREE.Mesh(pcbG,pcbM);
pcb.position.y=-0.15;pcb.receiveShadow=true;S.add(pcb);
const edgeM=new THREE.LineBasicMaterial({{color:0x5be0a8,transparent:true,opacity:0.1}});
S.add(new THREE.LineSegments(new THREE.EdgesGeometry(pcbG),edgeM));
const grid=new THREE.GridHelper(Math.max(BOARD_W,BOARD_H),Math.round(Math.max(BOARD_W,BOARD_H)*2),0x0e4030,0x0e4030);
grid.position.y=0.01;grid.material.transparent=true;grid.material.opacity=0.12;S.add(grid);
// Materials
const padM=new THREE.MeshStandardMaterial({{color:0xd4a84a,roughness:.25,metalness:.9}});
const icM=new THREE.MeshStandardMaterial({{color:0x1a1a24,roughness:.4,metalness:.2}});
const capCerM=new THREE.MeshStandardMaterial({{color:0xc8b898,roughness:.5,metalness:.05}});
const capElM=new THREE.MeshStandardMaterial({{color:0x2a2a30,roughness:.4,metalness:.3}});
const resM=new THREE.MeshStandardMaterial({{color:0x1a1a1e,roughness:.5,metalness:.1}});
const indM=new THREE.MeshStandardMaterial({{color:0x2a2a30,roughness:.35,metalness:.4}});
const allObj=[];
const glowArr=[];
const lblArr=[];
function mkGlow(c){{return new THREE.MeshBasicMaterial({{color:c,transparent:true,opacity:0.3}})}}
// ============================================================
// BUILD COMPONENTS
// ============================================================
COMPS.forEach(c=>{{
const g=new THREE.Group();
if(c.type==='IC'){{
const bw=3.6,bh=0.5,bd=4.8;
const body=new THREE.Mesh(new THREE.BoxGeometry(bw,bh,bd),icM);
body.position.set(c.x,bh/2+0.01,c.z);body.castShadow=true;g.add(body);
const mark=new THREE.Mesh(new THREE.BoxGeometry(bw*0.85,0.02,bd*0.6),
new THREE.MeshStandardMaterial({{color:0x3a3a44,roughness:.6,metalness:.1}}));
mark.position.set(c.x,bh+0.02,c.z);g.add(mark);
const dot=new THREE.Mesh(new THREE.SphereGeometry(0.08,8,8),new THREE.MeshBasicMaterial({{color:0xffffff}}));
dot.position.set(c.x-bw/2+0.2,bh+0.03,c.z-bd/2+0.2);g.add(dot);
const gl=new THREE.Mesh(new THREE.PlaneGeometry(bw+0.8,bd+0.8),mkGlow(0x5be0a8));
gl.rotation.x=-Math.PI/2;gl.position.set(c.x,0.02,c.z);g.add(gl);glowArr.push(gl);
body.userData={{ref:c.ref,value:c.value,desc:c.description,fp:c.footprint,
pins:c.pins.map(p=>p.num+':'+p.name).join(' ')}};
allObj.push(body);
}}
else if(c.type==='C'){{
const isE=c.value.includes('µF')&&!c.value.includes('nF');
const h=isE?0.55:0.3,w=isE?0.6:0.35,d=isE?0.45:0.25;
const body=new THREE.Mesh(new THREE.BoxGeometry(w,h,d),isE?capElM:capCerM);
body.position.set(c.x,h/2+0.01,c.z);body.castShadow=true;g.add(body);
const gl=new THREE.Mesh(new THREE.PlaneGeometry(w+0.5,d+0.8),mkGlow(netColor(c.pins[0]?.name||'')));
gl.rotation.x=-Math.PI/2;gl.position.set(c.x,0.02,c.z);g.add(gl);glowArr.push(gl);
body.userData={{ref:c.ref,value:c.value,desc:c.description,fp:c.footprint,
pins:c.pins.map((p,i)=>(i+1)+':'+p.name).join(' ')}};
allObj.push(body);
}}
else if(c.type==='R'){{
const isH=c.angle===90||c.angle===270;
const bw=isH?0.5:0.3,bh=0.2,bd=isH?0.15:0.5;
const body=new THREE.Mesh(new THREE.BoxGeometry(bw,bh,bd),resM);
body.position.set(c.x,bh/2+0.01,c.z);body.castShadow=true;g.add(body);
const gs=isH?[1.0,0.5]:[0.5,1.0];
const gl=new THREE.Mesh(new THREE.PlaneGeometry(gs[0],gs[1]),mkGlow(netColor(c.pins[0]?.name||'')));
gl.rotation.x=-Math.PI/2;gl.position.set(c.x,0.02,c.z);g.add(gl);glowArr.push(gl);
body.userData={{ref:c.ref,value:c.value,desc:c.description,fp:c.footprint,
pins:c.pins.map((p,i)=>(i+1)+':'+p.name).join(' ')}};
allObj.push(body);
}}
else if(c.type==='L'){{
const body=new THREE.Mesh(new THREE.BoxGeometry(1.0,0.6,0.9),indM);
body.position.set(c.x,0.31,c.z);body.castShadow=true;g.add(body);
const gl=new THREE.Mesh(new THREE.PlaneGeometry(1.6,1.4),mkGlow(0xff8800));
gl.rotation.x=-Math.PI/2;gl.position.set(c.x,0.02,c.z);g.add(gl);glowArr.push(gl);
body.userData={{ref:c.ref,value:c.value,desc:c.description,fp:c.footprint,
pins:c.pins.map((p,i)=>(i+1)+':'+p.name).join(' ')}};
allObj.push(body);
}}
S.add(g);
}});
// ============================================================
// BUILD TRACES
// ============================================================
const traceByNet={{}};
WIRES.forEach(w=>{{
const dx=w.x2-w.x1,dz=w.z2-w.z1;
const len=Math.sqrt(dx*dx+dz*dz);
if(len<0.01)return;
const nc=netColor(w.net);
const isPwr=['VIN','VOUT','GND','SW'].includes(w.net);
const tw=isPwr?0.12:0.07;
const mat=new THREE.MeshStandardMaterial({{color:nc,roughness:.25,metalness:.7,emissive:nc,emissiveIntensity:.2}});
const geo=new THREE.BoxGeometry(len,0.04,tw);
const m=new THREE.Mesh(geo,mat);
m.position.set((w.x1+w.x2)/2,0.03,(w.z1+w.z2)/2);
m.rotation.y=-Math.atan2(dz,dx);
S.add(m);
if(!traceByNet[w.net])traceByNet[w.net]=[];
traceByNet[w.net].push(m);
}});
// Junctions
JUNCS.forEach(j=>{{
const m=new THREE.Mesh(new THREE.SphereGeometry(0.08,8,8),new THREE.MeshBasicMaterial({{color:0x5be0a8}}));
m.position.set(j.x,0.06,j.z);S.add(m);
}});
// ============================================================
// LABELS (sprites)
// ============================================================
function mkLabel(text,x,y,z,color,sz){{
const cv2=document.createElement('canvas');
const ct=cv2.getContext('2d');
cv2.width=256;cv2.height=64;
ct.font='bold 26px Share Tech Mono,monospace';
ct.fillStyle='#'+color.toString(16).padStart(6,'0');
ct.textAlign='center';ct.textBaseline='middle';
ct.fillText(text,128,32);
const tex=new THREE.CanvasTexture(cv2);
tex.minFilter=THREE.LinearFilter;
const sp=new THREE.Sprite(new THREE.SpriteMaterial({{map:tex,transparent:true,opacity:0.85,depthTest:false}}));
sp.position.set(x,y,z);sp.scale.set(sz*2,sz*0.5,1);
S.add(sp);lblArr.push(sp);return sp;
}}
COMPS.forEach(c=>{{
if(c.type!=='PWR'){{
mkLabel(c.ref,c.x,1.2,c.z,0x5be0a8,0.5);
mkLabel(c.value,c.x,0.9,c.z,0x88aacc,0.4);
}}
}});
LABELS.forEach(l=>{{
mkLabel(l.text,l.x,0.55,l.z,l.type==='global'?0xffaa44:0xffcc66,0.45);
}});
// ============================================================
// RAYCASTING / TOOLTIP
// ============================================================
const rc=new THREE.Raycaster();
const ms=new THREE.Vector2();
const ttEl=document.getElementById('tt');
cv.addEventListener('mousemove',e=>{{
ms.x=(e.clientX/innerWidth)*2-1;
ms.y=-(e.clientY/innerHeight)*2+1;
rc.setFromCamera(ms,cam);
const hit=rc.intersectObjects(allObj);
if(hit.length){{
const d=hit[0].object.userData;
ttEl.querySelector('.tt-r').textContent=d.ref||'';
ttEl.querySelector('.tt-v').textContent=d.value||'';
ttEl.querySelector('.tt-d').textContent=d.desc||'';
ttEl.querySelector('.tt-p').textContent=d.pins||'';
ttEl.style.left=Math.min(e.clientX+14,innerWidth-280)+'px';
ttEl.style.top=(e.clientY-8)+'px';
ttEl.classList.add('show');
cv.style.cursor='crosshair';
}} else {{
ttEl.classList.remove('show');
cv.style.cursor=orb?'grabbing':'grab';
}}
}});
// ============================================================
// NET HIGHLIGHT
// ============================================================
let activeN='';
function hlNet(n){{
activeN=n;
document.querySelectorAll('.sp-i[data-net]').forEach(el=>el.classList.toggle('on',el.dataset.net===n));
Object.keys(traceByNet).forEach(tn=>{{
traceByNet[tn].forEach(m=>{{
if(!n){{m.material.opacity=1;m.material.transparent=false;m.material.emissiveIntensity=.2}}
else if(tn===n){{m.material.opacity=1;m.material.transparent=false;m.material.emissiveIntensity=.6}}
else{{m.material.transparent=true;m.material.opacity=0.08;m.material.emissiveIntensity=0}}
}});
}});
}}
// Controls
function resetCam(){{sph={{t:0.65,p:1.0,r:Math.max(BOARD_W,BOARD_H)*1.5}};pan.set(0,0,0)}}
function togGlow(){{const v=glowArr[0]?.visible!==false;glowArr.forEach(g=>g.visible=!v);document.getElementById('bGlow').classList.toggle('on',!v)}}
function togLbl(){{const v=lblArr[0]?.visible!==false;lblArr.forEach(l=>l.visible=!v);document.getElementById('bLbl').classList.toggle('on',!v)}}
function togPnl(){{const p=document.getElementById('sp');p.classList.toggle('hide');document.getElementById('bPnl').classList.toggle('on',!p.classList.contains('hide'))}}
// ============================================================
// ANIMATE
// ============================================================
function anim(t){{
requestAnimationFrame(anim);
updCam();
if(glowArr[0]?.visible){{
const p=0.25+Math.sin(t*0.002)*0.1;
glowArr.forEach(g=>{{if(g.material)g.material.opacity=p}});
}}
R.render(S,cam);
}}
addEventListener('resize',()=>{{cam.aspect=innerWidth/innerHeight;cam.updateProjectionMatrix();R.setSize(innerWidth,innerHeight)}});
anim(0);
</script>
</body>
</html>"""
return html
# ============================================================
# HELPERS
# ============================================================
def _esc(s: str) -> str:
"""HTML-escape a string."""
return str(s).replace('&','&').replace('<','<').replace('>','>').replace('"','"').replace("'","'")
NET_COLOR_MAP = {
'VIN': '#ff4444', 'SW': '#ff8800', 'VOUT': '#5be0a8', 'GND': '#556677',
'BOOT': '#4488ff', 'FB': '#cc44ff', 'EN': '#ccaa00',
}
def _net_color_css(net: str) -> str:
return NET_COLOR_MAP.get(net, '#5be0a8')
def _build_net_panel(net_list):
parts = []
for n in net_list:
c = _net_color_css(n)
en = _esc(n)
parts.append(f'<div class="sp-i" data-net="{en}" onclick="hlNet(\'{en}\')"><div class="sp-d" style="background:{c}"></div>{en}</div>')
return '\n '.join(parts)
def _build_comp_panel(comps):
parts = []
for c in comps:
parts.append(f'<div class="sp-i">{_esc(c.ref)} — {_esc(c.value)}</div>')
return '\n '.join(parts)
# ============================================================
# MAIN
# ============================================================
def main():
if len(sys.argv) < 2:
print("Usage: python kicad_sch_to_3d.py <input.kicad_sch> [output.html]")
sys.exit(1)
input_path = Path(sys.argv[1])
output_path = Path(sys.argv[2]) if len(sys.argv) > 2 else input_path.with_suffix('.3d.html')
print(f"[1/5] Reading {input_path} ...")
text = input_path.read_text(encoding='utf-8')
print(f"[2/5] Parsing S-expressions ({len(text):,} chars) ...")
parser = SExprParser(text)
sexpr = parser.parse()
print(f"[3/5] Extracting schematic data ...")
extractor = KiCadSchExtractor(sexpr)
sch = extractor.extract()
real_comps = [c for c in sch.components if not c.is_power]
print(f" Title: {sch.title}")
print(f" Components: {len(real_comps)}")
print(f" Wires: {len(sch.wires)}")
print(f" Junctions: {len(sch.junctions)}")
print(f" Labels: {len(sch.labels)}")
print(f" Lib syms: {len(sch.lib_symbols)}")
print(f"[4/5] Resolving nets ...")
resolver = NetResolver(sch)
wire_nets = resolver.resolve()
unique_nets = set(wire_nets.values())
named_nets = [n for n in unique_nets if not n.startswith('NET_')]
print(f" Nets found: {len(named_nets)} named, {len(unique_nets)-len(named_nets)} unnamed")
for n in sorted(named_nets):
count = sum(1 for v in wire_nets.values() if v == n)
print(f" {n:10s} — {count} wire segments")
print(f"[5/5] Generating Three.js HTML ...")
html = generate_threejs_html(sch, wire_nets)
output_path.write_text(html, encoding='utf-8')
print(f" Output: {output_path} ({len(html):,} bytes)")
print(" Done!")
if __name__ == '__main__':
main()