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

src/project.rs (31.8K)

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