graphic design tool
git clone https://git.lucas.co/cce-designer.git
feat: a GPU setting picks the integrated or discrete card
The renderer's device was chosen only by CCE_VK_DEVICE, which meant
launching from a shell to use the discrete GPU. The dialog's GPU row
(integrated | discrete) persists as gpu in state.kdl, and main sets it as
CCE_VK_DEVICE before the engine creates the renderer, so it takes effect
on the next launch; the status line says whether the running process is
on the chosen card.
An explicit CCE_VK_DEVICE still wins. integrated sets nothing, since any
explicit request lifts the session's Intel ICD pin and wakes the dGPU to
enumerate it. Thumbnail and export exit first, so previews never wake it.
DesignSettings::default() now carries the integrated default; the derived
one left gpu empty whenever state.kdl was missing or unreadable.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md | 17 ++++++++++++++
src/app.rs | 74 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
src/dialog.rs | 32 +++++++++++++++++++++++---
src/main.rs | 39 +++++++++++++++++++++++++++++++
4 files changed, 158 insertions(+), 4 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 912a8b7..a053cfa 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -337,6 +337,23 @@ autostart that beat the network — so the one launch that raced the filesystem
took the setting with it, silently. (Found exactly that way: a default under
`~/Dropbox` that stopped opening, with the key simply gone from state.kdl.)
+**`gpu`** in state.kdl (`integrated` | `discrete`, the dialog's **GPU**
+row) picks the device the window's renderer asks Vulkan for. It is read ONCE,
+by `app::apply_gpu_preference` in `main` just before `engine::run`, and set
+as `CCE_VK_DEVICE` — the variable cce-ui's device selection already reads,
+which also steers `gpu.rs`'s compute device — so a change takes effect on the
+next launch, and the row's status line says whether the running process is
+on it (`gpu_at_launch`). Three rules, all in `gpu_env_for`: an explicit
+`CCE_VK_DEVICE` in the environment wins (a per-run override); `integrated`
+sets NOTHING, because cce-ui treats any explicit request as licence to lift
+the session's `VK_DRIVER_FILES` pin to the Intel ICD, which loads the NVIDIA
+driver and wakes the dGPU just to enumerate it; and the thumbnail and export
+modes exit before the call, so cce-files' preview cache never wakes it
+either. Top-level in `DesignSettings`, beside `default_project`, not in the
+render block — that block rides the project file, and which GPU a machine
+has is not a property of a scene. Verified under the session's pin: unset
+opens the Iris Xe, `discrete` the RTX 4080.
+
`DesignSettings` (viewport/graph display state the app rewrites itself:
colors, grid sizes, show flags) persists to `state.kdl` — deliberately NOT
`config.kdl`, which is the user-authored toolkit-config override slot that
diff --git a/src/app.rs b/src/app.rs
index 8f8631c..95185c2 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -1423,7 +1423,7 @@ pub const MAX_PITCH_X: f32 = 625.0;
pub const MIN_PITCH_Y: f32 = 22.5;
pub const MAX_PITCH_Y: f32 = 375.0;
-#[derive(Serialize, Deserialize, Clone, Debug, Default)]
+#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct DesignSettings {
#[serde(default)]
pub viewport: ViewportSettings,
@@ -1437,6 +1437,63 @@ pub struct DesignSettings {
/// "make this the default" must not rewrite it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_project: Option<String>,
+ /// Which GPU the window's renderer asks Vulkan for: one of
+ /// [`GPU_CHOICES`]. Read ONCE, at launch, by [`apply_gpu_preference`],
+ /// because the device is chosen when the renderer is created and a
+ /// renderer is not rebuilt on a whim — so a change takes effect on the
+ /// next start. Here rather than in the render block because that block
+ /// rides the project file, and which GPU a machine has is not a property
+ /// of a scene.
+ #[serde(default = "default_gpu")]
+ pub gpu: String,
+}
+
+impl Default for DesignSettings {
+ fn default() -> Self {
+ Self {
+ viewport: ViewportSettings::default(),
+ render: RenderSettings::default(),
+ default_project: None,
+ gpu: default_gpu(),
+ }
+ }
+}
+
+/// The GPU setting's options. "integrated" is cce-ui's own default
+/// (`CCE_VK_DEVICE` unset); "discrete" asks for the dedicated card.
+pub const GPU_CHOICES: &[&str] = &["integrated", "discrete"];
+
+fn default_gpu() -> String {
+ GPU_CHOICES[0].to_string()
+}
+
+/// What `CCE_VK_DEVICE` should be set to for a saved GPU preference, given
+/// what the environment already says — `None` to leave it alone.
+///
+/// An explicit `CCE_VK_DEVICE` at launch wins: it is a per-run override,
+/// and a setting that silently undid it would make it useless for trying
+/// the other card once. "integrated" sets NOTHING rather than
+/// "integrated": cce-ui treats any explicit request as licence to lift a
+/// session-wide ICD pin (`VK_DRIVER_FILES`), which loads every vendor's
+/// driver and can wake a sleeping discrete GPU just to enumerate it — the
+/// opposite of what asking for the integrated one means.
+pub(crate) fn gpu_env_for(pref: &str, existing: Option<&str>) -> Option<&'static str> {
+ if existing.is_some_and(|v| !v.is_empty()) {
+ return None;
+ }
+ (pref == "discrete").then_some("discrete")
+}
+
+/// Put the saved GPU preference into the environment, where cce-ui's
+/// renderer — and the compute device `gpu.rs` opens — read it. Called from
+/// `main` before the engine starts, while the process has one thread; the
+/// detached windows inherit it, since the main window spawns them.
+pub(crate) fn apply_gpu_preference() {
+ let pref = DesignSettings::load().gpu;
+ let existing = std::env::var("CCE_VK_DEVICE").ok();
+ if let Some(v) = gpu_env_for(&pref, existing.as_deref()) {
+ std::env::set_var("CCE_VK_DEVICE", v);
+ }
}
fn float_array_to_hex(rgb: &[f32; 3]) -> String {
@@ -2059,6 +2116,13 @@ pub struct State {
/// mirrored live so "Set As Default" can rewrite it and `save_settings` —
/// which reconstructs DesignSettings from live state — can carry it.
pub default_project_setting: Option<String>,
+ /// The GPU setting (`DesignSettings::gpu`), mirrored live like the
+ /// startup project so `save_settings` carries it. Takes effect at the
+ /// next launch; `gpu_at_launch` is what this process is running on.
+ pub gpu_preference: String,
+ /// `CCE_VK_DEVICE` as the renderer read it — "integrated" when unset —
+ /// so the setting row can say whether a change is still pending.
+ pub gpu_at_launch: String,
pub last_saved_root_json: String,
/// The pane layout as of the last save — [`State::pane_layout_json`] —
/// so a dragged plate edge, a collapse or a re-dock dirties the title
@@ -2393,6 +2457,7 @@ impl State {
viewport: display.viewport,
render: display.render,
default_project: self.default_project_setting.clone(),
+ gpu: self.gpu_preference.clone(),
};
settings.save();
self.last_design_mod_time = {
@@ -5645,6 +5710,12 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
floating_spreadsheet_inset_right: 0.0,
loaded_project_path: None,
default_project_setting: settings.default_project.clone(),
+ gpu_preference: settings.gpu.clone(),
+ gpu_at_launch: std::env::var("CCE_VK_DEVICE")
+ .ok()
+ .filter(|v| !v.is_empty())
+ .map(|v| v.to_lowercase())
+ .unwrap_or_else(default_gpu),
last_saved_root_json: serde_json::to_string(&fs_root).unwrap_or_default(),
last_saved_layout_json: String::new(),
recent_files,
@@ -9312,6 +9383,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
self.last_design_mod_time = Some(mod_time);
let settings = DesignSettings::load();
self.default_project_setting = settings.default_project.clone();
+ self.gpu_preference = settings.gpu.clone();
self.square_viewport = settings.viewport.square;
self.grid_thickness = settings.viewport.grid_thickness;
self.viewport_mut().show_grid = settings.viewport.show_grid_enabled;
diff --git a/src/dialog.rs b/src/dialog.rs
index 9babc4a..16e8a5f 100644
--- a/src/dialog.rs
+++ b/src/dialog.rs
@@ -1298,6 +1298,9 @@ impl Setting {
pub const SETTINGS: &[Setting] = &[
Setting::field("Background Color", "bg_color", Ctl::Color),
Setting::field("World Unit", "world_unit", Ctl::Choice(&["mm", "cm", "m", "in"])),
+ // Takes effect at the next launch — the renderer's device is chosen
+ // once, when it is created (`app::apply_gpu_preference`).
+ Setting::field("GPU", "gpu", Ctl::Choice(crate::app::GPU_CHOICES)),
Setting::field("Geometry Opacity", "geo_opacity", Ctl::Slider { min: 0.0, max: 1.0, dec: 2 }),
// The colour applies only in single-colour mode (off, the wires carry
// the geometry's vertex colours) — so a colour edit turns that mode on,
@@ -1784,6 +1787,7 @@ impl State {
fn settings_field_text(&self, key: &str) -> String {
match key {
"world_unit" => self.world_unit.suffix().to_string(),
+ "gpu" => self.gpu_preference.clone(),
_ => String::new(),
}
}
@@ -1847,14 +1851,19 @@ impl State {
_ => {}
}
}
- Ctl::Choice(_) => {
- if key == "world_unit" {
+ Ctl::Choice(options) => match key {
+ "world_unit" => {
if let Some(u) = cce_ui::units::Unit::parse(value) {
self.world_unit = u;
self.viewport_dirty = true;
}
}
- }
+ "gpu" => {
+ let Some(v) = options.iter().find(|o| o.eq_ignore_ascii_case(value)) else { return };
+ self.gpu_preference = v.to_string();
+ }
+ _ => {}
+ },
}
}
@@ -1910,6 +1919,23 @@ impl State {
// The params pane may be showing one of these very nodes.
self.sync_parameters_pane();
self.refresh_dialog_controls();
+ // After the pass above, whose own status line would bury it.
+ if s.owner == Owner::Field("gpu") {
+ self.announce_gpu_setting();
+ }
+ }
+
+ /// The renderer's device is fixed for the life of the process, so the
+ /// GPU row says plainly whether this one is on it — a row that changed
+ /// and a picture that did not would read as a setting that does nothing.
+ fn announce_gpu_setting(&mut self) {
+ let (want, have) = (&self.gpu_preference, &self.gpu_at_launch);
+ let note = if want == have {
+ format!("GPU: {want} — in use now.")
+ } else {
+ format!("GPU: {want} — takes effect when cce-designer restarts (running on {have}).")
+ };
+ self.update_status_text(¬e);
}
/// Lay the dialog out over the window.
diff --git a/src/main.rs b/src/main.rs
index e72d2a5..8cbe6af 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -117,6 +117,11 @@ fn main() {
}
}
+ // The GPU setting, as CCE_VK_DEVICE for the renderer the engine is about
+ // to create. Only here: the thumbnail and export modes above exit first,
+ // so cce-files' preview cache never wakes a discrete GPU.
+ app::apply_gpu_preference();
+
// Everything windowed runs on the cce-ui engine (application.rs holds the
// Application impl; --detached-network is read there).
cce_ui::engine::run::<app::State>();
@@ -1676,6 +1681,40 @@ mod tests {
context_menu::hide();
}
+ /// The GPU setting: a choice row of the dialog, persisted in state.kdl
+ /// (absent = integrated), NOT carried by a project's display block, and
+ /// turned into `CCE_VK_DEVICE` at launch only when it asks for the
+ /// discrete card and the environment has not already said.
+ #[test]
+ fn the_gpu_setting_picks_the_renderers_device_at_launch() {
+ use crate::app::gpu_env_for;
+ let mut state = State::new(false);
+ state.gpu_preference = "integrated".into();
+ state.gpu_at_launch = "integrated".into();
+ assert_eq!(state.settings_row_value("GPU"), "integrated");
+ state.apply_setting("GPU", "discrete");
+ assert_eq!(state.gpu_preference, "discrete");
+ assert!(state.last_status_text.contains("restart"), "{}", state.last_status_text);
+ let kdl = fs::read_to_string(DesignSettings::file_path()).expect("saved");
+ assert_eq!(DesignSettings::from_kdl_str(&kdl).gpu, "discrete", "{kdl}");
+ state.apply_setting("GPU", "Integrated");
+ assert_eq!(state.gpu_preference, "integrated");
+ assert!(state.last_status_text.contains("in use"), "{}", state.last_status_text);
+ state.apply_setting("GPU", "quantum");
+ assert_eq!(state.gpu_preference, "integrated", "an unknown option is refused");
+
+ // A file from before the setting existed: integrated.
+ assert_eq!(DesignSettings::from_kdl_str("viewport {\n}\n").gpu, "integrated");
+ // A project does not carry it.
+ let json = serde_json::to_value(state.display_settings()).unwrap();
+ assert!(!json.to_string().contains("\"gpu\""), "{json}");
+
+ assert_eq!(gpu_env_for("discrete", None), Some("discrete"));
+ assert_eq!(gpu_env_for("discrete", Some("")), Some("discrete"));
+ assert_eq!(gpu_env_for("integrated", None), None, "integrated sets nothing, keeping any ICD pin");
+ assert_eq!(gpu_env_for("discrete", Some("intel")), None, "an explicit CCE_VK_DEVICE wins");
+ }
+
/// The wires' opacity is a setting of its own, apart from the polygons':
/// the viewport menu's Wire Opacity slider moves `wire_opacity` and
/// leaves `geo_opacity` alone (and the other way round), the dialog has