Files
isokit/tools/build_web.py
T
grabowski 95ea4a8512
Deploy web Studio / deploy (push) Successful in 3m27s
Build your own house: walls, windows, doors, floors, roof, stairs, furniture
House parts (stdlib isokit/house): house_wall drawn along a line, with ends
that adapt at corners, T-junctions and straight runs so no two walls
overlap; window and door that sit on a wall and cut its opening (door
styles panel, glass, double, sliding, opening; swing and hinge by
rotation); floor (boards, tiles, carpet, stone); roof (hip, gable, flat,
optional solar); stairs.

Furniture (stdlib isokit/furniture): chair, sofa / armchair, table with
chairs, desk with monitor and chair, TV, bed, cupboard, bookshelf, kitchen
run, bathtub, shower, toilet, washbasin, plant, rug, floor lamp.

Cut-open levels: on gf / 1f the walls in front of a room are cut low
(rooms are found by their floors), the roof becomes an outline on the
floor, and storeys above are left out. A part's storey comes from z
(storey_h, default 3 m).

Scripting: objects_of, objects_at, objects_on, mount_of, mounted, frame,
to_world, to_local, local_of. Prefab meta: shape (line / rect), mount,
storey.

Studio: drag to draw lines and rectangles (walls, roads, fences, hedges,
floors, areas, buildings, roofs). The Room tool draws a floor plus walls,
without doubling walls that already stand on an edge. Windows and doors
snap onto walls with a hover preview, and they move, turn, copy and
delete with their wall in one undo step. New parts land on the storey
the view shows. Palette categories collapse, and the palette scrolls
apart from the object list.

The MCP instructions explain house building, and list_prefabs shows
sits_on. examples/house is a furnished bungalow; the web Studio gets a
House demo button. Tests: wall corners and junctions, zero depth-sort
cycles in every view, cut levels, and the house demo in every view;
check_studio covers the room / window / door / storey flow, check_web
the House demo.
2026-10-24 17:39:04 +07:00

190 lines
8.9 KiB
Python

"""Build the web Studio: the Studio GUI with the isokit engine compiled to WASM.
python tools/build_web.py [--out dist/web] [--skip-cargo]
Needs rustup with the wasm32-unknown-unknown target (rustup target add
wasm32-unknown-unknown) and the wasm-bindgen CLI at the version in Cargo.lock.
rustup's cargo is used even when another Rust install comes first on PATH.
wasm-opt (binaryen) is used when it is on PATH. The output folder is static
files: serve it from any web server (it needs http(s), not file://).
"""
import argparse
import hashlib
import json
import os
import re
import shutil
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
SKIP_DIRS = {"dist", "target", "node_modules"}
TARGET = "wasm32-unknown-unknown"
# rustup's bin folder first: a standalone Rust install earlier on PATH (one
# without the wasm target, e.g. "Rust stable GNU") must not win
CARGO_BIN = Path(os.environ.get("CARGO_HOME") or Path.home() / ".cargo") / "bin"
ENV = dict(os.environ)
if CARGO_BIN.is_dir():
ENV["PATH"] = str(CARGO_BIN) + os.pathsep + ENV.get("PATH", "")
def tool(name):
return shutil.which(name, path=ENV["PATH"])
def run(cmd, **kw):
print("+", " ".join(str(c) for c in cmd), flush=True)
subprocess.run([str(c) for c in cmd], check=True, cwd=ROOT, env=ENV, **kw)
def check_toolchain():
"""rustup with the wasm target, and wasm-bindgen at the locked version."""
rustup = tool("rustup")
if not rustup:
sys.exit("rustup not found (https://rustup.rs): the WASM build needs rustup's toolchain")
installed = subprocess.run([rustup, "target", "list", "--installed"], capture_output=True, text=True,
cwd=ROOT, env=ENV).stdout.split()
if TARGET not in installed:
sys.exit(f"the {TARGET} target is not installed: rustup target add {TARGET}")
cargo = tool("cargo")
if not cargo or Path(cargo).parent.resolve() != CARGO_BIN.resolve():
sys.exit(f"cargo resolves to {cargo}, not rustup's in {CARGO_BIN}")
want = lock_version("wasm-bindgen")
exe = tool("wasm-bindgen")
if not exe:
sys.exit(f"wasm-bindgen CLI not found: cargo install wasm-bindgen-cli --version {want}")
have = subprocess.run([exe, "--version"], capture_output=True, text=True, env=ENV).stdout.split()[-1]
if have != want:
sys.exit(f"wasm-bindgen CLI is {have}, Cargo.lock has {want}: cargo install wasm-bindgen-cli --version {want}")
def lock_version(name):
lock = (ROOT / "Cargo.lock").read_text(encoding="utf-8")
m = re.search(r'name = "%s"\nversion = "([^"]+)"' % re.escape(name), lock)
return m.group(1) if m else None
def demo_files(name="demo"):
"""An example project as the web Studio stores projects: path -> text."""
base = ROOT / "examples" / name
out = {}
for p in sorted(base.rglob("*")):
rel = p.relative_to(base).as_posix()
parts = rel.split("/")
if p.is_dir() or any(s.startswith(".") or s in SKIP_DIRS for s in parts[:-1]):
continue
if p.suffix in (".rhai", ".toml", ".json") or p.name == "README.md":
out[rel] = p.read_text(encoding="utf-8")
assert "isokit.toml" in out, f"examples/{name} has no isokit.toml"
return out
def build_info():
"""'<commit> <commit date> [dirty]' for the version text in the Studio's
status bar; GITHUB_SHA (CI) wins over git. Empty when git is missing."""
def git(*args):
r = subprocess.run(["git", *args], capture_output=True, text=True, cwd=ROOT)
return r.stdout.strip() if r.returncode == 0 else ""
try:
sha = os.environ.get("GITHUB_SHA") or git("rev-parse", "HEAD")
if not sha:
return ""
parts = [sha, git("log", "-1", "--format=%cs", sha) or "-"]
if git("status", "--porcelain", "--untracked-files=no"):
parts.append("dirty")
return " ".join(parts)
except OSError:
return ""
def html_attr(s):
return s.replace("&", "&amp;").replace('"', "&quot;").replace("<", "&lt;")
def replace_once(s, old, new):
n = s.count(old)
assert n == 1, f"studio/index.html: expected {old!r} once, found {n}"
return s.replace(old, new)
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--out", default=str(ROOT / "dist" / "web"))
ap.add_argument("--skip-cargo", action="store_true", help="reuse the last WASM build (front-end changes only)")
ap.add_argument("--agent-url", help="default endpoint of the agent window: a relay on the same site (/llm/v1)")
ap.add_argument("--agent-upstream", default="https://inference.p0.contact/v1",
help="the endpoint that --agent-url relays to; entered in the window, it goes through the relay")
a = ap.parse_args()
out = Path(a.out).resolve()
web = out / "assets" / "web"
wasm = ROOT / "target" / "wasm32-unknown-unknown" / "web" / "isokit_wasm.wasm"
if not a.skip_cargo:
check_toolchain()
run([tool("cargo"), "build", "-p", "isokit-wasm", "--target", TARGET, "--profile", "web"])
elif not tool("wasm-bindgen"):
sys.exit("wasm-bindgen CLI not found")
if out.exists():
shutil.rmtree(out)
web.mkdir(parents=True)
if not wasm.exists():
sys.exit(f"{wasm} missing: run without --skip-cargo")
run([tool("wasm-bindgen"), "--target", "web", "--no-typescript", "--remove-name-section",
"--remove-producers-section", "--out-dir", web.as_posix(), wasm.as_posix()])
bg = web / "isokit_wasm_bg.wasm"
if tool("wasm-opt"):
run([tool("wasm-opt"), "-Oz", "--all-features", bg.as_posix(), "-o", bg.as_posix()])
else:
print(" (wasm-opt not found: the .wasm stays unoptimised by binaryen, about 15% larger)")
for src, dst in [("viewer/isokit-view.js", "viewer"), ("viewer/isokit-view.css", "viewer"),
("studio/studio.js", "studio"), ("studio/agent.js", "studio"), ("studio/studio.css", "studio"),
("studio/favicon.svg", "studio"),
("web/web.js", "web"), ("web/web.css", "web")]:
(out / "assets" / dst).mkdir(parents=True, exist_ok=True)
shutil.copy2(ROOT / src, out / "assets" / dst / Path(src).name)
(web / "demo.json").write_text(json.dumps(demo_files(), ensure_ascii=False), encoding="utf-8")
(web / "house.json").write_text(json.dumps(demo_files("house"), ensure_ascii=False), encoding="utf-8")
h = hashlib.sha256()
for p in sorted((out / "assets").rglob("*")):
if p.is_file():
h.update(p.read_bytes())
v = "v=" + h.hexdigest()[:10]
html = (ROOT / "studio" / "index.html").read_text(encoding="utf-8")
build = build_info()
html = replace_once(html, '<title>isokit Studio</title>',
'<title>isokit Studio</title>\n<meta name="description" content="isokit Studio: edit isometric site plans in the browser.">'
+ (f'\n<meta name="isokit-build" content="{build}">' if build else "")
+ (f'\n<meta name="isokit-agent" content="{html_attr(a.agent_url)}" data-upstream="{html_attr(a.agent_upstream)}">'
if a.agent_url else ""))
html = replace_once(html, 'href="/assets/studio/favicon.svg"', f'href="assets/studio/favicon.svg?{v}"')
html = replace_once(html, 'href="/assets/viewer/isokit-view.css"', f'href="assets/viewer/isokit-view.css?{v}"')
html = replace_once(html, '<link rel="stylesheet" href="/assets/theme.css">', '<style id="iso-theme"></style>')
html = replace_once(html, '<link rel="stylesheet" href="/assets/studio/studio.css">',
f'<link rel="stylesheet" href="assets/studio/studio.css?{v}">\n<link rel="stylesheet" href="assets/web/web.css?{v}">')
html = replace_once(html, 'href="/view"', 'href="#"')
html = replace_once(html, '<script src="/assets/viewer/isokit-view.js"></script>', f'<script src="assets/viewer/isokit-view.js?{v}"></script>')
# agent.js waits for studio.js, which web.js starts once the engine is loaded
html = replace_once(html, '<script src="/assets/studio/agent.js"></script>', f'<script src="assets/studio/agent.js?{v}"></script>')
html = replace_once(html, '<script src="/assets/studio/studio.js"></script>',
f'<script type="module" src="assets/web/web.js?{v}"></script>\n'
'<noscript>The isokit Studio needs JavaScript and WebAssembly.</noscript>')
assert "/assets/" not in html, "an absolute /assets/ URL is left in index.html"
(out / "index.html").write_text(html, encoding="utf-8")
total = 0
for p in sorted(out.rglob("*")):
if p.is_file():
total += p.stat().st_size
print(f" {p.relative_to(out).as_posix():40} {p.stat().st_size / 1024:8.1f} kB")
print(f"{out} ({total / 1024 / 1024:.1f} MB, {v}, build {build or 'unknown'})")
if __name__ == "__main__":
main()