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

src/project.rs (33K)

  1 use std::fs;
  2 use std::path::Path;
  3 
  4 use crate::app::{State, Project, FsNode, ProjectViewState, PlateGeometry, ParamDef};
  5 use crate::slots::CONTENT_IDX;
  6 
  7 pub(crate) fn color_to_hex(rgb: [f32; 3]) -> String {
  8     format!("#{:02x}{:02x}{:02x}",
  9         (rgb[0] * 255.0).round().clamp(0.0, 255.0) as u8,
 10         (rgb[1] * 255.0).round().clamp(0.0, 255.0) as u8,
 11         (rgb[2] * 255.0).round().clamp(0.0, 255.0) as u8
 12     )
 13 }
 14 
 15 pub(crate) fn hex_to_color(hex: &str) -> Option<[f32; 3]> {
 16     cce_ui::color::parse_hex_rgb(hex)
 17 }
 18 
 19 /// 6- or 8-digit hex → RGBA (alpha 1.0 when absent).
 20 pub(crate) fn hex_to_rgba(hex: &str) -> Option<[f32; 4]> {
 21     cce_ui::color::parse_hex_rgba(hex)
 22 }
 23 
 24 /// "network" -> "Network", for rebuilding an old save's param names.
 25 fn capitalize(s: &str) -> String {
 26     let mut c = s.chars();
 27     match c.next() {
 28         Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
 29         None => String::new(),
 30     }
 31 }
 32 
 33 impl State {
 34 
 35 
 36     pub(crate) fn update_window_title(&mut self) {
 37         let base_title = if self.is_detached_network {
 38             "Network Pane"
 39         } else {
 40             "Designer"
 41         };
 42         
 43         let mut title = base_title.to_string();
 44         
 45         if let Some(path) = &self.loaded_project_path {
 46             if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
 47                 title.push_str(" - ");
 48                 title.push_str(filename);
 49             }
 50         }
 51         
 52         if self.has_unsaved_changes() {
 53             title.push_str("*");
 54         }
 55 
 56         // The engine polls `Application::settings` and applies title changes.
 57         self.title = title;
 58     }
 59 
 60     /// The recent list, from `<config home>/cce/<app>/recent-files.kdl`.
 61     ///
 62     /// Empty under test, and the write below is skipped there for the same
 63     /// reason [`DesignSettings::file_path`](crate::app::DesignSettings) is
 64     /// redirected: the toolkit derives that path from the EXE's basename, so
 65     /// a test binary wrote a real `~/.config/cce/cce_designer-<hash>/` of its
 66     /// own — seven of them had accumulated by 2026-09-23. Reading is no safer
 67     /// than writing, either: a test that loaded the real list would assert
 68     /// against whatever projects happen to be on the machine running it.
 69     pub(crate) fn load_recent_files() -> Vec<std::path::PathBuf> {
 70         if cfg!(test) {
 71             return Vec::new();
 72         }
 73         cce_ui::config::load_recent_files()
 74             .into_iter()
 75             .map(std::path::PathBuf::from)
 76             .collect()
 77     }
 78 
 79     fn save_recent_files(files: &[std::path::PathBuf]) {
 80         if cfg!(test) {
 81             return;
 82         }
 83         let string_files: Vec<String> = files.iter().map(|p| p.to_string_lossy().to_string()).collect();
 84         cce_ui::config::save_recent_files(&string_files);
 85     }
 86 
 87     pub(crate) fn add_recent_file(&mut self, path: std::path::PathBuf) {
 88         let abs_path = std::fs::canonicalize(&path).unwrap_or(path);
 89         self.recent_files.retain(|p| p != &abs_path);
 90         self.recent_files.insert(0, abs_path);
 91         self.recent_files.truncate(10);
 92         Self::save_recent_files(&self.recent_files);
 93         self.migrate_meta_settings_node();
 94     }
 95 
 96 
 97 
 98     /// The view-state block every save and snapshot shares — the pane state
 99     /// (visibility, collapse, splitter proportions, plate sizes) beside the
100     /// camera/pan fields. Visibility rode the root meta node's View subnet
101     /// params into the file until that node was retired.
102     pub(crate) fn project_view_state(&self) -> ProjectViewState {
103         let collapsed_panes = crate::plate_menu::PLATE_SLOTS
104             .iter()
105             .filter(|&&i| self.collapsed_panes[i])
106             .filter_map(|&i| crate::plate_menu::pane_name_from_slot(i))
107             .map(str::to_string)
108             .collect();
109         let splitters = if self.width > 1.0 {
110             Some((
111                 self.splitter_layout.splitter1_x / self.width,
112                 self.splitter_layout.splitter2_x / self.width,
113             ))
114         } else {
115             None
116         };
117         // A plate's stored size is what was asked for and may exceed a window
118         // that has since shrunk; the loader refuses a fraction past 1 (and
119         // with it the whole block), so a save caps each at the full window.
120         let plates = if self.width > 1.0 && self.height > 1.0 {
121             Some(PlateGeometry {
122                 spreadsheet_height: (self.floating_spreadsheet_height / self.height).min(1.0),
123                 hud_width: Some((self.params_hud_width / self.width).min(1.0)),
124                 params_width: None,
125             })
126         } else {
127             None
128         };
129         ProjectViewState {
130             active_camera: self.active_camera.clone(),
131             pan: (self.pan_x, self.pan_y),
132             current_path: self.current_path.clone(),
133             selected_node: self.graph().selected_node(),
134             visible_panes: Some(
135                 Self::PANE_FLAGS
136                     .iter()
137                     .filter(|(_, get, _)| get(self))
138                     .map(|(name, _, _)| name.to_string())
139                     .collect(),
140             ),
141             collapsed_panes,
142             splitters,
143             plates,
144             frame_range: {
145                 let pb = self.ui_context[self.slots.playbar].inner();
146                 Some((pb.start_frame.round() as i32, pb.end_frame.round() as i32))
147             },
148             default_view: Some(crate::app::DefaultCameraView {
149                 square: self.square_viewport,
150                 show_pivot: self.viewport().show_camera_pivot,
151                 pivot_size: self.camera_pivot_size,
152                 rotation: (self.viewport().rotation_x, self.viewport().rotation_y),
153                 zoom: self.viewport().zoom,
154                 pivot: self.viewport().pivot.to_array(),
155             }),
156             display: Some(self.display_settings()),
157         }
158     }
159 
160     /// The saved Default Camera view onto the live state — after the
161     /// active camera and the path are known. The orbit, zoom and pivot are
162     /// the view and always restore; the square aspect, pivot marker and its
163     /// size are a camera NODE's own params when one is active, so those
164     /// restore only for a view with no node.
165     fn apply_default_view_from_project(&mut self, view: Option<crate::app::DefaultCameraView>) {
166         let Some(v) = view else { return };
167         let active = self.active_camera.clone();
168         let has_node = active != "Default Camera"
169             && self.camera_level().children.iter().any(|c| c.node_type == "camera" && c.name == active);
170         if !has_node {
171             self.square_viewport = v.square;
172             self.camera_pivot_size = v.pivot_size;
173             self.viewport_mut().show_camera_pivot = v.show_pivot;
174         }
175         let vp = self.viewport_mut();
176         vp.rotation_x = v.rotation.0;
177         vp.rotation_y = v.rotation.1;
178         vp.zoom = v.zoom.clamp(0.05, crate::viewport_3d::Viewport3D::MAX_ZOOM);
179         vp.pivot = glam::Vec3::from_array(v.pivot);
180         vp.reset_velocity();
181     }
182 
183 
184     /// The view state a DETACHED window writes into the sync channel: the
185     /// file's own (the main window's layout, display settings and camera,
186     /// as it last wrote them) with only the navigation replaced — the path,
187     /// selection, pan and active camera, which are the parts the main
188     /// window's reload takes. A detached window has no layout worth saving;
189     /// it used to write its defaults here, leaving a file that restored the
190     /// wrong plates and panes whenever it was loaded in full. With no
191     /// readable file the layout blocks go absent, which a full load reads
192     /// as "keep the live layout".
193     fn detached_view_state(&self, path: &Path) -> ProjectViewState {
194         #[derive(serde::Deserialize)]
195         struct ViewOnly {
196             #[serde(default)]
197             view_state: ProjectViewState,
198         }
199         let mut vs = fs::read_to_string(path)
200             .ok()
201             .and_then(|c| serde_json::from_str::<ViewOnly>(&c).ok())
202             .map(|v| v.view_state)
203             .unwrap_or_default();
204         let own = self.project_view_state();
205         vs.active_camera = own.active_camera;
206         vs.pan = own.pan;
207         vs.current_path = own.current_path;
208         vs.selected_node = own.selected_node;
209         vs
210     }
211 
212     pub(crate) fn save_to_file(&mut self, path: &Path) -> Result<(), Box<dyn std::error::Error>> {
213         // The View subnet params mirror the live pane flags, but nothing
214         // refreshes them on a pane toggle — sync the mirror now so the saved
215         // tree carries the pane state that is actually on screen. Main window
216         // only: a detached pane window writing the sync channel would stamp
217         // its single-pane layout into the file, and the main window's next
218         // reload would apply it (the load side is gated the same way).
219         if !self.is_detached_network && self.detached_pane.is_none() {
220             self.migrate_meta_settings_node();
221         }
222         if path.file_name().map_or(false, |n| n == "default_project.json") {
223             let view_state = if self.is_detached_network || self.detached_pane.is_some() {
224                 self.detached_view_state(path)
225             } else {
226                 self.project_view_state()
227             };
228             let proj = Project {
229                 name: "Default Project".to_string(),
230                 root: self.fs_root.clone(),
231                 format: crate::app::PROJECT_FORMAT,
232                 view_state,
233             };
234             let content = serde_json::to_string_pretty(&proj)?;
235             fs::write(path, content)?;
236             self.mark_saved();
237             return Ok(());
238         }
239 
240         let project_dir = path;
241         let project_name = project_dir.file_name()
242             .and_then(|n| n.to_str())
243             .unwrap_or("Default Project")
244             .to_string();
245 
246         fs::create_dir_all(project_dir)?;
247 
248         let state_file_path = project_dir.join("state.json");
249 
250         let proj = Project {
251             name: project_name,
252             root: self.fs_root.clone(),
253             format: crate::app::PROJECT_FORMAT,
254             view_state: self.project_view_state(),
255         };
256         let content = serde_json::to_string_pretty(&proj)?;
257         fs::write(&state_file_path, content)?;
258         self.mark_saved();
259         Ok(())
260     }
261 
262     /// The five pane-visibility flags by their saved name, with the menu
263     /// action that flips each — the one table the save and the load share,
264     /// so a pane cannot be written under a name the loader does not know.
265     const PANE_FLAGS: [(&'static str, fn(&State) -> bool, &'static str); 5] = [
266         ("network", |s| s.show_network, "Show Network Pane"),
267         ("viewport", |s| s.show_viewport, "Show Viewport Pane"),
268         ("parameters", |s| s.show_parameters, "Show Parameters Pane"),
269         ("spreadsheet", |s| s.show_spreadsheet, "Show Spreadsheet Pane"),
270         ("playbar", |s| s.show_playbar, "Show Playbar Pane"),
271     ];
272 
273     /// Apply a loaded project's pane state: visibility diffs fire the same
274     /// menu actions the View toggles use (slots, checkmarks, focus fixup all
275     /// included), then collapse and splitter proportions. Main window only —
276     /// detached windows own their single-pane layout, and the sync channel
277     /// must not re-shape them.
278     fn apply_pane_state_from_project(&mut self, vs: &ProjectViewState) {
279         if self.is_detached_network || self.detached_pane.is_some() {
280             return;
281         }
282         // Absent (an older save, or one written before pane state moved off
283         // the meta node) keeps the live layout — the same rule the collapse
284         // list and the splitters follow.
285         if let Some((start, end)) = vs.frame_range {
286             if start >= 1 && end > start {
287                 let pb = self.ui_context[self.slots.playbar].inner_mut();
288                 pb.start_frame = start as f32;
289                 pb.end_frame = end as f32;
290                 pb.current_frame = pb.current_frame.clamp(pb.start_frame, pb.end_frame);
291             }
292         }
293         if let Some(open) = &vs.visible_panes {
294             for (name, get, action) in Self::PANE_FLAGS {
295                 let desired = open.iter().any(|n| n == name);
296                 if get(self) != desired {
297                     self.execute_menu_action(action);
298                 }
299             }
300         }
301         // Absent names expand: an older save (no collapse list) loads with
302         // every pane open rather than inheriting this session's collapses.
303         for &idx in crate::plate_menu::PLATE_SLOTS.iter() {
304             let desired = crate::plate_menu::pane_name_from_slot(idx)
305                 .map_or(false, |n| vs.collapsed_panes.iter().any(|c| c == n));
306             self.set_pane_collapsed(idx, desired);
307         }
308         if let Some((f1, f2)) = vs.splitters {
309             if self.width > 1.0 && f1 > 0.02 && f2 < 0.98 && f1 < f2 {
310                 self.splitter_layout.splitter1_x = f1 * self.width;
311                 self.splitter_layout.splitter2_x = f2 * self.width;
312                 self.rebuild_positions();
313                 self.apply_layout();
314             }
315         }
316         // Plate geometry: fractions of the window, see `apply_plate_geometry`.
317         if let Some(pg) = vs.plates {
318             self.apply_plate_geometry(pg);
319         }
320         // Before the first configure the fractions just landed on the
321         // placeholder size; the first real resize applies them again.
322         self.pending_plates = if self.window_configured { None } else { vs.plates };
323     }
324 
325     /// Scale saved plate fractions onto this window. The layout pass clamps
326     /// them exactly as a drag would (minimum widths and heights). A save with a nonsense value keeps the live geometry rather
327     /// than loading half of one.
328     pub(crate) fn apply_plate_geometry(&mut self, pg: crate::app::PlateGeometry) {
329         let sane = |f: f32| f.is_finite() && (0.0..=1.0).contains(&f);
330         let Some(hud) = pg.hud_width.or(pg.params_width) else { return };
331         let all = [pg.spreadsheet_height, hud];
332         if self.width > 1.0 && self.height > 1.0 && all.iter().all(|&f| sane(f)) {
333             self.params_hud_width = hud * self.width;
334             self.floating_spreadsheet_height = pg.spreadsheet_height * self.height;
335             self.rebuild_positions();
336             self.apply_layout();
337         }
338     }
339 
340     /// Load the bundled `default_project.json`, which is also the detached
341     /// windows' sync channel. `keep_own_view` is the main window reloading
342     /// what a DETACHED window wrote: it takes the tree and where the user is
343     /// in it (path, selection, pan, active camera) and keeps its own layout,
344     /// display settings and camera view. A detached window has no plates,
345     /// docks or viewport of its own worth the name, so the view state it
346     /// writes is its defaults — and applying that on every one of its
347     /// autosaves reset the main window's plate sizes, pane visibility,
348     /// collapses and camera, and cancelled any pane-edge drag in progress.
349     pub(crate) fn load_sync_channel(&mut self, path: &Path, keep_own_view: bool) -> Result<(), Box<dyn std::error::Error>> {
350         let content = fs::read_to_string(path)?;
351         let mut proj: Project = serde_json::from_str(&content)?;
352         proj.sanitize_node_names();
353         proj.migrate_format();
354         crate::app::merge_template_defs(&mut proj.root, &self.node_templates);
355         self.fs_root = proj.root;
356         // Another window's edits, or the bundled file: not this one's to undo.
357         self.structure_base = None;
358         self.migrate_meta_settings_node();
359         if !keep_own_view {
360             // Before the default view, whose camera-node rule has the last
361             // word on the square aspect and the pivot marker.
362             if let Some(d) = &proj.view_state.display {
363                 self.apply_display_settings(d);
364             }
365             self.apply_pane_state_from_project(&proj.view_state);
366         }
367         self.set_active_camera(proj.view_state.active_camera);
368         self.pan_x = proj.view_state.pan.0;
369         self.pan_y = proj.view_state.pan.1;
370         self.pan_velocity_x = 0.0;
371         self.pan_velocity_y = 0.0;
372         self.last_frame_pan_x = self.pan_x;
373         self.last_frame_pan_y = self.pan_y;
374         self.current_path = proj.view_state.current_path;
375         if !keep_own_view {
376             self.apply_default_view_from_project(proj.view_state.default_view);
377         }
378 
379         // The graph is given the loaded nodes BEFORE the selection: it
380         // resolves an index to a node's id against the nodes it holds, so a
381         // selection set first was dropped whenever the load brought a node
382         // the graph had not seen — a detached window reloading the node the
383         // main one had just added and selected showed nothing.
384         self.sync_grid_settings();
385         self.sync_nodes();
386         let sel = proj.view_state.selected_node;
387         self.graph_mut().set_selected_node(sel);
388         if sel.is_some() {
389             self.focused_widget = Some(CONTENT_IDX);
390         } else {
391             self.focused_widget = None;
392         }
393         self.drag_widget = None;
394         if !keep_own_view {
395             // A pane-edge drag is layout, which a sync reload no
396             // longer touches — the drag in hand stays in hand.
397             self.app_drag = None;
398         }
399         self.last_click = None;
400 
401         self.sync_grid_settings();
402         self.sync_nodes();
403         self.sync_cursor_and_selection_from_loaded();
404         self.sync_cursor_and_selection();
405         self.sync_parameters_pane();
406 
407         self.rebuild_scene_geometry();
408         self.rebuild_positions();
409         self.apply_layout();
410         self.update_panel_bounds();
411         self.loaded_project_path = None;
412         self.mark_saved();
413         self.update_window_title();
414         Ok(())
415     }
416 
417     pub(crate) fn load_from_file(&mut self, path: &Path) -> Result<(), Box<dyn std::error::Error>> {
418         if path.file_name().map_or(false, |n| n == "default_project.json") {
419             return self.load_sync_channel(path, false);
420         }
421 
422         let (state_file_path, project_dir) = if path.is_dir() {
423             (path.join("state.json"), path.to_path_buf())
424         } else {
425             if path.file_name().map_or(false, |name| name == "state.json") {
426                 (path.to_path_buf(), path.parent().unwrap_or(path).to_path_buf())
427             } else {
428                 (path.to_path_buf(), path.parent().unwrap_or(path).to_path_buf())
429             }
430         };
431 
432         let content = fs::read_to_string(&state_file_path)?;
433         let mut proj: Project = serde_json::from_str(&content)?;
434         proj.sanitize_node_names();
435         proj.migrate_format();
436         crate::app::merge_template_defs(&mut proj.root, &self.node_templates);
437         self.fs_root = proj.root;
438         self.edit_history.clear();
439         self.structure_base = None;
440         self.migrate_meta_settings_node();
441         // As in the default-project branch.
442         if let Some(d) = &proj.view_state.display {
443             self.apply_display_settings(d);
444         }
445         self.apply_pane_state_from_project(&proj.view_state);
446         self.set_active_camera(proj.view_state.active_camera);
447         self.pan_x = proj.view_state.pan.0;
448         self.pan_y = proj.view_state.pan.1;
449         self.pan_velocity_x = 0.0;
450         self.pan_velocity_y = 0.0;
451         self.last_frame_pan_x = self.pan_x;
452         self.last_frame_pan_y = self.pan_y;
453         self.current_path = proj.view_state.current_path;
454         self.apply_default_view_from_project(proj.view_state.default_view);
455 
456         // The graph is given the loaded nodes BEFORE the selection: it
457         // resolves an index to a node's id against the nodes it holds, so a
458         // selection set first was dropped whenever the load brought a node
459         // the graph had not seen — a detached window reloading the node the
460         // main one had just added and selected showed nothing.
461         self.sync_grid_settings();
462         self.sync_nodes();
463         let sel = proj.view_state.selected_node;
464         self.graph_mut().set_selected_node(sel);
465         if sel.is_some() {
466             self.focused_widget = Some(CONTENT_IDX);
467         } else {
468             self.focused_widget = None;
469         }
470         self.drag_widget = None;
471         self.app_drag = None;
472         self.last_click = None;
473 
474         self.sync_grid_settings();
475         self.sync_nodes();
476         self.sync_cursor_and_selection_from_loaded();
477         self.sync_cursor_and_selection();
478         self.add_recent_file(project_dir.clone());
479         self.sync_parameters_pane();
480 
481         self.rebuild_scene_geometry();
482         self.report_invalid_params();
483         self.rebuild_positions();
484         self.apply_layout();
485         self.update_panel_bounds();
486         self.loaded_project_path = Some(project_dir);
487         self.mark_saved();
488         self.update_window_title();
489         Ok(())
490     }
491 
492     /// Say on the status line when a loaded project holds values that do not
493     /// fit their parameter's kind — kept verbatim, read as they always were,
494     /// but worth knowing about (`ParamSlot::Invalid`). Only over the plain
495     /// success message: a node error is the more urgent line.
496     pub(crate) fn report_invalid_params(&mut self) {
497         let bad = crate::app::invalid_params(&self.fs_root);
498         let Some((at, name, why)) = bad.first() else { return };
499         if !self.last_status_text.starts_with("Geometry updated") {
500             return;
501         }
502         let more = if bad.len() > 1 { format!(" (and {} more)", bad.len() - 1) } else { String::new() };
503         self.update_status_text(&format!("A value does not fit its parameter — {at} {name}: {why}{more}"));
504     }
505 
506     /// The Main node's "Set As Default": remember the currently-loaded project
507     /// as what the app opens at startup. A pointer in state.kdl — NOT a rewrite
508     /// of the bundled default_project.json, which is versioned and doubles as
509     /// the detached-window sync channel. A scratch (never-saved) project has no
510     /// path to point at, so the click is a no-op with a note.
511     pub(crate) fn set_current_as_default(&mut self) {
512         match &self.loaded_project_path {
513             Some(path) => {
514                 self.default_project_setting = Some(path.to_string_lossy().into_owned());
515                 self.save_settings();
516             }
517             None => {
518                 eprintln!("Set As Default: no project file is loaded — save the project first");
519             }
520         }
521     }
522 
523     /// Open the configured startup project, if any. Main window only — the
524     /// detached windows must keep seeding from default_project.json, which is
525     /// their sync channel with the parent.
526     ///
527     /// A default that cannot be opened — gone, or unreadable — falls back to
528     /// what `State::new` already loaded and **keeps the setting**, saying so
529     /// on the status line. It used to DELETE the pointer on a path that did
530     /// not exist, reasoning that a dead default should not fail on every
531     /// launch. The trade is the wrong way round: failing costs one line of
532     /// stderr and a fallback that already works, while forgetting costs the
533     /// user a setting they cannot get back without reopening the project and
534     /// pressing the button again. And a path is absent for reasons that pass
535     /// — a cloud-synced folder the daemon has not mounted yet, an external
536     /// drive, a machine that autostarts the app before the network is up —
537     /// so the one launch that raced the filesystem took the setting with it.
538     pub(crate) fn load_default_project_setting(&mut self) {
539         let Some(configured) = self.default_project_setting.clone() else { return };
540         let path = std::path::PathBuf::from(&configured);
541         if !path.exists() {
542             eprintln!("Default project is not there right now: {configured}");
543             self.update_status_text(&format!("Default project not found: {configured}"));
544             return;
545         }
546         if let Err(e) = self.load_from_file(&path) {
547             eprintln!("Failed to load default project {configured}: {e:?}");
548             self.update_status_text(&format!("Default project would not open: {configured}"));
549         }
550     }
551 
552     pub(crate) fn new_project(&mut self) {
553         self.fs_root = FsNode {
554             id: "root".to_string(),
555             name: "root".to_string(),
556             node_type: "node".to_string(),
557             children: vec![],
558             params: vec![],
559             geometry_visible: true,
560             bypassed: false,
561             position: (0.0, 0.0),
562             inputs: 0,
563             outputs: 0,
564         };
565         self.edit_history.clear();
566         self.structure_base = None;
567         self.migrate_meta_settings_node();
568         self.set_active_camera("Default Camera");
569         self.pan_x = 0.0;
570         self.pan_y = 0.0;
571         self.pan_velocity_x = 0.0;
572         self.pan_velocity_y = 0.0;
573         self.last_frame_pan_x = 0.0;
574         self.last_frame_pan_y = 0.0;
575         self.current_path.clear();
576         self.grid_cursor_col = 0;
577         self.grid_cursor_row = 0;
578 
579         self.focused_widget = None;
580         self.drag_widget = None;
581         self.app_drag = None;
582         self.last_click = None;
583 
584         self.sync_grid_settings();
585         self.sync_nodes();
586         self.sync_cursor_and_selection();
587         self.sync_parameters_pane();
588         self.rebuild_scene_geometry();
589         self.rebuild_positions();
590         self.apply_layout();
591         self.update_panel_bounds();
592         self.loaded_project_path = None;
593         self.mark_saved();
594         self.update_window_title();
595     }
596 
597     /// Pull an older project's settings off its root `meta` node, then take
598     /// the node out.
599     ///
600     /// Until 2026-09-23 session-wide display settings lived as params on four
601     /// utility subnets (`main`, `view`, `guides`, `render`) under a permanent
602     /// root `meta` node, and that node tree was the STORE OF RECORD:
603     /// `ensure_menubar_subnets` rebuilt it from live state and
604     /// `apply_settings_from_menubar_subnets` copied it back over live state
605     /// after every parameter edit anywhere. A display preference was
606     /// therefore a piece of project data, carried in the file, reset by
607     /// opening someone else's scene — and editable only by selecting the
608     /// right node in the right utility subnet.
609     ///
610     /// They are settings, and they are set from the command palette now: the
611     /// live fields are the values, `DesignSettings` persists them to
612     /// `state.kdl`, and the dialog's Settings half edits them (see
613     /// `SETTINGS` in `src/dialog.rs`). This runs once per load to carry a
614     /// saved project's values across rather than dropping them on the floor —
615     /// a user who set a grid colour two years ago keeps it.
616     ///
617     /// Pane visibility is NOT read here: it is genuinely project state and
618     /// has moved to `ProjectViewState::visible_panes`, which `load_from_file`
619     /// applies. An old save's `view` subnet is read for it, though, or every
620     /// project saved before the move would open with the default layout.
621     pub(crate) fn migrate_meta_settings_node(&mut self) {
622         let meta_idx = self.fs_root.children.iter().position(|c| {
623             matches!(c.node_type.as_str(), "session" | "meta") || c.name == "Session"
624         });
625         // Pre-Session saves parked the four subnets FLAT at the root, with no
626         // container above them, so the sweep below runs whether or not a meta
627         // node was found — an early return on the container alone left that
628         // whole generation of file carrying four dead nodes forever.
629         let mut subnets: Vec<FsNode> = match meta_idx {
630             Some(i) => self.fs_root.children.remove(i).children,
631             None => Vec::new(),
632         };
633         let mut i = 0;
634         while i < self.fs_root.children.len() {
635             let c = &self.fs_root.children[i];
636             if c.node_type == "utility"
637                 && matches!(c.name.as_str(), "main" | "view" | "guides" | "render")
638             {
639                 subnets.push(self.fs_root.children.remove(i));
640             } else {
641                 i += 1;
642             }
643         }
644         // Anything else that was living under the meta node is the user's,
645         // not ours: adding a non-geometry node in there was allowed, so a
646         // migration that quietly ate one would be eating their work. Re-home
647         // it at the root, where the level it was in used to be — or, for
648         // what belongs inside a geometry node (a subnet), in the root's
649         // first one.
650         let mut i = 0;
651         while i < subnets.len() {
652             if matches!(subnets[i].name.as_str(), "main" | "view" | "guides" | "render") {
653                 i += 1;
654             } else if crate::context::placement(&subnets[i].node_type) == crate::context::Placement::Geometry {
655                 let mut node = subnets.remove(i);
656                 let home = crate::context::geometry_home(&mut self.fs_root);
657                 let level = &mut self.fs_root.children[home];
658                 node.position = crate::context::free_cell(level, node.position);
659                 level.children.push(node);
660             } else {
661                 let mut node = subnets.remove(i);
662                 let (nx, ny) = self.find_empty_cell(node.position.0, node.position.1, None);
663                 node.position = (nx, ny);
664                 self.fs_root.children.push(node);
665             }
666         }
667         if subnets.is_empty() {
668             return;
669         }
670         let params = |name: &str| -> Vec<ParamDef> {
671             subnets
672                 .iter()
673                 .find(|c| c.name.eq_ignore_ascii_case(name))
674                 .map(|n| n.params.clone())
675                 .unwrap_or_default()
676         };
677         let as_bool = |p: &ParamDef| p.text().parse::<bool>().ok();
678         let as_f32 = |p: &ParamDef| p.text().parse::<f32>().ok();
679 
680         for p in params("guides").iter().chain(params("main").iter()) {
681             match p.name.as_str() {
682                 "Show Grid Guide" | "Show Grid" => {
683                     if let Some(v) = as_bool(p) { self.viewport_mut().show_grid = v; }
684                 }
685                 // The reference cube guide was removed on 2026-09-25; an old
686                 // save's value for it has nowhere to go and is dropped.
687                 "Show Reference Cube" | "Cube" => {}
688                 "Show Origin Axes" | "Origin" => {
689                     if let Some(v) = as_bool(p) { self.viewport_mut().show_origin = v; }
690                 }
691                 "Grid Thickness" => if let Some(v) = as_f32(p) { self.grid_thickness = v / 1000.0; },
692                 "Origin Guide Size" => if let Some(v) = as_f32(p) { self.origin_size = v / 10.0; },
693                 "Camera Pivot Size" => if let Some(v) = as_f32(p) { self.camera_pivot_size = v / 10.0; },
694                 "Grid Color" => if let Some(c) = hex_to_color(p.text()) { self.viewport_mut().grid_color = c; },
695                 "Background Color" => if let Some(c) = hex_to_color(p.text()) { self.viewport_mut().bg_color = c; },
696                 "Point Marker Size" => if let Some(v) = as_f32(p) { self.point_marker_size = v / 1000.0; },
697                 "Point Marker Color" => if let Some(c) = hex_to_color(p.text()) { self.point_marker_color = c; },
698                 "World Unit" => if let Some(u) = cce_ui::units::Unit::parse(p.text()) { self.world_unit = u; },
699                 "Circular Pane" => if let Some(v) = as_bool(p) { self.circular_network_pane = v; },
700                 "Ray Traced Preview" => if let Some(v) = as_bool(p) { self.viewport_mut().rt_mode = v; },
701                 _ => {}
702             }
703         }
704         for p in params("render") {
705             match p.name.as_str() {
706                 "Show Wireframe" => if let Some(v) = as_bool(&p) { self.wireframe = v; },
707                 "Wire Single Color" => if let Some(v) = as_bool(&p) { self.wire_single_color = v; },
708                 // Its alpha was the wire opacity until that was a setting.
709                 "Wire Color" => if let Some([r, g, b, a]) = hex_to_rgba(p.text()) {
710                     self.wire_color = [r, g, b];
711                     self.wire_opacity = a;
712                 },
713                 "Wire Thickness" => if let Some(v) = as_f32(&p) { self.wire_width = v.clamp(1.0, 8.0); },
714                 "Opacity" => if let Some(v) = as_f32(&p) { self.geo_opacity = v.clamp(0.0, 1.0); },
715                 // Render Points, Point Size and Point Color were the Show
716                 // Points display, retired as a double of Show Point Markers.
717                 _ => {}
718             }
719         }
720         // The pane layout an old save carried on its `view` subnet, in the
721         // shape `apply_pane_state_from_project` now reads.
722         let view = params("view");
723         if !view.is_empty() && !(self.is_detached_network || self.detached_pane.is_some()) {
724             for (name, get, action) in Self::PANE_FLAGS {
725                 let Some(p) = view
726                     .iter()
727                     .find(|p| p.kind() == crate::app::ParamKind::Toggle && p.name == format!("Show {} Pane", capitalize(name)))
728                 else {
729                     continue;
730                 };
731                 if let Some(desired) = as_bool(p) {
732                     if get(self) != desired {
733                         self.execute_menu_action(action);
734                     }
735                 }
736             }
737         }
738         // The values are live state now, so they belong in state.kdl — this
739         // is the one write that makes the migration stick.
740         self.save_settings();
741     }
742 }