graphic design tool
git clone https://git.lucas.co/cce-designer.git
feat: Session becomes the root meta node; Guides gains Point Marker Size
The session-wide settings container is now typed and named 'meta' — the
root network's counterpart of every node's per-node meta child, still a
subnet holding Main/View/Guides/Render. ensure_menubar_subnets retypes an
old save's 'session' container in place (children and params intact) on
load; the delete gates, in_settings_dir, the settings reader, and the MCP
delete refusal all accept meta (keeping legacy session for pre-migration
robustness). ensure_meta_on already skips meta-typed nodes, so the
settings tree stays free of nested per-node metas by construction.
Guides gains 'Point Marker Size' (spinbox, thousandths of a world unit,
default 20 = 0.02): it drives the per-node meta Point Markers overlay
through State::meta_marker_size, decoupled from the Render node's Point
Size, and a change re-collects the overlays immediately.
CLAUDE.md | 29 +++++++++++++++++------------
src/app.rs | 10 +++++++---
src/main.rs | 54 +++++++++++++++++++++++++++++++++++++++++-------------
src/project.rs | 41 +++++++++++++++++++++++++++++++----------
src/render.rs | 2 +-
src/window.rs | 7 +++++--
6 files changed, 102 insertions(+), 41 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index c9eba88..ecb0a4e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -162,18 +162,23 @@ The `zcce_inspector_v1` integration (window-position tracking + widget-state
streaming to cce-test-interface) was dropped in the engine migration; the HTTP API
is the introspection surface.
-### The Session node
-
-Session-wide settings live under one permanent root node: `Session` (node type
-`session`) contains the Main/View/Guides/Render utility subnets that used to
-sit flat in `/`. `ensure_menubar_subnets` creates it and MIGRATES root-level
-settings nodes from older saves into it (moved, not recreated — params
-survive). It cannot be deleted: `delete_node` refuses the `session` type (the
-one gate every deletion route funnels through), the context menu omits Delete,
-and the graph draws it without a geometry toggle. `State::session_node()` /
-`in_settings_dir()` are the accessors — the latter walks the whole
-`current_path`, since a first-segment check stopped working the day the
-settings nodes gained a parent.
+### The root meta node (nee Session)
+
+Session-wide settings live under one permanent root node: `meta` (node type
+`meta` — retyped/renamed from the old `Session`/`session` on load, children
+intact) contains the Main/View/Guides/Render utility subnets that used to
+sit flat in `/`. It is the root network's counterpart of every node's
+per-node `meta` child, but still a subnet. `ensure_menubar_subnets` creates
+it and MIGRATES older saves into it (root-level settings nodes moved, not
+recreated — params survive; a `session`-typed container retypes in place).
+It cannot be deleted: `delete_node` refuses the `meta` (and legacy
+`session`) type — the one gate every deletion route funnels through — the
+context menu omits Delete, and the graph draws it without a geometry
+toggle. `State::session_node()` / `in_settings_dir()` are the accessors —
+the latter walks the whole `current_path`, since a first-segment check
+stopped working the day the settings nodes gained a parent. Guides holds
+"Point Marker Size" (thousandths of a world unit), driving the per-node
+meta Point Markers overlay via `State::meta_marker_size`.
### The meta node (per-node preferences)
diff --git a/src/app.rs b/src/app.rs
index c37cc1b..0dfaba8 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -1083,6 +1083,9 @@ pub struct State {
/// triangles, drawn as a wire pass over the scene fill.
pub meta_wire_verts: Vec<Vertex3D>,
pub meta_wire_count: u32,
+ /// World-unit radius of the meta "Point Markers" overlay — the Guides
+ /// subnet's "Point Marker Size" control (stored there in thousandths).
+ pub meta_marker_size: f32,
/// The raster scene's model-view-projection and the viewport pane rect in
/// LOGICAL px, cached at staging so the 2D pass can project 3D overlays.
pub last_scene_mvp: Option<Mat4>,
@@ -1553,11 +1556,11 @@ impl State {
/// settings nodes (Main/View/Guides/Render). `ensure_menubar_subnets`
/// guarantees it exists, so `None` only before the first ensure.
pub fn session_node(&self) -> Option<&FsNode> {
- self.fs_root.children.iter().find(|c| c.node_type == "session")
+ self.fs_root.children.iter().find(|c| c.node_type == "meta")
}
pub fn session_node_mut(&mut self) -> Option<&mut FsNode> {
- self.fs_root.children.iter_mut().find(|c| c.node_type == "session")
+ self.fs_root.children.iter_mut().find(|c| c.node_type == "meta")
}
/// Is the network currently inside a settings directory (the Session node
@@ -1569,7 +1572,7 @@ impl State {
for &idx in &self.current_path {
match node.children.get(idx) {
Some(child) => {
- if matches!(child.node_type.as_str(), "utility" | "session") {
+ if matches!(child.node_type.as_str(), "utility" | "session" | "meta") {
return true;
}
node = child;
@@ -3262,6 +3265,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
meta_number_labels: Vec::new(),
meta_wire_verts: Vec::new(),
meta_wire_count: 0,
+ meta_marker_size: 0.02,
last_scene_mvp: None,
last_scene_view_rect: (0.0, 0.0, 0.0, 0.0),
last_viewport_rt_mode: false,
diff --git a/src/main.rs b/src/main.rs
index db73fbc..6b52605 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -463,25 +463,26 @@ mod tests {
}
}
- /// The settings nodes live inside the permanent Session node now; tests
- /// that need Main resolve it through there.
+ /// The settings nodes live inside the permanent root meta node (nee
+ /// Session) now; tests that need Main resolve it through there.
fn session_and_main(state: &State) -> (usize, usize) {
- let s_idx = state.fs_root.children.iter().position(|c| c.node_type == "session").expect("Session node");
+ let s_idx = state.fs_root.children.iter().position(|c| c.node_type == "meta").expect("root meta node");
let m_idx = state.fs_root.children[s_idx].children.iter().position(|c| c.name == "Main").expect("Main inside Session");
(s_idx, m_idx)
}
- /// The Session node: exists at root, typed "session", holds exactly the
- /// four settings nodes, and refuses deletion through the one gate every
- /// deletion route funnels into.
+ /// The root meta node (nee Session): exists at root, typed "meta" but
+ /// still a subnet, holds exactly the four settings nodes, and refuses
+ /// deletion through the one gate every deletion route funnels into.
#[test]
fn test_session_node_exists_and_cannot_be_deleted() {
let mut state = State::new(false);
state.ensure_menubar_subnets();
- let s_idx = state.fs_root.children.iter().position(|c| c.node_type == "session").expect("Session node at root");
+ let s_idx = state.fs_root.children.iter().position(|c| c.node_type == "meta").expect("root meta node");
let session = &state.fs_root.children[s_idx];
- assert_eq!(session.name, "Session");
+ assert_eq!(session.name, "meta");
+ assert!(session.is_enterable(), "the root meta stays a subnet");
let names: Vec<&str> = session.children.iter().map(|c| c.name.as_str()).collect();
for expected in ["Main", "View", "Guides", "Render"] {
assert!(names.contains(&expected), "Session is missing {expected}: {names:?}");
@@ -495,9 +496,36 @@ mod tests {
}
let before = state.fs_root.children.len();
- assert!(!state.delete_node(s_idx), "delete_node deleted the Session node");
- assert_eq!(state.fs_root.children.len(), before, "Session vanished anyway");
- assert!(state.fs_root.children[s_idx].node_type == "session");
+ assert!(!state.delete_node(s_idx), "delete_node deleted the root meta node");
+ assert_eq!(state.fs_root.children.len(), before, "root meta vanished anyway");
+ assert!(state.fs_root.children[s_idx].node_type == "meta");
+
+ // An old save's "session"-typed container retypes to meta in place,
+ // children intact.
+ state.fs_root.children[s_idx].node_type = "session".to_string();
+ state.fs_root.children[s_idx].name = "Session".to_string();
+ state.ensure_menubar_subnets();
+ let s_idx = state.fs_root.children.iter().position(|c| c.node_type == "meta")
+ .expect("session retyped to meta");
+ assert_eq!(state.fs_root.children[s_idx].name, "meta");
+ let names: Vec<&str> =
+ state.fs_root.children[s_idx].children.iter().map(|c| c.name.as_str()).collect();
+ for expected in ["Main", "View", "Guides", "Render"] {
+ assert!(names.contains(&expected), "retype lost {expected}: {names:?}");
+ }
+
+ // Guides carries the Point Marker Size control (thousandths), and
+ // applying the settings drives the overlay size.
+ {
+ let guides = state.fs_root.children[s_idx].children.iter_mut()
+ .find(|c| c.name == "Guides").unwrap();
+ let p = guides.params.iter_mut().find(|p| p.name == "Point Marker Size")
+ .expect("Guides has Point Marker Size");
+ assert_eq!(p.default, "20", "default = 0.02 world units");
+ p.default = "50".to_string();
+ }
+ state.apply_settings_from_menubar_subnets();
+ assert!((state.meta_marker_size - 0.05).abs() < 1e-6);
}
/// An old save carries Main/View/Guides/Render at the root with the user's
@@ -513,7 +541,7 @@ mod tests {
// synced toggles are rewritten from app state by design, so only a
// foreign param can distinguish MOVED (probe survives) from RECREATED
// (probe gone).
- let s_idx = state.fs_root.children.iter().position(|c| c.node_type == "session").unwrap();
+ let s_idx = state.fs_root.children.iter().position(|c| c.node_type == "meta").unwrap();
let mut session = state.fs_root.children.remove(s_idx);
for mut child in session.children.drain(..) {
if child.name == "Guides" {
@@ -532,7 +560,7 @@ mod tests {
}
state.ensure_menubar_subnets();
- let s_idx = state.fs_root.children.iter().position(|c| c.node_type == "session").expect("Session recreated");
+ let s_idx = state.fs_root.children.iter().position(|c| c.node_type == "meta").expect("root meta recreated");
let guides = state.fs_root.children[s_idx].children.iter().find(|c| c.name == "Guides").expect("Guides migrated in");
let v = guides.params.iter().find(|p| p.name == "migration probe").map(|p| p.default.as_str());
assert_eq!(v, Some("survived"), "migration recreated Guides instead of moving it");
diff --git a/src/project.rs b/src/project.rs
index 973173d..5453216 100644
--- a/src/project.rs
+++ b/src/project.rs
@@ -401,11 +401,13 @@ impl State {
// Retain only the Main utility subnet, removing the rest
self.fs_root.children.retain(|c| c.name != "Network" && c.name != "Viewport" && c.name != "Parameters" && c.name != "Spreadsheet");
- // The Session node: the permanent root container for the session-wide
- // settings nodes (Main/View/Guides/Render). Older saves carried the
- // four at the root — they are MOVED in, params intact, so an old
- // project's values survive as the seeds. The node itself is
- // undeletable (delete_node refuses the "session" type).
+ // The root meta node: the permanent root container for the
+ // session-wide settings nodes (Main/View/Guides/Render) — the root
+ // network's counterpart of every node's per-node `meta` child, and
+ // still a subnet. It began life as the "Session" node; older saves
+ // carry it typed "session" (or the four settings nodes flat at the
+ // root) and are migrated — retyped/renamed, params intact. The node
+ // itself is undeletable (delete_node refuses the "meta" type).
let mut migrated: Vec<FsNode> = Vec::new();
{
let mut idx = 0;
@@ -420,16 +422,22 @@ impl State {
}
}
}
- let session_idx = match self.fs_root.children.iter().position(|c| c.node_type == "session" || c.name == "Session") {
+ let session_idx = match self
+ .fs_root
+ .children
+ .iter()
+ .position(|c| matches!(c.node_type.as_str(), "session" | "meta") || c.name == "Session")
+ {
Some(i) => {
- self.fs_root.children[i].node_type = "session".to_string();
+ self.fs_root.children[i].node_type = "meta".to_string();
+ self.fs_root.children[i].name = "meta".to_string();
i
}
None => {
self.fs_root.children.push(FsNode {
id: crate::app::generate_node_id(),
- name: "Session".to_string(),
- node_type: "session".to_string(),
+ name: "meta".to_string(),
+ node_type: "meta".to_string(),
children: vec![],
params: vec![],
geometry_visible: true,
@@ -679,6 +687,10 @@ impl State {
ensure_param(guides_node, "Origin Guide Size", "spinbox", &origin_size_seed, &[], Some(1.0), Some(50.0), Some(1.0));
let grid_color_seed = migrated_grid_color.unwrap_or_else(|| color_to_hex(vp_grid_color));
ensure_param(guides_node, "Grid Color", "color", &grid_color_seed, &[], None, None, None);
+ // Size of the per-node meta "Point Markers" overlay, in thousandths
+ // (the Grid Thickness convention): 20 = 0.02 world units.
+ let marker_size_seed = ((self.meta_marker_size * 1000.0).round() as i32).to_string();
+ ensure_param(guides_node, "Point Marker Size", "spinbox", &marker_size_seed, &[], Some(5.0), Some(100.0), Some(1.0));
for p in guides_node.params.iter_mut() {
match p.name.as_str() {
"Show Grid Guide" => set_toggle(p, vp_show_grid),
@@ -808,7 +820,7 @@ impl State {
let session_params = |root: &FsNode, name: &str| -> Option<Vec<ParamDef>> {
root.children
.iter()
- .find(|c| c.node_type == "session")
+ .find(|c| c.node_type == "meta")
.and_then(|s| s.children.iter().find(|c| c.name == name))
.map(|n| n.params.clone())
};
@@ -821,6 +833,15 @@ impl State {
"Grid Thickness" => if let Ok(val) = p.default.parse::<f32>() { self.grid_thickness = val / 1000.0; }
"Origin Guide Size" => if let Ok(val) = p.default.parse::<f32>() { self.origin_size = val / 10.0; }
"Grid Color" => if let Some(col) = hex_to_color(&p.default) { self.viewport_mut().grid_color = col; }
+ "Point Marker Size" => if let Ok(val) = p.default.parse::<f32>() {
+ let size = val / 1000.0;
+ if (size - self.meta_marker_size).abs() > 1e-6 {
+ self.meta_marker_size = size;
+ // The marker geometry bakes the radius in, so a
+ // size change re-collects the overlays.
+ self.rebuild_scene_geometry();
+ }
+ }
_ => {}
}
}
diff --git a/src/render.rs b/src/render.rs
index c0d5669..6e4c13b 100644
--- a/src/render.rs
+++ b/src/render.rs
@@ -858,7 +858,7 @@ impl State {
let mut sim_cache = std::mem::take(&mut self.sim_cache);
let (markers, labels, wires) = {
let mut sim = crate::geometry::EvalSim::new(frame, start, &mut sim_cache);
- collect_meta_overlays(&self.fs_root, self.point_size, &mut sim)
+ collect_meta_overlays(&self.fs_root, self.meta_marker_size, &mut sim)
};
self.sim_cache = sim_cache;
self.meta_marker_verts = markers;
diff --git a/src/window.rs b/src/window.rs
index 4b5f157..9e6954c 100644
--- a/src/window.rs
+++ b/src/window.rs
@@ -813,9 +813,12 @@ impl State {
}
McpAction::DeleteNode { slot } => {
if slot < state.current_dir().children.len()
- && state.current_dir().children[slot].node_type == "session"
+ && matches!(
+ state.current_dir().children[slot].node_type.as_str(),
+ "session" | "meta"
+ )
{
- return Err("The Session node is permanent and cannot be deleted".to_string());
+ return Err("Meta nodes are permanent and cannot be deleted".to_string());
}
if state.delete_node(slot) {
// delete_node clears/shifts the selection; the param pane