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..d46af8231 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,9 @@ __pycache__/ # Claude Code local preview/dev-server config .claude/launch.json + +# nix-direnv +.direnv/ + +# Worktrees created for isolated runs +.claude/worktrees/ 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 new file mode 100644 index 000000000..89610a0b6 --- /dev/null +++ b/flake.nix @@ -0,0 +1,156 @@ +{ + 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, 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" = "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="; + }; + }; + + 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); + }; +} diff --git a/src/app/commands/layerprops.rs b/src/app/commands/layerprops.rs index 0ae2e63b5..3943e0a33 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 @@ -506,6 +631,72 @@ 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 { + // 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(id); + 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( @@ -1366,6 +1557,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/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/app/document.rs b/src/app/document.rs index 5759e3d75..e7f9041ed 100644 --- a/src/app/document.rs +++ b/src/app/document.rs @@ -100,6 +100,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 { @@ -185,6 +206,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. @@ -611,6 +637,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, diff --git a/src/app/helpers.rs b/src/app/helpers.rs index 678ae2144..d1b3d3fb7 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; @@ -577,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/src/app/mod.rs b/src/app/mod.rs index ebaa09771..8a4319beb 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -734,6 +734,12 @@ 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, + /// 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 @@ -1442,6 +1448,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 { @@ -2127,6 +2141,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). @@ -3931,6 +3951,8 @@ impl OpenCADStudio { show_properties: true, 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/settings.rs b/src/app/settings.rs index 46baa6dae..2ddc3c39f 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -653,8 +653,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, @@ -693,6 +695,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. 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/update/mod.rs b/src/app/update/mod.rs index 5d650515d..480608dcd 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -7067,6 +7067,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/mod.rs b/src/app/view/mod.rs index ed7acfba9..52d4e18cb 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -1840,6 +1840,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> { @@ -2901,6 +2902,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/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/command.rs b/src/command.rs index 356048dd9..44852bd88 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 diff --git a/src/snap.rs b/src/snap.rs index eea0b02e7..eee33903f 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, diff --git a/src/ui/dock.rs b/src/ui/dock.rs index 475fa0023..2be4829a2 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, } } @@ -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/style/fusion_theme.rs b/src/ui/style/fusion_theme.rs new file mode 100644 index 000000000..ba869c1da --- /dev/null +++ b/src/ui/style/fusion_theme.rs @@ -0,0 +1,144 @@ +//! 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. +#[must_use] +pub fn fusion_black() -> Theme { + Theme::custom( + FUSION_BLACK, + Seed { + 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(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), + 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/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; diff --git a/src/ui/window/options.rs b/src/ui/window/options.rs index aa490a9f7..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 @@ -179,7 +217,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())) @@ -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), 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" + ); + } +}