diff --git a/Cargo.lock b/Cargo.lock
index cc262a188..25ff2fcf2 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -46,6 +46,7 @@ dependencies = [
"open",
"printpdf",
"rayon",
+ "resvg",
"rfd",
"rust-embed",
"rustc-hash 2.1.3",
diff --git a/Cargo.toml b/Cargo.toml
index c654e58d5..81bedef67 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -39,6 +39,10 @@ acadrust = { version = "0.4", features = ["serde"] }
dwg-thumbnailer = { path = "crates/dwg-thumbnailer" }
flate2 = "1"
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] }
+# Rasterises assets/logo.svg into the window icon at startup, so the taskbar
+# icon comes from the same file every other build target does. Already in the
+# tree behind iced's svg feature; named here only to use it directly.
+resvg = { version = "0.45", default-features = false }
i18n-embed = { version = "0.16", features = ["fluent-system"] }
i18n-embed-fl = "0.10"
rust-embed = "8"
diff --git a/assets/logo.svg b/assets/logo.svg
index aa1d04098..e6bdb30b5 100644
--- a/assets/logo.svg
+++ b/assets/logo.svg
@@ -4,7 +4,7 @@
version="1.1"
id="svg8"
sodipodi:docname="logo.svg"
- inkscape:version="1.4.3 (0d15f75042, 2025-12-25)"
+ inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
@@ -20,9 +20,9 @@
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#505050"
- inkscape:zoom="15.020833"
- inkscape:cx="23.966713"
- inkscape:cy="24"
+ inkscape:zoom="14.27082"
+ inkscape:cx="10.370813"
+ inkscape:cy="24.630681"
inkscape:window-width="1920"
inkscape:window-height="939"
inkscape:window-x="0"
@@ -37,18 +37,114 @@
fill="#B03020"
id="rect1" />
-
-
-
-
+
+
+
+
-
-
-
+
+
+
-
-
-
-
-
+
+
+
+
+
diff --git a/src/app/helpers.rs b/src/app/helpers.rs
index 94301642f..c273b5230 100644
--- a/src/app/helpers.rs
+++ b/src/app/helpers.rs
@@ -481,54 +481,33 @@ pub(super) fn title_case_word(value: &str) -> String {
// ── Window icon ────────────────────────────────────────────────────────────
-/// Builds a 32×32 RGBA icon: red background with OCS drawn in white pixels.
+/// The window icon, rasterised from the same file every other build target
+/// draws its icon from.
+///
+/// This used to redraw the mark stroke by stroke in code, with the background
+/// colour written out as a literal. The taskbar therefore kept showing whatever
+/// the logo used to be, however many times the logo itself was redrawn — the
+/// one icon in the application that did not come from `assets/logo.svg`.
+///
+/// Returns 32×32 RGBA. A logo that cannot be rendered leaves the window with
+/// the platform default rather than something invented here, which would put
+/// this back where it started.
#[cfg(not(target_arch = "wasm32"))]
-pub(super) fn build_window_icon() -> Vec {
- const W: usize = 32;
- const SZ: usize = W * W * 4;
+pub(super) fn build_window_icon() -> Option> {
+ const W: u32 = 32;
+ static LOGO: &[u8] = include_bytes!("../../assets/logo.svg");
- let bg = [176u8, 48, 32, 255];
- let fg = [255u8, 255, 255, 255];
-
- let mut px = vec![0u8; SZ];
- for i in 0..W * W {
- px[i * 4..i * 4 + 4].copy_from_slice(&bg);
- }
-
- fn stroke(px: &mut Vec, ax: i32, ay: i32, bx: i32, by: i32, fg: [u8; 4]) {
- let steps = ((bx - ax).abs().max((by - ay).abs()) * 3).max(1);
- for s in 0..=steps {
- let t = s as f32 / steps as f32;
- let cx = ax as f32 + (bx - ax) as f32 * t;
- let cy = ay as f32 + (by - ay) as f32 * t;
- for dy in -1i32..=1 {
- for dx in -1i32..=1 {
- let ix = cx.round() as i32 + dx;
- let iy = cy.round() as i32 + dy;
- if ix >= 0 && ix < W as i32 && iy >= 0 && iy < W as i32 {
- let idx = (iy as usize * W + ix as usize) * 4;
- px[idx..idx + 4].copy_from_slice(&fg);
- }
- }
- }
- }
- }
-
- // O
- stroke(&mut px, 3, 6, 9, 6, fg);
- stroke(&mut px, 3, 25, 9, 25, fg);
- stroke(&mut px, 3, 6, 3, 25, fg);
- stroke(&mut px, 9, 6, 9, 25, fg);
- // C
- stroke(&mut px, 12, 6, 18, 6, fg);
- stroke(&mut px, 12, 25, 18, 25, fg);
- stroke(&mut px, 12, 6, 12, 25, fg);
- // S
- stroke(&mut px, 21, 6, 27, 6, fg);
- stroke(&mut px, 21, 6, 21, 15, fg);
- stroke(&mut px, 21, 15, 27, 15, fg);
- stroke(&mut px, 27, 15, 27, 25, fg);
- stroke(&mut px, 21, 25, 27, 25, fg);
-
- px
+ let tree = resvg::usvg::Tree::from_data(LOGO, &resvg::usvg::Options::default()).ok()?;
+ let mut pixmap = resvg::tiny_skia::Pixmap::new(W, W)?;
+ let size = tree.size();
+ // Fit the artwork to the square without distorting it, whatever aspect the
+ // logo happens to have.
+ let scale = (W as f32 / size.width()).min(W as f32 / size.height());
+ let transform = resvg::tiny_skia::Transform::from_translate(
+ (W as f32 - size.width() * scale) / 2.0,
+ (W as f32 - size.height() * scale) / 2.0,
+ )
+ .pre_scale(scale, scale);
+ resvg::render(&tree, transform, &mut pixmap.as_mut());
+ Some(pixmap.take())
}
diff --git a/src/app/mod.rs b/src/app/mod.rs
index 20a923ab1..acf0f920b 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -3405,7 +3405,8 @@ impl OpenCADStudio {
let state = Self::new();
let (id, open_task) = window::open(window::Settings {
maximized: true,
- icon: window::icon::from_rgba(build_window_icon(), 32, 32).ok(),
+ icon: build_window_icon()
+ .and_then(|rgba| window::icon::from_rgba(rgba, 32, 32).ok()),
exit_on_close_request: false,
// A Wayland compositor has no StartupWMClass to go on: it resolves a
// window's dock icon by matching the window's app_id against the
diff --git a/src/io/file_association.rs b/src/io/file_association.rs
index 6b4eeba68..9a2d111fa 100644
--- a/src/io/file_association.rs
+++ b/src/io/file_association.rs
@@ -514,6 +514,14 @@ mod linux_impl {
StartupWMClass=OpenCADStudio\n"
);
+ // The icon is checked first and on its own. It and the .desktop entry
+ // change for different reasons — the entry only when this text does,
+ // the icon whenever the logo is redrawn — so gating the icon on the
+ // entry meant a new logo never reached the theme after the entry had
+ // been written once. `install_icon` compares bytes itself, so this
+ // costs a read when nothing has changed.
+ install_icon()?;
+
// Nothing changed since last launch → skip the write and the (slow)
// desktop-database refresh.
if std::fs::read_to_string(&desktop_path).ok().as_deref() == Some(contents.as_str()) {
@@ -523,8 +531,6 @@ mod linux_impl {
std::fs::create_dir_all(&apps).map_err(|e| e.to_string())?;
std::fs::write(&desktop_path, &contents).map_err(|e| e.to_string())?;
- install_icon()?;
-
// Best-effort: refresh the MIME→app cache. Absent on minimal systems.
let _ = std::process::Command::new("update-desktop-database")
.arg(&apps)