Object tree: groups and the drawing order
Deploy web Studio / deploy (push) Successful in 1m5s

Groups: objects of type "group" draw nothing; members carry the group's id
in a new `group` field (groups nest). A hidden group hides everything in it
(Doc::hidden_in_groups, skipped by the driver), a locked one locks it in
the Studio. Removing a group moves its members up into its parent group.
isokit check warns about members of missing groups. New `order` op: the
whole object order at once (each id once), one undo step.

Studio object list as a tree in the document order, front first: each
group sits just after its members, so it draws as one block, and ground
pieces higher in the list lie on top. Groups fold open and shut; every row
has an eye and a lock (shown on hover or when set); a click on a group
selects all of it, so a drag on the map moves it all. Group / Ungroup
(Ctrl+G, Ctrl+Shift+G), Order up/down (Ctrl+] Ctrl+[, with Shift to the
front / back), drag rows above, below or into a group. Group panel: name,
zoom, ungroup, to front / back, lock, hide, delete with contents.

MCP: group field in the object schema, order op, guidance.

Tests: Rust 81 (ops: groups, order, removal; driver: nested hidden
groups), Studio 131, web 100.
This commit is contained in:
grabowski committed 2026-10-25 18:06:04 +07:00
1 parent 5ec164a633
commit 540b37020c
15 files changed
+573 -30

No files matched your search

+55
View File
@@ -606,6 +606,61 @@ with sync_playwright() as p:
pg.wait_for_timeout(700)
check(len(doc()["objects"]) == n5, "two undos take the routes back")
# groups and the drawing order in the object list: two trees grouped (Ctrl+G), the group hidden
# by its eye, the yard (a ground area) to the front, a lamp dragged into the group, ungrouped
order0 = [o["id"] for o in doc()["objects"]]
row = lambda id: pg.locator(f'#objects li[data-id="{id}"]')
row("tree-1").scroll_into_view_if_needed()
row("tree-1").click()
row("tree-2").click(modifiers=["Control"])
pg.keyboard.press("Control+g")
pg.wait_for_timeout(900)
d = doc()
grp = [o for o in d["objects"] if o["type"] == "group"]
gid = grp[0]["id"] if grp else "?"
ids1 = [o["id"] for o in d["objects"]]
mem = sorted(o["id"] for o in d["objects"] if o.get("group") == gid)
depth = pg.evaluate("() => getComputedStyle(document.querySelector('#objects li[data-id=\"tree-1\"]')).getPropertyValue('--d').trim()")
check(len(grp) == 1 and mem == ["tree-1", "tree-2"] and ids1.index(gid) == ids1.index("tree-2") + 1 and depth == "1"
and pg.locator("#right h3").inner_text().strip().endswith("Group 1"),
f"Ctrl+G groups two trees: {gid} holds {mem}, indented ({depth}), just in front of them")
drawn = "() => ['tree-1', 'tree-2'].map(id => document.querySelectorAll(`.iso-svg [data-obj='${id}']`).length)"
row(gid).hover()
row(gid).locator("[data-eye]").click()
pg.wait_for_timeout(900)
gone = pg.evaluate(drawn)
row(gid).locator("[data-eye]").click()
pg.wait_for_timeout(900)
back = pg.evaluate(drawn)
check(gone == [0, 0] and back[0] > 0 and back[1] > 0, f"the group's eye hides what is in it: {gone} -> {back}")
row("yard").scroll_into_view_if_needed()
row("yard").click()
pg.keyboard.press("Control+Shift+BracketRight")
pg.wait_for_timeout(900)
check([o["id"] for o in doc()["objects"]][-1] == "yard", "Ctrl+Shift+] puts the yard (ground) in front: last in the drawing order")
row(gid).scroll_into_view_if_needed()
src, dst = row("lamp-1").bounding_box(), row(gid).bounding_box()
pg.mouse.move(src["x"] + 60, src["y"] + src["height"] / 2)
pg.mouse.down()
pg.mouse.move(dst["x"] + 60, dst["y"] + dst["height"] / 2, steps=8)
pg.mouse.up()
pg.wait_for_timeout(900)
check(obj("lamp-1").get("group") == gid, "a row dragged onto a group row goes into the group")
row(gid).click()
pg.keyboard.press("Control+Shift+g")
pg.wait_for_timeout(900)
d = doc()
check(not any(o["type"] == "group" for o in d["objects"]) and not any(o.get("group") for o in d["objects"]) and len(d["objects"]) == n5,
"Ctrl+Shift+G ungroups: the group goes, what was in it stays")
pg.screenshot(path=str(OUT / "studio-groups.jpg"), type="jpeg", quality=80)
for _ in range(6):
pg.locator("#undo").click()
pg.wait_for_timeout(700)
check([o["id"] for o in doc()["objects"]] == order0 and not any(o.get("group") for o in doc()["objects"]),
"six undos restore the order and take the group back out")
pg.evaluate("__studio.select(null)")
pg.wait_for_timeout(300)
# 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")
+16
View File
@@ -387,6 +387,22 @@ try:
check(len(during) >= 2 and after and after[0] < 150 and tf == "",
f"a long pinch is drawn anew {len(during)} times on the way and sharp {round(after[0]) if after else None} ms after it ends")
pg.evaluate("__studio.view.fit()")
# groups in the web Studio (the same ops in WASM): two bushes grouped, the group hidden, undone
pg.locator('#objects li[data-id="bush-1"]').scroll_into_view_if_needed()
pg.locator('#objects li[data-id="bush-1"]').click()
pg.locator('#objects li[data-id="bush-2"]').click(modifiers=["Control"])
pg.keyboard.press("Control+g")
pg.wait_for_timeout(800)
g = [o for o in doc(pg)["objects"] if o["type"] == "group"]
members = sorted(o["id"] for o in doc(pg)["objects"] if g and o.get("group") == g[0]["id"])
pg.evaluate("id => __studio.op({op: 'update', id: id, patch: {hidden: true}})", g[0]["id"] if g else "")
pg.wait_for_timeout(800)
hid = pg.evaluate("() => document.querySelectorAll(`.iso-svg [data-obj='bush-1'], .iso-svg [data-obj='bush-2']`).length")
for _ in range(2):
pg.locator("#undo").click()
pg.wait_for_timeout(600)
check(len(g) == 1 and members == ["bush-1", "bush-2"] and hid == 0 and not any(o["type"] == "group" for o in doc(pg)["objects"]),
f"web Studio groups: {members} in {g[0]['id'] if g else None}, hidden with it, undone")
# ---------------------------------------------------------------- a system asking for less motion
# (Windows: Animation effects off): the view starts still, but the button stays and turns it on