import { createViewer } from './viewer.js'; const DEFAULT_VIEW = [0.86, -1.35, 0.98]; const MAX_FILE_BYTES = 512 * 1024; const byId = (id) => document.getElementById(id); const els = { runtime: byId('runtime'), runtimeText: byId('runtimeText'), loadBtn: byId('loadBtn'), fileInput: byId('fileInput'), exampleList: byId('exampleList'), exampleCount: byId('exampleCount'), fileName: byId('fileName'), fileState: byId('fileState'), revertBtn: byId('revertBtn'), runBtn: byId('runBtn'), editorWrap: byId('editorWrap'), cursorInfo: byId('cursorInfo'), objectCount: byId('objectCount'), autoBtn: byId('autoBtn'), resetBtn: byId('resetBtn'), canvasWrap: byId('canvasWrap'), view: byId('view'), canvasHint: byId('canvasHint'), loadingState: byId('loadingState'), loadingText: byId('loadingText'), emptyState: byId('emptyState'), emptyHint: byId('emptyHint'), errorState: byId('errorState'), errorText: byId('errorText'), dismissError: byId('dismissError'), toast: byId('toast'), dropOverlay: byId('dropOverlay'), }; const state = { examples: [], current: null, mode: 'example', source: '', fileName: '未命名.py', dirty: false, scene: null, sceneSource: '', busy: false, pendingRun: null, uiSeq: 0, cm: null, viewer: null, toastTimer: null, hintTimer: null, }; const RUNTIME_LABELS = { idle: 'Python 待命', loading: 'Python 載入中', ready: 'Python 就緒', busy: 'Python 執行中', error: 'Python 離線', }; class PythonRuntime { constructor() { this.worker = null; this.ready = false; this.failed = false; this.shim = null; this.initPromise = null; this.seq = 0; this.pending = new Map(); this.onState = () => {}; this.lastError = ''; this._readyResolve = null; this._readyReject = null; this._initTimer = null; } setState(name, detail = '') { this.state = name; this.onState(name, detail); } async loadShim() { if (this.shim) return this.shim; const response = await fetch('cadshim.py', { cache: 'no-cache' }); if (!response.ok) { throw new Error(`無法載入 Python shim(HTTP ${response.status})`); } const text = await response.text(); if (!text.includes('__wfcad_runtime')) { throw new Error('Python shim 內容不正確'); } this.shim = text; return text; } preload() { this.ensure().catch(() => {}); } async ensure() { if (this.ready && this.worker) return; if (this.initPromise) return this.initPromise; this.initPromise = this.boot(); try { await this.initPromise; } catch (error) { if (this.initPromise === null) throw error; this.initPromise = null; throw error; } } async boot() { this.setState('loading'); let shim; try { shim = await this.loadShim(); } catch (error) { this.fail(error); throw error; } let worker; try { worker = new Worker('worker.js'); } catch (error) { this.fail(error); throw error; } this.worker = worker; worker.onmessage = (event) => this.handleMessage(event.data); worker.onerror = (event) => { this.fail(event.message || 'Python worker 發生錯誤'); }; const ready = new Promise((resolve, reject) => { this._readyResolve = resolve; this._readyReject = reject; }); this._initTimer = setTimeout(() => { this.fail('Python 執行環境載入逾時,請檢查網路後重試。'); }, 90000); worker.postMessage({ type: 'init', shim }); await ready; } handleMessage(message) { if (!message) return; if (message.type === 'ready') { if (this._initTimer) { clearTimeout(this._initTimer); this._initTimer = null; } this.ready = true; this.failed = false; this.setState('ready'); const resolve = this._readyResolve; this._readyResolve = null; this._readyReject = null; if (resolve) resolve(); return; } if (message.type === 'fatal') { this.fail(message.error || 'Python 執行環境發生錯誤'); return; } if (message.type === 'result' || message.type === 'error') { const pending = this.pending.get(message.id); if (!pending) return; this.pending.delete(message.id); if (message.type === 'result') { pending.resolve(message.result); } else { pending.reject(new Error(message.error || 'Python 執行失敗')); } } } fail(error) { const reason = error instanceof Error ? error.message : String(error || 'Python 執行環境發生錯誤'); this.lastError = reason; this.ready = false; this.failed = true; if (this._initTimer) { clearTimeout(this._initTimer); this._initTimer = null; } if (this.worker) { this.worker.terminate(); this.worker = null; } this.initPromise = null; const rejectReady = this._readyReject; this._readyResolve = null; this._readyReject = null; if (rejectReady) rejectReady(new Error(reason)); for (const pending of [...this.pending.values()]) { pending.reject(new Error(reason)); } this.pending.clear(); this.setState('error', reason); } async run(code) { await this.ensure(); if (!this.worker) throw new Error('Python 執行環境不在線'); this.setState('busy'); const id = ++this.seq; return new Promise((resolve, reject) => { const timer = setTimeout(() => { this.pending.delete(id); this.restart(); reject(new Error('執行逾時。程式可能進入了無窮迴圈,已停止執行。')); }, 20000); this.pending.set(id, { resolve: (value) => { clearTimeout(timer); this.finishRun(); resolve(value); }, reject: (error) => { clearTimeout(timer); this.finishRun(); reject(error); }, }); this.worker.postMessage({ type: 'run', id, code }); }); } finishRun() { if (this.ready) { this.setState('ready'); } else if (this.failed) { this.setState('error', this.lastError); } } restart() { if (this.worker) { this.worker.terminate(); this.worker = null; } this.ready = false; this.failed = false; this.initPromise = null; this.pending.clear(); this.setState('idle'); } } const runtime = new PythonRuntime(); runtime.onState = (name, detail) => { els.runtime.dataset.state = name; els.runtimeText.textContent = RUNTIME_LABELS[name] || 'Python'; if (name === 'error') { els.runtime.title = detail || 'Python 執行環境無法載入'; } else { els.runtime.title = 'Python 執行環境'; } }; // -------------------------------------------------------------------------- // small helpers // -------------------------------------------------------------------------- function escapeHtml(value) { return String(value).replace(/[&<>"']/g, (char) => { const map = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; return map[char]; }); } function cleanError(value) { let text = value instanceof Error ? value.stack || value.message : String(value || '未知錯誤'); text = text.replace(/\u001b\[[0-9;]*m/g, '').trim(); if (text.length > 7000) text = `${text.slice(0, 7000)}\n…`; return text || '未知錯誤'; } function hideToast() { if (state.toastTimer) clearTimeout(state.toastTimer); state.toastTimer = null; els.toast.hidden = true; } function toast(message, tone = 'info') { hideToast(); els.toast.textContent = message; els.toast.dataset.tone = tone; els.toast.hidden = false; state.toastTimer = setTimeout(() => { els.toast.hidden = true; state.toastTimer = null; }, tone === 'error' ? 4200 : 2800); } function showLoading(text) { hideToast(); if (text) els.loadingText.textContent = text; els.loadingState.hidden = false; els.emptyState.hidden = true; els.errorState.hidden = true; } function showError(message) { hideToast(); els.errorText.textContent = cleanError(message); els.errorState.hidden = false; els.loadingState.hidden = true; els.emptyState.hidden = true; els.canvasHint.classList.add('is-hidden'); els.fileState.textContent = '錯誤'; els.fileState.dataset.state = 'error'; } function hideError() { if (!els.errorState.hidden) { els.errorState.hidden = true; updateFileUI(); } } function updateCursor() { if (!state.cm) return; const cursor = state.cm.getCursor(); els.cursorInfo.textContent = `Ln ${cursor.line + 1}, Col ${cursor.ch + 1}`; } function updateRunButton() { els.runBtn.disabled = state.busy; els.runBtn.setAttribute('aria-busy', String(state.busy)); els.runBtn.classList.toggle('is-busy', state.busy); } function updateFileUI() { els.fileName.textContent = state.fileName || '未命名.py'; document.title = `${state.fileName || 'WFreeCAD'} · WFreeCAD`; els.revertBtn.disabled = !(state.mode === 'example' && state.current); els.revertBtn.title = els.revertBtn.disabled ? '只有範例可以還原' : '回到範例程式'; let label = '範例'; let kind = 'example'; if (state.mode === 'upload') { label = state.dirty ? '已修改' : '已載入'; kind = state.dirty ? 'modified' : 'upload'; } else if (state.dirty) { label = '已修改'; kind = 'modified'; } els.fileState.textContent = label; els.fileState.dataset.state = kind; } function updateActive(id) { const cards = els.exampleList.querySelectorAll('.example-card'); for (const card of cards) { const active = card.dataset.id === id; card.classList.toggle('is-active', active); if (active) { card.setAttribute('aria-current', 'true'); } else { card.removeAttribute('aria-current'); } } } // -------------------------------------------------------------------------- // editor // -------------------------------------------------------------------------- function initEditor() { state.cm = CodeMirror.fromTextArea(byId('editor'), { mode: 'python', lineNumbers: true, indentUnit: 4, tabSize: 4, indentWithTabs: false, lineWrapping: true, matchBrackets: true, autoCloseBrackets: true, styleActiveLine: true, viewportMargin: 40, extraKeys: { Tab: (editor) => { if (editor.somethingSelected()) { editor.indentSelection('add'); } else { editor.replaceSelection(' ', 'end'); } }, }, }); state.cm.setSize('100%', '100%'); state.cm.on('change', () => { const value = state.cm.getValue(); state.dirty = value !== state.source; updateFileUI(); updateCursor(); if (!els.errorState.hidden) hideError(); }); state.cm.on('cursorActivity', updateCursor); state.cm.on('focus', () => runtime.preload()); requestAnimationFrame(() => state.cm.refresh()); updateCursor(); } function setEditor(text, { mode, name } = {}) { state.source = text; if (mode) state.mode = mode; if (name) state.fileName = name; state.dirty = false; state.cm.setValue(text || ''); state.cm.clearHistory(); state.cm.setCursor({ line: 0, ch: 0 }); updateFileUI(); updateCursor(); requestAnimationFrame(() => state.cm.refresh()); } // -------------------------------------------------------------------------- // preview // -------------------------------------------------------------------------- function showScene(scene, view) { if (!scene) { showError('沒有可顯示的場景'); return { objects: 0, skipped: 0 }; } const stats = state.viewer.setScene(scene, view || DEFAULT_VIEW); els.objectCount.textContent = `${stats.objects} 個物件`; els.loadingState.hidden = true; els.errorState.hidden = true; if (stats.objects === 0) { els.emptyState.hidden = false; if (stats.skipped > 0) { els.emptyHint.textContent = '場景中的幾何使用了尚未支援的 FreeCAD API'; } else { els.emptyHint.innerHTML = '在程式裡呼叫 Part.show(shape)'; } } else { els.emptyState.hidden = true; } return stats; } function presentScene(scene, view, { notify = false } = {}) { const stats = showScene(scene, view); const errors = (scene && scene.errors) || []; if (stats.objects === 0 && errors.length) { showError(errors.join('\n')); } else if (errors.length) { toast(`有 ${errors.length} 個物件計算失敗`, 'error'); } else if (stats.objects === 0) { // Empty state already visible. } else if (stats.skipped > 0) { toast(`已顯示 ${stats.objects} 個物件,${stats.skipped} 個未支援`, 'error'); } else if (notify) { toast('執行完成'); } return stats; } // -------------------------------------------------------------------------- // examples // -------------------------------------------------------------------------- async function loadExamples() { const indexResponse = await fetch('examples/index.json', { cache: 'no-cache' }); if (!indexResponse.ok) { throw new Error(`無法載入範例索引(HTTP ${indexResponse.status})`); } const index = await indexResponse.json(); const examples = await Promise.all( index.map(async (item) => { const [codeResponse, sceneResponse] = await Promise.all([ fetch(`examples/${item.code}`, { cache: 'no-cache' }), fetch(`scenes/${item.scene}`, { cache: 'no-cache' }), ]); if (!codeResponse.ok || !sceneResponse.ok) { throw new Error(`無法載入 ${item.id} 的範例檔`); } return { ...item, codeText: await codeResponse.text(), sceneData: await sceneResponse.json(), }; }), ); state.examples = examples; els.exampleCount.textContent = String(examples.length).padStart(2, '0'); renderExamples(); return examples; } function renderExamples() { els.exampleList.innerHTML = ''; const fragment = document.createDocumentFragment(); state.examples.forEach((item, index) => { const button = document.createElement('button'); button.type = 'button'; button.className = 'example-card'; button.dataset.id = item.id; button.innerHTML = ` ${escapeHtml(item.title || item.id)} ${escapeHtml(item.subtitle || item.code)} ${String(index + 1).padStart(2, '0')} `; button.addEventListener('click', () => loadExample(item)); fragment.appendChild(button); }); els.exampleList.appendChild(fragment); } function loadExample(item, { initial = false } = {}) { if (!item) return; hideToast(); state.uiSeq += 1; state.pendingRun = null; state.current = item; state.mode = 'example'; state.fileName = item.code; state.source = item.codeText; state.dirty = false; state.scene = item.sceneData; state.sceneSource = item.codeText; setEditor(item.codeText, { mode: 'example', name: item.code }); updateActive(item.id); hideError(); presentScene(item.sceneData, item.view); if (initial) { els.canvasHint.classList.remove('is-hidden'); if (state.hintTimer) clearTimeout(state.hintTimer); state.hintTimer = setTimeout(() => els.canvasHint.classList.add('is-hidden'), 9000); } } // -------------------------------------------------------------------------- // running // -------------------------------------------------------------------------- async function runCode(code) { if (state.busy) { state.pendingRun = { code }; toast('正在執行,完成後會接著執行'); return; } state.busy = true; updateRunButton(); const uiSeq = state.uiSeq; showLoading(runtime.ready ? '正在解析 Part 指令…' : 'Python 執行環境載入中…(首次約 10 MB)'); runtime.preload(); try { const result = await runtime.run(code); if (uiSeq !== state.uiSeq) return; if (!result || !result.ok) { showError((result && result.error) || '執行失敗'); return; } state.scene = result.scene; state.sceneSource = code; presentScene(result.scene, state.current ? state.current.view : DEFAULT_VIEW, { notify: true }); } catch (error) { if (uiSeq !== state.uiSeq) return; showError(error && error.message ? error.message : error); } finally { state.busy = false; updateRunButton(); if (state.pendingRun) { const pending = state.pendingRun; state.pendingRun = null; runCode(pending.code); } } } function requestRun(code) { runtime.preload(); if (state.busy) { state.pendingRun = { code }; toast('正在執行,完成後會接著執行'); return; } runCode(code); } function runCurrent() { const code = state.cm.getValue(); if (state.mode === 'example' && state.current && !state.dirty && state.current.sceneData) { state.uiSeq += 1; presentScene(state.current.sceneData, state.current.view); if (els.errorState.hidden) toast('已重新載入範例預覽'); return; } requestRun(code); } // -------------------------------------------------------------------------- // files // -------------------------------------------------------------------------- async function loadFile(file) { if (!file) return; if (!/\.(py|txt)$/i.test(file.name)) { toast('請選擇 .py 檔', 'error'); return; } if (file.size > MAX_FILE_BYTES) { toast('檔案太大(上限 512 KB)', 'error'); return; } try { const text = await file.text(); state.uiSeq += 1; state.pendingRun = null; state.current = null; state.mode = 'upload'; state.fileName = file.name; state.source = text; state.dirty = false; state.scene = null; state.sceneSource = ''; setEditor(text, { mode: 'upload', name: file.name }); updateActive(null); hideError(); requestRun(text); } catch (error) { showError(error && error.message ? error.message : error); } } // -------------------------------------------------------------------------- // ui events // -------------------------------------------------------------------------- function bindUI() { els.loadBtn.addEventListener('click', () => { runtime.preload(); els.fileInput.click(); }); els.fileInput.addEventListener('change', () => { const file = els.fileInput.files && els.fileInput.files[0]; if (file) loadFile(file); els.fileInput.value = ''; }); els.runBtn.addEventListener('click', runCurrent); els.revertBtn.addEventListener('click', () => { if (!state.current) return; state.uiSeq += 1; state.pendingRun = null; loadExample(state.current); toast('已回到範例程式'); }); els.dismissError.addEventListener('click', hideError); els.autoBtn.addEventListener('click', () => { const enabled = !state.viewer.isAutoRotate(); state.viewer.setAutoRotate(enabled); els.autoBtn.classList.toggle('is-on', enabled); els.autoBtn.setAttribute('aria-pressed', String(enabled)); els.autoBtn.title = enabled ? '自動旋轉' : '自動旋轉(已暫停)'; }); els.resetBtn.addEventListener('click', () => { state.viewer.resetView(); els.canvasHint.classList.add('is-hidden'); }); els.view.addEventListener( 'pointerdown', () => { els.canvasHint.classList.add('is-hidden'); }, { once: true }, ); window.addEventListener('keydown', (event) => { if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') { event.preventDefault(); runCurrent(); } else if (event.key === 'Escape') { hideError(); els.dropOverlay.hidden = true; } }); let dragDepth = 0; const hasFiles = (event) => { const types = event.dataTransfer && event.dataTransfer.types; return Boolean(types && Array.from(types).includes('Files')); }; window.addEventListener('dragenter', (event) => { if (!hasFiles(event)) return; event.preventDefault(); dragDepth += 1; els.dropOverlay.hidden = false; }); window.addEventListener('dragover', (event) => { if (!hasFiles(event)) return; event.preventDefault(); if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy'; }); window.addEventListener('dragleave', (event) => { if (!hasFiles(event)) return; dragDepth = Math.max(0, dragDepth - 1); if (dragDepth === 0) els.dropOverlay.hidden = true; }); window.addEventListener('drop', (event) => { event.preventDefault(); dragDepth = 0; els.dropOverlay.hidden = true; const file = event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files[0]; if (file) loadFile(file); }); let resizeTimer = null; window.addEventListener('resize', () => { if (resizeTimer) clearTimeout(resizeTimer); resizeTimer = setTimeout(() => { if (state.cm) state.cm.refresh(); }, 120); }); } function schedulePreload() { const connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection; if (connection && (connection.saveData || /2g/.test(connection.effectiveType || ''))) { return; } setTimeout(() => runtime.preload(), 5000); } async function init() { state.viewer = createViewer(els.view, { autoRotate: true }); initEditor(); bindUI(); updateFileUI(); showLoading('正在載入範例…'); try { const examples = await loadExamples(); if (examples.length > 0) { loadExample(examples[0], { initial: true }); } else { showError('找不到任何範例。'); } } catch (error) { showError(error && error.message ? error.message : error); } schedulePreload(); } init().catch((error) => { console.error(error); showError(error && error.message ? error.message : error); });