diff --git a/README.md b/README.md index 08c8aa6..488593e 100644 --- a/README.md +++ b/README.md @@ -48,9 +48,11 @@ Try the demos: `isokit studio examples/demo` (a works site), | Do | How | |---|---| | Add | pick an object on the left, click on the map (Shift-click places several) | -| Draw | walls, fences, hedges, roads: drag along the map, or click corner after corner for a run around a plot (double-click / Enter ends it, the first corner closes it); ends snap onto the walls and fences they meet; a road run gets a curve in each corner. Pieces keep to x or y, or go diagonal at 45° once the pointer is more than 22.5° off; hold Ctrl for any angle Floors, areas, buildings, roofs, rugs: drag a rectangle | +| Draw | walls, fences, hedges, roads: drag along the map, or click corner after corner for a run around a plot (double-click / Enter ends it, the first corner closes it); ends snap onto the walls and fences they meet; a road run gets a curve in each corner. Pieces keep to x or y, or go diagonal at 45° once the pointer is more than 22.5° off; hold Ctrl for any angle. Floors, areas, buildings, roofs, rugs: drag a rectangle. Routes: click point after point | | Select | click it on the map or in the object list; click a wall or fence again to select every piece joined to it | | Several | Ctrl+click (map or list) adds or takes out one, Shift+click in the list a range, Shift+drag on the map a box, Ctrl+A all; then drag one to move them all, arrows, R, Ctrl+D, Delete and the property form work on all of them | +| Group | select several, then ▤ Group or Ctrl+G; Ctrl+Shift+G ungroups. In the object list a group folds open and shut, a click on it selects all of it (drag one on the map to move them all), its eye and lock hide or lock all of it; drag rows into or out of a group | +| Order | the object list is the drawing order of ground pieces (areas, beds, ponds, roads): higher lies on top. Drag rows, or ▲ ▼ / Ctrl+] Ctrl+[ (with Shift: to the front / back) | | Move | drag it, or arrow keys by the snap step (0.1 / 0.5 / 1 / 5 m) | | Turn | R / Shift+R or ⟲ ⟳ (quarter turns), or type any angle in Rotation | | Edit | the right panel: size, storeys, colours, solar, tooltip text, label, levels | diff --git a/crates/isokit-project/src/check.rs b/crates/isokit-project/src/check.rs index 1f9940b..affa23f 100644 --- a/crates/isokit-project/src/check.rs +++ b/crates/isokit-project/src/check.rs @@ -58,6 +58,25 @@ pub fn check(p: &Project) -> Vec { Some(o.id.clone()), )); } + if !o.group.is_empty() + && !p + .doc + .objects + .iter() + .any(|g| g.kind == isokit_script::doc::GROUP && g.id == o.group) + { + out.push(f( + "warning", + format!( + "object {} is in group \"{}\", which does not exist", + o.id, o.group + ), + Some(o.id.clone()), + )); + } + if o.kind == isokit_script::doc::GROUP { + continue; + } match rt.prefab(&o.kind) { None => out.push(f( "error", diff --git a/crates/isokit-project/src/mcp.rs b/crates/isokit-project/src/mcp.rs index 1cde4ea..41173ca 100644 --- a/crates/isokit-project/src/mcp.rs +++ b/crates/isokit-project/src/mcp.rs @@ -32,6 +32,9 @@ scattered within props.r metres of x, y, facing about the rotation; keep them ou A route (type route) moves props.n movers (props.kind: car, bus, motorbike, cyclist, person, deer, elephant) along \ waypoints pts (relative to x, y): round them when props.closed, else there and back; each way keeps to the left \ of the line (props.keep). For traffic, put the points on the road centre lines; keep routes clear of buildings. \ +Groups: add an object of type group (with a name), then set each member's group to its id; hiding or locking the \ +group hides or locks all of it, and removing it keeps the members. The object order is the drawing order of ground \ +pieces (areas, beds, ponds, roads): later lies on top; keep a group's members just before the group object. \ Production lines (category Industry): a producer (props.rate per minute, product) puts goods out on its local +x \ side; a conveyor (rot 0 runs towards +x, 90 +y, 180 -x, 270 -y; w = length, props.speed m/s) starting exactly \ at that side carries them, and a conveyor whose start is at another's end carries on with them (corners join). \ @@ -63,6 +66,7 @@ pub fn tools() -> Vec { "levels": { "type": "array", "items": { "type": "string" }, "description": "levels it shows on (empty: all)" }, "pts": { "type": "array", "items": { "type": "array", "items": { "type": "number" }, "minItems": 2, "maxItems": 2 }, "description": "points [[x, y], ...] relative to x, y (in the object's turned frame): a route's waypoints, an area's outline" }, + "group": { "type": "string", "description": "id of the group (type group) it belongs to; empty: none" }, }); let mut add = obj_fields.as_object().unwrap().clone(); add.insert("type".into(), json!({ "type": "string", "description": "prefab type, e.g. building, tree, car (list_prefabs)" })); @@ -86,8 +90,9 @@ pub fn tools() -> Vec { tool("remove_object", "Delete an object.", json!({ "id": { "type": "string" } }), &["id"]), tool("duplicate_object", "Copy an object, offset by dx, dy metres (default 2, 2).", json!({ "id": { "type": "string" }, "dx": { "type": "number" }, "dy": { "type": "number" } }), &["id"]), - tool("apply_ops", "Several edits as one undo step. Each op is {op: add|update|remove|duplicate|reorder|set_cfg, ...} \ - (add: same fields as add_object; update: {id, patch: {...}}; reorder: {id, to: front|back|index}).", + tool("apply_ops", "Several edits as one undo step. Each op is {op: add|update|remove|duplicate|reorder|order|set_cfg, ...} \ + (add: same fields as add_object; update: {id, patch: {...}}; reorder: {id, to: front|back|index}; \ + order: {ids: [every object id, back to front]}).", json!({ "ops": { "type": "array", "items": { "type": "object" } } }), &["ops"]), tool("set_params", "Set project parameter values (see project_info); null resets to the default.", json!({ "values": { "type": "object" } }), &["values"]), diff --git a/crates/isokit-project/src/ops.rs b/crates/isokit-project/src/ops.rs index 9b7e2fb..9d6378d 100644 --- a/crates/isokit-project/src/ops.rs +++ b/crates/isokit-project/src/ops.rs @@ -1,7 +1,7 @@ //! Edits to the scene document. The Studio and the MCP tools send the same //! JSON operations: `{"op": "add", "type": "tree", "x": 3, "y": 4}`. -use isokit_script::doc::{Doc, Object, PrefabMeta}; +use isokit_script::doc::{Doc, Object, PrefabMeta, GROUP}; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; @@ -32,6 +32,9 @@ pub enum Op { levels: Vec, #[serde(default)] pts: Option>, + /// The group it goes into. + #[serde(default)] + group: String, /// Wanted id (made unique); default from the type. #[serde(default)] id: Option, @@ -56,6 +59,11 @@ pub enum Op { id: String, to: Value, }, + /// Put every object in this order (back to front: ground pieces later in the + /// list lie on top). `ids` must name each object once. + Order { + ids: Vec, + }, /// Set parameter values (null removes the document's value). SetCfg { values: Map, @@ -171,6 +179,7 @@ pub fn patch_object( "tag" => o.tag = str_of(v), "hidden" => o.hidden = v.as_bool().unwrap_or(false), "locked" => o.locked = v.as_bool().unwrap_or(false), + "group" => o.group = str_of(v), "levels" => { o.levels = v.as_array().map(|a| a.iter().map(str_of).filter(|s| !s.is_empty()).collect()).unwrap_or_default() } @@ -200,7 +209,7 @@ pub fn patch_object( o.kind = t; } "id" => {} - other => return Err(format!("unknown field \"{other}\" (x, y, z, rot, name, info, tag, levels, props, pts, hidden, locked, type)")), + other => return Err(format!("unknown field \"{other}\" (x, y, z, rot, name, info, tag, levels, props, pts, hidden, locked, group, type)")), } } Ok(()) @@ -222,8 +231,22 @@ pub fn apply(doc: &mut Doc, catalog: &[PrefabMeta], op: Op) -> Result { + if kind == GROUP { + // a group draws nothing and has no props + let id = doc.free_id(id.as_deref().filter(|s| !s.is_empty()).unwrap_or(GROUP)); + doc.objects.push(Object { + id: id.clone(), + kind, + name, + info, + group, + ..Default::default() + }); + return Ok(Outcome { id: Some(id) }); + } let Some(meta) = meta_of(&kind) else { let mut known: Vec<&str> = catalog.iter().map(|m| m.kind.as_str()).collect(); known.sort(); @@ -249,6 +272,7 @@ pub fn apply(doc: &mut Doc, catalog: &[PrefabMeta], op: Op) -> Result Result { - let n = doc.objects.len(); - doc.objects.retain(|o| o.id != id); - if doc.objects.len() == n { - return Err(format!("no object \"{id}\"")); + let i = doc + .objects + .iter() + .position(|o| o.id == id) + .ok_or_else(|| format!("no object \"{id}\""))?; + let gone = doc.objects.remove(i); + // what was in a removed group moves up into the group it was in + if gone.kind == GROUP { + for o in doc.objects.iter_mut().filter(|o| o.group == id) { + o.group = gone.group.clone(); + } } Ok(Outcome { id: None }) } + Op::Order { ids } => { + let n = doc.objects.len(); + let mut pos = std::collections::HashMap::with_capacity(n); + for (i, id) in ids.iter().enumerate() { + if pos.insert(id.as_str(), i).is_some() { + return Err(format!("order names \"{id}\" twice")); + } + } + if ids.len() != n || doc.objects.iter().any(|o| !pos.contains_key(o.id.as_str())) { + return Err(format!( + "order must name every object once ({n} objects, {} ids)", + ids.len() + )); + } + doc.objects.sort_by_key(|o| pos[o.id.as_str()]); + Ok(Outcome { id: None }) + } Op::Duplicate { id, dx, dy } => { let src = doc .get(&id) @@ -447,6 +495,86 @@ mod tests { assert!(e.contains("no object"), "{e}"); } + #[test] + fn groups_and_the_drawing_order() { + let mut d = Doc::default(); + let c = catalog(); + for (x, id) in [(0, "a"), (1, "b"), (2, "c")] { + apply( + &mut d, + &c, + op(json!({"op": "add", "type": "tree", "x": x, "id": id})), + ) + .unwrap(); + } + // a group draws nothing and takes no props; members point at it + let g = apply( + &mut d, + &c, + op(json!({"op": "add", "type": "group", "name": "Trees", "id": "trees"})), + ) + .unwrap() + .id + .unwrap(); + assert_eq!(g, "trees"); + for id in ["a", "b"] { + apply( + &mut d, + &c, + op(json!({"op": "update", "id": id, "patch": {"group": "trees"}})), + ) + .unwrap(); + } + let inner = apply( + &mut d, + &c, + op(json!({"op": "add", "type": "group", "id": "inner", "group": "trees"})), + ) + .unwrap() + .id + .unwrap(); + apply( + &mut d, + &c, + op(json!({"op": "update", "id": "a", "patch": {"group": inner}})), + ) + .unwrap(); + // hiding the outer group hides what is in the inner one + apply( + &mut d, + &c, + op(json!({"op": "update", "id": "trees", "patch": {"hidden": true}})), + ) + .unwrap(); + assert!(d.hidden_in_groups(d.get("a").unwrap())); + assert!(d.hidden_in_groups(d.get("b").unwrap())); + assert!(!d.hidden_in_groups(d.get("c").unwrap())); + // the whole order at once, every object once + apply( + &mut d, + &c, + op(json!({"op": "order", "ids": ["c", "a", "inner", "b", "trees"]})), + ) + .unwrap(); + let ids: Vec<&str> = d.objects.iter().map(|o| o.id.as_str()).collect(); + assert_eq!(ids, ["c", "a", "inner", "b", "trees"]); + let e = apply(&mut d, &c, op(json!({"op": "order", "ids": ["c", "a"]}))).unwrap_err(); + assert!(e.contains("every object once"), "{e}"); + let e = apply( + &mut d, + &c, + op(json!({"op": "order", "ids": ["c", "c", "a", "b", "trees"]})), + ) + .unwrap_err(); + assert!(e.contains("twice"), "{e}"); + // removing a group keeps its members: they move up into the group it was in + apply(&mut d, &c, op(json!({"op": "remove", "id": "inner"}))).unwrap(); + assert_eq!(d.get("a").unwrap().group, "trees"); + apply(&mut d, &c, op(json!({"op": "remove", "id": "trees"}))).unwrap(); + assert_eq!(d.get("a").unwrap().group, ""); + assert_eq!(d.objects.len(), 3); + } + #[test] fn reorder_and_cfg() { let mut d = Doc::default(); diff --git a/crates/isokit-project/tests/demo.rs b/crates/isokit-project/tests/demo.rs index 2b79da9..01dd822 100644 --- a/crates/isokit-project/tests/demo.rs +++ b/crates/isokit-project/tests/demo.rs @@ -49,7 +49,7 @@ fn demo_renders_every_view_cleanly() { l.id, v.nodes ); - for o in &p.doc.objects { + for o in p.doc.objects.iter().filter(|o| o.kind != "group") { assert!( v.boxes.contains_key(&o.id), "{} not drawn in {}/{rot}", @@ -108,7 +108,7 @@ fn example_renders_every_view_cleanly(name: &str) { assert!(v.warnings.is_empty(), "{}/{rot}: {:?}", l.id, v.warnings); assert_eq!(v.cycles, 0, "{}/{rot}: depth-sort cycles", l.id); assert!(!v.html.contains("NaN")); - for o in &p.doc.objects { + for o in p.doc.objects.iter().filter(|o| o.kind != "group") { assert!( v.boxes.contains_key(&o.id), "{} not drawn in {}/{rot}", diff --git a/crates/isokit-script/src/doc.rs b/crates/isokit-script/src/doc.rs index 1722c9d..7167e98 100644 --- a/crates/isokit-script/src/doc.rs +++ b/crates/isokit-script/src/doc.rs @@ -62,6 +62,34 @@ pub struct Object { pub hidden: bool, #[serde(default, skip_serializing_if = "is_false")] pub locked: bool, + /// Id of the group (an object of type "group") this object belongs to. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub group: String, +} + +/// The type of the objects that group others: they draw nothing; hiding or +/// locking one hides or locks everything in it. +pub const GROUP: &str = "group"; + +impl Doc { + /// Whether an object is hidden, by itself or by a group it is in (groups can nest). + pub fn hidden_in_groups(&self, o: &Object) -> bool { + let mut cur = o; + for _ in 0..64 { + if cur.hidden { + return true; + } + match self + .objects + .iter() + .find(|g| g.kind == GROUP && g.id == cur.group) + { + Some(g) if !cur.group.is_empty() => cur = g, + _ => return false, + } + } + false + } } /// One editable property of a prefab. diff --git a/crates/isokit-script/src/lib.rs b/crates/isokit-script/src/lib.rs index 6e793ac..22fbc8e 100644 --- a/crates/isokit-script/src/lib.rs +++ b/crates/isokit-script/src/lib.rs @@ -488,6 +488,10 @@ impl Runtime { .try_cast::() .unwrap_or_default(); for o in &doc.objects { + // groups draw nothing; a hidden group hides what is in it + if o.kind == doc::GROUP { + continue; + } let mut j = serde_json::to_value(o).unwrap_or_default(); let meta = b.catalog.get(&o.kind); let props = meta @@ -510,7 +514,9 @@ impl Runtime { } let d = dyn_json(&j); b.all_objects.push(d.clone()); - if !o.hidden && (o.levels.is_empty() || o.levels.iter().any(|l| l == level)) { + if !doc.hidden_in_groups(o) + && (o.levels.is_empty() || o.levels.iter().any(|l| l == level)) + { b.objects.push(d); } } diff --git a/crates/isokit-script/src/tests.rs b/crates/isokit-script/src/tests.rs index a2cf96d..b29e9c5 100644 --- a/crates/isokit-script/src/tests.rs +++ b/crates/isokit-script/src/tests.rs @@ -858,6 +858,33 @@ fn piece(id: &str, kind: &str, a: [f64; 2], b: [f64; 2], props: Value) -> Object o } +#[test] +fn hidden_groups_hide_what_is_in_them() { + let r = Runtime::new(&Bundle::default()).unwrap(); + let mut g = obj("g", "group", 0.0, 0.0, json!({})); + g.hidden = true; + let mut inner = obj("inner", "group", 0.0, 0.0, json!({})); + inner.group = "g".into(); + let mut t1 = obj("t1", "tree", 0.0, 0.0, json!({})); + t1.group = "inner".into(); + let mut t2 = obj("t2", "tree", 5.0, 0.0, json!({})); + t2.group = "g".into(); + let doc = Doc { + objects: vec![t1, t2, inner, g, obj("t3", "tree", 10.0, 0.0, json!({}))], + ..Default::default() + }; + let b = r.build("site", 0, &cfg(), &doc).unwrap(); + // groups draw nothing and warn about nothing + assert!(b.scene.warnings.is_empty(), "{:?}", b.scene.warnings); + assert_eq!(sprites_of(&b, "g") + sprites_of(&b, "inner"), 0); + assert_eq!(sprites_of(&b, "t1") + sprites_of(&b, "t2"), 0); + assert_eq!(sprites_of(&b, "t3"), 1); + let mut shown = doc.clone(); + shown.objects[3].hidden = false; + let b = r.build("site", 0, &cfg(), &shown).unwrap(); + assert_eq!(sprites_of(&b, "t1") + sprites_of(&b, "t2"), 2); +} + /// The windows of every route mover piece of object `id`: (piece sprite, windows). fn travel_windows(b: &crate::Built, id: &str) -> Vec> { fn walk(p: &isokit_core::Prim, out: &mut Vec>) { diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index d0f8031..06cbd39 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -152,12 +152,20 @@ sprite whose sort box is its lane strip, so movers pass in front of and behind w beside the road; keyframes per piece show a mover only while it is on it. Streets demo: traffic round the block, a bus, motorbikes, cyclists, people; country demo: cars on the lane. +Groups and the drawing order: objects of type `group` draw nothing; members carry the group's id +in `group` (groups nest), hiding or locking a group hides or locks all of it, removing it keeps +its members. The Studio's object list is a tree in the document order (each group just after +its members, so it draws as one block; ground pieces later lie on top): fold groups, eye and lock +per row, Ctrl+G / Ctrl+Shift+G, drag rows to reorder or into a group, ▲ ▼ and Ctrl+] / Ctrl+[ +(with Shift to the front / back). Every tree edit is one undo step (`order` op: all ids at once). + ## Phase 2: richer editing - [x] Interior prefabs (see Build your own house); a building `rooms` prop is still open - [ ] Path objects in the Studio: click points for areas, roads (polyline with width), fences, walls, - pipe runs; vertex handles + pipe runs; vertex handles (routes are drawn point by point: meta `shape: "path"`) - [x] Multi-select (box select, Ctrl+click, Shift range), group move, turn, copy, delete, edit +- [x] Groups in the object tree (fold, hide, lock, drag in and out) and the drawing order (drag, ▲ ▼) - [ ] Align / distribute the selection - [ ] Copy/paste between projects; prefab thumbnails in the palette (rendered icons) - [ ] Measure tool and grid overlay; numeric resize handles on the selection diff --git a/docs/SCRIPTING.md b/docs/SCRIPTING.md index 87108b4..7fa06dc 100644 --- a/docs/SCRIPTING.md +++ b/docs/SCRIPTING.md @@ -168,6 +168,14 @@ spread within `r` of the object, each facing its rotation give or take 70°, each its own sprite. Flocks of birds, butterflies and bees use `swim` with a `shape`. +## Groups and the drawing order + +Objects of type `group` are never passed to scripts (they draw nothing), nor is anything inside a +hidden group (`Doc::hidden_in_groups`); prefabs never see the `group` field's effect. The order of +`scene.json` is the drawing order of the `ground` layer (unsorted): later ground pieces lie on +top. Sorted layers ignore it except for ties. The Studio keeps each group's members just before +the group object (`tree`, `flatten` in studio.js) and sends tree edits as one `order` op. + ## Routes `routes.rhai` (`route` prefab, meta `shape: "path"`: the Studio draws it point by point and diff --git a/studio/index.html b/studio/index.html index 43be05e..bdae709 100644 --- a/studio/index.html +++ b/studio/index.html @@ -38,6 +38,13 @@ document.documentElement.setAttribute("data-theme",t);})();

Objects

+
+ + + Order + + +
    diff --git a/studio/studio.css b/studio/studio.css index d060eeb..be0afca 100644 --- a/studio/studio.css +++ b/studio/studio.css @@ -54,6 +54,21 @@ input[type=checkbox] { accent-color: var(--accent); } .st-objects li span { flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .st-objects li small { color: var(--mute); } .st-objects li.st-hidden span { opacity: .5; text-decoration: line-through; } +/* the object tree: groups indent what is in them; a hide and a lock toggle per row, shown on hover or when set */ +.st-objects li { padding-left: calc(2px + var(--d, 0) * 14px); } +.st-objects li .st-tw { width: 12px; flex: none; font-weight: normal; color: var(--mute); text-align: center; cursor: pointer; } +.st-objects li.st-group > span { font-weight: 600; } +.st-objects li.st-inh span { text-decoration: none; } +.st-objects li button { flex: none; border: 0; background: none; padding: 0 2px; min-width: 18px; color: var(--mute); cursor: pointer; font-size: 12px; line-height: 1; opacity: 0; } +.st-objects li:hover button, .st-objects li button.st-on, .st-objects li button:focus-visible { opacity: 1; } +.st-objects li button:hover { color: var(--fg); } +.st-objects li.st-drop-above { box-shadow: inset 0 2px 0 var(--accent); } +.st-objects li.st-drop-below { box-shadow: inset 0 -2px 0 var(--accent); } +.st-objects li.st-drop-in { outline: 2px solid var(--accent); outline-offset: -2px; } +body.st-listdrag, body.st-listdrag * { cursor: grabbing !important; } +.st-otools { display: flex; align-items: center; gap: 4px; margin: 4px 0 2px; font-size: 12px; } +.st-otools button { padding: 2px 7px; font-size: 12px; } +.st-otools span { margin-left: auto; color: var(--mute); cursor: help; } .st-centre { position: relative; display: flex; flex-direction: column; min-width: 0; min-height: 0; } .st-centre > #view { flex: 1 1 auto; min-height: 0; } diff --git a/studio/studio.js b/studio/studio.js index 718a44d..45f3493 100644 --- a/studio/studio.js +++ b/studio/studio.js @@ -26,8 +26,8 @@ function snap(v) { var s = snapStep(); return s ? Math.round(v / s) * s : Math.round(v * 100) / 100; } function prefab(kind) { return (S.meta.catalog || []).filter(function (m) { return m.type === kind; })[0]; } function obj(id) { return (S.doc.objects || []).filter(function (o) { return o.id === id; })[0]; } - function label(o) { var m = prefab(o.type); return o.name || (m ? m.label : o.type); } - function icon(kind) { var m = prefab(kind); return (m && m.icon) || ICON_DEFAULT; } + function label(o) { var m = prefab(o.type); return o.name || (o.type === "group" ? "Group" : m ? m.label : o.type); } + function icon(kind) { var m = prefab(kind); return kind === "group" ? "▤" : (m && m.icon) || ICON_DEFAULT; } function r3(v) { return Math.round(v * 1000) / 1000; } // ------------------------------------------------------------ storeys, frames, mounts @@ -407,7 +407,7 @@ drag = { id: o.id, ox: o.x, oy: o.y, w0: hit.world, moved: false, els: null, group: set, carry: [] }; return true; } - if (!o || o.locked) return false; + if (!o || lockedEff(o)) return false; var m = prefab(o.type), mount = !!(m && m.mount && m.mount.length), h = mount ? hostOf(o) : null; drag = { id: o.id, ox: o.x, oy: o.y, w0: hit.world, moved: false, els: null, m: m, mount: mount, rel: h ? qOf((o.rot || 0) - (h.host.rot || 0)) : null, carry: mountedOn(o) }; @@ -600,6 +600,7 @@ var ids = selIds(); S.view.selectObj(ids.length > 1 ? ids : S.sel); Array.prototype.forEach.call(document.querySelectorAll("#objects li"), function (li) { li.setAttribute("aria-selected", String(ids.indexOf(li.getAttribute("data-id")) >= 0)); }); + otools(); if (!quiet) renderRight(); } function select(id, quiet) { @@ -645,7 +646,7 @@ select(id); } function selectAll() { - selectMany((S.doc.objects || []).filter(function (o) { return !o.locked && !o.hidden; }).map(function (o) { return o.id; })); + selectMany((S.doc.objects || []).filter(function (o) { return !lockedEff(o) && !hiddenEff(o) && !isGroup(o); }).map(function (o) { return o.id; })); } // the selection box drawn with Shift / Ctrl + drag function band(r) { @@ -662,7 +663,7 @@ if (!mv.moved) { if (d.hit) toggleSel(d.hit); return; } // every object drawn wholly inside the box (not locked or hidden) var r = bandRect(d, e), found = (S.doc.objects || []).filter(function (o) { - if (o.locked || o.hidden) return false; + if (lockedEff(o) || hiddenEff(o) || isGroup(o)) return false; var els = moveEls(o.id), u = null; Array.prototype.forEach.call(els, function (el) { var b = el.getBoundingClientRect(); @@ -677,7 +678,7 @@ } // what a group move takes along: the selected objects that are not locked, and what sits on them function moveSet(ids) { - var objs = ids.map(obj).filter(function (o) { return o && !o.locked; }), seen = {}, carry = []; + var objs = ids.map(obj).filter(function (o) { return o && !lockedEff(o) && !isGroup(o); }), seen = {}, carry = []; objs.forEach(function (o) { seen[o.id] = 1; }); objs.forEach(function (o) { mountedOn(o).forEach(function (c) { if (!seen[c.id]) { seen[c.id] = 1; carry.push(c); } }); }); return { objs: objs, carry: carry }; @@ -708,6 +709,7 @@ }); } function duplicateMany(ids) { + ids = ids.filter(function (id) { return !isGroup(obj(id)); }); var dd = Math.max(snapStep(), 1) * 2, extra = extraOn(ids); opList(extra.concat(ids).map(function (id) { return { op: "duplicate", id: id, dx: dd, dy: dd }; })).then(function (r) { if (r && r.ids) selectMany(r.ids.slice(-ids.length)); @@ -724,6 +726,127 @@ var y0 = Math.min.apply(null, bs.map(function (b) { return b[1]; })), y1 = Math.max.apply(null, bs.map(function (b) { return b[4]; })); S.view.focus([(x0 + x1) / 2, (y0 + y1) / 2, 0], 2); } + // ------------------------------------------------------------ groups and the drawing order + // A group is an object of type "group" that draws nothing; what is in it carries its id in + // `group` (groups nest). The object list is a tree built from the document order: a group's + // members lie just before the group object, so a group draws as one block at its place, and + // ground pieces (areas, beds, ponds, roads) later in the order lie on top. The list shows the + // front first. Every change to the tree is one op batch: group changes and the whole order. + var GROUP = "group", ICON_GROUP = "▤", openGroups = {}, closedGroups = {}; + function isGroup(o) { return !!o && o.type === GROUP; } + function parentOf(o) { var g = o && o.group ? obj(o.group) : null; return isGroup(g) && g !== o ? g : null; } + function ancestors(o) { var out = [], g = parentOf(o); while (g && out.length < 64 && out.indexOf(g) < 0) { out.push(g); g = parentOf(g); } return out; } + function hiddenEff(o) { return !!o && (!!o.hidden || ancestors(o).some(function (g) { return g.hidden; })); } + function lockedEff(o) { return !!o && (!!o.locked || ancestors(o).some(function (g) { return g.locked; })); } + // what is in group gid: its direct members, or (deep) everything below it, groups included + function membersOf(gid, deep) { + var out = [], todo = [gid]; + while (todo.length) { + var g = todo.shift(); + (S.doc.objects || []).forEach(function (o) { + if (o.group === g && out.indexOf(o) < 0 && o.id !== gid) { out.push(o); if (deep && isGroup(o)) todo.push(o.id); } + }); + } + return out; + } + // the tree, back to front: [{ o, kids }]; a group cycle (only by hand-edited files) stays at the root + function tree() { + var byId = {}, roots = []; + (S.doc.objects || []).forEach(function (o) { byId[o.id] = { o: o, kids: [] }; }); + (S.doc.objects || []).forEach(function (o) { + var p = parentOf(o), cyc = p && ancestors(o).indexOf(o) >= 0; + (p && !cyc && byId[p.id] ? byId[p.id].kids : roots).push(byId[o.id]); + }); + return roots; + } + // the document order of a tree: each group after what is in it + function flatten(nodes, out) { nodes.forEach(function (n) { flatten(n.kids, out); out.push(n.o.id); }); return out; } + // where a node is: { node, list (its siblings), parent (group id or "") } + function findNode(nodes, id, parent) { + for (var i = 0; i < nodes.length; i++) { + if (nodes[i].o.id === id) return { node: nodes[i], list: nodes, parent: parent || "" }; + var f = findNode(nodes[i].kids, id, nodes[i].o.id); + if (f) return f; + } + return null; + } + // the selected objects that are not inside another selected group + function topSel(ids) { + ids = (ids || selIds()).filter(function (id) { return obj(id); }); + return ids.filter(function (id) { return !ancestors(obj(id)).some(function (g) { return ids.indexOf(g.id) >= 0; }); }); + } + function docIndex(id) { return (S.doc.objects || []).map(function (o) { return o.id; }).indexOf(id); } + // send a changed tree: first `pre` (adds, removes), then group changes, then the order + function commitTree(roots, changes, pre, msg) { + var ops = (pre || []).concat(changes || []), ids = flatten(roots, []); + var cur = (S.doc.objects || []).map(function (o) { return o.id; }).filter(function (id) { return ids.indexOf(id) >= 0 || (pre || []).every(function (p) { return p.op !== "remove" || p.id !== id; }); }); + if (ids.join("\n") !== cur.join("\n")) ops.push({ op: "order", ids: ids }); + if (!ops.length) return Promise.resolve(null); + return op({ ops: ops }).then(function (r) { if (r && msg) toast(msg); return r; }); + } + function groupSelection() { + var ids = topSel(); + if (ids.length < 2) { toast("Select two or more objects to group them (Ctrl+click in the list or on the map)"); return; } + var roots = tree(), locs = ids.map(function (id) { return findNode(roots, id); }).filter(Boolean); + locs.sort(function (a, b) { return docIndex(a.node.o.id) - docIndex(b.node.o.id); }); + var front = locs[locs.length - 1], k = 1; + while (obj("group-" + k)) k++; + var gid = "group-" + k, n = (S.doc.objects || []).filter(isGroup).length + 1; + var gnode = { o: { id: gid, type: GROUP, group: front.parent }, kids: [] }; + // the group takes the place of the front-most of them, in its parent + front.list.splice(front.list.indexOf(front.node) + 1, 0, gnode); + locs.forEach(function (l) { l.list.splice(l.list.indexOf(l.node), 1); gnode.kids.push(l.node); }); + var changes = locs.map(function (l) { return { op: "update", id: l.node.o.id, patch: { group: gid } }; }); + commitTree(roots, changes, [{ op: "add", type: GROUP, id: gid, name: "Group " + n, group: front.parent }], "Grouped " + locs.length + " · Ctrl+Shift+G ungroups").then(function (r) { if (r) selectGroup(gid); }); + } + // the group the selection is or is in + function selGroup() { + var o = S.sel ? obj(S.sel) : null; + return isGroup(o) ? o : parentOf(o); + } + function ungroup(g) { + g = g || selGroup(); + if (!g) { toast("Select a group (or something in one) to ungroup it"); return; } + var roots = tree(), loc = findNode(roots, g.id), kids = loc.node.kids; + Array.prototype.splice.apply(loc.list, [loc.list.indexOf(loc.node), 1].concat(kids)); + // removing a group moves what was in it up into its parent (ops.rs) + commitTree(roots, [], [{ op: "remove", id: g.id }], "Ungrouped " + label(g) + ": " + kids.length + " object" + (kids.length === 1 ? "" : "s") + " · Ctrl+Z groups them again").then(function (r) { + if (r) selectMany(kids.map(function (n) { return n.o.id; })); + }); + } + function selectGroup(gid) { selectMany([gid].concat(membersOf(gid, true).map(function (o) { return o.id; })), gid); } + // forward / backward one place among their siblings, or to the front / back of them + function moveOrder(how, ids) { + ids = topSel(ids); + if (!ids.length) return; + var roots = tree(), lists = []; + ids.forEach(function (id) { var l = findNode(roots, id); if (l && lists.indexOf(l.list) < 0) lists.push(l.list); }); + lists.forEach(function (list) { + var sel = list.filter(function (n) { return ids.indexOf(n.o.id) >= 0; }), rest = list.filter(function (n) { return ids.indexOf(n.o.id) < 0; }), i; + if (how === "front" || how === "back") { list.length = 0; Array.prototype.push.apply(list, how === "front" ? rest.concat(sel) : sel.concat(rest)); return; } + if (how === "forward") { for (i = list.length - 2; i >= 0; i--) if (sel.indexOf(list[i]) >= 0 && sel.indexOf(list[i + 1]) < 0) { var a = list[i]; list[i] = list[i + 1]; list[i + 1] = a; } } + else { for (i = 1; i < list.length; i++) if (sel.indexOf(list[i]) >= 0 && sel.indexOf(list[i - 1]) < 0) { var b = list[i]; list[i] = list[i - 1]; list[i - 1] = b; } } + }); + var words = { front: "to the front", back: "to the back", forward: "forward", backward: "backward" }; + commitTree(roots, [], [], null).then(function (r) { if (r) toast("Moved " + words[how] + " · higher in the list draws on top (ground pieces)"); }); + } + // rows dropped above (in front of), below (behind) or into a group row + function dropRows(ids, t) { + ids = topSel(ids); + var tgt = obj(t.id); + if (!tgt || ids.indexOf(t.id) >= 0) return; + if (ids.some(function (id) { return ancestors(tgt).some(function (g) { return g.id === id; }); })) { toast("A group cannot go into itself", true); return; } + var roots = tree(), locs = ids.map(function (id) { return findNode(roots, id); }).filter(Boolean); + locs.sort(function (a, b) { return docIndex(a.node.o.id) - docIndex(b.node.o.id); }); + var nodes = locs.map(function (l) { l.list.splice(l.list.indexOf(l.node), 1); return l.node; }); + var dest, at, parent; + if (t.where === "into") { var g = findNode(roots, t.id); dest = g.node.kids; at = dest.length; parent = t.id; } + else { var l = findNode(roots, t.id); dest = l.list; parent = l.parent; at = dest.indexOf(l.node) + (t.where === "above" ? 1 : 0); } + Array.prototype.splice.apply(dest, [at, 0].concat(nodes)); + var changes = nodes.filter(function (n) { return (n.o.group || "") !== parent; }).map(function (n) { return { op: "update", id: n.o.id, patch: { group: parent } }; }); + commitTree(roots, changes, [], t.where === "into" ? "Moved into " + label(tgt) : null); + } + function opList(ops, quiet) { return op(ops.length === 1 ? ops[0] : { ops: ops }, quiet); } // the object and what sits on it (a wall's windows and doors) as one undo step; the object last, so it stays selected function remove(id) { @@ -757,7 +880,7 @@ } function nudge(id, dx, dy) { var o = obj(id), many = selIds().length > 1; - if (!o || (o.locked && !many)) return; + if (!o || (lockedEff(o) && !many)) return; var s = snapStep() || 0.5; // arrow keys follow the screen: map screen directions onto world axes for the current rotation var rot = S.view.getRotation(); @@ -781,6 +904,10 @@ if (ctrl && (k === "a" || k === "A")) { selectAll(); return true; } if (!S.sel) return false; var many = selIds().length > 1; + // groups and the drawing order: Ctrl+G, Ctrl+Shift+G; Ctrl+] / Ctrl+[ forward / backward, with Shift to the front / back + if (ctrl && (k === "g" || k === "G")) { if (e.shiftKey) ungroup(); else groupSelection(); return true; } + if (ctrl && (k === "]" || k === "}")) { moveOrder(e.shiftKey ? "front" : "forward"); return true; } + if (ctrl && (k === "[" || k === "{")) { moveOrder(e.shiftKey ? "back" : "backward"); return true; } if (k === "Delete" || k === "Backspace") { if (many) removeMany(selIds()); else remove(S.sel); return true; } if (ctrl && (k === "d" || k === "D")) { if (many) duplicateMany(selIds()); else duplicate(S.sel); return true; } if (!ctrl && (k === "r" || k === "R")) { if (many) turnGroup(selIds(), e.shiftKey ? -90 : 90); else turn(S.sel, e.shiftKey ? -90 : 90); return true; } @@ -815,14 +942,38 @@ }).join("") + ""; }).join("") || '
    No object matches.
    '; } + // the object tree, front first: groups fold open and shut, every row has a hide and a lock toggle function renderLists() { - var q = $("osearch").value.trim().toLowerCase(), list = S.doc.objects || []; + var q = $("osearch").value.trim().toLowerCase(), list = S.doc.objects || [], out = []; $("ocount").textContent = list.length; - $("objects").innerHTML = list.filter(function (o) { - return !q || (o.id + " " + label(o) + " " + o.type).toLowerCase().indexOf(q) >= 0; - }).slice().reverse().map(function (o) { - return '
  • ' + esc(icon(o.type)) + "" + esc(label(o)) + "" + (o.locked ? "🔒" : "") + "
  • "; - }).join(""); + function hit(o) { return (o.id + " " + label(o) + " " + o.type).toLowerCase().indexOf(q) >= 0; } + function shows(n) { return !q || hit(n.o) || n.kids.some(shows); } + function walk(nodes, depth) { + for (var i = nodes.length - 1; i >= 0; i--) { + var n = nodes[i], o = n.o, g = isGroup(o); + if (!shows(n)) continue; + // groups start open; a filter opens every group that holds a match + var open = g && (q ? true : !closedGroups[o.id]); + var cls = (g ? "st-group" : "") + (o.hidden ? " st-hidden" : hiddenEff(o) ? " st-hidden st-inh" : "") + (lockedEff(o) ? " st-locked" : ""); + out.push('
  • ' + + (g ? '' + (open ? "▾" : "▸") + "" : '') + + "" + esc(icon(o.type)) + "" + esc(label(o)) + "" + + (g ? "" + membersOf(o.id, true).filter(function (x) { return !isGroup(x); }).length + "" : "") + + '" + + '
  • "); + if (g && open) walk(n.kids, depth + 1); + } + } + walk(tree(), 0); + $("objects").innerHTML = out.join(""); + otools(); + } + // the list's tool strip follows the selection + function otools() { + var sel = selIds(); + $("ogroup").disabled = topSel(sel).length < 2; + $("oungroup").disabled = !selGroup(); + $("ofwd").disabled = $("obwd").disabled = !sel.length; } // closed palette categories, remembered in this browser function paletteClosed() { try { return JSON.parse(localStorage.getItem("isokit-palette-closed") || "[]"); } catch (e) { return []; } } @@ -844,16 +995,59 @@ $("osearch").addEventListener("input", renderLists); // Ctrl+click adds or takes out one, Shift+click selects the range from the last picked one $("objects").addEventListener("click", function (e) { + if (listDragged) { listDragged = false; return; } var li = e.target.closest("li"); if (!li) return; - var id = li.getAttribute("data-id"); + var id = li.getAttribute("data-id"), o = obj(id); + if (!o) return; + if (e.target.closest("[data-tw]")) { if (closedGroups[id]) delete closedGroups[id]; else closedGroups[id] = 1; renderLists(); return; } + if (e.target.closest("[data-eye]")) { update(id, { hidden: !o.hidden }); return; } + if (e.target.closest("[data-lk]")) { update(id, { locked: !o.locked }); return; } if (e.ctrlKey || e.metaKey) { toggleSel(id); return; } if (e.shiftKey && S.sel) { var all = Array.prototype.map.call(document.querySelectorAll("#objects li"), function (x) { return x.getAttribute("data-id"); }), a = all.indexOf(S.sel), b = all.indexOf(id); if (a >= 0 && b >= 0) { selectMany(all.slice(Math.min(a, b), Math.max(a, b) + 1), S.sel); return; } } - select(id); + // a group row selects the group and everything in it, so a drag on the map moves it all + if (isGroup(o)) selectGroup(id); else select(id); }); - $("objects").addEventListener("dblclick", function (e) { var li = e.target.closest("li"); if (li) S.view.focus(li.getAttribute("data-id"), 3); }); + $("objects").addEventListener("dblclick", function (e) { + var li = e.target.closest("li"); if (!li || e.target.closest("button,[data-tw]")) return; + var id = li.getAttribute("data-id"); + if (isGroup(obj(id))) focusMany(membersOf(id, true).map(function (o) { return o.id; })); else S.view.focus(id, 3); + }); + // rows dragged in the list: above or below another row (in front of or behind it), or into a + // group (the middle of its row); the selected rows go together when the one dragged is selected + var listDrag = null, listDragged = false; + function clearDrop() { Array.prototype.forEach.call(document.querySelectorAll("#objects .st-drop-above, #objects .st-drop-below, #objects .st-drop-in"), function (x) { x.classList.remove("st-drop-above", "st-drop-below", "st-drop-in"); }); } + $("objects").addEventListener("pointerdown", function (e) { + var li = e.target.closest("li"); + if (e.button !== 0 || !li || e.target.closest("button,[data-tw]") || e.shiftKey || e.ctrlKey || e.metaKey) return; + listDrag = { id: li.getAttribute("data-id"), y0: e.clientY, on: false, t: null }; + }); + document.addEventListener("pointermove", function (e) { + var d = listDrag; if (!d) return; + if (!d.on && Math.abs(e.clientY - d.y0) < 6) return; + d.on = true; document.body.classList.add("st-listdrag"); + clearDrop(); + var el = document.elementFromPoint(e.clientX, e.clientY), li = el && el.closest ? el.closest("#objects li") : null; + if (!li || li.getAttribute("data-id") === d.id) { d.t = null; return; } + var r = li.getBoundingClientRect(), f = (e.clientY - r.top) / r.height, g = li.classList.contains("st-group"); + var where = g && f > 0.28 && f < 0.72 ? "into" : f < 0.5 ? "above" : "below"; + li.classList.add(where === "into" ? "st-drop-in" : "st-drop-" + where); + d.t = { id: li.getAttribute("data-id"), where: where }; + }); + document.addEventListener("pointerup", function () { + var d = listDrag; listDrag = null; + if (!d) return; + document.body.classList.remove("st-listdrag"); clearDrop(); + if (!d.on) return; + listDragged = true; setTimeout(function () { listDragged = false; }, 0); + if (d.t) dropRows(selIds().indexOf(d.id) >= 0 ? selIds() : [d.id], d.t); + }); + $("ogroup").addEventListener("click", function () { groupSelection(); }); + $("oungroup").addEventListener("click", function () { ungroup(); }); + $("ofwd").addEventListener("click", function (e) { moveOrder(e.shiftKey ? "front" : "forward"); }); + $("obwd").addEventListener("click", function (e) { moveOrder(e.shiftKey ? "back" : "backward"); }); // ------------------------------------------------------------ right panel function field(key, lab, html, full) { return '" + (full ? '
    ' + html + "
    " : html); } @@ -877,6 +1071,7 @@ function renderRight() { var R = $("right"), o = S.sel ? obj(S.sel) : null, ids = selIds(); if (!o) { renderProject(R); return; } + if (isGroup(o)) { renderGroup(R, o); return; } if (ids.length > 1) { renderMany(R, ids); return; } var m = prefab(o.type) || { label: o.type, props: [] }, p = o.props || {}; var levels = S.meta.levels || []; @@ -909,6 +1104,21 @@ R.innerHTML = h; } // several objects: what they are, actions for all, and the properties when they are all one type + function renderGroup(R, g) { + var kids = membersOf(g.id, false), all = membersOf(g.id, true).filter(function (o) { return !isGroup(o); }); + R.innerHTML = "

    " + ICON_GROUP + "" + esc(label(g)) + "

    " + + '
    Group · ' + kids.length + " inside, " + all.length + " object" + (all.length === 1 ? "" : "s") + " in all · id " + esc(g.id) + "
    " + + '

    Everything in the group is selected: drag one of them on the map to move them all, R turns them, arrow keys nudge. Hiding or locking the group hides or locks all of it. In the list, drag rows into or out of it; higher in the list draws on top (ground pieces).

    ' + + '
    ' + + '' + + '' + + '' + + '' + + '" + + '" + + '
    ' + + '

    Group

    ' + field("name", "Name", '') + "
    "; + } function renderMany(R, ids) { var os = ids.map(obj), count = {}, order = []; os.forEach(function (o) { if (!count[o.type]) { count[o.type] = 0; order.push(o.type); } count[o.type]++; }); @@ -950,6 +1160,15 @@ $("right").addEventListener("click", function (e) { var b = e.target.closest("button[data-act]"); if (!b || !S.sel) return; var a = b.getAttribute("data-act"), o = obj(S.sel), ids = selIds(); + if (isGroup(o)) { + if (a === "focus") focusMany(membersOf(o.id, true).map(function (x) { return x.id; })); + else if (a === "ungroup") ungroup(o); + else if (a === "front" || a === "back") moveOrder(a, [o.id]); + else if (a === "lock") update(o.id, { locked: !o.locked }); + else if (a === "hide") update(o.id, { hidden: !o.hidden }); + else if (a === "del") removeMany([o.id].concat(membersOf(o.id, true).map(function (x) { return x.id; }))); + return; + } if (ids.length > 1) { if (a === "del") removeMany(ids); else if (a === "dup") duplicateMany(ids); @@ -962,7 +1181,7 @@ if (a === "del") remove(S.sel); else if (a === "dup") duplicate(S.sel); else if (a === "focus") S.view.focus(S.sel, 3); - else if (a === "front" || a === "back") op({ op: "reorder", id: S.sel, to: a }); + else if (a === "front" || a === "back") moveOrder(a, [S.sel]); else if (a === "lock") update(S.sel, { locked: !o.locked }); else if (a === "hide") update(S.sel, { hidden: !o.hidden }); else if (a === "rotl") turn(S.sel, -90); diff --git a/tools/check_studio.py b/tools/check_studio.py index b061068..e5c6dba 100644 --- a/tools/check_studio.py +++ b/tools/check_studio.py @@ -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") diff --git a/tools/check_web.py b/tools/check_web.py index 72a8e35..03d4603 100644 --- a/tools/check_web.py +++ b/tools/check_web.py @@ -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