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

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

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

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

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

No files matched your search

+19
View File
@@ -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",
+7 -2
View File
@@ -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"]),
+134 -6
View File
@@ -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();
+2 -2
View File
@@ -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}",
+28
View File
@@ -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.
+7 -1
View File
@@ -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);
}
}
+27
View File
@@ -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]>>) {