From 3f2324bc01465fa30ef778a0410d1b27c8c755fa Mon Sep 17 00:00:00 2001 From: rvba Date: Tue, 1 Sep 2026 10:37:51 +0200 Subject: [PATCH] release: 0.0.1 --- .gitignore | 17 + LICENSE | 21 + Makefile | 80 +++ README.md | 3 + index.html | 197 +++++++ package.json | 27 + playwright.config.ts | 18 + pnpm-lock.yaml | 1004 ++++++++++++++++++++++++++++++++++ pnpm-workspace.yaml | 6 + public/samples/index.json | 17 + scripts/bundle-app.mjs | 26 + scripts/cdn-to-bare.mjs | 27 + src/bimr_api.py | 185 +++++++ src/main.ts | 614 +++++++++++++++++++++ src/style.css | 747 +++++++++++++++++++++++++ src/ui.ts | 275 ++++++++++ src/vite-env.d.ts | 6 + tests/compile.spec.ts | 265 +++++++++ tests/examples-menu.spec.ts | 16 + tests/ifcx-open.spec.ts | 276 ++++++++++ tests/offline-viewer.spec.ts | 52 ++ vite.config.ts | 118 ++++ 22 files changed, 3997 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 index.html create mode 100644 package.json create mode 100644 playwright.config.ts create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 public/samples/index.json create mode 100644 scripts/bundle-app.mjs create mode 100644 scripts/cdn-to-bare.mjs create mode 100644 src/bimr_api.py create mode 100644 src/main.ts create mode 100644 src/style.css create mode 100644 src/ui.ts create mode 100644 src/vite-env.d.ts create mode 100644 tests/compile.spec.ts create mode 100644 tests/examples-menu.spec.ts create mode 100644 tests/ifcx-open.spec.ts create mode 100644 tests/offline-viewer.spec.ts create mode 100644 vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..63c1302 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d9e1af9 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6a4edb0 --- /dev/null +++ b/Makefile @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..865c499 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# bimr-web + +Browser editor for BIMR diff --git a/index.html b/index.html new file mode 100644 index 0000000..98ce750 --- /dev/null +++ b/index.html @@ -0,0 +1,197 @@ + + + + + + BIMR.NET + + + + + + +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+
+ BIMR + +
+ +
+
+ + +
+
+
+
+
+ +
+ +
+ + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..769f00a --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..11ed2f6 --- /dev/null +++ b/playwright.config.ts @@ -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, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..204b910 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1004 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@codemirror/lang-json': + specifier: ^6.0.2 + version: 6.0.2 + '@codemirror/lang-python': + specifier: ^6.2.1 + version: 6.2.1 + '@codemirror/language': + specifier: ^6.12.2 + version: 6.12.2 + '@codemirror/state': + specifier: ^6.6.0 + version: 6.6.0 + '@codemirror/theme-one-dark': + specifier: ^6.1.3 + version: 6.1.3 + '@codemirror/view': + specifier: ^6.40.0 + version: 6.40.0 + codemirror: + specifier: ^6.0.2 + version: 6.0.2 + three: + specifier: ^0.183.2 + version: 0.183.2 + devDependencies: + '@playwright/test': + specifier: ^1.58.2 + version: 1.58.2 + vite: + specifier: ^5.0.0 + version: 5.4.21 + vite-plugin-top-level-await: + specifier: ^1.4.0 + version: 1.6.0(rollup@4.59.0)(vite@5.4.21) + vite-plugin-wasm: + specifier: ^3.3.0 + version: 3.5.0(vite@5.4.21) + +packages: + + '@codemirror/autocomplete@6.20.1': + resolution: {integrity: sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A==} + + '@codemirror/commands@6.10.3': + resolution: {integrity: sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==} + + '@codemirror/lang-json@6.0.2': + resolution: {integrity: sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==} + + '@codemirror/lang-python@6.2.1': + resolution: {integrity: sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==} + + '@codemirror/language@6.12.2': + resolution: {integrity: sha512-jEPmz2nGGDxhRTg3lTpzmIyGKxz3Gp3SJES4b0nAuE5SWQoKdT5GoQ69cwMmFd+wvFUhYirtDTr0/DRHpQAyWg==} + + '@codemirror/lint@6.9.5': + resolution: {integrity: sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA==} + + '@codemirror/search@6.6.0': + resolution: {integrity: sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw==} + + '@codemirror/state@6.6.0': + resolution: {integrity: sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==} + + '@codemirror/theme-one-dark@6.1.3': + resolution: {integrity: sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==} + + '@codemirror/view@6.40.0': + resolution: {integrity: sha512-WA0zdU7xfF10+5I3HhUUq3kqOx3KjqmtQ9lqZjfK7jtYk4G72YW9rezcSywpaUMCWOMlq+6E0pO1IWg1TNIhtg==} + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@lezer/common@1.5.1': + resolution: {integrity: sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==} + + '@lezer/highlight@1.2.3': + resolution: {integrity: sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==} + + '@lezer/json@1.0.3': + resolution: {integrity: sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==} + + '@lezer/lr@1.4.8': + resolution: {integrity: sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA==} + + '@lezer/python@1.1.18': + resolution: {integrity: sha512-31FiUrU7z9+d/ElGQLJFXl+dKOdx0jALlP3KEOsGTex8mvj+SoE1FgItcHWK/axkxCHGUSpqIHt6JAWfWu9Rhg==} + + '@marijn/find-cluster-break@1.0.2': + resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==} + + '@playwright/test@1.58.2': + resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==} + engines: {node: '>=18'} + hasBin: true + + '@rollup/plugin-virtual@3.0.2': + resolution: {integrity: sha512-10monEYsBp3scM4/ND4LNH5Rxvh3e/cVeL3jWTgZ2SrQ+BmUoQcopVQvnaMcOnykb1VkxUFuDAN+0FnpTFRy2A==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/rollup-android-arm-eabi@4.59.0': + resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.59.0': + resolution: {integrity: sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.59.0': + resolution: {integrity: sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.59.0': + resolution: {integrity: sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.59.0': + resolution: {integrity: sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.59.0': + resolution: {integrity: sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': + resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.59.0': + resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.59.0': + resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.59.0': + resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.59.0': + resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.59.0': + resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.59.0': + resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.59.0': + resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.59.0': + resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.59.0': + resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.59.0': + resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.59.0': + resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.59.0': + resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.59.0': + resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.59.0': + resolution: {integrity: sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.59.0': + resolution: {integrity: sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.59.0': + resolution: {integrity: sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.59.0': + resolution: {integrity: sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.59.0': + resolution: {integrity: sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==} + cpu: [x64] + os: [win32] + + '@swc/core-darwin-arm64@1.15.18': + resolution: {integrity: sha512-+mIv7uBuSaywN3C9LNuWaX1jJJ3SKfiJuE6Lr3bd+/1Iv8oMU7oLBjYMluX1UrEPzwN2qCdY6Io0yVicABoCwQ==} + engines: {node: '>=10'} + cpu: [arm64] + os: [darwin] + + '@swc/core-darwin-x64@1.15.18': + resolution: {integrity: sha512-wZle0eaQhnzxWX5V/2kEOI6Z9vl/lTFEC6V4EWcn+5pDjhemCpQv9e/TDJ0GIoiClX8EDWRvuZwh+Z3dhL1NAg==} + engines: {node: '>=10'} + cpu: [x64] + os: [darwin] + + '@swc/core-linux-arm-gnueabihf@1.15.18': + resolution: {integrity: sha512-ao61HGXVqrJFHAcPtF4/DegmwEkVCo4HApnotLU8ognfmU8x589z7+tcf3hU+qBiU1WOXV5fQX6W9Nzs6hjxDw==} + engines: {node: '>=10'} + cpu: [arm] + os: [linux] + + '@swc/core-linux-arm64-gnu@1.15.18': + resolution: {integrity: sha512-3xnctOBLIq3kj8PxOCgPrGjBLP/kNOddr6f5gukYt/1IZxsITQaU9TDyjeX6jG+FiCIHjCuWuffsyQDL5Ew1bg==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@swc/core-linux-arm64-musl@1.15.18': + resolution: {integrity: sha512-0a+Lix+FSSHBSBOA0XznCcHo5/1nA6oLLjcnocvzXeqtdjnPb+SvchItHI+lfeiuj1sClYPDvPMLSLyXFaiIKw==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@swc/core-linux-x64-gnu@1.15.18': + resolution: {integrity: sha512-wG9J8vReUlpaHz4KOD/5UE1AUgirimU4UFT9oZmupUDEofxJKYb1mTA/DrMj0s78bkBiNI+7Fo2EgPuvOJfuAA==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@swc/core-linux-x64-musl@1.15.18': + resolution: {integrity: sha512-4nwbVvCphKzicwNWRmvD5iBaZj8JYsRGa4xOxJmOyHlMDpsvvJ2OR2cODlvWyGFH6BYL1MfIAK3qph3hp0Az6g==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@swc/core-win32-arm64-msvc@1.15.18': + resolution: {integrity: sha512-zk0RYO+LjiBCat2RTMHzAWaMky0cra9loH4oRrLKLLNuL+jarxKLFDA8xTZWEkCPLjUTwlRN7d28eDLLMgtUcQ==} + engines: {node: '>=10'} + cpu: [arm64] + os: [win32] + + '@swc/core-win32-ia32-msvc@1.15.18': + resolution: {integrity: sha512-yVuTrZ0RccD5+PEkpcLOBAuPbYBXS6rslENvIXfvJGXSdX5QGi1ehC4BjAMl5FkKLiam4kJECUI0l7Hq7T1vwg==} + engines: {node: '>=10'} + cpu: [ia32] + os: [win32] + + '@swc/core-win32-x64-msvc@1.15.18': + resolution: {integrity: sha512-7NRmE4hmUQNCbYU3Hn9Tz57mK9Qq4c97ZS+YlamlK6qG9Fb5g/BB3gPDe0iLlJkns/sYv2VWSkm8c3NmbEGjbg==} + engines: {node: '>=10'} + cpu: [x64] + os: [win32] + + '@swc/core@1.15.18': + resolution: {integrity: sha512-z87aF9GphWp//fnkRsqvtY+inMVPgYW3zSlXH1kJFvRT5H/wiAn+G32qW5l3oEk63KSF1x3Ov0BfHCObAmT8RA==} + engines: {node: '>=10'} + peerDependencies: + '@swc/helpers': '>=0.5.17' + peerDependenciesMeta: + '@swc/helpers': + optional: true + + '@swc/counter@0.1.3': + resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} + + '@swc/types@0.1.25': + resolution: {integrity: sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==} + + '@swc/wasm@1.15.18': + resolution: {integrity: sha512-zeSORFArxqUwfVMTRHu8AN9k9LlfSn0CKDSzLhJDITpgLoS0xpnocxsgMjQjUcVYDgO47r9zLP49HEjH/iGsFg==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + codemirror@6.0.2: + resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==} + + crelt@1.0.6: + resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + playwright-core@1.58.2: + resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.58.2: + resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==} + engines: {node: '>=18'} + hasBin: true + + postcss@8.5.8: + resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} + engines: {node: ^10 || ^12 || >=14} + + rollup@4.59.0: + resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + style-mod@4.1.3: + resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==} + + three@0.183.2: + resolution: {integrity: sha512-di3BsL2FEQ1PA7Hcvn4fyJOlxRRgFYBpMTcyOgkwJIaDOdJMebEFPA+t98EvjuljDx4hNulAGwF6KIjtwI5jgQ==} + + uuid@10.0.0: + resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} + hasBin: true + + vite-plugin-top-level-await@1.6.0: + resolution: {integrity: sha512-bNhUreLamTIkoulCR9aDXbTbhLk6n1YE8NJUTTxl5RYskNRtzOR0ASzSjBVRtNdjIfngDXo11qOsybGLNsrdww==} + peerDependencies: + vite: '>=2.8' + + vite-plugin-wasm@3.5.0: + resolution: {integrity: sha512-X5VWgCnqiQEGb+omhlBVsvTfxikKtoOgAzQ95+BZ8gQ+VfMHIjSHr0wyvXFQCa0eKQ0fKyaL0kWcEnYqBac4lQ==} + peerDependencies: + vite: ^2 || ^3 || ^4 || ^5 || ^6 || ^7 + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + w3c-keyname@2.2.8: + resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + +snapshots: + + '@codemirror/autocomplete@6.20.1': + dependencies: + '@codemirror/language': 6.12.2 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.40.0 + '@lezer/common': 1.5.1 + + '@codemirror/commands@6.10.3': + dependencies: + '@codemirror/language': 6.12.2 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.40.0 + '@lezer/common': 1.5.1 + + '@codemirror/lang-json@6.0.2': + dependencies: + '@codemirror/language': 6.12.2 + '@lezer/json': 1.0.3 + + '@codemirror/lang-python@6.2.1': + dependencies: + '@codemirror/autocomplete': 6.20.1 + '@codemirror/language': 6.12.2 + '@codemirror/state': 6.6.0 + '@lezer/common': 1.5.1 + '@lezer/python': 1.1.18 + + '@codemirror/language@6.12.2': + dependencies: + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.40.0 + '@lezer/common': 1.5.1 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.8 + style-mod: 4.1.3 + + '@codemirror/lint@6.9.5': + dependencies: + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.40.0 + crelt: 1.0.6 + + '@codemirror/search@6.6.0': + dependencies: + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.40.0 + crelt: 1.0.6 + + '@codemirror/state@6.6.0': + dependencies: + '@marijn/find-cluster-break': 1.0.2 + + '@codemirror/theme-one-dark@6.1.3': + dependencies: + '@codemirror/language': 6.12.2 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.40.0 + '@lezer/highlight': 1.2.3 + + '@codemirror/view@6.40.0': + dependencies: + '@codemirror/state': 6.6.0 + crelt: 1.0.6 + style-mod: 4.1.3 + w3c-keyname: 2.2.8 + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@lezer/common@1.5.1': {} + + '@lezer/highlight@1.2.3': + dependencies: + '@lezer/common': 1.5.1 + + '@lezer/json@1.0.3': + dependencies: + '@lezer/common': 1.5.1 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.8 + + '@lezer/lr@1.4.8': + dependencies: + '@lezer/common': 1.5.1 + + '@lezer/python@1.1.18': + dependencies: + '@lezer/common': 1.5.1 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.8 + + '@marijn/find-cluster-break@1.0.2': {} + + '@playwright/test@1.58.2': + dependencies: + playwright: 1.58.2 + + '@rollup/plugin-virtual@3.0.2(rollup@4.59.0)': + optionalDependencies: + rollup: 4.59.0 + + '@rollup/rollup-android-arm-eabi@4.59.0': + optional: true + + '@rollup/rollup-android-arm64@4.59.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.59.0': + optional: true + + '@rollup/rollup-darwin-x64@4.59.0': + optional: true + + '@rollup/rollup-freebsd-arm64@4.59.0': + optional: true + + '@rollup/rollup-freebsd-x64@4.59.0': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.59.0': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-x64-musl@4.59.0': + optional: true + + '@rollup/rollup-openbsd-x64@4.59.0': + optional: true + + '@rollup/rollup-openharmony-arm64@4.59.0': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.59.0': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.59.0': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.59.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.59.0': + optional: true + + '@swc/core-darwin-arm64@1.15.18': + optional: true + + '@swc/core-darwin-x64@1.15.18': + optional: true + + '@swc/core-linux-arm-gnueabihf@1.15.18': + optional: true + + '@swc/core-linux-arm64-gnu@1.15.18': + optional: true + + '@swc/core-linux-arm64-musl@1.15.18': + optional: true + + '@swc/core-linux-x64-gnu@1.15.18': + optional: true + + '@swc/core-linux-x64-musl@1.15.18': + optional: true + + '@swc/core-win32-arm64-msvc@1.15.18': + optional: true + + '@swc/core-win32-ia32-msvc@1.15.18': + optional: true + + '@swc/core-win32-x64-msvc@1.15.18': + optional: true + + '@swc/core@1.15.18': + dependencies: + '@swc/counter': 0.1.3 + '@swc/types': 0.1.25 + optionalDependencies: + '@swc/core-darwin-arm64': 1.15.18 + '@swc/core-darwin-x64': 1.15.18 + '@swc/core-linux-arm-gnueabihf': 1.15.18 + '@swc/core-linux-arm64-gnu': 1.15.18 + '@swc/core-linux-arm64-musl': 1.15.18 + '@swc/core-linux-x64-gnu': 1.15.18 + '@swc/core-linux-x64-musl': 1.15.18 + '@swc/core-win32-arm64-msvc': 1.15.18 + '@swc/core-win32-ia32-msvc': 1.15.18 + '@swc/core-win32-x64-msvc': 1.15.18 + + '@swc/counter@0.1.3': {} + + '@swc/types@0.1.25': + dependencies: + '@swc/counter': 0.1.3 + + '@swc/wasm@1.15.18': {} + + '@types/estree@1.0.8': {} + + codemirror@6.0.2: + dependencies: + '@codemirror/autocomplete': 6.20.1 + '@codemirror/commands': 6.10.3 + '@codemirror/language': 6.12.2 + '@codemirror/lint': 6.9.5 + '@codemirror/search': 6.6.0 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.40.0 + + crelt@1.0.6: {} + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + + fsevents@2.3.2: + optional: true + + fsevents@2.3.3: + optional: true + + nanoid@3.3.11: {} + + picocolors@1.1.1: {} + + playwright-core@1.58.2: {} + + playwright@1.58.2: + dependencies: + playwright-core: 1.58.2 + optionalDependencies: + fsevents: 2.3.2 + + postcss@8.5.8: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + rollup@4.59.0: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.59.0 + '@rollup/rollup-android-arm64': 4.59.0 + '@rollup/rollup-darwin-arm64': 4.59.0 + '@rollup/rollup-darwin-x64': 4.59.0 + '@rollup/rollup-freebsd-arm64': 4.59.0 + '@rollup/rollup-freebsd-x64': 4.59.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.59.0 + '@rollup/rollup-linux-arm-musleabihf': 4.59.0 + '@rollup/rollup-linux-arm64-gnu': 4.59.0 + '@rollup/rollup-linux-arm64-musl': 4.59.0 + '@rollup/rollup-linux-loong64-gnu': 4.59.0 + '@rollup/rollup-linux-loong64-musl': 4.59.0 + '@rollup/rollup-linux-ppc64-gnu': 4.59.0 + '@rollup/rollup-linux-ppc64-musl': 4.59.0 + '@rollup/rollup-linux-riscv64-gnu': 4.59.0 + '@rollup/rollup-linux-riscv64-musl': 4.59.0 + '@rollup/rollup-linux-s390x-gnu': 4.59.0 + '@rollup/rollup-linux-x64-gnu': 4.59.0 + '@rollup/rollup-linux-x64-musl': 4.59.0 + '@rollup/rollup-openbsd-x64': 4.59.0 + '@rollup/rollup-openharmony-arm64': 4.59.0 + '@rollup/rollup-win32-arm64-msvc': 4.59.0 + '@rollup/rollup-win32-ia32-msvc': 4.59.0 + '@rollup/rollup-win32-x64-gnu': 4.59.0 + '@rollup/rollup-win32-x64-msvc': 4.59.0 + fsevents: 2.3.3 + + source-map-js@1.2.1: {} + + style-mod@4.1.3: {} + + three@0.183.2: {} + + uuid@10.0.0: {} + + vite-plugin-top-level-await@1.6.0(rollup@4.59.0)(vite@5.4.21): + dependencies: + '@rollup/plugin-virtual': 3.0.2(rollup@4.59.0) + '@swc/core': 1.15.18 + '@swc/wasm': 1.15.18 + uuid: 10.0.0 + vite: 5.4.21 + transitivePeerDependencies: + - '@swc/helpers' + - rollup + + vite-plugin-wasm@3.5.0(vite@5.4.21): + dependencies: + vite: 5.4.21 + + vite@5.4.21: + dependencies: + esbuild: 0.21.5 + postcss: 8.5.8 + rollup: 4.59.0 + optionalDependencies: + fsevents: 2.3.3 + + w3c-keyname@2.2.8: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..afee034 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,6 @@ +allowBuilds: + '@swc/core': true + esbuild: true +onlyBuiltDependencies: + - '@swc/core' + - esbuild diff --git a/public/samples/index.json b/public/samples/index.json new file mode 100644 index 0000000..fcb55a3 --- /dev/null +++ b/public/samples/index.json @@ -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" + } +] diff --git a/scripts/bundle-app.mjs b/scripts/bundle-app.mjs new file mode 100644 index 0000000..9ec0f09 --- /dev/null +++ b/scripts/bundle-app.mjs @@ -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], +}); diff --git a/scripts/cdn-to-bare.mjs b/scripts/cdn-to-bare.mjs new file mode 100644 index 0000000..1aa5cdb --- /dev/null +++ b/scripts/cdn-to-bare.mjs @@ -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 }; + }); + }, +}; diff --git a/src/bimr_api.py b/src/bimr_api.py new file mode 100644 index 0000000..c993b04 --- /dev/null +++ b/src/bimr_api.py @@ -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() diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..6b98b94 --- /dev/null +++ b/src/main.ts @@ -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 | null = null; +let printBuffer = ""; + +// One-time, idempotent MicroPython initialisation. +function ensureMicroPython(): Promise { + mpInitPromise ??= initMicroPython(); + return mpInitPromise; +} + +// Run-loop state — persisted in localStorage. +type RunMode = "realtime" | "manual"; +let mode: RunMode = + (localStorage.getItem("bimr.runMode") as RunMode) ?? "realtime"; +let debounceMs: number = Number( + localStorage.getItem("bimr.debounceMs") ?? DEBOUNCE_MIN_MS, +); +let debounceTimer: ReturnType | null = null; +// Suppress the next doc-change auto-run — used when loading a .ifcx file +// into the editor as a placeholder comment (no script to execute). +let suppressAutoRun = false; + +function scheduleRun() { + if (mode === "manual") return; + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + debounceTimer = null; + run(); + }, debounceMs); +} + +async function initMicroPython() { + const mpUrl = new URL(import.meta.env.BASE_URL + "mp/micropython.mjs", location.origin).href; + const { loadMicroPython } = await import(/* @vite-ignore */ mpUrl); + mp = await loadMicroPython({ + stdout: (line: string) => { + printBuffer += line + "\n"; + }, + stderr: (line: string) => { + printBuffer += line + "\n"; + }, + heapsize: 2 * 1024 * 1024, + }); + + // Register the native `bimr` module BEFORE running bimr_api.py, + // because bimr_api.py executes `import bimr as _bimr` at module level. + // WASM must already be initialised (init() is awaited before this is called). + // + // The whole-source bridge: exactly one function. The Python layer owns + // the .bimr buffer and hands it over whole; we compile it once and stash + // both artifacts for run(). + bimrModule = { + lastBimr: null as string | null, + lastIfcx: null as string | null, + compile(src: string) { + bimrModule.lastBimr = src; + bimrModule.lastIfcx = compile(src); + // Returns nothing: a returned IFCX string would be materialized as a + // Python str inside the MicroPython heap (2MB) and blow it on large + // models. run() reads lastBimr/lastIfcx from the JS side. + }, + }; + mp.registerJsModule("bimr", bimrModule); + + // bimr_api.py is inlined at build time (?raw import) — never fetched at + // runtime, so a stale HTTP cache can never serve an old or empty layer. + mp.runPython(bimrApiSrc); +} + +// IFCX viewer (render.mjs, Three.js) — poll until ready (race condition with module load order). +async function initViewer() { + for (let i = 0; i < 50; i++) { + const viewer = (window as any).__bimrViewer; + if (viewer?.addModel) { + console.log("[bimr] viewer ready after", i, "tries"); + return { addModel: viewer.addModel, clearModels: viewer.clearModels }; + } + await new Promise((r) => setTimeout(r, 100)); + } + console.warn("[bimr] viewer not available"); + return { addModel: null, clearModels: null }; +} + +// The viewer is the generated bare-import bundle (sync-viewer) — Vite +// resolves three locally, so there is no runtime CDN dependency. +// viewerReady settles exactly when the module has registered (or never +// blocks the run pipeline on failure). +const viewerReady: Promise<{ addModel: ((name: string, m: any) => void) | null; clearModels: (() => void) | null }> = + import("./viewer.render.mjs") + .then(() => initViewer()) + .catch((e) => { + console.error("[bimr] viewer:", e); + return { addModel: null, clearModels: null }; + }); + +const output = document.getElementById("output") as HTMLPreElement; + +// Run-loop widget +const btnAuto = document.getElementById( + "btn-mode-realtime", +) as HTMLButtonElement; +const btnRun = document.getElementById("btn-run") as HTMLButtonElement; + +function applyMode() { + btnAuto.classList.toggle("active", mode === "realtime"); + btnRun.style.display = mode === "manual" ? "" : "none"; + localStorage.setItem("bimr.runMode", mode); +} + +applyMode(); + +btnAuto.addEventListener("click", () => { + mode = mode === "realtime" ? "manual" : "realtime"; + applyMode(); +}); + +btnRun.addEventListener("click", () => run()); + +document.addEventListener("keydown", (e) => { + if (e.ctrlKey && (e.key === "Enter" || e.key === "e" || e.key === "E")) { + e.preventDefault(); + run(); + } +}); + +async function fetchSample(id: string): Promise { + try { + const res = await fetch(import.meta.env.BASE_URL + `samples/${id}.py`); + return res.ok ? res.text() : ""; + } catch { return ""; } +} + +function editorExtensions() { + return [ + basicSetup, + langCompartment.of(python()), + indentUnit.of(" "), + themeCompartment.of(editorThemeExt()), + editableCompartment.of(EditorView.editable.of(true)), + EditorView.updateListener.of((update) => { + if (update.docChanged) { + if (suppressAutoRun) suppressAutoRun = false; + else scheduleRun(); + } + }), + ]; +} + +// --- Layout tabs (editor / viewer — narrow screens only) --- + +const layoutTabBar = document.getElementById("layout-tab-bar") as HTMLDivElement; +const btnPaneEditor = document.getElementById("btn-pane-editor") as HTMLButtonElement; +const btnPaneViewer = document.getElementById("btn-pane-viewer") as HTMLButtonElement; +const panesEl = document.querySelector(".panes") as HTMLDivElement; + +type LayoutPane = "editor" | "viewer"; +let activeLayoutPane: LayoutPane = "editor"; + +function switchLayoutPane(pane: LayoutPane) { + if (pane === activeLayoutPane) return; + activeLayoutPane = pane; + panesEl.setAttribute("data-active-pane", pane); + btnPaneEditor.classList.toggle("active", pane === "editor"); + btnPaneViewer.classList.toggle("active", pane === "viewer"); + if (pane === "viewer") { + requestAnimationFrame(() => window.dispatchEvent(new Event("resize"))); + } +} + +btnPaneEditor.addEventListener("click", () => switchLayoutPane("editor")); +btnPaneViewer.addEventListener("click", () => switchLayoutPane("viewer")); + +// --- Tab management --- + +interface Tab { id: string; name: string; state: EditorState; } + +let tabs: Tab[] = []; +let activeTabId = ""; + +const tabBar = document.getElementById("tab-bar") as HTMLDivElement; + +function renderTabBar() { + if (tabs.length <= 1) { + tabBar.style.display = "none"; + return; + } + tabBar.style.display = "flex"; + tabBar.innerHTML = ""; + for (const tab of tabs) { + const el = document.createElement("div"); + el.className = "tab" + (tab.id === activeTabId ? " active" : ""); + + const label = document.createElement("span"); + label.className = "tab-label"; + label.textContent = tab.name; + label.addEventListener("click", () => switchTab(tab.id)); + + const close = document.createElement("button"); + close.className = "tab-close"; + close.textContent = "x"; + close.title = "Close tab"; + close.addEventListener("click", (e) => { e.stopPropagation(); closeTab(tab.id); }); + + el.appendChild(label); + el.appendChild(close); + tabBar.appendChild(el); + } +} + +function switchTab(id: string) { + if (id === activeTabId) return; + if (viewMode !== "python") applyViewMode("python"); + tabs.find(t => t.id === activeTabId)!.state = editorView.state; + activeTabId = id; + const target = tabs.find(t => t.id === id)!; + editorView.setState(target.state); + editorView.dispatch({ effects: themeCompartment.reconfigure(editorThemeExt()) }); + renderTabBar(); +} + +function closeTab(id: string) { + if (tabs.length <= 1) return; + if (viewMode !== "python") applyViewMode("python"); + const idx = tabs.findIndex(t => t.id === id); + tabs.splice(idx, 1); + if (activeTabId === id) { + const next = tabs[Math.min(idx, tabs.length - 1)]; + activeTabId = next.id; + editorView.setState(next.state); + editorView.dispatch({ effects: themeCompartment.reconfigure(editorThemeExt()) }); + } + renderTabBar(); +} + +function createTab(name: string, content: string) { + if (tabs.length > 0) tabs.find(t => t.id === activeTabId)!.state = editorView.state; + const id = "tab-" + Date.now(); + const state = EditorState.create({ doc: content, extensions: editorExtensions() }); + tabs.push({ id, name, state }); + activeTabId = id; + editorView.setState(state); + renderTabBar(); +} + +// --- CodeMirror editor --- + +const defaultDoc = await fetchSample("building"); +const initialState = EditorState.create({ + doc: defaultDoc, + extensions: editorExtensions(), +}); + +const editorView = new EditorView({ + state: initialState, + parent: document.getElementById("editor")!, +}); + +// Seed the first tab (no tab bar shown for a single tab) +tabs.push({ id: "tab-0", name: "building.py", state: initialState }); +activeTabId = "tab-0"; + +// Expose active tab name for the rename dialog pre-fill +(window as any).__bimrActiveTabName = () => tabs.find(t => t.id === activeTabId)?.name ?? ""; + +window.addEventListener("bimr-theme-change", (e: Event) => { + const dark = (e as CustomEvent<{ dark: boolean }>).detail.dark; + editorView.dispatch({ + effects: themeCompartment.reconfigure(dark ? oneDark : []), + }); +}); + +// --- View mode (python / bimr / ifcx) --- + +type ViewMode = "python" | "bimr" | "ifcx"; +let viewMode: ViewMode = "python"; +let savedPythonState: import("@codemirror/state").EditorState | null = null; +let savedBimrState: import("@codemirror/state").EditorState | null = null; + +const btnViewPython = document.getElementById("btn-view-python") as HTMLButtonElement; +const btnViewBimr = document.getElementById("btn-view-bimr") as HTMLButtonElement; +const btnViewIfcx = document.getElementById("btn-view-ifcx") as HTMLButtonElement; + +function applyViewMode(next: ViewMode) { + if (next === viewMode) return; + + // Leaving a mode — save its editor state so edits survive the switch. + if (viewMode === "python") { + savedPythonState = editorView.state; + } else if (viewMode === "bimr") { + savedBimrState = editorView.state; + } + + viewMode = next; + btnViewPython.classList.toggle("active", next === "python"); + btnViewBimr.classList.toggle("active", next === "bimr"); + btnViewIfcx.classList.toggle("active", next === "ifcx"); + + if (next === "python") { + editorView.setState(savedPythonState!); + editorView.dispatch({ + effects: [ + langCompartment.reconfigure(python()), + editableCompartment.reconfigure(EditorView.editable.of(true)), + themeCompartment.reconfigure(editorThemeExt()), + ], + }); + savedPythonState = null; + } else if (next === "bimr") { + // Editable like python: the bimr view is a source editor (lastBimr is just + // the last serialised/compiled source used to seed it), with realtime run. + if (savedBimrState) { + editorView.setState(savedBimrState); + editorView.dispatch({ + effects: [ + langCompartment.reconfigure(python()), + editableCompartment.reconfigure(EditorView.editable.of(true)), + themeCompartment.reconfigure(editorThemeExt()), + ], + }); + savedBimrState = null; + } else { + const content = lastBimr ?? ""; + editorView.setState( + EditorState.create({ doc: content, extensions: editorExtensions() }), + ); + } + } else if (next === "ifcx") { + const content = lastIfcx ?? "// no model yet — run the script first"; + editorView.setState( + EditorState.create({ + doc: content, + extensions: [ + basicSetup, + langCompartment.of(json()), + themeCompartment.of(editorThemeExt()), + editableCompartment.of(EditorView.editable.of(false)), + ], + }), + ); + } +} + +btnViewPython.addEventListener("click", () => applyViewMode("python")); +btnViewBimr.addEventListener("click", () => applyViewMode("bimr")); +btnViewIfcx.addEventListener("click", () => applyViewMode("ifcx")); + +let lastIfcx: string | null = null; +let lastBimr: string | null = null; + +// Drop stale run output so bimr/ifcx views show "no model yet" until the +// next run. +function clearModelBuffers() { + lastIfcx = null; + lastBimr = null; + updateSaveMenu(); +} + +// Enable/disable the Save submenu entries according to available buffers. +function updateSaveMenu() { + const bimrBtn = document.getElementById("menu-save-bimr") as HTMLButtonElement | null; + const ifcxBtn = document.getElementById("menu-save-ifcx") as HTMLButtonElement | null; + // The bimr view is editable, so its live doc is always saveable. + if (bimrBtn) bimrBtn.disabled = !lastBimr && viewMode !== "bimr"; + if (ifcxBtn) ifcxBtn.disabled = !lastIfcx; +} + +// Monotonic run token — a run whose sequence is superseded (a newer run +// started, or a file was loaded) must not write output or buffers. +let runSeq = 0; + +async function run() { + const seq = ++runSeq; + const src = editorView.state.doc.toString(); + let result: string; + let bimrSrc: string | null = null; + if (viewMode === "bimr") { + // BIMR DSL source — compile directly via WASM (no MicroPython output). + printBuffer = ""; + result = compile(src); + bimrSrc = src; + } else { + await ensureMicroPython(); + if (seq !== runSeq) return; + try { + printBuffer = ""; + bimrModule.lastBimr = null; + bimrModule.lastIfcx = null; + // Fresh buffer per run (a failed script must not leak lines), then + // execute the user code and compile the buffer whole. + mp.runPython("_bimr_reset()"); + mp.runPython(src); + mp.runPython("import gc; gc.collect()"); + mp.runPython("_bimr_build()"); + bimrSrc = bimrModule.lastBimr; + result = bimrModule.lastIfcx ?? ""; + } catch (e) { + if (seq !== runSeq) return; + output.textContent = + (printBuffer ? printBuffer + "\n" : "") + `Error: ${e}`; + output.className = "error"; + lastIfcx = null; + lastBimr = null; + updateSaveMenu(); + return; + } + } + if (seq !== runSeq) return; + try { + const parsed = JSON.parse(result); + if (parsed.error) { + output.textContent = + (printBuffer ? printBuffer + "\n" : "") + `Error: ${parsed.error}`; + output.className = "error"; + lastIfcx = null; + lastBimr = null; + updateSaveMenu(); + } else { + lastBimr = bimrSrc; + lastIfcx = JSON.stringify(parsed, null, 2); + updateSaveMenu(); + if (viewMode === "bimr") { + const n = Array.isArray(parsed.data) ? parsed.data.length : 0; + output.textContent = `Compiled ${n} entit${n === 1 ? "y" : "ies"}`; + } else { + output.textContent = printBuffer; + } + output.className = ""; + const { addModel, clearModels } = await viewerReady; + if (seq !== runSeq) return; + clearModels?.(); + // Viewer failures (e.g. no WebGL context) must not clobber the run + // output — the compile result or error stays in #output. + try { + await addModel?.("live", parsed); + } catch (e) { + console.error("[bimr] viewer:", e); + } + if (window.matchMedia("(max-width: 768px)").matches) { + switchLayoutPane("viewer"); + } + } + } catch { + if (seq !== runSeq) return; + output.textContent = (printBuffer ? printBuffer + "\n" : "") + result; + output.className = ""; + lastIfcx = null; + lastBimr = null; + updateSaveMenu(); + } +} + +window.addEventListener('bimr-new-file', () => { + applyViewMode("python"); + savedBimrState = null; + clearModelBuffers(); + editorView.dispatch({ + changes: { from: 0, to: editorView.state.doc.length, insert: '' }, + }); + tabs.find(t => t.id === activeTabId)!.name = 'default.py'; + renderTabBar(); +}); + +window.addEventListener('bimr-new-tab', () => { + applyViewMode("python"); + savedBimrState = null; + clearModelBuffers(); + createTab('default.py', ''); +}); + +window.addEventListener('bimr-save-buffer', (e: Event) => { + const { format } = (e as CustomEvent<{ format: "python" | "bimr" | "ifcx" }>).detail; + const tabName = tabs.find(t => t.id === activeTabId)?.name ?? 'model'; + const baseName = tabName.replace(/\.[^.]+$/, ''); + let content: string | null; + let ext: string; + let mime = "text/plain"; + if (format === "python") { + content = editorView.state.doc.toString(); + ext = "py"; + } else if (format === "bimr") { + content = viewMode === "bimr" ? editorView.state.doc.toString() : lastBimr; + ext = "bimr"; + } else { + content = lastIfcx; + ext = "ifcx"; + mime = "application/json"; + } + if (content == null) return; + const blob = new Blob([content], { type: mime }); + const a = document.createElement("a"); + a.href = URL.createObjectURL(blob); + a.download = `${baseName}.${ext}`; + a.click(); + URL.revokeObjectURL(a.href); +}); + +window.addEventListener('bimr-open-file', (e: Event) => { + runSeq++; // invalidate any in-flight run — a loaded file wins + const { text, name, placeholder } = (e as CustomEvent<{ text: string; name: string; placeholder?: boolean }>).detail; + applyViewMode("python"); + if (placeholder) { + suppressAutoRun = true; + if (debounceTimer) { clearTimeout(debounceTimer); debounceTimer = null; } + } else { + clearModelBuffers(); + } + editorView.dispatch({ + changes: { from: 0, to: editorView.state.doc.length, insert: text }, + }); + tabs.find(t => t.id === activeTabId)!.name = name; + renderTabBar(); +}); + +window.addEventListener('bimr-rename-tab', (e: Event) => { + const { name } = (e as CustomEvent<{ name: string }>).detail; + tabs.find(t => t.id === activeTabId)!.name = name; + renderTabBar(); +}); + +window.addEventListener('bimr-load-model', (e: Event) => { + runSeq++; // invalidate any in-flight run — a loaded file wins + const { text, name, bimr } = (e as CustomEvent<{ text: string; name: string; bimr?: string }>).detail; + try { + const parsed = JSON.parse(text); + lastIfcx = text; + lastBimr = bimr ?? null; + updateSaveMenu(); + viewerReady.then(({ addModel, clearModels }) => { + clearModels?.(); + addModel?.('loaded', parsed); + }); + output.textContent = `Loaded: ${name}`; + output.className = ''; + } catch { + output.textContent = `Error: could not parse ${name}`; + output.className = 'error'; + } +}); + +window.addEventListener('bimr-open-bimr', (e: Event) => { + runSeq++; // invalidate any in-flight run — a loaded file wins + const { text, name } = (e as CustomEvent<{ text: string; name: string }>).detail; + const result = compile(text); + let parsed: { error?: string } | null = null; + try { parsed = JSON.parse(result); } catch { parsed = null; } + if (!parsed || parsed.error) { + output.textContent = `Error: ${parsed?.error ?? `could not compile ${name}`}`; + output.className = "error"; + return; + } + window.dispatchEvent(new CustomEvent('bimr-load-model', { detail: { text: result, name, bimr: text } })); + // Show the source in the editable bimr view so it can be tweaked and re-run. + savedBimrState = null; + lastBimr = text; + if (viewMode !== "bimr") { + applyViewMode("bimr"); + } else { + // Refresh the editor — suppress the auto-run (model already loaded above). + suppressAutoRun = true; + if (debounceTimer) { clearTimeout(debounceTimer); debounceTimer = null; } + editorView.dispatch({ + changes: { from: 0, to: editorView.state.doc.length, insert: text }, + }); + } + tabs.find(t => t.id === activeTabId)!.name = name; + renderTabBar(); +}); + +await init(); +updateSaveMenu(); + +// Defer MicroPython to the browser's idle period so it does not block the +// initial render. The demo still runs (and renders a model) once available. +// Skip the warm-up run if the user already triggered one (mpInitPromise set). +if ("requestIdleCallback" in window) { + requestIdleCallback( + () => { + // Skip if the user already triggered a run, or loaded a model — + // opening an .ifcx must not be clobbered by the demo run. + if (!mpInitPromise && lastIfcx === null) void run(); + }, + { timeout: 3000 }, + ); +} else { + void run(); +} diff --git a/src/style.css b/src/style.css new file mode 100644 index 0000000..543f242 --- /dev/null +++ b/src/style.css @@ -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; + } +} diff --git a/src/ui.ts b/src/ui.ts new file mode 100644 index 0000000..767502e --- /dev/null +++ b/src/ui.ts @@ -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 = '
  • No samples found
  • '; + 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 = '
  • Failed to load index
  • '; + }); + } + } + + 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(); }); +})(); diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..2683031 --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1,6 @@ +/// + +declare module "*?raw" { + const content: string; + export default content; +} diff --git a/tests/compile.spec.ts b/tests/compile.spec.ts new file mode 100644 index 0000000..4547256 --- /dev/null +++ b/tests/compile.spec.ts @@ -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 { + 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 { + 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")}`), + ); +}); diff --git a/tests/examples-menu.spec.ts b/tests/examples-menu.spec.ts new file mode 100644 index 0000000..e580877 --- /dev/null +++ b/tests/examples-menu.spec.ts @@ -0,0 +1,16 @@ +import { test, expect } from "@playwright/test"; + +// The Examples menu is dynamic: one button per index.json entry, each +// loading /samples/.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", + ]); +}); diff --git a/tests/ifcx-open.spec.ts b/tests/ifcx-open.spec.ts new file mode 100644 index 0000000..8ac3ffa --- /dev/null +++ b/tests/ifcx-open.spec.ts @@ -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"); +}); diff --git a/tests/offline-viewer.spec.ts b/tests/offline-viewer.spec.ts new file mode 100644 index 0000000..0194d96 --- /dev/null +++ b/tests/offline-viewer.spec.ts @@ -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"); +}); diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..bebacd0 --- /dev/null +++ b/vite.config.ts @@ -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; + }, + }, + }, +});