forked from 1b-agent/opencadstudio-upstream
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:
commit
3ba4ab9e7f
24 files changed
+1277
-59
No files matched your search
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -358,6 +358,7 @@ impl OpenCADStudio {
|
||||
&self.model_bg_input,
|
||||
&self.paper_bg_input,
|
||||
&self.desk_bg_input,
|
||||
self.bg_picker,
|
||||
flow,
|
||||
)
|
||||
},
|
||||
|
||||
@@ -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
@@ -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
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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
@@ -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),
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user