电子电路设计与 PCB 布局组件 (概念演示)
概述
这是一个交互式的 Web 组件,用于演示电子电路原理图设计和 PCB 布局的基本概念。用户可以从元件库中选择元件,在原理图和 PCB 画布上放置、移动,进行原理图连线,并触发模拟的 PCB 自动布线和高亮联动效果。请注意,这是一个高度简化的概念演示,并非功能完善的 EDA 工具。
主要功能
- 双视图编辑: 同时提供原理图 (Schematic) 和 PCB 布局 (Layout) 两个画布区域。
- 元件库与放置:
- 提供包含常用元件(电阻、电容、IC、连接器、LED)的元件库。
- 支持从库中选择元件并点击放置到任一画布(另一画布会同步生成默认位置的对应元件)。
- 基本交互:
- 选择/移动: 可选中元件并在原理图或 PCB 画布上拖动。
- 原理图连线: 在原理图画布上,可通过点击引脚来创建连接线 (Wire),并自动分配网络 (Net)。
- 概念性 PCB 功能:
- 模拟自动布线: 点击按钮后,根据原理图的网络连接,在 PCB 画布上逐步绘制简单的 L 形走线 (Trace) 连接对应引脚(视觉效果)。
- 视图联动:
- 高亮显示: 可切换高亮联动模式。选中原理图/PCB 上的元件、引脚或导线时,另一视图中对应的元素以及属于同一网络的所有元素都会高亮显示。
- 界面与风格:
- 采用苹果科技风格,界面简洁。
- 三栏响应式布局(控制 | 原理图 | PCB),适应不同屏幕尺寸。
如何使用
- 打开页面: 在浏览器中打开
index.html
。 - 选择工具: 在左侧"工具"栏选择 “选择/移动” 或 “连接 (原理图)”。
- 放置元件:
- 点击左侧"常用元件"列表中的元件类型。
- 此时工具会自动切换到"放置"模式 (光标变为 copy 状)。
- 在原理图或 PCB 画布上点击想要放置的位置。
- 放置后工具会自动切换回 “选择/移动”。
- 移动元件:
- 确保处于 “选择/移动” 工具模式。
- 在任一画布上点击并拖动元件主体进行移动。
- 原理图连线:
- 切换到 “连接 (原理图)” 工具模式。
- 在 原理图画布 上,依次点击两个需要连接的元件引脚。
- 连线会自动生成,并分配网络号。点击空白处或同一引脚可取消连线操作。
- PCB 模拟布线:
- 完成原理图连线后,点击左侧操作区的 “模拟布线 (PCB)” 按钮。
- 观察 PCB 画布上逐步绘制出连接走线的动画(仅为视觉效果)。
- 高亮联动:
- 点击 “启用/禁用高亮联动” 按钮切换模式。
- 启用后,在 “选择/移动” 模式下,点击原理图或 PCB 上的元件、引脚、导线,查看关联元素的高亮效果。
- 清空: 点击 “清空画布” 按钮将移除所有元件和连线。
文件结构
pcb-layout-tool/
├── index.html # HTML 页面结构
├── styles.css # CSS 样式定义
├── script.js # JavaScript 交互与绘图逻辑
└── README.md # 本说明文件
技术栈
- HTML5 / CSS3 (Flexbox, CSS Variables)
- JavaScript (ES6+)
- HTML Canvas 2D API
重要提示
- 概念性演示: 功能高度简化,仅用于展示基本概念和交互流程。
- 绘图基础: 使用基础的 Canvas 2D API 绘制,未进行深度优化。
- 元件符号/封装: 仅为简单的矩形或圆形示意,非标准库。
- 原理图连线: 目前仅支持直线连接,无复杂的布线规则。
- PCB 自动布线: 仅为视觉动画模拟,使用极简的 L 形路径连接引脚,不执行任何实际的布线算法、DRC (设计规则检查) 或优化。
- 数据持久化: 不支持保存或加载设计。
- 性能: 放置大量元件或执行复杂操作可能导致性能下降。
效果展示
源码
index.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>电子电路设计与PCB布局 - 工业控制器</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div class="pcb-design-container">
<header class="app-header">
<h1>电子电路设计 & PCB 布局</h1>
<p>应用于工业控制器电路板开发 (概念演示)</p>
</header>
<div class="main-layout-area">
<!-- 左侧: 元件库与工具箱 -->
<aside class="controls-and-library-panel">
<h2>元件库 & 工具</h2>
<div class="tool-section">
<h3>工具</h3>
<button id="selectTool" class="tool-button active">选择/移动</button>
<button id="wireTool" class="tool-button">连接 (原理图)</button>
<!-- <button id="routeTool" class="tool-button">布线 (PCB)</button> -->
</div>
<div class="library-section">
<h3>常用元件</h3>
<ul id="componentList">
<li data-type="resistor">电阻 (R)</li>
<li data-type="capacitor">电容 (C)</li>
<li data-type="ic_dip8">IC (DIP8)</li>
<li data-type="ic_qfp32">IC (QFP32)</li>
<li data-type="connector">连接器</li>
<li data-type="led">LED</li>
</ul>
<small>点击选择元件,然后在画布上放置。</small>
</div>
<div class="action-section">
<h3>操作</h3>
<button id="autoRouteButton">模拟布线 (PCB)</button>
<button id="clearButton">清空画布</button>
<button id="toggleHighlightButton">切换高亮联动</button>
</div>
<div class="status-display">
<h3>状态</h3>
<p id="statusText">准备就绪。</p>
</div>
</aside>
<!-- 中间: 原理图绘制区 -->
<section class="schematic-view-area view-panel">
<div class="view-header">原理图 (Schematic)</div>
<canvas id="schematicCanvas"></canvas>
</section>
<!-- 右侧: PCB 布局区 -->
<section class="pcb-view-area view-panel">
<div class="view-header">PCB 布局 (Layout)</div>
<canvas id="pcbCanvas"></canvas>
</section>
</div>
<footer class="app-footer">
<p>概念性电子电路设计与 PCB 布局组件</p>
</footer>
</div>
<script src="script.js"></script>
</body>
</html>
styles.css
/* styles.css - PCB Layout Tool Component */
:root {
--primary-bg: #ffffff;
--secondary-bg: #f5f5f7;
--controls-bg: #e8e8ed;
--canvas-bg: #ffffff; /* White canvas background */
--text-primary: #1d1d1f;
--text-secondary: #515154;
--accent-blue: #007aff;
--accent-blue-hover: #005ec4;
--border-color: #d2d2d7;
--shadow-color: rgba(0, 0, 0, 0.08);
--highlight-color: rgba(255, 215, 0, 0.5); /* Gold highlight */
--schematic-wire-color: #333333;
--pcb-trace-color: #00aa00; /* Green for traces */
--component-fill: #f0f0f0;
--component-stroke: #888888;
--apple-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
body {
font-family: var(--apple-font);
margin: 0;
background-color: var(--secondary-bg);
color: var(--text-primary);
font-size: 14px;
line-height: 1.5;
overflow: hidden; /* Prevent body scroll */
}
.pcb-design-container {
width: 100%;
height: 100vh; /* Full viewport height */
display: flex;
flex-direction: column;
background-color: var(--primary-bg);
box-sizing: border-box;
}
.app-header {
flex-shrink: 0; /* Prevent header shrinking */
background-color: var(--primary-bg);
text-align: center;
padding: 10px 20px; /* Reduced padding */
border-bottom: 1px solid var(--border-color);
}
.app-header h1 {
margin: 0 0 2px 0;
font-size: 1.5em; /* Slightly smaller */
font-weight: 600;
}
.app-header p {
margin: 0;
color: var(--text-secondary);
font-size: 0.85em;
}
.main-layout-area {
flex-grow: 1; /* Fill remaining vertical space */
display: flex;
width: 100%;
overflow: hidden; /* Prevent layout area scroll */
}
.controls-and-library-panel {
width: 280px; /* Fixed width for controls/library */
flex-shrink: 0;
background-color: var(--controls-bg);
padding: 15px;
border-right: 1px solid var(--border-color);
overflow-y: auto; /* Allow scrolling for controls */
box-sizing: border-box;
display: flex;
flex-direction: column;
}
.controls-and-library-panel h2 {
margin-top: 0;
margin-bottom: 20px;
font-size: 1.2em;
font-weight: 600;
color: var(--text-primary);
border-bottom: 1px solid #c8c8cc;
padding-bottom: 8px;
}
.tool-section,
.library-section,
.action-section,
.status-display {
margin-bottom: 20px;
}
.tool-section h3,
.library-section h3,
.action-section h3,
.status-display h3 {
margin-top: 0;
margin-bottom: 10px;
font-size: 0.95em;
font-weight: 600;
color: var(--text-secondary);
}
.tool-button {
display: block;
width: 100%;
padding: 8px 12px;
margin-bottom: 8px;
font-size: 0.9em;
text-align: left;
background-color: #fff;
border: 1px solid var(--border-color);
border-radius: 5px;
cursor: pointer;
transition: background-color 0.2s ease, border-color 0.2s ease;
}
.tool-button:hover {
background-color: #f8f8fa;
border-color: #b8b8bd;
}
.tool-button.active {
background-color: var(--accent-blue);
color: white;
border-color: var(--accent-blue);
}
#componentList {
list-style: none;
padding: 0;
margin: 0;
}
#componentList li {
padding: 6px 10px;
margin-bottom: 5px;
background-color: #fff;
border: 1px solid transparent; /* Placeholder for selected state */
border-radius: 4px;
cursor: pointer;
transition: background-color 0.2s ease, border-color 0.2s ease;
}
#componentList li:hover {
background-color: #f8f8fa;
}
#componentList li.selected {
border-color: var(--accent-blue);
background-color: #e0efff; /* Light blue background */
}
.library-section small {
font-size: 0.8em;
color: var(--text-secondary);
}
.action-section button {
display: block;
width: 100%;
padding: 8px 12px;
margin-bottom: 10px;
font-size: 0.9em;
font-weight: 500;
color: #fff;
background-color: #5856d6; /* Purple for actions */
border: none;
border-radius: 5px;
cursor: pointer;
transition: background-color 0.2s ease;
}
.action-section button:hover {
background-color: #4341a0;
}
#clearButton {
background-color: #dc3545; /* Red for clear */
}
#clearButton:hover {
background-color: #c82333;
}
.status-display {
margin-top: auto; /* Push status to bottom */
padding-top: 15px;
border-top: 1px solid #c8c8cc;
}
#statusText {
font-size: 0.85em;
color: var(--text-primary);
min-height: 2.5em;
}
.view-panel {
flex-grow: 1; /* Allow panels to grow */
position: relative; /* For positioning header/canvas */
border-left: 1px solid var(--border-color);
display: flex;
flex-direction: column;
overflow: hidden; /* Important: Prevent canvas overflow */
}
.view-header {
flex-shrink: 0;
padding: 8px 15px;
background-color: var(--secondary-bg);
border-bottom: 1px solid var(--border-color);
font-weight: 500;
color: var(--text-secondary);
font-size: 0.9em;
}
canvas {
display: block; /* Remove extra space below canvas */
width: 100%;
height: 100%; /* Fill the available space within the parent */
background-color: var(--canvas-bg);
cursor: crosshair; /* Default cursor for drawing areas */
}
.tool-button.active[id="selectTool"] + .schematic-view-area canvas,
.tool-button.active[id="selectTool"] + .pcb-view-area canvas {
cursor: default; /* Or 'grab'/'move' depending on interaction */
}
.app-footer {
flex-shrink: 0;
text-align: center;
padding: 8px 20px;
border-top: 1px solid var(--border-color);
background-color: var(--primary-bg);
color: var(--text-secondary);
font-size: 0.8em;
}
/* Responsive adjustments */
@media (max-width: 900px) { /* Stack schematic and PCB */
.main-layout-area {
flex-direction: column; /* Stack all sections */
}
.controls-and-library-panel {
width: 100%;
max-height: 40vh; /* Limit height */
border-right: none;
border-bottom: 1px solid var(--border-color);
order: 1; /* Show controls first */
}
.view-panel {
border-left: none;
flex-grow: 1;
height: 30vh; /* Equal height distribution (approx) */
min-height: 200px; /* Ensure minimum drawing space */
order: 2; /* Show views after controls */
}
}
@media (max-width: 480px) { /* Further adjustments for very small screens */
.controls-and-library-panel {
max-height: 50vh; /* Allow more control space */
}
.view-panel {
height: 25vh;
min-height: 150px;
}
.app-header h1 {
font-size: 1.3em;
}
}
script.js
// script.js - PCB Layout Tool Component
document.addEventListener('DOMContentLoaded', () => {
// --- DOM Elements ---
const schematicCanvas = document.getElementById('schematicCanvas');
const pcbCanvas = document.getElementById('pcbCanvas');
const componentList = document.getElementById('componentList');
const selectToolBtn = document.getElementById('selectTool');
const wireToolBtn = document.getElementById('wireTool');
const autoRouteButton = document.getElementById('autoRouteButton');
const clearButton = document.getElementById('clearButton');
const toggleHighlightButton = document.getElementById('toggleHighlightButton');
const statusText = document.getElementById('statusText');
// --- Canvas Contexts ---
const schCtx = schematicCanvas.getContext('2d');
const pcbCtx = pcbCanvas.getContext('2d');
// --- Default Style Values (Fallbacks for CSS Variables) ---
const styles = {
highlightColor: 'rgba(255, 215, 0, 0.5)', // Gold highlight
schematicWireColor: '#333333',
pcbTraceColor: '#00aa00', // Green for traces
componentFill: '#f0f0f0',
componentStroke: '#888888',
textPrimary: '#1d1d1f',
accentBlue: '#007aff',
padColor: '#b87333', // Copper color
silkscreenColor: 'rgba(180, 180, 180, 0.5)'
};
// --- State Variables ---
let components = []; // {id, type, schX, schY, pcbX, pcbY, width, height, pins: [{id, def:{x,y}, schX, schY, pcbX, pcbY, net}, ...]}
let wires = []; // {id, startCompId, startPinId, endCompId, endPinId, net}
let traces = []; // {id, net, path: [{x,y}, ...]}
let nextCompId = 1;
let nextWireId = 1;
let nextTraceId = 1;
let currentTool = 'select'; // 'select', 'wire', 'place'
let selectedComponentType = null; // Type of component selected from library for placing
let selectedItem = null; // Generic selected object {type: 'component'/'pin'/'wire'/'trace', id, ...}
let draggingItem = null; // {type, id, isSchematic, startX, startY, offsetX, offsetY}
let wiringState = { startPin: null }; // { componentId, pinId, x, y }
let highlightLinked = false;
let nextNet = 1;
let isAnimating = false; // Flag to prevent multiple animations
// --- Update Status Function ---
function updateStatus(message) {
if (statusText) {
statusText.textContent = message;
} else {
console.warn("Status text element not found.");
}
}
// --- Component Definitions (Simplified) ---
const componentDefs = {
resistor: { schWidth: 40, schHeight: 15, pcbWidth: 10, pcbHeight: 4, pins: [{ id: 1, x: -20, y: 0 }, { id: 2, x: 20, y: 0 }] },
capacitor: { schWidth: 20, schHeight: 15, pcbWidth: 6, pcbHeight: 6, pins: [{ id: 1, x: -10, y: 0 }, { id: 2, x: 10, y: 0 }] },
ic_dip8: { schWidth: 40, schHeight: 60, pcbWidth: 15, pcbHeight: 25, pins: Array.from({ length: 8 }, (_, i) => ({ id: i + 1, x: i < 4 ? -20 : 20, y: (i % 4) * 15 - 22.5 })) },
ic_qfp32: { schWidth: 70, schHeight: 70, pcbWidth: 20, pcbHeight: 20, pins: Array.from({ length: 32 }, (_, i) => {
const side = Math.floor(i / 8);
const posOnSide = i % 8;
const spacing = 2.5;
const halfSidePins = 7 * spacing / 2;
const edgeOffset = 10;
if (side === 0) return { id: i + 1, x: -edgeOffset, y: posOnSide * spacing - halfSidePins }; // Left
if (side === 1) return { id: i + 1, x: posOnSide * spacing - halfSidePins, y: edgeOffset }; // Top (relative to center)
if (side === 2) return { id: i + 1, x: edgeOffset, y: -(posOnSide * spacing - halfSidePins) }; // Right
return { id: i + 1, x: -(posOnSide * spacing - halfSidePins), y: -edgeOffset }; // Bottom
}) },
connector: { schWidth: 15, schHeight: 50, pcbWidth: 8, pcbHeight: 18, pins: Array.from({ length: 5 }, (_, i) => ({ id: i + 1, x: 0, y: i * 10 - 20 })) },
led: { schWidth: 25, schHeight: 25, pcbWidth: 5, pcbHeight: 5, pins: [{ id: 1, x: -12.5, y: 0 }, { id: 2, x: 12.5, y: 0 }] }
};
// --- Canvas Setup & Drawing ---
function resizeCanvases() {
[schematicCanvas, pcbCanvas].forEach(canvas => {
const container = canvas.parentElement;
// Check if container has valid dimensions
if (container && container.clientWidth > 0 && container.clientHeight > 0) {
// Subtract padding/border if needed, or use offsetWidth/Height
const width = container.clientWidth;
const height = container.clientHeight;
canvas.width = width;
canvas.height = height;
} else {
// Fallback or wait for layout
console.warn("Canvas container not ready or has zero dimensions during resize.");
}
});
drawAll(); // Redraw after resize
}
function clearCanvas(ctx) {
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
}
function drawAll() {
drawSchematic();
drawPCB();
}
// --- Schematic Drawing ---
function drawSchematic() {
clearCanvas(schCtx);
wires.forEach(wire => drawSchematicWire(wire));
components.forEach(comp => drawSchematicComponent(comp));
if (currentTool === 'wire' && wiringState.startPin) {
// Draw preview wire - handled in mousemove
}
}
function drawSchematicComponent(comp) {
const def = componentDefs[comp.type];
if (!def) { console.error(`Definition not found for type: ${comp.type}`); return; }
schCtx.save();
schCtx.translate(comp.schX, comp.schY);
schCtx.fillStyle = styles.componentFill;
schCtx.strokeStyle = styles.componentStroke;
schCtx.lineWidth = 1;
schCtx.fillRect(-def.schWidth / 2, -def.schHeight / 2, def.schWidth, def.schHeight);
schCtx.strokeRect(-def.schWidth / 2, -def.schHeight / 2, def.schWidth, def.schHeight);
schCtx.fillStyle = styles.textPrimary;
schCtx.font = '10px sans-serif';
schCtx.textAlign = 'center';
schCtx.fillText(`${comp.type.toUpperCase()}${comp.id}`, 0, -def.schHeight / 2 - 5);
comp.pins.forEach(pin => {
const relX = pin.def ? pin.def.x : 0;
const relY = pin.def ? pin.def.y : 0;
schCtx.beginPath();
schCtx.moveTo(relX, relY);
if (Math.abs(relX) === def.schWidth / 2) { // Horizontal pins
schCtx.lineTo(relX + (relX > 0 ? 5 : -5), relY);
} else { // Vertical pins
schCtx.lineTo(relX, relY + (relY > 0 ? 5 : -5));
}
schCtx.strokeStyle = styles.schematicWireColor;
schCtx.stroke();
// Highlight pin calculation needs absolute pin coords stored in pin object
const absPinX = pin.schX; // Use stored absolute coords
const absPinY = pin.schY;
if ( (selectedItem && selectedItem.type === 'pin' && selectedItem.componentId === comp.id && selectedItem.pinId === pin.id) ||
(highlightLinked && selectedItem && selectedItem.type === 'component' && pin.net && isNetSelected(pin.net)) ) {
schCtx.fillStyle = styles.highlightColor;
schCtx.beginPath();
schCtx.arc(relX, relY, 4, 0, Math.PI * 2); // Highlight around relative pin pos
schCtx.fill();
}
});
if (selectedItem && selectedItem.type === 'component' && selectedItem.id === comp.id) {
schCtx.strokeStyle = styles.accentBlue;
schCtx.lineWidth = 1.5;
schCtx.strokeRect(-def.schWidth / 2 - 2, -def.schHeight / 2 - 2, def.schWidth + 4, def.schHeight + 4);
}
schCtx.restore();
}
function drawSchematicWire(wire) {
const startPin = findPin(wire.startCompId, wire.startPinId);
const endPin = findPin(wire.endCompId, wire.endPinId);
if (!startPin || !endPin) return;
schCtx.beginPath();
schCtx.moveTo(startPin.schX, startPin.schY);
schCtx.lineTo(endPin.schX, endPin.schY);
schCtx.strokeStyle = styles.schematicWireColor;
schCtx.lineWidth = 1;
if ((selectedItem && selectedItem.type === 'wire' && selectedItem.id === wire.id) ||
(highlightLinked && wire.net && isNetSelected(wire.net)) ) {
schCtx.strokeStyle = styles.accentBlue;
schCtx.lineWidth = 2.5;
}
schCtx.stroke();
}
// --- PCB Drawing ---
function drawPCB() {
clearCanvas(pcbCtx);
traces.forEach(trace => drawPCBTrace(trace));
components.forEach(comp => drawPCBFootprint(comp));
}
function drawPCBFootprint(comp) {
const def = componentDefs[comp.type];
if (!def) return;
pcbCtx.save();
pcbCtx.translate(comp.pcbX, comp.pcbY);
pcbCtx.fillStyle = styles.silkscreenColor;
pcbCtx.fillRect(-def.pcbWidth / 2, -def.pcbHeight / 2, def.pcbWidth, def.pcbHeight);
comp.pins.forEach(pin => {
const padSize = comp.type.includes('ic') ? 1.5 : 2.5;
const relX = pin.def ? pin.def.x : 0; // Use relative def for drawing pad pos
const relY = pin.def ? pin.def.y : 0;
pcbCtx.fillStyle = styles.padColor;
pcbCtx.beginPath();
// Draw square pads for ICs, round for others (example)
if (comp.type.includes('ic') || comp.type.includes('conn')) {
pcbCtx.fillRect(relX - padSize, relY - padSize, padSize * 2, padSize * 2);
} else {
pcbCtx.arc(relX, relY, padSize, 0, Math.PI * 2);
pcbCtx.fill();
}
if ( (selectedItem && selectedItem.type === 'pin' && selectedItem.componentId === comp.id && selectedItem.pinId === pin.id) ||
(highlightLinked && selectedItem && selectedItem.type === 'component' && pin.net && isNetSelected(pin.net)) ) {
pcbCtx.fillStyle = styles.highlightColor;
pcbCtx.beginPath();
pcbCtx.arc(relX, relY, padSize + 2, 0, Math.PI * 2);
pcbCtx.fill();
}
});
if (selectedItem && selectedItem.type === 'component' && selectedItem.id === comp.id) {
pcbCtx.strokeStyle = styles.accentBlue;
pcbCtx.lineWidth = 1.5;
pcbCtx.strokeRect(-def.pcbWidth / 2 - 2, -def.pcbHeight / 2 - 2, def.pcbWidth + 4, def.pcbHeight + 4);
}
pcbCtx.restore();
}
function drawPCBTrace(trace) {
if (!trace.path || trace.path.length < 2) return;
pcbCtx.beginPath();
pcbCtx.moveTo(trace.path[0].x, trace.path[0].y);
for (let i = 1; i < trace.path.length; i++) {
pcbCtx.lineTo(trace.path[i].x, trace.path[i].y);
}
pcbCtx.strokeStyle = styles.pcbTraceColor;
pcbCtx.lineWidth = 1.5;
if ((selectedItem && selectedItem.type === 'trace' && selectedItem.id === trace.id) ||
(highlightLinked && trace.net && isNetSelected(trace.net)) ) {
pcbCtx.strokeStyle = styles.accentBlue;
pcbCtx.lineWidth = 3;
}
pcbCtx.stroke();
}
// --- Interaction Logic ---
function setupEventListeners() {
selectToolBtn.addEventListener('click', () => setTool('select'));
wireToolBtn.addEventListener('click', () => setTool('wire'));
componentList.addEventListener('click', (e) => {
if (e.target.tagName === 'LI') {
selectComponentFromLibrary(e.target);
}
});
[schematicCanvas, pcbCanvas].forEach(canvas => {
canvas.addEventListener('mousedown', handleMouseDown);
canvas.addEventListener('mousemove', handleMouseMove);
canvas.addEventListener('mouseup', handleMouseUp);
canvas.addEventListener('click', handleCanvasClick);
});
autoRouteButton.addEventListener('click', simulateAutoRouting);
clearButton.addEventListener('click', clearDesign);
toggleHighlightButton.addEventListener('click', toggleHighlight);
window.addEventListener('resize', resizeCanvases);
}
function setTool(tool) {
currentTool = tool;
wiringState.startPin = null;
document.querySelectorAll('.tool-button').forEach(btn => btn.classList.remove('active'));
const activeBtn = document.getElementById(tool + 'Tool');
if (activeBtn) activeBtn.classList.add('active');
deselectComponentFromLibrary(); // Deselect library item when changing tool
selectedItem = null; // Deselect any canvas item
updateStatus(`工具已切换: ${tool}`);
[schematicCanvas, pcbCanvas].forEach(canvas => {
if (tool === 'wire') canvas.style.cursor = 'crosshair';
else if (tool === 'place') canvas.style.cursor = 'copy';
else canvas.style.cursor = 'default';
});
drawAll(); // Redraw to remove selection highlights if any
}
function selectComponentFromLibrary(listItem) {
// No explicit 'place' tool, selection implies placement mode
currentTool = 'place';
document.querySelectorAll('#componentList li').forEach(li => li.classList.remove('selected'));
listItem.classList.add('selected');
selectedComponentType = listItem.dataset.type;
updateStatus(`已选择元件: ${selectedComponentType},请在画布上点击放置。`);
document.querySelectorAll('.tool-button').forEach(btn => btn.classList.remove('active')); // Deactivate tool buttons
[schematicCanvas, pcbCanvas].forEach(canvas => canvas.style.cursor = 'copy');
}
function deselectComponentFromLibrary() {
document.querySelectorAll('#componentList li').forEach(li => li.classList.remove('selected'));
selectedComponentType = null;
// If tool was 'place', revert to 'select'
if (currentTool === 'place') {
setTool('select');
}
}
function getMousePos(canvas, event) {
const rect = canvas.getBoundingClientRect();
return {
x: event.clientX - rect.left,
y: event.clientY - rect.top
};
}
function handleCanvasClick(event) {
const pos = getMousePos(event.target, event);
const isSchematic = event.target === schematicCanvas;
if (currentTool === 'place' && selectedComponentType) {
placeComponent(pos.x, pos.y, isSchematic);
} else if (currentTool === 'select') {
selectItemAt(pos.x, pos.y, isSchematic);
} else if (currentTool === 'wire' && isSchematic) {
handleWireClick(pos.x, pos.y);
}
}
function handleMouseDown(event) {
if (currentTool !== 'select') return;
const pos = getMousePos(event.target, event);
const item = getItemAt(pos.x, pos.y, event.target === schematicCanvas);
if (item && item.type === 'component') {
// Check if we clicked the same component again to prevent losing selection
if (!selectedItem || selectedItem.type !== 'component' || selectedItem.id !== item.component.id) {
selectItemAt(pos.x, pos.y, event.target === schematicCanvas); // Select before dragging
}
draggingItem = {
type: 'component',
id: item.component.id,
isSchematic: event.target === schematicCanvas,
startX: pos.x,
startY: pos.y,
offsetX: pos.x - (event.target === schematicCanvas ? item.component.schX : item.component.pcbX),
offsetY: pos.y - (event.target === schematicCanvas ? item.component.schY : item.component.pcbY)
};
[schematicCanvas, pcbCanvas].forEach(canvas => canvas.style.cursor = 'grabbing');
} else {
// Clear selection if clicking empty space
if (selectedItem) {
selectItemAt(pos.x, pos.y, event.target === schematicCanvas); // This will clear if no item found
}
draggingItem = null; // Ensure no dragging starts
}
}
function handleMouseMove(event) {
const pos = getMousePos(event.target, event);
const isSchematic = event.target === schematicCanvas;
if (draggingItem) {
const targetX = pos.x - draggingItem.offsetX;
const targetY = pos.y - draggingItem.offsetY;
moveComponent(draggingItem.id, targetX, targetY, draggingItem.isSchematic);
} else if (currentTool === 'wire' && wiringState.startPin && isSchematic) {
drawAll(); // Redraw base
schCtx.beginPath();
schCtx.moveTo(wiringState.startPin.x, wiringState.startPin.y);
schCtx.lineTo(pos.x, pos.y);
schCtx.strokeStyle = 'rgba(0, 0, 255, 0.5)';
schCtx.setLineDash([5, 3]);
schCtx.stroke();
schCtx.setLineDash([]);
}
}
function handleMouseUp(event) {
if (draggingItem) {
updateStatus(`元件 ${draggingItem.id} 已移动。`);
draggingItem = null;
[schematicCanvas, pcbCanvas].forEach(canvas => canvas.style.cursor = (currentTool === 'wire' ? 'crosshair' : 'default'));
// No need to redraw here, mousemove already did
}
}
function placeComponent(x, y, isSchematic) {
if (!selectedComponentType) return;
const def = componentDefs[selectedComponentType];
const compId = nextCompId++;
const newComp = {
id: compId,
type: selectedComponentType,
// Place on both canvases simultaneously, using default pos for the non-clicked one
schX: isSchematic ? x : 10 + (compId % 5) * 60,
schY: isSchematic ? y : 50 + Math.floor(compId / 5) * 80,
pcbX: !isSchematic ? x : 50 + (compId % 8) * 40,
pcbY: !isSchematic ? y : 50 + Math.floor(compId / 8) * 40,
pins: [] // Initialize pins array
};
// Calculate absolute pin positions
newComp.pins = def.pins.map(pDef => ({
id: pDef.id,
def: pDef, // Store relative definition
schX: newComp.schX + pDef.x,
schY: newComp.schY + pDef.y,
pcbX: newComp.pcbX + pDef.x, // Use relative pin def for PCB too
pcbY: newComp.pcbY + pDef.y,
net: null
}));
components.push(newComp);
updateStatus(`放置元件: ${newComp.type}${newComp.id}`);
deselectComponentFromLibrary(); // This will switch tool back to 'select'
drawAll();
}
function moveComponent(id, x, y, isSchematic) {
const comp = components.find(c => c.id === id);
if (!comp) return;
if (isSchematic) {
comp.schX = x;
comp.schY = y;
comp.pins.forEach(pin => {
pin.schX = x + pin.def.x;
pin.schY = y + pin.def.y;
});
// If PCB view exists, maybe snap PCB pos to grid or keep relative?
// For now, moving one doesn't auto-move the other precisely.
} else {
comp.pcbX = x;
comp.pcbY = y;
comp.pins.forEach(pin => {
pin.pcbX = x + pin.def.x;
pin.pcbY = y + pin.def.y;
});
}
drawAll();
}
function getItemAt(x, y, isSchematic, tolerance = 5) {
// Check pins first
for (const comp of components) {
for (const pin of comp.pins) {
const pinX = isSchematic ? pin.schX : pin.pcbX;
const pinY = isSchematic ? pin.schY : pin.pcbY;
if (Math.abs(x - pinX) < tolerance && Math.abs(y - pinY) < tolerance) {
return { type: 'pin', componentId: comp.id, pinId: pin.id, x: pinX, y: pinY, pin: pin };
}
}
}
// Check component bodies
for (const comp of components) {
const compX = isSchematic ? comp.schX : comp.pcbX;
const compY = isSchematic ? comp.schY : comp.pcbY;
const def = componentDefs[comp.type];
const width = isSchematic ? def.schWidth : def.pcbWidth;
const height = isSchematic ? def.schHeight : def.pcbHeight;
if (x > compX - width / 2 - tolerance && x < compX + width / 2 + tolerance &&
y > compY - height / 2 - tolerance && y < compY + height / 2 + tolerance) {
return { type: 'component', component: comp }; // Return the component object
}
}
// Check wires (Schematic only)
if (isSchematic) {
for (const wire of wires) {
const startPin = findPin(wire.startCompId, wire.startPinId);
const endPin = findPin(wire.endCompId, wire.endPinId);
if (!startPin || !endPin) continue;
// Basic line collision check (distance from point to line segment)
// Simplified: check distance to endpoints for now
if ((Math.abs(x - startPin.schX) < tolerance && Math.abs(y - startPin.schY) < tolerance) ||
(Math.abs(x - endPin.schX) < tolerance && Math.abs(y - endPin.schY) < tolerance)) {
// This might select the pin instead, needs better line hit test
}
// TODO: Implement point-to-line segment distance check
}
}
// Check traces (PCB only) - TODO
return null;
}
function selectItemAt(x, y, isSchematic) {
const itemInfo = getItemAt(x, y, isSchematic);
if (itemInfo) {
// Reconstruct selectedItem structure for consistency
if (itemInfo.type === 'component') {
selectedItem = { type: 'component', id: itemInfo.component.id };
} else if (itemInfo.type === 'pin') {
selectedItem = { type: 'pin', componentId: itemInfo.componentId, pinId: itemInfo.pinId };
}
// TODO: Handle wire/trace selection
updateStatus(`选中: ${selectedItem.type} ${selectedItem.id !== undefined ? selectedItem.id : selectedItem.pinId}`);
} else {
selectedItem = null;
updateStatus("选择已清除。");
}
drawAll(); // Redraw to show selection
}
function findPin(componentId, pinId) {
const comp = components.find(c => c.id === componentId);
return comp ? comp.pins.find(p => p.id === pinId) : null;
}
function handleWireClick(x, y) {
const clickedPinInfo = getItemAt(x, y, true, 8); // Schematic only, larger tolerance
if (clickedPinInfo && clickedPinInfo.type === 'pin') {
if (!wiringState.startPin) {
wiringState.startPin = clickedPinInfo; // Store { type, componentId, pinId, x, y, pin }
updateStatus(`开始连接,起点: 元件 ${clickedPinInfo.componentId}, 引脚 ${clickedPinInfo.pinId}。点击终点引脚。`);
} else {
if (wiringState.startPin.componentId === clickedPinInfo.componentId &&
wiringState.startPin.pinId === clickedPinInfo.pinId) {
wiringState.startPin = null;
updateStatus("连接已取消。");
} else {
const startPinObj = wiringState.startPin.pin; // Actual pin object
const endPinObj = clickedPinInfo.pin; // Actual pin object
if (startPinObj && endPinObj) {
let assignedNet = startPinObj.net || endPinObj.net;
if (!assignedNet) {
assignedNet = `N${nextNet++}`;
}
// Assign net to all connected pins/wires implicitly
propagateNet(startPinObj, assignedNet);
propagateNet(endPinObj, assignedNet);
const newWire = {
id: nextWireId++,
startCompId: wiringState.startPin.componentId,
startPinId: wiringState.startPin.pinId,
endCompId: clickedPinInfo.componentId,
endPinId: clickedPinInfo.pinId,
net: assignedNet
};
wires.push(newWire);
updateStatus(`连接完成: (${newWire.startCompId}:${newWire.startPinId} <-> ${newWire.endCompId}:${newWire.endPinId}) Net ${assignedNet}`);
wiringState.startPin = null;
} else {
updateStatus("错误:无法找到引脚对象。");
wiringState.startPin = null;
}
}
}
} else {
if (wiringState.startPin) {
wiringState.startPin = null;
updateStatus("连接已取消。");
}
}
drawAll();
}
// Propagate net assignment through connected wires
function propagateNet(startPin, net) {
if (!startPin || startPin.net === net) return; // Already assigned or no pin
startPin.net = net;
// Find wires connected to this pin and propagate to the other end
wires.forEach(wire => {
let otherPin = null;
if (wire.startCompId === startPin.componentId && wire.startPinId === startPin.id) {
otherPin = findPin(wire.endCompId, wire.endPinId);
wire.net = net; // Assign net to wire itself
} else if (wire.endCompId === startPin.componentId && wire.endPinId === startPin.id) {
otherPin = findPin(wire.startCompId, wire.startPinId);
wire.net = net; // Assign net to wire itself
}
if (otherPin) {
propagateNet(otherPin, net);
}
});
// Also update corresponding pins on other components if needed (e.g., through pre-existing wires)
components.forEach(comp => {
comp.pins.forEach(p => {
if(p !== startPin && p.net === null) { // Check other pins
// See if this pin is connected to the startPin via a wire
const connectedWire = wires.find(w =>
(w.startCompId === startPin.componentId && w.startPinId === startPin.id && w.endCompId === p.componentId && w.endPinId === p.id) ||
(w.endCompId === startPin.componentId && w.endPinId === startPin.id && w.startCompId === p.componentId && w.startPinId === p.id)
);
if(connectedWire) {
propagateNet(p, net);
}
}
});
});
}
function isNetSelected(net) {
if (!selectedItem || !net) return false;
if (selectedItem.type === 'component') {
const comp = components.find(c => c.id === selectedItem.id);
return comp && comp.pins.some(p => p.net === net);
} else if (selectedItem.type === 'pin') {
const pin = findPin(selectedItem.componentId, selectedItem.pinId);
return pin && pin.net === net;
} else if (selectedItem.type === 'wire') {
const wire = wires.find(w => w.id === selectedItem.id);
return wire && wire.net === net;
} else if (selectedItem.type === 'trace') {
const trace = traces.find(t => t.id === selectedItem.id);
return trace && trace.net === net;
}
return false;
}
// --- Action Button Logics (Simplified/Conceptual) ---
function simulateAutoRouting() {
if (isAnimating) return; // Prevent re-entry
isAnimating = true;
autoRouteButton.disabled = true;
updateStatus("开始模拟 PCB 自动布线 (概念性)... ");
traces = [];
nextTraceId = 1;
let currentNetIndex = 1;
const maxNetIndex = nextNet -1;
let traceDelay = 50; // Faster delay
function routeNextNet() {
const netName = `N${currentNetIndex}`;
if (currentNetIndex > maxNetIndex) {
updateStatus("模拟布线完成。");
isAnimating = false;
autoRouteButton.disabled = false;
drawPCB();
return;
}
let pinsInNet = [];
components.forEach(comp => {
comp.pins.forEach(pin => {
if (pin.net === netName) {
pinsInNet.push({ x: pin.pcbX, y: pin.pcbY });
}
});
});
if (pinsInNet.length < 2) {
currentNetIndex++;
setTimeout(routeNextNet, 5); // Skip quickly
return;
}
// Simple routing: connect pins sequentially with L-shapes
const newTrace = { id: nextTraceId++, net: netName, path: [] };
let currentPathPoint = { x: pinsInNet[0].x, y: pinsInNet[0].y };
newTrace.path.push(currentPathPoint);
let pinIndex = 1;
function routeToNextPin() {
if (pinIndex >= pinsInNet.length) {
traces.push(newTrace); // Add completed trace
currentNetIndex++;
setTimeout(routeNextNet, traceDelay * 2); // Pause between nets
return;
}
const targetPin = pinsInNet[pinIndex];
// Simple L-route: X then Y
const midPoint = { x: targetPin.x, y: currentPathPoint.y };
// Animate drawing segments
animateTraceSegment(currentPathPoint, midPoint, () => {
animateTraceSegment(midPoint, targetPin, () => {
currentPathPoint = targetPin;
pinIndex++;
routeToNextPin();
});
});
}
routeToNextPin(); // Start routing for the current net
}
// Helper for animating trace drawing
function animateTraceSegment(start, end, callback) {
const dx = end.x - start.x;
const dy = end.y - start.y;
const distance = Math.sqrt(dx*dx + dy*dy);
const segments = Math.max(1, Math.ceil(distance / 5)); // Draw in 5px segments
let currentSegment = 0;
function drawSegment() {
if (currentSegment > segments) {
// Ensure the final point is exactly added
if (newTrace.path[newTrace.path.length - 1].x !== end.x || newTrace.path[newTrace.path.length - 1].y !== end.y) {
newTrace.path.push({ x: end.x, y: end.y });
}
drawPCB();
if (callback) setTimeout(callback, traceDelay / 2);
return;
}
const t = currentSegment / segments;
const currentX = start.x + dx * t;
const currentY = start.y + dy * t;
newTrace.path.push({ x: currentX, y: currentY });
currentSegment++;
drawPCB();
setTimeout(drawSegment, traceDelay / segments);
}
drawSegment();
}
routeNextNet(); // Start the process
}
function clearDesign() {
components = [];
wires = [];
traces = [];
selectedItem = null;
draggingItem = null;
wiringState.startPin = null;
nextCompId = 1;
nextWireId = 1;
nextTraceId = 1;
nextNet = 1;
if (highlightLinked) toggleHighlight();
updateStatus("画布已清空。");
drawAll();
}
function toggleHighlight() {
highlightLinked = !highlightLinked;
toggleHighlightButton.textContent = highlightLinked ? "禁用高亮联动" : "启用高亮联动";
updateStatus(highlightLinked ? "高亮联动已启用。选择查看关联。" : "高亮联动已禁用。");
if (selectedItem) drawAll();
}
// --- Initialization Call ---
// Use setTimeout to ensure layout is complete before getting canvas size
setTimeout(() => {
resizeCanvases();
setupEventListeners();
setTool('select');
updateStatus("电子电路设计组件初始化成功。");
}, 100); // Delay slightly
});