git.lucas.co / cce-designer
graphic design tool
git clone https://git.lucas.co/cce-designer.git

commitc1581aad58b0b3711755cf47e16c777f3bb5409e
parentc0a8822fdd
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-02 14:29
feat: the root is the object level; geometry goes in a Geometry node

A new `geometry` node (nodes/geometry.json) is Houdini's geometry
object: it stands at the root, and every operator - generators,
modifiers, subnets, simnets, repeats, the subnet templates - is placed
inside one. Cameras are object-level too; pages and export may stand
anywhere. src/context.rs holds the rule.

- Add Node lists only what fits the level; MCP add_node refuses with
  the reason; a paste that does not fit is refused whole.
- A Geometry node draws like a subnet seen from outside, evaluates to
  its displayed child, and its display flag is exclusive with nothing:
  several objects show at once at the root.
- Cameras are looked up at the root from every level, so diving into a
  Geometry node keeps the active camera (it used to drop to Default).
- Format 5 migrates an older save: root operators move, in order, into
  a new geometry1; cross-level channel paths are re-pointed; the view
  opens inside on the node that was selected.

Checked on the user's project and both bundled ones: the old build and
this one export the same mesh at frames 1, 30 and 120.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md           |  78 ++++++++++-
 nodes/geometry.json |   7 +
 src/api.rs          |   2 +-
 src/app.rs          |  81 +++++++++---
 src/context.rs      | 312 +++++++++++++++++++++++++++++++++++++++++++
 src/dialog.rs       |  26 ++--
 src/edit_history.rs |   2 +-
 src/geometry.rs     |  22 +++
 src/image_tools.rs  |   2 +-
 src/main.rs         | 375 ++++++++++++++++++++++++++++++++++++++++------------
 src/project.rs      |  16 ++-
 src/window.rs       |  10 +-
 12 files changed, 806 insertions(+), 127 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 13ed1d9..d64650a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -115,7 +115,7 @@ matches, ran the wrong item (the header's Save opened a project).
 **The cameras and the parameter reset are commands too** (the same day),
 so the palette reaches them and a chord can: `next_camera` /
 `previous_camera` step through `State::camera_names` (the Default Camera,
-then the level's camera nodes) and wrap, `default_camera` goes back to it,
+then the root's camera nodes — see "The root is the object level") and wrap, `default_camera` goes back to it,
 and each camera NODE is a row of the palette — `Camera: camera1`, id under
 `CAMERA_ROW_PREFIX`, ranked among the commands as the viewport's, with
 `active` in the chord column of the one in use. They are rows and not
@@ -374,6 +374,9 @@ gone from cce-ui with the wgpu path).
   its top edge, so a mode line there lands under the collapsed stubs. It exists
   because a viewer state changes what every click does and snapping silently
   changes what a drag does.
+- `src/context.rs` — where a node may stand: the object level and the
+  geometry context, the placement rule Add Node, paste and MCP hold, and
+  the format-5 migration. See "The root is the object level".
 - `src/param.rs` — node parameters: `ParamDef` (text and parsed value kept
   together, both private), `ParamKind`, `ParamValue`, `ParamSlot`. See
   "Parameter kinds and typed values".
@@ -389,6 +392,68 @@ 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 root is the object level; geometry goes in a Geometry node (since 2026-10-02)
+
+Houdini's `/obj` and its geometry objects. The root holds **Geometry**
+nodes (`nodes/geometry.json`, type `geometry`), cameras and pages; every
+operator — generators, modifiers, subnets, simnets, repeats, the subnet
+templates — stands inside a Geometry node, at any depth. Until this every
+node could stand anywhere and the root was one big geometry level.
+`src/context.rs` is the whole rule.
+
+- **Placement is by node type** (`context::placement`): Object (the
+  `geometry` container and `camera`, root only), Any (the page nodes, which
+  are a 2D context of their own and stay where they always could, and
+  `export`, which writes a page or a mesh), Geometry (everything else, so a
+  new node type is a geometry operator without a line anywhere). A level's
+  context is `context_at(path)`: the root is Object, every level under it
+  Geometry — since a subnet is an operator, the root's only enterable nodes
+  are Geometry nodes. No Geometry node inside a Geometry node.
+- **Held where a node arrives**: the Add Node list shows only what fits the
+  level (`refresh_dialog_rows`), MCP's `add_node` refuses with the reason on
+  the status line (`context::refusal`), and a paste that does not fit is
+  refused WHOLE ("Not pasted: …") rather than pasted in part with its wires
+  cut. NOT held by the evaluator: a hand-built tree with a sphere at the
+  root still draws, which is what keeps the suite's fixtures meaning what
+  they meant.
+- **A Geometry node draws like a subnet seen from outside**: the scene walk
+  goes in and draws its children by their flags, so dived in or not it shows
+  its displayed node. Evaluated directly (an export at the root, `--export
+  --node`, the spreadsheet) it is its displayed child, as a Houdini object
+  is its display SOP. Its own flag is exclusive with nothing: several
+  objects show at once at the root, and showing one turns no other off.
+- **Cameras stand at the root and are seen from every level**
+  (`State::camera_level`). Until this a camera was looked up on the
+  CURRENT level — `camera_names`, the pose, Frame All, the orbit and pan
+  write-back, the rename — so diving into a subnet silently dropped to the
+  Default Camera view; with all geometry one level down that would have
+  been every working view. `frame_all_frames_the_root_camera_from_inside_a_subnet`.
+- **Format 5 migrates an older save** (`context::wrap_root_geometry`, in
+  `Project::migrate_format`, so on every load path): every root child that
+  is an operator goes, in order, with its position, flags and wires, into
+  one new `geometry1` at the root on a free cell, shown. Cameras, pages, the
+  retired `meta` / `session` / `utility` nodes (whose own migration runs
+  after and finds them there) and an export reading a root page stay. A
+  wire needs nothing — wires look among siblings first and the siblings came
+  along — but a CHANNEL PATH does: one that reaches into the moved nodes
+  absolutely (`/sphere1/radius` → `/geometry1/sphere1/radius`), or crosses
+  between them and the root relatively (`../camera1/pivot.x` from a moved
+  node → `../../camera1/pivot.x`), is resolved in the old tree and written
+  again from where its holder now stands; paths that do not cross are left
+  as written. The view follows: an editor at the root opens inside the new
+  node on the node it had selected (unless that was a camera or a page), and
+  a path into a moved subnet goes through it. The meta migration re-homes a
+  subnet it finds in the root's first Geometry node (`context::geometry_home`).
+  The bundled `default_project.json` and `project.json` are NOT rewritten on
+  disk: they carry no format and migrate on every load, so the suite's
+  `State::new` opens inside `geometry1` (`test_prelude::geo` reaches it).
+  Checked on the user's project and both bundled ones: the old build and the
+  new export the same mesh at frames 1, 30 and 120.
+
+`an_older_save_puts_its_geometry_in_a_geometry_node`,
+`geometry_nodes_at_the_root_each_show_their_own` and
+`the_add_node_list_offers_what_belongs_at_the_level` are the tests.
+
 ### There are no meta nodes (retired 2026-09-23)
 
 Two different things were called `meta`, and both are gone. What replaced
@@ -466,7 +531,8 @@ inferring one would turn a single node's preference into a setting over the
 whole scene.
 
 Gone with them: `session_node()`, `in_settings_dir()` (there is no settings
-directory, so Add Node offers every template everywhere), `write_meta_toggle`
+directory; what Add Node offers is now the level's CONTEXT — see "The root
+is the object level"), `write_meta_toggle`
 and its two wrappers, `refresh_main_node_live_toggles`,
 `update_recent_files_layout`, the `utility` / `session` / `meta` node types,
 the undeletable-node gate in `delete_node`, and `layout.rs`'s pinning (whose
@@ -1056,7 +1122,9 @@ not `@UVW`) — and never a choice row, so the Sphere's Method keeps its
 `UV` option. Once, by the version, so an attribute someone names `Norm` or
 `UV` afterwards is theirs (`a_save_naming_norm_or_uv_names_n_or_uv`).
 **Format 4** (the same day): parameter names are identifiers — see "A
-parameter has a name and a label". Templates go through
+parameter has a name and a label". **Format 5** (2026-10-02): the root is
+the object level and an older save's geometry goes into a Geometry node —
+see "The root is the object level". Templates go through
 `infer_template_exprs` instead: a default that READS as a reference is one
 (`embryo.json` says `chf("../radius")` now). The same inference applies to a
 value typed into a plain row or scripted through `set_param`: a reference
@@ -2230,7 +2298,9 @@ print first.
 **The display flag is exclusive within its CONTEXT**
 (`set_child_geometry_visible`): the page nodes and the geometry nodes each
 have one, so a level shows one image and one geometry. One flag over both
-is what made showing a picture hide the model.
+is what made showing a picture hide the model. A Geometry NODE's flag is
+its own and exclusive with nothing (since 2026-10-02): at the root several
+objects draw at once.
 
 **The image commands** (`src/image_tools.rs`, all registry rows):
 `frame_image` (Ctrl+Shift+F, and a viewport-menu row while an image shows)
diff --git a/nodes/geometry.json b/nodes/geometry.json
new file mode 100644
index 0000000..389fc46
--- /dev/null
+++ b/nodes/geometry.json
@@ -0,0 +1,7 @@
+{
+  "name": "Geometry",
+  "type": "geometry",
+  "inputs": 0,
+  "outputs": 0,
+  "params": []
+}
diff --git a/src/api.rs b/src/api.rs
index 867041e..aff02b3 100644
--- a/src/api.rs
+++ b/src/api.rs
@@ -110,7 +110,7 @@ pub(crate) fn mcp_tools() -> Vec<McpTool> {
         ),
         tool(
             "add_node",
-            "Add a node from a template (e.g. \"Sphere\") at grid position (x, y) in the current network level.",
+            "Add a node from a template (e.g. \"Sphere\") at grid position (x, y) in the current network level. The root holds Geometry nodes, cameras and pages; every operator goes inside a Geometry node, and a template that does not belong at the level is refused.",
             json!({
                 "type": "object",
                 "properties": {
diff --git a/src/app.rs b/src/app.rs
index f33f694..bf9cbe4 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -169,7 +169,7 @@ impl FsNode {
     /// of them learned about new container types: subnet-like types by name,
     /// otherwise anything that actually has children.
     pub fn is_enterable(&self) -> bool {
-        matches!(self.node_type.as_str(), "node" | "simnet" | "repeat")
+        matches!(self.node_type.as_str(), "node" | "simnet" | "repeat" | crate::context::GEOMETRY)
             || !self.children.is_empty()
     }
 
@@ -184,13 +184,23 @@ impl FsNode {
     /// geometry nodes each have a display flag of their own, so a level shows
     /// one image and one geometry — a picture behind the model drawn over
     /// it. Until then a page took the viewport's pane whole and one flag did.
+    ///
+    /// A GEOMETRY CONTAINER's flag is its own (since 2026-10-02): the root is
+    /// the object level, where each geometry node is shown or not, as
+    /// Houdini's objects are, and several draw at once. Showing one leaves
+    /// its siblings as they are, and showing anything else leaves it.
     pub fn set_child_geometry_visible(&mut self, slot: usize, visible: bool) {
         if slot >= self.children.len() {
             return;
         }
-        if visible {
+        if visible && crate::context::is_geometry_container(&self.children[slot].node_type) {
+            self.children[slot].geometry_visible = true;
+        } else if visible {
             let page = crate::page::is_page_node(&self.children[slot].node_type);
             for (i, child) in self.children.iter_mut().enumerate() {
+                if crate::context::is_geometry_container(&child.node_type) {
+                    continue;
+                }
                 if crate::page::is_page_node(&child.node_type) == page {
                     child.geometry_visible = i == slot;
                 }
@@ -369,7 +379,10 @@ pub struct Project {
     /// when a bare `ch("Name")` meant the PARENT's parameter; 1 is Houdini's
     /// semantics, where it means the node's own; 2 (2026-10-01) is the
     /// generators' normal attribute called `N` where it was `Norm`, and 3
-    /// (the same day) their texture coordinates `uv` where they were `UV`.
+    /// (the same day) their texture coordinates `uv` where they were `UV`;
+    /// 4 (the same day) parameter names as identifiers; 5 (2026-10-02) the
+    /// root as the object level, its geometry inside a `geometry` node
+    /// (`context::wrap_root_geometry`).
     /// `migrate_format` takes a file through each step it is behind, and a
     /// step must not run twice.
     #[serde(default)]
@@ -377,7 +390,7 @@ pub struct Project {
 }
 
 /// The format `Project` saves in — see its `format` field.
-pub const PROJECT_FORMAT: u32 = 4;
+pub const PROJECT_FORMAT: u32 = 5;
 
 /// One entry in a node's right-click context menu, parallel to the visible
 /// labels shown via `context_menu::show`.
@@ -1257,6 +1270,9 @@ impl Project {
         if self.format < 4 {
             self.migrate_param_names();
         }
+        if self.format < 5 {
+            crate::context::wrap_root_geometry(self);
+        }
         self.format = PROJECT_FORMAT;
     }
 
@@ -3637,7 +3653,7 @@ impl State {
         let fmt3 = |v: Vec3| format!("{:.4}:{:.4}:{:.4}", v.x, v.y, v.z);
         let exact = self.pan_exact.take();
         let node = (active != "Default Camera")
-            .then(|| self.current_dir_mut().children.iter_mut().find(|c| c.node_type == "camera" && c.name == active))
+            .then(|| self.camera_level_mut().children.iter_mut().find(|c| c.node_type == "camera" && c.name == active))
             .flatten();
         match node {
             Some(node) => {
@@ -3797,12 +3813,25 @@ impl State {
         self.active_camera = name;
     }
 
-    /// The cameras this level offers, the Default Camera first: what the
+    /// The level camera nodes stand on: the root, the object level
+    /// (`context`). One level for every view, since 2026-10-02 — a camera
+    /// was looked up on the CURRENT level until then, so diving into a
+    /// subnet lost it, and with the geometry inside a Geometry node that
+    /// would be every working view.
+    pub fn camera_level(&self) -> &FsNode {
+        &self.fs_root
+    }
+
+    pub fn camera_level_mut(&mut self) -> &mut FsNode {
+        &mut self.fs_root
+    }
+
+    /// The cameras the scene offers, the Default Camera first: what the
     /// camera commands step through and the palette's camera rows list.
     pub fn camera_names(&self) -> Vec<String> {
         let mut names = vec!["Default Camera".to_string()];
         names.extend(
-            self.current_dir()
+            self.camera_level()
                 .children
                 .iter()
                 .filter(|c| c.node_type == "camera")
@@ -5451,14 +5480,13 @@ impl State {
         // touching the pane edges.
         let dist = (radius / half.sin()) * 1.25;
 
-        // A camera node applies in the directory it lives in (the render
-        // looks it up there): a named camera that is not in THIS directory
-        // is the Default Camera view, and is framed as one. Before, this
-        // silently did nothing — inside a subnet, Frame All was a no-op.
+        // A camera node stands at the root and applies from every level;
+        // a named camera that is not there is the Default Camera view, and
+        // is framed as one.
         let camera_name = self.active_camera.clone();
         let mut framed_node = false;
         if self.active_camera != "Default Camera" {
-            let dir = self.current_dir_mut();
+            let dir = self.camera_level_mut();
             if let Some(node) = dir.children.iter_mut().find(|c| c.node_type == "camera" && c.name == camera_name) {
                 framed_node = true;
                 let parse3 = |s: &str| -> Option<Vec3> {
@@ -5549,12 +5577,12 @@ impl State {
     /// very large or small unit — then the readout shows what was reached.
     pub fn view_one_to_one(&mut self) {
         let dist = self.one_to_one_distance();
-        // As in `frame_all`: a named camera not in this directory is the
+        // As in `frame_all`: a named camera that is not there is the
         // Default Camera view, and is fitted as one.
         let camera_name = self.active_camera.clone();
         let mut fitted_node = false;
         if self.active_camera != "Default Camera" {
-            let dir = self.current_dir_mut();
+            let dir = self.camera_level_mut();
             if let Some(node) = dir.children.iter_mut().find(|c| c.node_type == "camera" && c.name == camera_name) {
                 fitted_node = true;
                 let parse3 = |s: &str| -> Option<Vec3> {
@@ -6743,10 +6771,10 @@ impl State {
     pub fn rename_node(&mut self, id: &str, typed: &str) -> Result<String, String> {
         let new = self.rename_check(id, typed)?;
         let old = crate::viewer_state::find_node_by_id(&self.fs_root, id).map(|n| n.name.clone()).unwrap_or_default();
-        // The active camera is looked up where it applies, the current
-        // level, and is this node only if this node is there.
+        // The active camera is looked up where it stands, the root, and is
+        // this node only if this node is there.
         let is_camera = self
-            .current_dir()
+            .camera_level()
             .children
             .iter()
             .any(|c| c.id == id && c.node_type == "camera" && c.name == self.active_camera);
@@ -6906,7 +6934,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
             return false;
         }
         let camera_name = self.active_camera.clone();
-        let dir = self.current_dir_mut();
+        let dir = self.camera_level_mut();
         if let Some(node) = dir.children.iter_mut().find(|c| c.node_type == "camera" && c.name == camera_name) {
             // The camera's base pitch above the horizon (Position vs Pivot): the
             // Rotation.x clamp below is on the TOTAL pitch, matching get_matrices'
@@ -6967,7 +6995,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
             return false;
         }
         let camera_name = self.active_camera.clone();
-        let dir = self.current_dir_mut();
+        let dir = self.camera_level_mut();
         if let Some(node) = dir.children.iter_mut().find(|c| c.node_type == "camera" && c.name == camera_name) {
             if let Some(p) = node.params.iter_mut().find(|p| p.name == "rotation") {
                 p.set_text("0.00:0.00:0.00".to_string());
@@ -7161,7 +7189,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
         use cce_ui::widget::PathController as _;
         self.slots.breadcrumb2.set_path(&names2);
 
-        let camera_nodes: Vec<String> = self.current_dir().children.iter()
+        let camera_nodes: Vec<String> = self.camera_level().children.iter()
             .filter(|c| c.node_type == "camera")
             .map(|c| c.name.clone())
             .collect();
@@ -9277,6 +9305,17 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
         if self.node_clipboard.is_empty() {
             return false;
         }
+        // Whole or not at all: a set that half belongs here is pasted
+        // nowhere, rather than as the part that does with its wires cut.
+        let here = crate::context::context_at(&self.current_path);
+        if let Some(why) = self
+            .node_clipboard
+            .iter()
+            .find_map(|n| crate::context::refusal(&n.name, &n.node_type, here))
+        {
+            self.update_status_text(&format!("Not pasted: {why}"));
+            return false;
+        }
         let origin = self.node_clipboard.iter().fold((f32::MAX, f32::MAX), |(x, y), n| {
             (x.min(n.position.0), y.min(n.position.1))
         });
@@ -12093,7 +12132,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
         let mut ry = 0.0f32;
         let mut rz = 0.0f32;
         if self.active_camera != "Default Camera" {
-            if let Some(node) = self.current_dir().children.iter().find(|c| c.node_type == "camera" && c.name == self.active_camera) {
+            if let Some(node) = self.camera_level().children.iter().find(|c| c.node_type == "camera" && c.name == self.active_camera) {
                 let mut cx = 2.5f32;
                 let mut cy = 1.8f32;
                 let mut cz = 2.5f32;
diff --git a/src/context.rs b/src/context.rs
new file mode 100644
index 0000000..e11a681
--- /dev/null
+++ b/src/context.rs
@@ -0,0 +1,312 @@
+//! Where a node may stand: the OBJECT level and the GEOMETRY context.
+//!
+//! The root is the object level, Houdini's `/obj`. Geometry is not built
+//! there: it is built inside a `geometry` node, a container that stands at
+//! the root and is what the root draws, and everything that makes or
+//! changes geometry is placed inside one (at any depth — a subnet, a simnet
+//! or a repeat inside a geometry node is in its context too). Since
+//! 2026-10-02; until then every node could stand anywhere and the root was
+//! one big geometry level.
+//!
+//! Three placements, by node type ([`placement`]):
+//!
+//! - **Object** — the root only: the `geometry` container itself, and
+//!   cameras, which are seen from every level (`State::camera_level`).
+//! - **Geometry** — inside a geometry node only: every operator, subnets,
+//!   simnets, repeats and the subnet templates (the Embryo, the Remesh).
+//! - **Any** — the page nodes, which are a 2D context of their own and stay
+//!   where they always could, and `export`, which writes a page or a mesh.
+//!
+//! The rule is held where a node ARRIVES at a level — Add Node, paste, MCP's
+//! `add_node` — and by the load ([`wrap_root_geometry`], format 5). It is not
+//! held by the evaluator: a hand-built tree with a sphere at the root still
+//! draws, which is what keeps the suite's fixtures meaning what they meant.
+
+use crate::app::{FsNode, ParamKind, Project};
+
+/// The container's node type.
+pub const GEOMETRY: &str = "geometry";
+
+/// What a level is for.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum Context {
+    Object,
+    Geometry,
+}
+
+/// Where a node of some type may stand.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum Placement {
+    Object,
+    Geometry,
+    Any,
+}
+
+pub fn is_geometry_container(node_type: &str) -> bool {
+    node_type.eq_ignore_ascii_case(GEOMETRY)
+}
+
+/// Where a node of `node_type` may stand. Everything not named is an
+/// operator, so a new node type is a geometry node without a line here.
+pub fn placement(node_type: &str) -> Placement {
+    if is_geometry_container(node_type) || node_type.eq_ignore_ascii_case("camera") {
+        Placement::Object
+    } else if crate::page::is_page_node(node_type) || node_type.eq_ignore_ascii_case("export") {
+        Placement::Any
+    } else {
+        Placement::Geometry
+    }
+}
+
+/// The context of the level a network editor's path addresses. The root is
+/// the object level and every level under it is inside a geometry node:
+/// the root's only enterable nodes are geometry containers, since a subnet
+/// is a geometry node. (A hand-built tree with a subnet at the root is in
+/// the geometry context inside it too, which is what it was built to be.)
+pub fn context_at(path: &[usize]) -> Context {
+    if path.is_empty() { Context::Object } else { Context::Geometry }
+}
+
+pub fn fits(placement: Placement, context: Context) -> bool {
+    match placement {
+        Placement::Any => true,
+        Placement::Object => context == Context::Object,
+        Placement::Geometry => context == Context::Geometry,
+    }
+}
+
+/// Why `label` (a template's label or a node's name) cannot go into a level
+/// of `context` — the status line's text — or None when it can.
+pub fn refusal(label: &str, node_type: &str, context: Context) -> Option<String> {
+    match placement(node_type) {
+        p if fits(p, context) => None,
+        Placement::Geometry => Some(format!("{label} goes inside a Geometry node: add one here and dive in")),
+        _ => Some(format!("{label} goes at the root")),
+    }
+}
+
+/// The root's first geometry container, made when there is none: where a
+/// geometry node that has arrived at the root is re-homed. Its slot.
+pub fn geometry_home(root: &mut FsNode) -> usize {
+    if let Some(i) = root.children.iter().position(|c| is_geometry_container(&c.node_type)) {
+        return i;
+    }
+    let name = unused_name(root, "geometry");
+    let position = free_cell(root, (0.0, 0.0));
+    root.children.push(container(name, position));
+    root.children.len() - 1
+}
+
+fn container(name: String, position: (f32, f32)) -> FsNode {
+    FsNode {
+        id: crate::app::generate_node_id(),
+        name,
+        node_type: GEOMETRY.to_string(),
+        children: Vec::new(),
+        params: Vec::new(),
+        geometry_visible: true,
+        bypassed: false,
+        position,
+        inputs: 0,
+        outputs: 0,
+    }
+}
+
+fn unused_name(level: &FsNode, base: &str) -> String {
+    (1..)
+        .map(|i| format!("{base}{i}"))
+        .find(|n| !level.children.iter().any(|c| &c.name == n))
+        .unwrap()
+}
+
+/// The first cell at or right of `at` no child of `level` stands on.
+pub fn free_cell(level: &FsNode, at: (f32, f32)) -> (f32, f32) {
+    let mut cell = at;
+    while level.children.iter().any(|c| c.position == cell) {
+        cell.0 += 1.0;
+    }
+    cell
+}
+
+/// Whether a root child stays at the root when an older save is carried
+/// into the object level. The retired settings nodes stay so that
+/// `migrate_meta_settings_node`, which runs after, finds them where they
+/// were; an export stays only when it reads a page standing at the root,
+/// since what it writes is then a page.
+fn stays_at_root(root: &FsNode, node: &FsNode) -> bool {
+    if matches!(node.node_type.as_str(), "session" | "meta" | "utility") {
+        return true;
+    }
+    if node.node_type.eq_ignore_ascii_case("export") {
+        let input = crate::geometry::node_param_node(node, "input");
+        return input.is_some_and(|name| {
+            root.children.iter().any(|c| c.name == name && crate::page::is_page_node(&c.node_type))
+        });
+    }
+    placement(&node.node_type) != Placement::Geometry
+}
+
+/// Format 4 → 5: the root becomes the object level. Every root child that
+/// is a geometry node goes into one new `geometry` container at the root,
+/// in its order, with its position, wires and flags — so it reads what it
+/// read, since a wire looks among its siblings first and they came along.
+/// Cameras, pages and the retired settings nodes stay.
+///
+/// What NAMES a moved node from somewhere else is re-pointed: a channel
+/// path, in an expression or a wrangle's Code, that crosses between the
+/// container and the root (relative) or reaches into it from the top
+/// (absolute) is resolved where it stood and written again from where it
+/// stands. And the view follows: an editor looking at the root looks into
+/// the container, at the node it had selected, and a path into a moved
+/// subnet goes through the container.
+pub fn wrap_root_geometry(project: &mut Project) {
+    let old = project.root.clone();
+    let moving: Vec<bool> = old.children.iter().map(|c| !stays_at_root(&old, c)).collect();
+    if !moving.contains(&true) {
+        return;
+    }
+
+    // Every channel path anywhere, resolved in the tree as it stands.
+    let refs = channel_refs(&old);
+
+    let mut kept = Vec::new();
+    let mut moved = Vec::new();
+    // Old slot → where it went: (in the container?, new slot).
+    let mut map = Vec::new();
+    for (child, go) in old.children.iter().cloned().zip(&moving) {
+        if *go {
+            map.push((true, moved.len()));
+            moved.push(child);
+        } else {
+            map.push((false, kept.len()));
+            kept.push(child);
+        }
+    }
+    let corner = moved.iter().fold((f32::MAX, f32::MAX), |(x, y), n| (x.min(n.position.0), y.min(n.position.1)));
+    let mut root = old.clone();
+    root.children = kept;
+    let name = unused_name(&root, GEOMETRY);
+    let position = free_cell(&root, corner);
+    let mut geo = container(name, position);
+    geo.children = moved;
+    let geo_slot = root.children.len();
+    root.children.push(geo);
+
+    repoint_channel_refs(&mut root, &refs);
+    project.root = root;
+
+    let remap = |path: &[usize]| -> Vec<usize> {
+        match path.split_first() {
+            None => vec![geo_slot],
+            Some((&first, rest)) => match map.get(first) {
+                Some(&(true, slot)) => [&[geo_slot, slot][..], rest].concat(),
+                Some(&(false, slot)) => [&[slot][..], rest].concat(),
+                None => Vec::new(),
+            },
+        }
+    };
+    let view = &mut project.view_state;
+    if view.current_path.is_empty() {
+        match view.selected_node.and_then(|s| map.get(s).copied()) {
+            // A camera or a page selected at the root: the editor stays.
+            Some((false, slot)) => view.selected_node = Some(slot),
+            Some((true, slot)) => {
+                view.current_path = vec![geo_slot];
+                view.selected_node = Some(slot);
+            }
+            None => {
+                view.current_path = vec![geo_slot];
+                view.selected_node = None;
+            }
+        }
+    } else {
+        view.current_path = remap(&view.current_path);
+    }
+    view.current_path2 = remap(&view.current_path2);
+}
+
+/// One channel path as it resolved before a move.
+struct ChannelRef {
+    holder: String,
+    param: String,
+    path: String,
+    target: String,
+    absolute: bool,
+    param_part: String,
+}
+
+/// The parameters whose text spells channel paths: expressions, and a
+/// wrangle's Code.
+fn spells_paths(p: &crate::app::ParamDef) -> bool {
+    p.is_expr() || p.kind() == ParamKind::Code
+}
+
+fn channel_refs(root: &FsNode) -> Vec<ChannelRef> {
+    fn walk(root: &FsNode, node: &FsNode, out: &mut Vec<ChannelRef>) {
+        for p in node.params.iter().filter(|p| spells_paths(p)) {
+            crate::expr::rewrite_paths(p.text(), |path| {
+                if let Some((target, absolute, param_part)) = crate::geometry::ref_path_target(root, node, path) {
+                    out.push(ChannelRef {
+                        holder: node.id.clone(),
+                        param: p.name.clone(),
+                        path: path.to_string(),
+                        target,
+                        absolute,
+                        param_part,
+                    });
+                }
+                None
+            });
+        }
+        for c in &node.children {
+            walk(root, c, out);
+        }
+    }
+    let mut out = Vec::new();
+    walk(root, root, &mut out);
+    out
+}
+
+/// Write each path in `refs` again from where its holder now stands, where
+/// the move changed what it has to say.
+fn repoint_channel_refs(root: &mut FsNode, refs: &[ChannelRef]) {
+    let inside = |root: &FsNode, id: &str| -> bool {
+        crate::geometry::node_chain(root, id)
+            .and_then(|chain| chain.first().map(|n| is_geometry_container(&n.node_type) && n.id != id))
+            .unwrap_or(false)
+    };
+    let mut edits: Vec<(String, String, String)> = Vec::new();
+    for holder in refs.iter().map(|r| r.holder.as_str()).collect::<std::collections::BTreeSet<_>>() {
+        let Some(node) = crate::viewer_state::find_node_by_id(root, holder) else { continue };
+        for p in node.params.iter().filter(|p| spells_paths(p)) {
+            let text = crate::expr::rewrite_paths(p.text(), |path| {
+                let r = refs.iter().find(|r| r.holder == holder && r.param == p.name && r.path == path)?;
+                let target_moved = inside(root, &r.target);
+                let needed = if r.absolute { target_moved } else { inside(root, holder) != target_moved };
+                if !needed {
+                    return None;
+                }
+                let node_path = if r.absolute {
+                    crate::geometry::absolute_ref_path(root, &r.target)?
+                } else {
+                    crate::geometry::relative_ref_path(root, holder, &r.target)?
+                };
+                Some(match node_path.as_str() {
+                    "" => r.param_part.clone(),
+                    "/" => format!("/{}", r.param_part),
+                    np => format!("{np}/{}", r.param_part),
+                })
+            });
+            if text != p.text() {
+                edits.push((holder.to_string(), p.name.clone(), text));
+            }
+        }
+    }
+    for (holder, param, text) in edits {
+        if let Some(n) = crate::viewer_state::find_node_by_id_mut(root, &holder) {
+            if let Some(p) = n.params.iter_mut().find(|p| p.name == param) {
+                p.set_text(text);
+            }
+        }
+    }
+}
diff --git a/src/dialog.rs b/src/dialog.rs
index 1ea8cda..707db07 100644
--- a/src/dialog.rs
+++ b/src/dialog.rs
@@ -1871,11 +1871,18 @@ impl State {
                     .collect()
             }
             Mode::AddNode => {
-                // Every template, everywhere. The settings directories that
-                // refused geometry were the root meta node's utility subnets,
-                // and they are gone.
-                let offered: Vec<&str> =
-                    self.node_templates.iter().map(|t| t.label.as_str()).collect();
+                // The templates that may stand at this level: at the root
+                // the Geometry node, cameras and pages; inside a geometry
+                // node everything but the first two (`context::placement`).
+                // The settings directories that refused geometry once were
+                // the root meta node's utility subnets, and they are gone.
+                let here = crate::context::context_at(&self.current_path);
+                let offered: Vec<&str> = self
+                    .node_templates
+                    .iter()
+                    .filter(|t| crate::context::fits(crate::context::placement(&t.node.node_type), here))
+                    .map(|t| t.label.as_str())
+                    .collect();
                 crate::command::fuzzy_rank(&query, &offered)
                     .into_iter()
                     .map(|i| Row::plain(offered[i], offered[i], ""))
@@ -2824,10 +2831,11 @@ impl State {
                             self.splice_new_node(&src_id, &dest_id);
                         }
                     }
-                    // The one refusal this can hit is a geometry template in
-                    // a utility dir, which `refresh_dialog_rows` already
-                    // filters out — but the rule lives in `apply_action`, so
-                    // say what it said rather than assume it cannot fire.
+                    // The one refusal this can hit is a template that does
+                    // not belong at this level, which `refresh_dialog_rows`
+                    // already filters out — but the rule lives in
+                    // `apply_action`, so say what it said rather than assume
+                    // it cannot fire.
                     Err(e) => self.update_status_text(&e),
                 }
             }
diff --git a/src/edit_history.rs b/src/edit_history.rs
index e90b1aa..2f4b1bf 100644
--- a/src/edit_history.rs
+++ b/src/edit_history.rs
@@ -514,7 +514,7 @@ impl State {
                     .map(|n| n.id.clone());
                 // The active camera is a name, which a rename changes.
                 let camera = self
-                    .current_dir()
+                    .camera_level()
                     .children
                     .iter()
                     .find(|c| c.node_type == "camera" && c.name == self.active_camera)
diff --git a/src/geometry.rs b/src/geometry.rs
index 207b475..656850c 100644
--- a/src/geometry.rs
+++ b/src/geometry.rs
@@ -780,6 +780,16 @@ fn split_ref_path(path: &str) -> (bool, Vec<&str>, &str) {
     (absolute, segs, param)
 }
 
+/// What a channel path on `holder` names: the node's id, whether the path
+/// is absolute, and its parameter segment as written. None when it names
+/// no node.
+pub fn ref_path_target(root: &FsNode, holder: &FsNode, path: &str) -> Option<(String, bool, String)> {
+    let (absolute, segs, param) = split_ref_path(path);
+    let start = if absolute { root } else { holder };
+    let target = walk_ref_path(root, start, &segs).ok()?;
+    Some((target.id.clone(), absolute, param.to_string()))
+}
+
 /// A parameter named by the last path segment, with an optional `.x` / `.y`
 /// / `.z` component for a float3 — tried as a whole name first, so a
 /// parameter that really is called `Size.x` still resolves.
@@ -1458,6 +1468,17 @@ pub fn generate_single_node_geometry_with_errors(
         } else {
             None
         }
+    } else if crate::context::is_geometry_container(&target.node_type) {
+        // A geometry node is what its display flag shows, as a Houdini
+        // object is its display SOP: what an export at the root reads, and
+        // `--export --node`. The scene walk does not come here — it goes
+        // into the container and draws its children by their flags, as it
+        // goes into a subnet.
+        target
+            .children
+            .iter()
+            .find(|c| c.geometry_visible && crate::context::placement(&c.node_type) != crate::context::Placement::Object && !crate::page::is_page_node(&c.node_type))
+            .and_then(|shown| generate_single_node_geometry_with_errors(root, shown, visited, ocl_error, sim))
     } else if target.node_type.eq_ignore_ascii_case("output") {
         // Sibling-first, then anywhere — `find_input_node`'s own rule,
         // which this arm spelled out by hand before that function existed.
@@ -6564,6 +6585,7 @@ mod tests {
             let ty = t.node_type.as_str();
             let resolvable = is_geometry_node_type(ty)
                 || ty.eq_ignore_ascii_case("node")
+                || crate::context::is_geometry_container(ty)
                 || crate::page::is_page_node(ty)
                 || ty.eq_ignore_ascii_case("camera");
             if !resolvable {
diff --git a/src/image_tools.rs b/src/image_tools.rs
index 2892f43..55948b6 100644
--- a/src/image_tools.rs
+++ b/src/image_tools.rs
@@ -107,7 +107,7 @@ impl State {
         let (orbit_x, orbit_y) = (self.viewport().rotation_x, self.viewport().rotation_y);
         let mut reached = None;
         if camera_name != "Default Camera" {
-            let dir = self.current_dir_mut();
+            let dir = self.camera_level_mut();
             if let Some(node) =
                 dir.children.iter_mut().find(|c| c.node_type == "camera" && c.name == camera_name)
             {
diff --git a/src/main.rs b/src/main.rs
index 74a0d6f..00854bb 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -29,6 +29,7 @@ pub mod viewport_3d;
 pub mod api;
 pub mod window;
 pub mod geometry;
+pub mod context;
 pub mod project;
 pub mod render;
 pub mod shortcut;
@@ -52,6 +53,22 @@ mod test_prelude {
     pub use glam::{Mat4, Vec3};
     pub use cce_ui::widget::{Key, NamedKey};
     pub use crate::app::{State, McpAction, ModifiersState};
+
+    /// The root's first Geometry node: where a loaded save's geometry
+    /// stands since the root became the object level (format 5).
+    pub fn geo(root: &crate::app::FsNode) -> &crate::app::FsNode {
+        root.children
+            .iter()
+            .find(|c| crate::context::is_geometry_container(&c.node_type))
+            .expect("a geometry node at the root")
+    }
+
+    pub fn geo_mut(root: &mut crate::app::FsNode) -> &mut crate::app::FsNode {
+        root.children
+            .iter_mut()
+            .find(|c| crate::context::is_geometry_container(&c.node_type))
+            .expect("a geometry node at the root")
+    }
 }
 
 fn main() {
@@ -667,12 +684,14 @@ mod tests {
 
         proj.migrate_format();
 
-        let names: Vec<&str> = proj.root.children.iter().map(|c| c.name.as_str()).collect();
-        assert!(names.contains(&"camera1"));
+        // The camera stays at the root; the rest is geometry, and went into
+        // a Geometry node (format 5) with its names and wires.
+        assert!(proj.root.children.iter().any(|c| c.name == "camera1"));
+        let names: Vec<&str> = geo(&proj.root).children.iter().map(|c| c.name.as_str()).collect();
         assert!(names.contains(&"my_region"));
         assert!(names.contains(&"sphere1"), "the hand-named sibling keeps its name");
         assert!(names.contains(&"sphere1_2"), "the migrated sphere steps aside from it: {names:?}");
-        let by_name = |n: &str| proj.root.children.iter().find(|c| c.name == n).unwrap();
+        let by_name = |n: &str| geo(&proj.root).children.iter().find(|c| c.name == n).unwrap();
         assert_eq!(by_name("my_region").params[0].text(), "sphere1_2", "the wire followed the rename");
         assert_eq!(by_name("sphere1").params[0].text(), "camera1");
         assert_eq!(proj.view_state.active_camera, "camera1");
@@ -755,12 +774,13 @@ mod tests {
         let mut proj = Project { name: "p".into(), root, view_state: Default::default(), format: 3 };
         proj.migrate_format();
         let names = |n: &FsNode| n.params.iter().map(|p| p.name.clone()).collect::<Vec<_>>();
-        assert_eq!(names(&proj.root.children[0]), ["base_resolution", "center"]);
-        assert_eq!(proj.root.children[1].params[0].name, "size_x");
-        assert_eq!(proj.root.children[1].params[0].text(), "ch(\"../ball/base_resolution\") * 2 + chf(\"../ball/center.y\")");
-        assert!(proj.root.children[1].params[0].is_expr());
-        assert_eq!(proj.root.children[2].params[0].text(), "@P.y += chv(\"../ball/center\").y; // ch(\"base_resolution\")");
-        assert_eq!(names(&proj.root.children[3]), ["Show Grid"], "a retired settings node keeps what its own migration reads");
+        let g = geo(&proj.root);
+        assert_eq!(names(&g.children[0]), ["base_resolution", "center"]);
+        assert_eq!(g.children[1].params[0].name, "size_x");
+        assert_eq!(g.children[1].params[0].text(), "ch(\"../ball/base_resolution\") * 2 + chf(\"../ball/center.y\")");
+        assert!(g.children[1].params[0].is_expr());
+        assert_eq!(g.children[2].params[0].text(), "@P.y += chv(\"../ball/center\").y; // ch(\"base_resolution\")");
+        assert_eq!(names(&proj.root.children[0]), ["Show Grid"], "a retired settings node keeps what its own migration reads, at the root");
         let p = ParamDef::new("radius", "slider", "1").with_label("Radius");
         assert_eq!((p.shown_name(), p.name.as_str()), ("Radius", "radius"));
     }
@@ -799,17 +819,17 @@ mod tests {
         let mut proj = Project { name: "p".into(), root, view_state: Default::default(), format: 1 };
         proj.migrate_format();
         assert_eq!(proj.format, PROJECT_FORMAT);
-        let text = |proj: &Project, i: usize| proj.root.children[i].params[0].text().to_string();
+        let text = |proj: &Project, i: usize| geo(&proj.root).children[i].params[0].text().to_string();
         assert_eq!(text(&proj, 0), "N");
         assert_eq!(text(&proj, 1), "Cd, N,uv", "every step a file is behind: N, then uv");
         assert_eq!(text(&proj, 2), "@P += @N * 0.1; @Normal = 1;");
         assert_eq!(text(&proj, 3), "Normx", "only the whole name");
-        let names: Vec<&str> = proj.root.children.iter().map(|c| c.params[0].name.as_str()).collect();
+        let names: Vec<&str> = geo(&proj.root).children.iter().map(|c| c.params[0].name.as_str()).collect();
         assert_eq!(names, ["attribute", "attributes", "code", "attribute"], "and then every name is one (format 4)");
 
         // Once: a format-2 file's Norm is its own attribute.
         let mut again = proj.clone();
-        again.root.children[0].params[0].set_text("Norm".to_string());
+        geo_mut(&mut again.root).children[0].params[0].set_text("Norm".to_string());
         again.migrate_format();
         assert_eq!(text(&again, 0), "Norm");
 
@@ -1229,17 +1249,19 @@ mod tests {
         state.migrate_meta_settings_node();
 
         assert!(!state.fs_root.children.iter().any(|c| c.node_type == "meta"));
-        let kept = state
-            .fs_root
+        // A subnet is a geometry node, so it is re-homed in the root's
+        // Geometry node rather than at the root itself.
+        let level = geo(&state.fs_root);
+        let kept = level
             .children
             .iter()
             .find(|c| c.id == "mine")
             .expect("the user's node was eaten with the meta subnet");
         assert_eq!(kept.name, "my_notes");
-        // Re-homed onto a free cell — the root may already have something
+        // Re-homed onto a free cell — the level may already have something
         // standing where it was.
         assert!(
-            state.fs_root.children.iter().filter(|c| c.position == kept.position).count() == 1,
+            level.children.iter().filter(|c| c.position == kept.position).count() == 1,
             "it landed on top of another node"
         );
     }
@@ -1387,16 +1409,17 @@ mod tests {
         assert_eq!(state.pane_in_dock(Dock::Left), NETWORK_PANEL2_IDX);
         assert_eq!(state.tab_dock_of_pane(NETWORK_PANEL_IDX), Some(Dock::Left));
 
+        let start = state.current_path.clone();
         let sphere = state
-            .fs_root
+            .current_dir()
             .children
             .iter()
             .position(|c| c.name == "sphere1")
             .expect("default project has sphere1");
-        state.current_path2 = vec![sphere];
+        state.current_path2 = [start.clone(), vec![sphere]].concat();
         state.sync_nodes();
-        assert!(state.current_path.is_empty(), "primary path must not follow");
-        assert_eq!(state.path_names_at(&state.current_path2), vec!["sphere1".to_string()]);
+        assert_eq!(state.current_path, start, "primary path must not follow");
+        assert_eq!(state.path_names_at(&state.current_path2), vec!["geometry1".to_string(), "sphere1".to_string()]);
 
         state.current_path2 = vec![99];
         state.sync_nodes();
@@ -1420,8 +1443,17 @@ mod tests {
         let mut state = State::new(false);
         state.add_dock_tab(Dock::Left, NETWORK_PANEL2_IDX);
 
-        let sphere = state.fs_root.children.iter().position(|c| c.name == "sphere1").unwrap();
-        let camera = state.fs_root.children.iter().position(|c| c.name == "camera1").unwrap();
+        // Both editors in the bundled project's Geometry node, where the
+        // sphere is; a second node there for the other editor to pick.
+        state.current_path2 = state.current_path.clone();
+        let mut other = state.current_dir().children.iter().find(|c| c.name == "sphere1").unwrap().clone();
+        crate::app::regenerate_node_ids(&mut other);
+        other.name = "sphere2".into();
+        other.position.0 += 2.0;
+        state.current_dir_mut().children.push(other);
+        state.sync_nodes();
+        let sphere = state.current_dir().children.iter().position(|c| c.name == "sphere1").unwrap();
+        let camera = state.current_dir().children.iter().position(|c| c.name == "sphere2").unwrap();
 
         // Pane 1 selects the sphere; the spreadsheet pins to pane 1.
         state.graph_mut().set_selected_node(Some(sphere));
@@ -1453,7 +1485,7 @@ mod tests {
         // And the spreadsheet refresh keys off the pinned selection.
         state.show_spreadsheet = true;
         state.sync_nodes();
-        let sphere_id = state.fs_root.children[sphere].id.clone();
+        let sphere_id = state.current_dir().children[sphere].id.clone();
         assert_eq!(
             state.last_spreadsheet_node_name.as_deref(),
             Some(sphere_id.as_str()),
@@ -1487,12 +1519,12 @@ mod tests {
         // fronted), dived one level down its own path.
         a.add_dock_tab(crate::app::Dock::Left, crate::slots::NETWORK_PANEL2_IDX);
         let sphere = a
-            .fs_root
+            .current_dir()
             .children
             .iter()
             .position(|c| c.name == "sphere1")
             .expect("default project has sphere1");
-        a.current_path2 = vec![sphere];
+        a.current_path2 = [a.current_path.clone(), vec![sphere]].concat();
         a.save_to_file(&dir).expect("save");
 
         let mut b = State::new(false);
@@ -1516,7 +1548,7 @@ mod tests {
             Some(crate::app::Dock::Left),
             "the primary must load as the waiting tab"
         );
-        assert_eq!(b.current_path2, vec![sphere], "the second editor's path must round-trip");
+        assert_eq!(b.current_path2, [a.current_path.clone(), vec![sphere]].concat(), "the second editor's path must round-trip");
 
         // A detached pane window must ignore the same file's pane state.
         let mut d = State::new(true);
@@ -3467,7 +3499,7 @@ mod tests {
         // a hundred small moves come to what one large one does.
         state.set_active_camera("camera1");
         let read = |state: &State, name: &str| {
-            let node = state.current_dir().children.iter().find(|c| c.name == "camera1").unwrap();
+            let node = state.camera_level().children.iter().find(|c| c.name == "camera1").unwrap();
             crate::geometry::node_param_vec3(node, name, Vec3::ZERO)
         };
         let (pivot0, pos0, rot0) = (read(&state, "pivot"), read(&state, "position"), read(&state, "rotation"));
@@ -4639,7 +4671,8 @@ mod tests {
         };
 
         let mut state = State::new(false);
-        state.fs_root.children = vec![
+        // Inside the bundled project's Geometry node, where geometry goes.
+        state.current_dir_mut().children = vec![
             instance(find("Sphere"), "s", "Sphere 1", &[]),
             instance(find("Group"), "g", "Group 1", &[
                 ("input", "Sphere 1"),
@@ -4671,7 +4704,7 @@ mod tests {
         edited.iter_mut().find(|r| r.0 == "Name").unwrap().1 = "weight".into();
         state.param_mut().set_display_params(&edited);
         state.sync_parameters_to_project();
-        let attr = &state.fs_root.children[2];
+        let attr = &state.current_dir().children[2];
         assert_eq!(attr.params.iter().find(|p| p.name == "attribute_name").unwrap().text(), "weight");
         state.sync_parameters_pane();
         let rows = state.param_mut().node_params();
@@ -6493,8 +6526,8 @@ mod tests {
             .expect("a Scatter template")
             .node
             .clone();
-        state.fs_root.children.push(scatter);
-        let idx = state.fs_root.children.len() - 1;
+        state.current_dir_mut().children.push(scatter);
+        let idx = state.current_dir().children.len() - 1;
         state.graph_mut().set_selected_node(Some(idx));
         state.sync_parameters_pane();
 
@@ -7150,16 +7183,22 @@ mod tests {
     /// do nothing at all; now that view is the Default Camera view and is
     /// framed as one — its pivot moves to the geometry's centre and the
     /// fixed eye ray is fitted with zoom.
+    /// A camera stands at the root and is seen from every level (since
+    /// 2026-10-02): Frame All from inside a subnet inside the Geometry node
+    /// frames the active camera NODE, where until then a camera not on the
+    /// current level was the Default Camera view and the node was left.
     #[test]
-    fn frame_all_frames_off_centre_geometry_without_a_camera_node_in_the_dir() {
-        use crate::geometry::Vertex3D;
+    fn frame_all_frames_the_root_camera_from_inside_a_subnet() {
+        use crate::geometry::{node_param_vec3, Vertex3D};
         let mut state = State::new(false);
-        // The root holds Camera 1; a subnet holds no camera at all.
-        state.active_camera = "camera1".to_string();
-        let sub = state.current_dir().children.iter().position(|c| c.name == "sphere1").expect("sphere1 at the root");
+        state.set_active_camera("camera1");
+        let sub = state.current_dir().children.iter().position(|c| c.name == "sphere1").expect("sphere1 in geometry1");
         state.current_path.push(sub);
         state.on_path_changed();
         assert!(!state.current_dir().children.iter().any(|c| c.node_type == "camera"), "no camera in the subnet");
+        assert!(state.camera_names().contains(&"camera1".to_string()), "the root's camera is offered here");
+        let camera = |state: &State| state.camera_level().children.iter().find(|c| c.name == "camera1").unwrap().clone();
+        let before = camera(&state);
         // Displayed geometry: a small cluster centred well off the origin.
         let c = [3.0f32, 0.5, -2.0];
         state.rt_sphere_verts = (0..12)
@@ -7171,16 +7210,17 @@ mod tests {
         state.last_viewport_width = 800;
         state.last_viewport_height = 600;
         let zoom_before = state.viewport().zoom;
-        assert_eq!(state.viewport().pivot, Vec3::ZERO);
 
         state.frame_all();
 
-        let piv = state.viewport().pivot;
+        let after = camera(&state);
+        let piv = node_param_vec3(&after, "pivot", Vec3::ZERO);
         for k in 0..3 {
-            assert!((piv[k] - c[k]).abs() < 0.2, "pivot {piv:?} is not on the geometry's centre {c:?}");
+            assert!((piv[k] - c[k]).abs() < 0.2, "the camera's pivot {piv:?} is not on the geometry's centre {c:?}");
         }
-        assert!(state.viewport().zoom != zoom_before, "the fixed ray was fitted");
-        assert!(state.viewport().zoom < 1.0, "a 0.25 sphere frames closer than the stock view: zoom {}", state.viewport().zoom);
+        let reach = |n: &FsNode| (node_param_vec3(n, "position", Vec3::ZERO) - node_param_vec3(n, "pivot", Vec3::ZERO)).length();
+        assert!(reach(&after) < reach(&before), "a 0.25 cluster frames closer than the stock camera");
+        assert_eq!(state.viewport().zoom, zoom_before, "the node was framed, not the Default Camera");
     }
 
     /// The scene file carries the Default Camera VIEW (square aspect, pivot
@@ -7762,10 +7802,11 @@ mod tests {
             .apply_action(McpAction::AddNode { template_name: "Embryo".into(), name: None, x: 9.0, y: 9.0 }, &mut redraw)
             .unwrap();
         let slot = state.current_dir().children.len() - 1;
-        state.current_path = vec![slot];
+        let level = state.current_path.clone();
+        state.current_path.push(slot);
         state.sync_nodes();
         assert!(state.history_step(true));
-        assert!(state.current_path.is_empty(), "the editor is inside a node that is gone");
+        assert_eq!(state.current_path, level, "the editor is inside a node that is gone");
 
         // A wire, made as the graph makes one and as the pane does: each is
         // one step, and the second is not noticed a second time.
@@ -7838,8 +7879,9 @@ mod tests {
             p.set_text(reference.clone());
             p.set_expr(true);
         }
-        let camera = state.current_dir().children.iter().position(|c| c.node_type == "camera").unwrap();
-        let camera_name = state.current_dir().children[camera].name.clone();
+        // The camera stands at the root, the sphere in its Geometry node.
+        let camera = state.camera_level().children.iter().find(|c| c.node_type == "camera").unwrap();
+        let (camera_id, camera_name) = (camera.id.clone(), camera.name.clone());
         state.set_active_camera(camera_name.clone());
         state.record_structure_changes();
         state.edit_history.clear();
@@ -7858,7 +7900,8 @@ mod tests {
         assert_eq!(before.2, reference);
 
         state.apply_action(McpAction::RenameNode { slot: sphere, new_name: "Ball".into() }, &mut redraw).unwrap();
-        state.apply_action(McpAction::RenameNode { slot: camera, new_name: "lens".into() }, &mut redraw).unwrap();
+        state.rename_node(&camera_id, "lens").unwrap();
+        state.record_structure_changes();
         let after = names(&state);
         assert_eq!(after.0, "ball");
         assert_eq!(after.1, "ball", "the wire followed the rename");
@@ -8084,11 +8127,15 @@ mod tests {
     fn a_node_is_renamed_from_its_menu() {
         use crate::dialog::{Mode, RENAME_ROW_ID};
         let mut state = State::new(false);
+        // A sibling for the sphere, whose name is taken.
+        state
+            .apply_action(McpAction::AddNode { template_name: "Box".into(), name: None, x: 9.0, y: 9.0 }, &mut false)
+            .unwrap();
         state.record_structure_changes();
         let sphere = state.current_dir().children.iter().position(|c| c.node_type == "sphere").unwrap();
-        let camera = state.current_dir().children.iter().position(|c| c.node_type == "camera").unwrap();
+        let sibling = state.current_dir().children.iter().position(|c| c.node_type == "box").unwrap();
         let old = state.current_dir().children[sphere].name.clone();
-        let other = state.current_dir().children[camera].name.clone();
+        let other = state.current_dir().children[sibling].name.clone();
         let row = |state: &State| state.slots.dialog.rows.iter().map(|r| r.label.clone()).collect::<Vec<_>>();
         let retype = |state: &mut State, name: &str| {
             while !state.slots.dialog.query.is_empty() {
@@ -8679,8 +8726,8 @@ mod tests {
     #[test]
     fn every_wire_is_drawn_into_its_own_port() {
         use cce_ui::widget::node_wires;
+        // In the bundled project's Geometry node, where it opens.
         let mut state = State::new(false);
-        state.current_path.clear();
         let mut redraw = false;
         state.apply_action(McpAction::AddNode { template_name: "Remesh".into(), name: Some("remesh1".into()), x: 3.0, y: 8.0 }, &mut redraw).unwrap();
         let slot = state.current_dir().children.iter().position(|c| c.name == "remesh1").unwrap();
@@ -9445,6 +9492,118 @@ mod tests {
         assert_eq!(rewrite_paths("touch(\"x\")", |_| Some("no".into())), "touch(\"x\")", "only channel calls are paths");
     }
 
+    /// Format 4 → 5: the root is the object level, and an older save's
+    /// geometry goes into one new Geometry node there, in its order and
+    /// with its wires — while cameras, pages and an export of a page stay.
+    /// What names a moved node across the move is re-pointed (an absolute
+    /// path into it, a relative path between it and the root, both ways),
+    /// what does not cross is left as written, and the view follows: an
+    /// editor at the root looks into the new node at the node it had
+    /// selected, and a path into a moved subnet goes through it. Once.
+    #[test]
+    fn an_older_save_puts_its_geometry_in_a_geometry_node() {
+        use crate::app::{Project, PROJECT_FORMAT};
+        let at = |mut n: FsNode, x: f32, y: f32| {
+            n.position = (x, y);
+            n
+        };
+        let root = ref_node("root", "root", "node", vec![], vec![
+            at(ref_node("cam", "camera1", "camera", vec![("pivot", "float3", "1:2:3")], vec![]), 0.0, 0.0),
+            at(ref_node("pg", "page1", "page", vec![("width", "float", "ch(\"../sphere1/radius\")")], vec![]), 1.0, 0.0),
+            at(ref_node("xp", "export_page", "export", vec![("input", "node", "page1")], vec![]), 1.0, 1.0),
+            at(ref_node("sp", "sphere1", "sphere", vec![("radius", "slider", "ch(\"../camera1/pivot.x\")")], vec![]), 4.0, 2.0),
+            at(ref_node("xf", "xform1", "transform", vec![
+                ("input", "node", "sphere1"),
+                ("scale", "float", "ch(\"/sphere1/radius\") * 2 + ch(\"../sphere1/radius\")"),
+            ], vec![]), 4.0, 3.0),
+            at(ref_node("xg", "export1", "export", vec![("input", "node", "xform1")], vec![]), 4.0, 4.0),
+            at(ref_node("sub", "sub1", "node", vec![], vec![ref_node("in", "input1", "input", vec![], vec![])]), 6.0, 2.0),
+        ]);
+        let mut proj = Project { name: "p".into(), root, view_state: Default::default(), format: 4 };
+        proj.view_state.selected_node = Some(4);
+        proj.view_state.current_path2 = vec![6, 0];
+        proj.migrate_format();
+        assert_eq!(proj.format, PROJECT_FORMAT);
+
+        let names = |n: &FsNode| n.children.iter().map(|c| c.name.clone()).collect::<Vec<_>>();
+        assert_eq!(names(&proj.root), ["camera1", "page1", "export_page", "geometry1"]);
+        let g = geo(&proj.root);
+        assert_eq!((g.node_type.as_str(), g.geometry_visible), ("geometry", true));
+        assert_eq!(names(g), ["sphere1", "xform1", "export1", "sub1"]);
+        assert!(!proj.root.children[..3].iter().any(|c| c.position == g.position), "it stands on a free cell");
+        assert_eq!(g.children[1].params[0].text(), "sphere1", "a wire between moved nodes is left alone");
+
+        let text = |n: &FsNode, p: &str| n.params.iter().find(|q| q.name == p).unwrap().text().to_string();
+        assert_eq!(text(&g.children[1], "scale"), "ch(\"/geometry1/sphere1/radius\") * 2 + ch(\"../sphere1/radius\")");
+        assert_eq!(text(&g.children[0], "radius"), "ch(\"../../camera1/pivot.x\")", "out of the node to the root");
+        assert_eq!(text(&proj.root.children[1], "width"), "ch(\"../geometry1/sphere1/radius\")", "from the root into it");
+        let mut err = None;
+        let resolved = crate::geometry::resolve_param_refs(&proj.root, &g.children[0], 0, &mut err).unwrap();
+        assert!(err.is_none(), "{err:?}");
+        assert_eq!(text(&resolved, "radius"), "1");
+
+        // The view: the root editor is inside, on xform1; the second editor's
+        // path into sub1 goes through the new node.
+        assert_eq!(proj.view_state.current_path, vec![3]);
+        assert_eq!(proj.view_state.selected_node, Some(1));
+        assert_eq!(proj.view_state.current_path2, vec![3, 3, 0]);
+
+        // Once.
+        let before = serde_json::to_string(&proj).unwrap();
+        proj.migrate_format();
+        assert_eq!(serde_json::to_string(&proj).unwrap(), before);
+
+        // A save with nothing to move is left as it was.
+        let root = ref_node("root", "root", "node", vec![], vec![ref_node("cam", "camera1", "camera", vec![], vec![])]);
+        let mut proj = Project { name: "p".into(), root, view_state: Default::default(), format: 4 };
+        proj.migrate_format();
+        assert_eq!(names(&proj.root), ["camera1"]);
+        assert!(proj.view_state.current_path.is_empty());
+    }
+
+    /// The root is the object level: each Geometry node there is shown or
+    /// not by its own flag, and several draw at once, where inside one the
+    /// flag is exclusive as it always was. A Geometry node resolves to what
+    /// its flag inside shows, as a Houdini object is its display SOP.
+    #[test]
+    fn geometry_nodes_at_the_root_each_show_their_own() {
+        use crate::geometry::{generate_single_node_geometry_with_errors, network_sphere_vertices, EvalSim, SimCache};
+        let sphere = |id: &str, r: &str| {
+            ref_node(id, id, "sphere", vec![("radius", "slider", r), ("center", "float3", "0:0:0"), ("rows", "spinbox", "4"), ("columns", "spinbox", "6")], vec![])
+        };
+        let box_ = |id: &str| ref_node(id, id, "box", vec![("size", "float3", "1:1:1"), ("center", "float3", "3:0:0")], vec![]);
+        let mut a = ref_node("ga", "geometry1", "geometry", vec![], vec![sphere("s1", "0.5"), box_("b1")]);
+        a.set_child_geometry_visible(0, true);
+        assert!(!a.children[1].geometry_visible, "inside, the flag is exclusive");
+        let mut root = ref_node("root", "root", "node", vec![], vec![
+            a,
+            ref_node("gb", "geometry2", "geometry", vec![], vec![box_("b2")]),
+            ref_node("cam", "camera1", "camera", vec![], vec![]),
+        ]);
+        let count = |root: &FsNode| network_sphere_vertices(root).num_points();
+        let (n_sphere, n_box) = {
+            let mut cache = SimCache::default();
+            let mut sim = EvalSim::new(0, 0, &mut cache);
+            let mut err = None;
+            let s = generate_single_node_geometry_with_errors(&root, &root.children[0], &mut Vec::new(), &mut err, &mut sim).unwrap();
+            let b = generate_single_node_geometry_with_errors(&root, &root.children[1], &mut Vec::new(), &mut err, &mut sim).unwrap();
+            assert!(err.is_none(), "{err:?}");
+            (s.num_points(), b.num_points())
+        };
+        assert_eq!(n_box, 8, "geometry2 is its box");
+        assert!(n_sphere > 8, "geometry1 is its shown sphere, not its box");
+
+        root.set_child_geometry_visible(0, true);
+        root.set_child_geometry_visible(1, true);
+        assert!(root.children[0].geometry_visible && root.children[1].geometry_visible, "both stay shown");
+        assert_eq!(count(&root), n_sphere + n_box, "and both draw");
+        root.set_child_geometry_visible(1, false);
+        assert_eq!(count(&root), n_sphere);
+        root.set_child_geometry_visible(1, true);
+        root.set_child_geometry_visible(2, true);
+        assert!(root.children[0].geometry_visible && root.children[1].geometry_visible, "a camera's flag turns no object off");
+    }
+
     /// The pre-expression reference migrates to Houdini's semantics: a bare
     /// name meant the parent and gains `../`, an explicit `../` is kept, and
     /// anything else is not a legacy reference.
@@ -9474,16 +9633,17 @@ mod tests {
         };
         proj.root.children[0].children[0].params[0].set_expr(false);
         proj.migrate_format();
-        let r = &proj.root.children[0].children[0].params[0];
+        // Format 4 → 5 put the subnet inside a Geometry node.
+        let r = &geo(&proj.root).children[0].children[0].params[0];
         // Format 0 → 1 gives the reference its parent; 3 → 4 the name.
         assert_eq!(r.text(), "chf(\"../size\")");
         assert_eq!(r.name, "radius");
         assert!(r.is_expr());
         assert_eq!(proj.format, crate::app::PROJECT_FORMAT);
         // A NEW file's bare name is the node's own parameter and stays.
-        proj.root.children[0].children[0].params[0].set_text("chf(\"radius\")");
+        geo_mut(&mut proj.root).children[0].children[0].params[0].set_text("chf(\"radius\")");
         proj.migrate_format();
-        assert_eq!(proj.root.children[0].children[0].params[0].text(), "chf(\"radius\")");
+        assert_eq!(geo(&proj.root).children[0].children[0].params[0].text(), "chf(\"radius\")");
     }
 
     #[test]
@@ -9896,7 +10056,7 @@ mod tests {
 
         // Absolute paste, then Delete Expression bakes the current value.
         state.run_param_action(&ball_id, "radius", ParamMenuAction::PasteAbsolute);
-        assert_eq!(radius(&state, ball).text(), "ch(\"/sphere1/radius\")");
+        assert_eq!(radius(&state, ball).text(), "ch(\"/geometry1/sphere1/radius\")");
         state.run_param_action(&ball_id, "radius", ParamMenuAction::DeleteExpression);
         assert_eq!(radius(&state, ball).text(), "0.9");
         assert!(!radius(&state, ball).is_expr());
@@ -13346,11 +13506,10 @@ mod tests {
     #[test]
     fn bypass_is_one_flag_however_it_is_asked_for() {
         use crate::app::McpAction;
+        // An empty Geometry node: the bundled project's, emptied.
         let mut state = State::new(false);
         let mut redraw = false;
-        while !state.current_path.is_empty() {
-            state.apply_action(McpAction::Up, &mut redraw).unwrap();
-        }
+        assert_eq!(state.current_path.len(), 1, "the bundled project opens in its Geometry node");
         state.current_dir_mut().children.clear();
         state.sync_nodes();
         state.apply_action(McpAction::AddNode { template_name: "Sphere".into(), name: Some("ball".into()), x: 3.0, y: 3.0 }, &mut redraw).unwrap();
@@ -13391,7 +13550,7 @@ mod tests {
         assert_eq!(state.set_bypassed(&[ball], false), 1);
 
         // It is saved with the project and comes back with it.
-        let saved = serde_json::to_string(&state.fs_root).unwrap();
+        let saved = serde_json::to_string(state.current_dir()).unwrap();
         let back: FsNode = serde_json::from_str(&saved).unwrap();
         assert!(back.children[pull].bypassed && !back.children[ball].bypassed);
     }
@@ -16024,7 +16183,9 @@ mod tests {
 
         assert!(state.dialog_visible());
         assert_eq!(state.slots.dialog.mode, Mode::AddNode);
-        assert_eq!(state.slots.dialog.rows.len(), state.node_templates.len());
+        // Inside the bundled project's Geometry node: every template but the
+        // two that stand at the root.
+        assert_eq!(state.slots.dialog.rows.len(), state.node_templates.len() - 2);
         assert!(
             state.slots.dialog.rows.iter().all(|r| r.chord.is_empty()),
             "a template has no chord to teach"
@@ -16110,34 +16271,77 @@ mod tests {
         assert_eq!(input_of(&state, "c").as_deref(), Some(mid.as_str()), "{gen} did not cut the wire");
     }
 
-    /// The Add Node list offers every template, everywhere.
+    /// The Add Node list offers what may stand at the level (since
+    /// 2026-10-02, `context`): at the root, the object level, the Geometry
+    /// node, cameras and the page nodes, and no operator; inside a Geometry
+    /// node, and in a subnet inside one, every operator and the pages, and
+    /// neither the Geometry node nor a camera. The same rule refuses MCP's
+    /// `add_node` and a paste, so no way in gets around it.
     ///
-    /// It used to hide the geometry ones inside a "utility dir" — the root
-    /// meta node and its `main`/`view`/`guides`/`render` subnets, where
-    /// placing geometry was refused. Those nodes are gone with the settings
-    /// they held, so there is no such directory left to be in and no filter
-    /// to apply.
+    /// It once hid the geometry templates inside a "utility dir" — the root
+    /// meta node's subnets — and then, those gone, offered everything
+    /// everywhere.
     #[test]
-    fn dialog_add_node_hides_geometry_templates_in_a_utility_dir() {
+    fn the_add_node_list_offers_what_belongs_at_the_level() {
         let mut state = State::new(false);
+        let labels = |state: &State| state.slots.dialog.rows.iter().map(|r| r.label.clone()).collect::<Vec<_>>();
+        let inside = state.current_path.clone();
+        assert_eq!(state.path_names_at(&inside), ["geometry1"]);
+
+        // Inside the Geometry node.
         state.open_node_palette();
-        let at_root = state.slots.dialog.rows.len();
-        assert_eq!(at_root, state.node_templates.len(), "the palette dropped templates");
-        assert!(state.slots.dialog.rows.iter().any(|r| r.label == "Grid"));
-        assert!(state.slots.dialog.rows.iter().any(|r| r.label == "Box"));
+        let here = labels(&state);
+        for operator in ["Sphere", "Box", "Grid", "Subnet", "Simnet", "Embryo", "Page", "Export"] {
+            assert!(here.iter().any(|l| l == operator), "{operator} missing inside: {here:?}");
+        }
+        assert!(!here.iter().any(|l| l == "Geometry" || l == "Camera"), "{here:?}");
         state.close_dialog();
 
-        // Inside a subnet, the same list.
-        let sphere = state
-            .fs_root
-            .children
-            .iter()
-            .position(|c| c.name.starts_with("sphere"))
-            .expect("a sphere at the root");
-        state.current_path.push(sphere);
+        // At the root.
+        state.current_path.clear();
         state.on_path_changed();
         state.open_node_palette();
-        assert_eq!(state.slots.dialog.rows.len(), at_root);
+        let mut root = labels(&state);
+        root.sort();
+        assert_eq!(root, ["Camera", "Export", "Geometry", "Page", "Page Border", "Page Grid", "Page Shape", "Page Text"]);
+        state.close_dialog();
+
+        // MCP: an operator at the root is refused, with why.
+        let mut redraw = false;
+        let count = state.fs_root.children.len();
+        let said = state
+            .apply_action(McpAction::AddNode { template_name: "Sphere".into(), name: None, x: 9.0, y: 9.0 }, &mut redraw)
+            .unwrap_err();
+        assert!(said.contains("inside a Geometry node"), "{said}");
+        assert_eq!(state.fs_root.children.len(), count);
+        // A Geometry node there is fine, and is entered as a subnet is.
+        state
+            .apply_action(McpAction::AddNode { template_name: "Geometry".into(), name: None, x: 9.0, y: 9.0 }, &mut redraw)
+            .unwrap();
+        let geo2 = state.fs_root.children.iter().position(|c| c.name == "geometry2").expect("geometry2");
+        assert!(state.fs_root.children[geo2].is_enterable());
+        state.apply_action(McpAction::Enter { slot: geo2 }, &mut redraw).unwrap();
+        state
+            .apply_action(McpAction::AddNode { template_name: "Box".into(), name: None, x: 1.0, y: 1.0 }, &mut redraw)
+            .unwrap();
+        let said = state
+            .apply_action(McpAction::AddNode { template_name: "Camera".into(), name: None, x: 2.0, y: 1.0 }, &mut redraw)
+            .unwrap_err();
+        assert!(said.contains("at the root"), "{said}");
+
+        // A paste: the box copied into the root is refused whole.
+        let boxed = state.current_dir().children.iter().position(|c| c.node_type == "box").unwrap();
+        state.node_clipboard = vec![state.current_dir().children[boxed].clone()];
+        state.current_path.clear();
+        state.on_path_changed();
+        let count = state.fs_root.children.len();
+        assert!(!state.paste_nodes());
+        assert_eq!(state.fs_root.children.len(), count);
+        assert!(state.last_status_text.contains("Not pasted"), "{}", state.last_status_text);
+        // And into the other Geometry node it goes.
+        state.current_path = inside;
+        state.on_path_changed();
+        assert!(state.paste_nodes());
     }
 
     /// Ctrl+P opens the list rather than toggling, which is the one thing
@@ -16936,7 +17140,8 @@ mod tests {
             inst
         };
         let mut state = State::new(false);
-        state.fs_root.children = vec![
+        // Inside the bundled project's Geometry node, where geometry goes.
+        state.current_dir_mut().children = vec![
             instance(find("Sphere"), "s", "Sphere 1", &[]),
             instance(find("Attribute"), "a", "pull1", &[
                 ("input", "Sphere 1"),
@@ -16958,7 +17163,7 @@ mod tests {
         assert_eq!(value_row(&mut state), wide, "Modify on Pos");
 
         let set = |state: &mut State, name: &str, val: &str| {
-            state.fs_root.children[1].params.iter_mut().find(|p| p.name == name).unwrap().set_text(val.to_string());
+            state.current_dir_mut().children[1].params.iter_mut().find(|p| p.name == name).unwrap().set_text(val.to_string());
         };
         set(&mut state, "attribute_name", "N");
         assert_eq!(value_row(&mut state), wide, "Modify on an input Float3");
@@ -16994,7 +17199,7 @@ mod tests {
         // Read back unchanged, the spread number is not an edit.
         let steps = state.edit_history.undo_len();
         state.sync_parameters_to_project();
-        assert_eq!(state.fs_root.children[1].params.iter().find(|p| p.name == "value").unwrap().text(), "1.00");
+        assert_eq!(state.current_dir().children[1].params.iter().find(|p| p.name == "value").unwrap().text(), "1.00");
         assert_eq!(state.edit_history.undo_len(), steps);
 
         set(&mut state, "type", "Float3");
@@ -17012,9 +17217,9 @@ mod tests {
         // Far inside, it comes back down.
         set(&mut state, "value", "1.00:2.00:3.00");
         assert_eq!(value_row(&mut state), "float3:-10:10:trackball:soft");
-        state.fs_root.children[1].params.iter_mut().find(|p| p.name == "value").unwrap().set_expr(true);
+        state.current_dir_mut().children[1].params.iter_mut().find(|p| p.name == "value").unwrap().set_expr(true);
         assert_eq!(value_row(&mut state), "text", "an expression is shown as its text");
-        state.fs_root.children[1].params.iter_mut().find(|p| p.name == "value").unwrap().set_expr(false);
+        state.current_dir_mut().children[1].params.iter_mut().find(|p| p.name == "value").unwrap().set_expr(false);
 
         // Read from an attribute: no Value row, and the source is picked
         // from the input's attributes.
@@ -17027,7 +17232,7 @@ mod tests {
         set(&mut state, "value_from", "Constant");
 
         // The parameter itself never changed kind: it is text in the node.
-        assert_eq!(state.fs_root.children[1].params.iter().find(|p| p.name == "value").unwrap().kind(), crate::param::ParamKind::Text);
+        assert_eq!(state.current_dir().children[1].params.iter().find(|p| p.name == "value").unwrap().kind(), crate::param::ParamKind::Text);
     }
 
     /// A trackpad swipe over a band of the pull node's float3 Value row
@@ -18058,7 +18263,7 @@ mod tests {
 
         // A camera node is rewritten, whatever orbit the widget holds.
         let mut state = state_showing_image(300, 200, 100);
-        assert!(state.current_dir().children.iter().any(|c| c.name == "camera1"), "the bundled project has no camera1");
+        assert!(state.camera_level().children.iter().any(|c| c.name == "camera1"), "the bundled project has no camera1");
         state.set_active_camera("camera1");
         state.viewport_mut().rotation_x = 0.2;
         state.viewport_mut().rotation_y = 0.7;
diff --git a/src/project.rs b/src/project.rs
index f1a2129..cedfa32 100644
--- a/src/project.rs
+++ b/src/project.rs
@@ -186,13 +186,13 @@ impl State {
     /// The saved Default Camera view onto the live state — after the
     /// active camera and the path are known. The orbit, zoom and pivot are
     /// the view and always restore; the square aspect, pivot marker and its
-    /// size are a camera NODE's own params when one is active in the
-    /// current directory, so those restore only for a view with no node.
+    /// size are a camera NODE's own params when one is active, so those
+    /// restore only for a view with no node.
     fn apply_default_view_from_project(&mut self, view: Option<crate::app::DefaultCameraView>) {
         let Some(v) = view else { return };
         let active = self.active_camera.clone();
         let has_node = active != "Default Camera"
-            && self.current_dir().children.iter().any(|c| c.node_type == "camera" && c.name == active);
+            && self.camera_level().children.iter().any(|c| c.node_type == "camera" && c.name == active);
         if !has_node {
             self.square_viewport = v.square;
             self.camera_pivot_size = v.pivot_size;
@@ -742,11 +742,19 @@ impl State {
         // Anything else that was living under the meta node is the user's,
         // not ours: adding a non-geometry node in there was allowed, so a
         // migration that quietly ate one would be eating their work. Re-home
-        // it at the root, where the level it was in used to be.
+        // it at the root, where the level it was in used to be — or, for
+        // what belongs inside a geometry node (a subnet), in the root's
+        // first one.
         let mut i = 0;
         while i < subnets.len() {
             if matches!(subnets[i].name.as_str(), "main" | "view" | "guides" | "render") {
                 i += 1;
+            } else if crate::context::placement(&subnets[i].node_type) == crate::context::Placement::Geometry {
+                let mut node = subnets.remove(i);
+                let home = crate::context::geometry_home(&mut self.fs_root);
+                let level = &mut self.fs_root.children[home];
+                node.position = crate::context::free_cell(level, node.position);
+                level.children.push(node);
             } else {
                 let mut node = subnets.remove(i);
                 let (nx, ny) = self.find_empty_cell(node.position.0, node.position.1, None);
diff --git a/src/window.rs b/src/window.rs
index f2b8c8f..593bb9c 100644
--- a/src/window.rs
+++ b/src/window.rs
@@ -463,7 +463,15 @@ impl State {
                     t.label.to_lowercase() == template_name.to_lowercase()
                         || t.node.name.to_lowercase() == template_name.to_lowercase()
                 });
-                if let Some(idx) = template_idx {
+                let here = crate::context::context_at(&state.current_path);
+                let refused = template_idx.and_then(|idx| {
+                    let t = &state.node_templates[idx];
+                    crate::context::refusal(&t.label, &t.node.node_type, here)
+                });
+                if let Some(why) = refused {
+                    state.update_status_text(&why);
+                    Err(why)
+                } else if let Some(idx) = template_idx {
                     let mut node = state.node_templates[idx].node.clone();
                     // Fresh ids, like paste: a verbatim clone shares the
                     // template's ids across every instance.