Multi-select, select joined walls and fences, pond with swimming fish
Deploy web Studio / deploy (push) Successful in 1m27s

Selection: S.sel stays the object picked last; S.selIds holds everything
selected.
- In the object list, Ctrl+click adds or removes an object and
  Shift+click selects a range.
- On the map, Ctrl/Shift+click toggles an object and Shift+drag draws a
  box that selects what lies wholly inside it (locked and hidden objects
  are left out).
- Ctrl+A selects everything that is not locked; Esc selects nothing.
- Clicking a wall or fence a second time selects every piece of the same
  type joined to it; another click goes back to that one piece.
- Dragging one selected object moves them all, together with the windows
  and doors on them, as one undo step. Arrow keys, R (turns the group
  about its middle), Ctrl+D, Delete, Lock and Hide all work on the whole
  selection.
- When all selected objects share a type, the property form edits them
  all; fields where they differ show "mixed".
- The agent's system prompt lists the selected objects.

The viewer outlines several objects with selectObj([ids]) and passes the
click event to onPick. A batch op now answers `ids`, one per operation,
so the copies from a group duplicate get selected.

Pond (Nature): natural, round or square, with a stone or grass edge,
fish, lily pads, spreading ripples and reed clumps. The fish come from a
new Swim primitive (script: s.swim). One matrix maps the object's frame
to the screen. Each fish turns round its ellipse with a CSS rotation,
so off-screen pausing and reduced motion apply as for the other
animations. The house demo gets a koi pond in the back garden.

Tests: ponds in every shape and rotation have the right number of fish,
no NaN and no depth-sort cycles, and stay inside their rectangle.
check_studio covers list and map multi-select, a group drag undone in
one step, joined-fence selection, a mixed-value form applied to all, box
select, delete and undo, Ctrl+A, Esc, and a new pond's fish.
This commit is contained in:
grabowski committed 2026-10-24 18:36:39 +07:00
1 parent e4017ce2a8
commit 19f4c83ae8
21 files changed
+759 -59

No files matched your search

+92
View File
@@ -253,6 +253,98 @@ with sync_playwright() as p:
pg.wait_for_timeout(500)
pg.screenshot(path=str(OUT / "studio-fences.jpg"), type="jpeg", quality=80)
# several objects at once: Ctrl+click in the list and on the map, a group drag as one step, Shift+drag
# draws a box; a second click on a fence selects every fence joined to it, whose design changes for all
sel = lambda: sorted(pg.evaluate("__studio.S.selIds"))
at3 = lambda x, y, z: pg.evaluate(to_client, [x, y, z])
pg.evaluate("__studio.select(null)")
pg.locator('#objects li[data-id="truck"]').click()
pg.locator('#objects li[data-id="genset"]').click(modifiers=["Control"])
pg.wait_for_timeout(300)
check(sel() == ["genset", "truck"] and "2 objects selected" in pg.locator("#right h3").inner_text(),
f"Ctrl+click in the list adds a second object: {sel()}")
check(pg.locator("#objects li[aria-selected=true]").count() == 2 and pg.locator(".iso-svg .iso-osel").count() >= 2,
"both are marked in the list and outlined on the map")
pg.evaluate("__studio.view.focus('truck', 2.5)")
pg.wait_for_timeout(400)
t0, g0 = obj("truck"), obj("genset")
tb = pg.locator('.iso-svg g[data-obj="truck"] path.iso-t').first.bounding_box()
mx, my = tb["x"] + tb["width"] / 2, tb["y"] + tb["height"] / 2
a0, a1 = at(t0["x"], t0["y"]), at(t0["x"] + 2, t0["y"])
pg.mouse.move(mx, my)
pg.mouse.down()
for i in range(1, 11):
pg.mouse.move(mx + (a1[0] - a0[0]) * i / 10, my + (a1[1] - a0[1]) * i / 10)
pg.wait_for_timeout(15)
pg.mouse.up()
pg.wait_for_timeout(800)
check(obj("truck")["x"] == t0["x"] + 2 and obj("genset")["x"] == g0["x"] + 2 and sel() == ["genset", "truck"],
f"dragging one moves both and keeps them selected: truck {obj('truck')['x']}, generator {obj('genset')['x']}")
pg.locator("#undo").click()
pg.wait_for_timeout(700)
check(obj("truck")["x"] == t0["x"] and obj("genset")["x"] == g0["x"], "one undo puts both back")
# the fence run: a click selects one piece, a second click every fence joined to it
pg.evaluate("__studio.select(null)")
pg.evaluate("__studio.view.focus([29, -9.5, 0], 3)")
pg.wait_for_timeout(400)
pg.mouse.click(*at3(28, -12, 0.9))
pg.wait_for_timeout(400)
check(sel() == [south], f"a click selects one fence: {sel()}")
pg.mouse.click(*at3(28, -12, 0.9))
pg.wait_for_timeout(400)
joined = set(o["id"] for o in run) | {sp["id"], "fence-east"}
check(set(sel()) == joined, f"a second click selects every fence joined to it ({len(sel())} pieces)")
pg.screenshot(path=str(OUT / "studio-multi.jpg"), type="jpeg", quality=80)
check(pg.locator('#right select[data-prop="style"]').input_value() == "" and pg.locator('#right input[type=number][data-prop="w"]').input_value() == "",
"where the pieces differ (design, length) the form says mixed")
pg.locator('#right select[data-prop="style"]').select_option("wood")
pg.wait_for_timeout(800)
check(all(obj(i)["props"].get("style") == "wood" for i in joined), "a design picked for the selection goes to all of them")
pg.mouse.click(*at3(28, -12, 0.9))
pg.wait_for_timeout(400)
check(sel() == [south], "a click on one of them selects just that one again")
# Shift+drag: a box around the run selects what lies wholly inside it
pg.evaluate("__studio.select(null)")
corners = [at(23, -13), at(33, -13), at(33, -6), at(23, -6)]
x0, x1 = min(c[0] for c in corners), max(c[0] for c in corners)
y0, y1 = min(c[1] for c in corners) - 40, max(c[1] for c in corners)
pg.keyboard.down("Shift")
pg.mouse.move(x0, y0)
pg.mouse.down()
for i in range(1, 11):
pg.mouse.move(x0 + (x1 - x0) * i / 10, y0 + (y1 - y0) * i / 10)
pg.wait_for_timeout(15)
check(pg.locator("#stband").count() == 1, "Shift+drag draws a selection box")
pg.mouse.up()
pg.keyboard.up("Shift")
pg.wait_for_timeout(400)
boxed = set(sel())
check(set(o["id"] for o in run) <= boxed and "site" not in boxed and pg.locator("#stband").count() == 0,
f"the box selects the run's fences, not the locked ground ({len(boxed)} objects)")
# Delete removes them all as one step
n4 = len(doc()["objects"])
pg.locator(".iso-svg").focus()
pg.keyboard.press("Delete")
pg.wait_for_timeout(800)
check(len(doc()["objects"]) == n4 - len(boxed) and sel() == [], f"Delete removes all {len(boxed)}")
pg.keyboard.press("Control+z")
pg.wait_for_timeout(800)
check(len(doc()["objects"]) == n4, "one undo brings them all back")
pg.keyboard.press("Control+a")
pg.wait_for_timeout(300)
check(len(sel()) == len([o for o in doc()["objects"] if not o.get("locked") and not o.get("hidden")]), "Ctrl+A selects everything not locked")
pg.keyboard.press("Escape")
pg.wait_for_timeout(300)
check(sel() == [], "Esc clears the selection")
# a pond: fish swim round in it (CSS animation, one rotating group per fish)
pg.evaluate("__studio.op({op: 'add', type: 'pond', x: -29, y: 13})")
pg.wait_for_timeout(900)
check(pg.locator('.iso-svg g[data-obj] .iso-swim').count() == 5 and pg.evaluate("getComputedStyle(document.querySelector('.iso-svg .iso-swim')).animationName") == "iso-spin",
"a new pond has five fish swimming round")
pg.locator("#undo").click()
pg.wait_for_timeout(600)
pg.evaluate("__studio.select(null)")
# the middle mouse button only moves the camera: it never drags, selects or places anything
pg.locator('#objects li[data-id="truck"]').click()
pg.evaluate("__studio.view.focus('truck', 3)")