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