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:
1 parent
5ec164a633
commit
540b37020c
15 files changed
+573
-30
No files matched your search
@@ -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 |
|
||||
|
||||
@@ -58,6 +58,25 @@ pub fn check(p: &Project) -> Vec<Finding> {
|
||||
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",
|
||||
|
||||
@@ -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<Value> {
|
||||
"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<Value> {
|
||||
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"]),
|
||||
|
||||
@@ -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<String>,
|
||||
#[serde(default)]
|
||||
pts: Option<Vec<[f64; 2]>>,
|
||||
/// The group it goes into.
|
||||
#[serde(default)]
|
||||
group: String,
|
||||
/// Wanted id (made unique); default from the type.
|
||||
#[serde(default)]
|
||||
id: Option<String>,
|
||||
@@ -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<String>,
|
||||
},
|
||||
/// Set parameter values (null removes the document's value).
|
||||
SetCfg {
|
||||
values: Map<String, Value>,
|
||||
@@ -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<Outcome, S
|
||||
tag,
|
||||
levels,
|
||||
pts,
|
||||
group,
|
||||
id,
|
||||
} => {
|
||||
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<Outcome, S
|
||||
pts,
|
||||
hidden: false,
|
||||
locked: false,
|
||||
group,
|
||||
});
|
||||
Ok(Outcome { id: Some(id) })
|
||||
}
|
||||
@@ -271,13 +295,37 @@ pub fn apply(doc: &mut Doc, catalog: &[PrefabMeta], op: Op) -> Result<Outcome, S
|
||||
Ok(Outcome { id: Some(id) })
|
||||
}
|
||||
Op::Remove { id } => {
|
||||
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();
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -488,6 +488,10 @@ impl Runtime {
|
||||
.try_cast::<Map>()
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Vec<[f64; 2]>> {
|
||||
fn walk(p: &isokit_core::Prim, out: &mut Vec<Vec<[f64; 2]>>) {
|
||||
|
||||
+9
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -38,6 +38,13 @@ document.documentElement.setAttribute("data-theme",t);})();
|
||||
<section class="st-panel st-grow-panel">
|
||||
<h2>Objects <small id="ocount"></small></h2>
|
||||
<input type="search" id="osearch" placeholder="Filter…" autocomplete="off">
|
||||
<div class="st-otools">
|
||||
<button type="button" id="ogroup" title="Group the selected objects (Ctrl+G)">▤ Group</button>
|
||||
<button type="button" id="oungroup" title="Ungroup (Ctrl+Shift+G)">Ungroup</button>
|
||||
<span title="The order draws ground pieces (areas, beds, ponds, roads): higher in the list lies on top. Drag rows to reorder, or into a group">Order</span>
|
||||
<button type="button" id="ofwd" title="Forward: draw on top of the one above (Ctrl+]); Shift: to the front (Ctrl+Shift+])">▲</button>
|
||||
<button type="button" id="obwd" title="Backward (Ctrl+[); Shift: to the back (Ctrl+Shift+[)">▼</button>
|
||||
</div>
|
||||
<ul id="objects" class="st-objects"></ul>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
+236
-17
@@ -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("") + "</div></div>";
|
||||
}).join("") || '<div class="st-help">No object matches.</div>';
|
||||
}
|
||||
// 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 '<li data-id="' + esc(o.id) + '" aria-selected="' + (S.selIds.indexOf(o.id) >= 0) + '"' + (o.hidden ? ' class="st-hidden"' : "") + ' title="' + esc(o.id) + '"><i>' + esc(icon(o.type)) + "</i><span>" + esc(label(o)) + "</span><small>" + (o.locked ? "🔒" : "") + "</small></li>";
|
||||
}).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('<li data-id="' + esc(o.id) + '" aria-selected="' + (S.selIds.indexOf(o.id) >= 0) + '" class="' + cls.trim() + '" style="--d:' + depth + '" title="' + esc(o.id) + '">' +
|
||||
(g ? '<b class="st-tw" data-tw title="' + (open ? "Fold" : "Unfold") + '">' + (open ? "▾" : "▸") + "</b>" : '<b class="st-tw"></b>') +
|
||||
"<i>" + esc(icon(o.type)) + "</i><span>" + esc(label(o)) + "</span>" +
|
||||
(g ? "<small>" + membersOf(o.id, true).filter(function (x) { return !isGroup(x); }).length + "</small>" : "") +
|
||||
'<button type="button" class="st-eye' + (o.hidden ? " st-on" : "") + '" data-eye title="' + (o.hidden ? "Show" : "Hide") + '">' + (o.hidden ? "◌" : "◉") + "</button>" +
|
||||
'<button type="button" class="st-lk' + (o.locked ? " st-on" : "") + '" data-lk title="' + (o.locked ? "Unlock" : "Lock") + '">' + (o.locked ? "🔒" : "🔓") + "</button></li>");
|
||||
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 '<label for="f-' + key + '">' + esc(lab) + "</label>" + (full ? '<div class="st-full">' + html + "</div>" : 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 = "<h3><i>" + ICON_GROUP + "</i>" + esc(label(g)) + "</h3>" +
|
||||
'<div class="st-sub">Group · ' + kids.length + " inside, " + all.length + " object" + (all.length === 1 ? "" : "s") + " in all · id " + esc(g.id) + "</div>" +
|
||||
'<p class="st-help">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).</p>' +
|
||||
'<div class="st-actions">' +
|
||||
'<button type="button" data-act="focus">Zoom to</button>' +
|
||||
'<button type="button" data-act="ungroup" title="Ctrl+Shift+G">Ungroup</button>' +
|
||||
'<button type="button" data-act="front" title="Ctrl+Shift+]">To front</button>' +
|
||||
'<button type="button" data-act="back" title="Ctrl+Shift+[">To back</button>' +
|
||||
'<button type="button" data-act="lock">' + (g.locked ? "Unlock" : "Lock") + "</button>" +
|
||||
'<button type="button" data-act="hide">' + (g.hidden ? "Show" : "Hide") + "</button>" +
|
||||
'<button type="button" data-act="del" class="st-danger">Delete with contents</button></div>' +
|
||||
'<h2>Group</h2><div class="st-form">' + field("name", "Name", '<input data-f="name" value="' + esc(g.name || "") + '" placeholder="Group">') + "</div>";
|
||||
}
|
||||
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);
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user