Content is user-generated and unverified.
#!/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 &bull; 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('&','&amp;').replace('<','&lt;').replace('>','&gt;').replace('"','&quot;').replace("'","&#39;") 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()
Content is user-generated and unverified.
    KiCad Schematic to 3D Visualization Tool | Claude