graphic design tool
git clone https://git.lucas.co/cce-designer.git
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.