Deploy web Studio / deploy (push) Successful in 3m27s
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.
190 lines
8.9 KiB
Python
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("&", "&").replace('"', """).replace("<", "<")
|
|
|
|
|
|
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()
|