commit 3f2324bc01465fa30ef778a0410d1b27c8c755fa Author: rvba Date: Tue Sep 1 10:37:51 2026 +0200 release: 0.0.1 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; + }, + }, + }, +});