release: 0.0.1
This commit is contained in:
commit
52c560419d
23 changed files with 4057 additions and 0 deletions
17
.gitignore
vendored
Normal file
17
.gitignore
vendored
Normal 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
60
AGENTS.md
Normal 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
21
LICENSE
Normal 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
80
Makefile
Normal 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
3
README.md
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# bimr-web
|
||||||
|
|
||||||
|
Browser editor for BIMR
|
||||||
197
index.html
Normal file
197
index.html
Normal 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 > New</td><td>Erase current file or open a new tab</td></tr>
|
||||||
|
<tr><td>File > Open</td><td>Load a local .py or .bimr script into the editor</td></tr>
|
||||||
|
<tr><td>File > Save</td><td>Save the Python, Bimr or Ifcx buffer as a file</td></tr>
|
||||||
|
<tr><td>File > Rename</td><td>Rename the current file</td></tr>
|
||||||
|
<tr><td>File > 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 & 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">▶ 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
27
package.json
Normal 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
18
playwright.config.ts
Normal 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
1004
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
6
pnpm-workspace.yaml
Normal file
6
pnpm-workspace.yaml
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
allowBuilds:
|
||||||
|
'@swc/core': true
|
||||||
|
esbuild: true
|
||||||
|
onlyBuiltDependencies:
|
||||||
|
- '@swc/core'
|
||||||
|
- esbuild
|
||||||
17
public/samples/index.json
Normal file
17
public/samples/index.json
Normal 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
26
scripts/bundle-app.mjs
Normal 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
27
scripts/cdn-to-bare.mjs
Normal 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
185
src/bimr_api.py
Normal 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
614
src/main.ts
Normal 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
747
src/style.css
Normal 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
275
src/ui.ts
Normal 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
6
src/vite-env.d.ts
vendored
Normal 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
265
tests/compile.spec.ts
Normal 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")}`),
|
||||||
|
);
|
||||||
|
});
|
||||||
16
tests/examples-menu.spec.ts
Normal file
16
tests/examples-menu.spec.ts
Normal 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
276
tests/ifcx-open.spec.ts
Normal 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");
|
||||||
|
});
|
||||||
52
tests/offline-viewer.spec.ts
Normal file
52
tests/offline-viewer.spec.ts
Normal 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
118
vite.config.ts
Normal 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;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue