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);
});