diff --git a/web-ui/arduino-emulator.html b/web-ui/arduino-emulator.html index f0dd871..630b913 100644 --- a/web-ui/arduino-emulator.html +++ b/web-ui/arduino-emulator.html @@ -5,13 +5,40 @@ Arduino Emulator @@ -141,7 +195,14 @@ sketch.ino 컴파일 중...
- + + +
+ + + + +
속도 - - -
@@ -160,15 +218,99 @@
ARDUINO UNO · ATmega328P
- - - - - ATmega328P - DIGITAL - ANALOG / POWER - - L + + + + + DIGITAL + + + + + + + + + + + + + + + + + + + RST + + + + + + + + + + + + + + + + + + + + + + + + + ATmega + 328P + + + PWR + + + L + + + + + ICSP + + ANALOG / POWER + + + + + + + + + + + + + + + + + + + + + + + + + + + +
@@ -188,6 +330,17 @@
+ + + -
-
DIGITAL PINS
-
-
-
ANALOG PINS
-
-
@@ -237,22 +383,69 @@ - -
-
-

📦 라이브러리 관리 - -

-
- - + +
+
+ 📌 GPIO 핀 상태 + +
+
+
DIGITAL PINS
+
+
+
ANALOG PINS
+
+
+
+ + + +
+
+ 🔌 브레드보드 & SPICE +
+ +
- +
+ +
+
+ 도구: + + + + + + + + + + + + +
+
+ +
+
+ +
+
+ SPICE 넷리스트 + + + +
+
+ +
+
▶ DC 시뮬레이션을 실행하면
노드 전압과 소자 상태가
여기에 표시됩니다.
+
+
-
불러오는 중...
-
@@ -463,9 +656,9 @@ function oledFlushCanvas() { for (let bit = 0; bit < 8; bit++) { const on = (byte >> bit) & 1; const row = page * 8 + bit; - const pr = on ? (oled.inverse ? 0 : 0xff) : 0; - const pg = on ? (oled.inverse ? 0 : 0x5a) : 0; - const pb = on ? 0xff : 0; + const pr = on ? (oled.inverse ? 0xff : 0x60) : 0; + const pg = on ? (oled.inverse ? 0xff : 0xa5) : 0; + const pb = on ? (oled.inverse ? 0xff : 0xfa) : 0; for (let dy = 0; dy < 2; dy++) { for (let dx = 0; dx < 2; dx++) { const idx = ((row * 2 + dy) * 256 + (col * 2 + dx)) * 4; @@ -481,6 +674,164 @@ function oledFlushCanvas() { oledCtx.putImageData(img, 0, 0); } +// ── TFT LCD emulator (ST7735 / ILI9341 · hardware SPI) ─────────────────────── +// Pins: CS=D10(PB2), DC=D9(PB1), RST=D8(PB0), MOSI=D11, SCK=D13 +let tftCtx = null; +const tft = { + cs:true, dc:true, + pendingCmd:-1, pendingArgs:[], pendingNeed:0, + inRamWr:false, pixelByte:0, pixelByteIdx:0, + xs:0, xe:127, ys:0, ye:159, curX:0, curY:0, + w:128, h:160, + framebuf:null, // Uint16Array, stride 480 + dirty:false, active:false, _sizeReady:false, + madctl:0, inverse:false, +}; + +// arg byte counts for common init commands +const _TFT_ARGC = { + 0x26:1,0x36:1,0x3A:1,0x44:2,0x2A:4,0x2B:4, + 0xB0:1,0xB1:3,0xB2:5,0xB3:6,0xB4:1,0xB5:4,0xB6:3,0xB7:1, + 0xBB:1,0xBD:1,0xBE:1, + 0xC0:1,0xC1:1,0xC2:2,0xC3:1,0xC4:1,0xC5:1,0xC6:1,0xC7:1, + 0xD0:2,0xD3:3, + 0xE0:14,0xE1:14,0xE2:12,0xE3:4, + 0xF0:5,0xF1:5,0xF2:5,0xF3:5, +}; + +function tftReset() { + Object.assign(tft, { + cs:true, dc:true, pendingCmd:-1, pendingArgs:[], pendingNeed:0, + inRamWr:false, pixelByteIdx:0, + xs:0, xe:127, ys:0, ye:159, curX:0, curY:0, + w:128, h:160, + framebuf: new Uint16Array(480*480), + dirty:false, active:false, _sizeReady:false, madctl:0, inverse:false, + }); + const s = document.getElementById('tft-section'); + if (s) s.style.display = 'none'; + const dot = document.getElementById('tft-dot'); + if (dot) dot.classList.remove('on'); + const c = document.getElementById('tft-canvas'); + if (c) { c.width=128; c.height=160; tftCtx=c.getContext('2d'); tftCtx.fillStyle='#000'; tftCtx.fillRect(0,0,128,160); } +} + +function _tftShowSection() { + const s = document.getElementById('tft-section'); + if (s && s.style.display==='none') { + s.style.display=''; + document.getElementById('tft-dot').classList.add('on'); + document.getElementById('tft-label').textContent = `TFT LCD ${tft.w}×${tft.h}`; + } +} + +function _tftResizeCanvas() { + const c = document.getElementById('tft-canvas'); + if (!c) return; + c.width=tft.w; c.height=tft.h; + tftCtx = c.getContext('2d'); + const lbl = document.getElementById('tft-label'); + if (lbl) lbl.textContent = `TFT LCD ${tft.w}×${tft.h}`; +} + +function _tftCmd(cmd, args) { + switch(cmd) { + case 0x01: // SWRESET + tft.framebuf.fill(0); tft.dirty=true; break; + case 0x11: case 0x13: case 0x29: // SLPOUT / NORON / DISPON + tft.active=true; _tftShowSection(); break; + case 0x20: tft.inverse=false; tft.dirty=true; break; + case 0x21: tft.inverse=true; tft.dirty=true; break; + case 0x2A: // CASET + tft.xs=(args[0]<<8)|args[1]; tft.xe=(args[2]<<8)|args[3]; tft.curX=tft.xs; + // 첫 RAMWR 전까지만 w/h 결정 허용 (ILI9341은 DISPON 후 첫 draw에서 CASET/RASET 전송) + if (!tft._sizeReady && tft.xe+1 > tft.w) tft.w=tft.xe+1; + break; + case 0x2B: // RASET + tft.ys=(args[0]<<8)|args[1]; tft.ye=(args[2]<<8)|args[3]; tft.curY=tft.ys; + if (!tft._sizeReady && tft.ye+1 > tft.h) tft.h=tft.ye+1; + break; + case 0x2C: // RAMWR — 첫 픽셀 쓰기에서 w/h 확정 후 캔버스 리사이즈 + tft.inRamWr=true; tft.pixelByteIdx=0; + tft.curX=tft.xs; tft.curY=tft.ys; + tft.active=true; + if (!tft._sizeReady) { tft._sizeReady=true; _tftResizeCanvas(); } + _tftShowSection(); + break; + case 0x36: { // MADCTL — MV 비트 변경 시 w↔h 프리스왑, 다음 RAMWR에서 캔버스 확정 + const prevMv=(tft.madctl>>5)&1, newMv=(args[0]>>5)&1; + tft.madctl=args[0]; + if (tft._sizeReady && prevMv!==newMv) { + const tmp=tft.w; tft.w=tft.h; tft.h=tmp; + tft._sizeReady=false; // 다음 RAMWR에서 _tftResizeCanvas 재실행 + } + break; + } + } +} + +function tftSPIByte(byte) { + if (!tft.dc) { + // Command byte — cancel any ongoing RAMWR / arg collection + tft.inRamWr=false; tft.pixelByteIdx=0; + const n = _TFT_ARGC[byte]??0; + if (n===0) { _tftCmd(byte,[]); tft.pendingCmd=-1; tft.pendingNeed=0; } + else { tft.pendingCmd=byte; tft.pendingArgs=[]; tft.pendingNeed=n; } + } else { + // Data byte + if (tft.pendingNeed>0) { + tft.pendingArgs.push(byte); + if (--tft.pendingNeed===0) { _tftCmd(tft.pendingCmd,tft.pendingArgs); tft.pendingCmd=-1; tft.pendingArgs=[]; } + return; + } + if (!tft.inRamWr) return; + // RGB565 pixel data (big-endian, 2 bytes per pixel) + if (tft.pixelByteIdx===0) { tft.pixelByte=byte; tft.pixelByteIdx=1; } + else { + tft.pixelByteIdx=0; + let c = (tft.pixelByte<<8)|byte; + if (tft.inverse) c ^= 0xFFFF; + const xi=tft.curX, yi=tft.curY; + if (xi<480 && yi<480) { tft.framebuf[yi*480+xi]=c; tft.dirty=true; } + if (++tft.curX>tft.xe){ tft.curX=tft.xs; if(++tft.curY>tft.ye) tft.curY=tft.ys; } + } + } +} + +function tftFlushCanvas() { + if (!tftCtx||!tft.dirty||!tft.active) return; + tft.dirty=false; + const {w,h}=tft, bgr=(tft.madctl>>3)&1; + const img=tftCtx.createImageData(w,h); + for (let y=0;y>11)&0x1F, g6=(c>>5)&0x3F, b5=c&0x1F; + if (bgr){ const t=r5; r5=b5; b5=t; } + const i=(y*w+x)*4; + img.data[i] =(r5<<3)|(r5>>2); + img.data[i+1]=(g6<<2)|(g6>>4); + img.data[i+2]=(b5<<3)|(b5>>2); + img.data[i+3]=255; + } + tftCtx.putImageData(img,0,0); +} + +function setupSPI(cpu) { + // Intercept SPDR writes (data addr 0x4E) — simulate instant SPI transfer + if (cpu.writeHooks) { + cpu.writeHooks[0x4E] = (value) => { + cpu.data[0x4D] |= 0x80; // set SPIF (transfer complete) + if (!tft.cs) tftSPIByte(value); + }; + } + // Track CS=PB2(D10) and DC=PB1(D9) pin changes + portB.addListener((value) => { + const cs=!!((value>>2)&1), dc=!!((value>>1)&1); + if (!tft.cs && cs) { tft.inRamWr=false; tft.pendingNeed=0; tft.pendingArgs=[]; } + tft.cs=cs; tft.dc=dc; + }); +} + function oledWriteData(byte) { oled.gddram[oled.page * 128 + oled.col] = byte; oledDirty = true; @@ -605,9 +956,10 @@ function setupTWI(cpu) { // ── State ──────────────────────────────────────────────────────────────────── let cpu = null, uart = null, portB = null, portC = null, portD = null; let rafId = null, ticksPerFrame = 160000, totalCycles = 0, running = false; -let hexData = null; +let hexData = null, emulatorReady = false; const PIN_COUNT_D = 14, PIN_COUNT_A = 6; let serialLineSpan = null; +let serialByteBuffer = []; // ── UI helpers ─────────────────────────────────────────────────────────────── function setStatus(cls, text) { @@ -708,6 +1060,7 @@ function initEmulator(hex) { if (timer2Config) new AVRTimer(cpu, timer2Config); setupTWI(cpu); + setupSPI(cpu); uart = new AVRUSART(cpu, { rxCompleteInterrupt: 0x24, dataRegisterEmptyInterrupt: 0x26, txCompleteInterrupt: 0x28, @@ -715,9 +1068,15 @@ function initEmulator(hex) { }, 16e6); serialLineSpan = null; + serialByteBuffer = []; uart.onByteTransmit = (value) => { - const ch = String.fromCharCode(value); - if (ch === '\r') return; + if (value === 0x0D) return; // \r + serialByteBuffer.push(value); + const first = serialByteBuffer[0]; + const expectedLen = first < 0x80 ? 1 : first < 0xE0 ? 2 : first < 0xF0 ? 3 : 4; + if (serialByteBuffer.length < expectedLen) return; + const ch = new TextDecoder('utf-8').decode(new Uint8Array(serialByteBuffer)); + serialByteBuffer = []; const out = document.getElementById('serial-output'); if (!serialLineSpan) { serialLineSpan = document.createElement('span'); @@ -732,6 +1091,7 @@ function initEmulator(hex) { totalCycles = 0; lcdReset(); oledReset(); + tftReset(); const oledCanvas = document.getElementById('oled-canvas'); oledCtx = oledCanvas.getContext('2d'); oledCtx.clearRect(0, 0, 256, 128); @@ -746,11 +1106,11 @@ function initEmulator(hex) { } }); - document.getElementById('btn-run').disabled = false; - document.getElementById('btn-reset').disabled = false; + emulatorReady = true; setStatus('ready', '준비'); document.getElementById('overlay').style.display = 'none'; - appendSerial('에뮬레이터 준비 완료. ▶ 실행을 누르세요.\n', 'sys'); + appendSerial('에뮬레이터 준비 완료. 보드의 파워 스위치를 켜세요.\n', 'sys'); + _updatePowerSwitch(); } // ── Run loop ────────────────────────────────────────────────────────────────── @@ -765,6 +1125,7 @@ function runFrame() { totalCycles = cpu.cycles; updatePinUI(); oledFlushCanvas(); + tftFlushCanvas(); const simSec = (totalCycles / 16e6).toFixed(2); document.getElementById('footer-cycles').textContent = `${Math.round(totalCycles).toLocaleString()} cycles (${simSec}s)`; rafId = requestAnimationFrame(runFrame); @@ -772,8 +1133,7 @@ function runFrame() { running = false; setStatus('error', '오류'); appendSerial('실행 오류: ' + e.message + '\n', 'error'); - document.getElementById('btn-run').disabled = false; - document.getElementById('btn-stop').disabled = true; + _updatePowerSwitch(); } } @@ -782,8 +1142,7 @@ window.startEmulator = () => { running = true; appendSerial('▶ 실행 시작\n', 'sys'); setStatus('running', '실행 중'); - document.getElementById('btn-run').disabled = true; - document.getElementById('btn-stop').disabled = false; + _updatePowerSwitch(); runFrame(); }; @@ -791,102 +1150,63 @@ window.stopEmulator = () => { running = false; if (rafId) { cancelAnimationFrame(rafId); rafId = null; } setStatus('stopped', '정지'); - document.getElementById('btn-run').disabled = false; - document.getElementById('btn-stop').disabled = true; + _updatePowerSwitch(); }; window.resetEmulator = () => { + if (!hexData) return; stopEmulator(); - if (hexData) { appendSerial('\n--- 리셋 ---\n', 'sys'); initEmulator(hexData); } + emulatorReady = false; + _updatePowerSwitch(); + appendSerial('\n--- 리셋 ---\n', 'sys'); + initEmulator(hexData); }; window.setSpeed = (val) => { ticksPerFrame = Math.round(160000 * parseInt(val)); }; -// ── Library manager ─────────────────────────────────────────────────────────── -let libTab = 'installed'; - -window.openLibModal = async () => { - document.getElementById('lib-modal').classList.add('open'); - if (libTab === 'installed') await loadInstalledLibs(); -}; -window.closeLibModal = () => document.getElementById('lib-modal').classList.remove('open'); - -window.switchLibTab = async (tab) => { - libTab = tab; - document.getElementById('tab-installed').classList.toggle('active', tab === 'installed'); - document.getElementById('tab-search').classList.toggle('active', tab === 'search'); - document.getElementById('lib-search-row').style.display = tab === 'search' ? 'flex' : 'none'; - document.getElementById('lib-status').textContent = ''; - if (tab === 'installed') await loadInstalledLibs(); - else document.getElementById('lib-list').innerHTML = '
라이브러리 이름을 검색하세요.
'; -}; - -async function loadInstalledLibs() { - document.getElementById('lib-list').innerHTML = '
불러오는 중...
'; - try { - const r = await fetch('/api/arduino/libs'); - const data = await r.json(); - renderLibList(data.libs || [], 'installed'); - } catch { document.getElementById('lib-list').innerHTML = '
오류
'; } +// ── 보드 파워 스위치 ─────────────────────────────────────────────────────────── +function _updatePowerSwitch() { + const arc = document.getElementById('pw-arc'); + const line = document.getElementById('pw-line'); + const bg = document.getElementById('pw-bg'); + const ring = document.getElementById('pw-ring'); + const pwrLed = document.getElementById('board-pwr-led'); + if (!arc) return; + if (running) { + arc.setAttribute('stroke','#22c55e'); + line.setAttribute('stroke','#22c55e'); + bg.setAttribute('fill','#071a0d'); + bg.setAttribute('stroke','#22c55e'); + ring.setAttribute('stroke','#166534'); + bg.style.filter='drop-shadow(0 0 3px #22c55e)'; + if (pwrLed) { pwrLed.setAttribute('fill','#22c55e'); pwrLed.setAttribute('stroke','#22c55e'); pwrLed.style.filter='drop-shadow(0 0 2px #22c55e)'; } + } else { + const clr = emulatorReady ? '#2a4a2a' : '#252545'; + arc.setAttribute('stroke', clr); + line.setAttribute('stroke', clr); + bg.setAttribute('fill','#07070f'); + bg.setAttribute('stroke', emulatorReady ? '#2a4a2a' : '#252545'); + ring.setAttribute('stroke','#1e1e3e'); + bg.style.filter=''; + if (pwrLed) { pwrLed.setAttribute('fill','#0d2a0d'); pwrLed.setAttribute('stroke','#1a5a1a'); pwrLed.style.filter=''; } + } } -window.doLibSearch = async () => { - const q = document.getElementById('lib-search-input').value.trim(); - if (!q) return; - document.getElementById('lib-list').innerHTML = '
검색 중...
'; - try { - const r = await fetch('/api/arduino/libs/search?q=' + encodeURIComponent(q)); - const data = await r.json(); - renderLibList(data.libs || [], 'search'); - } catch { document.getElementById('lib-list').innerHTML = '
오류
'; } +// ── 테마 ─────────────────────────────────────────────────────────────────────── +window.setTheme = (name) => { + document.body.setAttribute('data-theme', name); + localStorage.setItem('emu-theme', name); + document.querySelectorAll('.tdot').forEach(d => d.classList.remove('active')); + const el = document.getElementById('td-' + name); + if (el) el.classList.add('active'); }; -function renderLibList(libs, mode) { - const el = document.getElementById('lib-list'); - if (!libs.length) { el.innerHTML = '
없음
'; return; } - el.innerHTML = libs.map(l => ` -
-
-
${l.name}
-
${l.author || ''} · v${l.version || '?'}
- ${l.sentence ? `
${l.sentence}
` : ''} -
-
- ${mode === 'installed' - ? `` - : `` - } -
-
`).join(''); -} - -window.libInstall = async (name) => { - const st = document.getElementById('lib-status'); - st.textContent = `설치 중: ${name}...`; - try { - const r = await fetch('/api/arduino/libs/install', { - method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ name }) - }); - const data = await r.json(); - if (data.error) { st.textContent = '오류: ' + data.error.slice(0, 120); return; } - st.textContent = `✅ ${name} 설치 완료`; - } catch(e) { st.textContent = '오류: ' + e.message; } +window.togglePower = () => { + if (running) { stopEmulator(); return; } + if (!emulatorReady) return; + startEmulator(); }; -window.libUninstall = async (name) => { - if (!confirm(`"${name}" 을 삭제할까요?`)) return; - const st = document.getElementById('lib-status'); - st.textContent = `삭제 중: ${name}...`; - try { - const r = await fetch('/api/arduino/libs/uninstall', { - method: 'DELETE', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ name }) - }); - const data = await r.json(); - if (data.error) { st.textContent = '오류: ' + data.error.slice(0, 120); return; } - st.textContent = `✅ ${name} 삭제 완료`; - await loadInstalledLibs(); - } catch(e) { st.textContent = '오류: ' + e.message; } -}; // ── Boot ────────────────────────────────────────────────────────────────────── async function boot() { @@ -924,6 +1244,584 @@ async function boot() { } catch(e) { showOverlayError('서버 오류: ' + e.message); } } +// 저장된 테마 복원 +setTheme(localStorage.getItem('emu-theme') || 'cosmic'); + +// ── Pin popup ───────────────────────────────────────────────────────────────── +window.togglePinPopup = () => { + document.getElementById('pin-popup').classList.toggle('open'); +}; + +// Drag support for pin popup +(function() { + const popup = document.getElementById('pin-popup'); + const hdr = document.getElementById('pin-popup-hdr'); + let dragging = false, ox = 0, oy = 0; + hdr.addEventListener('mousedown', e => { + if (e.target.tagName === 'BUTTON') return; + dragging = true; + const r = popup.getBoundingClientRect(); + ox = e.clientX - r.left; + oy = e.clientY - r.top; + e.preventDefault(); + }); + document.addEventListener('mousemove', e => { + if (!dragging) return; + let nx = e.clientX - ox, ny = e.clientY - oy; + nx = Math.max(0, Math.min(nx, window.innerWidth - popup.offsetWidth)); + ny = Math.max(0, Math.min(ny, window.innerHeight - popup.offsetHeight)); + popup.style.left = nx + 'px'; + popup.style.top = ny + 'px'; + popup.style.right = 'auto'; + }); + document.addEventListener('mouseup', () => { dragging = false; }); +})(); + +// ── Breadboard & SPICE Simulator ────────────────────────────────────────────── + +const BB = window._BB = { + ROWS: 30, PITCH: 14, HOLE_R: 4, + COL_L: ['a','b','c','d','e'], COL_R: ['f','g','h','i','j'], + GAP: 20, RAIL: 32, // wider rail (holds + and - with gap) + MT: 38, ML: 44, MR: 40, MB: 18, + tool: 'wire', items: [], sel: null, nextId: 1, +}; +const BB_COLOR = { wire:'#60a5fa', res:'#fbbf24', led:'#f87171', cap:'#a78bfa', btn:'#34d058', vcc:'#f87171', gnd:'#6aa6f8', ard:'#fb923c' }; + +function bbXY(row, col) { + const allC = [...BB.COL_L, ...BB.COL_R]; + const ci = allC.indexOf(col); + const gw = BB.COL_L.length * BB.PITCH; + const x = ci < 5 + ? BB.ML + BB.RAIL + ci * BB.PITCH + : BB.ML + BB.RAIL + gw + BB.GAP + (ci-5) * BB.PITCH; + return { x, y: BB.MT + (row-1)*BB.PITCH }; +} + +function bbRailXY(row, rail) { + const y = BB.MT + (row-1)*BB.PITCH; + if (rail === '+') return { x: BB.ML + 8, y }; + return { x: BB.ML + BB.RAIL - 8, y }; // - is at ML+24 +} + +function bbNode(row, col) { + if (col === '+') return 'VCC'; + if (col === '-') return 'GND'; + return `N_${row}_${BB.COL_L.includes(col) ? 'L' : 'R'}`; +} + +function bbHoleToXY(h) { + if (h.col === '+') return bbRailXY(h.row, '+'); + if (h.col === '-') return bbRailXY(h.row, '-'); + return bbXY(h.row, h.col); +} + +function bbHit(cx, cy) { + for (let r = 1; r <= BB.ROWS; r++) { + const p = bbRailXY(r, '+'), m = bbRailXY(r, '-'); + if (Math.hypot(cx-p.x, cy-p.y) < BB.HOLE_R+5) return {row:r, col:'+'}; + if (Math.hypot(cx-m.x, cy-m.y) < BB.HOLE_R+5) return {row:r, col:'-'}; + } + for (let r = 1; r <= BB.ROWS; r++) { + for (const col of [...BB.COL_L, ...BB.COL_R]) { + const {x,y} = bbXY(r, col); + if (Math.hypot(cx-x, cy-y) < BB.HOLE_R+5) return {row:r, col}; + } + } + return null; +} + +function bbRender() { + const cv = document.getElementById('bb-canvas'); + if (!cv) return; + const ctx = cv.getContext('2d'); + const W = cv.width, H = cv.height; + ctx.clearRect(0,0,W,H); + ctx.fillStyle = '#080812'; ctx.fillRect(0,0,W,H); + + const allC = [...BB.COL_L, ...BB.COL_R]; + const gw = BB.COL_L.length * BB.PITCH; + const railH = (BB.ROWS-1)*BB.PITCH + BB.PITCH; + const railTop = BB.MT - BB.PITCH*0.5; + + // Rail backgrounds (+red, -blue) + ctx.fillStyle = 'rgba(248,113,113,0.08)'; + ctx.fillRect(BB.ML+2, railTop, BB.RAIL/2-4, railH); + ctx.fillStyle = 'rgba(96,165,250,0.08)'; + ctx.fillRect(BB.ML+BB.RAIL/2+2, railTop, BB.RAIL/2-4, railH); + // Divider line in rail + ctx.strokeStyle = '#1a1a30'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(BB.ML+BB.RAIL/2, railTop); ctx.lineTo(BB.ML+BB.RAIL/2, railTop+railH); ctx.stroke(); + // Center gap + const gx = BB.ML+BB.RAIL+gw; + ctx.fillStyle = '#06060e'; + ctx.fillRect(gx, railTop, BB.GAP, railH); + ctx.strokeStyle = '#181830'; ctx.lineWidth = 1; + ctx.strokeRect(gx, railTop, BB.GAP, railH); + + // Column labels + ctx.fillStyle = '#3a3a60'; ctx.font = '8px IBM Plex Mono,monospace'; + ctx.textAlign = 'center'; ctx.textBaseline = 'bottom'; + for (const col of allC) { + const {x} = bbXY(1, col); + ctx.fillText(col, x, BB.MT - 4); + } + ctx.fillStyle = '#9a4040'; ctx.fillText('+', BB.ML+8, BB.MT-6); + ctx.fillStyle = '#40508a'; ctx.fillText('−', BB.ML+BB.RAIL-8, BB.MT-6); + + // Row labels + ctx.fillStyle = '#2a2a50'; ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; + for (let r = 1; r <= BB.ROWS; r += 5) { + const y = BB.MT + (r-1)*BB.PITCH; + ctx.fillText(r, BB.ML-4, y); + } + + // Draw items first (wires behind holes) + for (const item of BB.items) bbDrawItem(ctx, item); + + // Holes + for (let r = 1; r <= BB.ROWS; r++) { + for (const col of allC) { + const {x,y} = bbXY(r, col); + ctx.beginPath(); ctx.arc(x,y,BB.HOLE_R,0,Math.PI*2); + ctx.fillStyle = '#131322'; ctx.fill(); + ctx.strokeStyle = '#2a2a4a'; ctx.lineWidth = 0.8; ctx.stroke(); + ctx.beginPath(); ctx.arc(x,y,1.5,0,Math.PI*2); + ctx.fillStyle = '#0a0a18'; ctx.fill(); + } + // Rail holes + ['+', '-'].forEach(r2 => { + const {x,y} = bbRailXY(r, r2); + ctx.beginPath(); ctx.arc(x,y,BB.HOLE_R,0,Math.PI*2); + ctx.fillStyle = r2==='+' ? 'rgba(248,113,113,0.12)' : 'rgba(96,165,250,0.10)'; + ctx.fill(); + ctx.strokeStyle = r2==='+' ? '#4a2020' : '#202040'; + ctx.lineWidth = 0.7; ctx.stroke(); + }); + } + + // Selection indicator + if (BB.sel) { + const p = bbHoleToXY(BB.sel); + ctx.beginPath(); ctx.arc(p.x,p.y,BB.HOLE_R+4,0,Math.PI*2); + ctx.strokeStyle = 'rgba(255,255,255,0.5)'; ctx.lineWidth = 1.5; ctx.stroke(); + } +} + +function bbDrawItem(ctx, item) { + const p1 = bbHoleToXY(item.p1); + const p2 = item.p2 ? bbHoleToXY(item.p2) : null; + const col = item.color || BB_COLOR[item.type] || '#888'; + ctx.lineCap = 'round'; + + if (item.type === 'wire') { + ctx.beginPath(); ctx.moveTo(p1.x,p1.y); ctx.lineTo(p2.x,p2.y); + ctx.strokeStyle = col; ctx.lineWidth = 2.5; ctx.stroke(); + ctx.beginPath(); ctx.arc(p1.x,p1.y,3,0,Math.PI*2); ctx.arc(p2.x,p2.y,3,0,Math.PI*2); + ctx.fillStyle = col; ctx.fill(); + } else if (item.type === 'vcc') { + ctx.fillStyle = '#f87171'; ctx.font = 'bold 9px IBM Plex Mono,monospace'; + ctx.textAlign = 'center'; ctx.textBaseline = 'bottom'; + ctx.fillText('+5V', p1.x, p1.y-5); + ctx.beginPath(); ctx.arc(p1.x,p1.y,4,0,Math.PI*2); ctx.fillStyle='#f87171'; ctx.fill(); + } else if (item.type === 'gnd') { + ctx.fillStyle = '#60a5fa'; ctx.font = 'bold 9px IBM Plex Mono,monospace'; + ctx.textAlign = 'center'; ctx.textBaseline = 'top'; + ctx.fillText('GND', p1.x, p1.y+5); + ctx.beginPath(); ctx.arc(p1.x,p1.y,4,0,Math.PI*2); ctx.fillStyle='#60a5fa'; ctx.fill(); + } else if (item.type === 'ard') { + const pinV = bbGetPinV(item.value||''); + ctx.fillStyle = '#fb923c'; ctx.font = '9px IBM Plex Mono,monospace'; + ctx.textAlign = 'center'; ctx.textBaseline = 'bottom'; + ctx.fillText(item.value||'D?', p1.x, p1.y-5); + ctx.beginPath(); ctx.arc(p1.x,p1.y,5,0,Math.PI*2); + ctx.fillStyle = pinV > 2.5 ? '#22c55e' : '#fb923c'; ctx.fill(); + } else if (p2) { + bbDrawTwoPoint(ctx, p1, p2, col, item); + } +} + +function bbDrawTwoPoint(ctx, p1, p2, col, item) { + const dx=p2.x-p1.x, dy=p2.y-p1.y; + const len=Math.sqrt(dx*dx+dy*dy); + if (len<4) return; + const nx=dx/len, ny=dy/len; + const blen = Math.min(len*0.45, 22); + const mx=(p1.x+p2.x)/2, my=(p1.y+p2.y)/2; + + // lead wires + ctx.strokeStyle = col; ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.moveTo(p1.x,p1.y); ctx.lineTo(mx-nx*blen/2, my-ny*blen/2); + ctx.moveTo(mx+nx*blen/2, my+ny*blen/2); ctx.lineTo(p2.x,p2.y); + ctx.stroke(); + + // body + ctx.save(); ctx.translate(mx,my); ctx.rotate(Math.atan2(dy,dx)); + if (item.type === 'led') { + const glow = item._on; + if (glow) { ctx.shadowBlur = 10; ctx.shadowColor = col; } + ctx.fillStyle = glow ? col : col+'44'; + ctx.strokeStyle = col; ctx.lineWidth = 1.2; + // triangle symbol + ctx.beginPath(); ctx.moveTo(-blen/2,-5); ctx.lineTo(blen/2,0); ctx.lineTo(-blen/2,5); ctx.closePath(); + ctx.fill(); ctx.stroke(); + // cathode bar + ctx.beginPath(); ctx.moveTo(blen/2,-5); ctx.lineTo(blen/2,5); ctx.stroke(); + ctx.shadowBlur = 0; + } else if (item.type === 'cap') { + ctx.fillStyle = col+'22'; ctx.strokeStyle = col; ctx.lineWidth = 1.2; + ctx.beginPath(); ctx.rect(-blen/2,-5,blen,10); ctx.fill(); ctx.stroke(); + // plate lines + ctx.beginPath(); ctx.moveTo(-2,-5); ctx.lineTo(-2,5); ctx.moveTo(2,-5); ctx.lineTo(2,5); ctx.stroke(); + ctx.fillStyle = col; ctx.font = '6px IBM Plex Mono'; ctx.textAlign='center'; ctx.textBaseline='middle'; + ctx.fillText('C', 0, 0); + } else if (item.type === 'btn') { + ctx.strokeStyle = col; ctx.lineWidth = 1.5; + ctx.beginPath(); ctx.rect(-blen/2,-5,blen,10); ctx.fillStyle=col+'22'; ctx.fill(); ctx.stroke(); + ctx.strokeStyle = col+'aa'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(-blen/4,-3); ctx.lineTo(blen/4,-3); ctx.stroke(); + ctx.fillStyle = col; ctx.font = '6px IBM Plex Mono'; ctx.textAlign='center'; ctx.textBaseline='middle'; + ctx.fillText('BTN', 0, 3); + } else { // res default + ctx.fillStyle = col+'22'; ctx.strokeStyle = col; ctx.lineWidth = 1.2; + ctx.beginPath(); ctx.rect(-blen/2,-4.5,blen,9); ctx.fill(); ctx.stroke(); + ctx.fillStyle = col; ctx.font = '6px IBM Plex Mono'; ctx.textAlign='center'; ctx.textBaseline='middle'; + ctx.fillText('R', 0, 0); + } + ctx.restore(); + + // value label + const vStr = item.value ? String(item.value).slice(0,8) : ''; + if (vStr) { + ctx.fillStyle = col; ctx.font = '8px IBM Plex Mono,monospace'; + ctx.textAlign = 'center'; ctx.textBaseline = 'bottom'; + ctx.fillText(vStr, mx, my - blen/2 - 3); + } + // endpoints + ctx.beginPath(); ctx.arc(p1.x,p1.y,2.5,0,Math.PI*2); ctx.arc(p2.x,p2.y,2.5,0,Math.PI*2); + ctx.fillStyle = col; ctx.fill(); +} + +function bbOnClick(e) { + const cv = document.getElementById('bb-canvas'); + const rect = cv.getBoundingClientRect(); + const cx = (e.clientX - rect.left) * (cv.width / rect.width); + const cy = (e.clientY - rect.top) * (cv.height / rect.height); + const hole = bbHit(cx, cy); + if (!hole) { BB.sel = null; bbRender(); return; } + + const t = BB.tool; + if (t === 'vcc') { + BB.items.push({type:'vcc', p1:hole, id:BB.nextId++}); + BB.sel = null; bbUpdateNetlist(); bbRender(); return; + } + if (t === 'gnd') { + BB.items.push({type:'gnd', p1:hole, id:BB.nextId++}); + BB.sel = null; bbUpdateNetlist(); bbRender(); return; + } + if (t === 'ard') { + const pin = prompt('Arduino 핀 입력 (예: D13, A0, D5, 5V):'); + if (pin) BB.items.push({type:'ard', p1:hole, value:pin.trim(), id:BB.nextId++}); + BB.sel = null; bbUpdateNetlist(); bbRender(); return; + } + if (!BB.sel) { BB.sel = hole; bbRender(); return; } + + const p1 = BB.sel; BB.sel = null; + if (p1.row === hole.row && p1.col === hole.col) { bbRender(); return; } + + let value = ''; + if (t === 'res') value = prompt('저항값 (예: 220 Ω, 1000 Ω):', '1000') || '1000'; + if (t === 'cap') value = prompt('커패시터값 (예: 100n, 10u, 1u):', '100n') || '100n'; + if (t === 'led') value = prompt('LED 색 (red/green/blue/yellow/white):', 'red') || 'red'; + + const ledColMap = {red:'#f87171', green:'#34d058', blue:'#60a5fa', yellow:'#fcd34d', white:'#e5e7eb'}; + const color = t === 'led' ? (ledColMap[value] || '#f87171') + : t === 'wire' ? (['#60a5fa','#34d058','#fb923c','#fcd34d','#a78bfa'][BB.items.filter(i=>i.type==='wire').length % 5]) + : BB_COLOR[t]; + + BB.items.push({type:t, p1, p2:hole, value, color, id:BB.nextId++}); + bbUpdateNetlist(); bbRender(); +} + +window.bbSetTool = (t) => { + BB.tool = t; BB.sel = null; + document.querySelectorAll('.bb-tool').forEach(b => b.classList.remove('active')); + const el = document.getElementById('tool-' + t); + if (el) el.classList.add('active'); + bbRender(); +}; +window.bbClear = () => { + if (!confirm('브레드보드를 초기화하시겠습니까?')) return; + BB.items = []; BB.sel = null; bbUpdateNetlist(); bbRender(); +}; +window.bbUndo = () => { + if (BB.items.length) { BB.items.pop(); bbUpdateNetlist(); bbRender(); } +}; +window.toggleBB = () => { + const el = document.getElementById('bb-popup'); + el.classList.toggle('open'); + if (el.classList.contains('open')) { bbUpdateNetlist(); bbRender(); } +}; +window.bbTab = (tab) => { + document.querySelectorAll('.bb-content').forEach(c => c.classList.remove('active')); + document.querySelectorAll('.bb-tab').forEach(b => b.classList.remove('active')); + document.getElementById('bb-content-'+tab)?.classList.add('active'); + document.getElementById('bb-tab-btn-'+tab)?.classList.add('active'); + if (tab === 'board') bbRender(); + if (tab === 'spice') bbUpdateNetlist(); +}; + +// ── Arduino pin voltage ──────────────────────────────────────────────────────── +function bbGetPinV(pinStr) { + if (!cpu || !portB || !portC || !portD) return 0; + const s = pinStr.toUpperCase().trim(); + if (s === '5V' || s === 'VCC') return 5; + if (s === '3.3V') return 3.3; + if (s === 'GND' || s === '0V') return 0; + const md = s.match(/^D(\d+)$/); + if (md) { + const n = parseInt(md[1]); + if (n < 8) return portD.pinState(n) ? 5 : 0; + if (n < 14) return portB.pinState(n-8) ? 5 : 0; + } + const ma = s.match(/^A(\d+)$/); + if (ma) return portC.pinState(parseInt(ma[1])) ? 5 : 0; + return 0; +} + +// ── SPICE Netlist ────────────────────────────────────────────────────────────── +function bbToNetlist() { + const lines = ['* Arduino Breadboard — SPICE Netlist', '* Generated by Arduino Emulator', '']; + let vi=1, ri=1, di=1, ci=1; + const hasVCC = BB.items.some(i=>i.type==='vcc')||BB.items.some(i=>i.type==='ard'&&i.value?.toUpperCase()==='5V'); + if (hasVCC) lines.push('VVCC VCC 0 5.0 ; 5V supply'); + lines.push(''); + + for (const it of BB.items) { + if (it.type==='ard') { + const v = bbGetPinV(it.value||''); + const n = bbNode(it.p1.row, it.p1.col); + lines.push(`V${vi++} ${n} 0 ${v.toFixed(2)} ; Arduino ${it.value}`); + } else if (it.type==='vcc') { + const n = bbNode(it.p1.row, it.p1.col); + lines.push(`V${vi++} ${n} 0 5.00 ; VCC`); + } else if (it.type==='gnd') { + const n = bbNode(it.p1.row, it.p1.col); + lines.push(`R_gnd${ri++} ${n} 0 0.001 ; GND tie`); + } else if (it.type==='res' && it.p2) { + const n1=bbNode(it.p1.row,it.p1.col), n2=bbNode(it.p2.row,it.p2.col); + const val = parseFloat(it.value)||1000; + lines.push(`R${ri++} ${n1} ${n2} ${val}`); + } else if (it.type==='led' && it.p2) { + const n1=bbNode(it.p1.row,it.p1.col), n2=bbNode(it.p2.row,it.p2.col); + const col=(it.value||'red').toLowerCase(); + const mdl = col==='green'?'LED_G':col==='blue'?'LED_B':col==='yellow'?'LED_Y':'LED_R'; + lines.push(`D${di++} ${n1} ${n2} ${mdl}`); + } else if (it.type==='cap' && it.p2) { + const n1=bbNode(it.p1.row,it.p1.col), n2=bbNode(it.p2.row,it.p2.col); + let v=it.value||'100n'; + const vn = v.endsWith('u')?parseFloat(v)*1e-6:v.endsWith('n')?parseFloat(v)*1e-9:parseFloat(v)||100e-9; + lines.push(`C${ci++} ${n1} ${n2} ${vn.toExponential(3)}`); + } else if (it.type==='wire' && it.p2) { + const n1=bbNode(it.p1.row,it.p1.col), n2=bbNode(it.p2.row,it.p2.col); + if (n1!==n2) lines.push(`R_w${ri++} ${n1} ${n2} 0.001 ; wire`); + } + } + lines.push(''); + lines.push('.model LED_R D(Is=4.2e-21 N=1.8 VJ=1.90 Rs=5) ; Red LED ~2.0V@20mA'); + lines.push('.model LED_G D(Is=1.2e-22 N=1.9 VJ=2.00 Rs=5) ; Green LED ~2.1V@20mA'); + lines.push('.model LED_B D(Is=2.5e-25 N=2.0 VJ=2.75 Rs=4) ; Blue LED ~3.0V@20mA'); + lines.push('.model LED_Y D(Is=3.0e-21 N=1.8 VJ=1.90 Rs=4) ; Yellow LED ~2.1V@20mA'); + lines.push(''); + lines.push('.op'); + lines.push('.end'); + return lines.join('\n'); +} + +function bbUpdateNetlist() { + const ta = document.getElementById('spice-netlist'); + if (ta) ta.value = bbToNetlist(); +} + +// ── MNA SPICE Solver ─────────────────────────────────────────────────────────── +function spiceGaussian(A, b) { + const n = A.length; + const M = A.map((row,i) => [...row, b[i]]); + for (let c=0; cMath.abs(M[mx][c])) mx=r; + [M[c],M[mx]]=[M[mx],M[c]]; + if (Math.abs(M[c][c])<1e-15) return null; + for (let r=c+1; r=0; i--) { + x[i]=M[i][n]; + for (let j=i+1; j { + if (name==='0'||name.toLowerCase()==='gnd') return 0; + if (!nodes.has(name)) nodes.set(name, ++N); + return nodes.get(name); + }; + const lines = netlist.split('\n').map(l=>l.trim()).filter(l=>l&&!l.startsWith('*')&&!l.startsWith('.')); + const elems = []; + for (const line of lines) { + const p=line.split(/\s+/); + if (p.length<4) continue; + const name=p[0].toUpperCase(); + if (name[0]==='R') elems.push({t:'R', n1:p[1],n2:p[2], val:parseFloat(p[3])||1e-3}); + else if (name[0]==='C') elems.push({t:'C', n1:p[1],n2:p[2], val:parseFloat(p[3])||1e-9}); + else if (name[0]==='V') elems.push({t:'V', n1:p[1],n2:p[2], val:parseFloat(p[3])||0}); + else if (name[0]==='D') { + const mLine=netlist.match(new RegExp('\\.model\\s+'+p[3]+'\\s+D\\([^)]*VJ=([0-9.e+-]+)','i')); + const vj=mLine?parseFloat(mLine[1]):0.7; + const nLine=netlist.match(new RegExp('\\.model\\s+'+p[3]+'\\s+D\\([^)]*N=([0-9.]+)','i')); + const nn=nLine?parseFloat(nLine[1]):1.8; + const isLine=netlist.match(new RegExp('\\.model\\s+'+p[3]+'\\s+D\\([^)]*Is=([0-9.e+-]+)','i')); + const is=isLine?parseFloat(isLine[1]):1e-9; + elems.push({t:'D', n1:p[1],n2:p[2], vj, nn, is, name:p[0]}); + } + } + if (!elems.length) return {voltages:{}, diodes:[]}; + + // Pre-pass node registration + for (const e of elems) { getN(e.n1); getN(e.n2); } + + // Newton-Raphson (max 60 iter) + const diodes = elems.filter(e=>e.t==='D'); + const Vd = Object.fromEntries(diodes.map(d=>[d.name, d.vj])); + const VT = 0.02585; + + let voltages = null; + for (let iter=0; iter<60; iter++) { + const sz=N; if (!sz) break; + const G=Array.from({length:sz},()=>new Array(sz).fill(0)); + const I=new Array(sz).fill(0); + + const stamp=(n1,n2,g)=>{ + if (n1>0){G[n1-1][n1-1]+=g; if(n2>0)G[n1-1][n2-1]-=g;} + if (n2>0){G[n2-1][n2-1]+=g; if(n1>0)G[n2-1][n1-1]-=g;} + }; + const injectI=(n,i)=>{ if(n>0)I[n-1]+=i; }; + + for (const e of elems) { + const n1=getN(e.n1), n2=getN(e.n2); + if (e.t==='R') stamp(n1,n2, 1/Math.max(e.val,1e-9)); + else if (e.t==='V') { + // Stamp voltage source via large admittance (companion model) + const Gv=1e8; + stamp(n1,n2,Gv); + injectI(n1, e.val*Gv); injectI(n2, -e.val*Gv); + } else if (e.t==='D') { + const vdg=Vd[e.name]||e.vj; + const Is=e.is||1e-9; + const Id=Is*(Math.exp(vdg/(e.nn*VT))-1); + const Gd=Is*Math.exp(vdg/(e.nn*VT))/(e.nn*VT); + const Gdc=Math.max(Gd,1e-15); + const Ieq=Id-Gdc*vdg; + stamp(n1,n2,Gdc); + injectI(n1,-Ieq); injectI(n2,Ieq); + } + } + + const x=spiceGaussian(G,I); + if (!x) break; + const newV={}; + newV['0']=0; + for (const [nm,idx] of nodes) newV[nm]=x[idx-1]||0; + voltages=newV; + + let conv=true; + for (const d of diodes) { + const nv=(newV[d.n1]||0)-(newV[d.n2]||0); + if (Math.abs(nv-Vd[d.name])>1e-5) conv=false; + Vd[d.name]=Math.max(-5, Math.min(nv, 10)); + } + if (conv) break; + } + return {voltages: voltages||{'0':0}, diodes: diodes.map(d=>({name:d.name, vd:Vd[d.name]||0}))}; +} + +window.bbRunSpice = () => { + const ta = document.getElementById('spice-netlist'); + const res = document.getElementById('spice-results'); + const st = document.getElementById('spice-status'); + st.textContent='계산 중…'; st.style.color='var(--yellow)'; + try { + const {voltages, diodes} = spiceSolve(ta.value); + // Show voltages + let html = '
노드 전압 (DC)
'; + const sorted=Object.entries(voltages).sort(([a],[b])=>a.localeCompare(b)); + for (const [n,v] of sorted) { + const vs=isNaN(v)?'N/A':(Math.abs(v)<1e-9?'0.0000':v.toFixed(4))+' V'; + html+=`
${n}${vs}
`; + } + // Diodes + if (diodes.length) { + html+='
다이오드 상태
'; + for (const d of diodes) { + const on=d.vd>0.4; + html+=`
${d.name}${d.vd.toFixed(3)}V ${on?'🟢 ON':'⚫ OFF'}
`; + } + // Update breadboard LED visuals + for (const it of BB.items) { + if (it.type!=='led'||!it.p2) continue; + const diodeName = `D${BB.items.filter(i=>i.type==='led'&&i.p2).indexOf(it)+1}`; + const dinfo = diodes.find(d=>d.name.toUpperCase()===diodeName.toUpperCase()); + it._on = dinfo ? dinfo.vd > 0.4 : false; + } + bbRender(); + } + res.innerHTML=html; + st.textContent='완료 ✓'; st.style.color='var(--green)'; + } catch(e) { + st.textContent='오류: '+e.message; st.style.color='var(--red)'; + } +}; + +window.bbExportNetlist = () => { + const txt=document.getElementById('spice-netlist').value; + const a=document.createElement('a'); + a.href=URL.createObjectURL(new Blob([txt],{type:'text/plain'})); + a.download='circuit.cir'; a.click(); + URL.revokeObjectURL(a.href); +}; + +// Drag for bb popup +(()=>{ + const pop=document.getElementById('bb-popup'); + const hdr=document.getElementById('bb-hdr'); + let drag=false, ox=0, oy=0; + hdr.addEventListener('mousedown', e=>{ + if (e.target.tagName==='BUTTON'||e.target.closest('.bb-tabs-row')) return; + drag=true; + const r=pop.getBoundingClientRect(); + ox=e.clientX-r.left; oy=e.clientY-r.top; e.preventDefault(); + }); + document.addEventListener('mousemove', e=>{ + if (!drag) return; + let nx=e.clientX-ox, ny=e.clientY-oy; + nx=Math.max(0,Math.min(nx,innerWidth-pop.offsetWidth)); + ny=Math.max(0,Math.min(ny,innerHeight-pop.offsetHeight)); + pop.style.left=nx+'px'; pop.style.top=ny+'px'; pop.style.right='auto'; + }); + document.addEventListener('mouseup',()=>{drag=false;}); +})(); + +document.getElementById('bb-canvas').addEventListener('click', bbOnClick); + if (avr8jsReady) boot();