// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov // SPDX-License-Identifier: MIT import init, { compile } from "./wasm/bimr_wasm.js"; import bimrApiSrc from "./bimr_api.py?raw"; import "./ui"; import { EditorState, Compartment } from "@codemirror/state"; import { EditorView, basicSetup } from "codemirror"; import { python } from "@codemirror/lang-python"; import { json } from "@codemirror/lang-json"; import { indentUnit } from "@codemirror/language"; import { oneDark } from "@codemirror/theme-one-dark"; const themeCompartment = new Compartment(); const langCompartment = new Compartment(); const editableCompartment = new Compartment(); function editorThemeExt() { return document.documentElement.getAttribute("data-theme") === "dark" ? oneDark : []; } const DEBOUNCE_MIN_MS = 100; // MicroPython context — initialised lazily on first run (or idle warm-up), // not on the page-load critical path. Loaded from public/mp/ via dynamic // import so Vite does not attempt to bundle it. let mp: any = null; let bimrModule: any = null; let mpInitPromise: Promise | null = null; let printBuffer = ""; // One-time, idempotent MicroPython initialisation. function ensureMicroPython(): Promise { mpInitPromise ??= initMicroPython(); return mpInitPromise; } // Run-loop state — persisted in localStorage. type RunMode = "realtime" | "manual"; let mode: RunMode = (localStorage.getItem("bimr.runMode") as RunMode) ?? "realtime"; let debounceMs: number = Number( localStorage.getItem("bimr.debounceMs") ?? DEBOUNCE_MIN_MS, ); let debounceTimer: ReturnType | null = null; // Suppress the next doc-change auto-run — used when loading a .ifcx file // into the editor as a placeholder comment (no script to execute). let suppressAutoRun = false; function scheduleRun() { if (mode === "manual") return; if (debounceTimer) clearTimeout(debounceTimer); debounceTimer = setTimeout(() => { debounceTimer = null; run(); }, debounceMs); } async function initMicroPython() { const mpUrl = new URL(import.meta.env.BASE_URL + "mp/micropython.mjs", location.origin).href; const { loadMicroPython } = await import(/* @vite-ignore */ mpUrl); mp = await loadMicroPython({ stdout: (line: string) => { printBuffer += line + "\n"; }, stderr: (line: string) => { printBuffer += line + "\n"; }, heapsize: 2 * 1024 * 1024, }); // Register the native `bimr` module BEFORE running bimr_api.py, // because bimr_api.py executes `import bimr as _bimr` at module level. // WASM must already be initialised (init() is awaited before this is called). // // The whole-source bridge: exactly one function. The Python layer owns // the .bimr buffer and hands it over whole; we compile it once and stash // both artifacts for run(). bimrModule = { lastBimr: null as string | null, lastIfcx: null as string | null, compile(src: string) { bimrModule.lastBimr = src; bimrModule.lastIfcx = compile(src); // Returns nothing: a returned IFCX string would be materialized as a // Python str inside the MicroPython heap (2MB) and blow it on large // models. run() reads lastBimr/lastIfcx from the JS side. }, }; mp.registerJsModule("bimr", bimrModule); // bimr_api.py is inlined at build time (?raw import) — never fetched at // runtime, so a stale HTTP cache can never serve an old or empty layer. mp.runPython(bimrApiSrc); } // IFCX viewer (render.mjs, Three.js) — poll until ready (race condition with module load order). async function initViewer() { for (let i = 0; i < 50; i++) { const viewer = (window as any).__bimrViewer; if (viewer?.addModel) { console.log("[bimr] viewer ready after", i, "tries"); return { addModel: viewer.addModel, clearModels: viewer.clearModels }; } await new Promise((r) => setTimeout(r, 100)); } console.warn("[bimr] viewer not available"); return { addModel: null, clearModels: null }; } // The viewer is the generated bare-import bundle (sync-viewer) — Vite // resolves three locally, so there is no runtime CDN dependency. // viewerReady settles exactly when the module has registered (or never // blocks the run pipeline on failure). const viewerReady: Promise<{ addModel: ((name: string, m: any) => void) | null; clearModels: (() => void) | null }> = import("./viewer.render.mjs") .then(() => initViewer()) .catch((e) => { console.error("[bimr] viewer:", e); return { addModel: null, clearModels: null }; }); const output = document.getElementById("output") as HTMLPreElement; // Run-loop widget const btnAuto = document.getElementById( "btn-mode-realtime", ) as HTMLButtonElement; const btnRun = document.getElementById("btn-run") as HTMLButtonElement; function applyMode() { btnAuto.classList.toggle("active", mode === "realtime"); btnRun.style.display = mode === "manual" ? "" : "none"; localStorage.setItem("bimr.runMode", mode); } applyMode(); btnAuto.addEventListener("click", () => { mode = mode === "realtime" ? "manual" : "realtime"; applyMode(); }); btnRun.addEventListener("click", () => run()); document.addEventListener("keydown", (e) => { if (e.ctrlKey && (e.key === "Enter" || e.key === "e" || e.key === "E")) { e.preventDefault(); run(); } }); async function fetchSample(id: string): Promise { try { const res = await fetch(import.meta.env.BASE_URL + `samples/${id}.py`); return res.ok ? res.text() : ""; } catch { return ""; } } function editorExtensions() { return [ basicSetup, langCompartment.of(python()), indentUnit.of(" "), themeCompartment.of(editorThemeExt()), editableCompartment.of(EditorView.editable.of(true)), EditorView.updateListener.of((update) => { if (update.docChanged) { if (suppressAutoRun) suppressAutoRun = false; else scheduleRun(); } }), ]; } // --- Layout tabs (editor / viewer — narrow screens only) --- const layoutTabBar = document.getElementById("layout-tab-bar") as HTMLDivElement; const btnPaneEditor = document.getElementById("btn-pane-editor") as HTMLButtonElement; const btnPaneViewer = document.getElementById("btn-pane-viewer") as HTMLButtonElement; const panesEl = document.querySelector(".panes") as HTMLDivElement; type LayoutPane = "editor" | "viewer"; let activeLayoutPane: LayoutPane = "editor"; function switchLayoutPane(pane: LayoutPane) { if (pane === activeLayoutPane) return; activeLayoutPane = pane; panesEl.setAttribute("data-active-pane", pane); btnPaneEditor.classList.toggle("active", pane === "editor"); btnPaneViewer.classList.toggle("active", pane === "viewer"); if (pane === "viewer") { requestAnimationFrame(() => window.dispatchEvent(new Event("resize"))); } } btnPaneEditor.addEventListener("click", () => switchLayoutPane("editor")); btnPaneViewer.addEventListener("click", () => switchLayoutPane("viewer")); // --- Tab management --- interface Tab { id: string; name: string; state: EditorState; } let tabs: Tab[] = []; let activeTabId = ""; const tabBar = document.getElementById("tab-bar") as HTMLDivElement; function renderTabBar() { if (tabs.length <= 1) { tabBar.style.display = "none"; return; } tabBar.style.display = "flex"; tabBar.innerHTML = ""; for (const tab of tabs) { const el = document.createElement("div"); el.className = "tab" + (tab.id === activeTabId ? " active" : ""); const label = document.createElement("span"); label.className = "tab-label"; label.textContent = tab.name; label.addEventListener("click", () => switchTab(tab.id)); const close = document.createElement("button"); close.className = "tab-close"; close.textContent = "x"; close.title = "Close tab"; close.addEventListener("click", (e) => { e.stopPropagation(); closeTab(tab.id); }); el.appendChild(label); el.appendChild(close); tabBar.appendChild(el); } } function switchTab(id: string) { if (id === activeTabId) return; if (viewMode !== "python") applyViewMode("python"); tabs.find(t => t.id === activeTabId)!.state = editorView.state; activeTabId = id; const target = tabs.find(t => t.id === id)!; editorView.setState(target.state); editorView.dispatch({ effects: themeCompartment.reconfigure(editorThemeExt()) }); renderTabBar(); } function closeTab(id: string) { if (tabs.length <= 1) return; if (viewMode !== "python") applyViewMode("python"); const idx = tabs.findIndex(t => t.id === id); tabs.splice(idx, 1); if (activeTabId === id) { const next = tabs[Math.min(idx, tabs.length - 1)]; activeTabId = next.id; editorView.setState(next.state); editorView.dispatch({ effects: themeCompartment.reconfigure(editorThemeExt()) }); } renderTabBar(); } function createTab(name: string, content: string) { if (tabs.length > 0) tabs.find(t => t.id === activeTabId)!.state = editorView.state; const id = "tab-" + Date.now(); const state = EditorState.create({ doc: content, extensions: editorExtensions() }); tabs.push({ id, name, state }); activeTabId = id; editorView.setState(state); renderTabBar(); } // --- CodeMirror editor --- const defaultDoc = await fetchSample("building"); const initialState = EditorState.create({ doc: defaultDoc, extensions: editorExtensions(), }); const editorView = new EditorView({ state: initialState, parent: document.getElementById("editor")!, }); // Seed the first tab (no tab bar shown for a single tab) tabs.push({ id: "tab-0", name: "building.py", state: initialState }); activeTabId = "tab-0"; // Expose active tab name for the rename dialog pre-fill (window as any).__bimrActiveTabName = () => tabs.find(t => t.id === activeTabId)?.name ?? ""; window.addEventListener("bimr-theme-change", (e: Event) => { const dark = (e as CustomEvent<{ dark: boolean }>).detail.dark; editorView.dispatch({ effects: themeCompartment.reconfigure(dark ? oneDark : []), }); }); // --- View mode (python / bimr / ifcx) --- type ViewMode = "python" | "bimr" | "ifcx"; let viewMode: ViewMode = "python"; let savedPythonState: import("@codemirror/state").EditorState | null = null; let savedBimrState: import("@codemirror/state").EditorState | null = null; const btnViewPython = document.getElementById("btn-view-python") as HTMLButtonElement; const btnViewBimr = document.getElementById("btn-view-bimr") as HTMLButtonElement; const btnViewIfcx = document.getElementById("btn-view-ifcx") as HTMLButtonElement; function applyViewMode(next: ViewMode) { if (next === viewMode) return; // Leaving a mode — save its editor state so edits survive the switch. if (viewMode === "python") { savedPythonState = editorView.state; } else if (viewMode === "bimr") { savedBimrState = editorView.state; } viewMode = next; btnViewPython.classList.toggle("active", next === "python"); btnViewBimr.classList.toggle("active", next === "bimr"); btnViewIfcx.classList.toggle("active", next === "ifcx"); if (next === "python") { editorView.setState(savedPythonState!); editorView.dispatch({ effects: [ langCompartment.reconfigure(python()), editableCompartment.reconfigure(EditorView.editable.of(true)), themeCompartment.reconfigure(editorThemeExt()), ], }); savedPythonState = null; } else if (next === "bimr") { // Editable like python: the bimr view is a source editor (lastBimr is just // the last serialised/compiled source used to seed it), with realtime run. if (savedBimrState) { editorView.setState(savedBimrState); editorView.dispatch({ effects: [ langCompartment.reconfigure(python()), editableCompartment.reconfigure(EditorView.editable.of(true)), themeCompartment.reconfigure(editorThemeExt()), ], }); savedBimrState = null; } else { const content = lastBimr ?? ""; editorView.setState( EditorState.create({ doc: content, extensions: editorExtensions() }), ); } } else if (next === "ifcx") { const content = lastIfcx ?? "// no model yet — run the script first"; editorView.setState( EditorState.create({ doc: content, extensions: [ basicSetup, langCompartment.of(json()), themeCompartment.of(editorThemeExt()), editableCompartment.of(EditorView.editable.of(false)), ], }), ); } } btnViewPython.addEventListener("click", () => applyViewMode("python")); btnViewBimr.addEventListener("click", () => applyViewMode("bimr")); btnViewIfcx.addEventListener("click", () => applyViewMode("ifcx")); let lastIfcx: string | null = null; let lastBimr: string | null = null; // Drop stale run output so bimr/ifcx views show "no model yet" until the // next run. function clearModelBuffers() { lastIfcx = null; lastBimr = null; updateSaveMenu(); } // Enable/disable the Save submenu entries according to available buffers. function updateSaveMenu() { const bimrBtn = document.getElementById("menu-save-bimr") as HTMLButtonElement | null; const ifcxBtn = document.getElementById("menu-save-ifcx") as HTMLButtonElement | null; // The bimr view is editable, so its live doc is always saveable. if (bimrBtn) bimrBtn.disabled = !lastBimr && viewMode !== "bimr"; if (ifcxBtn) ifcxBtn.disabled = !lastIfcx; } // Monotonic run token — a run whose sequence is superseded (a newer run // started, or a file was loaded) must not write output or buffers. let runSeq = 0; async function run() { const seq = ++runSeq; const src = editorView.state.doc.toString(); let result: string; let bimrSrc: string | null = null; if (viewMode === "bimr") { // BIMR DSL source — compile directly via WASM (no MicroPython output). printBuffer = ""; result = compile(src); bimrSrc = src; } else { await ensureMicroPython(); if (seq !== runSeq) return; try { printBuffer = ""; bimrModule.lastBimr = null; bimrModule.lastIfcx = null; // Fresh buffer per run (a failed script must not leak lines), then // execute the user code and compile the buffer whole. mp.runPython("_bimr_reset()"); mp.runPython(src); mp.runPython("import gc; gc.collect()"); mp.runPython("_bimr_build()"); bimrSrc = bimrModule.lastBimr; result = bimrModule.lastIfcx ?? ""; } catch (e) { if (seq !== runSeq) return; output.textContent = (printBuffer ? printBuffer + "\n" : "") + `Error: ${e}`; output.className = "error"; lastIfcx = null; lastBimr = null; updateSaveMenu(); return; } } if (seq !== runSeq) return; try { const parsed = JSON.parse(result); if (parsed.error) { output.textContent = (printBuffer ? printBuffer + "\n" : "") + `Error: ${parsed.error}`; output.className = "error"; lastIfcx = null; lastBimr = null; updateSaveMenu(); } else { lastBimr = bimrSrc; lastIfcx = JSON.stringify(parsed, null, 2); updateSaveMenu(); if (viewMode === "bimr") { const n = Array.isArray(parsed.data) ? parsed.data.length : 0; output.textContent = `Compiled ${n} entit${n === 1 ? "y" : "ies"}`; } else { output.textContent = printBuffer; } output.className = ""; const { addModel, clearModels } = await viewerReady; if (seq !== runSeq) return; clearModels?.(); // Viewer failures (e.g. no WebGL context) must not clobber the run // output — the compile result or error stays in #output. try { await addModel?.("live", parsed); } catch (e) { console.error("[bimr] viewer:", e); } if (window.matchMedia("(max-width: 768px)").matches) { switchLayoutPane("viewer"); } } } catch { if (seq !== runSeq) return; output.textContent = (printBuffer ? printBuffer + "\n" : "") + result; output.className = ""; lastIfcx = null; lastBimr = null; updateSaveMenu(); } } window.addEventListener('bimr-new-file', () => { applyViewMode("python"); savedBimrState = null; clearModelBuffers(); editorView.dispatch({ changes: { from: 0, to: editorView.state.doc.length, insert: '' }, }); tabs.find(t => t.id === activeTabId)!.name = 'default.py'; renderTabBar(); }); window.addEventListener('bimr-new-tab', () => { applyViewMode("python"); savedBimrState = null; clearModelBuffers(); createTab('default.py', ''); }); window.addEventListener('bimr-save-buffer', (e: Event) => { const { format } = (e as CustomEvent<{ format: "python" | "bimr" | "ifcx" }>).detail; const tabName = tabs.find(t => t.id === activeTabId)?.name ?? 'model'; const baseName = tabName.replace(/\.[^.]+$/, ''); let content: string | null; let ext: string; let mime = "text/plain"; if (format === "python") { content = editorView.state.doc.toString(); ext = "py"; } else if (format === "bimr") { content = viewMode === "bimr" ? editorView.state.doc.toString() : lastBimr; ext = "bimr"; } else { content = lastIfcx; ext = "ifcx"; mime = "application/json"; } if (content == null) return; const blob = new Blob([content], { type: mime }); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = `${baseName}.${ext}`; a.click(); URL.revokeObjectURL(a.href); }); window.addEventListener('bimr-open-file', (e: Event) => { runSeq++; // invalidate any in-flight run — a loaded file wins const { text, name, placeholder } = (e as CustomEvent<{ text: string; name: string; placeholder?: boolean }>).detail; applyViewMode("python"); if (placeholder) { suppressAutoRun = true; if (debounceTimer) { clearTimeout(debounceTimer); debounceTimer = null; } } else { clearModelBuffers(); } editorView.dispatch({ changes: { from: 0, to: editorView.state.doc.length, insert: text }, }); tabs.find(t => t.id === activeTabId)!.name = name; renderTabBar(); }); window.addEventListener('bimr-rename-tab', (e: Event) => { const { name } = (e as CustomEvent<{ name: string }>).detail; tabs.find(t => t.id === activeTabId)!.name = name; renderTabBar(); }); window.addEventListener('bimr-load-model', (e: Event) => { runSeq++; // invalidate any in-flight run — a loaded file wins const { text, name, bimr } = (e as CustomEvent<{ text: string; name: string; bimr?: string }>).detail; try { const parsed = JSON.parse(text); lastIfcx = text; lastBimr = bimr ?? null; updateSaveMenu(); viewerReady.then(({ addModel, clearModels }) => { clearModels?.(); addModel?.('loaded', parsed); }); output.textContent = `Loaded: ${name}`; output.className = ''; } catch { output.textContent = `Error: could not parse ${name}`; output.className = 'error'; } }); window.addEventListener('bimr-open-bimr', (e: Event) => { runSeq++; // invalidate any in-flight run — a loaded file wins const { text, name } = (e as CustomEvent<{ text: string; name: string }>).detail; const result = compile(text); let parsed: { error?: string } | null = null; try { parsed = JSON.parse(result); } catch { parsed = null; } if (!parsed || parsed.error) { output.textContent = `Error: ${parsed?.error ?? `could not compile ${name}`}`; output.className = "error"; return; } window.dispatchEvent(new CustomEvent('bimr-load-model', { detail: { text: result, name, bimr: text } })); // Show the source in the editable bimr view so it can be tweaked and re-run. savedBimrState = null; lastBimr = text; if (viewMode !== "bimr") { applyViewMode("bimr"); } else { // Refresh the editor — suppress the auto-run (model already loaded above). suppressAutoRun = true; if (debounceTimer) { clearTimeout(debounceTimer); debounceTimer = null; } editorView.dispatch({ changes: { from: 0, to: editorView.state.doc.length, insert: text }, }); } tabs.find(t => t.id === activeTabId)!.name = name; renderTabBar(); }); await init(); updateSaveMenu(); // Defer MicroPython to the browser's idle period so it does not block the // initial render. The demo still runs (and renders a model) once available. // Skip the warm-up run if the user already triggered one (mpInitPromise set). if ("requestIdleCallback" in window) { requestIdleCallback( () => { // Skip if the user already triggered a run, or loaded a model — // opening an .ifcx must not be clobbered by the demo run. if (!mpInitPromise && lastIfcx === null) void run(); }, { timeout: 3000 }, ); } else { void run(); }