Merge pull request #1341 from dichhead/main

Nix flake (dev shell + package build), object snap on by default,
Fusion Black/White themes, UCS FACE / UCS OBJECT, CREATESKETCH /
FINISHSKETCH sketch mode, BROWSER panel, and a colour wheel for the
viewport background swatches.
This commit is contained in:
Hakan Seven committed 2026-09-18 17:49:22 +03:00
commit 3ba4ab9e7f
24 files changed
+1277 -59

No files matched your search

+1
View File
@@ -0,0 +1 @@
use flake
+6
View File
@@ -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/
Generated
+48
View File
@@ -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
}
+156
View File
@@ -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);
};
}
+259
View File
@@ -314,6 +314,131 @@ impl OpenCADStudio {
self.command_line
.push_output(crate::t!("UCS aligned to the current view.").as_ref());
}
// UCS FACE <handle> <x,y,z> — 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 <handle> — 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 <handle>").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<glam::DVec3> = 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<acadrust::Handle> = 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<acadrust::Handle> = 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)
+5
View File
@@ -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",
+21 -3
View File
@@ -176,11 +176,20 @@ impl UiThemePalette {
}
}
pub fn builtin_theme(name: &str) -> Option<iced::Theme> {
/// 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> {
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<iced::Theme> {
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<iced::Theme> {
"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,
}
}
+28
View File
@@ -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<Ucs>,
/// 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<acadrust::Handle>,
}
// ── 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<Ucs>,
/// Open sketch, if CREATESKETCH is in effect. `None` is the normal
/// direct-modelling state.
pub(super) sketch_session: Option<SketchSession>,
/// 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,
+109
View File
@@ -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<Ucs> {
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<Vec<u8>> {
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());
}
}
+22
View File
@@ -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<BgTarget>,
/// 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<Option<acadrust::Preview>>,
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(),
+37 -2
View File
@@ -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.
+4
View File
@@ -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,
}
}
+33
View File
@@ -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() {
+7
View File
@@ -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<Element<'_, Message>> {
@@ -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 {
+1
View File
@@ -358,6 +358,7 @@ impl OpenCADStudio {
&self.model_bg_input,
&self.paper_bg_input,
&self.desk_bg_input,
self.bg_picker,
flow,
)
},
+56
View File
@@ -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 <handle> <x,y,z>` 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 <type> <old> <new>` handler via [`CmdResult::Dispatch`] — the
+1 -1
View File
@@ -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,
+10 -1
View File
@@ -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));
}
}
+144
View File
@@ -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<Theme> {
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);
}
}
+1
View File
@@ -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 {
+205
View File
@@ -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()
}
+1
View File
@@ -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;
+42 -52
View File
@@ -146,6 +146,43 @@ impl<T> fmt::Display for Labelled<T> {
}
}
/// 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<crate::app::BgTarget>,
) -> 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<crate::app::BgTarget>,
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),
+80
View File
@@ -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"
);
}
}