release: 0.0.1

This commit is contained in:
Milovann Yanatchkov 2026-08-31 10:49:15 +02:00
commit 52c560419d
23 changed files with 4057 additions and 0 deletions

17
.gitignore vendored Normal file
View file

@ -0,0 +1,17 @@
node_modules/
dist/
src/wasm/
test-results/
.claude/
.opencode/
*.ifc
*.ifcx
docs/book
# Generated viewer bundle — source is bimr-viewer-ifcx/src/viewer/render.ts
# Rebuild with: make sync-viewer (bundled by Vite — no runtime CDN)
src/viewer.render.mjs
# Downloaded MicroPython WASM runtime — fetch with: make download-mp
public/mp/
# Generated samples — source is bimr-engine-white/samples/python/
# Rebuild with: make sync-samples
public/samples/*.py

60
AGENTS.md Normal file
View file

@ -0,0 +1,60 @@
# AGENTS.md — bimr-web
Browser app on the BIMR stack — batch-DSL Python over
[`bimr-wasm`](../bimr-wasm)'s single `compile()` export. Plan:
[`task/refactor/2026-08-13/white_web.md`](../task/refactor/2026-08-13/white_web.md).
## Build
```bash
make serve # Vite HMR dev server at /editor/ (sync-wasm + samples + mp)
make build-web # production build (sync-viewer + wasm + samples)
make build-web-dev # build for /editor/ deployment path
make sync-viewer # rebuild + sync viewer from bimr-viewer-ifcx
make sync-wasm # copy the bimr-wasm pkg into src/wasm (built by make -C ../bimr-wasm pkg)
make sync-samples # copy the subset samples from bimr-engine
make download-mp # fetch MicroPython WASM runtime (jsDelivr, pinned @1.28.0-6)
make test # Playwright app tests (after make build-web)
```
`serve`/`build-web` depend on `download-mp` (needs network on first use).
## Architecture (D8 — whole-source only)
```
User Python → micropython.wasm → bimr_api.py (string-buffer .bimr emitter)
→ bimrModule = { compile } → compile(dsl) once → IFCX → viewer
```
- `bimr_api.py` is a **subset-only buffer emitter** (`Point`, `Line`, `Wall`,
`List`, `Column`, `Circle`, `Curve`, `Divide`, `Cut`, `Vector`,
`Extrusion`, `Frame`, `Explode`, `Random`, `Slab`, `Level`, `Building` —
`Column` lifts over a `List` of Points or a `Divide`; `Extrusion` takes a
`List` of Lines or a `Cut`; `Frame` lifts over `Explode` pairs) — no
per-op WASM calls, no handles, no
`_h()`. Unsupported vocabulary does not exist. It lives in `src/` and is
**inlined into the bundle at build time** (`?raw` import) — never fetched
at runtime (cache-skew incident: a stale cached body made `_bimr_reset`
vanish in Firefox).
- `bimrModule` is exactly `{ compile }` — no session, no other exports.
- `run()` compiles whole-source in both modes (python buffer / bimr doc);
there is no session juggling.
- The bimr view shows the **live buffer** (last compiled source).
## Viewer
Viewer source is `bimr-viewer-ifcx/src/viewer/render.ts` — never edit
`src/viewer.render.mjs` (generated, gitignored). Run `make sync-viewer`; the
app bundle uses bare imports that Vite resolves from `node_modules` — no
runtime CDN dependency (offline-capable).
## Samples
Subset corpus — canonical source is
`bimr-engine/samples/python/` (`building.py`, `grid.py`,
`cylinders.py`; whitelisted in the Makefile). `make
sync-samples` copies them into
`public/samples/` to match `public/samples/index.json`. Never edit
`public/samples/*.py` directly.
*This document should be updated as the codebase evolves.*

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Milovann Yanatchkov
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

80
Makefile Normal file
View file

@ -0,0 +1,80 @@
SHELL := /bin/bash
.PHONY: serve build-web build-web-dev sync-viewer sync-wasm sync-samples run download-mp test
# Pinned MicroPython WASM runtime (official @micropython npm package, PyScript variant).
# sha256 from micropython.mjs / micropython.wasm of the published package.
MP_NPM := @micropython/micropython-webassembly-pyscript@1.28.0-6
MP_DIR := public/mp
MP_MJS_SHA256 := 8c4067836ce5b71aeddac0315c25f73b642399fdb2e2e94acea612f60747b21a
MP_WASM_SHA256 := 3f705482c97498cb24c90275b33038d9a2d1868439d8216002d90a75bdd28997
# Fetch the MicroPython WASM runtime from jsDelivr unless the pinned files are
# already present and match the known-good hashes. Served from public/mp/.
download-mp:
@mkdir -p $(MP_DIR)
@if echo "$(MP_MJS_SHA256) $(MP_DIR)/micropython.mjs" | sha256sum -c - --quiet 2>/dev/null \
&& echo "$(MP_WASM_SHA256) $(MP_DIR)/micropython.wasm" | sha256sum -c - --quiet 2>/dev/null; then \
echo "==> micropython $(MP_NPM): up to date"; \
else \
echo "==> downloading micropython $(MP_NPM)"; \
curl -fL -o $(MP_DIR)/micropython.mjs https://cdn.jsdelivr.net/npm/$(MP_NPM)/micropython.mjs; \
curl -fL -o $(MP_DIR)/micropython.wasm https://cdn.jsdelivr.net/npm/$(MP_NPM)/micropython.wasm; \
echo "$(MP_MJS_SHA256) $(MP_DIR)/micropython.mjs" | sha256sum -c -; \
echo "$(MP_WASM_SHA256) $(MP_DIR)/micropython.wasm" | sha256sum -c -; \
fi
# The engine pkg — whole-source `compile()` only (bimr-wasm, D8). Built by
# `make -C ../bimr-wasm pkg` (the factory `build-wasm` target once renamed).
sync-wasm:
@mkdir -p src/wasm
@test -f ../bimr-wasm/pkg/bimr_wasm.js || \
(echo "==> bimr-wasm pkg missing — run 'make -C ../bimr-wasm pkg'" >&2; exit 1)
@cp ../bimr-wasm/pkg/bimr_wasm.js ../bimr-wasm/pkg/bimr_wasm_bg.wasm \
../bimr-wasm/pkg/bimr_wasm_bg.wasm.d.ts ../bimr-wasm/pkg/bimr_wasm.d.ts src/wasm/
@echo "==> synced bimr-wasm pkg into src/wasm"
# Canonical Python samples live in bimr-engine/samples/python/.
# The corpus is the engine's supported subset (wall.py + pure-subset
# samples); the operator corpus stayed in bimr-black (archived).
ENGINE_SAMPLES := ../bimr-engine/samples/python
BIMR_SAMPLES := building.py grid.py cylinders.py
sync-samples:
@mkdir -p public/samples
@rm -f public/samples/*.py
@for s in $(BIMR_SAMPLES); do \
test -f $(ENGINE_SAMPLES)/$$s || (echo "==> missing sample $$s" >&2; exit 1); \
cp $(ENGINE_SAMPLES)/$$s public/samples/; \
done
@echo "==> synced samples from bimr-engine"
# Vite HMR dev server — base=/editor/ matches the production deployment path
serve: download-mp sync-wasm sync-samples
pnpm vite dev --base=/editor/
build-web: sync-viewer download-mp sync-wasm sync-samples
pnpm build
# Build for embedding at /editor/ (dev.bimr.net — served under /editor/ path).
build-web-dev: sync-viewer download-mp sync-wasm sync-samples
pnpm vite build --base=/editor/
# Build bimr-viewer-ifcx/src/viewer/render.ts twice:
# - standalone artifact keeps the CDN URLs (no plugin);
# - app bundle gets bare imports via the cdn-to-bare plugin — written into
# src/ so Vite resolves three from node_modules at build time (the app
# has no runtime CDN dependency).
sync-viewer:
@mkdir -p viewer ../bimr-viewer-ifcx/web/viewer
@test -x ../bimr-viewer-ifcx/src/node_modules/.bin/esbuild || \
(echo "==> esbuild missing in bimr-viewer-ifcx/src — npm install (first run only)" && \
cd ../bimr-viewer-ifcx/src && npm install)
cd ../bimr-viewer-ifcx/src && node_modules/.bin/esbuild viewer/render.ts --bundle --outfile=../web/viewer/render.mjs --external:three --format=esm
cd ../bimr-viewer-ifcx/src && node ../../bimr-web/scripts/bundle-app.mjs
# Playwright app tests (build first: make build-web). Installs the pinned
# chromium build on first use (idempotent afterwards).
test:
pnpm exec playwright install chromium && \
pnpm exec playwright test

3
README.md Normal file
View file

@ -0,0 +1,3 @@
# bimr-web
Browser editor for BIMR

197
index.html Normal file
View file

@ -0,0 +1,197 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>BIMR.NET</title>
<link rel="stylesheet" href="./src/style.css" />
<script>
(function () {
var saved = localStorage.getItem('bimr.theme');
if (saved === 'dark') document.documentElement.setAttribute('data-theme', 'dark');
})();
</script>
</head>
<body>
<input type="file" id="file-open" accept=".py,.bimr,.bimrs,.ifcx" style="display:none">
<div id="help-dialog-overlay">
<div id="help-dialog" role="dialog" aria-modal="true">
<div id="help-dialog-header">
<h2>BIMR</h2>
<button id="help-dialog-close">x</button>
</div>
<div id="help-dialog-body">
<p class="help-intro">BIMR is a text-based Building Information Modeler.
<div class="help-section">
<h3>Two ways to write</h3>
<table class="help-table">
<tr>
<td>
Python mode
</td>
<td>
This is where you can express the design logic using BIMR's high-level entities.
</td>
</tr>
<tr>
<td>
Bimr mode
</td>
<td>
This is the output generated by Python. It is a microlanguage used by BIMR to build the model.
</td>
</tr>
</table>
</div>
<div class="help-section">
<h3>Commands</h3>
<table class="help-table">
<tr><td>Ctrl+Enter / Ctrl+E</td><td>Run the current script</td></tr>
<tr><td>Ctrl+H</td><td>Open this help window</td></tr>
<tr><td>realtime button</td><td>Toggle auto-run on every keystroke</td></tr>
<tr><td>File &gt; New</td><td>Erase current file or open a new tab</td></tr>
<tr><td>File &gt; Open</td><td>Load a local .py or .bimr script into the editor</td></tr>
<tr><td>File &gt; Save</td><td>Save the Python, Bimr or Ifcx buffer as a file</td></tr>
<tr><td>File &gt; Rename</td><td>Rename the current file</td></tr>
<tr><td>File &gt; Load</td><td>Fetch a script from a Forge or GitHub URL</td></tr>
</table>
</div>
<div class="help-section">
<h3>Geometry</h3>
<table class="help-table">
<tr><td>Point(x, y, z)</td><td>A 3D point</td></tr>
<tr><td>Line(p1, p2)</td><td>Line between two Points</td></tr>
<tr><td>Circle(center, radius)</td><td>Circle from center Point and a radius</td></tr>
<tr><td>Curve(ctrl)</td><td>Closed Catmull-Rom spline through a List of Points</td></tr>
<tr><td>Vector(x, y, z)</td><td>A 3D vector</td></tr>
</table>
</div>
<div class="help-section">
<h3>Lists &amp; operators</h3>
<table class="help-table">
<tr><td>List(items)</td><td>A list of entities or values</td></tr>
<tr><td>Divide(source, n)</td><td>Divide an Entity into points. Returns a List</td></tr>
<tr><td>Cut(source, n)</td><td>Cut an Entity into segements. Returns a List</td></tr>
<tr><td>Explode(source)</td><td>Explode an Entity or a List into individual items</td></tr>
<tr><td>Random(source, seed, min, max, dims=2)</td><td>Random generator</td></tr>
<tr><td>Extrusion(profile, vec)</td><td>Extrude an Entity</td></tr>
</table>
</div>
<div class="help-section">
<h3>Architecture</h3>
<table class="help-table">
<tr><td>Wall(line, thickness=20, height=300)</td><td>Wall along a Line</td></tr>
<tr><td>Column(base, height, w, h)</td><td>Column at a Point</td></tr>
<tr><td>Slab(source, thickness, elevation)</td><td>Slab over a closed profile</td></tr>
<tr><td>Frame(origin, end, w, h, d, tk)</td><td>Frame between two Points</td></tr>
</table>
</div>
</div>
</div>
</div>
<div id="new-dialog-overlay">
<div id="new-dialog" role="dialog" aria-modal="true">
<p>Open a new file?</p>
<div id="new-dialog-actions">
<button id="new-dialog-cancel">Cancel</button>
<button id="new-dialog-erase">Erase current</button>
<button id="new-dialog-tab">New tab</button>
</div>
</div>
</div>
<div id="rename-dialog-overlay">
<div id="rename-dialog" role="dialog" aria-modal="true">
<label for="rename-dialog-input">Rename file</label>
<input type="text" id="rename-dialog-input" spellcheck="false" autocomplete="off">
<div id="rename-dialog-error"></div>
<div id="rename-dialog-actions">
<button id="rename-dialog-cancel">Cancel</button>
<button id="rename-dialog-confirm">Rename</button>
</div>
</div>
</div>
<div id="load-dialog-overlay">
<div id="load-dialog" role="dialog" aria-modal="true" aria-labelledby="load-dialog-label">
<label id="load-dialog-label" for="load-dialog-url">Load from URL</label>
<input type="url" id="load-dialog-url" placeholder="https://gitaec.org/rvba/docs/src/branch/main/bimr/sample.py" spellcheck="false" autocomplete="off">
<div id="load-dialog-error"></div>
<div id="load-dialog-actions">
<button id="load-dialog-cancel">Cancel</button>
<button id="load-dialog-confirm">Load</button>
</div>
</div>
</div>
<header>
<div class="header-left">
<strong>BIMR</strong>
<nav class="menubar">
<div class="menu" id="menu-file">
<button class="menu-trigger" id="menu-file-trigger">File</button>
<ul class="menu-dropdown" id="menu-file-dropdown">
<li><button id="menu-new">New</button></li>
<li><button id="menu-open">Open</button></li>
<li class="menu-sub">
<button id="menu-save">Save</button>
<ul class="menu-submenu" id="menu-save-submenu">
<li><button id="menu-save-python">Python</button></li>
<li><button id="menu-save-bimr">Bimr</button></li>
<li><button id="menu-save-ifcx">Ifcx</button></li>
</ul>
</li>
<li><button id="menu-rename">Rename</button></li>
<li><button id="menu-load">Load</button></li>
<li class="menu-separator"></li>
<li><button id="menu-help">Help</button></li>
</ul>
</div>
<div class="menu" id="menu-examples">
<button class="menu-trigger" id="menu-examples-trigger">Examples</button>
<ul class="menu-dropdown" id="menu-examples-dropdown">
<li><em style="padding: 6px 12px; display:block; opacity:0.6;">Loading…</em></li>
</ul>
</div>
<a class="menu-trigger" id="menu-source" href="https://gitaec.org/rvba/bimr" target="_blank" rel="noopener">Source ↗</a>
</nav>
</div>
<button id="btn-theme" title="Toggle dark mode">☽</button>
</header>
<div id="layout-tab-bar">
<button id="btn-pane-editor" class="active">Editor</button>
<button id="btn-pane-viewer">Viewer</button>
</div>
<div class="panes" data-active-pane="editor">
<div class="pane">
<div id="tab-bar"></div>
<div id="editor"></div>
<div id="editor-footer">
<div id="view-mode-controls">
<button id="btn-view-python" class="active">python</button>
<button id="btn-view-bimr">bimr</button>
<button id="btn-view-ifcx">ifcx</button>
</div>
<div id="run-controls">
<button id="btn-mode-realtime" class="active">realtime</button>
<button id="btn-run" style="display:none">&#9654; Run</button>
</div>
</div>
</div>
<div class="pane" id="right">
<div id="viewer-pane" class="viewport"></div>
<div id="console-pane">
<div class="console-header">console</div>
<pre id="output"></pre>
</div>
</div>
</div>
<script type="module" src="./src/main.ts"></script>
</body>
</html>

27
package.json Normal file
View file

@ -0,0 +1,27 @@
{
"name": "bimr-web",
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "playwright test"
},
"devDependencies": {
"@playwright/test": "^1.58.2",
"vite": "^5.0.0",
"vite-plugin-top-level-await": "^1.4.0",
"vite-plugin-wasm": "^3.3.0"
},
"dependencies": {
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-python": "^6.2.1",
"@codemirror/language": "^6.12.2",
"@codemirror/state": "^6.6.0",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.40.0",
"codemirror": "^6.0.2",
"three": "^0.183.2"
}
}

18
playwright.config.ts Normal file
View file

@ -0,0 +1,18 @@
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "./tests",
// The idle warm-up compiles the MicroPython wasm on the main thread and
// can stall the first seconds of a page — assertions wait through it.
expect: { timeout: 15000 },
use: {
baseURL: "http://localhost:4173",
browserName: "chromium",
headless: true,
},
webServer: {
command: "pnpm preview",
url: "http://localhost:4173",
reuseExistingServer: false,
},
});

1004
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

6
pnpm-workspace.yaml Normal file
View file

@ -0,0 +1,6 @@
allowBuilds:
'@swc/core': true
esbuild: true
onlyBuiltDependencies:
- '@swc/core'
- esbuild

17
public/samples/index.json Normal file
View file

@ -0,0 +1,17 @@
[
{
"id": "building",
"name": "building.py",
"description": "The building-03 recreation — Curve, Cut, Frame, Slab, Storey, Building"
},
{
"id": "grid",
"name": "grid.py",
"description": "A 4x4 grid of columns lifted over a List of Points"
},
{
"id": "cylinders",
"name": "cylinders.py",
"description": "Three cylinders of frames — a reusable function: Circle, Cut, Explode, Frame"
}
]

26
scripts/bundle-app.mjs Normal file
View file

@ -0,0 +1,26 @@
// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov
// SPDX-License-Identifier: MIT
// bundle-app.mjs — build the bimr-web viewer bundle via the esbuild JS API,
// using the cdn-to-bare plugin so three.js imports are bare specifiers that
// the import map in index.html resolves at runtime.
//
// Run from bimr-viewer-ifcx/src (where esbuild is installed), so the output
// matches the standalone bundle produced by the CLI build in the Makefile:
// cd ../bimr-viewer-ifcx/src && node ../../bimr-web/scripts/bundle-app.mjs
import { createRequire } from "node:module";
import { cdnToBare } from "../../bimr-web/scripts/cdn-to-bare.mjs";
const require = createRequire(process.cwd() + "/");
const esbuild = require("esbuild");
await esbuild.build({
entryPoints: ["viewer/render.ts"],
bundle: true,
// Bare-import bundle — Vite resolves three from node_modules at build
// time, so the app has no runtime CDN dependency (offline-capable).
outfile: "../../bimr-web/src/viewer.render.mjs",
format: "esm",
plugins: [cdnToBare],
});

27
scripts/cdn-to-bare.mjs Normal file
View file

@ -0,0 +1,27 @@
// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov
// SPDX-License-Identifier: MIT
// cdn-to-bare.mjs — esbuild onResolve plugin.
// Rewrites three.js CDN URL imports to bare specifiers at bundle time, so the
// bimr-web bundle is resolved by the import map in index.html at runtime.
// Source keeps absolute CDN URLs (required by the standalone viewer repo).
const THREE_VERSION = "0.177.0";
const THREE_CDN = `https://cdn.jsdelivr.net/npm/three@${THREE_VERSION}`;
const THREE_CORE = `${THREE_CDN}/build/three.module.js`;
const THREE_ADDONS = `${THREE_CDN}/examples/jsm/`;
export const cdnToBare = {
name: "cdn-to-bare",
setup(build) {
build.onResolve({ filter: new RegExp(`^${THREE_CDN.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`) }, (args) => {
if (args.path === THREE_CORE) {
return { path: "three", external: true };
}
if (args.path.startsWith(THREE_ADDONS)) {
return { path: `three/addons/${args.path.slice(THREE_ADDONS.length)}`, external: true };
}
return { path: args.path, external: true };
});
},
};

185
src/bimr_api.py Normal file
View file

@ -0,0 +1,185 @@
# SPDX-FileCopyrightText: 2026 Milovann Yanatchkov
# SPDX-License-Identifier: MIT
# bimr_api.py — batch-DSL emitter for bimr-engine (whole-source).
#
# The Python layer owns the .bimr buffer: constructors append lines with
# deterministic names, build() hands the text to _bimr.compile() in ONE call
# (the same path as the CLI). No per-op WASM calls, no handles, no sessions —
# unsupported vocabulary does not exist here; the engine's supported subset
# is the API (Point, Line, Wall, List, Column, Circle, Curve, Divide, Cut,
# Vector, Extrusion, Frame, Explode, Slab, Storey, Building).
import bimr as _bimr
_buf = []
_n = 0
_built = False
def _fmt(x):
"""Clean integer when the float has no fractional part (fmt_num parity)."""
x = float(x)
return str(int(x)) if x == int(x) else str(x)
def _emit(line):
global _built
_built = False
_buf.append(line)
def _next(prefix):
global _n
_n += 1
return prefix + str(_n)
def _bimr_reset():
"""Drop the buffer — main.ts calls this before executing user code.
Underscore-prefixed: runPython executes bimr_api.py into the shared
__main__ globals, so these entry points must not collide with user names.
"""
global _n, _built
_buf.clear()
_n = 0
_built = False
def Point(x, y, z):
name = _next("p")
_emit(f"{name} = Point({_fmt(x)}, {_fmt(y)}, {_fmt(z)})")
return name
def Line(p1, p2):
name = _next("l")
_emit(f"{name} = Line({p1}, {p2})")
return name
def Wall(line, thickness=20, height=300):
name = _next("w")
_emit(f"{name} = Wall({line}, thickness={_fmt(thickness)}, height={_fmt(height)})")
return name
def List(*items):
name = _next("lst")
_emit(f"{name} = List({', '.join(items)})")
return name
def Column(base, height, section_width, section_height):
# base is a point name or a List of point names — the engine lifts.
name = _next("col")
_emit(f"{name} = Column({base}, {_fmt(height)}, {_fmt(section_width)}, {_fmt(section_height)})")
return name
def Circle(center, radius):
name = _next("c")
_emit(f"{name} = Circle({center}, {_fmt(radius)})")
return name
def Curve(ctrl):
# A closed Catmull-Rom spline through the control points — a List of
# Points or a Divide record. Divide and Cut work on it like a Circle.
name = _next("crv")
_emit(f"{name} = Curve({ctrl})")
return name
def Divide(source, n):
# The engine creates the divided points; the record stays parametric.
name = _next("d")
_emit(f"{name} = Divide({source}, {_fmt(n)})")
return name
def Cut(source, n):
# Curve pieces: the engine creates the Lines; the record stays parametric.
name = _next("ct")
_emit(f"{name} = Cut({source}, {_fmt(n)})")
return name
def Random(source, seed, min, max, dims=2):
# A seeded displacement of a List of Points — dims=2 keeps z.
name = _next("r")
_emit(
f"{name} = Random({source}, seed={_fmt(seed)}, min={_fmt(min)}, "
f"max={_fmt(max)}, dims={_fmt(dims)})"
)
return name
def Vector(x, y, z):
name = _next("v")
_emit(f"{name} = Vector({_fmt(x)}, {_fmt(y)}, {_fmt(z)})")
return name
def Extrusion(profile, vec):
# profile is a List of Lines or a Cut record; vec a Vector name.
name = _next("e")
_emit(f"{name} = Extrusion({profile}, {vec})")
return name
def Frame(*args):
# Frame(origin, end, w, h, d, tk) — or Frame(pairs, w, h, d, tk),
# lifting one frame per pair of Points.
name = _next("fr")
parts = []
for a in args:
parts.append(_fmt(a) if isinstance(a, (int, float)) else a)
_emit(f"{name} = Frame({', '.join(parts)})")
return name
def Explode(source):
# A Line explodes to its endpoints; a List of Lines (or a Cut record)
# to pairs of Points — the Frame lifts one frame per pair.
name = _next("ex")
_emit(f"{name} = Explode({source})")
return name
def Slab(source, thickness, elevation):
name = _next("s")
_emit(f"{name} = Slab({source}, {_fmt(thickness)}, {_fmt(elevation)})")
return name
def Storey(elevation, entities):
name = _next("st")
_emit(f"{name} = Storey({_fmt(elevation)}, {entities})")
return name
def Building(name, storeys):
bname = _next("bld")
_emit(f'{bname} = Building("{name}", {storeys})')
return bname
def _bimr_build():
"""Compile the whole buffer in one call. Recompiles only if new lines
appeared since the last build (the buffer is never cleared — the bimr
view shows the full generated source)."""
global _built
if _built:
return
_built = True
_bimr.compile("\n".join(_buf))
class Project:
def __init__(self, name="model"):
self.name = name
def build(self):
_bimr_build()

614
src/main.ts Normal file
View file

@ -0,0 +1,614 @@
// 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<void> | null = null;
let printBuffer = "";
// One-time, idempotent MicroPython initialisation.
function ensureMicroPython(): Promise<void> {
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<typeof setTimeout> | 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<string> {
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();
}

747
src/style.css Normal file
View file

@ -0,0 +1,747 @@
:root {
--bg: #f8f8f8;
--bg-raised: #ffffff;
--bg-active: #ebebeb;
--border: #e0e0e0;
--border-sub: #e8e8e8;
--border-hover: #999;
--text: #1e1e1e;
--text-dim: #555;
--text-dimmer: #888;
--text-btn: #555;
--text-btn-hover: #111;
--text-strong: #222;
--error: #c0392b;
--shadow-active: rgba(0,0,0,0.12);
}
[data-theme="dark"] {
--bg: #1a1a1a;
--bg-raised: #111;
--bg-active: #2a2a2a;
--border: #333;
--border-sub: #222;
--border-hover: #555;
--text: #d4d4d4;
--text-dim: #666;
--text-dimmer: #888;
--text-btn: #888;
--text-btn-hover: #ccc;
--text-strong: #ccc;
--error: #f48771;
--shadow-active: rgba(0,0,0,0.6);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: monospace;
background: var(--bg);
color: var(--text);
height: 100vh;
display: flex;
flex-direction: column;
}
header {
padding: 0.75rem 1.25rem;
background: var(--bg-raised);
border-bottom: 1px solid var(--border);
font-size: 0.85rem;
color: var(--text-dimmer);
display: flex;
align-items: center;
justify-content: space-between;
}
header strong { color: var(--text-strong); }
.header-left {
display: flex;
align-items: center;
gap: 1rem;
}
.menubar {
display: flex;
align-items: center;
gap: 0.1rem;
}
.menu {
position: relative;
}
.menu-trigger {
font-family: monospace;
font-size: 0.85rem;
color: var(--text-btn);
background: none;
border: none;
padding: 0.2rem 0.5rem;
cursor: pointer;
border-radius: 2px;
}
a.menu-trigger {
text-decoration: none;
display: inline-block;
}
.menu-trigger:hover,
.menu-trigger.open {
color: var(--text-btn-hover);
background: var(--bg-active);
}
.menu-dropdown {
display: none;
position: absolute;
top: 100%;
left: 0;
z-index: 100;
background: var(--bg-raised);
border: 1px solid var(--border);
box-shadow: 0 4px 12px var(--shadow-active);
min-width: 130px;
list-style: none;
padding: 0.25rem 0;
margin-top: 2px;
}
.menu-dropdown.open {
display: block;
}
.menu-dropdown li button {
display: block;
width: 100%;
text-align: left;
font-family: monospace;
font-size: 0.85rem;
color: var(--text-btn);
background: none;
border: none;
padding: 0.3rem 1rem;
cursor: pointer;
}
.menu-dropdown li button:hover {
color: var(--text-btn-hover);
background: var(--bg-active);
}
.menu-sub {
position: relative;
}
.menu-submenu {
display: none;
position: absolute;
left: 100%;
top: -0.25rem;
z-index: 101;
background: var(--bg-raised);
border: 1px solid var(--border);
box-shadow: 0 4px 12px var(--shadow-active);
min-width: 110px;
list-style: none;
padding: 0.25rem 0;
}
.menu-submenu.open {
display: block;
}
.menu-submenu li button {
display: block;
width: 100%;
text-align: left;
font-family: monospace;
font-size: 0.85rem;
color: var(--text-btn);
background: none;
border: none;
padding: 0.3rem 1rem;
cursor: pointer;
}
.menu-submenu li button:hover:not(:disabled) {
color: var(--text-btn-hover);
background: var(--bg-active);
}
.menu-submenu li button:disabled {
color: var(--text-dimmer);
cursor: default;
opacity: 0.5;
}
.menu-separator {
height: 1px;
background: var(--border-sub);
margin: 0.25rem 0;
}
#btn-theme {
font-size: 1rem;
color: var(--text-dimmer);
background: none;
border: none;
padding: 0;
cursor: pointer;
line-height: 1;
}
#btn-theme:hover {
color: var(--text-strong);
}
.panes {
flex: 1;
display: grid;
grid-template-columns: 1fr 1fr;
overflow: hidden;
}
.pane {
display: flex;
flex-direction: column;
overflow: hidden;
}
.pane + .pane {
border-left: 1px solid var(--border);
}
#editor {
flex: 1;
overflow: auto;
min-height: 0;
}
#editor .cm-editor {
height: 100%;
font-size: 0.9rem;
}
#editor .cm-scroller {
font-family: monospace;
line-height: 1.6;
}
#right {
display: flex;
flex-direction: column;
overflow: hidden;
}
#viewer-pane {
flex: 1;
overflow: hidden;
position: relative;
}
/* render.mjs mounts into .viewport — override its 100vh to fill our container */
#viewer-pane .viewport {
position: absolute;
inset: 0;
}
#console-pane {
border-top: 1px solid var(--border);
background: var(--bg-raised);
flex-shrink: 0;
max-height: 30vh;
overflow: auto;
display: flex;
flex-direction: column;
}
.console-header {
padding: 0.3rem 1rem;
font-size: 0.75rem;
color: var(--text-dim);
border-bottom: 1px solid var(--border-sub);
flex-shrink: 0;
}
pre {
padding: 0 1rem 0.75rem;
font-size: 0.85rem;
line-height: 1.6;
white-space: pre;
}
pre.error { color: var(--error); }
#editor-footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.4rem;
padding: 0.4rem 1rem;
font-size: 0.75rem;
background: var(--bg-raised);
border-top: 1px solid var(--border-sub);
flex-shrink: 0;
}
#editor-footer button {
font-family: monospace;
font-size: 0.75rem;
color: var(--text-btn);
background: none;
border: 1px solid var(--border);
padding: 0.15rem 0.5rem;
cursor: pointer;
border-radius: 2px;
}
#editor-footer button:hover {
color: var(--text-btn-hover);
border-color: var(--border-hover);
}
#view-mode-controls {
display: flex;
align-items: center;
gap: 0.4rem;
border-right: 1px solid var(--border);
padding-right: 0.6rem;
margin-right: 0.2rem;
}
#editor-footer button:disabled {
color: var(--text-dimmer);
border-color: var(--border-sub);
cursor: default;
opacity: 0.5;
}
#run-controls {
display: flex;
align-items: center;
gap: 0.4rem;
border-right: 1px solid var(--border);
padding-right: 0.6rem;
margin-right: 0.2rem;
}
#editor-footer button.active {
color: var(--text-btn-hover);
border-color: var(--border-hover);
background: var(--bg-active);
box-shadow: inset 0 1px 3px var(--shadow-active);
}
#tab-bar {
display: none;
align-items: stretch;
background: var(--bg);
border-bottom: 1px solid var(--border);
overflow-x: auto;
flex-shrink: 0;
font-family: monospace;
font-size: 0.75rem;
}
.tab {
display: flex;
align-items: center;
gap: 0.3rem;
padding: 0.4rem 0.6rem 0.4rem 1rem;
border-right: 1px solid var(--border);
cursor: default;
color: var(--text-dim);
white-space: nowrap;
min-width: 0;
user-select: none;
}
.tab:hover { background: var(--bg-active); color: var(--text); }
.tab.active {
background: var(--bg-raised);
color: var(--text);
box-shadow: inset 0 2px 0 var(--border-hover);
}
.tab-label {
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
}
.tab-close {
font-family: monospace;
font-size: 0.9rem;
color: var(--text-dimmer);
background: none;
border: none;
cursor: pointer;
padding: 0 2px;
line-height: 1;
flex-shrink: 0;
}
.tab-close:hover { color: var(--text); }
#rename-dialog-overlay {
display: none;
position: fixed;
inset: 0;
z-index: 200;
background: rgba(0,0,0,0.35);
align-items: center;
justify-content: center;
}
#rename-dialog-overlay.open { display: flex; }
#rename-dialog {
background: var(--bg-raised);
border: 1px solid var(--border);
box-shadow: 0 8px 24px var(--shadow-active);
padding: 1.25rem 1.5rem;
width: 320px;
max-width: 90vw;
font-family: monospace;
font-size: 0.85rem;
}
#rename-dialog label {
display: block;
color: var(--text-dim);
margin-bottom: 0.5rem;
}
#rename-dialog input[type="text"] {
display: block;
width: 100%;
font-family: monospace;
font-size: 0.85rem;
color: var(--text);
background: var(--bg);
border: 1px solid var(--border);
padding: 0.35rem 0.6rem;
outline: none;
box-sizing: border-box;
}
#rename-dialog input[type="text"]:focus { border-color: var(--border-hover); }
#rename-dialog-error {
color: var(--error);
font-size: 0.8rem;
min-height: 1.2em;
margin-top: 0.4rem;
}
#rename-dialog-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 1rem;
}
#rename-dialog-actions button {
font-family: monospace;
font-size: 0.8rem;
color: var(--text-btn);
background: none;
border: 1px solid var(--border);
padding: 0.2rem 0.75rem;
cursor: pointer;
border-radius: 2px;
}
#rename-dialog-actions button:hover {
color: var(--text-btn-hover);
border-color: var(--border-hover);
}
#help-dialog-overlay {
display: none;
position: fixed;
inset: 0;
z-index: 200;
background: rgba(0,0,0,0.35);
align-items: center;
justify-content: center;
}
#help-dialog-overlay.open { display: flex; }
#help-dialog {
background: var(--bg-raised);
border: 1px solid var(--border);
box-shadow: 0 8px 24px var(--shadow-active);
width: 816px;
max-width: 92vw;
max-height: 82vh;
display: flex;
flex-direction: column;
font-family: monospace;
font-size: 0.85rem;
}
#help-dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.9rem 1.25rem 0.75rem;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
#help-dialog-header h2 {
font-size: 1rem;
font-weight: bold;
color: var(--text-strong);
letter-spacing: 0.05em;
}
#help-dialog-close {
font-family: monospace;
font-size: 1.1rem;
color: var(--text-dimmer);
background: none;
border: none;
cursor: pointer;
padding: 0 2px;
line-height: 1;
}
#help-dialog-close:hover { color: var(--text); }
#help-dialog-body {
overflow-y: auto;
padding: 1rem 1.25rem 1.25rem;
}
.help-intro {
color: var(--text-dim);
line-height: 1.6;
margin-bottom: 1.25rem;
}
.help-section {
margin-bottom: 1.25rem;
}
.help-section h3 {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-dimmer);
margin-bottom: 0.5rem;
padding-bottom: 0.25rem;
border-bottom: 1px solid var(--border-sub);
}
.help-table {
width: 100%;
border-collapse: collapse;
}
.help-table tr + tr td { border-top: 1px solid var(--border-sub); }
.help-table td {
padding: 0.3rem 0.5rem;
vertical-align: top;
line-height: 1.5;
}
.help-table td:first-child {
color: var(--text-strong);
white-space: nowrap;
padding-right: 1.5rem;
width: 1%;
}
.help-table td:last-child {
color: var(--text-dim);
}
#new-dialog-overlay {
display: none;
position: fixed;
inset: 0;
z-index: 200;
background: rgba(0,0,0,0.35);
align-items: center;
justify-content: center;
}
#new-dialog-overlay.open { display: flex; }
#new-dialog {
background: var(--bg-raised);
border: 1px solid var(--border);
box-shadow: 0 8px 24px var(--shadow-active);
padding: 1.25rem 1.5rem;
font-family: monospace;
font-size: 0.85rem;
}
#new-dialog p {
color: var(--text-dim);
margin-bottom: 1rem;
}
#new-dialog-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
#new-dialog-actions button {
font-family: monospace;
font-size: 0.8rem;
color: var(--text-btn);
background: none;
border: 1px solid var(--border);
padding: 0.2rem 0.75rem;
cursor: pointer;
border-radius: 2px;
}
#new-dialog-actions button:hover {
color: var(--text-btn-hover);
border-color: var(--border-hover);
}
#load-dialog-overlay {
display: none;
position: fixed;
inset: 0;
z-index: 200;
background: rgba(0,0,0,0.35);
align-items: center;
justify-content: center;
}
#load-dialog-overlay.open {
display: flex;
}
#load-dialog {
background: var(--bg-raised);
border: 1px solid var(--border);
box-shadow: 0 8px 24px var(--shadow-active);
padding: 1.25rem 1.5rem;
width: 480px;
max-width: 90vw;
font-family: monospace;
font-size: 0.85rem;
}
#load-dialog label {
display: block;
color: var(--text-dim);
margin-bottom: 0.5rem;
}
#load-dialog input[type="url"] {
display: block;
width: 100%;
font-family: monospace;
font-size: 0.85rem;
color: var(--text);
background: var(--bg);
border: 1px solid var(--border);
padding: 0.35rem 0.6rem;
outline: none;
box-sizing: border-box;
}
#load-dialog input[type="url"]:focus {
border-color: var(--border-hover);
}
#load-dialog-error {
color: var(--error);
font-size: 0.8rem;
min-height: 1.2em;
margin-top: 0.4rem;
}
#load-dialog-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 1rem;
}
#load-dialog-actions button {
font-family: monospace;
font-size: 0.8rem;
color: var(--text-btn);
background: none;
border: 1px solid var(--border);
padding: 0.2rem 0.75rem;
cursor: pointer;
border-radius: 2px;
}
#load-dialog-actions button:hover {
color: var(--text-btn-hover);
border-color: var(--border-hover);
}
#load-dialog-actions button#load-dialog-confirm {
color: var(--text-strong);
border-color: var(--border-hover);
}
/* Layout tab bar — hidden on wide screens */
#layout-tab-bar {
display: none;
flex-shrink: 0;
border-bottom: 1px solid var(--border);
background: var(--bg-raised);
}
#layout-tab-bar button {
flex: 1;
padding: 0.6rem 1rem;
border: none;
background: transparent;
color: var(--text-btn);
font-size: 0.9rem;
cursor: pointer;
border-bottom: 2px solid transparent;
}
#layout-tab-bar button.active {
color: var(--text-strong);
border-bottom-color: var(--text-strong);
}
#layout-tab-bar button:hover {
color: var(--text-btn-hover);
}
@media (max-width: 768px) {
#layout-tab-bar {
display: flex;
}
.panes {
grid-template-columns: 1fr;
}
.panes[data-active-pane="editor"] #right {
display: none;
}
.panes[data-active-pane="viewer"] > .pane:first-child {
display: none;
}
.pane + .pane {
border-left: none;
}
}

275
src/ui.ts Normal file
View file

@ -0,0 +1,275 @@
// ui.ts — menubar, dialogs, theme button and help wiring (extracted from index.html).
// Close every open menu in the menubar.
function bimrCloseAllMenus() {
var triggers = document.querySelectorAll('.menu-trigger.open');
for (var i = 0; i < triggers.length; i++) triggers[i].classList.remove('open');
var dropdowns = document.querySelectorAll('.menu-dropdown.open');
for (var i = 0; i < dropdowns.length; i++) dropdowns[i].classList.remove('open');
var submenus = document.querySelectorAll('.menu-submenu.open');
for (var i = 0; i < submenus.length; i++) submenus[i].classList.remove('open');
}
(function () {
var btn = document.getElementById('btn-theme');
function updateBtn() {
var dark = document.documentElement.getAttribute('data-theme') === 'dark';
btn.textContent = dark ? '☀' : '☽';
}
updateBtn();
btn.addEventListener('click', function () {
var dark = document.documentElement.getAttribute('data-theme') === 'dark';
var next = dark ? 'light' : 'dark';
if (next === 'dark') {
document.documentElement.setAttribute('data-theme', 'dark');
} else {
document.documentElement.removeAttribute('data-theme');
}
localStorage.setItem('bimr.theme', next);
updateBtn();
window.dispatchEvent(new CustomEvent('bimr-theme-change', { detail: { dark: next === 'dark' } }));
});
})();
(function () {
var trigger = document.getElementById('menu-file-trigger');
var dropdown = document.getElementById('menu-file-dropdown');
function openMenu() {
trigger.classList.add('open');
dropdown.classList.add('open');
}
function closeMenu() {
trigger.classList.remove('open');
dropdown.classList.remove('open');
}
trigger.addEventListener('click', function (e) {
e.stopPropagation();
if (dropdown.classList.contains('open')) closeMenu();
else { bimrCloseAllMenus(); openMenu(); }
});
document.addEventListener('click', function () { bimrCloseAllMenus(); });
dropdown.addEventListener('click', function (e) { e.stopPropagation(); closeMenu(); });
// Save submenu
var saveTrigger = document.getElementById('menu-save');
var saveSubmenu = document.getElementById('menu-save-submenu');
saveTrigger.addEventListener('click', function (e) {
e.stopPropagation();
saveSubmenu.classList.toggle('open');
});
document.getElementById('menu-save-python').addEventListener('click', function () {
window.dispatchEvent(new CustomEvent('bimr-save-buffer', { detail: { format: 'python' } }));
});
document.getElementById('menu-save-bimr').addEventListener('click', function () {
window.dispatchEvent(new CustomEvent('bimr-save-buffer', { detail: { format: 'bimr' } }));
});
document.getElementById('menu-save-ifcx').addEventListener('click', function () {
window.dispatchEvent(new CustomEvent('bimr-save-buffer', { detail: { format: 'ifcx' } }));
});
// Rename
var renameOverlay = document.getElementById('rename-dialog-overlay');
var renameInput = document.getElementById('rename-dialog-input');
var renameError = document.getElementById('rename-dialog-error');
function openRenameDialog() {
renameError.textContent = '';
renameInput.value = window.__bimrActiveTabName ? window.__bimrActiveTabName() : '';
renameOverlay.classList.add('open');
renameInput.focus();
renameInput.select();
}
function closeRenameDialog() { renameOverlay.classList.remove('open'); }
function doRename() {
var name = renameInput.value.trim();
if (!name) { renameError.textContent = 'Name cannot be empty.'; return; }
closeRenameDialog();
window.dispatchEvent(new CustomEvent('bimr-rename-tab', { detail: { name: name } }));
}
document.getElementById('menu-rename').addEventListener('click', openRenameDialog);
document.getElementById('rename-dialog-cancel').addEventListener('click', closeRenameDialog);
document.getElementById('rename-dialog-confirm').addEventListener('click', doRename);
renameInput.addEventListener('keydown', function (e) {
if (e.key === 'Enter') doRename();
if (e.key === 'Escape') closeRenameDialog();
});
renameOverlay.addEventListener('click', function (e) {
if (e.target === renameOverlay) closeRenameDialog();
});
// New
var newOverlay = document.getElementById('new-dialog-overlay');
function openNewDialog() { newOverlay.classList.add('open'); }
function closeNewDialog() { newOverlay.classList.remove('open'); }
document.getElementById('menu-new').addEventListener('click', openNewDialog);
document.getElementById('new-dialog-cancel').addEventListener('click', closeNewDialog);
document.getElementById('new-dialog-erase').addEventListener('click', function () {
closeNewDialog();
window.dispatchEvent(new CustomEvent('bimr-new-file'));
});
document.getElementById('new-dialog-tab').addEventListener('click', function () {
closeNewDialog();
window.dispatchEvent(new CustomEvent('bimr-new-tab'));
});
newOverlay.addEventListener('click', function (e) {
if (e.target === newOverlay) closeNewDialog();
});
// Open: load a .py/.bimrs script into the editor, or display a .ifcx
// model in the viewer (editor shows a comment with the file name).
document.getElementById('menu-open').addEventListener('click', function () {
document.getElementById('file-open').click();
});
document.getElementById('file-open').addEventListener('change', function (e) {
var file = e.target.files[0];
if (!file) return;
var reader = new FileReader();
reader.onload = function (ev) {
var text = ev.target.result;
var isIfcx = /\.ifcx$/i.test(file.name);
var isBimr = /\.bimr$/i.test(file.name);
if (isIfcx) {
var parsed = null;
try { parsed = JSON.parse(text); } catch (err) { parsed = null; }
window.dispatchEvent(new CustomEvent('bimr-load-model', { detail: { text: text, name: file.name } }));
if (parsed) {
window.dispatchEvent(new CustomEvent('bimr-open-file', { detail: { text: '# ' + file.name, name: file.name, placeholder: true } }));
}
} else if (isBimr) {
window.dispatchEvent(new CustomEvent('bimr-open-bimr', { detail: { text: text, name: file.name } }));
} else {
window.dispatchEvent(new CustomEvent('bimr-open-file', { detail: { text: text, name: file.name } }));
}
};
reader.readAsText(file);
e.target.value = '';
});
// Load: fetch a script from a URL via dialog
var loadOverlay = document.getElementById('load-dialog-overlay');
var loadInput = document.getElementById('load-dialog-url');
var loadError = document.getElementById('load-dialog-error');
var loadConfirm = document.getElementById('load-dialog-confirm');
var loadCancel = document.getElementById('load-dialog-cancel');
var DEFAULT_LOAD_URL = 'https://gitaec.org/rvba/docs/src/branch/main/bimr/sample.py';
function openLoadDialog() {
loadError.textContent = '';
if (!loadInput.value) loadInput.value = DEFAULT_LOAD_URL;
loadOverlay.classList.add('open');
loadInput.focus();
loadInput.select();
}
function closeLoadDialog() {
loadOverlay.classList.remove('open');
}
function doLoad() {
var url = loadInput.value.trim();
if (!url) return;
var name = url.split('/').pop() || 'file.py';
loadError.textContent = '';
loadConfirm.disabled = true;
fetch('/forge-proxy?url=' + encodeURIComponent(url))
.then(function (r) {
if (!r.ok) return r.text().then(function(msg) { throw new Error(msg || 'HTTP ' + r.status); });
return r.text();
})
.then(function (text) {
closeLoadDialog();
window.dispatchEvent(new CustomEvent('bimr-open-file', { detail: { text: text, name: name } }));
})
.catch(function (err) {
loadError.textContent = 'Error: ' + err.message;
})
.finally(function () {
loadConfirm.disabled = false;
});
}
document.getElementById('menu-load').addEventListener('click', openLoadDialog);
loadCancel.addEventListener('click', closeLoadDialog);
loadConfirm.addEventListener('click', doLoad);
loadInput.addEventListener('keydown', function (e) {
if (e.key === 'Enter') doLoad();
if (e.key === 'Escape') closeLoadDialog();
});
loadOverlay.addEventListener('click', function (e) {
if (e.target === loadOverlay) closeLoadDialog();
});
// Help
var helpOverlay = document.getElementById('help-dialog-overlay');
function openHelp() { helpOverlay.classList.add('open'); }
function closeHelp() { helpOverlay.classList.remove('open'); }
document.getElementById('menu-help').addEventListener('click', openHelp);
document.getElementById('help-dialog-close').addEventListener('click', closeHelp);
helpOverlay.addEventListener('click', function (e) { if (e.target === helpOverlay) closeHelp(); });
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') closeHelp();
if (e.ctrlKey && (e.key === 'h' || e.key === 'H')) { e.preventDefault(); openHelp(); }
});
// Examples menu
var examplesTrigger = document.getElementById('menu-examples-trigger');
var examplesDropdown = document.getElementById('menu-examples-dropdown');
var examplesLoaded = false;
function openExamplesMenu() {
examplesTrigger.classList.add('open');
examplesDropdown.classList.add('open');
if (!examplesLoaded) {
examplesLoaded = true;
// Base-aware: the app is served under /editor/ in dev and
// production — resolve against the document URL, not the root.
var samplesBase = new URL('samples/', document.baseURI);
fetch(samplesBase + 'index.json')
.then(function (r) { return r.ok ? r.json() : []; })
.then(function (samples) {
examplesDropdown.innerHTML = '';
if (!samples.length) {
examplesDropdown.innerHTML = '<li><em style="padding:6px 12px;display:block;opacity:0.6;">No samples found</em></li>';
return;
}
samples.forEach(function (s) {
var li = document.createElement('li');
var btn = document.createElement('button');
btn.textContent = s.name;
btn.title = s.description || '';
btn.addEventListener('click', function () {
closeExamplesMenu();
fetch(samplesBase + s.id + '.py')
.then(function (r) { return r.ok ? r.text() : Promise.reject(r.status); })
.then(function (text) {
window.dispatchEvent(new CustomEvent('bimr-open-file', { detail: { text: text, name: s.name } }));
})
.catch(function (err) { console.error('Failed to load sample', s.id, err); });
});
li.appendChild(btn);
examplesDropdown.appendChild(li);
});
})
.catch(function () {
examplesDropdown.innerHTML = '<li><em style="padding:6px 12px;display:block;opacity:0.6;">Failed to load index</em></li>';
});
}
}
function closeExamplesMenu() {
examplesTrigger.classList.remove('open');
examplesDropdown.classList.remove('open');
}
examplesTrigger.addEventListener('click', function (e) {
e.stopPropagation();
if (examplesDropdown.classList.contains('open')) closeExamplesMenu();
else { bimrCloseAllMenus(); openExamplesMenu(); }
});
document.addEventListener('click', function () { bimrCloseAllMenus(); });
examplesDropdown.addEventListener('click', function (e) { e.stopPropagation(); closeExamplesMenu(); });
})();

6
src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1,6 @@
/// <reference types="vite/client" />
declare module "*?raw" {
const content: string;
export default content;
}

265
tests/compile.spec.ts Normal file
View file

@ -0,0 +1,265 @@
import { test, expect } from "@playwright/test";
const WALL_SRC = `p1 = Point(0,0,0)
p2 = Point(5000,0,0)
l1 = Line(p1,p2)
w1 = Wall(l1,200,3000)`;
const INVALID_SRC = `w1 = Wall(missing)`;
function spyViewer() {
return `
Object.defineProperty(window, "__bimrViewer", {
configurable: true,
set(v) {
const record = [];
(window).__addedModels = record;
v.addModel = (name, m) => { record.push({ name, m }); };
this._v = v;
},
get() { return this._v; },
});
`;
}
async function loadScript(page, src: string) {
// The editor builds after the default sample fetch resolves (top-level
// await in main.ts) — dispatching before that races the open-file
// listener registration. Wait for the editor to hold content.
await page.waitForFunction(
() => (document.querySelector(".cm-content")?.textContent ?? "").length > 0,
undefined,
{ timeout: 15000 },
);
await page.evaluate((text) => {
window.dispatchEvent(new CustomEvent("bimr-open-file", {
detail: { text, name: "test.py" },
}));
}, src);
}
// Path of the last live model rendered by the viewer — null while nothing
// has been compiled yet. Total: never throws, never returns undefined.
async function lastLivePath(page): Promise<string | null> {
return page.evaluate(() => {
const live = ((window as any).__addedModels ?? []).filter(
(a: { name: string }) => a.name === "live",
);
if (!live.length) return null;
return live[live.length - 1].m?.data?.[0]?.path ?? null;
});
}
// Paths of the last live model — null while nothing has been compiled yet.
async function livePaths(page): Promise<string[] | null> {
return page.evaluate(() => {
const live = ((window as any).__addedModels ?? []).filter(
(a: { name: string }) => a.name === "live",
);
if (!live.length) return null;
return (live[live.length - 1].m?.data ?? []).map((d: { path: string }) => d.path);
});
}
// Python-mode success shows the script's prints in #output (empty for
// printless scripts) — the model itself is asserted via the viewer spy,
// which works headless without WebGL.
test("compiles a wall to IFCX", async ({ page }) => {
await page.addInitScript(spyViewer());
await page.goto("/");
await loadScript(page, WALL_SRC);
await expect.poll(() => lastLivePath(page), { timeout: 20000 }).toBe("wall-001");
});
test("shows error for invalid input", async ({ page }) => {
await page.goto("/");
const output = page.locator("#output");
await loadScript(page, INVALID_SRC);
await expect(output).toContainText("Error:", { ignoreCase: true, timeout: 15000 });
});
test("python mode — wall produces IFCX through the batch bridge", async ({ page }) => {
await page.addInitScript(spyViewer());
await page.goto("/");
// Python is the default mode — load wall source directly
await loadScript(page, WALL_SRC);
await expect.poll(() => lastLivePath(page), { timeout: 20000 }).toBe("wall-001");
});
test("python mode — error for invalid script", async ({ page }) => {
await page.goto("/");
const output = page.locator("#output");
// Load invalid script, then wait for error in output
await loadScript(page, INVALID_SRC);
await expect(output).toContainText("Error:", { ignoreCase: true, timeout: 15000 });
});
test("updates live on input", async ({ page }) => {
await page.addInitScript(spyViewer());
await page.goto("/");
await loadScript(page, WALL_SRC);
await expect.poll(() => lastLivePath(page), { timeout: 20000 }).toBe("wall-001");
});
test("python mode — 4x4 column grid lifts over a List", async ({ page }) => {
await page.addInitScript(spyViewer());
await page.goto("/");
// The column_grid sample's Python form: List of Points, Column lifts.
const GRID_SRC = `pts = [Point(x * 1000, y * 1000, 0) for x in range(4) for y in range(4)]
grid = List(*pts)
cols = Column(grid, 300, 30, 30)`;
await loadScript(page, GRID_SRC);
const columnPaths = Array.from(
{ length: 16 },
(_, i) => `column-${String(i + 1).padStart(3, "0")}`,
);
await expect.poll(() => livePaths(page), { timeout: 20000 }).toEqual(columnPaths);
});
test("python mode — 12 columns lift over a Divide", async ({ page }) => {
await page.addInitScript(spyViewer());
await page.goto("/");
// The columns_ring sample: Circle → Divide (the engine creates the
// points) → Column lifts over the record's items.
const RING_SRC = `center = Point(0, 0, 0)
circle = Circle(center, 2000)
ring = Divide(circle, 12)
cols = Column(ring, 300, 30, 30)`;
await loadScript(page, RING_SRC);
const columnPaths = Array.from(
{ length: 12 },
(_, i) => `column-${String(i + 1).padStart(3, "0")}`,
);
await expect.poll(() => livePaths(page), { timeout: 20000 }).toEqual(columnPaths);
});
test("python mode — cylinder: Circle, Cut, Extrusion", async ({ page }) => {
await page.addInitScript(spyViewer());
await page.goto("/");
// The cylinder sample: Cut creates the pieces, the Extrusion sweeps them.
const CYL_SRC = `center = Point(0, 0, 0)
circle = Circle(center, 1000)
pieces = Cut(circle, 16)
axis = Vector(0, 0, 3000)
cylinder = Extrusion(pieces, axis)`;
await loadScript(page, CYL_SRC);
await expect.poll(() => livePaths(page), { timeout: 20000 }).toEqual([
"extrusion-001",
]);
});
test("python mode — a cylinder of frames: Cut, Explode, Frame lift", async ({ page }) => {
await page.addInitScript(spyViewer());
await page.goto("/");
// The frames_cylinder sample: the Cut pieces explode into pairs, the
// Frame lifts one frame per pair — no generated names referenced.
const RING_SRC = `center = Point(0, 0, 0)
circle = Circle(center, 1000)
pieces = Cut(circle, 12)
pairs = Explode(pieces)
frames = Frame(pairs, 100, 300, 20, 10)`;
await loadScript(page, RING_SRC);
const framePaths = Array.from(
{ length: 12 },
(_, i) => `frame-${String(i + 1).padStart(3, "0")}`,
);
await expect.poll(() => livePaths(page), { timeout: 20000 }).toEqual(framePaths);
});
test("python mode — peanut: Curve, Cut, Extrusion", async ({ page }) => {
await page.addInitScript(spyViewer());
await page.goto("/");
// The peanut sample: a closed curve through eight controls, cut and
// extruded — the deformed cylinder.
const PEANUT_SRC = `c0 = Point(1600, 0, 0)
c1 = Point(600, 500, 0)
c2 = Point(0, 250, 0)
c3 = Point(-600, 500, 0)
c4 = Point(-1600, 0, 0)
c5 = Point(-600, -500, 0)
c6 = Point(0, -250, 0)
c7 = Point(600, -500, 0)
ctrl = List(c0, c1, c2, c3, c4, c5, c6, c7)
curve = Curve(ctrl)
pieces = Cut(curve, 24)
axis = Vector(0, 0, 3000)
peanut = Extrusion(pieces, axis)`;
await loadScript(page, PEANUT_SRC);
await expect.poll(() => livePaths(page), { timeout: 20000 }).toEqual([
"extrusion-001",
]);
});
test("python mode — the building: Curve, Cut, Frame, Slab, Storey, Building", async ({ page }) => {
await page.addInitScript(spyViewer());
await page.goto("/");
// The building sample: per storey a curve cut into frames (no Each) and
// a slab; Storeys gathered into a Building.
const BLD_SRC = `N_LEVELS = 4
N_PIECES = 24
FRAME_W, FRAME_H, FRAME_D, FRAME_T = 200, 180, 35, 10
SLAB_T = 150
LEVEL_H = FRAME_H + 2 * SLAB_T
BASE = [(-1500, -300), (0, 1000), (850, 800), (300, 0), (500, -800), (0, -1000), (-500, -800), (-300, 0)]
levels = []
for lvl in range(N_LEVELS):
z = lvl * LEVEL_H
base = List(*[Point(x, y, z) for x, y in BASE])
pts = Random(base, seed=128 + lvl, min=-150, max=150, dims=2)
curve = Curve(pts)
Slab(curve, SLAB_T, z)
pieces = Cut(curve, N_PIECES)
pairs = Explode(pieces)
frames = Frame(pairs, FRAME_W, FRAME_H, FRAME_D, FRAME_T)
levels.append(Storey(z, frames))
building = Building("building-03", List(*levels))`;
await loadScript(page, BLD_SRC);
// 4 slabs + 4 x 24 frames = 100 mesh entries, slabs first per storey.
await expect.poll(() => livePaths(page), { timeout: 30000 }).toHaveLength(100);
const first = await page.evaluate(() => {
const live = ((window as any).__addedModels ?? []).filter(
(a: { name: string }) => a.name === "live",
);
return live.length ? live[live.length - 1].m?.data?.[0]?.path : null;
});
expect(first).toBe("slab-001");
});
test("python mode — 40x40 grid stays inside the MicroPython heap", async ({ page }) => {
await page.addInitScript(spyViewer());
await page.goto("/");
// 1600 lifted columns — the IFCX document (~4.7MB) must never cross the
// JS→MicroPython boundary (regression: compile() used to return it).
const GRID_SRC = `pts = [Point(x * 100, y * 100, 0) for x in range(40) for y in range(40)]
grid = List(*pts)
cols = Column(grid, 300, 30, 30)`;
await loadScript(page, GRID_SRC);
await expect.poll(() => livePaths(page), { timeout: 30000 }).toEqual(
Array.from({ length: 1600 }, (_, i) => `column-${String(i + 1).padStart(3, "0")}`),
);
});

View file

@ -0,0 +1,16 @@
import { test, expect } from "@playwright/test";
// The Examples menu is dynamic: one button per index.json entry, each
// loading /samples/<id>.py. Every synced sample must appear.
test("examples menu lists every synced sample", async ({ page }) => {
await page.goto("/");
await page.click("#menu-examples-trigger");
const buttons = page.locator("#menu-examples-dropdown button");
await expect(buttons).toHaveText([
"building.py",
"grid.py",
"cylinders.py",
]);
});

276
tests/ifcx-open.spec.ts Normal file
View file

@ -0,0 +1,276 @@
import { test, expect } from "@playwright/test";
import { readFileSync } from "fs";
function spyViewer() {
return `
Object.defineProperty(window, "__bimrViewer", {
configurable: true,
set(v) {
const record = [];
(window).__addedModels = record;
v.addModel = (name, m) => { record.push({ name, m }); };
this._v = v;
},
get() { return this._v; },
});
`;
}
test("open .ifcx file displays in viewer and comments the editor", async ({ page }) => {
// Intercept the viewer registration (render.mjs) to record every addModel call.
await page.addInitScript(spyViewer());
await page.goto("/");
const ifcx = JSON.stringify({ header: { id: "test.ifcx" }, layers: [] });
await page.locator("#file-open").setInputFiles({
name: "test.ifcx",
mimeType: "application/json",
buffer: Buffer.from(ifcx),
});
await expect(page.locator("#editor .cm-content")).toContainText("# test.ifcx");
await expect(page.locator("#output")).toContainText("Loaded: test.ifcx");
await expect
.poll(() =>
page.evaluate(() =>
((window as any).__addedModels ?? []).filter(
(a: { name: string }) => a.name === "loaded",
),
),
)
.toHaveLength(1);
const loaded = await page.evaluate(() =>
((window as any).__addedModels ?? []).filter((a: { name: string }) => a.name === "loaded"),
);
expect(loaded[0].m.header.id).toBe("test.ifcx");
});
test("open .py file still loads into the editor", async ({ page }) => {
await page.goto("/");
await page.locator("#file-open").setInputFiles({
name: "script.py",
mimeType: "text/x-python",
buffer: Buffer.from("print('hi')\n"),
});
await expect(page.locator("#editor .cm-content")).toContainText("print('hi')");
const name = await page.evaluate(() => (window as any).__bimrActiveTabName());
expect(name).toBe("script.py");
});
test("New clears stale bimr/ifcx buffers", async ({ page }) => {
await page.addInitScript(spyViewer());
await page.addInitScript(() => localStorage.setItem("bimr.runMode", "manual"));
await page.goto("/");
// Produce data in the buffers by running the default script.
await page.locator("#btn-run").click();
await expect
.poll(() =>
page.evaluate(() =>
((window as any).__addedModels ?? []).filter((a: { name: string }) => a.name === "live").length,
),
)
.toBeGreaterThan(0);
await page.locator("#btn-view-ifcx").click();
await expect(page.locator("#editor .cm-content")).toContainText('"data"');
// New -> Erase current
await page.locator("#menu-file-trigger").click();
await page.locator("#menu-new").click();
await page.locator("#new-dialog-erase").click();
// py buffer emptied.
await expect(page.locator("#editor .cm-content")).toHaveText("");
// ifcx buffer cleared — shows the placeholder, not stale data.
await page.locator("#btn-view-ifcx").click();
await expect(page.locator("#editor .cm-content")).toContainText("no model yet");
});
test("Save submenu disables unavailable buffers and downloads the chosen one", async ({ page }) => {
await page.addInitScript(spyViewer());
await page.addInitScript(() => localStorage.setItem("bimr.runMode", "manual"));
await page.goto("/");
// Wait for the idle warm-up run to settle, then New clears the buffers.
await expect
.poll(() =>
page.evaluate(() =>
((window as any).__addedModels ?? []).filter((a: { name: string }) => a.name === "live").length,
),
)
.toBeGreaterThan(0);
await page.locator("#menu-file-trigger").click();
await page.locator("#menu-new").click();
await page.locator("#new-dialog-erase").click();
// Cleared buffers -> bimr/ifcx saves disabled, python always enabled.
await page.locator("#menu-file-trigger").click();
await page.locator("#menu-save").click();
await expect(page.locator("#menu-save-python")).not.toBeDisabled();
await expect(page.locator("#menu-save-bimr")).toBeDisabled();
await expect(page.locator("#menu-save-ifcx")).toBeDisabled();
// Load a real sample and run it to populate the bimr/ifcx buffers.
await page.locator("#btn-view-python").click();
await page.locator("#menu-examples-trigger").click();
await page.locator("#menu-examples-dropdown li button").first().click();
await expect(page.locator("#editor .cm-content")).toContainText("Welcome to BIMR.NET!");
await page.locator("#btn-run").click();
await expect(page.locator("#menu-save-bimr")).not.toBeDisabled({ timeout: 20000 });
await expect(page.locator("#menu-save-ifcx")).not.toBeDisabled();
// Download the bimr buffer.
await page.locator("#menu-file-trigger").click();
await page.locator("#menu-save").click();
const downloadPromise = page.waitForEvent("download");
await page.locator("#menu-save-bimr").click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe("building.bimr");
const bimrText = readFileSync((await download.path())!, "utf8");
expect(bimrText.length).toBeGreaterThan(0);
});
test("opening a real .ifcx does not auto-run or empty the ifcx view", async ({ page }) => {
await page.addInitScript(spyViewer());
await page.goto("/");
// Wait for the idle auto-run to have produced a live model.
await expect
.poll(() =>
page.evaluate(() =>
((window as any).__addedModels ?? []).filter((a: { name: string }) => a.name === "live").length,
),
)
.toBeGreaterThan(0);
// Capture the real app-generated IFCX via File > Save > Ifcx.
const downloadPromise = page.waitForEvent("download");
await page.locator("#menu-file-trigger").click();
await page.locator("#menu-save").click();
await page.locator("#menu-save-ifcx").click();
const download = await downloadPromise;
const ifcxText = readFileSync((await download.path())!, "utf8");
expect(ifcxText).toContain('"data"');
const liveBefore = await page.evaluate(() =>
((window as any).__addedModels ?? []).filter((a: { name: string }) => a.name === "live").length,
);
await page.locator("#file-open").setInputFiles({
name: "my-model.ifcx",
mimeType: "application/json",
buffer: Buffer.from(ifcxText),
});
await expect(page.locator("#editor .cm-content")).toContainText("# my-model.ifcx");
await expect(page.locator("#output")).toContainText("Loaded: my-model.ifcx");
// Wait past the run debounce — opening an ifcx must NOT trigger a new run.
await page.waitForTimeout(800);
const liveAfter = await page.evaluate(() =>
((window as any).__addedModels ?? []).filter((a: { name: string }) => a.name === "live").length,
);
expect(liveAfter).toBe(liveBefore);
// The ifcx view must show the loaded model's data, not an empty ifcx.
await page.locator("#btn-view-ifcx").click();
await expect(page.locator("#editor .cm-content")).toContainText('"data"');
});
test("open .bimr file compiles, displays in viewer and loads source into the editable bimr view", async ({ page }) => {
await page.addInitScript(spyViewer());
await page.goto("/");
const bimrSrc = `p1 = Point(0, 0, 0)
p2 = Point(1000, 0, 0)
p3 = Point(1000, 800, 0)
p4 = Point(0, 800, 0)
l1 = Line(p1, p2)
l2 = Line(p2, p3)
l3 = Line(p3, p4)
l4 = Line(p4, p1)
w1 = Wall(l1, 20, 300)
w2 = Wall(l2, 20, 300)
w3 = Wall(l3, 20, 300)
w4 = Wall(l4, 20, 300)`;
await page.locator("#file-open").setInputFiles({
name: "house.bimr",
mimeType: "text/plain",
buffer: Buffer.from(bimrSrc),
});
await expect(page.locator("#output")).toContainText("Loaded: house.bimr");
await expect
.poll(() =>
page.evaluate(() =>
((window as any).__addedModels ?? []).filter(
(a: { name: string }) => a.name === "loaded",
),
),
)
.toHaveLength(1);
const loaded = await page.evaluate(() =>
((window as any).__addedModels ?? []).filter((a: { name: string }) => a.name === "loaded"),
);
const data = loaded[0].m.data as unknown[];
expect(data.length).toBe(4);
// The source must be loaded into the editable bimr view, ready to tweak.
await expect(page.locator("#editor .cm-content")).toContainText("w1 = Wall(l1, 20, 300)");
await expect(page.locator("#btn-view-bimr")).toHaveClass(/active/);
});
test("bimr mode: editing DSL source and running compiles to a model", async ({ page }) => {
await page.addInitScript(spyViewer());
await page.goto("/");
await page.locator("#btn-view-bimr").click();
const editor = page.locator("#editor .cm-content");
await editor.click();
await page.keyboard.press("Control+a");
await page.keyboard.insertText(
"p1 = Point(0,0,0)\np2 = Point(5000,0,0)\nl1 = Line(p1,p2)\nw1 = Wall(l1,200,3000)",
);
await expect(page.locator("#output")).toContainText("Compiled 1 entity", { timeout: 15000 });
await expect
.poll(() =>
page.evaluate(() =>
((window as any).__addedModels ?? []).filter((a: { name: string }) => a.name === "live").length,
),
)
.toBeGreaterThan(0);
await expect(page.locator("#menu-save-bimr")).not.toBeDisabled();
});
test("bimr mode: invalid DSL source shows an error", async ({ page }) => {
await page.addInitScript(spyViewer());
await page.addInitScript(() => localStorage.setItem("bimr.runMode", "manual"));
await page.goto("/");
await page.locator("#btn-view-bimr").click();
const editor = page.locator("#editor .cm-content");
await editor.click();
await page.keyboard.press("Control+a");
await page.keyboard.insertText("w1 = Wall(missing)");
await page.keyboard.press("Control+Enter");
await expect(page.locator("#output")).toContainText("Error:", { ignoreCase: true });
});
test("open invalid .bimr file shows an error and does not touch the editor", async ({ page }) => {
await page.addInitScript(spyViewer());
await page.goto("/");
await page.locator("#file-open").setInputFiles({
name: "broken.bimr",
mimeType: "text/plain",
buffer: Buffer.from("p1 = Point(0, 0, 0)\nunknown = Frobnicate(p1)\n"),
});
await expect(page.locator("#output")).toContainText("Error");
await expect(page.locator("#editor .cm-content")).not.toContainText("# broken.bimr");
});

View file

@ -0,0 +1,52 @@
import { test, expect } from "@playwright/test";
function spyViewer() {
return `
Object.defineProperty(window, "__bimrViewer", {
configurable: true,
set(v) {
const record = [];
(window).__addedModels = record;
v.addModel = (name, m) => { record.push({ name, m }); };
this._v = v;
},
get() { return this._v; },
});
`;
}
// The viewer is bundled with the app (three resolved at build time) —
// blocking the CDN must not affect it. Regression: the viewer used to load
// three from jsDelivr via the import map, silently dying offline.
test("viewer works with the CDN blocked", async ({ page }) => {
await page.route("https://cdn.jsdelivr.net/**", (route) => route.abort());
await page.addInitScript(spyViewer());
await page.goto("/");
// The editor builds after the default sample fetch resolves (top-level
// await in main.ts) — dispatching before that races the open-file
// listener registration.
await page.waitForFunction(
() => (document.querySelector(".cm-content")?.textContent ?? "").length > 0,
undefined,
{ timeout: 15000 },
);
await page.evaluate((text) => {
window.dispatchEvent(new CustomEvent("bimr-open-file", {
detail: { text, name: "test.py" },
}));
}, "p1 = Point(0,0,0)\np2 = Point(5000,0,0)\nl1 = Line(p1,p2)\nw1 = Wall(l1,200,3000)");
await expect
.poll(
() =>
page.evaluate(() => {
const live = ((window as any).__addedModels ?? []).filter(
(a: { name: string }) => a.name === "live",
);
return live.length ? (live[live.length - 1].m?.data?.[0]?.path ?? null) : null;
}),
{ timeout: 20000 },
)
.toBe("wall-001");
});

118
vite.config.ts Normal file
View file

@ -0,0 +1,118 @@
import { defineConfig, type Plugin } from "vite";
import wasm from "vite-plugin-wasm";
import topLevelAwait from "vite-plugin-top-level-await";
// Normalise a Forge browse/raw URL to its contents API equivalent.
// Returns { apiUrl, accept } or null if no pattern matched.
function forgeApiUrl(input: string): { apiUrl: string; accept: string } | null {
try {
const u = new URL(input);
// GitHub: github.com/{owner}/{repo}/blob/{branch}/{filepath}
if (u.hostname === "github.com") {
const m = u.pathname.match(/^\/([^/]+)\/([^/]+)\/blob\/([^/]+)\/(.+)$/);
if (!m) return null;
const [, owner, repo, branch, filepath] = m;
return {
apiUrl: `https://api.github.com/repos/${owner}/${repo}/contents/${filepath}?ref=${encodeURIComponent(branch)}`,
accept: "application/vnd.github.v3+json",
};
}
// Forgejo / Gitea: {forge}/{owner}/{repo}/src/branch/{branch}/{filepath}
// {forge}/{owner}/{repo}/raw/branch/{branch}/{filepath}
const m = u.pathname.match(
/^\/([^/]+)\/([^/]+)\/(?:src|raw)\/branch\/([^/]+)\/(.+)$/,
);
if (!m) return null;
const [, owner, repo, branch, filepath] = m;
return {
apiUrl: `${u.origin}/api/v1/repos/${owner}/${repo}/contents/${filepath}?ref=${encodeURIComponent(branch)}`,
accept: "application/json",
};
} catch {
return null;
}
}
function forgeProxy(): Plugin {
async function handler(req: any, res: any) {
const inputUrl = new URL(req.url, "http://localhost").searchParams.get("url");
if (!inputUrl) {
res.statusCode = 400;
res.end("missing url parameter");
return;
}
const parsed = forgeApiUrl(inputUrl);
const apiUrl = parsed?.apiUrl ?? inputUrl;
const accept = parsed?.accept ?? "application/json";
try {
const upstream = await fetch(apiUrl, {
headers: { accept },
});
res.setHeader("content-type", "text/plain; charset=utf-8");
if (!upstream.ok) {
res.statusCode = upstream.status;
res.end(`HTTP ${upstream.status}`);
return;
}
const ct = upstream.headers.get("content-type") || "";
// Forgejo contents API — decode base64 content field
if (ct.includes("application/json")) {
const json = await upstream.json() as any;
if (json.encoding === "base64" && typeof json.content === "string") {
// content may have newlines inserted every 60 chars
const b64 = json.content.replace(/\s/g, "");
res.statusCode = 200;
res.end(Buffer.from(b64, "base64").toString("utf-8"));
return;
}
res.statusCode = 422;
res.end("Unexpected API response shape");
return;
}
// Plain text fallthrough (e.g. already a direct raw URL)
if (ct.includes("text/html")) {
res.statusCode = 422;
res.end("Forge returned HTML — check the URL or authentication");
return;
}
res.statusCode = 200;
res.end(await upstream.text());
} catch (e) {
res.statusCode = 502;
res.end(String(e));
}
}
return {
name: "forge-proxy",
configureServer(server) {
server.middlewares.use("/forge-proxy", handler);
},
configurePreviewServer(server) {
server.middlewares.use("/forge-proxy", handler);
},
};
}
export default defineConfig({
plugins: [wasm(), topLevelAwait(), forgeProxy()],
build: {
target: "esnext",
rollupOptions: {
external: (id) => {
// MicroPython is served from public/mp/ — never bundled.
if (id.includes("micropython.mjs")) return true;
return false;
},
},
},
});