From f204042bf2195c607721b63715b90438704a9e18 Mon Sep 17 00:00:00 2001 From: HEOJUNFO <100739943+HEOJUNFO@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:49:51 +0900 Subject: [PATCH] =?UTF-8?q?Preferences=20=E2=80=BA=20Sketch:=20auto-projec?= =?UTF-8?q?tion=20holds=20across=20designs=20(#680)=20(#959)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Session::replace_design keeps auto_project when file.new, doc.open and doc.recover swap in another design, and the UI keeps it across tabs, adopt and the last-tab close. Regression tests in engine (sketch_project) and ui-egui (documents). --- crates/engine/src/cmd/file.rs | 8 ++--- crates/engine/src/cmd/sketch_project_tests.rs | 24 +++++++++++++ crates/engine/src/lib.rs | 8 +++++ crates/ui-egui/src/documents.rs | 34 +++++++++++++++++-- 4 files changed, 68 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 3b77ceda..007e0cd1 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -51,7 +51,7 @@ pub static COMMANDS: &[CommandSpec] = &[ fn new_doc(s: &mut Session, p: &Value) -> Result { let name = str_(p, "name").unwrap_or("Untitled"); - *s = Session::new(Document::new(name)); + s.replace_design(Session::new(Document::new(name))); Ok(json!({"name": name})) } @@ -130,7 +130,7 @@ fn open(s: &mut Session, p: &Value) -> Result { let mut fresh = Session::new(Document::new(&stem)); let r = if solidcraft_io::is_step_path(path) { add_step(&mut fresh, path, None)? } else { add_mesh(&mut fresh, path, None)? }; fresh.undo.clear(); - *s = fresh; + s.replace_design(fresh); let errors = s.model.results.iter().filter(|r| r.error.is_some()).count(); return Ok(json!({"path": path, "features": s.doc.features.len(), "errors": errors, "bodies": r["bodies"], "warnings": r["warnings"]})); } @@ -138,7 +138,7 @@ fn open(s: &mut Session, p: &Value) -> Result { if let Some(stem) = default_name_for(&doc.name, path) { doc.name = stem; } - *s = Session::new(doc); + s.replace_design(Session::new(doc)); s.path = Some(path.to_string()); let errors = s.model.results.iter().filter(|r| r.error.is_some()).count(); Ok(json!({"path": path, "features": s.doc.features.len(), "errors": errors, "name": s.doc.name})) @@ -421,7 +421,7 @@ fn recover(s: &mut Session, p: &Value) -> Result { if let Some(stem) = entry.path.as_deref().and_then(|path| default_name_for(&doc.name, path)) { doc.name = stem; } - *s = Session::new(doc); + s.replace_design(Session::new(doc)); s.path = entry.path.clone(); s.mark_unsaved(); if bool_(p, "discard").unwrap_or(false) { diff --git a/crates/engine/src/cmd/sketch_project_tests.rs b/crates/engine/src/cmd/sketch_project_tests.rs index f56dc583..d9855856 100644 --- a/crates/engine/src/cmd/sketch_project_tests.rs +++ b/crates/engine/src/cmd/sketch_project_tests.rs @@ -148,6 +148,30 @@ fn auto_project_can_be_turned_off() { assert!(r["projected"].is_null()); } +/// #680: auto-projection is a preference, not part of a design: turning it off holds for the +/// next design started or opened in the session (`doc.recover` replaces the session the same way). +#[test] +fn auto_project_off_holds_for_designs_started_and_opened() { + let dir = std::env::temp_dir().join(format!("solidcraft-auto-project-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("box.solidcraft"); + let mut s = Session::default(); + box_part(&mut s); + run(&mut s, "file.save_as", json!({"path": file.to_string_lossy()})); + run(&mut s, "sketch.auto_project", json!({"value": false})); + run(&mut s, "file.new", json!({})); + assert!(!s.auto_project, "file.new"); + run(&mut s, "doc.open", json!({"path": file.to_string_lossy()})); + assert!(!s.auto_project, "doc.open"); + let r = run(&mut s, "sketch.create", json!({"plane": {"face": [20, 15, 20]}})); + assert!(r["projected"].is_null(), "{r}"); + run(&mut s, "sketch.auto_project", json!({"value": true})); + run(&mut s, "file.new", json!({})); + assert!(s.auto_project, "turned back on, it stays on"); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn project_edges_bodies_sketches_and_origin() { let mut s = Session::default(); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 6f295766..79d741df 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -328,6 +328,14 @@ impl Session { } } + /// Become `fresh` (another design opened, recovered or started), keeping the settings that + /// are the user's rather than the design's: `auto_project` is a preference (#680). + pub fn replace_design(&mut self, fresh: Session) { + let auto_project = self.auto_project; + *self = fresh; + self.auto_project = auto_project; + } + /// The design has changes not in its file: in the document, or in what is hidden. pub fn is_dirty(&self) -> bool { (!Arc::ptr_eq(&self.doc, &self.saved) && *self.doc != *self.saved) || self.visibility != self.saved_visibility diff --git a/crates/ui-egui/src/documents.rs b/crates/ui-egui/src/documents.rs index 7d909719..8aea4744 100644 --- a/crates/ui-egui/src/documents.rs +++ b/crates/ui-egui/src/documents.rs @@ -101,6 +101,8 @@ fn swap_in(app: &mut SolveApp, slot: DocSlot) -> DocSlot { view: take_view(app), }; put_view(app, slot.view); + // Preferences the session holds go with the user, not the tab (#680). + app.session.auto_project = out.session.auto_project; // Views cache by revision: the incoming design must not look unchanged. app.session.invalidate_view(out_rev); app.ui.tab = if app.session.active_sketch.is_some() { "SKETCH".into() } else { "SOLID".into() }; @@ -153,7 +155,7 @@ pub fn adopt(app: &mut SolveApp, session: Session) { app.tool = None; app.menu.props = None; crate::sketch_attachment_ui::clear(app); - app.session = session; + app.session.replace_design(session); app.sketch_choices = crate::sketch_families::Choices::default(); app.session.invalidate_view(rev); app.cam = Camera::default(); @@ -201,7 +203,7 @@ pub fn close(app: &mut SolveApp, i: usize, force: bool) { let rev = app.session.revision; crate::dialogs::cancel(app); app.tool = None; - app.session = Session::default(); + app.session.replace_design(Session::default()); app.session.revision = rev + 1; app.cam = Camera::default(); app.fit_view(); @@ -683,6 +685,34 @@ mod tests { let _ = std::fs::remove_file(&file); } + /// #680: "Project face edges into new sketches" is a preference: it holds in new tabs, in + /// the tab switched to, in a design adopted over a blank one and after the last tab closes, + /// and it is what the preferences file keeps. + #[test] + fn auto_project_follows_the_user_across_tabs() { + let mut a = app(); + a.run("solid.box", json!({"length": 10, "width": 10, "height": 10})).unwrap(); + a.run("sketch.auto_project", json!({"value": false})).unwrap(); + new_design(&mut a); + assert_eq!(a.docs.count(), 2); + assert!(!a.session.auto_project, "a new tab"); + a.run("solid.box", json!({"length": 5, "width": 5, "height": 5})).unwrap(); + a.run("sketch.auto_project", json!({"value": true})).unwrap(); + switch(&mut a, 0); + assert!(a.session.auto_project, "the tab switched to"); + a.run("sketch.auto_project", json!({"value": false})).unwrap(); + switch(&mut a, 1); + assert!(!a.session.auto_project); + let prefs: Value = serde_json::from_str(&a.prefs()).unwrap(); + assert_eq!(prefs["auto_project"], false); + close(&mut a, 1, true); + close(&mut a, 0, true); + assert!(active_is_blank(&a) && !a.session.auto_project, "after the last tab closes"); + adopt(&mut a, Session::default()); + assert_eq!(a.docs.count(), 1); + assert!(!a.session.auto_project, "a design adopted over a blank one"); + } + /// A design built elsewhere replaces a blank one and gets a tab otherwise. #[test] fn adopt_reuses_a_blank_design() {