Build your own house: walls, windows, doors, floors, roof, stairs, furniture
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.
This commit is contained in:
grabowski committed 2026-10-24 17:39:04 +07:00
1 parent 84309a7a08
commit 95ea4a8512
26 files changed
+2137 -64

No files matched your search

+5 -4
View File
@@ -66,9 +66,9 @@ def lock_version(name):
return m.group(1) if m else None
def demo_files():
"""The demo project as the web Studio stores projects: path -> text."""
base = ROOT / "examples" / "demo"
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()
@@ -77,7 +77,7 @@ def demo_files():
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, "examples/demo has no isokit.toml"
assert "isokit.toml" in out, f"examples/{name} has no isokit.toml"
return out
@@ -147,6 +147,7 @@ def main():
(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("*")):
+83
View File
@@ -115,6 +115,89 @@ with sync_playwright() as p:
pg.wait_for_timeout(700)
check(obj(new["id"]) is None, "Delete removes the selected object")
# build a house: a room (floor + 4 walls in one step), a window and a door snapped onto its walls,
# the wall carrying its window when dragged and deleted, furniture on the first floor, the cut-open level
to_client = """(w) => { const v = __studio.view, s = v.screenOf(w[0], w[1], w[2] || 0), svg = v.svg, pt = svg.createSVGPoint();
pt.x = s[0]; pt.y = s[1]; const q = pt.matrixTransform(svg.getScreenCTM()); return [q.x, q.y]; }"""
at = lambda x, y: pg.evaluate(to_client, [x, y, 0])
pg.evaluate("__studio.select(null)")
pg.evaluate("__studio.view.focus([25.5, 4, 0], 3)")
pg.wait_for_timeout(400)
n1 = len(doc()["objects"])
pg.locator('.st-tile[data-type="@room"]').click()
a, b2 = at(23, 2), at(28, 6)
pg.mouse.move(*a)
pg.mouse.down()
for i in range(1, 13):
pg.mouse.move(a[0] + (b2[0] - a[0]) * i / 12, a[1] + (b2[1] - a[1]) * i / 12)
pg.wait_for_timeout(15)
check(pg.locator(".st-ghost").count() == 5 and "5.00 × 4.00" in pg.locator("#hint").inner_text(), "the Room tool previews a 5 x 4 m room while dragging")
pg.screenshot(path=str(OUT / "studio-room-drag.jpg"), type="jpeg", quality=80)
pg.mouse.up()
pg.wait_for_timeout(800)
new = doc()["objects"][n1:]
kinds = sorted(o["type"] for o in new)
check(kinds == ["floor", "house_wall", "house_wall", "house_wall", "house_wall"], f"a room is a floor and four walls: {kinds}")
fl = next((o for o in new if o["type"] == "floor"), {})
check(fl.get("props") == {"w": 5, "d": 4} or fl.get("props") == {"w": 5.0, "d": 4.0}, f"the floor is 5 x 4 m: {fl.get('props')}")
south = next((o for o in new if o["type"] == "house_wall" and abs(o["y"] - 2) < 1e-6), None)
pg.locator('.st-tile[data-type="window"]').click()
pg.mouse.move(*at(25.1, 1.6))
pg.mouse.move(*at(25.2, 1.7))
check(pg.locator(".st-ghost").count() == 1, "hovering a wall with a window shows where it goes")
pg.mouse.click(*at(25.2, 1.7))
pg.wait_for_timeout(800)
win = doc()["objects"][-1]
check(win["type"] == "window" and abs(win["y"] - 2) < 1e-6 and abs(win["x"] - 25) < 1e-6, f"the window snaps onto the wall: ({win['x']}, {win['y']})")
pg.locator('.st-tile[data-type="door"]').click()
pg.mouse.click(*at(27.6, 4.2))
pg.wait_for_timeout(800)
door = doc()["objects"][-1]
check(door["type"] == "door" and abs(door["x"] - 28) < 1e-6 and door.get("rot") == 90, f"the door snaps onto the east wall and opens into the room: {door}")
# drag the south wall 1 m south: its window comes along, as one undo step
pg.evaluate("__studio.select(null)")
wb = pg.locator(f'.iso-svg g[data-obj="{south["id"]}"] path.iso-t').first.bounding_box()
cx, cy = wb["x"] + wb["width"] / 2, wb["y"] + wb["height"] / 2
d0, d1 = at(25, 2), at(25, 1)
pg.mouse.move(cx, cy)
pg.mouse.down()
for i in range(1, 11):
pg.mouse.move(cx + (d1[0] - d0[0]) * i / 10, cy + (d1[1] - d0[1]) * i / 10)
pg.wait_for_timeout(15)
pg.mouse.up()
pg.wait_for_timeout(800)
check(abs(obj(south["id"])["y"] - 1) < 1e-6 and abs(obj(win["id"])["y"] - 1) < 1e-6, "dragging the wall moves its window along")
pg.locator("#undo").click()
pg.wait_for_timeout(700)
check(abs(obj(south["id"])["y"] - 2) < 1e-6 and abs(obj(win["id"])["y"] - 2) < 1e-6, "one undo puts wall and window back")
pg.locator(f'#objects li[data-id="{south["id"]}"]').click()
pg.locator(".iso-svg").focus()
pg.keyboard.press("Delete")
pg.wait_for_timeout(800)
check(obj(south["id"]) is None and obj(win["id"]) is None, "deleting the wall deletes its window")
pg.keyboard.press("Control+z")
pg.wait_for_timeout(800)
check(obj(win["id"]) is not None, "undo brings both back")
# the cut-open level: walls in front of the room low, the others full height
rv = get("/api/render?level=gf&rot=1")
tops = sorted(round(rv["boxes"][o["id"]][5], 2) for o in new if o["type"] == "house_wall")
check(tops == [1.0, 1.0, 2.8, 2.8], f"GF cuts the two walls in front of the room low: {tops}")
# furniture placed while 1F is shown goes onto the first floor
pg.evaluate("__studio.select(null)")
pg.locator('.iso-seg button[data-level="1f"]').click()
pg.wait_for_timeout(600)
pg.locator('.st-tile[data-type="chair"]').click()
pg.mouse.click(*at(25, 4))
pg.wait_for_timeout(800)
chair = doc()["objects"][-1]
check(chair["type"] == "chair" and chair.get("z") == 3, f"a chair placed on 1F stands at z = 3: {chair.get('z')}")
pg.locator('.iso-seg button[data-level="gf"]').click()
pg.wait_for_timeout(600)
pg.screenshot(path=str(OUT / "studio-house-gf.jpg"), type="jpeg", quality=80)
pg.locator('.iso-seg button[data-level="site"]').click()
pg.evaluate("__studio.select(null)")
pg.wait_for_timeout(400)
# an agent edits over MCP: the open Studio follows
init = mcp({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "test", "version": "0"}}})
check(init["result"]["serverInfo"]["name"] == "isokit", "MCP initialize")
+12
View File
@@ -289,6 +289,18 @@ try:
check(pg.locator("#wb-plist li").count() == n_proj - 1, "a project can be deleted")
pg.keyboard.press("Escape")
# ---------------------------------------------------------------- the house demo: a house built from parts
pg.locator("#wb-projects").click()
pg.locator("#wb-nhouse").click()
pg.wait_for_load_state()
ready(pg)
pg.wait_for_timeout(500)
nh = len(doc(pg)["objects"])
check(title(pg) == "isokit house" and nh > 50, f"House demo opens a furnished house ({nh} objects)")
check(pg.evaluate("() => __studio.S.warnings.length") == 0 and pg.locator('.st-tile[data-type="@room"]').count() == 1,
"it draws without warnings, the palette has the Room tool")
shot(pg, "web-house.jpg")
# ---------------------------------------------------------------- folder project (OPFS stands in for a picked folder)
demo = {f.relative_to(ROOT / "examples" / "demo").as_posix(): f.read_text(encoding="utf-8")
for f in (ROOT / "examples" / "demo").rglob("*") if f.is_file() and "dist" not in f.parts}