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 }