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 }