graphic design tool
git clone https://git.lucas.co/cce-designer.git
refactor: retire the root meta node; settings are settings, not nodes
The root `meta` node (nee Session) was a permanent, undeletable subnet
holding four utility subnets — `main`, `view`, `guides`, `render` — whose
params were every session-wide setting. It was the STORE OF RECORD:
`ensure_menubar_subnets` rebuilt it from live state and
`apply_settings_from_menubar_subnets` copied it back OVER live state after
every parameter edit anywhere.
Three things followed from that, all bad. A display preference was project
data — carried in the file, reset by opening someone else's scene. Half of
those settings were reachable only by finding the right node in the right
subnet. And a command that flipped a live flag was undone by the next
unrelated edit unless it ALSO wrote the node, which is what
`write_guides_toggle` / `write_render_toggle` were for, and what made "Show
Cube hides the cube until you touch any parameter" a real bug.
Display settings are live `State` fields now, persisted by `DesignSettings`
into `state.kdl` (`viewport`, plus a new `render` block), and edited in the
dialog's Settings half. `Owner::Subnet` becomes `Owner::Field`, carrying a
`Ctl` because a bare Rust field has no type or range to borrow. The rows
that a registry toggle already owns end to end became `Owner::Command`,
read through `command_toggle_state` — one reading, so a switch here and a
switch in the palette cannot disagree.
That table is now the app's whole display configuration, so a value left
out of it would be GONE rather than merely hidden. Everything the four
subnets held is in it, and `every_retired_subnet_setting_is_reachable`
fails if one goes missing. Three settings that were node toggles and
reachable nowhere else became commands: `toggle_ray_traced_preview`,
`toggle_wire_single_color`, `toggle_render_points`.
Two things that were NOT settings moved to where they belong instead:
- Pane visibility rode the `view` subnet's five toggles into the file. It
is genuinely project state, so it is `ProjectViewState::visible_panes`
beside the collapse list and the splitters, with `PANE_FLAGS` the one
table the save and the load share.
- The recent-projects list was the Main node's "Open" dropdown, which
would have left `recent_files` written on every save and read by
nothing. It is rows at the head of the palette, under the open project's
own path row; picking one opens it.
`migrate_meta_settings_node` runs on every load: it takes the node (and the
four subnets, which pre-Session saves parked flat at the root — hence no
early return on the container alone), reads the values onto live state, and
saves them. A node the USER left inside that subnet is re-homed at the
root, not eaten with it.
Gone with all this: `session_node`, `in_settings_dir` (no settings
directory, so Add Node offers every template everywhere), `write_meta_toggle`
and its wrappers, `refresh_main_node_live_toggles`,
`update_recent_files_layout`, the `utility`/`session`/`meta` node types, the
undeletable-node gate, and layout.rs's pinning — whose only pinned nodes
were these.
Also here: the dialog plate was 420px, sized for thirteen settings rows and
now showing eight of nearly thirty; the colour round-trip through KDL was a
hand-written pair of `if let`s covering two of what are now five fields (the
wire colour's ALPHA was among the misses); and `test_keyboard_shortcut_system`
had lost its `#[test]` to a stray duplicate on the test below it, so the one
test CLAUDE.md names as the run-a-single-test example had not been running.
Verified against the real default project, which carried both kinds of meta
node: it loads with neither, and its own settings (background, grid
thickness, wireframe) land in state.kdl instead of being lost.
Co-Authored-By: Claude Opus 5 <[email protected]>
CLAUDE.md | 180 ++++++----
src/api.rs | 2 +-
src/app.rs | 428 +++++++++++------------
src/command.rs | 3 +
src/dialog.rs | 535 ++++++++++++++++++++++-------
src/layout.rs | 8 +-
src/main.rs | 1010 ++++++++++++++++++++++++++++++++++---------------------
src/project.rs | 872 +++++++++--------------------------------------
src/shortcut.rs | 7 +
src/window.rs | 28 +-
10 files changed, 1547 insertions(+), 1526 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 9633bfd..7eaab2f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -186,7 +186,7 @@ gone from cce-ui with the wgpu path).
instead of orbiting the camera. The framework owns everything that turned out
to be the same for any such tool: projection of world positions to handles
through `State::last_scene_mvp` + `last_scene_view_rect` (both LOGICAL px,
- the rect divided by scale where it is cached — the same path as the meta
+ the rect divided by scale where it is cached — the same path as the
Point Numbers overlay), hit-testing against `cursor_x/y`, dragging by
unprojecting the cursor at the grabbed handle's captured NDC depth, snapping,
the HUD, per-gesture undo (`cce_ui::history::History` of handle snapshots on
@@ -236,53 +236,101 @@ 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 meta node (nee Session)
-
-Session-wide settings live under one permanent root node: `meta` (node type
-`meta` — retyped/renamed from the old `Session`/`session` on load, children
-intact) contains the main/view/guides/render utility subnets that used to
-sit flat in `/`. It is the root network's counterpart of every node's
-per-node `meta` child, but still a subnet. `ensure_menubar_subnets` creates
-it and MIGRATES older saves into it (root-level settings nodes moved, not
-recreated — params survive; a `session`-typed container retypes in place).
-It cannot be deleted: `delete_node` refuses the `meta` (and legacy
-`session`) type — the one gate every deletion route funnels through — the
-context menu omits Delete, and the graph draws it without a geometry
-toggle. `State::session_node()` / `in_settings_dir()` are the accessors —
-the latter walks the whole `current_path`, since a first-segment check
-stopped working the day the settings nodes gained a parent. Guides holds
-"Point Marker Size" (thousandths of a world unit), driving the per-node
-meta Point Markers overlay via `State::meta_marker_size`, and **"World Unit"**
-(a `choice`: mm / cm / m / in, `State::world_unit`) — what one world unit IS.
-Geometry never converts; the declaration feeds two things through the display
-metric (`cce_ui::units`): the viewport's bottom-left **scale readout**
-(`append_scale_readout`: `1:2.3`, `1 mm = 0.43 mm on screen`, marked when the
-metric is only assumed) and the viewport context menu's **View 1:1**
-(`view_one_to_one`), which moves the active camera along its eye ray so the
-pivot plane shows one world unit at its true length — the default camera by
-zoom, a camera node by rewriting its Position, as Frame All does. The
-projection is a perspective (vertical FOV 0.9 rad), so 1:1 holds on the
-pivot plane only; `view_scale_ratio` is the readout's number.
-
-### The meta node (per-node preferences)
-
-Every geometry-producing node carries a **`meta` child** (node type `meta`) —
-per-node preferences, edited by entering the node and selecting it. Current
-prefs: "Point Markers" and "Point Numbers" (viewport overlays on that node's
-output; numbers project through the cached raster mvp into the 2D text pass,
-`append_meta_point_numbers`). `ensure_meta_on` / `ensure_meta_children`
-(src/app.rs) create it at instantiation and at every project load — the same
-migration pattern as the Session node — and also restore missing pref params,
-so adding a pref is one entry in `ensure_meta_on`'s list plus its consumer.
-`meta_pref(node, name)` is the read. Meta is undeletable (the `delete_node`
-gate alongside `session`), shows no geometry toggle, and is invisible to
-evaluation. Overlay data rebuilds with the scene (`collect_meta_overlays` in
-src/render.rs, walked with the scene's visibility chain).
+### There are no meta nodes (retired 2026-09-23)
+
+Two different things were called `meta`, and both are gone. What replaced
+them is the one rule worth remembering: **a display setting belongs to the
+view, so it is a live field on `State`, persisted to `state.kdl`, and
+reached from the command palette.** Never a node.
+
+**The root `meta` node (nee Session)** was a permanent, undeletable root
+subnet holding four utility subnets — `main`, `view`, `guides`, `render` —
+whose params were every session-wide setting. It was the STORE OF RECORD:
+`ensure_menubar_subnets` rebuilt it from live state and
+`apply_settings_from_menubar_subnets` copied it back OVER live state after
+every parameter edit anywhere. Three things followed, all bad. A display
+preference was project data, carried in the file and reset by opening
+someone else's scene. Half of those settings were reachable only by finding
+the right node in the right subnet. And a command that flipped a live flag
+was undone by the next unrelated edit unless it also wrote the node — which
+is what `write_guides_toggle` / `write_render_toggle` existed for, and what
+made "Show Cube hides the cube until you touch any parameter" a real bug.
+
+**The per-node `meta` child** was a hidden child on every geometry node
+carrying four display switches (Point Markers, Point Numbers, Point Normals,
+Wireframe), so seeing the point numbering of what was on screen meant diving
+into each node and flipping its own switch, one node at a time. Wireframe
+was already duplicated by a global `toggle_wireframe`.
+
+Where it all went:
+
+- **Display settings** are live `State` fields, persisted by
+ `DesignSettings` into `state.kdl` (`viewport` and the new `render` block),
+ and edited in the dialog's **Settings** half — `SETTINGS` in
+ `src/dialog.rs`, whose rows are `Owner::Field` (a live field, with a `Ctl`
+ saying what control draws it), `Owner::Command` (a registry toggle, read
+ through `command_toggle_state`), or `Owner::ActiveCamera`. The table is now
+ the app's whole display configuration, so a value left out of it is GONE,
+ not merely hidden — `every_retired_subnet_setting_is_reachable` is the
+ backstop, and `dialog_settings_rows_name_owners_that_exist` round-trips
+ every `Field` row because a key no dispatch arm names draws, accepts an
+ edit and does nothing.
+- **The three point overlays** are `toggle_point_markers` / `_numbers` /
+ `_normals`, collected in `rebuild_scene_geometry` off the merged scene
+ `Detail` (`render::scene_point_overlays`) rather than by a second walk that
+ re-evaluated every flagged node. **Wireframe folded into the existing
+ `toggle_wireframe`**, and the survivor draws the TOPOLOGICAL edge list
+ (`render::scene_edge_verts`) the per-node flag used, not the triangle soup
+ the global one did — shared edges once, quads as quads.
+- **Main's buttons** (New/Open/Save/Save As/Set As Default/Exit, Undo/Redo,
+ the zoom family, Detach Circular Window) were already registry commands.
+ Three settings that were toggles on those nodes and reachable NOWHERE else
+ became commands: `toggle_ray_traced_preview`, `toggle_wire_single_color`,
+ `toggle_render_points`.
+- **The recent-projects list** was the Main node's "Open" dropdown, which
+ would have left `recent_files` written and read by nothing. It is rows at
+ the head of the palette's Commands list (`RECENT_ROW_PREFIX`), under the
+ open project's own path row; picking one opens it.
+- **Pane visibility** was the `view` subnet's five toggles riding `fs_root`
+ into the file. It is genuinely project state, so it moved to
+ `ProjectViewState::visible_panes` beside the collapse list and the
+ splitters. `State::PANE_FLAGS` is the one table the save and the load share.
+- **The active camera** keeps the viewport menubar's own menu, whose entries
+ are the camera NODES — not something a fixed table can hold.
+
+`Project::migrate_meta_settings_node` runs on every load: it takes the meta
+node (and the four subnets, which PRE-Session saves parked flat at the root —
+hence no early return on the container alone), reads its values onto the live
+state, and saves them to `state.kdl`. Per-node children go in
+`app::strip_meta_children`, called from `merge_template_defs` because that is
+the one function every deserialization runs. Their VALUES are dropped
+deliberately: four per-node booleans do not reduce to one global switch, and
+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`
+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
+only pinned nodes were these).
+
+**"World Unit"** (mm / cm / m / in, `State::world_unit`) survives as a
+Settings row — what one world unit IS. Geometry never converts; the
+declaration feeds two things through the display metric (`cce_ui::units`):
+the viewport's bottom-left **scale readout** (`append_scale_readout`:
+`1:2.3`, `1 mm = 0.43 mm on screen`, marked when the metric is only assumed)
+and the viewport context menu's **View 1:1** (`view_one_to_one`), which moves
+the active camera along its eye ray so the pivot plane shows one world unit
+at its true length — the default camera by zoom, a camera node by rewriting
+its Position, as Frame All does. The projection is a perspective (vertical
+FOV 0.9 rad), so 1:1 holds on the pivot plane only; `view_scale_ratio` is the
+readout's number.
### App-written settings: `~/.config/cce/cce-designer/state.kdl`
`default_project` in state.kdl points at the project the main window opens on
-startup (the Main node's File > "Set As Default" button; absent = the bundled
+startup (the `set_as_default` command; absent = the bundled
`default_project.json`). It is a POINTER, never a rewrite of
default_project.json — that file is versioned and is the detached-window sync
channel. Detached windows ignore it: they must keep seeding from the sync
@@ -313,9 +361,10 @@ files migrate on load. Scroll behavior (`scroll_speed`, `inertial_scroll`,
this one hardcoded `$HOME/.config` until 2026-09-23 and was the only holdout.
**And under `cfg(test)` it is a temp directory**, which is the part worth
-knowing. `State::new` loads the bundled project, and
-`apply_settings_from_menubar_subnets` copies that project's meta subnets over
-the live viewport flags; so any test that then reached `save_settings` —
+knowing. `State::new` loads the bundled project, whose meta subnets used to
+be copied over the live viewport flags after every parameter change (the
+meta node is retired, but the hazard was real and this redirect is what
+caught it); so any test that then reached `save_settings` —
`run_command("toggle_network_plate")`, the dialog's toggle rows — wrote the
BUNDLED project's show_grid / show_cube / show_origin over the user's real
state.kdl. `cargo test` reset three of the user's own toggles on every run,
@@ -566,8 +615,7 @@ Scatter SOP with Relax Points) beside its original Volume mode, and
tangent plane unless In 3D Space; zero iterations is off) beside its
original Springs mode. A native `embryo` in an older save is recomposed on
load (`recompose_native_embryo` in `merge_template_defs`): id, name,
-position, flag, values and meta child carry over, the template's children
-arrive fresh.
+position, flag and values carry over, the template's children arrive fresh.
Two deliberate differences from the HDA. **Subdivide does not smooth**: it
is this app's `remesh::subdivide` (four triangles per triangle, points
@@ -1257,25 +1305,25 @@ sees one (`input.kdl`'s `cce-window-manager` domain has `super+d` on the app
launcher), and Super held is the DE's window-adjust modifier besides. Alt is the
app's own — the `move_*` family already lives there.
-**A Settings row edits the meta node, never the live field.** The values behind
-those rows have exactly one owner, and it is not `State` and not
-`DesignSettings`: `apply_settings_from_menubar_subnets` copies the utility
-subnets (`Main`, `View`, `Guides`) onto the live state on EVERY param change, so
-a write straight to `State::grid_thickness` survives until the next one and no
-longer. `SETTINGS` is the table of which row belongs to which owner, and `Owner`
-has three arms for the three kinds there turn out to be: a subnet param, a
-registry command (Square Aspect and Show Camera Pivot are per-CAMERA, with no
-node at all behind the Default Camera — their commands are the only code that
+**A Settings row edits the live field** — see "There are no meta nodes"
+above, which is where these values used to live and why a direct write did
+not stick. `SETTINGS` is the table of which row belongs to which owner, and
+`Owner` has three arms for the three kinds there turn out to be: a live
+field (`Field`, with a `Ctl` saying what control draws it, since a bare Rust
+field carries no type or range the way a param did), a registry command
+(`Command` — Square Aspect and Show Camera Pivot are per-CAMERA, with no node
+at all behind the Default Camera, and their commands are the only code that
gets both cases right), and an active-camera param with the live field as its
-fallback. Writeback is `sync_parameters_to_project`'s shape, polled rather than
-pushed for the same reason: a `ParametersBg` reports its values, it does not
-emit events. `dialog_settings_rows_name_owners_that_exist` is the backstop,
-because the failure is silent — `dialog_settings_params` SKIPS a row whose param
-it cannot find, so a rename quietly shortens the Settings half.
+fallback. Writeback is `sync_parameters_to_project`'s shape, polled rather
+than pushed for the same reason: a `ParametersBg` reports its values, it does
+not emit events. `dialog_settings_rows_name_owners_that_exist` is the
+backstop, because the failure is silent — a `Field` key no dispatch arm names
+reads a default and writes nowhere, so the row draws, takes an edit and does
+nothing, which is why that test round-trips every one of them.
**The dialog is painted after the overlay passes, not in the widget walk.** A
high `z_order` is not enough: `append_frame_text`, `append_scale_readout` and the
-meta-point overlays all run AFTER the whole walk, so the graph's node labels drew
+point-number overlay all run AFTER the whole walk, so the graph's node labels drew
straight over a dialog that had already covered them. `append_dialog` runs
between the plate corners and the context menu instead.
@@ -1351,9 +1399,7 @@ lowercase, as Houdini's are, and carry no spaces (since 2026-09-21).
between a template name and its index goes ("Sphere 1" → "sphere1", which
is also what minting now produces), any other whitespace becomes an
underscore ("My Region" → "my_region"), the whole thing is lowercased, and
-empty comes back as `node`. The app's OWN nodes follow it — the root meta
-node's utility subnets are `main`, `view`, `guides` and `render`, and every
-lookup names them so — because a path convention with exceptions is two
+empty comes back as `node`, because a path convention with exceptions is two
conventions. The template merge matches an instance to its template
case-insensitively ("sphere3" → "Sphere"). It runs at every entry point — minting, the
`add_node` name override, `rename_node` — and as a LOAD-TIME MIGRATION on
diff --git a/src/api.rs b/src/api.rs
index 67b2a77..a33c374 100644
--- a/src/api.rs
+++ b/src/api.rs
@@ -92,7 +92,7 @@ pub(crate) fn mcp_tools() -> Vec<McpTool> {
),
tool(
"toggle_geometry",
- "Toggle geometry visibility for the node at the given slot (not valid on utility nodes).",
+ "Toggle geometry visibility for the node at the given slot.",
json!({
"type": "object",
"properties": { "slot": slot("Child index in the current network level") },
diff --git a/src/app.rs b/src/app.rs
index 7bffd1d..8fa9922 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -198,7 +198,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" | "utility" | "simnet" | "session")
+ matches!(self.node_type.as_str(), "node" | "simnet")
|| !self.children.is_empty()
}
@@ -247,6 +247,17 @@ pub struct ProjectViewState {
pub current_path: Vec<usize>,
#[serde(default)]
pub selected_node: Option<usize>,
+ /// Which panes are OPEN, by name ("network", "viewport", "parameters",
+ /// "spreadsheet", "playbar").
+ ///
+ /// Pane visibility rode the root meta node's `view` subnet params into
+ /// the file until 2026-09-23 — five toggles on a node that existed to
+ /// hold them. It is pane state like the collapse list and the splitter
+ /// proportions below, so it sits with them. `None` (older saves, and
+ /// saves written before the move) keeps the live layout, which is what
+ /// the absent-value case always did.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub visible_panes: Option<Vec<String>>,
/// Collapsed plate panes by name ("network", "parameters", "spreadsheet",
/// "playbar"). Absent from older saves — an empty list expands everything,
/// so loading is deterministic either way.
@@ -289,7 +300,7 @@ pub struct ProjectViewState {
/// camera-pivot marker and its size, and the view itself (orbit, zoom,
/// pivot). Absent in older saves keeps the live values. A named camera's
/// own params still win over these when it is active and in the
- /// directory (`apply_settings_from_menubar_subnets`).
+ /// directory.
#[serde(default)]
pub default_view: Option<DefaultCameraView>,
}
@@ -340,7 +351,7 @@ pub struct Project {
pub enum NodeMenuAction {
/// Dive into the node's subnet (the double-click behavior).
Enter,
- /// Flip the node's geometry visibility (utility nodes excluded).
+ /// Flip the node's geometry visibility.
ToggleGeometry,
/// Enter/exit the curve viewer state (curve nodes only).
EditCurve,
@@ -586,9 +597,8 @@ pub fn strip_meta_children(root: &mut FsNode) {
/// between a template's name and its index ("Sphere 1"), simply goes, so a
/// migrated save reads like a fresh one; any other whitespace becomes an
/// underscore, so "My Region" keeps its two words. And the whole thing is
-/// lowercased, as Houdini names its nodes (`sphere1`, `camera1`) — the
-/// app's own utility nodes included (`/meta/guides`), since a path
-/// convention with exceptions is two conventions. Empty comes back as
+/// lowercased, as Houdini names its nodes (`sphere1`, `camera1`), since a
+/// path convention with exceptions is two conventions. Empty comes back as
/// `node`, since a node with no name has no path at all.
pub fn sanitize_node_name(name: &str) -> String {
let lowered = name.to_lowercase();
@@ -725,10 +735,6 @@ pub fn merge_template_defs(root: &mut FsNode, templates: &[NodeTemplate]) {
fp.default = p.default.clone();
}
}
- if let Some(meta) = c.children.iter().find(|m| m.node_type == "meta") {
- fresh.children.retain(|m| m.node_type != "meta");
- fresh.children.push(meta.clone());
- }
*c = fresh;
}
}
@@ -947,6 +953,97 @@ pub struct ViewportSettings {
pub show_point_numbers: bool,
#[serde(default)]
pub show_point_normals: bool,
+ /// World-unit radius and colour of the Show Point Markers overlay.
+ #[serde(default = "default_point_marker_size")]
+ pub point_marker_size: f32,
+ #[serde(default = "default_point_marker_color")]
+ pub point_marker_color: [f32; 3],
+ /// What one world unit IS (mm / cm / m / in). A DECLARATION — geometry
+ /// never converts; it feeds the scale readout and `View 1:1`.
+ #[serde(default = "default_world_unit")]
+ pub world_unit: String,
+ /// The path-traced preview.
+ #[serde(default)]
+ pub rt_mode: bool,
+ /// The network pane's circular shape.
+ #[serde(default)]
+ pub circular_pane: bool,
+}
+
+fn default_point_marker_size() -> f32 {
+ 0.02
+}
+
+fn default_point_marker_color() -> [f32; 3] {
+ [0.85, 0.85, 1.0]
+}
+
+fn default_world_unit() -> String {
+ "mm".to_string()
+}
+
+/// How the geometry itself is drawn — the wire pass and the point display.
+///
+/// These lived on the root meta node's `render` utility subnet until
+/// 2026-09-23, which made them per-PROJECT: opening someone else's scene
+/// reset how you looked at geometry. They are display preferences like the
+/// guides, so they persist here, and the dialog's Settings half is where
+/// they are edited.
+#[derive(Serialize, Deserialize, Clone, Debug)]
+pub struct RenderSettings {
+ #[serde(default)]
+ pub wireframe: bool,
+ #[serde(default)]
+ pub wire_single_color: bool,
+ /// RGBA: the alpha is the wireframe's OWN opacity, in both colour modes
+ /// (the geometry Opacity below is polygons-only).
+ #[serde(default = "default_wire_color")]
+ pub wire_color: [f32; 4],
+ #[serde(default = "default_wire_width")]
+ pub wire_width: f32,
+ #[serde(default = "default_geo_opacity")]
+ pub geo_opacity: f32,
+ #[serde(default)]
+ pub render_points: bool,
+ #[serde(default = "default_point_size")]
+ pub point_size: f32,
+ #[serde(default = "default_point_color")]
+ pub point_color: [f32; 3],
+}
+
+fn default_wire_color() -> [f32; 4] {
+ [0.0, 0.0, 0.0, 1.0]
+}
+
+fn default_wire_width() -> f32 {
+ 1.0
+}
+
+fn default_geo_opacity() -> f32 {
+ 1.0
+}
+
+fn default_point_size() -> f32 {
+ 0.02
+}
+
+fn default_point_color() -> [f32; 3] {
+ [1.0, 1.0, 1.0]
+}
+
+impl Default for RenderSettings {
+ fn default() -> Self {
+ Self {
+ wireframe: false,
+ wire_single_color: false,
+ wire_color: default_wire_color(),
+ wire_width: default_wire_width(),
+ geo_opacity: default_geo_opacity(),
+ render_points: false,
+ point_size: default_point_size(),
+ point_color: default_point_color(),
+ }
+ }
}
fn default_network_plate() -> bool {
@@ -967,6 +1064,11 @@ impl Default for ViewportSettings {
show_point_markers: false,
show_point_numbers: false,
show_point_normals: false,
+ point_marker_size: default_point_marker_size(),
+ point_marker_color: default_point_marker_color(),
+ world_unit: default_world_unit(),
+ rt_mode: false,
+ circular_pane: false,
origin_size: 1.0,
grid_thickness: default_grid_thickness(),
grid_color: default_grid_color(),
@@ -1043,6 +1145,8 @@ pub const MAX_PITCH_Y: f32 = 375.0;
pub struct DesignSettings {
#[serde(default)]
pub viewport: ViewportSettings,
+ #[serde(default)]
+ pub render: RenderSettings,
/// Project to open at startup instead of the bundled default — the Main
/// node's "Set As Default" button. A path string (what
/// `loaded_project_path` held when it was set); absent = the bundled
@@ -1064,6 +1168,22 @@ fn hex_to_float_array(hex: &str) -> Option<[f32; 3]> {
cce_ui::color::parse_hex_rgb(hex)
}
+fn float_array_to_hex4(rgba: &[f32; 4]) -> String {
+ let c = |v: f32| (v * 255.0).clamp(0.0, 255.0).round() as u8;
+ format!("#{:02x}{:02x}{:02x}{:02x}", c(rgba[0]), c(rgba[1]), c(rgba[2]), c(rgba[3]))
+}
+
+fn hex_to_float_array4(hex: &str) -> Option<[f32; 4]> {
+ let h = hex.trim_start_matches('#');
+ if h.len() == 8 {
+ let v = |i: usize| u8::from_str_radix(&h[i..i + 2], 16).ok().map(|b| b as f32 / 255.0);
+ Some([v(0)?, v(2)?, v(4)?, v(6)?])
+ } else {
+ let rgb = cce_ui::color::parse_hex_rgb(hex)?;
+ Some([rgb[0], rgb[1], rgb[2], 1.0])
+ }
+}
+
/// Where `cfg(test)` builds keep the files the installed app keeps under
/// `<config home>/cce/cce-designer/` — a directory of this TEST's own,
/// created on first use.
@@ -1112,9 +1232,10 @@ impl DesignSettings {
/// The same file under test, in a temp directory — and that redirect is
/// not a convenience.
///
- /// `State::new` loads the BUNDLED project, whose meta subnets overwrite
- /// the live viewport flags through `apply_settings_from_menubar_subnets`.
- /// So any test that then reached `save_settings` — `toggle_network_plate`,
+ /// `State::new` loads the BUNDLED project, whose meta subnets overwrote
+ /// the live viewport flags on every parameter change (the meta node is
+ /// retired, but the hazard was real and the redirect is what caught it).
+ /// Any test that then reached `save_settings` — `toggle_network_plate`,
/// the dialog's toggle rows — wrote the bundled project's
/// show_grid / show_cube / show_origin over the user's own state.kdl.
/// `cargo test` reset three of the user's toggles on every run, and
@@ -1135,24 +1256,32 @@ impl DesignSettings {
Some(Self::from_kdl_str(&content))
}
+ /// Every colour field, as `(block, field, components)`. KDL carries them
+ /// as hex strings — `#rrggbb`, or `#rrggbbaa` for the four-component wire
+ /// colour — so both directions walk this one table. It was a hand-written
+ /// pair of `if let`s per colour, which is why only two of the five were
+ /// ever converted once the render block arrived.
+ const COLOR_FIELDS: &'static [(&'static str, &'static str, usize)] = &[
+ ("viewport", "bg_color", 3),
+ ("viewport", "grid_color", 3),
+ ("viewport", "point_marker_color", 3),
+ ("render", "wire_color", 4),
+ ("render", "point_color", 3),
+ ];
+
pub(crate) fn from_kdl_str(content: &str) -> Self {
let mut json_val = cce_ui::config::parse_kdl_to_json(content);
- // Convert hex strings back to color arrays
if let Some(obj) = json_val.as_object_mut() {
- if let Some(viewport) = obj.get_mut("viewport").and_then(|v| v.as_object_mut()) {
- if let Some(serde_json::Value::String(hex_str)) = viewport.get("bg_color") {
- if let Some(arr) = hex_to_float_array(hex_str) {
- if let Ok(arr_val) = serde_json::to_value(arr) {
- viewport.insert("bg_color".to_string(), arr_val);
- }
- }
- }
- if let Some(serde_json::Value::String(hex_str)) = viewport.get("grid_color") {
- if let Some(arr) = hex_to_float_array(hex_str) {
- if let Ok(arr_val) = serde_json::to_value(arr) {
- viewport.insert("grid_color".to_string(), arr_val);
- }
- }
+ for &(block, field, n) in Self::COLOR_FIELDS {
+ let Some(b) = obj.get_mut(block).and_then(|v| v.as_object_mut()) else { continue };
+ let Some(serde_json::Value::String(hex)) = b.get(field) else { continue };
+ let parsed = if n == 4 {
+ hex_to_float_array4(hex).and_then(|a| serde_json::to_value(a).ok())
+ } else {
+ hex_to_float_array(hex).and_then(|a| serde_json::to_value(a).ok())
+ };
+ if let Some(v) = parsed {
+ b.insert(field.to_string(), v);
}
}
}
@@ -1196,20 +1325,17 @@ impl DesignSettings {
pub(crate) fn to_kdl_str(&self) -> Option<String> {
if let Ok(mut json_val) = serde_json::to_value(self) {
- // Convert color arrays to hex strings
if let Some(obj) = json_val.as_object_mut() {
- if let Some(viewport) = obj.get_mut("viewport").and_then(|v| v.as_object_mut()) {
- if let Some(val) = viewport.get("bg_color") {
- if let Ok(arr) = serde_json::from_value::<[f32; 3]>(val.clone()) {
- let hex_str = float_array_to_hex(&arr);
- viewport.insert("bg_color".to_string(), serde_json::Value::String(hex_str));
- }
- }
- if let Some(val) = viewport.get("grid_color") {
- if let Ok(arr) = serde_json::from_value::<[f32; 3]>(val.clone()) {
- let hex_str = float_array_to_hex(&arr);
- viewport.insert("grid_color".to_string(), serde_json::Value::String(hex_str));
- }
+ for &(block, field, n) in Self::COLOR_FIELDS {
+ let Some(b) = obj.get_mut(block).and_then(|v| v.as_object_mut()) else { continue };
+ let Some(val) = b.get(field).cloned() else { continue };
+ let hex = if n == 4 {
+ serde_json::from_value::<[f32; 4]>(val).ok().map(|a| float_array_to_hex4(&a))
+ } else {
+ serde_json::from_value::<[f32; 3]>(val).ok().map(|a| float_array_to_hex(&a))
+ };
+ if let Some(hex) = hex {
+ b.insert(field.to_string(), serde_json::Value::String(hex));
}
}
}
@@ -1887,26 +2013,6 @@ impl State {
- pub fn update_recent_files_layout(&mut self) {
- let mut opts = vec!["- Select -".to_string()];
- for path in &self.recent_files {
- opts.push(path.to_string_lossy().to_string());
- }
- opts.push("Other".to_string());
-
- let main_node = self
- .session_node_mut()
- .and_then(|s| s.children.iter_mut().find(|c| c.name == "main"));
- if let Some(main_node) = main_node {
- if let Some(p) = main_node.params.iter_mut().find(|p| p.name == "Open") {
- p.options = opts;
- if !p.options.contains(&p.default) {
- p.default = "- Select -".to_string();
- }
- }
- }
- self.sync_parameters_pane();
- }
pub fn save_settings(&mut self) {
let settings = DesignSettings {
@@ -1925,6 +2031,21 @@ impl State {
show_point_markers: self.show_point_markers,
show_point_numbers: self.show_point_numbers,
show_point_normals: self.show_point_normals,
+ point_marker_size: self.point_marker_size,
+ point_marker_color: self.point_marker_color,
+ world_unit: self.world_unit.suffix().to_string(),
+ rt_mode: self.viewport().rt_mode,
+ circular_pane: self.circular_network_pane,
+ },
+ render: RenderSettings {
+ wireframe: self.wireframe,
+ wire_single_color: self.wire_single_color,
+ wire_color: self.wire_color,
+ wire_width: self.wire_width,
+ geo_opacity: self.geo_opacity,
+ render_points: self.render_points,
+ point_size: self.point_size,
+ point_color: self.point_color,
},
default_project: self.default_project_setting.clone(),
};
@@ -2478,37 +2599,6 @@ impl State {
self.splitter_layout.clamp(self.width, self.detached_circular_network);
}
- /// The Session node: the permanent root container for the session-wide
- /// settings nodes (Main/View/Guides/Render). `ensure_menubar_subnets`
- /// guarantees it exists, so `None` only before the first ensure.
- pub fn session_node(&self) -> Option<&FsNode> {
- self.fs_root.children.iter().find(|c| c.node_type == "meta")
- }
-
- pub fn session_node_mut(&mut self) -> Option<&mut FsNode> {
- self.fs_root.children.iter_mut().find(|c| c.node_type == "meta")
- }
-
- /// Is the network currently inside a settings directory (the Session node
- /// or any utility node)? Geometry templates are refused there. Checks the
- /// whole path, not `current_path[0]` — the settings nodes live NESTED
- /// under Session now, so the old first-segment check would miss them.
- pub fn in_settings_dir(&self) -> bool {
- let mut node = &self.fs_root;
- for &idx in &self.current_path {
- match node.children.get(idx) {
- Some(child) => {
- if matches!(child.node_type.as_str(), "utility" | "session" | "meta") {
- return true;
- }
- node = child;
- }
- None => return false,
- }
- }
- false
- }
-
pub fn current_dir(&self) -> &FsNode {
let mut node = &self.fs_root;
for &i in &self.current_path {
@@ -2730,7 +2820,6 @@ impl State {
}
if param_changed {
- self.apply_settings_from_menubar_subnets();
self.sync_grid_settings();
self.rebuild_scene_geometry();
self.sync_nodes();
@@ -3153,91 +3242,14 @@ impl State {
}
}
- /// Write a toggle on the session's Render utility node — the source
- /// `apply_settings_from_menubar_subnets` reads render settings from —
- /// so a command that changed the live state leaves the node agreeing
- /// with it. Nothing when the project has no Render node yet.
- pub(crate) fn write_render_toggle(&mut self, name: &str, val: bool) {
- self.write_meta_toggle("render", name, val);
- }
- /// The Guides node's counterpart: what Show Grid, Show Cube and Show
- /// Origin write, for the same reason Show Wireframe writes the Render
- /// node — see [`State::write_meta_toggle`].
- pub(crate) fn write_guides_toggle(&mut self, name: &str, val: bool) {
- self.write_meta_toggle("guides", name, val);
- }
- /// Write a toggle's value onto the utility subnet that OWNS it.
- ///
- /// A command that flips only the live flag has flipped it until the next
- /// parameter edit anywhere: `apply_settings_from_menubar_subnets` copies
- /// the meta node's subnets onto the live state on every change, so the
- /// stored value wins and the toggle silently reverts. Show Cube did
- /// exactly that on 2026-09-21 — hidden by its command, back the moment a
- /// node's parameter was edited. The subnet param is the value's one
- /// owner (the dialog's Settings half writes there too), so a command
- /// that changes the value writes it there.
- pub(crate) fn write_meta_toggle(&mut self, subnet: &str, name: &str, val: bool) {
- if let Some(p) = self
- .fs_root
- .children
- .iter_mut()
- .find(|c| c.node_type == "meta")
- .and_then(|s| s.children.iter_mut().find(|c| c.name == subnet))
- .and_then(|n| n.params.iter_mut().find(|p| p.name == name))
- {
- p.default = if val { "true" } else { "false" }.to_string();
- }
- }
- pub(crate) fn refresh_main_node_live_toggles(&mut self, slot_idx: usize) {
- let live_main: [(&str, bool); 2] = [
- ("Circular Pane", self.circular_network_pane),
- ("Ray Traced Preview", self.viewport().rt_mode),
- ];
- let live_view: [(&str, bool); 6] = [
- ("Show Network Plate", self.network_plate),
- ("Show Network Pane", self.show_network),
- ("Show Viewport Pane", self.show_viewport),
- ("Show Parameters Pane", self.show_parameters),
- ("Show Spreadsheet Pane", self.show_spreadsheet),
- ("Show Playbar Pane", self.show_playbar),
- ];
- let live_guides: [(&str, bool); 3] = [
- ("Show Grid Guide", self.viewport().show_grid),
- ("Show Reference Cube", self.viewport().show_cube),
- ("Show Origin Axes", self.viewport().show_origin),
- ];
- let live_render: [(&str, bool); 2] = [
- ("Show Wireframe", self.wireframe),
- ("Wire Single Color", self.wire_single_color),
- ];
- let dir = self.param_editor_dir_mut();
- let Some(child) = dir.children.get_mut(slot_idx) else { return };
- let live: &[(&str, bool)] = match child.name.as_str() {
- "main" => &live_main,
- "view" => &live_view,
- "guides" => &live_guides,
- "render" => &live_render,
- _ => return,
- };
- for &(name, on) in live {
- if let Some(p) = child.params.iter_mut().find(|p| p.name == name && p.param_type == "toggle") {
- p.default = if on { "true" } else { "false" }.to_string();
- }
- }
- }
pub fn sync_parameters_pane(&mut self) {
// Selection reads through the param-editor accessors: whichever
// network editor took the last node click feeds the pane, at ITS
// level — no matter the tab.
- if !self.is_detached_network {
- if let Some(slot_idx) = self.param_editor_selected() {
- self.refresh_main_node_live_toggles(slot_idx);
- }
- }
let params = if !self.is_detached_network {
if let Some(slot_idx) = self.param_editor_selected() {
let dir = self.param_editor_dir();
@@ -3517,7 +3529,7 @@ impl State {
/// Open the node right-click context menu at the cursor for `slot`. The
/// items are contextual: Enter (dive into the subnet) for enterable nodes,
- /// Show/Hide Geometry for non-utility nodes, and Delete always.
+ /// Show/Hide Geometry, and Delete.
fn open_node_context_menu(&mut self, slot: usize) {
let (is_utility, geom_visible, enterable, curve_editing) = {
let dir = self.current_dir();
@@ -3531,7 +3543,7 @@ impl State {
self.viewer_tool.as_ref().map(|t| t.node_id == node.id).unwrap_or(false)
});
(
- matches!(node.node_type.as_str(), "utility" | "session" | "meta"),
+ false,
node.geometry_visible,
enterable,
curve_editing,
@@ -3541,7 +3553,7 @@ impl State {
let dir = self.current_dir();
dir.children
.get(slot)
- .map(|n| !matches!(n.node_type.as_str(), "session" | "meta"))
+ .map(|_| true)
.unwrap_or(false)
};
let mut options: Vec<String> = Vec::new();
@@ -4241,14 +4253,6 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
pub fn delete_node(&mut self, slot: usize) -> bool {
let len = self.current_dir().children.len();
- // The root meta node (nee Session) is permanent: every deletion
- // route (context menu, Delete key, MCP) funnels through here, so
- // this is the one gate.
- if slot < len
- && matches!(self.current_dir().children[slot].node_type.as_str(), "session" | "meta")
- {
- return false;
- }
if slot < len {
self.current_dir_mut().children.remove(slot);
if let Some(sel_idx) = self.graph().selected_node() {
@@ -4580,6 +4584,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
viewport.show_camera_pivot = settings.viewport.show_camera_pivot_enabled;
viewport.bg_color = settings.viewport.bg_color;
viewport.grid_color = settings.viewport.grid_color;
+ viewport.rt_mode = settings.viewport.rt_mode;
viewport.active_camera = active_camera.clone();
}
@@ -4742,7 +4747,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
graph_inertial_scroll: true,
graph_scroll_friction: 0.90,
last_config_read: Instant::now(),
- circular_network_pane: is_detached_network,
+ circular_network_pane: is_detached_network || settings.viewport.circular_pane,
circular_network_layout: cce_ui::layout::CircularPaneLayout::new(250.0, 300.0, 180.0),
is_detached_network,
detached_circular_network: false,
@@ -4814,20 +4819,20 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
last_viewport_height: 0,
last_viewport_active_camera: String::new(),
last_viewport_show_viewport: false,
- wireframe: false,
+ wireframe: settings.render.wireframe,
last_viewport_wireframe: false,
- wire_single_color: false,
- wire_color: [1.0, 1.0, 1.0, 1.0],
- wire_width: 1.0,
+ wire_single_color: settings.render.wire_single_color,
+ wire_color: settings.render.wire_color,
+ wire_width: settings.render.wire_width,
last_viewport_wire_single_color: false,
last_viewport_wire_color: [1.0, 1.0, 1.0, 1.0],
last_applied_wire_color: None,
last_viewport_wire_width: 1.0,
- geo_opacity: 1.0,
+ geo_opacity: settings.render.geo_opacity,
last_viewport_geo_opacity: 1.0,
- render_points: false,
- point_size: 0.02,
- point_color: [1.0, 1.0, 1.0],
+ render_points: settings.render.render_points,
+ point_size: settings.render.point_size,
+ point_color: settings.render.point_color,
last_points_key: None,
point_vertex_count: 0,
last_viewport_render_points: false,
@@ -4847,9 +4852,10 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
show_point_numbers: settings.viewport.show_point_numbers,
show_point_normals: settings.viewport.show_point_normals,
scene_edge_verts: Vec::new(),
- point_marker_size: 0.02,
- point_marker_color: [0.85, 0.85, 1.0],
- world_unit: cce_ui::units::Unit::Mm,
+ point_marker_size: settings.viewport.point_marker_size,
+ point_marker_color: settings.viewport.point_marker_color,
+ world_unit: cce_ui::units::Unit::parse(&settings.viewport.world_unit)
+ .unwrap_or(cce_ui::units::Unit::Mm),
pick_cache: None,
last_scene_mvp: None,
last_scene_view_rect: (0.0, 0.0, 0.0, 0.0),
@@ -4865,8 +4871,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
state.update_graph_settings_from_config();
state.update_window_title();
colors::set_node_color(state.node_color);
- state.ensure_menubar_subnets();
- state.apply_settings_from_menubar_subnets();
+ state.migrate_meta_settings_node();
state.sync_nodes();
state.rebuild_scene_geometry();
state.sync_grid_settings();
@@ -5786,7 +5791,6 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
widget.set_rect(x, y, w, h);
}
}
- self.update_recent_files_layout();
}
pub fn update_panel_bounds(&mut self) {
@@ -5895,7 +5899,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
.map(|p| p.default.clone()),
position: c.position,
// Utility trees stay where they were put; see the module doc.
- pinned: matches!(c.node_type.as_str(), "utility" | "session" | "meta"),
+ pinned: false,
})
.collect();
let moved = crate::layout::arrange(&nodes);
@@ -6414,21 +6418,18 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
Action::ToggleGrid => {
let val = !self.viewport().show_grid;
self.viewport_mut().show_grid = val;
- self.write_guides_toggle("Show Grid Guide", val);
self.menu_mut(RIGHT_MENUBAR_IDX).set_item_checked(2, 0, val);
settings_changed = true;
}
Action::ToggleCube => {
let val = !self.viewport().show_cube;
self.viewport_mut().show_cube = val;
- self.write_guides_toggle("Show Reference Cube", val);
self.menu_mut(RIGHT_MENUBAR_IDX).set_item_checked(2, 1, val);
settings_changed = true;
}
Action::ToggleOrigin => {
let val = !self.viewport().show_origin;
self.viewport_mut().show_origin = val;
- self.write_guides_toggle("Show Origin Axes", val);
self.menu_mut(RIGHT_MENUBAR_IDX).set_item_checked(2, 2, val);
settings_changed = true;
}
@@ -6440,14 +6441,8 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
settings_changed = true;
}
Action::ToggleWireframe => {
- // The Render utility node's "Show Wireframe" toggle is the
- // value `apply_settings_from_menubar_subnets` reads back on
- // EVERY parameter edit, so flipping the flag alone would
- // revert on the next unrelated edit: the node's toggle is
- // written too, and the pane shows the switch moved.
let val = !self.wireframe;
self.wireframe = val;
- self.write_render_toggle("Show Wireframe", val);
// The edge list is collected with the scene and dropped
// while the wireframe is off, so switching it on has to
// rebuild — there is nothing staged to draw otherwise.
@@ -6472,6 +6467,22 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
self.rebuild_scene_geometry();
settings_changed = true;
}
+ Action::ToggleRenderPoints => {
+ self.render_points = !self.render_points;
+ self.viewport_dirty = true;
+ settings_changed = true;
+ }
+ Action::ToggleWireSingleColor => {
+ self.wire_single_color = !self.wire_single_color;
+ self.viewport_dirty = true;
+ settings_changed = true;
+ }
+ Action::ToggleRayTracedPreview => {
+ let val = !self.viewport().rt_mode;
+ self.viewport_mut().rt_mode = val;
+ self.viewport_dirty = true;
+ settings_changed = true;
+ }
Action::WireframeColor => self.open_dialog_on_settings(),
Action::ToggleSquareViewport => {
self.square_viewport = !self.square_viewport;
@@ -6520,7 +6531,6 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
// The Main node owns this one, as Guides owns the guide
// toggles above: without the write, the next parameter edit
// put the pane back the way the node said.
- self.write_meta_toggle("main", "Circular Pane", val);
self.menu_mut(LEFT_MENUBAR_IDX).set_item_checked(2, 2, val);
self.rebuild_positions();
self.apply_layout();
@@ -8160,7 +8170,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
for slot_idx in selected {
let visible = target.unwrap_or(false);
let dir = self.current_dir();
- if slot_idx < dir.children.len() && dir.children[slot_idx].node_type != "utility" {
+ if slot_idx < dir.children.len() {
self.current_dir_mut().set_child_geometry_visible(slot_idx, visible);
self.sync_nodes();
self.rebuild_scene_geometry();
@@ -8478,8 +8488,6 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
self.viewport_mut().pending_pitch = 0.0;
}
- self.update_recent_files_layout();
-
// Drag-release fling: the velocity a middle / space+left drag had
// when the button went up keeps the canvas sliding under friction.
// (Wheel and trackpad motion coast inside the Graph widget instead.)
diff --git a/src/command.rs b/src/command.rs
index 4d76fa5..0ca75c9 100644
--- a/src/command.rs
+++ b/src/command.rs
@@ -196,6 +196,9 @@ pub const COMMANDS: &[Command] = &[
Command { id: "toggle_point_markers", label: "Show Point Markers", context: Context::Viewport, run: Run::Key(Action::TogglePointMarkers), default_chord: None },
Command { id: "toggle_point_numbers", label: "Show Point Numbers", context: Context::Viewport, run: Run::Key(Action::TogglePointNumbers), default_chord: None },
Command { id: "toggle_point_normals", label: "Show Point Normals", context: Context::Viewport, run: Run::Key(Action::TogglePointNormals), default_chord: None },
+ Command { id: "toggle_render_points", label: "Show Points", context: Context::Viewport, run: Run::Key(Action::ToggleRenderPoints), default_chord: None },
+ Command { id: "toggle_wire_single_color", label: "Wireframe Single Color", context: Context::Viewport, run: Run::Key(Action::ToggleWireSingleColor), default_chord: None },
+ Command { id: "toggle_ray_traced_preview", label: "Ray Traced Preview", context: Context::Viewport, run: Run::Key(Action::ToggleRayTracedPreview), default_chord: None },
Command { id: "toggle_square_viewport", label: "Square Aspect", context: Context::Viewport, run: Run::Key(Action::ToggleSquareViewport), default_chord: Some("Ctrl+a") },
// --- Parameters ---
diff --git a/src/dialog.rs b/src/dialog.rs
index 8d107a5..20aec18 100644
--- a/src/dialog.rs
+++ b/src/dialog.rs
@@ -11,9 +11,8 @@
//! (`DIALOG_PARAMS_IDX`, a `ParametersBg`) laid out inside this one's body, so
//! a slider in the dialog is the same slider as a slider in the params pane
//! rather than a second implementation that drifts from it. The values behind
-//! those rows stay owned by the meta node's utility subnets, which is where
-//! `apply_settings_from_menubar_subnets` reads them from — see
-//! `State::dialog_settings_rows`.
+//! those rows are the live `State` fields, persisted by `DesignSettings` —
+//! see [`SETTINGS`] and `Owner`.
//!
//! App-owned on the narrow traits wrapped in `Adapted<Dialog>`, like
//! [`crate::playbar::Playbar`], and a subtree painter for the same reason:
@@ -104,7 +103,13 @@ pub struct Row {
/// list, and a list that grows with the window turns into a wall of rows with
/// the one you want somewhere in it.
const DIALOG_W: f32 = 520.0;
-const DIALOG_H: f32 = 420.0;
+/// The plate's height, clamped to the window by [`layout_in`].
+///
+/// 420 until 2026-09-23, which was sized for a Settings half of thirteen
+/// rows. Retiring the root meta node moved everything its four utility
+/// subnets held into that table — it is nearer thirty now — and a list that
+/// shows eight of them is a list you scroll rather than read.
+const DIALOG_H: f32 = 640.0;
const PAD: f32 = 12.0;
/// Tab strip height, and the query line's.
@@ -1091,94 +1096,176 @@ pub const ZOOM_ROW_ID: &str = "zoom_level";
/// commands act on.
pub const PATH_ROW_ID: &str = "project_path";
-/// Where a Settings row's value actually lives.
+/// Prefix of a recent-project row's id; the rest is the path.
+///
+/// The recent list was the Main utility node's "Open" dropdown, and it went
+/// with that node — leaving `State::recent_files` written on every save and
+/// read by nothing. It is a list of documents, so it belongs where the open
+/// document's own path already is: rows under the path row, each opening its
+/// project. Ranked against the path text like everything else.
+pub const RECENT_ROW_PREFIX: &str = "recent:";
+
+/// How many recent projects the list offers. `recent_files` keeps ten; five
+/// is what fits above the commands without the palette reading as a file
+/// manager, and a query narrows the rest.
+pub const RECENT_ROW_LIMIT: usize = 5;
+
+/// Where a Settings row's value lives.
///
-/// Not the live `State` fields, and not `DesignSettings`: both are DOWNSTREAM
-/// of the meta node. `apply_settings_from_menubar_subnets` copies the utility
-/// subnets onto the live state on every param change, so a write straight to
-/// `State::grid_thickness` would survive exactly until the next one. The
-/// subnet param is the value's owner; this enum says which owner each row has,
-/// so the dialog edits values in the one place that keeps them.
+/// It used to be neither the live `State` fields nor `DesignSettings`: both
+/// were DOWNSTREAM of the root meta node, whose utility subnets were copied
+/// over live state on every param change, so a write straight to
+/// `State::grid_thickness` survived exactly until the next one. With that
+/// node retired the live field IS the value; this enum says which of the
+/// three remaining kinds of owner each row has.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Owner {
- /// A param on a utility subnet under the root meta node (`Main`, `View`,
- /// `Guides`), named here by subnet and param name.
- Subnet(&'static str, &'static str),
+ /// A display setting the app owns outright: a live field on `State`,
+ /// persisted by `DesignSettings` into `state.kdl`. Named by the key
+ /// `settings_field_read` / `settings_field_write` dispatch on.
+ ///
+ /// These were `Subnet(node, param)` — a param on a utility node under the
+ /// root meta node — until 2026-09-23. That node tree WAS the store of
+ /// record: `apply_settings_from_menubar_subnets` copied it onto the live
+ /// state after every edit, so writing a live field directly survived
+ /// until the next unrelated change and no longer. With the meta node
+ /// retired the live field is simply the value, and the row writes it.
+ Field(&'static str),
/// A toggle the command registry already owns end to end: the command does
- /// the live flip, the menu checkmark AND the per-camera writeback in one
- /// place. Square Aspect and Show Camera Pivot are per-CAMERA settings with
- /// no node at all behind the Default Camera, and their commands are the
- /// only code that gets both cases right — so the row dispatches instead of
- /// writing, and reads its displayed value off the live state.
+ /// the live flip, the menu checkmark AND any writeback in one place.
+ /// Square Aspect and Show Camera Pivot are per-CAMERA settings with no
+ /// node at all behind the Default Camera, and their commands are the only
+ /// code that gets both cases right — so the row dispatches instead of
+ /// writing, and reads its displayed value off `command_toggle_state`, the
+ /// same table the palette's own switches read.
Command(&'static str),
- /// A param on the ACTIVE camera node, with the live field as the fallback:
- /// the Default Camera has no node, so there is nothing to write but the
- /// field, and `apply_settings_from_menubar_subnets` leaves it alone.
+ /// A param on the ACTIVE camera node, with the live field as the
+ /// fallback: the Default Camera has no node, so there is nothing to write
+ /// but the field.
ActiveCamera(&'static str),
}
+/// The control a [`Setting`] row draws, for the rows that have no param
+/// elsewhere to borrow a shape from.
+///
+/// `Owner::Field` rows need this because their value is a bare Rust field —
+/// there is no `ParamDef` behind them carrying a type and a range the way a
+/// subnet param did. Spelling it here keeps the table the single description
+/// of the Settings half.
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub enum Ctl {
+ Toggle,
+ /// `#rrggbb`.
+ Color,
+ /// `#rrggbbaa` — the wire colour, whose alpha is its own opacity.
+ Rgba,
+ /// An integer spinbox over `min..=max`. The stored float is scaled by
+ /// `unit` (thousandths for Grid Thickness, tenths for Origin Size), which
+ /// is the convention those params already used.
+ Spin { min: f32, max: f32, unit: f32 },
+ /// A float slider, `min..=max`, shown to `dec` decimals.
+ Slider { min: f32, max: f32, dec: usize },
+ /// A fixed set of strings.
+ Choice(&'static [&'static str]),
+}
+
/// One row of the Settings half.
pub struct Setting {
/// What the dialog calls it — and, because `param_display` keys a row by
- /// its label, the identity the writeback resolves back to this row. Unique
- /// across the table, section titles included.
- ///
- /// Spelled out rather than borrowed from the owning param's own label: the
- /// subnets' labels are tuned for the section they sit in ("Plate", under
- /// the View node's Network section) and stop making sense anywhere else.
+ /// its label, the identity the writeback resolves back to this row.
+ /// Unique across the table, section titles included.
pub label: &'static str,
pub owner: Option<Owner>,
+ /// The control, for `Owner::Field` rows. `Command` rows are always
+ /// switches and `ActiveCamera` rows borrow the camera param's shape.
+ pub ctl: Option<Ctl>,
}
impl Setting {
const fn section(label: &'static str) -> Self {
- Setting { label, owner: None }
+ Setting { label, owner: None, ctl: None }
+ }
+
+ /// A row whose value the command registry owns.
+ const fn cmd(label: &'static str, id: &'static str) -> Self {
+ Setting { label, owner: Some(Owner::Command(id)), ctl: Some(Ctl::Toggle) }
}
- const fn row(label: &'static str, owner: Owner) -> Self {
- Setting { label, owner: Some(owner) }
+ /// A row over a live field.
+ const fn field(label: &'static str, key: &'static str, ctl: Ctl) -> Self {
+ Setting { label, owner: Some(Owner::Field(key)), ctl: Some(ctl) }
+ }
+
+ const fn camera(label: &'static str, name: &'static str) -> Self {
+ Setting { label, owner: Some(Owner::ActiveCamera(name)), ctl: None }
}
}
/// The Settings half, in order.
///
-/// Scope is exactly what `DesignSettings` persists — the viewport and graph
-/// DISPLAY state, which is the part of the app's configuration that is a
-/// preference rather than part of a project. What is deliberately NOT here:
-/// the render subnet (per-project look), the pane-visibility toggles (the
-/// View menu and the plate corners already own those, and a settings dialog
-/// is a strange place to hide a pane from), and keybindings, which this DE
-/// edits as `input.kdl` on purpose. The one exception is the wireframe
-/// pair: the palette's Wireframe Color command lands on the colour, so it
-/// is a row here with the single-colour switch that makes it apply, their
-/// owner still the Render node — per-project, unlike the rest of this
-/// table.
+/// Scope is exactly what `DesignSettings` persists: the DISPLAY state, which
+/// is the part of the app's configuration that is a preference rather than
+/// part of a project. That is now the whole of it — the four utility subnets
+/// under the root meta node (`main`, `view`, `guides`, `render`) held these
+/// values until 2026-09-23, and every one of them that was reachable only by
+/// selecting one of those nodes is a row here. Anything left out would not
+/// be "hidden in the node tree", it would be gone.
+///
+/// Still deliberately NOT here: the pane-visibility toggles (the View menu
+/// and the plate corners own those, and a settings dialog is a strange place
+/// to hide a pane from), the active camera (the viewport menubar's own menu,
+/// whose entries are the camera NODES and so cannot be a fixed table), and
+/// keybindings, which this DE edits as `input.kdl` on purpose.
pub const SETTINGS: &[Setting] = &[
Setting::section("Viewport"),
- Setting::row("Background Color", Owner::Subnet("main", "Background Color")),
- Setting::row("Square Aspect", Owner::Command("toggle_square_viewport")),
+ Setting::field("Background Color", "bg_color", Ctl::Color),
+ Setting::cmd("Square Aspect", "toggle_square_viewport"),
+ Setting::cmd("Ray Traced Preview", "toggle_ray_traced_preview"),
+ Setting::field("World Unit", "world_unit", Ctl::Choice(&["mm", "cm", "m", "in"])),
+ Setting::section("Geometry"),
+ Setting::field("Opacity", "geo_opacity", Ctl::Slider { min: 0.0, max: 1.0, dec: 2 }),
Setting::section("Wireframe"),
- Setting::row("Wireframe Color", Owner::Subnet("render", "Wire Color")),
+ Setting::cmd("Show Wireframe", "toggle_wireframe"),
+ Setting::field("Wireframe Color", "wire_color", Ctl::Rgba),
// The colour applies only in single-colour mode (off, the wires carry
// the geometry's vertex colours and the colour row sets their alpha
// alone) — so the switch sits beside the colour, or a colour set here
// looks ignored.
- Setting::row("Wireframe Single Color", Owner::Subnet("render", "Wire Single Color")),
+ Setting::cmd("Wireframe Single Color", "toggle_wire_single_color"),
+ Setting::field("Wire Thickness", "wire_width", Ctl::Slider { min: 1.0, max: 8.0, dec: 1 }),
+ Setting::section("Points"),
+ Setting::cmd("Show Points", "toggle_render_points"),
+ Setting::field("Point Size", "point_size", Ctl::Slider { min: 0.0, max: 0.1, dec: 3 }),
+ Setting::field("Point Color", "point_color", Ctl::Color),
+ Setting::cmd("Show Point Markers", "toggle_point_markers"),
+ Setting::field("Point Marker Size", "point_marker_size", Ctl::Spin { min: 5.0, max: 100.0, unit: 1000.0 }),
+ Setting::field("Point Marker Color", "point_marker_color", Ctl::Color),
+ Setting::cmd("Show Point Numbers", "toggle_point_numbers"),
+ Setting::cmd("Show Point Normals", "toggle_point_normals"),
Setting::section("Grid"),
- Setting::row("Show Grid", Owner::Subnet("guides", "Show Grid Guide")),
- Setting::row("Grid Color", Owner::Subnet("guides", "Grid Color")),
- Setting::row("Grid Thickness", Owner::Subnet("guides", "Grid Thickness")),
+ Setting::cmd("Show Grid", "toggle_grid"),
+ Setting::field("Grid Color", "grid_color", Ctl::Color),
+ Setting::field("Grid Thickness", "grid_thickness", Ctl::Spin { min: 2.0, max: 200.0, unit: 1000.0 }),
Setting::section("Guides"),
- Setting::row("Show Origin Axes", Owner::Subnet("guides", "Show Origin Axes")),
- Setting::row("Origin Size", Owner::Subnet("guides", "Origin Guide Size")),
- Setting::row("Show Reference Cube", Owner::Subnet("guides", "Show Reference Cube")),
+ Setting::cmd("Show Origin Axes", "toggle_origin"),
+ Setting::field("Origin Size", "origin_size", Ctl::Spin { min: 1.0, max: 50.0, unit: 10.0 }),
+ Setting::cmd("Show Reference Cube", "toggle_cube"),
Setting::section("Camera"),
- Setting::row("Show Camera Pivot", Owner::Command("toggle_camera_pivot")),
- Setting::row("Camera Pivot Size", Owner::ActiveCamera("Camera Pivot Size")),
+ Setting::cmd("Show Camera Pivot", "toggle_camera_pivot"),
+ Setting::camera("Camera Pivot Size", "Camera Pivot Size"),
Setting::section("Network"),
- Setting::row("Show Network Plate", Owner::Subnet("view", "Show Network Plate")),
+ Setting::cmd("Show Network Plate", "toggle_network_plate"),
+ Setting::cmd("Circular Pane", "toggle_circular_pane"),
];
+#[cfg(test)]
+fn field_key(s: &Setting) -> &'static str {
+ match s.owner {
+ Some(Owner::Field(key)) => key,
+ _ => panic!("'{}' is not a Field row", s.label),
+ }
+}
+
fn setting_by_label(label: &str) -> Option<&'static Setting> {
SETTINGS.iter().find(|s| s.label == label)
}
@@ -1190,15 +1277,30 @@ fn relabel(src: &ParamDef, label: &'static str) -> ParamDef {
}
fn bool_param(label: &'static str, on: bool) -> ParamDef {
+ shaped(label, "toggle", if on { "true" } else { "false" }.to_string(), None, None, None, &[])
+}
+
+/// A synthetic `ParamDef` for a row with no param of its own behind it: the
+/// label is both its name and its display key, which is how the writeback
+/// resolves a control back to its `Setting`.
+fn shaped(
+ label: &str,
+ param_type: &str,
+ default: String,
+ min: Option<f32>,
+ max: Option<f32>,
+ step: Option<f32>,
+ options: &[&str],
+) -> ParamDef {
ParamDef {
name: label.to_string(),
label: label.to_string(),
- param_type: "toggle".to_string(),
- default: if on { "true" } else { "false" }.to_string(),
- options: Vec::new(),
- min: None,
- max: None,
- step: None,
+ param_type: param_type.to_string(),
+ default,
+ options: options.iter().map(|s| s.to_string()).collect(),
+ min,
+ max,
+ step,
show_when: String::new(),
}
}
@@ -1361,6 +1463,41 @@ impl State {
);
}
}
+ // The recent projects, under the path row — the open
+ // document, then the ones before it. The project already
+ // open is not offered again.
+ let open_now = self.loaded_project_path.clone();
+ let recent: Vec<std::path::PathBuf> = self
+ .recent_files
+ .iter()
+ .filter(|p| Some(*p) != open_now.as_ref())
+ .take(RECENT_ROW_LIMIT)
+ .cloned()
+ .collect();
+ for path in recent.iter().rev() {
+ let text = path.to_string_lossy().to_string();
+ if crate::command::fuzzy_rank(&query, &[text.as_str()]).is_empty() {
+ continue;
+ }
+ let name = path
+ .file_name()
+ .map(|n| n.to_string_lossy().to_string())
+ .unwrap_or_default();
+ rows.insert(
+ 0,
+ Row {
+ id: format!("{RECENT_ROW_PREFIX}{text}"),
+ label: text,
+ chord: name,
+ swatch: None,
+ toggle: None,
+ slider: None,
+ // Same reason as the path row: the tail of a
+ // path is what identifies it.
+ truncate_head: true,
+ },
+ );
+ }
// The zoom slider heads the network pane's list, ranked like
// a row labelled "Zoom" so a query still finds (or drops) it.
if self.focused_context() == crate::command::Context::Network
@@ -1385,18 +1522,11 @@ impl State {
rows
}
Mode::AddNode => {
- // In a utility dir geometry templates are rejected at
- // placement — don't offer them.
- let in_utility = self.in_settings_dir();
- let offered: Vec<&str> = self
- .node_templates
- .iter()
- .filter(|t| {
- !in_utility
- || !crate::geometry::is_geometry_node_type(&t.node.node_type)
- })
- .map(|t| t.label.as_str())
- .collect();
+ // 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();
crate::command::fuzzy_rank(&query, &offered)
.into_iter()
.map(|i| Row {
@@ -1497,6 +1627,9 @@ impl State {
"toggle_point_markers" => self.show_point_markers,
"toggle_point_numbers" => self.show_point_numbers,
"toggle_point_normals" => self.show_point_normals,
+ "toggle_render_points" => self.render_points,
+ "toggle_wire_single_color" => self.wire_single_color,
+ "toggle_ray_traced_preview" => self.viewport().rt_mode,
"toggle_square_viewport" => self.square_viewport,
"toggle_network_plate" => self.network_plate,
"toggle_circular_pane" => self.circular_network_pane,
@@ -1535,15 +1668,6 @@ impl State {
/// place, shared with the params pane, instead of a second copy here that
/// could disagree about what a spinbox is.
fn dialog_settings_params(&self) -> Vec<ParamDef> {
- let subnet_param = |subnet: &str, name: &str| -> Option<&ParamDef> {
- self.session_node()?
- .children
- .iter()
- .find(|c| c.name == subnet)?
- .params
- .iter()
- .find(|p| p.name == name)
- };
let camera_param = |name: &str| -> Option<&ParamDef> {
if self.active_camera == "Default Camera" {
return None;
@@ -1560,49 +1684,31 @@ impl State {
let mut out = Vec::with_capacity(SETTINGS.len());
for s in SETTINGS {
match s.owner {
- None => out.push(ParamDef {
- name: s.label.to_string(),
- label: s.label.to_string(),
- param_type: "section".to_string(),
- default: String::new(),
- options: Vec::new(),
- min: None,
- max: None,
- step: None,
- show_when: String::new(),
- }),
- Some(Owner::Subnet(subnet, name)) => {
- // A subnet param that does not exist is a row that cannot
- // work, so it is not offered — the subnets are recreated
- // on every load, but a detached window has no meta node at
- // all.
- if let Some(p) = subnet_param(subnet, name) {
- out.push(relabel(p, s.label));
- }
+ None => out.push(shaped(s.label, "section", String::new(), None, None, None, &[])),
+ Some(Owner::Field(key)) => {
+ let ctl = s.ctl.expect("a Field row declares its control");
+ out.push(self.settings_field_param(s.label, key, ctl));
}
Some(Owner::Command(id)) => {
- let on = match id {
- "toggle_square_viewport" => self.square_viewport,
- "toggle_camera_pivot" => self.viewport().show_camera_pivot,
- _ => false,
- };
- out.push(bool_param(s.label, on));
+ // The same table the palette's own switches read, so a
+ // row here and a row there cannot disagree about which
+ // way a toggle is set.
+ out.push(bool_param(s.label, self.command_toggle_state(id).unwrap_or(false)));
}
Some(Owner::ActiveCamera(name)) => match camera_param(name) {
Some(p) => out.push(relabel(p, s.label)),
- // No camera node behind the Default Camera: the live field
- // is the value, in the same tenths the camera param uses.
- None => out.push(ParamDef {
- name: s.label.to_string(),
- label: s.label.to_string(),
- param_type: "spinbox".to_string(),
- default: ((self.camera_pivot_size * 10.0).round() as i32).to_string(),
- options: Vec::new(),
- min: Some(1.0),
- max: Some(50.0),
- step: Some(1.0),
- show_when: String::new(),
- }),
+ // No camera node behind the Default Camera: the live
+ // field is the value, in the same tenths the camera param
+ // uses.
+ None => out.push(shaped(
+ s.label,
+ "spinbox",
+ ((self.camera_pivot_size * 10.0).round() as i32).to_string(),
+ Some(1.0),
+ Some(50.0),
+ Some(1.0),
+ &[],
+ )),
},
}
}
@@ -1618,6 +1724,171 @@ impl State {
trimmed
}
+ /// One `Owner::Field` row: the live value, in the shape its `Ctl` names.
+ fn settings_field_param(&self, label: &'static str, key: &str, ctl: Ctl) -> ParamDef {
+ match ctl {
+ Ctl::Toggle => bool_param(label, self.settings_field_bool(key)),
+ Ctl::Color => {
+ let c = self.settings_field_color(key);
+ shaped(label, "color", crate::project::color_to_hex(c), None, None, None, &[])
+ }
+ Ctl::Rgba => {
+ let c = match key {
+ "wire_color" => self.wire_color,
+ _ => [0.0, 0.0, 0.0, 1.0],
+ };
+ shaped(label, "rgba", crate::project::color_to_hex8(c), None, None, None, &[])
+ }
+ Ctl::Spin { min, max, unit } => shaped(
+ label,
+ "spinbox",
+ ((self.settings_field_f32(key) * unit).round() as i32).to_string(),
+ Some(min),
+ Some(max),
+ Some(1.0),
+ &[],
+ ),
+ Ctl::Slider { min, max, dec } => shaped(
+ label,
+ &format!("slider:{min:.*}:{max:.*}:{dec}", dec, dec),
+ format!("{:.*}", dec, self.settings_field_f32(key)),
+ Some(min),
+ Some(max),
+ None,
+ &[],
+ ),
+ Ctl::Choice(options) => {
+ shaped(label, "choice", self.settings_field_text(key), None, None, None, options)
+ }
+ }
+ }
+
+ fn settings_field_bool(&self, key: &str) -> bool {
+ match key {
+ "wire_single_color" => self.wire_single_color,
+ "render_points" => self.render_points,
+ _ => false,
+ }
+ }
+
+ fn settings_field_color(&self, key: &str) -> [f32; 3] {
+ match key {
+ "bg_color" => self.viewport().bg_color,
+ "grid_color" => self.viewport().grid_color,
+ "point_color" => self.point_color,
+ "point_marker_color" => self.point_marker_color,
+ _ => [0.0; 3],
+ }
+ }
+
+ fn settings_field_f32(&self, key: &str) -> f32 {
+ match key {
+ "grid_thickness" => self.grid_thickness,
+ "origin_size" => self.origin_size,
+ "point_marker_size" => self.point_marker_size,
+ "wire_width" => self.wire_width,
+ "geo_opacity" => self.geo_opacity,
+ "point_size" => self.point_size,
+ _ => 0.0,
+ }
+ }
+
+ fn settings_field_text(&self, key: &str) -> String {
+ match key {
+ "world_unit" => self.world_unit.suffix().to_string(),
+ _ => String::new(),
+ }
+ }
+
+ /// Write one `Owner::Field` row's new value onto the live state.
+ ///
+ /// `settings_field_keys_are_all_handled` walks the table against these
+ /// four readers and this writer, because a key that no arm names reads
+ /// as a default and writes nowhere — a row that looks live and is inert.
+ fn settings_field_write(&mut self, key: &str, ctl: Ctl, value: &str) {
+ match ctl {
+ Ctl::Toggle => {
+ let on = value == "true";
+ match key {
+ "wire_single_color" => self.wire_single_color = on,
+ "render_points" => self.render_points = on,
+ _ => {}
+ }
+ }
+ Ctl::Color => {
+ let Some(c) = crate::project::hex_to_color(value) else { return };
+ match key {
+ "bg_color" => self.viewport_mut().bg_color = c,
+ "grid_color" => self.viewport_mut().grid_color = c,
+ "point_color" => self.point_color = c,
+ "point_marker_color" => self.point_marker_color = c,
+ _ => {}
+ }
+ }
+ Ctl::Rgba => {
+ let Some(c) = crate::project::hex_to_rgba(value) else { return };
+ if key == "wire_color" {
+ // Setting a wire colour means wanting to see it: the
+ // colour applies in single-colour mode only, so a colour
+ // edit turns that mode on if it was off. Twice read as
+ // "the colour did not take" (2026-09-21).
+ let changed = c != self.wire_color;
+ self.wire_color = c;
+ if changed && !self.wire_single_color {
+ self.wire_single_color = true;
+ }
+ }
+ }
+ Ctl::Spin { unit, .. } => {
+ let Ok(v) = value.parse::<f32>() else { return };
+ let v = v / unit;
+ match key {
+ "grid_thickness" => self.grid_thickness = v,
+ "origin_size" => self.origin_size = v,
+ "point_marker_size" => self.point_marker_size = v,
+ _ => {}
+ }
+ }
+ Ctl::Slider { min, max, .. } => {
+ let Ok(v) = value.parse::<f32>() else { return };
+ let v = v.clamp(min, max);
+ match key {
+ "wire_width" => self.wire_width = v,
+ "geo_opacity" => self.geo_opacity = v,
+ "point_size" => self.point_size = v,
+ _ => {}
+ }
+ }
+ Ctl::Choice(_) => {
+ if key == "world_unit" {
+ if let Some(u) = cce_ui::units::Unit::parse(value) {
+ self.world_unit = u;
+ self.viewport_dirty = true;
+ }
+ }
+ }
+ }
+ }
+
+ /// One Settings row's value as the dialog would show it. Test-facing:
+ /// `dialog_settings_rows_name_owners_that_exist` round-trips every
+ /// `Owner::Field` row through this and [`State::settings_write_row`],
+ /// which is the only way to catch a key that no dispatch arm names.
+ #[cfg(test)]
+ pub(crate) fn settings_row_value(&self, label: &str) -> String {
+ let s = SETTINGS.iter().find(|s| s.label == label).expect("no such Settings row");
+ let ctl = s.ctl.expect("that row declares no control");
+ self.settings_field_param(s.label, field_key(s), ctl).default
+ }
+
+ /// Write one Settings row, as the writeback does.
+ #[cfg(test)]
+ pub(crate) fn settings_write_row(&mut self, label: &str, value: &str) {
+ let s = SETTINGS.iter().find(|s| s.label == label).expect("no such Settings row");
+ let ctl = s.ctl.expect("that row declares no control");
+ self.settings_field_write(field_key(s), ctl, value);
+ }
+
/// Push the Settings rows into the dialog's params body, and remember them
/// as the baseline the writeback diffs against.
pub fn refresh_dialog_settings(&mut self) {
@@ -1650,14 +1921,9 @@ impl State {
let Some(owner) = setting.owner else { continue };
changed = true;
match owner {
- Owner::Subnet(subnet, name) => {
- if let Some(session) = self.session_node_mut() {
- if let Some(node) = session.children.iter_mut().find(|c| c.name == subnet) {
- if let Some(p) = node.params.iter_mut().find(|p| p.name == name) {
- p.default = value.clone();
- }
- }
- }
+ Owner::Field(key) => {
+ let ctl = setting.ctl.expect("a Field row declares its control");
+ self.settings_field_write(key, ctl, value);
}
Owner::Command(id) => commands.push(id),
Owner::ActiveCamera(name) => {
@@ -1697,7 +1963,6 @@ impl State {
self.run_command(id);
}
- self.apply_settings_from_menubar_subnets();
// The viewport meshes bake their sizes and colors in, so a changed
// thickness/size/tint is a re-generate, not a re-draw. This is the
// same set the settings-file reload in `tick_frame` regenerates.
@@ -1889,6 +2154,18 @@ impl State {
self.copy_project_path();
return;
}
+ // A recent project: open it, and say so if it will not open — a
+ // path in this list can have been moved or deleted since.
+ if mode == Mode::Tabbed {
+ if let Some(path) = id.strip_prefix(RECENT_ROW_PREFIX) {
+ let path = std::path::PathBuf::from(path);
+ self.close_dialog();
+ if let Err(e) = self.load_from_file(&path) {
+ self.update_status_text(&format!("Could not open {}: {e}", path.display()));
+ }
+ return;
+ }
+ }
if mode == Mode::Tabbed && self.command_toggle_state(&id).is_some() {
self.run_command(&id);
self.refresh_dialog_toggles();
diff --git a/src/layout.rs b/src/layout.rs
index b705fbe..33cc26a 100644
--- a/src/layout.rs
+++ b/src/layout.rs
@@ -19,10 +19,10 @@
//! the shortest, so a node always sits below every one of its inputs rather
//! than beside one of them.
//!
-//! Utility nodes are pinned. The settings tree lives at a place the user put
-//! it, and an "arrange everything" that relocated the meta node would be a
-//! surprise every time. Their cells are treated as occupied so nothing lands
-//! on top of them.
+//! Nothing is pinned any more. The settings tree — the root meta node and
+//! its four utility subnets — was, because it lived where the user put it and
+//! relocating it would have been a surprise every time; it is retired, and
+//! `Node::pinned` outlives it for whatever wants it next.
/// One node's layout input: what it is called, what it reads, where it is now,
/// and whether it may be moved.
diff --git a/src/main.rs b/src/main.rs
index 23d4d2a..4f4846c 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -321,19 +321,20 @@ mod tests {
assert_eq!(shape.default, "None");
}
- /// The button must exist on Main, inside the File section, before Exit.
+ /// Set As Default is reachable. It was a button on the Main utility
+ /// node's File section; with that node retired it is a registry command
+ /// like the rest of that section, findable in the palette.
#[test]
fn test_main_node_offers_set_as_default() {
- let mut state = State::new(false);
- state.ensure_menubar_subnets();
- let (s_idx, m_idx) = session_and_main(&state);
- let main = &state.fs_root.children[s_idx].children[m_idx];
- let names: Vec<&str> = main.params.iter().map(|p| p.name.as_str()).collect();
- let idx = names.iter().position(|n| *n == "Set As Default").expect("Set As Default param");
- let save_as = names.iter().position(|n| *n == "Save As").unwrap();
- let exit = names.iter().position(|n| *n == "Exit").unwrap();
- assert!(save_as < idx && idx < exit, "Set As Default out of place: {names:?}");
- assert_eq!(main.params[idx].param_type, "button");
+ use crate::command::{by_id, Run};
+ let cmd = by_id("set_as_default").expect("no set_as_default command");
+ assert_eq!(cmd.label, "Set As Default");
+ assert_eq!(cmd.run, Run::Menu("Set As Default"));
+ // Its File-section neighbours are commands too, or the retirement
+ // of the Main node took them with it.
+ for id in ["new_project", "open_project", "save_document", "save_document_as", "exit"] {
+ assert!(by_id(id).is_some(), "the File section lost '{id}'");
+ }
}
/// A scratch project has no path — the click must not invent a default.
@@ -748,41 +749,59 @@ mod tests {
}
/// DE chrome is config-owned (`style.surface.relief.profile` /
- /// `.edge_profile` / `style.surface.param.color`), so Main's retired Style
- /// section must not come back from an older project file — while it did,
- /// loading a project silently outranked the user's config.kdl.
+ /// `.edge_profile` / `style.surface.param.color`), and a project file
+ /// must not outrank the user's config.kdl. It did while the Main utility
+ /// node carried a Style section; the retirement of that whole node tree
+ /// is what closes it for good, so what is asserted now is that a save
+ /// carrying those params brings nothing back.
#[test]
fn test_legacy_style_params_are_dropped_from_main() {
let mut state = State::new(false);
- state.ensure_menubar_subnets();
- let (s_idx, main_idx) = session_and_main(&state);
-
- // Re-seed the params exactly as a pre-removal save carries them.
- for (name, ty, val) in [
- ("Style", "section", ""),
- ("Bevel Profile", "ramp", "smooth;0.000:0.000,0.500:0.900,1.000:1.000"),
- ("Edge Profile", "ramp", "smooth;0.000:0.000,1.000:1.000"),
- ("Plate Color", "rgba", "#11223344"),
- ] {
- state.fs_root.children[s_idx].children[main_idx].params.push(crate::app::ParamDef {
- name: name.to_string(),
- label: String::new(),
- param_type: ty.to_string(),
- default: val.to_string(),
- options: Vec::new(),
- min: None,
- max: None,
- step: None,
- show_when: String::new(),
- });
- }
+ // A pre-removal save: the four utility subnets flat at the root,
+ // Main carrying its retired Style section.
+ let style = |name: &str, ty: &str, val: &str| crate::app::ParamDef {
+ name: name.to_string(),
+ label: String::new(),
+ param_type: ty.to_string(),
+ default: val.to_string(),
+ options: Vec::new(),
+ min: None,
+ max: None,
+ step: None,
+ show_when: String::new(),
+ };
+ state.fs_root.children.push(crate::app::FsNode {
+ id: "legacy-main".to_string(),
+ name: "main".to_string(),
+ node_type: "utility".to_string(),
+ children: vec![],
+ params: vec![
+ style("Style", "section", ""),
+ style("Bevel Profile", "ramp", "smooth;0.000:0.000,1.000:1.000"),
+ style("Edge Profile", "ramp", "smooth;0.000:0.000,1.000:1.000"),
+ style("Plate Color", "rgba", "#11223344"),
+ ],
+ geometry_visible: true,
+ position: (0.0, 0.0),
+ inputs: 1,
+ outputs: 1,
+ });
+
+ state.migrate_meta_settings_node();
- state.ensure_menubar_subnets();
- let (s_idx, main_idx) = session_and_main(&state);
- let names: Vec<&str> = state.fs_root.children[s_idx].children[main_idx]
- .params.iter().map(|p| p.name.as_str()).collect();
+ assert!(
+ !state.fs_root.children.iter().any(|c| c.node_type == "utility"),
+ "a utility subnet survived the migration"
+ );
+ let names: Vec<&str> = state
+ .fs_root
+ .children
+ .iter()
+ .flat_map(|c| c.params.iter())
+ .map(|p| p.name.as_str())
+ .collect();
for retired in ["Style", "Bevel Profile", "Edge Profile", "Plate Color"] {
- assert!(!names.contains(&retired), "retired style param survived load: {retired} in {names:?}");
+ assert!(!names.contains(&retired), "retired style param survived load: {retired}");
}
}
@@ -807,14 +826,6 @@ mod tests {
}
}
- /// The settings nodes live inside the permanent root meta node (nee
- /// Session) now; tests that need Main resolve it through there.
- fn session_and_main(state: &State) -> (usize, usize) {
- let s_idx = state.fs_root.children.iter().position(|c| c.node_type == "meta").expect("root meta node");
- let m_idx = state.fs_root.children[s_idx].children.iter().position(|c| c.name == "main").expect("main inside Session");
- (s_idx, m_idx)
- }
-
/// `View 1:1` puts the pivot plane at true size: afterwards one world
/// unit spans its real length on the display, so the readout's ratio
/// is 1. Exercised on the default camera (zoom) at a centimetre world
@@ -823,7 +834,6 @@ mod tests {
#[test]
fn view_one_to_one_reaches_true_scale() {
let mut state = State::new(false);
- state.ensure_menubar_subnets();
state.world_unit = cce_ui::units::Unit::Cm;
// The default camera's eye ray is fixed, so 1:1 is a zoom — the one
// number the readout's cached state can follow here without a
@@ -849,114 +859,202 @@ mod tests {
assert!((mm - 1.0).abs() < 1e-3, "mm ratio {mm}");
}
- /// The root meta node (nee Session): exists at root, typed "meta" but
- /// still a subnet, holds exactly the four settings nodes, and refuses
- /// deletion through the one gate every deletion route funnels into.
+ /// The root meta node is gone, and a save that still carries one is
+ /// migrated rather than opened with it.
+ ///
+ /// It was the permanent root container for four utility subnets holding
+ /// every session-wide display setting — and it was the STORE OF RECORD
+ /// for them, copied back over live state after each parameter edit. That
+ /// made a display preference a piece of project data, editable only by
+ /// finding the right node. The settings live on `State` now and persist
+ /// to `state.kdl`; `migrate_meta_settings_node` reads an old file's
+ /// values across once and takes the node out.
#[test]
fn test_session_node_exists_and_cannot_be_deleted() {
let mut state = State::new(false);
- state.ensure_menubar_subnets();
-
- let s_idx = state.fs_root.children.iter().position(|c| c.node_type == "meta").expect("root meta node");
- let session = &state.fs_root.children[s_idx];
- assert_eq!(session.name, "meta");
- assert!(session.is_enterable(), "the root meta stays a subnet");
- let names: Vec<&str> = session.children.iter().map(|c| c.name.as_str()).collect();
- for expected in ["main", "view", "guides", "render"] {
- assert!(names.contains(&expected), "Session is missing {expected}: {names:?}");
- }
- // None of the four remain at root.
- for c in &state.fs_root.children {
- assert!(
- !(c.node_type == "utility" && matches!(c.name.as_str(), "main" | "view" | "guides" | "render")),
- "settings node '{}' still at root", c.name
- );
- }
+ assert!(
+ !state.fs_root.children.iter().any(|c| c.node_type == "meta"),
+ "a fresh tree grew a meta node"
+ );
- let before = state.fs_root.children.len();
- assert!(!state.delete_node(s_idx), "delete_node deleted the root meta node");
- assert_eq!(state.fs_root.children.len(), before, "root meta vanished anyway");
- assert!(state.fs_root.children[s_idx].node_type == "meta");
-
- // An old save's "session"-typed container retypes to meta in place,
- // children intact.
- state.fs_root.children[s_idx].node_type = "session".to_string();
- state.fs_root.children[s_idx].name = "Session".to_string();
- state.ensure_menubar_subnets();
- let s_idx = state.fs_root.children.iter().position(|c| c.node_type == "meta")
- .expect("session retyped to meta");
- assert_eq!(state.fs_root.children[s_idx].name, "meta");
- let names: Vec<&str> =
- state.fs_root.children[s_idx].children.iter().map(|c| c.name.as_str()).collect();
- for expected in ["main", "view", "guides", "render"] {
- assert!(names.contains(&expected), "retype lost {expected}: {names:?}");
- }
-
- // Guides carries the Point Marker Size control (thousandths), and
- // applying the settings drives the overlay size.
- {
- let guides = state.fs_root.children[s_idx].children.iter_mut()
- .find(|c| c.name == "guides").unwrap();
- let p = guides.params.iter_mut().find(|p| p.name == "Point Marker Size")
- .expect("Guides has Point Marker Size");
- assert_eq!(p.default, "20", "default = 0.02 world units");
- p.default = "50".to_string();
- let c = guides.params.iter_mut().find(|p| p.name == "Point Marker Color")
- .expect("Guides has Point Marker Color");
- assert_eq!(c.param_type, "color");
- c.default = "#ff8000".to_string();
- let u = guides.params.iter_mut().find(|p| p.name == "World Unit")
- .expect("Guides has World Unit");
- assert_eq!(u.param_type, "choice");
- assert_eq!(u.default, "mm", "a world unit is a millimetre until declared otherwise");
- u.default = "cm".to_string();
- }
- state.apply_settings_from_menubar_subnets();
+ // An old save: a "session"-typed container with the four subnets,
+ // carrying values that are not the defaults.
+ let p = |name: &str, ty: &str, val: &str| crate::app::ParamDef {
+ name: name.to_string(),
+ label: String::new(),
+ param_type: ty.to_string(),
+ default: val.to_string(),
+ options: Vec::new(),
+ min: None,
+ max: None,
+ step: None,
+ show_when: String::new(),
+ };
+ let subnet = |name: &str, params: Vec<crate::app::ParamDef>| crate::app::FsNode {
+ id: format!("legacy-{name}"),
+ name: name.to_string(),
+ node_type: "utility".to_string(),
+ children: vec![],
+ params,
+ geometry_visible: true,
+ position: (0.0, 0.0),
+ inputs: 1,
+ outputs: 1,
+ };
+ state.fs_root.children.push(crate::app::FsNode {
+ id: "legacy-session".to_string(),
+ name: "Session".to_string(),
+ node_type: "session".to_string(),
+ children: vec![
+ subnet("guides", vec![
+ p("Point Marker Size", "spinbox", "50"),
+ p("Point Marker Color", "color", "#ff8000"),
+ p("World Unit", "choice", "cm"),
+ p("Grid Thickness", "spinbox", "40"),
+ p("Show Reference Cube", "toggle", "true"),
+ ]),
+ subnet("render", vec![
+ p("Show Wireframe", "toggle", "true"),
+ p("Wire Thickness", "slider", "4.0"),
+ p("Point Color", "color", "#00ff00"),
+ ]),
+ subnet("main", vec![p("Circular Pane", "toggle", "true")]),
+ ],
+ params: vec![],
+ geometry_visible: true,
+ position: (0.0, 0.0),
+ inputs: 0,
+ outputs: 0,
+ });
+
+ state.migrate_meta_settings_node();
+
+ // The node is gone, along with every utility subnet it held.
+ assert!(!state.fs_root.children.iter().any(|c| {
+ matches!(c.node_type.as_str(), "meta" | "session" | "utility")
+ }), "the meta node survived the migration");
+
+ // And its values are the live settings — the point of migrating at
+ // all rather than simply dropping the node.
assert_eq!(state.world_unit, cce_ui::units::Unit::Cm);
assert!((state.world_unit_mm() - 10.0).abs() < 1e-4);
assert!((state.point_marker_size - 0.05).abs() < 1e-6);
assert!((state.point_marker_color[0] - 1.0).abs() < 0.01);
assert!((state.point_marker_color[1] - 0.5).abs() < 0.01);
assert!((state.point_marker_color[2] - 0.0).abs() < 0.01);
+ assert!((state.grid_thickness - 0.04).abs() < 1e-6);
+ assert!(state.viewport().show_cube);
+ assert!(state.wireframe);
+ assert!((state.wire_width - 4.0).abs() < 1e-6);
+ assert_eq!(state.point_color[1], 1.0);
+ assert!(state.circular_network_pane);
+
+ // Idempotent: a second pass has nothing to find and changes nothing.
+ let before = serde_json::to_string(&state.fs_root).unwrap();
+ state.migrate_meta_settings_node();
+ assert_eq!(before, serde_json::to_string(&state.fs_root).unwrap());
}
- /// An old save carries Main/View/Guides/Render at the root with the user's
- /// values in their params — migration must MOVE them (values intact), not
- /// recreate them fresh.
+ /// A node the user put INSIDE the meta subnet is re-homed, not eaten.
+ ///
+ /// Adding a non-geometry node in there was allowed, so the migration
+ /// cannot treat everything under that container as the app's own —
+ /// dropping the node with it would silently delete the user's work, and
+ /// the only trace would be its absence.
+ #[test]
+ fn the_migration_rehomes_a_node_the_user_left_in_the_meta_subnet() {
+ let mut state = State::new(false);
+ let mine = crate::app::FsNode {
+ id: "mine".to_string(),
+ name: "my_notes".to_string(),
+ node_type: "node".to_string(),
+ children: vec![],
+ params: vec![],
+ geometry_visible: false,
+ position: (2.0, 3.0),
+ inputs: 1,
+ outputs: 1,
+ };
+ state.fs_root.children.push(crate::app::FsNode {
+ id: "legacy-meta".to_string(),
+ name: "meta".to_string(),
+ node_type: "meta".to_string(),
+ children: vec![
+ crate::app::FsNode {
+ id: "legacy-guides".to_string(),
+ name: "guides".to_string(),
+ node_type: "utility".to_string(),
+ children: vec![],
+ params: vec![],
+ geometry_visible: true,
+ position: (0.0, 4.0),
+ inputs: 1,
+ outputs: 1,
+ },
+ mine,
+ ],
+ params: vec![],
+ geometry_visible: true,
+ position: (0.0, 0.0),
+ inputs: 0,
+ outputs: 0,
+ });
+
+ state.migrate_meta_settings_node();
+
+ assert!(!state.fs_root.children.iter().any(|c| c.node_type == "meta"));
+ let kept = state
+ .fs_root
+ .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
+ // standing where it was.
+ assert!(
+ state.fs_root.children.iter().filter(|c| c.position == kept.position).count() == 1,
+ "it landed on top of another node"
+ );
+ }
+
+ /// An OLDER save still carries Main/View/Guides/Render flat at the root,
+ /// with no meta node above them at all — the shape before the Session
+ /// node existed. The migration has to reach that generation too, or the
+ /// four nodes stay in the network forever doing nothing.
#[test]
fn test_old_saves_migrate_settings_nodes_into_session() {
let mut state = State::new(false);
- state.ensure_menubar_subnets();
-
- // Simulate the old shape: pull the four back out to root, drop the
- // Session node, and plant a probe param ensure doesn't own — the live-
- // synced toggles are rewritten from app state by design, so only a
- // foreign param can distinguish MOVED (probe survives) from RECREATED
- // (probe gone).
- let s_idx = state.fs_root.children.iter().position(|c| c.node_type == "meta").unwrap();
- let mut session = state.fs_root.children.remove(s_idx);
- for mut child in session.children.drain(..) {
- if child.name == "guides" {
- child.params.push(crate::app::ParamDef {
- name: "migration probe".to_string(),
- label: String::new(),
- param_type: "text".to_string(),
- default: "survived".to_string(),
- options: vec![],
- min: None,
- max: None,
- step: None,
- show_when: String::new(),
- });
- }
- state.fs_root.children.push(child);
- }
+ state.viewport_mut().show_grid = true;
+ state.fs_root.children.push(crate::app::FsNode {
+ id: "flat-guides".to_string(),
+ name: "guides".to_string(),
+ node_type: "utility".to_string(),
+ children: vec![],
+ params: vec![crate::app::ParamDef {
+ name: "Show Grid Guide".to_string(),
+ label: String::new(),
+ param_type: "toggle".to_string(),
+ default: "false".to_string(),
+ options: vec![],
+ min: None,
+ max: None,
+ step: None,
+ show_when: String::new(),
+ }],
+ geometry_visible: true,
+ position: (0.0, 4.0),
+ inputs: 1,
+ outputs: 1,
+ });
+
+ state.migrate_meta_settings_node();
- state.ensure_menubar_subnets();
- let s_idx = state.fs_root.children.iter().position(|c| c.node_type == "meta").expect("root meta recreated");
- let guides = state.fs_root.children[s_idx].children.iter().find(|c| c.name == "guides").expect("Guides migrated in");
- let v = guides.params.iter().find(|p| p.name == "migration probe").map(|p| p.default.as_str());
- assert_eq!(v, Some("survived"), "migration recreated Guides instead of moving it");
+ assert!(
+ !state.fs_root.children.iter().any(|c| c.node_type == "utility"),
+ "a root-level settings node survived"
+ );
+ assert!(!state.viewport().show_grid, "its value did not reach the live state");
}
/// Ctrl+S saves in place, Ctrl+Shift+S is Save As — and the Shift must
@@ -1122,7 +1220,6 @@ mod tests {
let mut a = State::new(false);
a.width = 1600.0;
- a.ensure_menubar_subnets();
assert!(a.show_viewport && !a.show_spreadsheet, "test assumes the default pane set");
a.execute_menu_action("Show Viewport Pane");
a.execute_menu_action("Show Spreadsheet Pane");
@@ -1143,7 +1240,6 @@ mod tests {
let mut b = State::new(false);
b.width = 800.0;
- b.ensure_menubar_subnets();
b.load_from_file(&dir).expect("load");
assert!(!b.show_viewport, "viewport hidden in the save must load hidden");
assert!(b.show_spreadsheet, "spreadsheet shown in the save must load shown");
@@ -1167,7 +1263,6 @@ mod tests {
// A detached pane window must ignore the same file's pane state.
let mut d = State::new(true);
- d.ensure_menubar_subnets();
let vp_before = d.show_viewport;
d.load_from_file(&dir).expect("load detached");
assert_eq!(d.show_viewport, vp_before, "detached windows keep their own pane layout");
@@ -1186,7 +1281,6 @@ mod tests {
let mut a = State::new(false);
a.resize(1600.0, 900.0, 1.0);
- a.ensure_menubar_subnets();
a.execute_menu_action("Show Spreadsheet Pane");
assert!(a.show_spreadsheet);
a.floating_network_layout.2 = 520.0;
@@ -1204,7 +1298,6 @@ mod tests {
let mut b = State::new(false);
b.resize(1600.0, 900.0, 1.0);
- b.ensure_menubar_subnets();
b.load_from_file(&dir).expect("load");
assert!((b.floating_network_layout.2 - 520.0).abs() < 0.5, "network width: {}", b.floating_network_layout.2);
assert!((b.floating_param_width - 360.0).abs() < 0.5, "param width: {}", b.floating_param_width);
@@ -1216,14 +1309,12 @@ mod tests {
// Half the window: the same fractions land at half the pixels.
let mut c = State::new(false);
c.resize(800.0, 450.0, 1.0);
- c.ensure_menubar_subnets();
c.load_from_file(&dir).expect("load half-size");
assert!((c.floating_network_layout.2 - 260.0).abs() < 0.5, "scaled network width: {}", c.floating_network_layout.2);
assert!((c.floating_param_width - 180.0).abs() < 0.5, "scaled param width: {}", c.floating_param_width);
// A detached pane window keeps its own plates.
let mut d = State::new(true);
- d.ensure_menubar_subnets();
let before = d.floating_param_width;
d.load_from_file(&dir).expect("load detached");
assert_eq!(d.floating_param_width, before);
@@ -1240,7 +1331,6 @@ mod tests {
let _ = fs::remove_dir_all(&dir);
let mut state = State::new(false);
- state.ensure_menubar_subnets();
// The tree baseline is taken before the meta-node migrations run
// (startup's project load re-baselines); this test is about the
// layout half, so baseline here.
@@ -3502,7 +3592,6 @@ mod tests {
assert_eq!(vw_e, 764.0);
}
- #[test]
/// The list's scrollbar is cce-mail's: sunk until a scroll raises it,
/// draggable while raised, sunk again after the hold — and while sunk it
/// takes no input, so a press on its lane reaches the row beneath.
@@ -3587,6 +3676,7 @@ mod tests {
assert!(d.scroll_px > before, "a wheel notch glides the list: {} -> {}", before, d.scroll_px);
}
+ #[test]
fn test_keyboard_shortcut_system() {
// Test parsing simple shortcut
let ctrl_g = Shortcut::parse("Ctrl+g").unwrap();
@@ -3651,11 +3741,19 @@ mod tests {
// geometry pass sat under every label, and the labels of buttons
// beneath the params pane's "Open" dropdown bled through it.
let mut state = State::new(false);
- state.ensure_menubar_subnets();
- let (s_idx, m_idx) = session_and_main(&state);
- state.current_path.push(s_idx);
- state.on_path_changed();
- state.graph_mut().set_selected_node(Some(m_idx));
+ // Any node with a choice param will do; the Main utility node's
+ // "Open" dropdown was this test's subject until that node was
+ // retired. Scatter's Mode is a choice.
+ let scatter = state
+ .node_templates
+ .iter()
+ .find(|t| t.label == "Scatter")
+ .expect("a Scatter template")
+ .node
+ .clone();
+ state.fs_root.children.push(scatter);
+ let idx = state.fs_root.children.len() - 1;
+ state.graph_mut().set_selected_node(Some(idx));
state.sync_parameters_pane();
{
@@ -3667,7 +3765,7 @@ mod tests {
.iter_mut()
.flatten()
.next()
- .expect("Main's params include a dropdown (Open)");
+ .expect("Scatter's params include a dropdown (Mode)");
dropdown.open = true;
// The popover expands on a wall-clock animation, and every geometry
// reader uses `anim_snap`, a snapshot refreshed only on tick/event —
@@ -3685,10 +3783,11 @@ mod tests {
matches!(&item.prim, cce_ui::scene::paint::Prim::Text { text, .. } if text == needle)
})
};
- // "New" is a button label sitting under the open dropdown;
- // "Other" only exists inside the popover's option list.
- let label_idx = text_pos("New").expect("button label in display list");
- let option_idx = text_pos("Other").expect("popover option text in display list");
+ // "Points" is a param label sitting under the open dropdown;
+ // "Surface" is not Mode's current value, so it exists only inside
+ // the popover's option list.
+ let label_idx = text_pos("Points").expect("param label in display list");
+ let option_idx = text_pos("Surface").expect("popover option text in display list");
assert!(option_idx > label_idx, "popover text must draw after widget labels");
let has_bg_between = list.items[label_idx..option_idx]
.iter()
@@ -4272,9 +4371,10 @@ mod tests {
/// The Wireframe Color command is a palette row that PREVIEWS the colour
/// — the row carries the live wire colour as its swatch — and, picked,
- /// lands on the Settings half's Wireframe Color row, whose owner is the
- /// Render node's "Wire Color" (the value's one home). The palette row
- /// shows the value; the settings row edits it.
+ /// lands on the Settings half's Wireframe Color row, which edits the
+ /// live `wire_color` (its owner was the Render node's "Wire Color" until
+ /// that node was retired). The palette row shows the value; the settings
+ /// row edits it.
#[test]
fn test_wireframe_color_row_previews_and_lands_on_settings() {
use crate::command::{by_id, Run};
@@ -4302,7 +4402,7 @@ mod tests {
// Picked from the list: the dialog closes, the command reopens it on
// Settings, and the Wireframe Color row is there as a colour control
- // owned by the Render node.
+ // over the live wire colour.
state.take_dialog_pick("wireframe_color".to_string());
assert!(state.dialog_visible());
assert_eq!(state.dialog_tab(), Tab::Settings);
@@ -4310,19 +4410,31 @@ mod tests {
assert!(shown.iter().any(|(k, _, t)| k == "Wireframe Color" && t == "rgba"), "{shown:?}");
assert!(shown.iter().any(|(k, _, t)| k == "Wireframe Single Color" && t == "toggle"), "{shown:?}");
let s = crate::dialog::SETTINGS.iter().find(|s| s.label == "Wireframe Color").unwrap();
- assert_eq!(s.owner, Some(crate::dialog::Owner::Subnet("render", "Wire Color")));
+ assert_eq!(s.owner, Some(crate::dialog::Owner::Field("wire_color")));
// Editing both rows reaches the live state: the colour AND the switch
// that makes the wire pass use it (off, the wires carry the
// geometry's colours and the colour row is their alpha alone).
- assert!(!state.wire_single_color, "single-colour mode is off by default");
+ state.wire_single_color = false;
+ state.wire_color = [1.0, 1.0, 1.0, 1.0];
+ state.refresh_dialog_settings();
let mut rows = state.dialog_settings_shown.clone();
rows.iter_mut().find(|(k, _, _)| k == "Wireframe Color").unwrap().1 = "#000000ff".to_string();
- rows.iter_mut().find(|(k, _, _)| k == "Wireframe Single Color").unwrap().1 = "true".to_string();
state.slots.dialog_params_mut().set_display_params(&rows);
state.sync_dialog_settings_to_project();
assert_eq!(state.wire_color, [0.0, 0.0, 0.0, 1.0], "the colour row writes the live wire colour");
- assert!(state.wire_single_color, "the switch row turns single-colour mode on");
+ // Setting a colour means wanting to see it, so single-colour mode
+ // comes on with it — off, the wires carry the geometry's own colours
+ // and this row is only their alpha.
+ assert!(state.wire_single_color, "a colour edit turns single-colour mode on");
+
+ // And the switch is a row of its own, dispatching its command.
+ state.refresh_dialog_settings();
+ let mut rows = state.dialog_settings_shown.clone();
+ rows.iter_mut().find(|(k, _, _)| k == "Wireframe Single Color").unwrap().1 = "false".to_string();
+ state.slots.dialog_params_mut().set_display_params(&rows);
+ state.sync_dialog_settings_to_project();
+ assert!(!state.wire_single_color, "the switch row did not reach the flag");
}
/// Frame All frames the displayed geometry from wherever the view is:
@@ -4364,108 +4476,159 @@ mod tests {
assert!(state.viewport().zoom < 1.0, "a 0.25 sphere frames closer than the stock view: zoom {}", state.viewport().zoom);
}
- /// The viewport settings live in the scene file: the Render node's
+ /// What the scene file carries, and what it no longer does.
+ ///
+ /// It used to carry the viewport DISPLAY settings — the Render node's
/// wireframe state and colour, the Guides node's grid and origin, Main's
- /// background, and the Default Camera view (square aspect, pivot marker,
- /// orbit/zoom/pivot). A fresh State whose live values differ takes the
- /// file's on load. Before this, `ensure_menubar_subnets` re-seeded the
- /// nodes from live state on load and the file's values were lost.
+ /// background — because the nodes holding them rode `fs_root` into the
+ /// file. That made a preference part of the project: opening someone
+ /// else's scene reset how you looked at geometry. Those settings persist
+ /// to `state.kdl` now, and the scene file keeps what is genuinely the
+ /// project's: the Default Camera VIEW (square aspect, pivot marker,
+ /// orbit/zoom/pivot), which is where you were standing in this scene.
#[test]
fn viewport_settings_round_trip_through_the_scene_file() {
let dir = std::env::temp_dir().join(format!("cce-designer-vp-settings-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let mut a = State::new(false);
- a.ensure_menubar_subnets();
// The Default Camera is active: a camera NODE's own Square Aspect and
// pivot params would override the saved view's, by design.
a.active_camera = "Default Camera".to_string();
- a.wireframe = true;
- a.wire_single_color = true;
- a.wire_color = [0.0, 0.0, 0.0, 1.0];
- a.wire_width = 3.0;
- a.viewport_mut().show_grid = false;
- a.viewport_mut().show_origin = true;
- a.viewport_mut().bg_color = [0.1, 0.2, 0.3];
a.square_viewport = true;
a.viewport_mut().show_camera_pivot = true;
a.viewport_mut().rotation_y = 0.7;
a.viewport_mut().zoom = 0.4;
a.viewport_mut().pivot = Vec3::new(3.0, 0.5, -2.0);
+ // A display setting, deliberately NOT expected to travel.
+ a.wireframe = true;
a.save_to_file(&dir).expect("save");
let mut b = State::new(false);
- b.ensure_menubar_subnets();
- assert!(!b.wireframe && !b.wire_single_color, "a fresh state starts without wires");
+ b.wireframe = false;
b.load_from_file(&dir).expect("load");
- assert!(b.wireframe, "Show Wireframe loads from the file");
- assert!(b.wire_single_color, "Wire Single Color loads from the file");
- assert_eq!(b.wire_color, [0.0, 0.0, 0.0, 1.0]);
- assert!((b.wire_width - 3.0).abs() < 1e-4);
- assert!(!b.viewport().show_grid, "Show Grid loads from the file");
- assert!(b.viewport().show_origin, "Show Origin loads from the file");
- let bg = b.viewport().bg_color;
- assert!((bg[0] - 0.1).abs() < 0.01 && (bg[1] - 0.2).abs() < 0.01 && (bg[2] - 0.3).abs() < 0.01, "background {bg:?}");
assert!(b.square_viewport, "Square Aspect loads from the file");
assert!(b.viewport().show_camera_pivot, "the pivot marker loads from the file");
assert!((b.viewport().rotation_y - 0.7).abs() < 1e-4);
assert!((b.viewport().zoom - 0.4).abs() < 1e-4);
assert_eq!(b.viewport().pivot, Vec3::new(3.0, 0.5, -2.0));
- // And the nodes agree with the live state after the load.
- let render = b.fs_root.children.iter().find(|c| c.node_type == "meta").unwrap()
- .children.iter().find(|c| c.name == "render").unwrap();
- assert_eq!(render.params.iter().find(|p| p.name == "Show Wireframe").unwrap().default, "true");
+ assert!(!b.wireframe, "a display preference rode the project file");
let _ = std::fs::remove_dir_all(&dir);
}
- /// Changing the wire colour turns single-colour mode on — on the node
- /// and live — so the colour shows; a load does not (a file that says
- /// off stays off, whatever colour it carries), and turning the switch
- /// off afterwards sticks until the colour changes again.
+ /// …and the display settings round-trip through `state.kdl` instead,
+ /// every one of them, including the colours that pass through hex on the
+ /// way. The colour table was a hand-written pair of `if let`s per field
+ /// and covered two of the five, so a new colour setting serialized as a
+ /// JSON array and came back as the default.
+ #[test]
+ fn display_settings_round_trip_through_state_kdl() {
+ use crate::app::DesignSettings;
+ let mut a = State::new(false);
+ a.viewport_mut().show_grid = false;
+ a.viewport_mut().show_cube = true;
+ a.viewport_mut().bg_color = [0.1, 0.2, 0.3];
+ a.viewport_mut().grid_color = [0.4, 0.5, 0.6];
+ a.viewport_mut().rt_mode = true;
+ a.grid_thickness = 0.04;
+ a.origin_size = 2.5;
+ a.show_point_markers = true;
+ a.show_point_numbers = true;
+ a.point_marker_size = 0.05;
+ a.point_marker_color = [1.0, 0.5, 0.0];
+ a.world_unit = cce_ui::units::Unit::Cm;
+ a.wireframe = true;
+ a.wire_single_color = true;
+ a.wire_color = [0.2, 0.4, 0.6, 0.5];
+ a.wire_width = 3.0;
+ a.geo_opacity = 0.75;
+ a.render_points = true;
+ a.point_size = 0.05;
+ a.point_color = [0.0, 1.0, 0.0];
+ a.save_settings();
+
+ let kdl = std::fs::read_to_string(DesignSettings::file_path()).expect("state.kdl was written");
+ let back = DesignSettings::from_kdl_str(&kdl);
+ let close = |x: f32, y: f32| (x - y).abs() < 0.01;
+
+ assert!(!back.viewport.show_grid_enabled);
+ assert!(back.viewport.show_cube_enabled);
+ assert!(back.viewport.rt_mode);
+ assert!(close(back.viewport.grid_thickness, 0.04));
+ assert!(close(back.viewport.origin_size, 2.5));
+ assert!(back.viewport.show_point_markers && back.viewport.show_point_numbers);
+ assert!(!back.viewport.show_point_normals);
+ assert!(close(back.viewport.point_marker_size, 0.05));
+ assert_eq!(back.viewport.world_unit, "cm");
+ for (got, want) in [
+ (back.viewport.bg_color, [0.1, 0.2, 0.3]),
+ (back.viewport.grid_color, [0.4, 0.5, 0.6]),
+ (back.viewport.point_marker_color, [1.0, 0.5, 0.0]),
+ (back.render.point_color, [0.0, 1.0, 0.0]),
+ ] {
+ for k in 0..3 {
+ assert!(close(got[k], want[k]), "colour {got:?} came back as {want:?}");
+ }
+ }
+ assert!(back.render.wireframe && back.render.wire_single_color);
+ // The wire colour is the four-component one: its ALPHA is the wire's
+ // own opacity, and dropping it would silently make every wireframe
+ // fully opaque.
+ for k in 0..4 {
+ assert!(close(back.render.wire_color[k], [0.2, 0.4, 0.6, 0.5][k]), "{:?}", back.render.wire_color);
+ }
+ assert!(close(back.render.wire_width, 3.0));
+ assert!(close(back.render.geo_opacity, 0.75));
+ assert!(back.render.render_points);
+ assert!(close(back.render.point_size, 0.05));
+ }
+
+ /// Changing the wire colour turns single-colour mode on, so the colour
+ /// shows; turning the switch off afterwards sticks, and a LOAD never
+ /// flips it — a project that says off stays off whatever colour it
+ /// carries.
#[test]
fn changing_the_wire_colour_turns_single_colour_mode_on() {
let mut state = State::new(false);
- state.ensure_menubar_subnets();
- state.apply_settings_from_menubar_subnets();
- assert!(!state.wire_single_color);
- let render_param = |state: &State, name: &str| -> String {
- state.fs_root.children.iter().find(|c| c.node_type == "meta").unwrap()
- .children.iter().find(|c| c.name == "render").unwrap()
- .params.iter().find(|p| p.name == name).unwrap().default.clone()
- };
- // An edit through the node, as the params pane and the dialog make it.
- {
- let meta = state.fs_root.children.iter_mut().find(|c| c.node_type == "meta").unwrap();
- let render = meta.children.iter_mut().find(|c| c.name == "render").unwrap();
- render.params.iter_mut().find(|p| p.name == "Wire Color").unwrap().default = "#000000ff".to_string();
- }
- state.apply_settings_from_menubar_subnets();
+ state.wire_single_color = false;
+ state.wire_color = [1.0, 1.0, 1.0, 1.0];
+
+ // An edit through the dialog's Settings half, which is the only way
+ // in now that the Render node is gone.
+ state.open_dialog();
+ state.slots.dialog.tab = crate::dialog::Tab::Settings;
+ state.refresh_dialog_settings();
+ let mut rows = state.dialog_settings_shown.clone();
+ rows.iter_mut().find(|(k, _, _)| k == "Wireframe Color").unwrap().1 = "#000000ff".to_string();
+ state.slots.dialog_params_mut().set_display_params(&rows);
+ state.sync_dialog_settings_to_project();
+ assert_eq!(state.wire_color, [0.0, 0.0, 0.0, 1.0]);
assert!(state.wire_single_color, "a colour change switches single-colour mode on");
- assert_eq!(render_param(&state, "Wire Single Color"), "true", "and the node's switch shows it");
- // Off again by hand stays off while the colour is unchanged.
- state.write_render_toggle("Wire Single Color", false);
- state.apply_settings_from_menubar_subnets();
- assert!(!state.wire_single_color);
-
- // A load: the file's colour differs from the fresh state's, its
- // switch is off, and it stays off.
+
+ // Off again by hand stays off while the colour is unchanged: the
+ // auto-enable fires on a CHANGE, not on every settings pass.
+ state.wire_single_color = false;
+ state.refresh_dialog_settings();
+ state.sync_dialog_settings_to_project();
+ assert!(!state.wire_single_color, "an unrelated poll flipped it back on");
+
+ // A load carries the project's geometry and leaves the wire
+ // settings — preferences now — exactly where they are.
let dir = std::env::temp_dir().join(format!("cce-designer-wire-colour-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
state.save_to_file(&dir).expect("save");
- let mut fresh = State::new(false);
- fresh.ensure_menubar_subnets();
- fresh.load_from_file(&dir).expect("load");
- assert_eq!(fresh.wire_color, [0.0, 0.0, 0.0, 1.0]);
- assert!(!fresh.wire_single_color, "a load never flips the switch");
+ state.load_from_file(&dir).expect("load");
+ assert_eq!(state.wire_color, [0.0, 0.0, 0.0, 1.0]);
+ assert!(!state.wire_single_color, "a load never flips the switch");
let _ = std::fs::remove_dir_all(&dir);
}
- /// The wireframe toggle is a palette row that flips the live flag AND
- /// the Render node's "Show Wireframe" switch. The node matters: it is
- /// what `apply_settings_from_menubar_subnets` reads back on every
- /// parameter edit, so a flag flipped alone would revert on the next
- /// unrelated edit. No settings write is involved, so running it here
- /// touches nothing outside the test.
+ /// The wireframe toggle is a palette row that flips the live flag, and
+ /// the flag is the whole of it. The value used to live on the Render
+ /// utility node, which `apply_settings_from_menubar_subnets` read back
+ /// over live state after every parameter edit anywhere — so a flag
+ /// flipped alone reverted on the next unrelated change, and the command
+ /// had to write the node too. There is no node and no read-back now.
#[test]
fn test_toggle_wireframe_flips_the_flag_and_the_render_node() {
use crate::command::{by_id, Run};
@@ -4473,28 +4636,19 @@ mod tests {
assert_eq!(cmd.label, "Show Wireframe");
assert_eq!(cmd.run, Run::Key(crate::shortcut::Action::ToggleWireframe));
- let render_toggle = |state: &State| -> String {
- state
- .fs_root
- .children
- .iter()
- .find(|c| c.node_type == "meta")
- .and_then(|s| s.children.iter().find(|c| c.name == "render"))
- .and_then(|n| n.params.iter().find(|p| p.name == "Show Wireframe"))
- .map(|p| p.default.clone())
- .expect("a Render node with a Show Wireframe toggle")
- };
let mut state = State::new(false);
- assert!(!state.wireframe, "wireframe is off unless the project turned it on");
+ state.wireframe = false;
assert!(state.run_command("toggle_wireframe"));
assert!(state.wireframe);
- assert_eq!(render_toggle(&state), "true");
- // The read-back path agrees with the flag instead of reverting it.
- state.apply_settings_from_menubar_subnets();
- assert!(state.wireframe);
+ assert_eq!(state.command_toggle_state("toggle_wireframe"), Some(true));
+ // An unrelated settings poll no longer reverts it.
+ state.open_dialog();
+ state.slots.dialog.tab = crate::dialog::Tab::Settings;
+ state.refresh_dialog_settings();
+ state.sync_dialog_settings_to_project();
+ assert!(state.wireframe, "a settings pass read the flag back over itself");
assert!(state.run_command("toggle_wireframe"));
assert!(!state.wireframe);
- assert_eq!(render_toggle(&state), "false");
}
/// The registry's own invariants. Ids are what `input.kdl` binds and
@@ -5243,84 +5397,49 @@ mod tests {
assert_eq!(cmd.run, Run::Key(crate::shortcut::Action::ToggleNetworkPlate));
assert!(cmd.default_chord.is_some(), "the plate toggle has no chord");
- // The View settings node mirrors the live flag, so the row in the
- // params pane shows what is actually on screen.
+ // The live flag and the dialog's switch are one reading: the row
+ // in the Settings half is `Owner::Command`, so it goes through
+ // `command_toggle_state` rather than mirroring the flag onto a node
+ // that could fall out of step with it.
let mut state = State::new(false);
- let session = state
- .fs_root
- .children
- .iter()
- .position(|c| c.node_type == "meta")
- .expect("root meta node");
- let view = state.fs_root.children[session]
- .children
- .iter()
- .position(|c| c.name == "view")
- .expect("View node");
- let plate_row = |state: &State| {
- state.fs_root.children[session].children[view]
- .params
- .iter()
- .find(|p| p.name == "Show Network Plate")
- .map(|p| (p.default.clone(), p.label.clone()))
- };
- assert_eq!(
- plate_row(&state),
- Some(("true".to_string(), "Plate".to_string())),
- "the View node has no Plate row, or it does not read as on"
- );
+ assert_eq!(state.command_toggle_state("toggle_network_plate"), Some(true));
+ assert!(state.run_command("toggle_network_plate"));
+ assert!(!state.network_plate);
+ assert_eq!(state.command_toggle_state("toggle_network_plate"), Some(false));
- state.network_plate = false;
- state.current_path = vec![session];
- state.refresh_main_node_live_toggles(view);
- assert_eq!(
- plate_row(&state).map(|(v, _)| v),
- Some("false".to_string()),
- "the View node's row did not follow the live flag"
- );
+ let row = crate::dialog::SETTINGS
+ .iter()
+ .find(|s| s.label == "Show Network Plate")
+ .expect("a Show Network Plate row");
+ assert_eq!(row.owner, Some(crate::dialog::Owner::Command("toggle_network_plate")));
}
/// The viewport guide toggles survive the next parameter edit.
///
- /// `apply_settings_from_menubar_subnets` copies the Guides node onto the
- /// live flags on EVERY parameter change, so a command that flipped only
- /// the flag was undone by the next edit anywhere — Show Cube hid the
- /// cube, and editing any node's parameter brought it back. The command
- /// has to write the Guides node, the value's owner, as Show Wireframe
- /// writes the Render node.
+ /// `apply_settings_from_menubar_subnets` used to copy the Guides utility
+ /// node onto the live flags on EVERY parameter change, so a command that
+ /// flipped only the flag was undone by the next edit anywhere — Show
+ /// Cube hid the cube, and editing any node's parameter brought it back.
+ /// The fix was to write the node as well; the node is gone now and the
+ /// flag is simply the value, which is the same guarantee with nothing
+ /// left to fall out of step. Still asserted, because the failure it
+ /// catches (an edit reverting a display toggle) is invisible in a test
+ /// that only flips the toggle.
#[test]
fn guide_toggles_survive_the_settings_apply_pass() {
let mut state = State::new(false);
- let guides_value = |state: &State, name: &str| -> String {
- state
- .session_node()
- .and_then(|s| s.children.iter().find(|c| c.name == "guides"))
- .and_then(|g| g.params.iter().find(|p| p.name == name))
- .map(|p| p.default.clone())
- .expect("the Guides param")
- };
- for (command, param) in [
- ("toggle_cube", "Show Reference Cube"),
- ("toggle_grid", "Show Grid Guide"),
- ("toggle_origin", "Show Origin Axes"),
- ] {
+ for command in ["toggle_cube", "toggle_grid", "toggle_origin", "toggle_point_markers"] {
let flag = |state: &State| match command {
"toggle_cube" => state.viewport().show_cube,
"toggle_grid" => state.viewport().show_grid,
- _ => state.viewport().show_origin,
+ "toggle_origin" => state.viewport().show_origin,
+ _ => state.show_point_markers,
};
let before = flag(&state);
- assert_eq!(guides_value(&state, param), before.to_string(), "{param} starts in step with the flag");
-
assert!(state.run_command(command));
assert_eq!(flag(&state), !before, "{command} flipped the flag");
- assert_eq!(guides_value(&state, param), (!before).to_string(), "{command} wrote the Guides node");
-
- // What every parameter edit runs.
- state.apply_settings_from_menubar_subnets();
- assert_eq!(flag(&state), !before, "{command} was undone by the apply pass");
- // And a real edit through the action path, on an unrelated node.
+ // A real edit through the action path, on an unrelated node.
let mut redraw = false;
let sphere = state.current_dir().children.iter().position(|c| c.name.starts_with("sphere")).expect("a sphere");
state
@@ -5329,12 +5448,10 @@ mod tests {
assert_eq!(flag(&state), !before, "{command} was undone by a parameter edit");
}
- // Circular Pane lives on the Main node and had the same hole.
+ // Circular Pane had the same hole.
let before = state.circular_network_pane;
assert!(state.run_command("toggle_circular_pane"));
assert_eq!(state.circular_network_pane, !before);
- state.apply_settings_from_menubar_subnets();
- assert_eq!(state.circular_network_pane, !before, "Circular Pane was undone by the apply pass");
}
/// A replacement renderer invalidates the page pane's image id, and the
@@ -8350,38 +8467,82 @@ mod tests {
key_press(Key::Character(c.to_string()))
}
- /// Every Settings row still names something that exists.
+ /// Every Settings row still names something that exists, and every
+ /// `Owner::Field` key is one the readers and the writer both handle.
///
- /// The failure this catches is silent and the reason the table is a table:
- /// `dialog_settings_params` SKIPS a row whose owning param it cannot find,
- /// so renaming a subnet param quietly shortens the Settings half and
- /// nothing says why. Same argument as
- /// `test_every_menu_command_names_a_label_that_is_dispatched`.
+ /// The failure this catches is silent, and it is the reason the table is
+ /// a table: a `Field` key that no arm names reads as a zero and writes
+ /// nowhere, so the row draws, accepts an edit and does nothing. (Before
+ /// the meta node was retired the same failure was a renamed subnet param
+ /// SKIPPING its row, which quietly shortened the Settings half.) Same
+ /// argument as `test_every_menu_command_names_a_label_that_is_dispatched`.
#[test]
fn dialog_settings_rows_name_owners_that_exist() {
- use crate::dialog::Owner;
- let state = State::new(false);
- let session = state.session_node().expect("the root meta node");
+ use crate::dialog::{Ctl, Owner};
+ let mut state = State::new(false);
for s in crate::dialog::SETTINGS {
match s.owner {
- None => {}
- Some(Owner::Subnet(subnet, name)) => {
- let node = session
- .children
- .iter()
- .find(|c| c.name == subnet)
- .unwrap_or_else(|| panic!("no '{subnet}' subnet for row '{}'", s.label));
+ None => assert!(s.ctl.is_none(), "section '{}' declares a control", s.label),
+ Some(Owner::Field(key)) => {
+ let ctl = s.ctl.unwrap_or_else(|| panic!("row '{}' declares no control", s.label));
+ // The round trip IS the check: read the row, write the
+ // value straight back, and read again. A key no arm
+ // names reads a default and writes nothing, so the two
+ // reads differ the moment the default is not the live
+ // value — which is why each row is nudged first.
+ match ctl {
+ Ctl::Toggle => {
+ let before = state.settings_row_value(s.label);
+ let flipped = if before == "true" { "false" } else { "true" };
+ state.settings_write_row(s.label, flipped);
+ assert_eq!(state.settings_row_value(s.label), flipped,
+ "row '{}' (key '{key}') did not take a write", s.label);
+ }
+ Ctl::Color => {
+ state.settings_write_row(s.label, "#123456");
+ assert_eq!(state.settings_row_value(s.label), "#123456",
+ "row '{}' (key '{key}') did not take a write", s.label);
+ }
+ Ctl::Rgba => {
+ state.settings_write_row(s.label, "#12345678");
+ assert_eq!(state.settings_row_value(s.label), "#12345678",
+ "row '{}' (key '{key}') did not take a write", s.label);
+ }
+ Ctl::Spin { min, max, .. } => {
+ let v = ((min + max) / 2.0).round() as i32;
+ state.settings_write_row(s.label, &v.to_string());
+ assert_eq!(state.settings_row_value(s.label), v.to_string(),
+ "row '{}' (key '{key}') did not take a write", s.label);
+ }
+ Ctl::Slider { min, max, dec } => {
+ let v = format!("{:.*}", dec, (min + max) / 2.0);
+ state.settings_write_row(s.label, &v);
+ assert_eq!(state.settings_row_value(s.label), v,
+ "row '{}' (key '{key}') did not take a write", s.label);
+ }
+ Ctl::Choice(options) => {
+ let last = options.last().expect("a choice with no options");
+ state.settings_write_row(s.label, last);
+ assert_eq!(state.settings_row_value(s.label), *last,
+ "row '{}' (key '{key}') did not take a write", s.label);
+ }
+ }
+ }
+ Some(Owner::Command(id)) => {
assert!(
- node.params.iter().any(|p| p.name == name),
- "'{subnet}' has no param '{name}' — row '{}' would vanish",
+ crate::command::by_id(id).is_some(),
+ "row '{}' names no command '{id}'",
+ s.label
+ );
+ // A Command row paints as a switch, so the command has
+ // to answer the toggle table or the row shows as off
+ // whatever the setting is.
+ assert!(
+ state.command_toggle_state(id).is_some(),
+ "row '{}' names command '{id}', which has no toggle state",
s.label
);
}
- Some(Owner::Command(id)) => assert!(
- crate::command::by_id(id).is_some(),
- "row '{}' names no command '{id}'",
- s.label
- ),
// The active camera's params exist only once a camera node
// does; the Default Camera branch is exercised below.
Some(Owner::ActiveCamera(_)) => {}
@@ -8789,10 +8950,14 @@ mod tests {
assert!(!state.slots.dialog_params.visible());
}
- /// A Settings row writes to whatever OWNS its value, not to the live field
- /// — which is the only write that survives, since
- /// `apply_settings_from_menubar_subnets` copies the subnets over the live
- /// state on every param change.
+ /// A Settings row writes to whatever OWNS its value.
+ ///
+ /// That used to mean a param on a utility subnet, never the live field:
+ /// `apply_settings_from_menubar_subnets` copied those subnets back over
+ /// live state on every param change, so a direct write survived until
+ /// the next edit and no longer. The live field IS the value now, and a
+ /// `Command` row goes through the command so the menus and the persist
+ /// come with it.
#[test]
fn dialog_settings_write_reaches_the_owning_subnet() {
use crate::dialog::Tab;
@@ -8800,25 +8965,112 @@ mod tests {
state.run_command("toggle_dialog");
state.set_dialog_tab(Tab::Settings);
+ // A Command row: Show Grid dispatches `toggle_grid`.
let was = state.viewport().show_grid;
- // What a click on the toggle leaves behind: the control reports the
- // flipped value, and the poll picks it up.
let mut rows = state.dialog_settings_shown.clone();
let row = rows.iter_mut().find(|(k, _, _)| k == "Show Grid").expect("the Show Grid row");
row.1 = if was { "false" } else { "true" }.to_string();
state.slots.dialog_params_mut().set_display_params(&rows);
state.sync_dialog_settings_to_project();
-
assert_eq!(state.viewport().show_grid, !was, "the live state followed");
- let guides = state
- .session_node()
- .expect("meta")
- .children
- .iter()
- .find(|c| c.name == "guides")
- .expect("Guides");
- let p = guides.params.iter().find(|p| p.name == "Show Grid Guide").expect("the param");
- assert_eq!(p.default == "true", !was, "and so did its owner");
+ assert_eq!(state.command_toggle_state("toggle_grid"), Some(!was), "and the switch shows it");
+
+ // A Field row: Grid Thickness is a spinbox in thousandths.
+ let mut rows = state.dialog_settings_shown.clone();
+ rows.iter_mut().find(|(k, _, _)| k == "Grid Thickness").expect("the row").1 = "40".to_string();
+ state.slots.dialog_params_mut().set_display_params(&rows);
+ state.sync_dialog_settings_to_project();
+ assert!((state.grid_thickness - 0.04).abs() < 1e-6, "{}", state.grid_thickness);
+
+ // And it survives an unrelated parameter edit, which is the whole
+ // reason the subnets had to be the owner before.
+ let mut redraw = false;
+ let sphere = state.current_dir().children.iter().position(|c| c.name.starts_with("sphere")).expect("a sphere");
+ state
+ .apply_action(crate::app::McpAction::SetParam { slot: sphere, name: "Radius".into(), value: "0.8".into() }, &mut redraw)
+ .expect("set a sphere param");
+ assert_eq!(state.viewport().show_grid, !was, "a param edit reverted the toggle");
+ assert!((state.grid_thickness - 0.04).abs() < 1e-6, "a param edit reverted the thickness");
+ }
+
+ /// The recent projects are rows of the Commands list.
+ ///
+ /// The list was the Main utility node's "Open" dropdown and went with
+ /// that node, which left `recent_files` written on every save and read
+ /// by nothing — a feature with no way in. It is a list of documents, so
+ /// it sits under the open document's own path row.
+ #[test]
+ fn the_palette_offers_the_recent_projects() {
+ use crate::dialog::RECENT_ROW_PREFIX;
+ let mut state = State::new(false);
+ let a = std::path::PathBuf::from("/tmp/cce-recent-alpha");
+ let b = std::path::PathBuf::from("/tmp/cce-recent-beta");
+ state.recent_files = vec![a.clone(), b.clone()];
+
+ state.open_dialog();
+ let rows: Vec<String> = state.slots.dialog.rows.iter().map(|r| r.id.clone()).collect();
+ let id_a = format!("{RECENT_ROW_PREFIX}{}", a.display());
+ let id_b = format!("{RECENT_ROW_PREFIX}{}", b.display());
+ let ia = rows.iter().position(|r| *r == id_a).expect("no row for the newest recent project");
+ let ib = rows.iter().position(|r| *r == id_b).expect("no row for the older recent project");
+ assert!(ia < ib, "the recent list is not in most-recent-first order");
+ // The row shows the path, truncated from the LEFT — the tail is what
+ // identifies a project — with the file name in the chord column.
+ let row = &state.slots.dialog.rows[ia];
+ assert_eq!(row.label, a.display().to_string());
+ assert_eq!(row.chord, "cce-recent-alpha");
+ assert!(row.truncate_head);
+ // And it ranks against the path text like any other row.
+ state.slots.dialog.query = "beta".to_string();
+ state.refresh_dialog_rows();
+ let rows: Vec<String> = state.slots.dialog.rows.iter().map(|r| r.id.clone()).collect();
+ assert!(rows.contains(&id_b) && !rows.contains(&id_a), "{rows:?}");
+ state.close_dialog();
+
+ // The project already open is not offered a second time.
+ state.loaded_project_path = Some(a.clone());
+ state.open_dialog();
+ let rows: Vec<String> = state.slots.dialog.rows.iter().map(|r| r.id.clone()).collect();
+ assert!(!rows.contains(&id_a), "the open project is listed as a recent one");
+ assert!(rows.contains(&id_b));
+ }
+
+ /// Every display setting the retired utility subnets held is reachable —
+ /// as a Settings row, a command, or both.
+ ///
+ /// This is the check the removal turns on. Those four nodes were the only
+ /// way to reach a good half of these values, so a setting left out of the
+ /// table when they went is not "hidden in the node tree", it is GONE, and
+ /// nothing else in the suite would notice.
+ #[test]
+ fn every_retired_subnet_setting_is_reachable() {
+ let labels: Vec<&str> = crate::dialog::SETTINGS.iter().map(|s| s.label).collect();
+ for label in [
+ // guides
+ "Show Grid", "Grid Color", "Grid Thickness", "Show Origin Axes",
+ "Origin Size", "Show Reference Cube", "Point Marker Size",
+ "Point Marker Color", "World Unit",
+ // render
+ "Show Wireframe", "Wireframe Single Color", "Wireframe Color",
+ "Wire Thickness", "Opacity", "Show Points", "Point Size", "Point Color",
+ // main
+ "Background Color", "Ray Traced Preview", "Circular Pane",
+ // camera
+ "Show Camera Pivot", "Camera Pivot Size", "Square Aspect",
+ // network
+ "Show Network Plate",
+ ] {
+ assert!(labels.contains(&label), "'{label}' has no Settings row and no other way in");
+ }
+ // The Main node's buttons are commands, and the active camera keeps
+ // the viewport menubar's own menu — neither is a row here.
+ for id in [
+ "new_project", "open_project", "save_document", "save_document_as",
+ "set_as_default", "exit", "undo", "redo",
+ "zoom_in", "zoom_out", "reset_zoom", "detach_circular_window",
+ ] {
+ assert!(crate::command::by_id(id).is_some(), "the Main node's '{id}' has no command");
+ }
}
/// Reopening starts clean: on Commands, with an empty query.
@@ -9560,44 +9812,34 @@ mod tests {
assert_eq!(added.position, (3.0, 2.0), "placed at the grid cursor");
}
- /// Geometry templates are refused inside a utility dir, so the list does
- /// not offer them there — the same filter the popup was fed.
+ /// The Add Node list offers every template, everywhere.
+ ///
+ /// 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.
#[test]
fn dialog_add_node_hides_geometry_templates_in_a_utility_dir() {
let mut state = State::new(false);
- let offered_at_root = {
- state.open_node_palette();
- let n = state.slots.dialog.rows.len();
- state.close_dialog();
- n
- };
+ 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"));
+ state.close_dialog();
- // Into the root meta node, which `in_settings_dir` reports as utility.
- let meta = state
+ // Inside a subnet, the same list.
+ let sphere = state
.fs_root
.children
.iter()
- .position(|c| c.node_type == "meta")
- .expect("the root meta node");
- state.current_path.push(meta);
- assert!(state.in_settings_dir());
-
+ .position(|c| c.name.starts_with("sphere"))
+ .expect("a sphere at the root");
+ state.current_path.push(sphere);
+ state.on_path_changed();
state.open_node_palette();
- let offered_in_utility = state.slots.dialog.rows.len();
- assert!(
- offered_in_utility < offered_at_root,
- "{offered_in_utility} offered in a utility dir vs {offered_at_root} at the root"
- );
- assert!(
- !state.slots.dialog.rows.iter().any(|r| r.label == "Grid"),
- "a geometry template would be refused at placement"
- );
- // Box/Sphere/Plane/Extrude are `"type": "node"` SUBNET templates, not
- // native geometry types, so `is_geometry_node_type` does not claim
- // them and the filter leaves them offered. Pre-existing, and exactly
- // what the popup was fed — asserted so the next reader does not take
- // it for a hole in this filter.
- assert!(state.slots.dialog.rows.iter().any(|r| r.label == "Box"));
+ assert_eq!(state.slots.dialog.rows.len(), at_root);
}
/// Ctrl+P lands on Commands rather than toggling, which is the one thing
diff --git a/src/project.rs b/src/project.rs
index 693a3ae..261c060 100644
--- a/src/project.rs
+++ b/src/project.rs
@@ -4,7 +4,7 @@ use std::path::Path;
use crate::app::{State, Project, FsNode, ProjectViewState, PlateGeometry, ParamDef};
use crate::slots::CONTENT_IDX;
-fn color_to_hex(rgb: [f32; 3]) -> String {
+pub(crate) fn color_to_hex(rgb: [f32; 3]) -> String {
format!("#{:02x}{:02x}{:02x}",
(rgb[0] * 255.0).round().clamp(0.0, 255.0) as u8,
(rgb[1] * 255.0).round().clamp(0.0, 255.0) as u8,
@@ -12,11 +12,11 @@ fn color_to_hex(rgb: [f32; 3]) -> String {
)
}
-fn hex_to_color(hex: &str) -> Option<[f32; 3]> {
+pub(crate) fn hex_to_color(hex: &str) -> Option<[f32; 3]> {
cce_ui::color::parse_hex_rgb(hex)
}
-fn color_to_hex8(rgba: [f32; 4]) -> String {
+pub(crate) fn color_to_hex8(rgba: [f32; 4]) -> String {
format!("#{:02x}{:02x}{:02x}{:02x}",
(rgba[0] * 255.0).round().clamp(0.0, 255.0) as u8,
(rgba[1] * 255.0).round().clamp(0.0, 255.0) as u8,
@@ -26,10 +26,19 @@ fn color_to_hex8(rgba: [f32; 4]) -> String {
}
/// 6- or 8-digit hex → RGBA (alpha 1.0 when absent).
-fn hex_to_rgba(hex: &str) -> Option<[f32; 4]> {
+pub(crate) fn hex_to_rgba(hex: &str) -> Option<[f32; 4]> {
cce_ui::color::parse_hex_rgba(hex)
}
+/// "network" -> "Network", for rebuilding an old save's param names.
+fn capitalize(s: &str) -> String {
+ let mut c = s.chars();
+ match c.next() {
+ Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
+ None => String::new(),
+ }
+}
+
impl State {
@@ -90,15 +99,15 @@ impl State {
self.recent_files.insert(0, abs_path);
self.recent_files.truncate(10);
Self::save_recent_files(&self.recent_files);
- self.ensure_menubar_subnets();
+ self.migrate_meta_settings_node();
}
- /// The view-state block every save and snapshot shares. Pane visibility
- /// is NOT here — it lives in the root meta node's View subnet params,
- /// which ride `fs_root` into the file; this carries the rest of the pane
- /// state (collapse + splitter proportions) beside the camera/pan fields.
+ /// The view-state block every save and snapshot shares — the pane state
+ /// (visibility, collapse, splitter proportions, docks, pins) beside the
+ /// camera/pan fields. Visibility rode the root meta node's View subnet
+ /// params into the file until that node was retired.
pub(crate) fn project_view_state(&self) -> ProjectViewState {
let collapsed_panes = crate::plate_corner::PLATE_SLOTS
.iter()
@@ -149,6 +158,13 @@ impl State {
pan: (self.pan_x, self.pan_y),
current_path: self.current_path.clone(),
selected_node: self.graph().selected_node(),
+ visible_panes: Some(
+ Self::PANE_FLAGS
+ .iter()
+ .filter(|(_, get, _)| get(self))
+ .map(|(name, _, _)| name.to_string())
+ .collect(),
+ ),
collapsed_panes,
splitters,
dock_tabs,
@@ -172,8 +188,7 @@ impl State {
/// 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 (`apply_settings_from_menubar_subnets` reads them
- /// off it), so those restore only for a view with no node.
+ /// current directory, 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();
@@ -222,7 +237,7 @@ impl State {
// its single-pane layout into the file, and the main window's next
// reload would apply it (the load side is gated the same way).
if !self.is_detached_network && self.detached_pane.is_none() {
- self.ensure_menubar_subnets();
+ self.migrate_meta_settings_node();
}
if path.file_name().map_or(false, |n| n == "default_project.json") {
let proj = Project {
@@ -257,45 +272,35 @@ impl State {
Ok(())
}
- /// The pane-visibility toggles as saved in a project tree's meta→View
- /// subnet. Read them off the LOADED tree before `ensure_menubar_subnets`
- /// runs — it refreshes those params from live state, clobbering what the
- /// file said.
- fn project_pane_visibility(root: &FsNode) -> Vec<(String, bool)> {
- root.children
- .iter()
- .find(|c| c.node_type == "meta")
- .and_then(|m| m.children.iter().find(|c| c.name == "view"))
- .map(|v| {
- v.params
- .iter()
- .filter(|p| p.param_type == "toggle" && p.name.starts_with("Show ") && p.name.ends_with(" Pane"))
- .filter_map(|p| p.default.parse::<bool>().ok().map(|b| (p.name.clone(), b)))
- .collect()
- })
- .unwrap_or_default()
- }
+ /// The five pane-visibility flags by their saved name, with the menu
+ /// action that flips each — the one table the save and the load share,
+ /// so a pane cannot be written under a name the loader does not know.
+ const PANE_FLAGS: [(&'static str, fn(&State) -> bool, &'static str); 5] = [
+ ("network", |s| s.show_network, "Show Network Pane"),
+ ("viewport", |s| s.show_viewport, "Show Viewport Pane"),
+ ("parameters", |s| s.show_parameters, "Show Parameters Pane"),
+ ("spreadsheet", |s| s.show_spreadsheet, "Show Spreadsheet Pane"),
+ ("playbar", |s| s.show_playbar, "Show Playbar Pane"),
+ ];
/// Apply a loaded project's pane state: visibility diffs fire the same
/// menu actions the View toggles use (slots, checkmarks, focus fixup all
/// included), then collapse and splitter proportions. Main window only —
/// detached windows own their single-pane layout, and the sync channel
/// must not re-shape them.
- fn apply_pane_state_from_project(&mut self, visibility: &[(String, bool)], vs: &ProjectViewState) {
+ fn apply_pane_state_from_project(&mut self, vs: &ProjectViewState) {
if self.is_detached_network || self.detached_pane.is_some() {
return;
}
- for (name, desired) in visibility {
- let cur = match name.as_str() {
- "Show Network Pane" => Some(self.show_network),
- "Show Viewport Pane" => Some(self.show_viewport),
- "Show Parameters Pane" => Some(self.show_parameters),
- "Show Spreadsheet Pane" => Some(self.show_spreadsheet),
- "Show Playbar Pane" => Some(self.show_playbar),
- _ => None,
- };
- if cur == Some(!*desired) {
- self.execute_menu_action(name);
+ // Absent (an older save, or one written before pane state moved off
+ // the meta node) keeps the live layout — the same rule the collapse
+ // list and the splitters follow.
+ if let Some(open) = &vs.visible_panes {
+ for (name, get, action) in Self::PANE_FLAGS {
+ let desired = open.iter().any(|n| n == name);
+ if get(self) != desired {
+ self.execute_menu_action(action);
+ }
}
}
// Absent names expand: an older save (no collapse list) loads with
@@ -386,21 +391,13 @@ impl State {
let mut proj: Project = serde_json::from_str(&content)?;
proj.sanitize_node_names();
crate::app::merge_template_defs(&mut proj.root, &self.node_templates);
- let saved_pane_vis = Self::project_pane_visibility(&proj.root);
self.fs_root = proj.root;
- // The project's viewport settings — the Guides and Render
- // nodes' values, Main's background — onto the live state FIRST:
- // `ensure_menubar_subnets` re-seeds those params from live state
- // (so a chord-flipped toggle shows on the node), which on a load
- // stamped the preferences file's values over the file's and lost
- // them before the apply below could read them (2026-09-21).
- // A load is not a colour change: the loaded tree's value is the
- // baseline, so the auto-enable of single-colour mode stays quiet.
+ // A load is not a colour change: an older save's wire colour is
+ // the baseline, so the auto-enable of single-colour mode stays
+ // quiet while the migration reads it.
self.last_applied_wire_color = None;
- self.apply_settings_from_menubar_subnets();
- self.ensure_menubar_subnets();
- self.apply_settings_from_menubar_subnets();
- self.apply_pane_state_from_project(&saved_pane_vis, &proj.view_state);
+ self.migrate_meta_settings_node();
+ self.apply_pane_state_from_project(&proj.view_state);
self.active_camera = proj.view_state.active_camera;
self.pan_x = proj.view_state.pan.0;
self.pan_y = proj.view_state.pan.1;
@@ -452,17 +449,11 @@ impl State {
let mut proj: Project = serde_json::from_str(&content)?;
proj.sanitize_node_names();
crate::app::merge_template_defs(&mut proj.root, &self.node_templates);
- let saved_pane_vis = Self::project_pane_visibility(&proj.root);
self.fs_root = proj.root;
- // As in the default-project branch: the file's viewport settings
- // land on the live state before ensure re-seeds the nodes from it.
- // A load is not a colour change: the loaded tree's value is the
- // baseline, so the auto-enable of single-colour mode stays quiet.
+ // As in the default-project branch.
self.last_applied_wire_color = None;
- self.apply_settings_from_menubar_subnets();
- self.ensure_menubar_subnets();
- self.apply_settings_from_menubar_subnets();
- self.apply_pane_state_from_project(&saved_pane_vis, &proj.view_state);
+ self.migrate_meta_settings_node();
+ self.apply_pane_state_from_project(&proj.view_state);
self.active_camera = proj.view_state.active_camera;
self.pan_x = proj.view_state.pan.0;
self.pan_y = proj.view_state.pan.1;
@@ -559,8 +550,7 @@ impl State {
inputs: 0,
outputs: 0,
};
- self.ensure_menubar_subnets();
- self.apply_settings_from_menubar_subnets();
+ self.migrate_meta_settings_node();
self.active_camera = "Default Camera".to_string();
self.pan_x = 0.0;
self.pan_y = 0.0;
@@ -590,668 +580,138 @@ impl State {
self.update_window_title();
}
- pub(crate) fn ensure_menubar_subnets(&mut self) {
- let vp_show_grid = self.viewport().show_grid;
- let vp_show_cube = self.viewport().show_cube;
- let vp_show_origin = self.viewport().show_origin;
- let vp_show_camera_pivot = self.viewport().show_camera_pivot;
- let vp_bg_color = self.viewport().bg_color;
- let vp_grid_color = self.viewport().grid_color;
- let vp_rt_mode = self.viewport().rt_mode;
- let show_network = self.show_network;
- let show_viewport = self.show_viewport;
- let show_parameters = self.show_parameters;
- let show_spreadsheet = self.show_spreadsheet;
- let show_playbar = self.show_playbar;
- let network_plate = self.network_plate;
- let wireframe = self.wireframe;
- let wire_single_color = self.wire_single_color;
- let wire_color = self.wire_color;
- let wire_width = self.wire_width;
- let geo_opacity = self.geo_opacity;
- let render_points = self.render_points;
- let point_size = self.point_size;
- let point_color = self.point_color;
- let square_viewport = self.square_viewport;
- let bool_str = |b: bool| if b { "true" } else { "false" };
-
- let camera_nodes: Vec<String> = self.current_dir().children.iter()
- .filter(|c| c.node_type == "camera")
- .map(|c| c.name.clone())
- .collect();
- let mut camera_options = vec!["Default Camera".to_string()];
- camera_options.extend(camera_nodes);
- let camera_options_refs: Vec<&str> = camera_options.iter().map(|s| s.as_str()).collect();
-
- let mut recent_options = vec!["- Select -".to_string()];
- for path in &self.recent_files {
- recent_options.push(path.to_string_lossy().to_string());
- }
- recent_options.push("Other".to_string());
- let recent_options_refs: Vec<&str> = recent_options.iter().map(|s| s.as_str()).collect();
-
-
- fn find_or_create_subnet<'a>(parent: &'a mut FsNode, name: &str, node_type: &str, pos: (f32, f32)) -> &'a mut FsNode {
- if let Some(idx) = parent.children.iter().position(|c| c.name == name) {
- let node = &mut parent.children[idx];
- node.node_type = node_type.to_string();
- if node.position == (0.0, 0.0) {
- node.position = pos;
- }
- node
+ /// Pull an older project's settings off its root `meta` node, then take
+ /// the node out.
+ ///
+ /// Until 2026-09-23 session-wide display settings lived as params on four
+ /// utility subnets (`main`, `view`, `guides`, `render`) under a permanent
+ /// root `meta` node, and that node tree was the STORE OF RECORD:
+ /// `ensure_menubar_subnets` rebuilt it from live state and
+ /// `apply_settings_from_menubar_subnets` copied it back over live state
+ /// after every parameter edit anywhere. A display preference was
+ /// therefore a piece of project data, carried in the file, reset by
+ /// opening someone else's scene — and editable only by selecting the
+ /// right node in the right utility subnet.
+ ///
+ /// They are settings, and they are set from the command palette now: the
+ /// live fields are the values, `DesignSettings` persists them to
+ /// `state.kdl`, and the dialog's Settings half edits them (see
+ /// `SETTINGS` in `src/dialog.rs`). This runs once per load to carry a
+ /// saved project's values across rather than dropping them on the floor —
+ /// a user who set a grid colour two years ago keeps it.
+ ///
+ /// Pane visibility is NOT read here: it is genuinely project state and
+ /// has moved to `ProjectViewState::visible_panes`, which `load_from_file`
+ /// applies. An old save's `view` subnet is read for it, though, or every
+ /// project saved before the move would open with the default layout.
+ pub(crate) fn migrate_meta_settings_node(&mut self) {
+ let meta_idx = self.fs_root.children.iter().position(|c| {
+ matches!(c.node_type.as_str(), "session" | "meta") || c.name == "Session"
+ });
+ // Pre-Session saves parked the four subnets FLAT at the root, with no
+ // container above them, so the sweep below runs whether or not a meta
+ // node was found — an early return on the container alone left that
+ // whole generation of file carrying four dead nodes forever.
+ let mut subnets: Vec<FsNode> = match meta_idx {
+ Some(i) => self.fs_root.children.remove(i).children,
+ None => Vec::new(),
+ };
+ let mut i = 0;
+ while i < self.fs_root.children.len() {
+ let c = &self.fs_root.children[i];
+ if c.node_type == "utility"
+ && matches!(c.name.as_str(), "main" | "view" | "guides" | "render")
+ {
+ subnets.push(self.fs_root.children.remove(i));
} else {
- let new_node = FsNode {
- id: crate::app::generate_node_id(),
- name: name.to_string(),
- node_type: node_type.to_string(),
- children: vec![],
- params: vec![],
- geometry_visible: true,
- position: pos,
- inputs: 1,
- outputs: 1,
- };
- parent.children.push(new_node);
- parent.children.last_mut().unwrap()
+ i += 1;
}
}
-
- fn ensure_param(node: &mut FsNode, name: &str, param_type: &str, default_val: &str, options: &[&str], min: Option<f32>, max: Option<f32>, step: Option<f32>) {
- if !node.params.iter().any(|p| p.name == name) {
- node.params.push(ParamDef {
- name: name.to_string(),
- label: name.to_string(),
- param_type: param_type.to_string(),
- default: default_val.to_string(),
- options: options.iter().map(|s| s.to_string()).collect(),
- min,
- max,
- step,
- show_when: String::new(),
- });
+ // 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.
+ let mut i = 0;
+ while i < subnets.len() {
+ if matches!(subnets[i].name.as_str(), "main" | "view" | "guides" | "render") {
+ i += 1;
+ } else {
+ let mut node = subnets.remove(i);
+ let (nx, ny) = self.find_empty_cell(node.position.0, node.position.1, None);
+ node.position = (nx, ny);
+ self.fs_root.children.push(node);
}
}
-
- fn set_toggle(p: &mut ParamDef, on: bool) {
- p.param_type = "toggle".to_string();
- p.options.clear();
- p.default = if on { "true" } else { "false" }.to_string();
- }
-
- // Retain only the Main utility subnet, removing the rest
- self.fs_root.children.retain(|c| c.name != "Network" && c.name != "Viewport" && c.name != "Parameters" && c.name != "Spreadsheet");
-
- // The root meta node: the permanent root container for the
- // session-wide settings nodes (Main/View/Guides/Render) — the root
- // network's counterpart of every node's per-node `meta` child, and
- // still a subnet. It began life as the "Session" node; older saves
- // carry it typed "session" (or the four settings nodes flat at the
- // root) and are migrated — retyped/renamed, params intact. The node
- // itself is undeletable (delete_node refuses the "meta" type).
- let mut migrated: Vec<FsNode> = Vec::new();
- {
- let mut idx = 0;
- while idx < self.fs_root.children.len() {
- let c = &self.fs_root.children[idx];
- if c.node_type == "utility"
- && matches!(c.name.as_str(), "main" | "view" | "guides" | "render")
- {
- migrated.push(self.fs_root.children.remove(idx));
- } else {
- idx += 1;
- }
- }
+ if subnets.is_empty() {
+ return;
}
- let session_idx = match self
- .fs_root
- .children
- .iter()
- .position(|c| matches!(c.node_type.as_str(), "session" | "meta") || c.name == "Session")
- {
- Some(i) => {
- self.fs_root.children[i].node_type = "meta".to_string();
- self.fs_root.children[i].name = "meta".to_string();
- i
- }
- None => {
- self.fs_root.children.push(FsNode {
- id: crate::app::generate_node_id(),
- name: "meta".to_string(),
- node_type: "meta".to_string(),
- children: vec![],
- params: vec![],
- geometry_visible: true,
- position: (0.0, 0.0),
- inputs: 0,
- outputs: 0,
- });
- self.fs_root.children.len() - 1
- }
+ let params = |name: &str| -> Vec<ParamDef> {
+ subnets
+ .iter()
+ .find(|c| c.name.eq_ignore_ascii_case(name))
+ .map(|n| n.params.clone())
+ .unwrap_or_default()
};
- for node in migrated {
- let session = &mut self.fs_root.children[session_idx];
- if !session.children.iter().any(|c| c.name == node.name) {
- session.children.push(node);
- }
- }
- let session = &mut self.fs_root.children[session_idx];
-
- // 1. Main subnet
- let main_node = find_or_create_subnet(session, "main", "utility", (0.0, 0.0));
- main_node.children.clear();
+ let as_bool = |p: &ParamDef| p.default.parse::<bool>().ok();
+ let as_f32 = |p: &ParamDef| p.default.parse::<f32>().ok();
- ensure_param(main_node, "File", "section", "", &[], None, None, None);
- ensure_param(main_node, "New Project", "button", "", &[], None, None, None);
-
- if let Some(p) = main_node.params.iter_mut().find(|p| p.name == "Open") {
- p.options = recent_options.clone();
- if !p.options.contains(&p.default) {
- p.default = "- Select -".to_string();
- }
- } else {
- ensure_param(main_node, "Open", "choice", "- Select -", &recent_options_refs, None, None, None);
- }
-
- ensure_param(main_node, "Save", "button", "", &[], None, None, None);
- ensure_param(main_node, "Save As", "button", "", &[], None, None, None);
- ensure_param(main_node, "Set As Default", "button", "", &[], None, None, None);
- ensure_param(main_node, "Exit", "button", "", &[], None, None, None);
-
- ensure_param(main_node, "Edit", "section", "", &[], None, None, None);
- ensure_param(main_node, "Undo", "button", "", &[], None, None, None);
- ensure_param(main_node, "Redo", "button", "", &[], None, None, None);
-
- // The pane-visibility toggles moved to the View utility node (below):
- // retire Main's copies and its now-empty View section from older saves.
- // No value migration — pane state is session-owned, never applied from
- // the project, so the View node seeds from live state.
- main_node.params.retain(|p| {
- !matches!(
- p.name.as_str(),
- "View" | "Show Network Pane" | "Show Viewport Pane" | "Show Parameters Pane"
- | "Show Spreadsheet Pane" | "Show Playbar Pane"
- )
- });
-
- // Network — renamed from the retired "Network Settings" (migrate older
- // saves' section param in place so its position survives the reorder).
- if let Some(sec) = main_node.params.iter_mut().find(|p| p.name == "Network Settings") {
- sec.name = "Network".to_string();
- sec.label = "Network".to_string();
- }
- ensure_param(main_node, "Network", "section", "", &[], None, None, None);
- ensure_param(main_node, "Zoom In", "button", "", &[], None, None, None);
- ensure_param(main_node, "Zoom Out", "button", "", &[], None, None, None);
- ensure_param(main_node, "Reset Zoom", "button", "", &[], None, None, None);
- ensure_param(main_node, "Detach Circular Window", "button", "", &[], None, None, None);
- ensure_param(main_node, "Circular Pane", "toggle", bool_str(self.circular_network_pane), &[], None, None, None);
- // Node color is config-owned (style.surface.graph.node.color in
- // config.kdl) — drop the retired per-project params from older saves.
- main_node.params.retain(|p| !matches!(p.name.as_str(), "Node Color R" | "Node Color G" | "Node Color B"));
-
- // Viewport — renamed from the retired "Viewport Settings" (migrate
- // older saves' section param in place).
- if let Some(sec) = main_node.params.iter_mut().find(|p| p.name == "Viewport Settings") {
- sec.name = "Viewport".to_string();
- sec.label = "Viewport".to_string();
- }
- ensure_param(main_node, "Viewport", "section", "", &[], None, None, None);
- if let Some(p) = main_node.params.iter_mut().find(|p| p.name == "Active Camera") {
- p.options = camera_options.clone();
- if !p.options.contains(&p.default) {
- p.default = "Default Camera".to_string();
- }
- } else {
- ensure_param(main_node, "Active Camera", "choice", &self.active_camera, &camera_options_refs, None, None, None);
- }
-
- ensure_param(main_node, "Ray Traced Preview", "toggle", bool_str(vp_rt_mode), &[], None, None, None);
- ensure_param(main_node, "Background Color", "color", &color_to_hex(vp_bg_color), &[], None, None, None);
- // Mirrors the live value, as the toggles do: a background set on the
- // viewport rather than through the node still reaches the saved tree.
- if let Some(p) = main_node.params.iter_mut().find(|p| p.name == "Background Color") {
- p.default = color_to_hex(vp_bg_color);
- }
-
- // The Style section is retired — DE chrome is config-owned, not
- // per-project: the wall and edge relief curves are
- // `style.surface.relief.profile` / `.edge_profile` and the params
- // plate tint is `style.surface.param.color` in config.kdl, which
- // cce-ui already applies for every client. Main's copies shadowed
- // those on load, so a project file silently outranked the user's
- // config. Drop them from older saves.
- main_node.params.retain(|p| {
- !matches!(
- p.name.as_str(),
- "Style" | "Bevel Profile" | "Edge Profile" | "Plate Color"
- )
- });
-
- // The Help section is retired — its only row was an About button nothing
- // dispatched. Drop it from older saves too.
- main_node.params.retain(|p| !matches!(p.name.as_str(), "Help" | "About"));
-
- // The viewport guide params moved to the Guides utility node: retire
- // Main's copies, keeping an older save's values as the seeds.
- let migrated_grid = main_node.params.iter()
- .find(|p| p.name == "Show Grid Guide")
- .and_then(|p| p.default.parse::<bool>().ok());
- let migrated_cube = main_node.params.iter()
- .find(|p| p.name == "Show Reference Cube")
- .and_then(|p| p.default.parse::<bool>().ok());
- let migrated_origin = main_node.params.iter()
- .find(|p| p.name == "Show Origin Axes")
- .and_then(|p| p.default.parse::<bool>().ok());
- let migrated_thickness = main_node.params.iter()
- .find(|p| p.name == "Grid Thickness")
- .map(|p| p.default.clone());
- let migrated_origin_size = main_node.params.iter()
- .find(|p| p.name == "Origin Guide Size")
- .map(|p| p.default.clone());
- let migrated_grid_color = main_node.params.iter()
- .find(|p| p.name == "Grid Color")
- .map(|p| p.default.clone());
- main_node.params.retain(|p| {
- !matches!(
- p.name.as_str(),
- "Show Grid Guide" | "Show Reference Cube" | "Show Origin Axes"
- | "Grid Thickness" | "Origin Guide Size" | "Grid Color"
- )
- });
-
- // Square Aspect / Show Camera Pivot moved to the camera nodes
- // (per-camera display params): retire Main's copies, keeping an older
- // save's values as the seed for the cameras below.
- let migrated_square = main_node.params.iter()
- .find(|p| p.name == "Square Aspect")
- .and_then(|p| p.default.parse::<bool>().ok());
- let migrated_pivot = main_node.params.iter()
- .find(|p| p.name == "Show Camera Pivot")
- .and_then(|p| p.default.parse::<bool>().ok());
- let migrated_pivot_size = main_node.params.iter()
- .find(|p| p.name == "Camera Pivot Size")
- .map(|p| p.default.clone());
- main_node.params.retain(|p| {
- !matches!(p.name.as_str(), "Square Aspect" | "Show Camera Pivot" | "Camera Pivot Size")
- });
-
- // Boolean settings render as toggles. Older saves stored these as
- // choice dropdowns / buttons; retype them so a reopened project shows
- // real switches.
- for p in main_node.params.iter_mut() {
+ for p in params("guides").iter().chain(params("main").iter()) {
match p.name.as_str() {
- "Circular Pane" | "Ray Traced Preview" => {
- p.param_type = "toggle".to_string();
- p.options.clear();
- if p.default != "true" { p.default = "false".to_string(); }
+ "Show Grid Guide" | "Show Grid" => {
+ if let Some(v) = as_bool(p) { self.viewport_mut().show_grid = v; }
}
- _ => {}
- }
- }
-
- const MAIN_PARAM_ORDER: [&str; 20] = [
- "File", "New Project", "Open", "Save", "Save As", "Set As Default", "Exit",
- "Edit", "Undo", "Redo",
- "Network", "Zoom In", "Zoom Out",
- "Reset Zoom", "Detach Circular Window", "Circular Pane",
- "Viewport", "Active Camera",
- "Ray Traced Preview",
- "Background Color",
- ];
- main_node.params.sort_by_key(|p| {
- MAIN_PARAM_ORDER
- .iter()
- .position(|n| *n == p.name)
- .unwrap_or(MAIN_PARAM_ORDER.len())
- });
-
- // Display labels only — the `name` stays the dispatch identity used by
- // execute_menu_action and the live-toggle refresh. The params pane keys
- // off `label` when set (param_display), and sync_parameters_to_project
- // resolves a click back to its param by that same display key.
- for p in main_node.params.iter_mut() {
- if p.name == "New Project" {
- p.label = "New".to_string();
- } else if p.param_type == "toggle" {
- if let Some(rest) = p.name.strip_prefix("Show ") {
- p.label = rest.to_string();
+ "Show Reference Cube" | "Cube" => {
+ if let Some(v) = as_bool(p) { self.viewport_mut().show_cube = v; }
}
- }
- }
-
- // 2. View subnet — the pane-visibility switches, migrated off Main's
- // View section (the Guides pattern: a setting's home is a utility
- // node; the header menu items stay as command access). The toggles
- // refresh from live state — mid-session, the live flags are the
- // authority — but they are ALSO the persisted pane state: save_to_file
- // syncs this mirror before cloning the tree, and load_from_file reads
- // the loaded values (before this refresh clobbers them) and applies
- // the diffs via apply_pane_state_from_project.
- let view_node = find_or_create_subnet(&mut self.fs_root.children[session_idx], "view", "utility", (0.0, 2.0));
- view_node.children.clear();
- ensure_param(view_node, "Panes", "section", "", &[], None, None, None);
- ensure_param(view_node, "Show Network Pane", "toggle", bool_str(show_network), &[], None, None, None);
- ensure_param(view_node, "Show Viewport Pane", "toggle", bool_str(show_viewport), &[], None, None, None);
- ensure_param(view_node, "Show Parameters Pane", "toggle", bool_str(show_parameters), &[], None, None, None);
- ensure_param(view_node, "Show Spreadsheet Pane", "toggle", bool_str(show_spreadsheet), &[], None, None, None);
- ensure_param(view_node, "Show Playbar Pane", "toggle", bool_str(show_playbar), &[], None, None, None);
- // Appearance, not visibility — hence its own section. The pane
- // toggles above say which panes EXIST; this one says whether the
- // network draws a surface under its graph or lets the scene through.
- ensure_param(view_node, "Network", "section", "", &[], None, None, None);
- ensure_param(
- view_node,
- "Show Network Plate",
- "toggle",
- bool_str(network_plate),
- &[],
- None,
- None,
- None,
- );
- for p in view_node.params.iter_mut() {
- match p.name.as_str() {
- "Show Network Pane" => set_toggle(p, show_network),
- "Show Viewport Pane" => set_toggle(p, show_viewport),
- "Show Parameters Pane" => set_toggle(p, show_parameters),
- "Show Spreadsheet Pane" => set_toggle(p, show_spreadsheet),
- "Show Playbar Pane" => set_toggle(p, show_playbar),
- "Show Network Plate" => set_toggle(p, network_plate),
- _ => {}
- }
- // "Show Network Pane" -> "Network": inside the Panes section the
- // toggles read by pane name alone. The `name` stays the dispatch
- // identity execute_menu_action fires on.
- if p.param_type == "toggle" {
- if let Some(rest) = p.name.strip_prefix("Show ").and_then(|r| r.strip_suffix(" Pane")) {
- p.label = rest.to_string();
- } else if p.name == "Show Network Plate" {
- // Under its own "Network" section the row reads as
- // "Plate", the same way the pane rows read as their pane.
- p.label = "Plate".to_string();
+ "Show Origin Axes" | "Origin" => {
+ if let Some(v) = as_bool(p) { self.viewport_mut().show_origin = v; }
}
- }
- }
-
- // 3. Guides subnet — viewport guide toggles (home of the grid toggle,
- // migrated off Main). The utility column keeps one empty cell between
- // nodes: Main (0,0), View (0,2), Guides (0,4), Render (0,6); older
- // saves parked at prior defaults slide to the spaced slots.
- let guides_node = find_or_create_subnet(&mut self.fs_root.children[session_idx], "guides", "utility", (0.0, 4.0));
- if guides_node.position == (0.0, 1.0) || guides_node.position == (0.0, 2.0) {
- guides_node.position = (0.0, 4.0);
- }
- guides_node.children.clear();
- ensure_param(guides_node, "Guides", "section", "", &[], None, None, None);
- ensure_param(guides_node, "Show Grid Guide", "toggle", bool_str(migrated_grid.unwrap_or(vp_show_grid)), &[], None, None, None);
- ensure_param(guides_node, "Show Reference Cube", "toggle", bool_str(migrated_cube.unwrap_or(vp_show_cube)), &[], None, None, None);
- ensure_param(guides_node, "Show Origin Axes", "toggle", bool_str(migrated_origin.unwrap_or(vp_show_origin)), &[], None, None, None);
- let thickness_seed = migrated_thickness
- .unwrap_or_else(|| ((self.grid_thickness * 1000.0) as i32).to_string());
- ensure_param(guides_node, "Grid Thickness", "spinbox", &thickness_seed, &[], Some(2.0), Some(200.0), Some(1.0));
- let origin_size_seed = migrated_origin_size
- .unwrap_or_else(|| ((self.origin_size * 10.0) as i32).to_string());
- ensure_param(guides_node, "Origin Guide Size", "spinbox", &origin_size_seed, &[], Some(1.0), Some(50.0), Some(1.0));
- let grid_color_seed = migrated_grid_color.unwrap_or_else(|| color_to_hex(vp_grid_color));
- ensure_param(guides_node, "Grid Color", "color", &grid_color_seed, &[], None, None, None);
- // Size of the Show Point Markers overlay, in thousandths
- // (the Grid Thickness convention): 20 = 0.02 world units.
- let marker_size_seed = ((self.point_marker_size * 1000.0).round() as i32).to_string();
- ensure_param(guides_node, "Point Marker Size", "spinbox", &marker_size_seed, &[], Some(5.0), Some(100.0), Some(1.0));
- let marker_color_seed = color_to_hex(self.point_marker_color);
- ensure_param(guides_node, "Point Marker Color", "color", &marker_color_seed, &[], None, None, None);
- // What a world unit is in the real world. The geometry never
- // converts; the viewport's scale readout and `View 1:1` do.
- ensure_param(guides_node, "World Unit", "choice", self.world_unit.suffix(), &["mm", "cm", "m", "in"], None, None, None);
- for p in guides_node.params.iter_mut() {
- match p.name.as_str() {
- "Show Grid Guide" => set_toggle(p, vp_show_grid),
- "Show Reference Cube" => set_toggle(p, vp_show_cube),
- "Show Origin Axes" => set_toggle(p, vp_show_origin),
+ "Grid Thickness" => if let Some(v) = as_f32(p) { self.grid_thickness = v / 1000.0; },
+ "Origin Guide Size" => if let Some(v) = as_f32(p) { self.origin_size = v / 10.0; },
+ "Camera Pivot Size" => if let Some(v) = as_f32(p) { self.camera_pivot_size = v / 10.0; },
+ "Grid Color" => if let Some(c) = hex_to_color(&p.default) { self.viewport_mut().grid_color = c; },
+ "Background Color" => if let Some(c) = hex_to_color(&p.default) { self.viewport_mut().bg_color = c; },
+ "Point Marker Size" => if let Some(v) = as_f32(p) { self.point_marker_size = v / 1000.0; },
+ "Point Marker Color" => if let Some(c) = hex_to_color(&p.default) { self.point_marker_color = c; },
+ "World Unit" => if let Some(u) = cce_ui::units::Unit::parse(&p.default) { self.world_unit = u; },
+ "Circular Pane" => if let Some(v) = as_bool(p) { self.circular_network_pane = v; },
+ "Ray Traced Preview" => if let Some(v) = as_bool(p) { self.viewport_mut().rt_mode = v; },
_ => {}
}
- if p.param_type == "toggle" {
- if let Some(rest) = p.name.strip_prefix("Show ") {
- p.label = rest.to_string();
- }
- }
}
-
- // 4. Render subnet — render/display controls, present by default like
- // Main. Toggles reflect live state so a reopened project shows real
- // switches. Two rows below Guides (the spaced column); older saves
- // parked at the prior defaults slide down.
- let render_node = find_or_create_subnet(&mut self.fs_root.children[session_idx], "render", "utility", (0.0, 6.0));
- if render_node.position == (0.0, 1.0) || render_node.position == (0.0, 2.0) || render_node.position == (0.0, 4.0) {
- render_node.position = (0.0, 6.0);
- }
- render_node.children.clear();
-
- // The overlay toggle is retired — drop it from older saves so the
- // pane doesn't resurrect it.
- render_node.params.retain(|p| p.name != "Wireframe Overlay");
-
- ensure_param(render_node, "Render Settings", "section", "", &[], None, None, None);
- ensure_param(render_node, "Show Wireframe", "toggle", bool_str(wireframe), &[], None, None, None);
- ensure_param(render_node, "Wire Single Color", "toggle", bool_str(wire_single_color), &[], None, None, None);
- // rgba: the alpha channel is the wireframe's own opacity (the
- // geometry Opacity slider deliberately leaves wires alone).
- ensure_param(render_node, "Wire Color", "rgba", &color_to_hex8(wire_color), &[], None, None, None);
- ensure_param(render_node, "Wire Thickness", "slider:1.0:8.0:1", &format!("{:.1}", wire_width), &[], Some(1.0), Some(8.0), None);
- ensure_param(render_node, "Opacity", "slider:0.00:1.00", &format!("{:.2}", geo_opacity), &[], Some(0.0), Some(1.0), None);
- ensure_param(render_node, "Render Points", "toggle", bool_str(render_points), &[], None, None, None);
- ensure_param(render_node, "Point Size", "slider:0.000:0.100:3", &format!("{:.3}", point_size), &[], Some(0.0), Some(0.10), None);
- ensure_param(render_node, "Point Color", "color", &color_to_hex(point_color), &[], None, None, None);
-
- for p in render_node.params.iter_mut() {
+ for p in params("render") {
match p.name.as_str() {
- "Show Wireframe" => set_toggle(p, wireframe),
- "Wire Single Color" => set_toggle(p, wire_single_color),
- "Wire Color" => {
- // Type migration: early saves carried a plain rgb color.
- p.param_type = "rgba".to_string();
- p.default = color_to_hex8(wire_color);
- }
- "Wire Thickness" => p.default = format!("{:.1}", wire_width),
- "Opacity" => p.default = format!("{:.2}", geo_opacity),
- "Render Points" => set_toggle(p, render_points),
- "Point Size" => {
- // Range/precision migration: older saves carried
- // slider:0.01:0.30 (2 decimals).
- p.param_type = "slider:0.000:0.100:3".to_string();
- p.min = Some(0.0);
- p.max = Some(0.10);
- p.default = format!("{:.3}", point_size);
- }
- "Point Color" => p.default = color_to_hex(point_color),
+ "Show Wireframe" => if let Some(v) = as_bool(&p) { self.wireframe = v; },
+ "Wire Single Color" => if let Some(v) = as_bool(&p) { self.wire_single_color = v; },
+ "Wire Color" => if let Some(c) = hex_to_rgba(&p.default) { self.wire_color = c; },
+ "Wire Thickness" => if let Some(v) = as_f32(&p) { self.wire_width = v.clamp(1.0, 8.0); },
+ "Opacity" => if let Some(v) = as_f32(&p) { self.geo_opacity = v.clamp(0.0, 1.0); },
+ "Render Points" => if let Some(v) = as_bool(&p) { self.render_points = v; },
+ "Point Size" => if let Some(v) = as_f32(&p) { self.point_size = v.clamp(0.0, 0.1); },
+ "Point Color" => if let Some(c) = hex_to_color(&p.default) { self.point_color = c; },
_ => {}
}
- if p.param_type == "toggle" {
- if let Some(rest) = p.name.strip_prefix("Show ") {
- p.label = rest.to_string();
- }
- }
}
-
- // 3. Camera display params — Square Aspect / Show Camera Pivot /
- // Camera Pivot Size live on the camera nodes (applied from the
- // ACTIVE camera). Ensured on every camera in the tree, seeded from
- // the retired Main copies (older saves) or the live values.
- fn ensure_camera_display_params(node: &mut FsNode, square: bool, pivot: bool, pivot_size: &str) {
- if node.node_type == "camera" {
- let bool_str = |b: bool| if b { "true" } else { "false" };
- if !node.params.iter().any(|p| p.name == "Square Aspect") {
- node.params.push(ParamDef {
- name: "Square Aspect".to_string(),
- label: "Square Aspect".to_string(),
- param_type: "toggle".to_string(),
- default: bool_str(square).to_string(),
- options: Vec::new(),
- min: None,
- max: None,
- step: None,
- show_when: String::new(),
- });
- }
- if !node.params.iter().any(|p| p.name == "Show Camera Pivot") {
- node.params.push(ParamDef {
- name: "Show Camera Pivot".to_string(),
- label: "Camera Pivot".to_string(),
- param_type: "toggle".to_string(),
- default: bool_str(pivot).to_string(),
- options: Vec::new(),
- min: None,
- max: None,
- step: None,
- show_when: String::new(),
- });
- }
- if !node.params.iter().any(|p| p.name == "Camera Pivot Size") {
- node.params.push(ParamDef {
- name: "Camera Pivot Size".to_string(),
- label: "Camera Pivot Size".to_string(),
- param_type: "spinbox".to_string(),
- default: pivot_size.to_string(),
- options: Vec::new(),
- min: Some(1.0),
- max: Some(50.0),
- step: Some(1.0),
- show_when: String::new(),
- });
- }
- }
- for child in &mut node.children {
- ensure_camera_display_params(child, square, pivot, pivot_size);
- }
- }
- let square_seed = migrated_square.unwrap_or(square_viewport);
- let pivot_seed = migrated_pivot.unwrap_or(vp_show_camera_pivot);
- let pivot_size_seed = migrated_pivot_size
- .unwrap_or_else(|| ((self.camera_pivot_size * 10.0) as i32).to_string());
- ensure_camera_display_params(&mut self.fs_root, square_seed, pivot_seed, &pivot_size_seed);
- }
-
- pub(crate) fn apply_settings_from_menubar_subnets(&mut self) {
- let session_params = |root: &FsNode, name: &str| -> Option<Vec<ParamDef>> {
- root.children
- .iter()
- .find(|c| c.node_type == "meta")
- .and_then(|s| s.children.iter().find(|c| c.name == name))
- .map(|n| n.params.clone())
- };
- if let Some(params) = session_params(&self.fs_root, "guides") {
- for p in ¶ms {
- match p.name.as_str() {
- "Show Grid Guide" => if let Ok(val) = p.default.parse::<bool>() { self.viewport_mut().show_grid = val; }
- "Show Reference Cube" => if let Ok(val) = p.default.parse::<bool>() { self.viewport_mut().show_cube = val; }
- "Show Origin Axes" => if let Ok(val) = p.default.parse::<bool>() { self.viewport_mut().show_origin = val; }
- "Grid Thickness" => if let Ok(val) = p.default.parse::<f32>() { self.grid_thickness = val / 1000.0; }
- "Origin Guide Size" => if let Ok(val) = p.default.parse::<f32>() { self.origin_size = val / 10.0; }
- "Grid Color" => if let Some(col) = hex_to_color(&p.default) { self.viewport_mut().grid_color = col; }
- "Point Marker Size" => if let Ok(val) = p.default.parse::<f32>() {
- let size = val / 1000.0;
- if (size - self.point_marker_size).abs() > 1e-6 {
- self.point_marker_size = size;
- // The marker geometry bakes the radius in, so a
- // size change re-collects the overlays.
- self.rebuild_scene_geometry();
- }
- }
- "Point Marker Color" => if let Some(col) = hex_to_color(&p.default) {
- if col != self.point_marker_color {
- self.point_marker_color = col;
- // Baked into the marker verts, like the radius.
- self.rebuild_scene_geometry();
- }
- }
- "World Unit" => if let Some(u) = cce_ui::units::Unit::parse(&p.default) {
- if u != self.world_unit {
- self.world_unit = u;
- self.viewport_dirty = true;
- }
- }
- _ => {}
- }
- }
- }
- if let Some(params) = session_params(&self.fs_root, "main") {
- for p in ¶ms {
- match p.name.as_str() {
- // Network Settings
- "Circular Pane" => if let Ok(val) = p.default.parse::<bool>() { self.circular_network_pane = val; }
-
- // Viewport Settings
- "Show Grid Guide" => if let Ok(val) = p.default.parse::<bool>() { self.viewport_mut().show_grid = val; }
- "Show Reference Cube" => if let Ok(val) = p.default.parse::<bool>() { self.viewport_mut().show_cube = val; }
- "Show Origin Axes" => if let Ok(val) = p.default.parse::<bool>() { self.viewport_mut().show_origin = val; }
- "Ray Traced Preview" => if let Ok(val) = p.default.parse::<bool>() { self.viewport_mut().rt_mode = val; }
- "Grid Thickness" => if let Ok(val) = p.default.parse::<f32>() { self.grid_thickness = val / 1000.0; }
- "Origin Guide Size" => if let Ok(val) = p.default.parse::<f32>() { self.origin_size = val / 10.0; }
- "Camera Pivot Size" => if let Ok(val) = p.default.parse::<f32>() { self.camera_pivot_size = val / 10.0; }
- "Background Color" => if let Some(col) = hex_to_color(&p.default) { self.viewport_mut().bg_color = col; }
- "Grid Color" => if let Some(col) = hex_to_color(&p.default) { self.viewport_mut().grid_color = col; }
- "Show Grid" => if let Ok(val) = p.default.parse::<bool>() { self.viewport_mut().show_grid = val; }
- "Cube" => if let Ok(val) = p.default.parse::<bool>() { self.viewport_mut().show_cube = val; }
- "Origin" => if let Ok(val) = p.default.parse::<bool>() { self.viewport_mut().show_origin = val; }
- "Active Camera" => {
- let cam = p.default.clone();
- self.active_camera = cam.clone();
- self.viewport_mut().active_camera = cam;
- }
-
- _ => {}
- }
- }
- }
-
- // The ACTIVE camera's display params (per-camera). Default Camera has
- // no node — the live values stand.
- if self.active_camera != "Default Camera" {
- let active = self.active_camera.clone();
- let cam_params = self.current_dir().children.iter()
- .find(|c| c.node_type == "camera" && c.name == active)
- .map(|c| c.params.clone());
- if let Some(params) = cam_params {
- for p in ¶ms {
- match p.name.as_str() {
- "Square Aspect" => if let Ok(val) = p.default.parse::<bool>() { self.square_viewport = val; }
- "Show Camera Pivot" => if let Ok(val) = p.default.parse::<bool>() { self.viewport_mut().show_camera_pivot = val; }
- "Camera Pivot Size" => if let Ok(val) = p.default.parse::<f32>() { self.camera_pivot_size = val / 10.0; }
- _ => {}
+ // The pane layout an old save carried on its `view` subnet, in the
+ // shape `apply_pane_state_from_project` now reads.
+ let view = params("view");
+ if !view.is_empty() && !(self.is_detached_network || self.detached_pane.is_some()) {
+ for (name, get, action) in Self::PANE_FLAGS {
+ let Some(p) = view
+ .iter()
+ .find(|p| p.param_type == "toggle" && p.name == format!("Show {} Pane", capitalize(name)))
+ else {
+ continue;
+ };
+ if let Some(desired) = as_bool(p) {
+ if get(self) != desired {
+ self.execute_menu_action(action);
}
}
}
}
-
- if let Some(params) = session_params(&self.fs_root, "render") {
- let before = self.wire_color;
- for p in ¶ms {
- match p.name.as_str() {
- "Show Wireframe" => if let Ok(val) = p.default.parse::<bool>() { self.wireframe = val; }
- "Wire Single Color" => if let Ok(val) = p.default.parse::<bool>() { self.wire_single_color = val; }
- "Wire Color" => if let Some(col) = hex_to_rgba(&p.default) { self.wire_color = col; }
- "Wire Thickness" => if let Ok(val) = p.default.parse::<f32>() { self.wire_width = val.clamp(1.0, 8.0); }
- "Opacity" => if let Ok(val) = p.default.parse::<f32>() { self.geo_opacity = val.clamp(0.0, 1.0); }
- "Render Points" => if let Ok(val) = p.default.parse::<bool>() { self.render_points = val; }
- "Point Size" => if let Ok(val) = p.default.parse::<f32>() { self.point_size = val.clamp(0.0, 0.1); }
- "Point Color" => if let Some(col) = hex_to_color(&p.default) { self.point_color = col; }
- _ => {}
- }
- }
- // Setting a wire colour means wanting to see it: a CHANGE to the
- // colour (not a load — the first read of a tree sets the
- // baseline, and a load's value is what it is) turns single-colour
- // mode on if it was off, on the node as well as live, so the
- // switch shows moved. Off, the wires carry the geometry's own
- // colours and the colour row is their alpha alone — which twice
- // read as "the colour did not take" (2026-09-21).
- let changed = self.last_applied_wire_color.is_some_and(|last| last != self.wire_color)
- && before != self.wire_color;
- self.last_applied_wire_color = Some(self.wire_color);
- if changed && !self.wire_single_color {
- self.wire_single_color = true;
- self.write_render_toggle("Wire Single Color", true);
- }
- }
+ // The values are live state now, so they belong in state.kdl — this
+ // is the one write that makes the migration stick.
+ self.save_settings();
}
}
diff --git a/src/shortcut.rs b/src/shortcut.rs
index 9827df0..9489f88 100644
--- a/src/shortcut.rs
+++ b/src/shortcut.rs
@@ -20,6 +20,13 @@ pub enum Action {
TogglePointMarkers,
TogglePointNumbers,
TogglePointNormals,
+ /// The path-traced preview, the wireframe's single-colour mode, and the
+ /// point display. All three were toggles on the root meta node's utility
+ /// subnets and reachable ONLY there; with those nodes retired they are
+ /// commands, which is what makes them reachable at all.
+ ToggleRayTracedPreview,
+ ToggleWireSingleColor,
+ ToggleRenderPoints,
ToggleCircularPane,
DetachCircularWindow,
Save,
diff --git a/src/window.rs b/src/window.rs
index 6815e18..c60a13c 100644
--- a/src/window.rs
+++ b/src/window.rs
@@ -339,7 +339,6 @@ impl State {
2 => {
state.circular_network_pane = !state.circular_network_pane;
let val = state.circular_network_pane;
- state.write_meta_toggle("main", "Circular Pane", val);
state.menu_mut(LEFT_MENUBAR_IDX).set_item_checked(2, 2, val);
state.rebuild_positions();
state.apply_layout();
@@ -691,7 +690,7 @@ impl State {
}
McpAction::Enter { slot } => {
let dir = state.current_dir();
- if slot < dir.children.len() && (dir.children[slot].node_type == "node" || dir.children[slot].node_type == "utility" || !dir.children[slot].children.is_empty()) {
+ if slot < dir.children.len() && (dir.children[slot].node_type == "node" || !dir.children[slot].children.is_empty()) {
state.current_path.push(slot);
state.on_path_changed();
state.sync_parameters_pane();
@@ -724,7 +723,6 @@ impl State {
// Same sequence as the interactive param-pane
// path, so settings params (viewport flags,
// grid) actually take effect via automation.
- state.apply_settings_from_menubar_subnets();
state.sync_grid_settings();
state.sync_nodes();
state.rebuild_scene_geometry();
@@ -775,9 +773,7 @@ impl State {
McpAction::ToggleGeometry { slot } => {
let active_nodes = state.current_dir().children.len();
if slot < active_nodes {
- if state.current_dir().children[slot].node_type == "utility" {
- Err("Cannot toggle geometry visibility on utility nodes".to_string())
- } else {
+ {
let visible = !state.current_dir().children[slot].geometry_visible;
state.current_dir_mut().set_child_geometry_visible(slot, visible);
state.sync_nodes();
@@ -799,16 +795,7 @@ impl State {
// Fresh ids, like paste: a verbatim clone shares the
// template's ids across every instance.
crate::app::regenerate_node_ids(&mut node);
- let mut allowed = true;
- let is_in_utility = state.in_settings_dir();
- if is_in_utility {
- if crate::geometry::is_geometry_node_type(&node.node_type) {
- allowed = false;
- }
- }
- if !allowed {
- Err("Utility nodes cannot contain geometry.".to_string())
- } else {
+ {
let (nx, ny) = state.find_empty_cell(x, y, None);
node.position = (nx, ny);
if let Some(n) = name {
@@ -836,14 +823,6 @@ impl State {
}
}
McpAction::DeleteNode { slot } => {
- if slot < state.current_dir().children.len()
- && matches!(
- state.current_dir().children[slot].node_type.as_str(),
- "session" | "meta"
- )
- {
- return Err("Meta nodes are permanent and cannot be deleted".to_string());
- }
if state.delete_node(slot) {
// delete_node clears/shifts the selection; the param pane
// resync normally comes from process_window_event's tail.
@@ -980,7 +959,6 @@ impl State {
McpAction::ToggleCircularPane => {
state.circular_network_pane = !state.circular_network_pane;
let val = state.circular_network_pane;
- state.write_meta_toggle("main", "Circular Pane", val);
state.menu_mut(LEFT_MENUBAR_IDX).set_item_checked(2, 2, val);
state.rebuild_positions();
state.apply_layout();