From ce0788389bdce5913800d060ed4b67a282124596 Mon Sep 17 00:00:00 2001 From: Matus Mastena Date: Sat, 12 Sep 2026 16:20:16 +0200 Subject: [PATCH 01/11] Add a Nix flake for the dev shell and package build The devShell carries the toolchain plus the X11, Wayland, Vulkan, fontconfig and freetype libraries winit and wgpu dlopen at run time, and the trunk/wasm-bindgen pair the web build needs. LD_LIBRARY_PATH is set there too, since a dev binary runs straight out of ./target and never gets the wrapper the package build applies. The four git dependencies each need an entry in cargoLock.outputHashes. They are lib.fakeHash for now; the first `nix build` prints the real values, and they have to be refreshed whenever a rev in Cargo.toml moves. --- .envrc | 1 + .gitignore | 3 ++ flake.nix | 151 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 155 insertions(+) create mode 100644 .envrc create mode 100644 flake.nix diff --git a/.envrc b/.envrc new file mode 100644 index 000000000..3550a30f2 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore index 80e292024..46903fb6d 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,6 @@ __pycache__/ # Claude Code local preview/dev-server config .claude/launch.json + +# nix-direnv +.direnv/ diff --git a/flake.nix b/flake.nix new file mode 100644 index 000000000..fd09ceec8 --- /dev/null +++ b/flake.nix @@ -0,0 +1,151 @@ +{ + description = "Open CAD Studio - 2D drafting and 3D modeling in Rust"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + # rust-overlay ships upstream Rust binaries rather than building them, so + # following nixpkgs costs it no binary cache. + rust-overlay = { + url = "github:oxalica/rust-overlay"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + }; + + outputs = { self, nixpkgs, rust-overlay }: + let + systems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ]; + forAllSystems = f: + nixpkgs.lib.genAttrs systems (system: f (import nixpkgs { + inherit system; + overlays = [ rust-overlay.overlays.default ]; + })); + in + { + packages = forAllSystems (pkgs: + let + inherit (pkgs) lib stdenv; + + toolchain = pkgs.rust-bin.stable.latest.default.override { + targets = [ "wasm32-unknown-unknown" ]; + }; + + # Build with the overlay's toolchain rather than the nixpkgs default, + # so the package and the devShell compile with the same rustc. + rustPlatform = pkgs.makeRustPlatform { + cargo = toolchain; + rustc = toolchain; + }; + + # winit/wgpu dlopen these at run time, so they must also be on the + # wrapped binary's LD_LIBRARY_PATH, not just at link time. + runtimeLibs = with pkgs; [ + libGL + libxkbcommon + vulkan-loader + wayland + xorg.libX11 + xorg.libXcursor + xorg.libXi + xorg.libXrandr + ]; + in + rec { + default = opencadstudio; + + opencadstudio = rustPlatform.buildRustPackage { + pname = "opencadstudio"; + version = (lib.importTOML ./Cargo.toml).package.version; + src = self; + + cargoLock = { + lockFile = ./Cargo.lock; + # One entry per distinct git source in Cargo.lock. Replace each + # lib.fakeHash with the sha256 that `nix build` prints on the + # first attempt; they must be refreshed whenever the matching + # rev in Cargo.toml moves. + outputHashes = { + "acadrust-0.5.5" = lib.fakeHash; + "cadkernel-0.1.0" = lib.fakeHash; + "iced-0.15.0-dev" = lib.fakeHash; + "iced_aw-0.14.1" = lib.fakeHash; + }; + }; + + nativeBuildInputs = with pkgs; [ + pkg-config + makeWrapper + ]; + + buildInputs = with pkgs; [ + fontconfig + freetype + ] ++ runtimeLibs + ++ lib.optionals stdenv.hostPlatform.isDarwin [ darwin.apple_sdk.frameworks.AppKit ]; + + # build.rs asks git for a revision and falls back to "unknown" + # when the repo or the binary is absent, which it is in the + # sandbox. Nothing to do but let it take the fallback. + doCheck = false; + + postInstall = lib.optionalString stdenv.hostPlatform.isLinux '' + wrapProgram $out/bin/OpenCADStudio \ + --prefix LD_LIBRARY_PATH : ${lib.makeLibraryPath runtimeLibs} + ''; + + meta = { + description = "Open-source 2D drafting and 3D modeling, built with Rust"; + homepage = "https://www.opencadstudio.com"; + license = lib.licenses.gpl3Only; + mainProgram = "OpenCADStudio"; + platforms = systems; + }; + }; + }); + + devShells = forAllSystems (pkgs: + let + toolchain = pkgs.rust-bin.stable.latest.default.override { + extensions = [ "rust-src" "rust-analyzer" "clippy" "rustfmt" ]; + targets = [ "wasm32-unknown-unknown" ]; + }; + runtimeLibs = with pkgs; [ + libGL + libxkbcommon + vulkan-loader + wayland + xorg.libX11 + xorg.libXcursor + xorg.libXi + xorg.libXrandr + ]; + in + { + default = pkgs.mkShell { + packages = with pkgs; [ + toolchain + pkg-config + fontconfig + freetype + git + # Web build: `trunk build` per Trunk.toml. wasm-bindgen-cli must + # match the wasm-bindgen version Cargo.lock resolves, the same + # constraint .github/workflows/pages.yml:83 reads out of the lock. + trunk + wasm-bindgen-cli + # Rasterises assets/logo.svg for packaging, as release.yml does. + librsvg + ] ++ runtimeLibs; + + # A dev binary is run straight out of ./target, so it never gets + # the wrapper the package build applies. + LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath runtimeLibs; + }; + }); + + checks = forAllSystems (pkgs: { + default = self.packages.${pkgs.stdenv.hostPlatform.system}.opencadstudio; + }); + + formatter = forAllSystems (pkgs: pkgs.nixpkgs-fmt); + }; +} From a570f4ac16ef280f4177f165b5e277836c902faf Mon Sep 17 00:00:00 2001 From: Matus Mastena Date: Sat, 12 Sep 2026 16:20:17 +0200 Subject: [PATCH 02/11] Turn object snap on by default The running snap set was already seeded with endpoint, midpoint, centre, node, quadrant, intersection and nearest, but the master suppress bit was set, so none of them did anything until the user found the status-bar pill. Snapping is what makes a drawing close rather than nearly close; it should be live from the first click. Only fresh configs change. A stored settings.json keeps whatever osmode it already holds, and a legacy file carrying its own $OSMODE still overrides on open. --- src/app/settings.rs | 6 ++++-- src/snap.rs | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/app/settings.rs b/src/app/settings.rs index 30c262533..fb9b3ed61 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -646,8 +646,10 @@ impl Default for UserSettings { literal_spaces: false, command_history_height: crate::ui::command_line::HISTORY_HEIGHT_DEFAULT, // Snapper::default(): END|MID|CEN|NODE|QUAD|INT|NEA (575), master - // off (suppress bit 16384). - osmode: 575 | OSMODE_SUPPRESS, + // on. Object snap is a drafting aid users expect to be live from + // the first click; the suppress bit (16384) is left for the user + // to set via the status-bar pill or OSNAP. + osmode: 575, texteditmode: false, quick_dimension_snap_priority: 0, dimension_continue_mode: 1, diff --git a/src/snap.rs b/src/snap.rs index 78e215382..28f8e0631 100644 --- a/src/snap.rs +++ b/src/snap.rs @@ -278,7 +278,7 @@ impl Default for Snapper { // would catch nearly every cursor position, masking the discrete // vertex/edge/face snaps. Opt in via the 3D Object Snap tab. Self { - snap_enabled: false, + snap_enabled: true, enabled, snap3d_enabled: true, enabled3d, From 648b261fd1b7e81cbc37de29dcade76977ae665f Mon Sep 17 00:00:00 2001 From: Matus Mastena Date: Sat, 12 Sep 2026 16:20:17 +0200 Subject: [PATCH 03/11] Add the Fusion Black and Fusion White themes Fusion Black pairs near-black chrome with a paper-white canvas, so the drawing is the only bright thing on screen. That breaks the assumption in theme_canvas_background that the canvas follows the chrome, hence the explicit lookup: falling through to the palette would paint model space black and undo the point of the theme. Both keep a blue accent rather than going fully greyscale. Selection highlight defaults to the theme primary, and a white primary would be invisible against a white canvas. These are Theme::Custom values and so are absent from iced's Theme::ALL. all_themes() is the list to enumerate from now; the picker in the options dialog uses it, and parse_theme_name resolves both by name. --- src/app/config.rs | 24 +++++- src/ui/style/fusion_theme.rs | 140 +++++++++++++++++++++++++++++++++++ src/ui/style/mod.rs | 1 + src/ui/window/options.rs | 2 +- 4 files changed, 163 insertions(+), 4 deletions(-) create mode 100644 src/ui/style/fusion_theme.rs diff --git a/src/app/config.rs b/src/app/config.rs index 6d1c8f0eb..f8d2f26d3 100644 --- a/src/app/config.rs +++ b/src/app/config.rs @@ -176,11 +176,20 @@ impl UiThemePalette { } } -pub fn builtin_theme(name: &str) -> Option { +/// Every theme the user may pick: iced's built-ins followed by the Fusion +/// pair. The Fusion themes are `Theme::Custom`, so they are not in +/// `iced::Theme::ALL`; anything enumerating themes for display or for test +/// coverage must use this instead, or they silently vanish from the list. +pub fn all_themes() -> Vec { iced::Theme::ALL .iter() - .find(|theme| theme.to_string() == name) .cloned() + .chain(crate::ui::style::fusion_theme::fusion_themes()) + .collect() +} + +pub fn builtin_theme(name: &str) -> Option { + all_themes().into_iter().find(|theme| theme.to_string() == name) } fn color_to_rgb(color: iced::Color) -> [u8; 3] { @@ -376,7 +385,14 @@ pub fn theme_canvas_background(theme: &iced::Theme) -> [u8; 3] { iced::Theme::Nightfly => [1, 22, 39], iced::Theme::Oxocarbon => [22, 22, 22], iced::Theme::Ferra => [43, 41, 46], - _ => color_to_rgb(theme.palette().background.base.color), + // The Fusion pair deliberately breaks the "canvas follows chrome" + // rule: black chrome is paired with a white canvas, so falling + // through to the palette below would paint model space black and + // undo the whole point of the theme. + other => match crate::ui::style::fusion_theme::fusion_canvas(other) { + Some(rgb) => rgb, + None => color_to_rgb(other.palette().background.base.color), + }, } } @@ -407,6 +423,8 @@ pub fn parse_theme_name(s: &str) -> Option { "NIGHTFLY" => Some(iced::Theme::Nightfly), "OXOCARBON" => Some(iced::Theme::Oxocarbon), "FERRA" => Some(iced::Theme::Ferra), + "FUSIONBLACK" => Some(crate::ui::style::fusion_theme::fusion_black()), + "FUSIONWHITE" => Some(crate::ui::style::fusion_theme::fusion_white()), _ => None, } } diff --git a/src/ui/style/fusion_theme.rs b/src/ui/style/fusion_theme.rs new file mode 100644 index 000000000..19a0aed73 --- /dev/null +++ b/src/ui/style/fusion_theme.rs @@ -0,0 +1,140 @@ +//! The Fusion theme pair: black chrome around a white canvas, and its +//! inverse. +//! +//! "Mixed black/white" is about the split, not about draining every colour +//! out of the UI. Ribbon, docks, status bar and command line sit at +//! near-black while model space stays paper-white, so the drawing is the +//! only bright thing on screen. One accent survives that split: selection +//! highlight defaults to the theme primary +//! (`ModelSpaceThemeConfig::selection_highlight_color` = 0), and a white +//! primary would be invisible against a white canvas. The accent is +//! therefore a saturated blue that reads against both halves. +//! +//! These are `iced::Theme::Custom` values, so they are absent from +//! `iced::Theme::ALL`. Anything enumerating themes must go through +//! [`crate::app::config::all_themes`] instead. + +use iced::theme::palette::Seed; +use iced::{Color, Theme}; + +/// Display name of the black-chrome theme. Persisted verbatim in +/// `settings.json` as `theme.name`, so changing it orphans a user's choice. +pub const FUSION_BLACK: &str = "Fusion Black"; + +/// Display name of the light-chrome theme. +pub const FUSION_WHITE: &str = "Fusion White"; + +/// Model-space canvas for [`FUSION_BLACK`] — paper white against the +/// near-black chrome. Deliberately not pure #FFFFFF: a fraction below white +/// keeps the paper-space sheet distinguishable from the model canvas. +pub const FUSION_BLACK_CANVAS: [u8; 3] = [250, 250, 250]; + +/// Model-space canvas for [`FUSION_WHITE`]. +pub const FUSION_WHITE_CANVAS: [u8; 3] = [255, 255, 255]; + +fn rgb(hex: u32) -> Color { + Color::from_rgb8( + ((hex >> 16) & 0xFF) as u8, + ((hex >> 8) & 0xFF) as u8, + (hex & 0xFF) as u8, + ) +} + +/// Near-black chrome, white text, blue accent. +pub fn fusion_black() -> Theme { + Theme::custom( + FUSION_BLACK, + Seed { + background: rgb(0x1A1A1A), + text: rgb(0xF2F2F2), + primary: rgb(0x0696D7), + success: rgb(0x4CAF50), + warning: rgb(0xFFB300), + danger: rgb(0xE53935), + }, + ) +} + +/// The inverse: light chrome, near-black text. The accent darkens so it +/// keeps its contrast against a light surface. +pub fn fusion_white() -> Theme { + Theme::custom( + FUSION_WHITE, + Seed { + background: rgb(0xFAFAFA), + text: rgb(0x1A1A1A), + primary: rgb(0x0277BD), + success: rgb(0x2E7D32), + warning: rgb(0xE65100), + danger: rgb(0xC62828), + }, + ) +} + +/// Both Fusion themes, in the order the theme picker should show them. +pub fn fusion_themes() -> Vec { + vec![fusion_black(), fusion_white()] +} + +/// The canvas colour a Fusion theme asks for, or `None` for any other theme. +/// Keyed by name because `Theme::Custom` carries no discriminant to match on. +pub fn fusion_canvas(theme: &Theme) -> Option<[u8; 3]> { + match theme.to_string().as_str() { + FUSION_BLACK => Some(FUSION_BLACK_CANVAS), + FUSION_WHITE => Some(FUSION_WHITE_CANVAS), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ui::style::common::wcag_contrast; + + /// The same bar `theme_accessibility_tests` holds the built-in themes to. + /// These themes are hand-written rather than generated, so nothing else + /// would catch a palette typo that drops text below AA. + #[test] + fn fusion_themes_meet_wcag_aa_on_every_surface() { + for theme in fusion_themes() { + let p = theme.palette(); + for (name, pair) in [ + ("base", p.background.base), + ("weak", p.background.weak), + ("strong", p.background.strong), + ("weakest", p.background.weakest), + ] { + let contrast = wcag_contrast(pair.text, pair.color); + assert!( + contrast >= 4.5, + "{theme} {name} surface text is {contrast:.2}:1, below AA" + ); + } + } + } + + /// The accent exists so selection stays visible on the white canvas. If + /// it ever drifts toward white this catches it. + #[test] + fn fusion_accent_is_visible_on_the_canvas() { + for (theme, canvas) in [ + (fusion_black(), FUSION_BLACK_CANVAS), + (fusion_white(), FUSION_WHITE_CANVAS), + ] { + let canvas = Color::from_rgb8(canvas[0], canvas[1], canvas[2]); + let contrast = wcag_contrast(theme.palette().primary.base.color, canvas); + assert!( + contrast >= 3.0, + "{theme} accent is {contrast:.2}:1 on its canvas, too faint \ + to show a selection" + ); + } + } + + #[test] + fn fusion_canvas_is_keyed_by_name() { + assert_eq!(fusion_canvas(&fusion_black()), Some(FUSION_BLACK_CANVAS)); + assert_eq!(fusion_canvas(&fusion_white()), Some(FUSION_WHITE_CANVAS)); + assert_eq!(fusion_canvas(&Theme::Dark), None); + } +} diff --git a/src/ui/style/mod.rs b/src/ui/style/mod.rs index 2a86accac..f98b8d751 100644 --- a/src/ui/style/mod.rs +++ b/src/ui/style/mod.rs @@ -11,6 +11,7 @@ pub mod anno_object_scale; pub mod style_manager; pub mod common; pub mod form; +pub mod fusion_theme; #[cfg(test)] mod common_tests { diff --git a/src/ui/window/options.rs b/src/ui/window/options.rs index aa490a9f7..a6664e345 100644 --- a/src/ui/window/options.rs +++ b/src/ui/window/options.rs @@ -179,7 +179,7 @@ pub fn view_window<'a>( .copied() .find(|candidate| *candidate == default_save_format); - let theme_options = Theme::ALL + let theme_options = crate::app::config::all_themes() .iter() .map(ToString::to_string) .chain(std::iter::once("Custom".to_string())) From bfb786d56e4481952ebed313a1d3236b35ac9b46 Mon Sep 17 00:00:00 2001 From: Matus Mastena Date: Sat, 12 Sep 2026 16:20:17 +0200 Subject: [PATCH 04/11] Add UCS FACE and UCS OBJECT Draw a profile, extrude it, then sketch on a face of the result: the operations were all there, but nothing turned a solid's face into the active drawing plane, so the second half of that loop was unreachable. UCS FACE takes a surface pick, finds the planar face under it and builds the plane from the face normal. X comes from the Arbitrary Axis Algorithm, so the same face always yields the same X axis and a sketch drawn on it does not spin when the UCS is rebuilt. UCS OBJECT adopts a planar entity's own stored axes instead of re-deriving X, so a sketch started from a circle lines up with how that circle is stored. It reuses the profile extraction EXTRUDE already runs, which means anything extrudable can also be sketched on. Bare UCS FACE starts an interactive pick that resolves through the same inline handler, so the clicked and typed paths cannot diverge. --- src/app/commands/layerprops.rs | 125 +++++++++++++++++++++++++++++++++ src/app/helpers.rs | 34 +++++++++ src/command.rs | 56 +++++++++++++++ 3 files changed, 215 insertions(+) diff --git a/src/app/commands/layerprops.rs b/src/app/commands/layerprops.rs index 0ae2e63b5..1efe23024 100644 --- a/src/app/commands/layerprops.rs +++ b/src/app/commands/layerprops.rs @@ -314,6 +314,131 @@ impl OpenCADStudio { self.command_line .push_output(crate::t!("UCS aligned to the current view.").as_ref()); } + // UCS FACE — adopt a solid's planar face + // as the drawing plane. This is the move Fusion calls + // "create sketch on a face": pick the face, draw flat on + // it, extrude. The pick comes from a surface pick, so it + // already lies on the face and serves as the origin. + "FACE" | "F" => { + let target = parts + .get(2) + .and_then(|token| { + u64::from_str_radix(token.trim().trim_start_matches("0x"), 16).ok() + }) + .map(acadrust::Handle::new) + .zip( + parts + .get(3) + .and_then(|value| super::super::helpers::parse_coord(value)) + .map(|(point, _)| point), + ); + match target { + // Bare `UCS FACE` starts the pick. The click comes + // back through this same arm with a handle and a + // surface point, so both entry points share the + // plane construction below. + None => { + let picker = crate::command::UcsFaceCommand; + self.command_line.push_info(&picker.prompt()); + self.tabs[i].active_cmd = Some(Box::new(picker)); + } + Some((handle, pick)) => { + let built = self.tabs[i] + .scene + .solid_models + .get(&handle) + .and_then(|body| { + use crate::scene::model::solid_model; + let face = + solid_model::nearest_planar_face(body, pick.to_array())?; + solid_model::planar_face_normal(body, face) + }) + .and_then(|normal| { + super::super::helpers::ucs_from_normal( + pick, + glam::DVec3::from_array(normal), + ) + }); + match built { + Some(ucs) => { + self.tabs[i].active_ucs = Some(ucs); + active_changed = true; + self.command_line.push_output( + crate::t!("UCS aligned to the selected face.").as_ref(), + ); + } + None => self.command_line.push_error( + crate::t!("UCS FACE: no planar face at that point.") + .as_ref(), + ), + } + } + } + } + // UCS OBJECT — adopt the plane of a planar + // entity. Reuses the profile extraction EXTRUDE already + // runs, so anything extrudable can also be sketched on. + "OBJECT" | "OB" => { + let target = parts + .get(2) + .and_then(|token| { + u64::from_str_radix(token.trim().trim_start_matches("0x"), 16).ok() + }) + .map(acadrust::Handle::new); + match target { + None => self + .command_line + .push_error(crate::t!("Usage: UCS OBJECT ").as_ref()), + Some(handle) => { + let built = self.tabs[i] + .scene + .document + .get_entity(handle) + .and_then(crate::scene::model::sweep_model::extrusion_profile_of) + .and_then(|(profile, _)| { + // The profile plane already carries the + // entity's own in-plane axes, so adopt + // them rather than re-deriving X from + // the normal: a sketch started on a + // circle then lines up with how that + // circle is stored, not with an + // arbitrary reference direction. + let plane = profile.plane; + let x = glam::DVec3::from_array(plane.x_axis); + let y = glam::DVec3::from_array(plane.y_axis); + if x.length_squared() < 1e-12 + || y.length_squared() < 1e-12 + { + return None; + } + let mut ucs = Ucs::new("*ACTIVE*"); + ucs.origin = Vector3::new( + plane.origin[0], + plane.origin[1], + plane.origin[2], + ); + ucs.x_axis = + Vector3::new(x.x, x.y, x.z); + ucs.y_axis = + Vector3::new(y.x, y.y, y.z); + Some(ucs) + }); + match built { + Some(ucs) => { + self.tabs[i].active_ucs = Some(ucs); + active_changed = true; + self.command_line.push_output( + crate::t!("UCS aligned to the selected object.") + .as_ref(), + ); + } + None => self.command_line.push_error( + crate::t!("UCS OBJECT: entity defines no plane.").as_ref(), + ), + } + } + } + } "3POINTW" => { let raw = parts.get(2).copied().unwrap_or(""); let points: Vec = raw diff --git a/src/app/helpers.rs b/src/app/helpers.rs index 678ae2144..5e084275e 100644 --- a/src/app/helpers.rs +++ b/src/app/helpers.rs @@ -247,6 +247,40 @@ pub(super) fn ucs_z_axis(ucs: &Ucs) -> glam::DVec3 { UcsXform::from_ucs(ucs).axes().3 } +/// Build a UCS whose Z axis is `normal`, anchored at `origin`. +/// +/// X and Y come from the Arbitrary Axis Algorithm, the same rule DWG uses to +/// turn an extrusion direction into an object coordinate system. Picking the +/// world axis that is *least* parallel to the normal keeps the cross product +/// well conditioned, and makes the result stable: the same face always yields +/// the same X axis, so a sketch drawn on it does not spin when the UCS is +/// rebuilt. +/// +/// Returns `None` when `normal` is degenerate and defines no plane. +pub(super) fn ucs_from_normal(origin: glam::DVec3, normal: glam::DVec3) -> Option { + let z = normal.normalize_or_zero(); + if z.length_squared() < 1e-12 { + return None; + } + // 1/64 is the threshold the DWG spec names for "close enough to the world + // Z axis that using it as the reference would be ill conditioned". + let reference = if z.x.abs() < 1.0 / 64.0 && z.y.abs() < 1.0 / 64.0 { + glam::DVec3::Y + } else { + glam::DVec3::Z + }; + let x = reference.cross(z).normalize_or_zero(); + if x.length_squared() < 1e-12 { + return None; + } + let y = z.cross(x).normalize_or_zero(); + let mut ucs = Ucs::new("*ACTIVE*"); + ucs.origin = acadrust::types::Vector3::new(origin.x, origin.y, origin.z); + ucs.x_axis = acadrust::types::Vector3::new(x.x, x.y, x.z); + ucs.y_axis = acadrust::types::Vector3::new(y.x, y.y, y.z); + Some(ucs) +} + /// Build a UCS with `origin` and axes rotated by `angle_z_rad` around the Z axis. pub(super) fn ucs_rotated_z(origin: glam::DVec3, angle_z: f32) -> Ucs { let cos = angle_z.cos() as f64; diff --git a/src/command.rs b/src/command.rs index 1704fc0ea..fb7138b02 100644 --- a/src/command.rs +++ b/src/command.rs @@ -347,6 +347,62 @@ impl CadCommand for ValuePromptCommand { } } +/// Interactive front-end for `UCS FACE` — Fusion's "create sketch on a face". +/// +/// Picks one face of a solid and hands the resulting plane to the inline +/// `UCS FACE ` handler via [`CmdResult::Dispatch`], so the +/// plane construction lives in exactly one place whether the user clicked a +/// face or typed the arguments. `entity_pick_uses_surface_point` is what +/// makes the click land *on the solid's surface* rather than on the current +/// drawing plane, which is the whole point: the pick coordinate is the face. +pub struct UcsFaceCommand; + +impl CadCommand for UcsFaceCommand { + fn name(&self) -> &'static str { + "UCS" + } + + fn prompt(&self) -> String { + crate::t!("UCS FACE Select a face to draw on:").into_owned() + } + + fn needs_entity_pick(&self) -> bool { + true + } + + fn entity_pick_uses_surface_point(&self) -> bool { + true + } + + fn entity_pick_highlights_hover(&self) -> bool { + true + } + + fn on_entity_pick(&mut self, handle: Handle, pt: DVec3) -> CmdResult { + if handle.is_null() { + return CmdResult::NeedPoint; + } + // Hex handle and comma-separated coordinates are what the inline + // parser reads back. Full `{}` precision, not a rounded format: the + // point has to stay on the face for the planar-face lookup. + CmdResult::Dispatch(format!( + "UCS FACE {:X} {},{},{}", + handle.value(), + pt.x, + pt.y, + pt.z + )) + } + + fn on_point(&mut self, _pt: DVec3) -> CmdResult { + CmdResult::NeedPoint + } + + fn on_enter(&mut self) -> CmdResult { + CmdResult::Cancel + } +} + /// Interactive front-end for RENAME. Prompts for the object type (as clickable /// buttons), then the current name, then the new name, and delegates to the /// inline `RENAME ` handler via [`CmdResult::Dispatch`] — the From 1aa299e20435957403b02867e622a1cbd442e64a Mon Sep 17 00:00:00 2001 From: Matus Mastena Date: Sat, 12 Sep 2026 17:58:57 +0200 Subject: [PATCH 05/11] Add sketch mode CREATESKETCH opens a sketch on the current UCS, or on one of the three origin planes with XY, XZ or YZ. FINISHSKETCH closes it again. Nothing here is new machinery: the plane is a UCS, the square-on view is PLAN, the drafting aids are the snapper. What the pair adds is holding them together and putting them back afterwards, including the snap master state, so a user who works with snapping off gets that back rather than silently keeping it. Squaring the view up is not cosmetic. Drawing on a plane seen nearly edge-on is the main way geometry lands somewhere the user did not mean, because a few pixels of cursor travel cover a long way in world space. A sketch is a mode rather than a document object, so what it produces is ordinary model-space geometry and the file stays a DWG other software can open. Whatever was drawn becomes the Previous selection set, so EXTRUDE then Previous consumes it without making the user reselect a profile they just finished. --- src/app/commands/layerprops.rs | 123 +++++++++++++++++++++++++++++++++ src/app/commands/mod.rs | 5 ++ src/app/document.rs | 28 ++++++++ 3 files changed, 156 insertions(+) diff --git a/src/app/commands/layerprops.rs b/src/app/commands/layerprops.rs index 1efe23024..bb39b3a15 100644 --- a/src/app/commands/layerprops.rs +++ b/src/app/commands/layerprops.rs @@ -631,6 +631,61 @@ impl OpenCADStudio { // PLAN — plan view of a UCS (#326). Shows its options right on // activation instead of flipping the view like a cube click; the // view only changes once an option (or the Current default) runs. + // ── Sketch mode ────────────────────────────────────────────── + // Fusion's sketch loop, assembled from pieces that already exist: + // a UCS for the plane, PLAN for the square-on view, the snapper + // for the drafting aids. A sketch is a mode, not a document + // object, so what it produces is ordinary model-space geometry + // and the file stays a DWG every other program can still open. + cmd if cmd == "CREATESKETCH" || cmd.starts_with("CREATESKETCH ") => { + if self.tabs[i].sketch_session.is_some() { + self.command_line.push_error( + crate::t!("A sketch is already open — FINISHSKETCH closes it.") + .as_ref(), + ); + return Some(Task::none()); + } + let arg = cmd["CREATESKETCH".len()..].trim().to_uppercase(); + // The three origin planes. Without them the first sketch in + // an empty drawing would have no face to land on. + let origin_plane = match arg.as_str() { + "XY" => Some(glam::DVec3::Z), + "XZ" => Some(glam::DVec3::NEG_Y), + "YZ" => Some(glam::DVec3::X), + _ => None, + }; + if let Some(normal) = origin_plane { + self.tabs[i].active_ucs = + super::super::helpers::ucs_from_normal(glam::DVec3::ZERO, normal); + self.commit_active_ucs_change(i, "CREATESKETCH"); + } else if !arg.is_empty() { + self.command_line.push_error( + crate::t!( + "Usage: CREATESKETCH [XY|XZ|YZ]; bare uses the current UCS." + ) + .as_ref(), + ); + return Some(Task::none()); + } + self.open_sketch(i); + } + // BROWSER — toggle the docked outline of planes, sketch and bodies. + "BROWSER" => { + self.show_browser ^= true; + if self.show_browser { + // Open expanded so it is usable straight away; the pin + // button still collapses it. + self.dock_expanded = Some(crate::ui::dock::PanelId::Browser); + self.command_line + .push_output(crate::t!("Browser opened.").as_ref()); + } else { + self.command_line + .push_output(crate::t!("Browser closed.").as_ref()); + } + } + "FINISHSKETCH" => { + self.finish_sketch(i); + } "PLAN" => { use crate::command::KeywordCommand; let c = KeywordCommand::new( @@ -1491,6 +1546,74 @@ impl OpenCADStudio { /// Snap to the plan view of the UCS whose rotation is `r_ucs`, straight — /// no "already there → flip to the opposite face" cube behaviour (#326). + /// Enter sketch mode on the current UCS. + /// + /// Squaring the view up is not cosmetic: drawing on a plane seen nearly + /// edge-on is the main way geometry ends up somewhere the user did not + /// mean, because a few pixels of cursor travel cover a long way in + /// world space. + fn open_sketch(&mut self, i: usize) { + let opened_with: std::collections::HashSet = self.tabs[i] + .scene + .document + .entities() + .map(|entity| entity.common().handle) + .collect(); + self.tabs[i].sketch_count += 1; + let name = format!("Sketch{}", self.tabs[i].sketch_count); + self.tabs[i].sketch_session = Some(crate::app::document::SketchSession { + name: name.clone(), + previous_ucs: self.tabs[i].active_ucs.clone(), + previous_snap_enabled: self.snapper.snap_enabled, + opened_with, + }); + let rotation = self.tabs[i].scene.viewcube_ucs_mat(); + self.plan_snap(i, rotation); + self.snapper.snap_enabled = true; + self.command_line.push_output( + crate::tf!("{} open. Draw on the plane, then FINISHSKETCH.", name).as_ref(), + ); + } + + /// Leave sketch mode, restoring what the sketch borrowed. + /// + /// Whatever was drawn becomes the "Previous" selection set, so the next + /// EXTRUDE can take it with the Previous keyword instead of making the + /// user reselect a profile they just finished drawing. + fn finish_sketch(&mut self, i: usize) { + let Some(session) = self.tabs[i].sketch_session.take() else { + self.command_line + .push_error(crate::t!("No sketch is open.").as_ref()); + return; + }; + let created: Vec = self.tabs[i] + .scene + .document + .entities() + .map(|entity| entity.common().handle) + .filter(|handle| !session.opened_with.contains(handle)) + .collect(); + self.tabs[i].active_ucs = session.previous_ucs; + self.snapper.snap_enabled = session.previous_snap_enabled; + self.commit_active_ucs_change(i, "FINISHSKETCH"); + if created.is_empty() { + self.command_line.push_output( + crate::tf!("{} closed; nothing was drawn.", session.name).as_ref(), + ); + } else { + let count = created.len(); + self.tabs[i].prev_selection = created; + self.command_line.push_output( + crate::tf!( + "{} closed with {} object(s). EXTRUDE then Previous uses them.", + session.name, + count + ) + .as_ref(), + ); + } + } + fn plan_snap(&mut self, i: usize, r_ucs: glam::Mat4) { let eye_dir = r_ucs.transform_vector3( crate::scene::CubeRegion::Face(crate::scene::pipeline::viewcube::FACE_TOP) diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index 7d3d3c611..42a9dd5a3 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -739,6 +739,11 @@ inventory::submit!(crate::command::CommandRegistration { "SELSIM", // Draw a new object of the same type as the selected one. (#239) "ADDSELECTED", + // Open and close a sketch: a drawing plane, a square-on view and + // snapping, held together until the profile is done. + "BROWSER", + "CREATESKETCH", + "FINISHSKETCH", "SHEETSET", "SHORTCUTS", "SSM", diff --git a/src/app/document.rs b/src/app/document.rs index 6b90aaf54..50f80fd09 100644 --- a/src/app/document.rs +++ b/src/app/document.rs @@ -101,6 +101,27 @@ fn default_role_for(component: DynComponent) -> crate::command::DynRole { } } +/// An open sketch — the state CREATESKETCH parks so FINISHSKETCH can put it +/// back. +/// +/// A sketch here is a *mode*, not a document object: the drawing plane, a +/// square-on view, and snapping forced live. The geometry it produces is +/// ordinary model-space entities, which is what keeps the result a normal +/// DWG that other software can open. `opened_with` is the handle set at the +/// moment the sketch opened, so the difference on finish is exactly what the +/// user drew — that difference is what gets offered to EXTRUDE. +pub(super) struct SketchSession { + /// Display name, e.g. `"Sketch1"`. + pub(super) name: String, + /// UCS active before the sketch opened. Restored on finish. + pub(super) previous_ucs: Option, + /// Snap master state before the sketch forced it on, so a user who works + /// with snapping off gets that back rather than silently keeping it. + pub(super) previous_snap_enabled: bool, + /// Entity handles already present when the sketch opened. + pub(super) opened_with: std::collections::HashSet, +} + // ── Per-document tab state ───────────────────────────────────────────────── pub(super) struct DocumentTab { @@ -186,6 +207,11 @@ pub(super) struct DocumentTab { pub(super) active_layer: String, /// Currently active UCS. `None` means WCS (identity transform). pub(super) active_ucs: Option, + /// Open sketch, if CREATESKETCH is in effect. `None` is the normal + /// direct-modelling state. + pub(super) sketch_session: Option, + /// Sketches opened in this tab so far, so each gets a distinct name. + pub(super) sketch_count: u32, /// Custom model-space background color. `None` = default dark grey. pub(super) bg_color: Option<[f32; 4]>, /// Custom paper-space background color. `None` = default off-white grey. @@ -629,6 +655,8 @@ impl DocumentTab { history: HistoryState::default(), active_layer: "0".to_string(), active_ucs: None, + sketch_session: None, + sketch_count: 0, bg_color: None, paper_bg_color: None, refedit_session: None, From d1c31062839e0b4d51e13dbdfb3505432bf55daa Mon Sep 17 00:00:00 2001 From: Matus Mastena Date: Sat, 12 Sep 2026 17:58:58 +0200 Subject: [PATCH 06/11] Add the Browser panel A docked outline of the drawing: the three origin planes, the open sketch, and every solid labelled with the operation that made it, recovered through primitive_property_operation. Rows run ordinary commands, so clicking a plane starts a sketch on it and clicking a body selects it. It is a table of contents, not a feature tree. The DWG history records how each solid was built but nothing re-runs it, so an earlier feature cannot be edited to rebuild the ones after it. Presenting the list as editable history would promise exactly that, so the panel states what it is instead. Off by default and opened with BROWSER, leaving the existing dock layout alone for anyone who does not want it. --- src/app/mod.rs | 4 + src/app/update/dialog.rs | 4 + src/app/view/mod.rs | 7 ++ src/ui/dock.rs | 13 ++- src/ui/window/browser.rs | 205 +++++++++++++++++++++++++++++++++++++++ src/ui/window/mod.rs | 1 + 6 files changed, 232 insertions(+), 2 deletions(-) create mode 100644 src/ui/window/browser.rs diff --git a/src/app/mod.rs b/src/app/mod.rs index e2f103d81..9204daedb 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -733,6 +733,9 @@ pub(super) struct OpenCADStudio { pub(crate) show_block_palette: bool, /// Docked External References panel visibility (EXTERNALREFERENCES). pub(crate) show_external_references: bool, + /// Whether the Browser panel is shown. Off until BROWSER opens it, so + /// the default layout is unchanged for existing users. + pub(crate) show_browser: bool, /// General edge-stack dock layout for the side panels. pub(crate) dock: crate::ui::dock::DockState, /// Which panel is currently floated at full height (hovered, or a pinned @@ -3929,6 +3932,7 @@ impl OpenCADStudio { show_properties: true, show_block_palette: false, show_external_references: false, + show_browser: false, block_palette: Default::default(), xref_manager: Default::default(), dock: Default::default(), diff --git a/src/app/update/dialog.rs b/src/app/update/dialog.rs index e7eac4b80..7c20cb776 100644 --- a/src/app/update/dialog.rs +++ b/src/app/update/dialog.rs @@ -602,6 +602,9 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven PanelId::ExternalReferences => { self.show_external_references = false; } + PanelId::Browser => { + self.show_browser = false; + } PanelId::Properties => { self.show_properties = false; self.ribbon.set_properties(false); @@ -704,6 +707,7 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven PanelId::Properties => self.show_properties, PanelId::BlockPalette => self.show_block_palette, PanelId::ExternalReferences => self.show_external_references, + PanelId::Browser => self.show_browser, } } diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 8ca29a3a2..7fefbba3c 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -1820,6 +1820,7 @@ bg={bg_ms:.1}ms n={view_count}" crate::ui::dock::PanelId::Properties => self.show_properties, crate::ui::dock::PanelId::BlockPalette => self.show_block_palette, crate::ui::dock::PanelId::ExternalReferences => self.show_external_references, + crate::ui::dock::PanelId::Browser => self.show_browser, } }; let edge_stack = |side: crate::app::config::DockSide| -> Option> { @@ -2881,6 +2882,12 @@ impl OpenCADStudio { tab.xref_missing, &tab.scene.document, ), + crate::ui::dock::PanelId::Browser => crate::ui::window::browser::view( + &tab.scene.document, + tab.sketch_session.as_ref().map(|session| session.name.as_str()), + width, + auto_collapse, + ), }; let divider = dock_divider(id); match side { diff --git a/src/ui/dock.rs b/src/ui/dock.rs index 475fa0023..68b49c015 100644 --- a/src/ui/dock.rs +++ b/src/ui/dock.rs @@ -42,6 +42,8 @@ pub enum PanelId { Properties, BlockPalette, ExternalReferences, + /// Outline of the drawing's origin planes, open sketch and solid bodies. + Browser, } impl PanelId { @@ -51,6 +53,7 @@ impl PanelId { PanelId::Properties => "Properties", PanelId::BlockPalette => "Block Palette", PanelId::ExternalReferences => "External References", + PanelId::Browser => "Browser", } } @@ -60,6 +63,7 @@ impl PanelId { PanelId::Properties => 250.0, PanelId::BlockPalette => 260.0, PanelId::ExternalReferences => 460.0, + PanelId::Browser => 230.0, } } @@ -117,7 +121,7 @@ impl Default for DockState { fn default() -> Self { Self { left: vec![PanelId::Properties], - right: vec![PanelId::BlockPalette], + right: vec![PanelId::Browser, PanelId::BlockPalette], panels: BTreeMap::new(), } } @@ -143,7 +147,12 @@ impl DockState { /// resize never hit a missing configuration. Also a cheap heal for configs /// written by an older version. pub fn ensure_settings(&mut self) { - for id in [PanelId::Properties, PanelId::BlockPalette, PanelId::ExternalReferences] { + for id in [ + PanelId::Properties, + PanelId::BlockPalette, + PanelId::ExternalReferences, + PanelId::Browser, + ] { self.panels.entry(id).or_insert_with(|| DockPanel::for_id(id)); } } diff --git a/src/ui/window/browser.rs b/src/ui/window/browser.rs new file mode 100644 index 000000000..c14dd4d54 --- /dev/null +++ b/src/ui/window/browser.rs @@ -0,0 +1,205 @@ +//! The Browser: a standing outline of what the drawing is built from. +//! +//! Three sections, each read straight from the document rather than from a +//! parallel model the panel would have to keep in sync: +//! +//! * **Origin planes** — the three world planes, as somewhere to start a +//! sketch when the drawing is still empty and there is no face to pick. +//! * **Sketch** — the open sketch, if there is one. +//! * **Bodies** — every `Solid3D`, labelled with the operation that made it, +//! recovered from the DWG solid history. +//! +//! What this panel deliberately is *not* is a parametric feature tree. The +//! history records how each solid was built, but nothing re-runs it, so +//! editing an earlier feature cannot rebuild the ones after it. Showing the +//! list as if it were editable history would promise exactly that. It is a +//! table of contents, and it is labelled as one. + +use crate::app::Message; +use crate::ui::dock::{DockMsg, PanelId}; +use acadrust::objects::SolidHistoryOperation; +use acadrust::{CadDocument, EntityType, Handle}; +use iced::widget::{button, column, container, mouse_area, row, scrollable, text, tooltip}; +use iced::{Background, Border, Element, Fill, Length, Theme}; + +/// The three world planes, with the argument `CREATESKETCH` takes for each. +const ORIGIN_PLANES: [(&str, &str); 3] = [("XY", "XY"), ("XZ", "XZ"), ("YZ", "YZ")]; + +/// Human name for the operation that produced a solid. `None` when the +/// drawing carries no history for it — a solid imported as plain geometry, +/// or one whose history was stripped on save. +fn operation_label(operation: &SolidHistoryOperation) -> &'static str { + match operation { + SolidHistoryOperation::Box(_) => "Box", + SolidHistoryOperation::Wedge(_) => "Wedge", + SolidHistoryOperation::Sphere(_) => "Sphere", + SolidHistoryOperation::Cone(_) => "Cone", + SolidHistoryOperation::Cylinder(_) => "Cylinder", + SolidHistoryOperation::Torus(_) => "Torus", + SolidHistoryOperation::Pyramid(_) => "Pyramid", + SolidHistoryOperation::Sweep(_) => "Sweep", + SolidHistoryOperation::Loft(_) => "Loft", + SolidHistoryOperation::Extrusion(_) => "Extrude", + SolidHistoryOperation::Revolve(_) => "Revolve", + SolidHistoryOperation::Fillet(_) => "Fillet", + SolidHistoryOperation::Chamfer(_) => "Chamfer", + _ => "Solid", + } +} + +/// Every `Solid3D` in model space, with the label to show for it. +fn bodies(document: &CadDocument) -> Vec<(Handle, String)> { + document + .entities() + .filter(|entity| matches!(entity, EntityType::Solid3D(_))) + .map(|entity| { + let handle = entity.common().handle; + let label = crate::scene::model::solid_history::primitive_property_operation( + document, handle, + ) + .as_ref() + .map_or("Solid", operation_label); + (handle, format!("{label} {:X}", handle.value())) + }) + .collect() +} + +fn section_header(label: String) -> Element<'static, Message> { + container(text(label).size(11)) + .width(Fill) + .padding([3, 6]) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.weak.color)), + ..Default::default() + }) + .into() +} + +/// A clickable row that runs `command`. +fn entry_row(label: String, command: String) -> Element<'static, Message> { + button(text(label).size(12)) + .on_press(Message::Command(command)) + .width(Fill) + .padding([3, 10]) + .style(button::subtle) + .into() +} + +/// A row that states something rather than doing something. +fn note_row(label: String) -> Element<'static, Message> { + container(text(label).size(11).style(crate::ui::style::common::muted_style)) + .width(Fill) + .padding([3, 10]) + .into() +} + +pub fn view<'a>( + document: &'a CadDocument, + open_sketch: Option<&'a str>, + width: f32, + auto_collapse: bool, +) -> Element<'a, Message> { + // ── Dock chrome (title, pin, close) — matches the other panels ──────── + let pin_icon = if auto_collapse { + crate::ui::icons::themed_primary_weak_text(crate::ui::icons::PIN, 12.0) + } else { + crate::ui::icons::themed_secondary(crate::ui::icons::PIN, 12.0) + }; + let pin = button(pin_icon) + .on_press(Message::Dock(DockMsg::AutoCollapseToggle(PanelId::Browser))) + .style(move |theme: &Theme, status| { + let mut style = button::subtle(theme, status); + if auto_collapse { + let palette = theme.palette(); + style.background = Some(Background::Color(palette.primary.weak.color)); + style.text_color = palette.primary.weak.text; + style.border.color = palette.primary.base.color; + style.border.width = 1.0; + } + style + }) + .padding([3, 5]); + let pin = tooltip(pin, text(crate::t!("Auto")).size(10), tooltip::Position::Bottom).gap(4); + + let close = button(crate::ui::icons::themed_secondary(crate::ui::icons::CLOSE, 12.0)) + .on_press(Message::Dock(DockMsg::Close(PanelId::Browser))) + .style(button::subtle) + .padding([3, 5]); + let close = tooltip(close, text(crate::t!("Close")).size(10), tooltip::Position::Bottom).gap(4); + + let title_bar = mouse_area( + container( + row![ + text(crate::t!("Browser")).size(12), + iced::widget::Space::new().width(Fill), + pin, + close, + ] + .spacing(3) + .align_y(iced::Center), + ) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.weak.color)), + ..Default::default() + }) + .width(Fill) + .padding([3, 6]), + ) + .on_press(Message::Dock(DockMsg::DockGrab(PanelId::Browser))) + .interaction(iced::mouse::Interaction::Grab); + + let mut tree = column![].spacing(1); + + tree = tree.push(section_header(crate::t!("Origin planes").into_owned())); + for (label, argument) in ORIGIN_PLANES { + tree = tree.push(entry_row( + label.to_string(), + format!("CREATESKETCH {argument}"), + )); + } + + tree = tree.push(section_header(crate::t!("Sketch").into_owned())); + match open_sketch { + Some(name) => { + tree = tree.push(entry_row(name.to_string(), "FINISHSKETCH".to_string())); + } + None => { + tree = tree.push(note_row(crate::t!("No sketch open").into_owned())); + } + } + + let bodies = bodies(document); + tree = tree.push(section_header(crate::tf!("Bodies ({})", bodies.len()).into_owned())); + if bodies.is_empty() { + tree = tree.push(note_row(crate::t!("No solids yet").into_owned())); + } else { + for (handle, label) in bodies { + // Selecting by handle is what SELECT already takes, so clicking a + // row reuses the ordinary selection path. + tree = tree.push(entry_row(label, format!("SELECT {:X}", handle.value()))); + } + } + + let body = scrollable(container(tree).padding(iced::Padding { + top: 0.0, + right: 8.0, + bottom: 6.0, + left: 0.0, + })) + .width(Fill) + .height(Fill); + + container(column![title_bar, body].spacing(6).padding(6)) + .width(Length::Fixed(width)) + .height(Fill) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.base.color)), + border: Border { + color: theme.palette().background.neutral.color, + width: 1.0, + radius: 0.0.into(), + }, + ..Default::default() + }) + .into() +} diff --git a/src/ui/window/mod.rs b/src/ui/window/mod.rs index ac3352c5c..85c39ac3c 100644 --- a/src/ui/window/mod.rs +++ b/src/ui/window/mod.rs @@ -1,5 +1,6 @@ pub mod about; pub mod block_palette; +pub mod browser; pub mod layout_manager; pub mod layer_state_manager; pub mod drawing_units; From 7e816f992ef53d78b429e0acc92923297f8f4a22 Mon Sep 17 00:00:00 2001 From: Matus Mastena Date: Sat, 12 Sep 2026 17:59:37 +0200 Subject: [PATCH 07/11] Ignore the worktree directory Isolated runs check out into .claude/worktrees/. Leaving it untracked would put a whole second copy of the tree in reach of git add -A. --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 46903fb6d..d46af8231 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,6 @@ __pycache__/ # nix-direnv .direnv/ + +# Worktrees created for isolated runs +.claude/worktrees/ From fe4c337425e493c893430299e8aef5877b1ff751 Mon Sep 17 00:00:00 2001 From: Matus Mastena Date: Sat, 12 Sep 2026 18:26:02 +0200 Subject: [PATCH 08/11] Group the Fusion palette literals and mark the constructors must_use MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Byte-pair separators in the hex colours, and #[must_use] on the four constructors, which return a value and do nothing else — dropping one is always a mistake rather than a choice. Found by clippy::pedantic. Only this file is touched: the same run reported ten thousand warnings across the tree, nearly all numeric cast lints that are load-bearing in geometry code, so fixing them wholesale is a separate decision rather than a drive-by. --- src/ui/style/fusion_theme.rs | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/src/ui/style/fusion_theme.rs b/src/ui/style/fusion_theme.rs index 19a0aed73..ba869c1da 100644 --- a/src/ui/style/fusion_theme.rs +++ b/src/ui/style/fusion_theme.rs @@ -41,43 +41,47 @@ fn rgb(hex: u32) -> Color { } /// Near-black chrome, white text, blue accent. +#[must_use] pub fn fusion_black() -> Theme { Theme::custom( FUSION_BLACK, Seed { - background: rgb(0x1A1A1A), - text: rgb(0xF2F2F2), - primary: rgb(0x0696D7), - success: rgb(0x4CAF50), - warning: rgb(0xFFB300), - danger: rgb(0xE53935), + background: rgb(0x1A_1A1A), + text: rgb(0xF2_F2F2), + primary: rgb(0x06_96D7), + success: rgb(0x4C_AF50), + warning: rgb(0xFF_B300), + danger: rgb(0xE5_3935), }, ) } /// The inverse: light chrome, near-black text. The accent darkens so it /// keeps its contrast against a light surface. +#[must_use] pub fn fusion_white() -> Theme { Theme::custom( FUSION_WHITE, Seed { - background: rgb(0xFAFAFA), - text: rgb(0x1A1A1A), - primary: rgb(0x0277BD), - success: rgb(0x2E7D32), - warning: rgb(0xE65100), - danger: rgb(0xC62828), + background: rgb(0xFA_FAFA), + text: rgb(0x1A_1A1A), + primary: rgb(0x02_77BD), + success: rgb(0x2E_7D32), + warning: rgb(0xE6_5100), + danger: rgb(0xC6_2828), }, ) } /// Both Fusion themes, in the order the theme picker should show them. +#[must_use] pub fn fusion_themes() -> Vec { vec![fusion_black(), fusion_white()] } /// The canvas colour a Fusion theme asks for, or `None` for any other theme. /// Keyed by name because `Theme::Custom` carries no discriminant to match on. +#[must_use] pub fn fusion_canvas(theme: &Theme) -> Option<[u8; 3]> { match theme.to_string().as_str() { FUSION_BLACK => Some(FUSION_BLACK_CANVAS), From 930cdc2df817a091f4e6a0c62e753d2e1af94a5c Mon Sep 17 00:00:00 2001 From: Matus Mastena Date: Sat, 12 Sep 2026 19:47:25 +0200 Subject: [PATCH 09/11] Cover the face-to-plane path with tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UCS FACE had no test at all: it was only known to compile. The plane it builds has two halves and they sit either side of a visibility boundary, so the coverage splits to match. tests/ucs_face_plane.rs takes the public route — extrude a disc, then check the caps come back as distinct planar faces with opposite outward normals, that an off-centre pick lands on the same face as a central one, and that the curved wall is never offered as a plane. The axis construction is pub(crate), so it is unit-tested next to ucs_from_normal instead: Z is the normal it was handed, the axes stay orthonormal, a normal along world Z still yields a plane rather than crossing with itself, the same normal is reproducible, and a zero vector is refused rather than silently producing a degenerate UCS. --- src/app/helpers.rs | 75 ++++++++++++++++++++++++++++++++++++++ tests/ucs_face_plane.rs | 80 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 tests/ucs_face_plane.rs diff --git a/src/app/helpers.rs b/src/app/helpers.rs index 5e084275e..d1b3d3fb7 100644 --- a/src/app/helpers.rs +++ b/src/app/helpers.rs @@ -611,3 +611,78 @@ pub(super) fn build_window_icon() -> Option> { resvg::render(&tree, transform, &mut pixmap.as_mut()); Some(pixmap.take()) } + +#[cfg(test)] +mod ucs_from_normal_tests { + use super::ucs_from_normal; + use glam::DVec3; + + fn axes(ucs: &acadrust::tables::Ucs) -> (DVec3, DVec3, DVec3) { + let x = DVec3::new(ucs.x_axis.x, ucs.x_axis.y, ucs.x_axis.z); + let y = DVec3::new(ucs.y_axis.x, ucs.y_axis.y, ucs.y_axis.z); + (x, y, x.cross(y)) + } + + /// The contract `UCS FACE` depends on: the plane's Z is the face normal it + /// was handed. If this drifts, sketches tilt off the face they were + /// started on. + #[test] + fn z_axis_is_the_supplied_normal() { + for normal in [ + DVec3::Z, + DVec3::NEG_Z, + DVec3::X, + DVec3::Y, + DVec3::new(1.0, 2.0, 3.0).normalize(), + ] { + let ucs = ucs_from_normal(DVec3::ZERO, normal).expect("a normal defines a plane"); + let (_, _, z) = axes(&ucs); + assert!( + (z - normal).length() < 1e-9, + "expected Z {normal:?}, got {z:?}" + ); + } + } + + /// Right-handed and unit length, or every coordinate read off the plane is + /// skewed. + #[test] + fn axes_are_orthonormal() { + for normal in [DVec3::Z, DVec3::new(-2.0, 0.5, 1.0).normalize()] { + let ucs = ucs_from_normal(DVec3::ZERO, normal).unwrap(); + let (x, y, z) = axes(&ucs); + for (name, axis) in [("X", x), ("Y", y), ("Z", z)] { + assert!((axis.length() - 1.0).abs() < 1e-9, "{name} is not unit"); + } + assert!(x.dot(y).abs() < 1e-9, "X and Y are not perpendicular"); + assert!(x.dot(z).abs() < 1e-9, "X and Z are not perpendicular"); + } + } + + /// The 1/64 branch exists so a normal along world Z does not cross-product + /// with itself. Both sides of that branch must still produce a usable + /// plane. + #[test] + fn a_normal_along_world_z_still_yields_a_plane() { + let ucs = ucs_from_normal(DVec3::ZERO, DVec3::Z).expect("world Z is a valid normal"); + let (x, _, _) = axes(&ucs); + assert!(x.length() > 0.5, "X degenerated next to the world Z axis"); + } + + /// The same face must always give the same X axis, or a sketch would spin + /// each time the UCS was rebuilt from it. + #[test] + fn the_same_normal_is_reproducible() { + let normal = DVec3::new(0.3, -0.7, 0.2).normalize(); + let first = axes(&ucs_from_normal(DVec3::ZERO, normal).unwrap()); + let second = axes(&ucs_from_normal(DVec3::ZERO, normal).unwrap()); + assert!((first.0 - second.0).length() < 1e-12); + } + + /// A zero vector defines no plane; it must be refused rather than + /// producing a degenerate UCS that silently misplaces geometry. + #[test] + fn a_degenerate_normal_is_rejected() { + assert!(ucs_from_normal(DVec3::ZERO, DVec3::ZERO).is_none()); + } +} diff --git a/tests/ucs_face_plane.rs b/tests/ucs_face_plane.rs new file mode 100644 index 000000000..57f49f0a3 --- /dev/null +++ b/tests/ucs_face_plane.rs @@ -0,0 +1,80 @@ +//! `UCS FACE` turns a pick on a solid into a drawing plane. The plane is only +//! as good as the face lookup underneath it, so this covers that half: given a +//! body and a point on one of its faces, does the right face come back, and is +//! its outward normal the one a sketch should be built on? +//! +//! The axis construction on top of that normal is unit-tested beside +//! `ucs_from_normal` in `src/app/helpers.rs`, which is `pub(crate)` and so out +//! of reach from here. + +use acadrust::{entities::Circle, types::Vector3, EntityType}; +use OpenCADStudio::scene::model::{presspull_model, solid_model}; + +/// Extrude a disc into a puck 10 units tall, centred on the origin. +fn puck() -> cadkernel::brep::Body { + let entity = EntityType::Circle(Circle::from_center_radius( + Vector3::new(0.0, 0.0, 0.0), + 100.0, + )); + let body = presspull_model::extrusion_body(&entity, [0.0, 0.0, 10.0]) + .expect("a closed circular profile extrudes"); + assert!(body.validate().is_empty(), "extrusion produced a valid body"); + body +} + +/// The two flat ends point away from the material, in opposite directions. +/// Getting this wrong is what would make a sketch land inside the solid, or +/// extrude back into it. +#[test] +fn opposite_faces_report_opposite_outward_normals() { + let body = puck(); + + let top = solid_model::nearest_planar_face(&body, [0.0, 0.0, 10.0]) + .expect("the top cap is planar"); + let bottom = solid_model::nearest_planar_face(&body, [0.0, 0.0, 0.0]) + .expect("the bottom cap is planar"); + assert_ne!(top, bottom, "the caps are distinct faces"); + + let top_normal = solid_model::planar_face_normal(&body, top).expect("top cap has a normal"); + let bottom_normal = + solid_model::planar_face_normal(&body, bottom).expect("bottom cap has a normal"); + + assert!( + (top_normal[2] - 1.0).abs() < 1e-9, + "top cap should point +Z, got {top_normal:?}" + ); + assert!( + (bottom_normal[2] + 1.0).abs() < 1e-9, + "bottom cap should point -Z, got {bottom_normal:?}" + ); +} + +/// A pick anywhere on a cap resolves to that cap, not merely to the one +/// nearest the origin. A sketch is started by clicking somewhere on a face, +/// rarely at its centre. +#[test] +fn an_off_centre_pick_still_lands_on_its_own_face() { + let body = puck(); + let centre = solid_model::nearest_planar_face(&body, [0.0, 0.0, 10.0]).unwrap(); + let off_centre = solid_model::nearest_planar_face(&body, [60.0, -25.0, 10.0]).unwrap(); + assert_eq!(centre, off_centre, "both picks are on the top cap"); +} + +/// The curved side is not a plane, so it cannot become a sketch plane. The +/// lookup has to decline rather than hand back a nonsense normal. +#[test] +fn the_curved_side_is_not_offered_as_a_plane() { + let body = puck(); + // Halfway up the barrel, on its surface. + let face = solid_model::nearest_planar_face(&body, [100.0, 0.0, 5.0]); + if let Some(face) = face { + // A planar cap may still be the nearest planar face; what must never + // happen is the barrel itself being reported as planar. + let normal = solid_model::planar_face_normal(&body, face) + .expect("whatever came back claims to be planar, so it has a normal"); + assert!( + normal[2].abs() > 1e-9, + "a cap normal has Z; a barrel wall wrongly called planar would not" + ); + } +} From 1529a8d9c13ffe818490326e30e006dcbf48da0c Mon Sep 17 00:00:00 2001 From: Matus Mastena Date: Sat, 12 Sep 2026 20:31:24 +0200 Subject: [PATCH 10/11] Pin the flake's git dependencies and lock its inputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nix build now succeeds. The hashes were placeholders before, so it could not have. Reading Cargo.toml for the git sources missed two of them: cryoglyph and dpi come in through iced, which pins its own forks, and they only appear in Cargo.lock. Six entries, not four. The comment says to regenerate the list from the lock for that reason. flake.lock is committed alongside, so a checkout resolves nixpkgs and rust-overlay the same way next month as today. Also asserts the snap default instead of leaving it to be eyeballed. It is only visible in a fresh profile — the app persists settings on change, so an existing settings.json keeps its own osmode and never shows what a new install would do. --- flake.lock | 48 +++++++++++++++++++++++++++++++++++++++++++++ flake.nix | 21 ++++++++++++-------- src/app/settings.rs | 33 +++++++++++++++++++++++++++++++ 3 files changed, 94 insertions(+), 8 deletions(-) create mode 100644 flake.lock diff --git a/flake.lock b/flake.lock new file mode 100644 index 000000000..95a880e21 --- /dev/null +++ b/flake.lock @@ -0,0 +1,48 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1789149629, + "narHash": "sha256-H6GwaZzZf+4npqv0tph94w9tZddSjFjmQrVsW0z78uk=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "eaad089433ca2bb662274377d33df3d0e51ef28b", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs", + "rust-overlay": "rust-overlay" + } + }, + "rust-overlay": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1789196581, + "narHash": "sha256-yJr1Bt4fKkKIpPYbsKGqJ0VFdoDnURgRwuffmBQ2WzY=", + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "228ecefb6329d5a531b77b46b581a2f0c26ee056", + "type": "github" + }, + "original": { + "owner": "oxalica", + "repo": "rust-overlay", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix index fd09ceec8..89610a0b6 100644 --- a/flake.nix +++ b/flake.nix @@ -59,15 +59,20 @@ cargoLock = { lockFile = ./Cargo.lock; - # One entry per distinct git source in Cargo.lock. Replace each - # lib.fakeHash with the sha256 that `nix build` prints on the - # first attempt; they must be refreshed whenever the matching - # rev in Cargo.toml moves. + # One entry per distinct git source in Cargo.lock, keyed by any + # one package from it. Two of these are transitive and easy to + # miss by reading Cargo.toml alone: cryoglyph and dpi arrive + # through iced, which pins its own forks. Regenerate the list + # from the lock rather than the manifest: + # grep -B2 'source = "git+' Cargo.lock + # Each hash must be refreshed whenever the matching rev moves. outputHashes = { - "acadrust-0.5.5" = lib.fakeHash; - "cadkernel-0.1.0" = lib.fakeHash; - "iced-0.15.0-dev" = lib.fakeHash; - "iced_aw-0.14.1" = lib.fakeHash; + "acadrust-0.5.5" = "sha256-rYtJaJsb+lZZIDBtPTCfWRVyA1Gfinn3UqJlfzkhhGk="; + "cadkernel-0.1.0" = "sha256-7smcL9oo5mJwoCLYUbD4+lA9Xcm+/qpVswYxzoGjTDk="; + "cryoglyph-0.1.0" = "sha256-5BOJNDcjhpt17/XSri3JGtX8NwC7db18bddp5wanMes="; + "dpi-0.1.1" = "sha256-pQn1lCFSJMkjUfHoggEzMHnm5k+Chnzi5JEDjahnjUA="; + "iced-0.15.0-dev" = "sha256-Atg+nr1VgXwW5I7LiMAMqHemz/9ZiehTc2EWuSFEGHI="; + "iced_aw-0.14.1" = "sha256-OynSIjv8SgSoxpw1S4wH58vDdqga/YojOR5A9ZLKrd8="; }; }; diff --git a/src/app/settings.rs b/src/app/settings.rs index fb9b3ed61..0be12cc9c 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -687,6 +687,39 @@ impl Default for UserSettings { mod tests { use super::*; + /// Object snap ships live. The modes were always pre-selected; only the + /// master switch was off, so a new user got a configured snap set that + /// did nothing until they found the status-bar pill. + /// + /// This is asserted rather than checked by eye because the default is + /// only observable in a fresh profile: the app persists settings on + /// change, so an existing settings.json keeps whatever osmode it already + /// holds and never reveals what a new install would do. + #[test] + fn snapping_is_enabled_in_a_fresh_profile() { + let settings = UserSettings::default(); + assert_eq!( + settings.osmode & OSMODE_SUPPRESS, + 0, + "the suppress bit is set, so snapping ships off" + ); + + let (modes, master_on) = snaps_from_osmode(settings.osmode); + assert!(master_on, "decoding the default must report snapping on"); + for expected in [ + SnapType::Endpoint, + SnapType::Midpoint, + SnapType::Center, + SnapType::Intersection, + ] { + assert!(modes.contains(&expected), "{expected:?} is not in the default set"); + } + + // The Snapper and the persisted default have to agree, or the running + // state and the saved state disagree the moment anything is written. + assert_eq!(crate::snap::Snapper::default().snap_enabled, master_on); + } + #[test] fn osmode_encodes_bits_and_suppress() { // Endpoint(1) + Midpoint(2) + Intersection(32) = 35, master on. From 82e410f8c0835e9aca5e2460bfd46927dceedbee Mon Sep 17 00:00:00 2001 From: Matus Mastena Date: Sat, 12 Sep 2026 21:00:29 +0200 Subject: [PATCH 11/11] Add a colour wheel for the viewport backgrounds The Display page could only take a typed #RRGGBB, which is precise but no way to choose a colour by eye. The swatches are now buttons that open a wheel. The wheel does not replace the hex field, it sits beside it: typing an exact value stays the quickest way to reproduce a known colour. On submit it hands a hex string to the existing ModelSpaceBgChanged and friends rather than writing the config itself, so the two routes cannot drift apart on validation, persistence, or the MatchTheme fallback an empty field triggers. Also stops the Browser shipping in the default dock layout. It was added to the right edge, which shifted the block palette to index 1 and broke four dock tests that encode the existing arrangement. Those tests were right and the change was wrong: the earlier commit claimed to leave the layout alone. BROWSER now docks the panel on first use, so nobody's edges rearrange on upgrade for a panel they never asked for. --- src/app/commands/layerprops.rs | 13 ++++- src/app/mod.rs | 18 +++++++ src/app/update/mod.rs | 33 ++++++++++++ src/app/view/modal.rs | 1 + src/ui/dock.rs | 2 +- src/ui/window/options.rs | 92 +++++++++++++++------------------- 6 files changed, 106 insertions(+), 53 deletions(-) diff --git a/src/app/commands/layerprops.rs b/src/app/commands/layerprops.rs index bb39b3a15..3943e0a33 100644 --- a/src/app/commands/layerprops.rs +++ b/src/app/commands/layerprops.rs @@ -673,9 +673,20 @@ impl OpenCADStudio { "BROWSER" => { self.show_browser ^= true; if self.show_browser { + // Dock it on first use rather than shipping it in the + // default layout: an existing user's edges should not + // rearrange themselves on upgrade for a panel they have + // not asked for. + let id = crate::ui::dock::PanelId::Browser; + if self.dock.location(id).is_none() { + // dock() clamps an out-of-range index to the end of + // the edge, so this appends below whatever is there. + self.dock + .dock(id, crate::app::config::DockSide::Right, usize::MAX); + } // Open expanded so it is usable straight away; the pin // button still collapses it. - self.dock_expanded = Some(crate::ui::dock::PanelId::Browser); + self.dock_expanded = Some(id); self.command_line .push_output(crate::t!("Browser opened.").as_ref()); } else { diff --git a/src/app/mod.rs b/src/app/mod.rs index 9204daedb..281daca5b 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -736,6 +736,9 @@ pub(super) struct OpenCADStudio { /// Whether the Browser panel is shown. Off until BROWSER opens it, so /// the default layout is unchanged for existing users. pub(crate) show_browser: bool, + /// Which viewport background the colour wheel is editing, or `None` when + /// it is closed. One slot, because only one wheel can be open at a time. + pub(crate) bg_picker: Option, /// General edge-stack dock layout for the side panels. pub(crate) dock: crate::ui::dock::DockState, /// Which panel is currently floated at full height (hovered, or a pinned @@ -1444,6 +1447,14 @@ pub struct SaveOutcome { refreshed_preview: Option>, result: Result<(), crate::io::SaveFailure>, } +/// Which viewport background a colour-wheel session is editing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BgTarget { + Model, + Paper, + Desk, +} + /// Active page in the shared CAD colour picker. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum ColorPickerTab { @@ -2129,6 +2140,12 @@ pub enum Message { ModelSpaceModeChanged(config::ModelSpaceMode), /// Change Model Space custom background color as hex or empty for default. ModelSpaceBgChanged(String), + /// Open the colour wheel on one of the viewport backgrounds. + BgPickerOpen(BgTarget), + /// Dismiss the wheel, changing nothing. + BgPickerCancel, + /// Accept the wheel's colour for whichever background it was opened on. + BgPickerSubmit(iced::Color), /// Change Paper Space custom sheet background color as hex or empty for default. PaperSpaceBgChanged(String), /// Change Paper Space desk surround background (#RRGGBB). @@ -3933,6 +3950,7 @@ impl OpenCADStudio { show_block_palette: false, show_external_references: false, show_browser: false, + bg_picker: None, block_palette: Default::default(), xref_manager: Default::default(), dock: Default::default(), diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index ee962d958..3573234d5 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -7068,6 +7068,39 @@ impl OpenCADStudio { Task::none() } + Message::BgPickerOpen(target) => { + self.bg_picker = Some(target); + Task::none() + } + + Message::BgPickerCancel => { + self.bg_picker = None; + Task::none() + } + + Message::BgPickerSubmit(color) => { + // Hand the result to the same handler the typed hex field + // uses, so the wheel and the field cannot drift apart on + // validation, persistence or the MatchTheme fallback. + let hex = crate::app::config::rgb_to_hex([ + (color.r * 255.0).round() as u8, + (color.g * 255.0).round() as u8, + (color.b * 255.0).round() as u8, + ]); + match self.bg_picker.take() { + Some(crate::app::BgTarget::Model) => { + Task::done(Message::ModelSpaceBgChanged(hex)) + } + Some(crate::app::BgTarget::Paper) => { + Task::done(Message::PaperSpaceBgChanged(hex)) + } + Some(crate::app::BgTarget::Desk) => { + Task::done(Message::DeskSpaceBgChanged(hex)) + } + None => Task::none(), + } + } + Message::ModelSpaceBgChanged(hex) => { self.model_bg_input = hex.clone(); if hex.trim().is_empty() { diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index d3bc809ab..80e96ac1d 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -358,6 +358,7 @@ impl OpenCADStudio { &self.model_bg_input, &self.paper_bg_input, &self.desk_bg_input, + self.bg_picker, flow, ) }, diff --git a/src/ui/dock.rs b/src/ui/dock.rs index 68b49c015..2be4829a2 100644 --- a/src/ui/dock.rs +++ b/src/ui/dock.rs @@ -121,7 +121,7 @@ impl Default for DockState { fn default() -> Self { Self { left: vec![PanelId::Properties], - right: vec![PanelId::Browser, PanelId::BlockPalette], + right: vec![PanelId::BlockPalette], panels: BTreeMap::new(), } } diff --git a/src/ui/window/options.rs b/src/ui/window/options.rs index a6664e345..1c1f35322 100644 --- a/src/ui/window/options.rs +++ b/src/ui/window/options.rs @@ -146,6 +146,43 @@ impl fmt::Display for Labelled { } } +/// A background swatch that opens the colour wheel when clicked. +/// +/// The wheel sits alongside the hex field rather than replacing it: typing +/// `#1E1E1E` stays the fastest way to reproduce an exact colour, while the +/// wheel is for choosing one by eye. +fn bg_swatch_picker<'a>( + target: crate::app::BgTarget, + rgb: [u8; 3], + open: Option, +) -> Element<'a, Message> { + let colour = iced::Color::from_rgb8(rgb[0], rgb[1], rgb[2]); + let swatch = container(Space::new()) + .width(28) + .height(22) + .style(move |theme: &Theme| container::Style { + background: Some(Background::Color(colour)), + border: Border { + color: theme.palette().background.strong.color, + width: 1.0, + radius: 3.0.into(), + }, + ..Default::default() + }); + let underlay = button(swatch) + .on_press(Message::BgPickerOpen(target)) + .padding(0) + .style(button::text); + iced_aw::ColorPicker::new( + open == Some(target), + colour, + underlay, + Message::BgPickerCancel, + Message::BgPickerSubmit, + ) + .into() +} + #[allow(clippy::too_many_arguments)] pub fn view_window<'a>( default_save_format: &'a str, @@ -172,6 +209,7 @@ pub fn view_window<'a>( model_bg_input: &'a str, paper_bg_input: &'a str, desk_bg_input: &'a str, + bg_picker: Option, sizing: crate::ui::modal::ModalSizing, ) -> Element<'a, Message> { let selected_format = crate::io::SAVE_FORMAT_OPTIONS @@ -455,58 +493,10 @@ pub fn view_window<'a>( }); let model_bg_rgb = model_space.custom_bg.unwrap_or(crate::app::config::CLASSIC_CAD_DARK_BG); - let model_bg_swatch = container(Space::new()) - .width(28) - .height(22) - .style(move |theme: &Theme| container::Style { - background: Some(Background::Color(iced::Color::from_rgb8( - model_bg_rgb[0], - model_bg_rgb[1], - model_bg_rgb[2], - ))), - border: Border { - color: theme.palette().background.strong.color, - width: 1.0, - radius: 3.0.into(), - }, - ..Default::default() - }); let paper_bg_rgb = model_space.custom_paper_bg.unwrap_or(crate::app::config::DEFAULT_PAPER_BG); - let paper_bg_swatch = container(Space::new()) - .width(28) - .height(22) - .style(move |theme: &Theme| container::Style { - background: Some(Background::Color(iced::Color::from_rgb8( - paper_bg_rgb[0], - paper_bg_rgb[1], - paper_bg_rgb[2], - ))), - border: Border { - color: theme.palette().background.strong.color, - width: 1.0, - radius: 3.0.into(), - }, - ..Default::default() - }); let desk_bg_rgb = model_space.custom_desk_bg.unwrap_or(crate::app::config::DEFAULT_DESK_BG); - let desk_bg_swatch = container(Space::new()) - .width(28) - .height(22) - .style(move |theme: &Theme| container::Style { - background: Some(Background::Color(iced::Color::from_rgb8( - desk_bg_rgb[0], - desk_bg_rgb[1], - desk_bg_rgb[2], - ))), - border: Border { - color: theme.palette().background.strong.color, - width: 1.0, - radius: 3.0.into(), - }, - ..Default::default() - }); let mode_options = crate::app::config::ModelSpaceMode::ALL .into_iter() @@ -641,7 +631,7 @@ pub fn view_window<'a>( display = display.push(Space::new().height(10)).push( row![ text(crate::t!("Model background")).size(12).width(140), - model_bg_swatch, + bg_swatch_picker(crate::app::BgTarget::Model, model_bg_rgb, bg_picker), text_input("#RRGGBB", model_bg_input) .on_input(Message::ModelSpaceBgChanged) .width(150), @@ -654,7 +644,7 @@ pub fn view_window<'a>( display = display.push(Space::new().height(10)).push( row![ text(crate::t!("Paper background")).size(12).width(140), - paper_bg_swatch, + bg_swatch_picker(crate::app::BgTarget::Paper, paper_bg_rgb, bg_picker), text_input("#RRGGBB", paper_bg_input) .on_input(Message::PaperSpaceBgChanged) .width(150), @@ -666,7 +656,7 @@ pub fn view_window<'a>( display = display.push(Space::new().height(10)).push( row![ text(crate::t!("Desk surround")).size(12).width(140), - desk_bg_swatch, + bg_swatch_picker(crate::app::BgTarget::Desk, desk_bg_rgb, bg_picker), text_input("#RRGGBB", desk_bg_input) .on_input(Message::DeskSpaceBgChanged) .width(150),