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
@@ -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]>>) {
|
||||
|
||||
Reference in new issue
Block a user