mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-08 21:11:56 +00:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 44525da669 | |||
| 33c442f031 | |||
| 8d47023049 | |||
| 3c619790ce | |||
| f4d9519bb9 | |||
| 9890abae36 | |||
| 28cfed5e07 | |||
| 01042ca81e | |||
| 42cd362748 | |||
| bfee9bcc19 | |||
| 1210ac5080 | |||
| 6e27cfa7ff | |||
| 5c95fb027c | |||
| 1dcb1066e5 | |||
| 94cb149856 | |||
| 9e3e441a2d | |||
| 3d78129af6 | |||
| 3fb1040164 |
@@ -13,7 +13,7 @@ Work: ROADMAP.md, "Where help is wanted most" in CONTRIBUTING.md, issues labelle
|
||||
- Model decisions (what an operation does, which bodies it touches, which face or edge a point or name means, depths, nearness) use exact geometry; a mesh is for display, export, previews and mesh bodies (`xtask/mesh-decisions.txt` only shrinks): `cargo xtask mesh-decisions`.
|
||||
- New kernel cases go to solidcore: `cargo xtask lists`.
|
||||
- Everything is a command and programmatic calls never open dialogs: `skills/solidcraft-commands/SKILL.md`.
|
||||
- Layering (geom, sketch/kernel/render, doc, io, engine, ui-egui, apps); only `crates/kernel` uses truck: `cargo xtask layers`.
|
||||
- Layering; only `crates/kernel` uses truck; solidcore only via `solidcore-ops`/`-tess`/`-record` (`xtask/solidcore-seam.txt`): `cargo xtask layers`.
|
||||
- One outer UI container, one camera path (`navigation::handle`), colours from theme tokens: `cargo xtask ui-containers`.
|
||||
- Application code is Rust only and builds for wasm: `cargo xtask wasm`, `cargo xtask web-offline-test`.
|
||||
- The product name is SolidCraft; the old name lives only in `docs/compatibility.md`.
|
||||
|
||||
Generated
-1
@@ -4405,7 +4405,6 @@ dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
"minisign-verify",
|
||||
"muda",
|
||||
"nix 0.31.3",
|
||||
"pollster 0.4.0",
|
||||
"proc-macro2",
|
||||
|
||||
@@ -35,7 +35,7 @@ mod native_crash;
|
||||
mod native_crash_macos;
|
||||
#[cfg(target_os = "windows")]
|
||||
mod native_frame;
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
#[cfg(target_os = "macos")]
|
||||
mod native_menu;
|
||||
mod nav_bridge;
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -913,7 +913,17 @@ fn app_viewport(custom_titlebar: bool) -> Result<egui::ViewportBuilder, eframe::
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let icon =
|
||||
egui::IconData { rgba: app_icon::rgba(64).map_err(|e| eframe::Error::AppCreation(std::io::Error::other(e).into()))?, width: 64, height: 64 };
|
||||
Ok(solidcraft_ui_egui::window_chrome::viewport("SolidCraft", APP_ID, custom_titlebar).with_icon(icon))
|
||||
Ok(egui::ViewportBuilder::default()
|
||||
.with_title("SolidCraft")
|
||||
.with_icon(icon)
|
||||
.with_inner_size([1600.0, 1000.0])
|
||||
.with_min_inner_size([960.0, 600.0])
|
||||
.with_drag_and_drop(true)
|
||||
.with_decorations(!custom_titlebar)
|
||||
.with_fullsize_content_view(true)
|
||||
.with_titlebar_shown(false)
|
||||
.with_title_shown(false)
|
||||
.with_app_id(APP_ID))
|
||||
}
|
||||
|
||||
/// The window's app id: it names the desktop entry, so it is the packages' reverse-DNS id.
|
||||
|
||||
+162
-347
@@ -1,221 +1,137 @@
|
||||
//! SolidCraft's macOS menu bar: its entries as kit menu items
|
||||
//! (`solidcraft_ui_egui::window_chrome::native_menu`), each with a command id or a `ui.*` id, run
|
||||
//! through the same actions as the application bar's File menu (`toolbar::FileAction`).
|
||||
//!
|
||||
//! Entries that are control actions use their ids and do the same there: `app.quit`,
|
||||
//! `ui.prefs {open}` (Settings and Keyboard Shortcuts), `ui.agent_chat {toggle}`, `ui.help {item}`. Undo and Redo are the commands
|
||||
//! `edit.undo` and `edit.redo`. The other entries' ids are defined here: `ui.file.new`,
|
||||
//! `ui.file.open`, `ui.file.open_recent {path}`, `ui.file.clear_recent`, `ui.file.save`,
|
||||
//! `ui.file.save_as`, `ui.file.save_json`, `ui.file.export {format}`,
|
||||
//! `ui.file.export_mesh {format}`. Menus are `menu.file`, `menu.file.recent`, `menu.edit`,
|
||||
//! `menu.window` and `menu.help`.
|
||||
//!
|
||||
//! The menu bar itself is macOS's; the entries and their ids build everywhere, so their tests run
|
||||
//! on every system.
|
||||
//! macOS system menus, owned and updated on eframe's main thread.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_ui_egui::{
|
||||
SolveApp,
|
||||
toolbar::FileAction,
|
||||
ui_kit::Item,
|
||||
window_chrome::native_menu::{self, MenuBar},
|
||||
use std::sync::mpsc::{Receiver, sync_channel};
|
||||
|
||||
use muda::{
|
||||
AboutMetadata, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu,
|
||||
accelerator::{Accelerator, Code, Modifiers},
|
||||
};
|
||||
use solidcraft_ui_egui::{SolveApp, toolbar::FileAction};
|
||||
|
||||
/// The Open Recent submenu, refilled when the recent designs change.
|
||||
const RECENT: &str = "menu.file.recent";
|
||||
const OPEN_RECENT: &str = "ui.file.open_recent";
|
||||
/// Recent designs listed.
|
||||
#[cfg(target_os = "macos")]
|
||||
const RECENT_COUNT: usize = 32;
|
||||
|
||||
/// File > Insert entries.
|
||||
const INSERTS: [(&str, FileAction); 3] = [
|
||||
("Insert STEP\u{2026}", FileAction::Command("file.insert_step")),
|
||||
("Insert Mesh\u{2026}", FileAction::Command("file.insert_mesh")),
|
||||
("Insert Design", FileAction::Command("component.insert_design")),
|
||||
];
|
||||
|
||||
/// File > Export entries.
|
||||
const EXPORTS: [(&str, FileAction); 7] = [
|
||||
("Export STL\u{2026}", FileAction::ExportMesh("stl")),
|
||||
("Export 3MF\u{2026}", FileAction::ExportMesh("3mf")),
|
||||
("Export STEP\u{2026}", FileAction::Export("step")),
|
||||
("Export Current View as DXF\u{2026}", FileAction::Command("file.export_view_dxf")),
|
||||
("Export OBJ\u{2026}", FileAction::Export("obj")),
|
||||
("Export URDF\u{2026}", FileAction::Export("urdf")),
|
||||
("Export Design as JSON\u{2026}", FileAction::Export("json")),
|
||||
];
|
||||
|
||||
const DESIGN_PROPERTIES: FileAction = FileAction::Command("document.metadata");
|
||||
|
||||
/// The other actions with a fixed id (all but Open Recent), to map an id back to its action.
|
||||
const FIXED: &[FileAction] = &[
|
||||
FileAction::Settings,
|
||||
FileAction::Shortcuts,
|
||||
FileAction::Quit,
|
||||
FileAction::New,
|
||||
FileAction::Open,
|
||||
FileAction::ClearRecent,
|
||||
FileAction::Save,
|
||||
FileAction::SaveAs,
|
||||
FileAction::SaveJson,
|
||||
DESIGN_PROPERTIES,
|
||||
FileAction::Undo,
|
||||
FileAction::Redo,
|
||||
FileAction::AgentChat,
|
||||
];
|
||||
|
||||
/// The Help menu's entries: the control channel's `ui.help {item}`.
|
||||
const HELP_ID: &str = "ui.help";
|
||||
/// The Help menu's items (`help::run`), top to bottom.
|
||||
const HELP: [&str; 7] = ["docs", "updates", "shortcuts", "messages", "report", "crashes", "install_cli"];
|
||||
|
||||
fn help_label<'a>(app: &'a SolveApp, item: &str) -> &'a str {
|
||||
match item {
|
||||
"docs" => app.tr_label("Documentation"),
|
||||
"updates" => app.tr("update.check_for_updates"),
|
||||
"shortcuts" => app.tr_label("Keyboard Shortcuts"),
|
||||
"messages" => app.tr_label("Messages\u{2026}"),
|
||||
"report" => app.tr_label("Report an Issue\u{2026}"),
|
||||
"crashes" => app.tr_label("Crash Reports\u{2026}"),
|
||||
_ => app.tr_label("Install solidcraft-cli command"),
|
||||
}
|
||||
}
|
||||
|
||||
/// What a menu entry runs.
|
||||
#[derive(Debug, PartialEq)]
|
||||
enum Entry {
|
||||
File(FileAction),
|
||||
Help(&'static str),
|
||||
}
|
||||
|
||||
/// The entry's id and parameters for `action`.
|
||||
fn id_of(action: &FileAction) -> (String, Option<Value>) {
|
||||
let fixed = |id: &str| (id.to_string(), None);
|
||||
match action {
|
||||
FileAction::Settings => ("ui.prefs".into(), Some(json!({ "open": "General" }))),
|
||||
FileAction::Shortcuts => ("ui.prefs".into(), Some(json!({ "open": "Shortcuts" }))),
|
||||
FileAction::Quit => fixed("app.quit"),
|
||||
FileAction::New => fixed("ui.file.new"),
|
||||
FileAction::Open => fixed("ui.file.open"),
|
||||
FileAction::OpenRecent(path) => (OPEN_RECENT.into(), Some(json!({ "path": path }))),
|
||||
FileAction::ClearRecent => fixed("ui.file.clear_recent"),
|
||||
FileAction::Save => fixed("ui.file.save"),
|
||||
FileAction::SaveAs => fixed("ui.file.save_as"),
|
||||
FileAction::SaveJson => fixed("ui.file.save_json"),
|
||||
FileAction::Export(format) => ("ui.file.export".into(), Some(json!({ "format": format }))),
|
||||
FileAction::ExportMesh(format) => ("ui.file.export_mesh".into(), Some(json!({ "format": format }))),
|
||||
FileAction::Command(command) => fixed(command),
|
||||
FileAction::Undo => fixed("edit.undo"),
|
||||
FileAction::Redo => fixed("edit.redo"),
|
||||
FileAction::AgentChat => ("ui.agent_chat".into(), Some(json!({ "toggle": true }))),
|
||||
}
|
||||
}
|
||||
|
||||
/// The entry with `id` and `params`, `None` for one this menu doesn't have.
|
||||
fn entry_of(id: &str, params: Option<&Value>) -> Option<Entry> {
|
||||
if id == OPEN_RECENT {
|
||||
return params.and_then(|p| p.get("path")).and_then(Value::as_str).map(|path| Entry::File(FileAction::OpenRecent(path.to_string())));
|
||||
}
|
||||
if id == HELP_ID {
|
||||
let item = params.and_then(|p| p.get("item")).and_then(Value::as_str);
|
||||
return HELP.into_iter().find(|h| Some(*h) == item).map(Entry::Help);
|
||||
}
|
||||
let wanted = (id.to_string(), params.cloned());
|
||||
FIXED.iter().cloned().chain(INSERTS.map(|(_, a)| a)).chain(EXPORTS.map(|(_, a)| a)).find(|a| id_of(a) == wanted).map(Entry::File)
|
||||
}
|
||||
|
||||
/// The entries with a key, as menu accelerators.
|
||||
const SHORTCUTS: [(FileAction, &str); 8] = [
|
||||
(FileAction::Settings, "Cmd+,"),
|
||||
(FileAction::Quit, "Cmd+Q"),
|
||||
(FileAction::New, "Cmd+N"),
|
||||
(FileAction::Open, "Cmd+O"),
|
||||
(FileAction::Save, "Cmd+S"),
|
||||
(FileAction::SaveAs, "Cmd+Shift+S"),
|
||||
(FileAction::Undo, "Cmd+Z"),
|
||||
(FileAction::Redo, "Cmd+Shift+Z"),
|
||||
];
|
||||
|
||||
fn item(label: &str, action: &FileAction) -> Item {
|
||||
let (id, params) = id_of(action);
|
||||
let shortcut = SHORTCUTS.iter().find(|(a, _)| a == action).map_or("", |(_, k)| *k);
|
||||
let mut item = Item::action(&id, label, "").key(shortcut);
|
||||
item.params = params;
|
||||
item
|
||||
}
|
||||
|
||||
fn submenu(id: &str, label: &str, children: Vec<Item>) -> Item {
|
||||
let mut menu = Item::action(id, label, "");
|
||||
menu.children = children;
|
||||
menu
|
||||
}
|
||||
|
||||
fn recent_items(app: &SolveApp, paths: &[String]) -> Vec<Item> {
|
||||
let mut items: Vec<Item> = paths.iter().map(|path| item(path, &FileAction::OpenRecent(path.clone()))).collect();
|
||||
items.push(Item::sep());
|
||||
items.push(item(app.tr("ui.clear_recent"), &FileAction::ClearRecent));
|
||||
items
|
||||
}
|
||||
|
||||
fn menu_bar(app: &SolveApp, recent: &[String]) -> MenuBar {
|
||||
let more = |key: &str| format!("{}\u{2026}", app.tr(key));
|
||||
let mut file = vec![
|
||||
item(app.tr("ui.new_design"), &FileAction::New),
|
||||
item(&more("ui.open"), &FileAction::Open),
|
||||
submenu(RECENT, app.tr("ui.open_recent"), recent_items(app, recent)),
|
||||
Item::sep(),
|
||||
];
|
||||
file.extend(INSERTS.iter().map(|(label, action)| item(app.tr_label(label), action)));
|
||||
file.extend([
|
||||
Item::sep(),
|
||||
item(app.tr("ui.save"), &FileAction::Save),
|
||||
item(&more("ui.save_as"), &FileAction::SaveAs),
|
||||
item(app.tr_label("Save As JSON\u{2026}"), &FileAction::SaveJson),
|
||||
Item::sep(),
|
||||
]);
|
||||
file.extend(EXPORTS.iter().map(|(label, action)| item(app.tr_label(label), action)));
|
||||
file.extend([Item::sep(), item(app.tr_label("Design Properties"), &DESIGN_PROPERTIES)]);
|
||||
let edit = vec![item(app.tr("ui.undo"), &FileAction::Undo), item(app.tr("ui.redo"), &FileAction::Redo)];
|
||||
let window = vec![item(app.tr_label("Agent Chat"), &FileAction::AgentChat)];
|
||||
let help = HELP.iter().map(|h| Item::action(HELP_ID, help_label(app, h), "").with(json!({ "item": h }))).collect();
|
||||
MenuBar {
|
||||
title: "SolidCraft".into(),
|
||||
version: env!("CARGO_PKG_VERSION").into(),
|
||||
app: vec![item(&more("ui.preferences"), &FileAction::Settings), item(&more("ui.keyboard_shortcuts"), &FileAction::Shortcuts)],
|
||||
quit: item(&format!("{} SolidCraft", app.tr("ui.quit")), &FileAction::Quit),
|
||||
menus: vec![
|
||||
submenu("menu.file", app.tr("ui.file"), file),
|
||||
submenu("menu.edit", app.tr_label("Edit"), edit),
|
||||
submenu(native_menu::WINDOW, "Window", window),
|
||||
submenu("menu.help", app.tr("ui.help"), help),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/// The menu bar, owned and updated on eframe's main thread.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub struct NativeMenu {
|
||||
menu: native_menu::NativeMenu,
|
||||
menu: Menu,
|
||||
actions: Vec<(MenuItem, FileAction)>,
|
||||
help_actions: Vec<(MenuItem, &'static str)>,
|
||||
events: Receiver<MenuEvent>,
|
||||
recent: Submenu,
|
||||
recent_paths: Vec<String>,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
impl NativeMenu {
|
||||
pub fn new(app: &SolveApp, ctx: &egui::Context) -> Result<Self, String> {
|
||||
let recent_paths: Vec<String> = app.home.recent.iter().take(RECENT_COUNT).cloned().collect();
|
||||
let menu = native_menu::NativeMenu::new(&menu_bar(app, &recent_paths), ctx)?;
|
||||
let native = Self { menu, recent_paths };
|
||||
native.update(app, ctx);
|
||||
pub fn new(app: &SolveApp, ctx: &egui::Context) -> muda::Result<Self> {
|
||||
let menu = Menu::new();
|
||||
let application = Submenu::new("SolidCraft", true);
|
||||
let file = Submenu::new(app.tr("ui.file"), true);
|
||||
let edit = Submenu::new(app.tr_label("Edit"), true);
|
||||
let recent = Submenu::new(app.tr("ui.open_recent"), true);
|
||||
let window = Submenu::new("Window", true);
|
||||
let help = Submenu::new(app.tr("ui.help"), true);
|
||||
let mut help_actions = Vec::new();
|
||||
let mut actions = Vec::new();
|
||||
let mut add = |parent: &Submenu, label: &str, action: FileAction, shortcut: Option<Accelerator>| -> muda::Result<()> {
|
||||
let item = MenuItem::new(label, Self::action_enabled(&action, app, ctx), shortcut);
|
||||
parent.append(&item)?;
|
||||
actions.push((item, action));
|
||||
Ok(())
|
||||
};
|
||||
application.append(&PredefinedMenuItem::about(
|
||||
Some("About SolidCraft"),
|
||||
Some(AboutMetadata { name: Some("SolidCraft".into()), version: Some(env!("CARGO_PKG_VERSION").into()), ..Default::default() }),
|
||||
))?;
|
||||
application.append(&PredefinedMenuItem::separator())?;
|
||||
add(
|
||||
&application,
|
||||
&format!("{}\u{2026}", app.tr("ui.preferences")),
|
||||
FileAction::Settings,
|
||||
Some(Accelerator::new(Modifiers::META, Code::Comma)),
|
||||
)?;
|
||||
add(&application, &format!("{}\u{2026}", app.tr("ui.keyboard_shortcuts")), FileAction::Shortcuts, None)?;
|
||||
application.append(&PredefinedMenuItem::separator())?;
|
||||
application.append(&PredefinedMenuItem::services(None))?;
|
||||
application.append(&PredefinedMenuItem::separator())?;
|
||||
application.append_items(&[
|
||||
&PredefinedMenuItem::hide(Some("Hide SolidCraft")),
|
||||
&PredefinedMenuItem::hide_others(None),
|
||||
&PredefinedMenuItem::show_all(None),
|
||||
&PredefinedMenuItem::separator(),
|
||||
])?;
|
||||
add(&application, &format!("{} SolidCraft", app.tr("ui.quit")), FileAction::Quit, Some(Accelerator::new(Modifiers::META, Code::KeyQ)))?;
|
||||
add(&file, app.tr("ui.new_design"), FileAction::New, Some(Accelerator::new(Modifiers::META, Code::KeyN)))?;
|
||||
add(&file, &format!("{}\u{2026}", app.tr("ui.open")), FileAction::Open, Some(Accelerator::new(Modifiers::META, Code::KeyO)))?;
|
||||
file.append(&recent)?;
|
||||
file.append(&PredefinedMenuItem::separator())?;
|
||||
for (label, command) in
|
||||
[("Insert STEP\u{2026}", "file.insert_step"), ("Insert Mesh\u{2026}", "file.insert_mesh"), ("Insert Design", "component.insert_design")]
|
||||
{
|
||||
add(&file, app.tr_label(label), FileAction::Command(command), None)?;
|
||||
}
|
||||
file.append(&PredefinedMenuItem::separator())?;
|
||||
add(&file, app.tr("ui.save"), FileAction::Save, Some(Accelerator::new(Modifiers::META, Code::KeyS)))?;
|
||||
add(
|
||||
&file,
|
||||
&format!("{}\u{2026}", app.tr("ui.save_as")),
|
||||
FileAction::SaveAs,
|
||||
Some(Accelerator::new(Modifiers::META | Modifiers::SHIFT, Code::KeyS)),
|
||||
)?;
|
||||
add(&file, app.tr_label("Save As JSON\u{2026}"), FileAction::SaveJson, None)?;
|
||||
file.append(&PredefinedMenuItem::separator())?;
|
||||
for (label, action) in [
|
||||
("Export STL\u{2026}", FileAction::ExportMesh("stl")),
|
||||
("Export 3MF\u{2026}", FileAction::ExportMesh("3mf")),
|
||||
("Export STEP\u{2026}", FileAction::Export("step")),
|
||||
("Export Current View as DXF\u{2026}", FileAction::Command("file.export_view_dxf")),
|
||||
("Export OBJ\u{2026}", FileAction::Export("obj")),
|
||||
("Export URDF\u{2026}", FileAction::Export("urdf")),
|
||||
("Export Design as JSON\u{2026}", FileAction::Export("json")),
|
||||
] {
|
||||
add(&file, app.tr_label(label), action, None)?;
|
||||
}
|
||||
file.append(&PredefinedMenuItem::separator())?;
|
||||
add(&file, app.tr_label("Design Properties"), FileAction::Command("document.metadata"), None)?;
|
||||
add(&edit, app.tr("ui.undo"), FileAction::Undo, Some(Accelerator::new(Modifiers::META, Code::KeyZ)))?;
|
||||
add(&edit, app.tr("ui.redo"), FileAction::Redo, Some(Accelerator::new(Modifiers::META | Modifiers::SHIFT, Code::KeyZ)))?;
|
||||
add(&window, app.tr_label("Agent Chat"), FileAction::AgentChat, None)?;
|
||||
window.append(&PredefinedMenuItem::separator())?;
|
||||
window.append_items(&[
|
||||
&PredefinedMenuItem::minimize(None),
|
||||
&PredefinedMenuItem::maximize(None),
|
||||
&PredefinedMenuItem::fullscreen(None),
|
||||
&PredefinedMenuItem::separator(),
|
||||
&PredefinedMenuItem::bring_all_to_front(None),
|
||||
])?;
|
||||
for (action, label) in [
|
||||
("docs", "Documentation"),
|
||||
("updates", app.tr("update.check_for_updates")),
|
||||
("shortcuts", "Keyboard Shortcuts"),
|
||||
("messages", "Messages\u{2026}"),
|
||||
("report", "Report an Issue\u{2026}"),
|
||||
("crashes", "Crash Reports\u{2026}"),
|
||||
("install_cli", "Install solidcraft-cli command"),
|
||||
] {
|
||||
let item = MenuItem::new(app.tr_label(label), true, None);
|
||||
help.append(&item)?;
|
||||
help_actions.push((item, action));
|
||||
}
|
||||
menu.append_items(&[&application, &file, &edit, &window, &help])?;
|
||||
let (sender, events) = sync_channel(64);
|
||||
let ctx = ctx.clone();
|
||||
MenuEvent::set_event_handler(Some(move |event| {
|
||||
let _ = sender.try_send(event);
|
||||
ctx.request_repaint();
|
||||
}));
|
||||
menu.init_for_nsapp();
|
||||
let mut native = Self { menu, actions, help_actions, events, recent, recent_paths: Vec::new() };
|
||||
native.update_recent(app)?;
|
||||
Ok(native)
|
||||
}
|
||||
|
||||
pub fn raw_input_hook(&mut self, app: &mut SolveApp, ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
for (id, params) in self.menu.events() {
|
||||
match entry_of(&id, params.as_ref()) {
|
||||
Some(Entry::Help(item)) => solidcraft_ui_egui::help::run(app, ctx, item),
|
||||
// A focused text field takes its own Undo and Redo.
|
||||
Some(Entry::File(action @ (FileAction::Undo | FileAction::Redo))) if ctx.egui_wants_keyboard_input() => {
|
||||
for event in self.events.try_iter().take(64) {
|
||||
if let Some((_, action)) = self.help_actions.iter().find(|(item, _)| item.id() == &event.id) {
|
||||
solidcraft_ui_egui::help::run(app, ctx, action);
|
||||
}
|
||||
if let Some((_, action)) = self.actions.iter().find(|(item, _)| item.id() == &event.id) {
|
||||
if matches!(action, FileAction::Undo | FileAction::Redo) && ctx.egui_wants_keyboard_input() {
|
||||
raw.events.push(egui::Event::Key {
|
||||
key: egui::Key::Z,
|
||||
physical_key: None,
|
||||
@@ -223,159 +139,58 @@ impl NativeMenu {
|
||||
repeat: false,
|
||||
modifiers: egui::Modifiers { command: true, mac_cmd: true, shift: matches!(action, FileAction::Redo), ..Default::default() },
|
||||
});
|
||||
} else {
|
||||
action.run(app);
|
||||
}
|
||||
Some(Entry::File(action)) => action.run(app),
|
||||
None => log::warn!("native menu: no entry `{id}`"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&self, app: &SolveApp, ctx: &egui::Context) {
|
||||
let focus = ctx.egui_wants_keyboard_input();
|
||||
self.menu.update(|id, params| match entry_of(id, params) {
|
||||
Some(Entry::File(action)) => action.menu_enabled(app, focus),
|
||||
Some(Entry::Help(item)) => solidcraft_ui_egui::help::available(app, item),
|
||||
None => false,
|
||||
});
|
||||
fn action_enabled(action: &FileAction, app: &SolveApp, ctx: &egui::Context) -> bool {
|
||||
action.menu_enabled(app, ctx.egui_wants_keyboard_input())
|
||||
}
|
||||
|
||||
pub fn tick(&mut self, app: &mut SolveApp, ctx: &egui::Context) {
|
||||
if !self.recent_paths.iter().eq(app.home.recent.iter().take(RECENT_COUNT)) {
|
||||
self.recent_paths = app.home.recent.iter().take(RECENT_COUNT).cloned().collect();
|
||||
if let Err(error) = self.menu.replace(RECENT, &recent_items(app, &self.recent_paths)) {
|
||||
app.set_status(format!("Recent designs menu: {error}"), true);
|
||||
for (item, action) in &self.actions {
|
||||
let enabled = Self::action_enabled(action, app, ctx);
|
||||
if item.is_enabled() != enabled {
|
||||
item.set_enabled(enabled);
|
||||
}
|
||||
}
|
||||
self.update(app, ctx);
|
||||
for (item, action) in &self.help_actions {
|
||||
let enabled = solidcraft_ui_egui::help::available(app, action);
|
||||
if item.is_enabled() != enabled {
|
||||
item.set_enabled(enabled);
|
||||
}
|
||||
}
|
||||
if !self.recent_paths.iter().eq(app.home.recent.iter().take(32))
|
||||
&& let Err(error) = self.update_recent(app)
|
||||
{
|
||||
app.set_status(format!("Recent designs menu: {error}"), true);
|
||||
}
|
||||
}
|
||||
|
||||
fn update_recent(&mut self, app: &SolveApp) -> muda::Result<()> {
|
||||
while self.recent.remove_at(0).is_some() {}
|
||||
self.actions.retain(|(_, action)| !matches!(action, FileAction::OpenRecent(_) | FileAction::ClearRecent));
|
||||
self.recent_paths = app.home.recent.iter().take(32).cloned().collect();
|
||||
for path in &self.recent_paths {
|
||||
let action = FileAction::OpenRecent(path.clone());
|
||||
let item = MenuItem::new(path, action.enabled(app), None);
|
||||
self.recent.append(&item)?;
|
||||
self.actions.push((item, action));
|
||||
}
|
||||
self.recent.append(&PredefinedMenuItem::separator())?;
|
||||
let clear = MenuItem::new(app.tr("ui.clear_recent"), !self.recent_paths.is_empty(), None);
|
||||
self.recent.append(&clear)?;
|
||||
self.actions.push((clear, FileAction::ClearRecent));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Every action the menu offers, Open Recent with a path.
|
||||
fn actions() -> Vec<FileAction> {
|
||||
let mut all: Vec<FileAction> = FIXED.iter().cloned().chain(INSERTS.map(|(_, a)| a)).chain(EXPORTS.map(|(_, a)| a)).collect();
|
||||
all.push(FileAction::OpenRecent("/work/a.solidcraft".into()));
|
||||
all
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_action_and_help_item_round_trips_through_its_id() {
|
||||
let mut seen = vec![];
|
||||
for action in actions() {
|
||||
let (id, params) = id_of(&action);
|
||||
assert!(!seen.contains(&(id.clone(), params.clone())), "two actions share `{id}` {params:?}");
|
||||
assert_eq!(entry_of(&id, params.as_ref()), Some(Entry::File(action)), "`{id}` {params:?}");
|
||||
seen.push((id, params));
|
||||
}
|
||||
for h in HELP {
|
||||
assert_eq!(entry_of(HELP_ID, Some(&json!({ "item": h }))), Some(Entry::Help(h)));
|
||||
}
|
||||
// The control channel's ids where it has one.
|
||||
assert_eq!(id_of(&FileAction::Quit), ("app.quit".to_string(), None));
|
||||
assert_eq!(id_of(&FileAction::Settings), ("ui.prefs".to_string(), Some(json!({"open": "General"}))));
|
||||
assert_eq!(id_of(&FileAction::Shortcuts), ("ui.prefs".to_string(), Some(json!({"open": "Shortcuts"}))));
|
||||
assert_eq!(id_of(&FileAction::AgentChat), ("ui.agent_chat".to_string(), Some(json!({"toggle": true}))));
|
||||
assert_eq!(entry_of("ui.file.export", Some(&json!({"format": "step"}))), Some(Entry::File(FileAction::Export("step"))));
|
||||
for (id, params) in [
|
||||
("ui.file.export", None),
|
||||
("ui.file.export", Some(json!({"format": "dwg"}))),
|
||||
(HELP_ID, Some(json!({"item": "nothing"}))),
|
||||
(HELP_ID, None),
|
||||
("ui.prefs", None),
|
||||
("solid.nothing", None),
|
||||
(OPEN_RECENT, None),
|
||||
(RECENT, None),
|
||||
(native_menu::WINDOW, None),
|
||||
] {
|
||||
assert_eq!(entry_of(id, params.as_ref()), None, "{id} {params:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_shortcut_is_one_actions_and_the_menu_shows_it() {
|
||||
for (i, (action, key)) in SHORTCUTS.iter().enumerate() {
|
||||
assert!(SHORTCUTS.iter().skip(i + 1).all(|(a, k)| a != action && k != key), "{action:?} {key}");
|
||||
assert_eq!(item("label", action).shortcut, *key);
|
||||
}
|
||||
assert_eq!(item("label", &FileAction::SaveJson).shortcut, "");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn menu_shortcuts_parse_as_the_keys_they_were() {
|
||||
use muda::accelerator::{Accelerator, Code, Modifiers};
|
||||
let (cmd, shift) = (Modifiers::META, Modifiers::META | Modifiers::SHIFT);
|
||||
let want = [
|
||||
Accelerator::new(cmd, Code::Comma),
|
||||
Accelerator::new(cmd, Code::KeyQ),
|
||||
Accelerator::new(cmd, Code::KeyN),
|
||||
Accelerator::new(cmd, Code::KeyO),
|
||||
Accelerator::new(cmd, Code::KeyS),
|
||||
Accelerator::new(shift, Code::KeyS),
|
||||
Accelerator::new(cmd, Code::KeyZ),
|
||||
Accelerator::new(shift, Code::KeyZ),
|
||||
];
|
||||
for ((action, key), want) in SHORTCUTS.iter().zip(want) {
|
||||
assert_eq!(key.parse::<Accelerator>().ok(), Some(want), "{action:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_entry_of_the_menu_bar_runs_its_action() {
|
||||
fn leaves(items: &[Item], out: &mut Vec<Item>) {
|
||||
for item in items {
|
||||
if item.children.is_empty() {
|
||||
out.push(item.clone());
|
||||
} else {
|
||||
leaves(&item.children, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
let app = SolveApp::new(solidcraft_engine::Session::default(), Default::default());
|
||||
let bar = menu_bar(&app, &["/work/a.solidcraft".to_string()]);
|
||||
let mut entries = vec![];
|
||||
leaves(&bar.app, &mut entries);
|
||||
leaves(std::slice::from_ref(&bar.quit), &mut entries);
|
||||
leaves(&bar.menus, &mut entries);
|
||||
entries.retain(|i| i.id != "-");
|
||||
// App menu 2 + Quit; File: New, Open, 1 recent, Clear, 3 inserts, Save, Save As, JSON,
|
||||
// 7 exports, Properties; Edit 2; Window 1; Help 7.
|
||||
assert_eq!(entries.len(), 3 + 4 + 3 + 3 + 7 + 1 + 2 + 1 + 7, "{:?}", entries.iter().map(|i| &i.id).collect::<Vec<_>>());
|
||||
for item in &entries {
|
||||
assert!(entry_of(&item.id, item.params.as_ref()).is_some(), "`{}` {:?} runs nothing", item.id, item.params);
|
||||
}
|
||||
assert!(bar.menus.iter().all(|m| m.id.starts_with("menu.")), "menu ids are never actions");
|
||||
}
|
||||
|
||||
/// The entries whose ids are control actions do over the control channel what they do in
|
||||
/// the menu.
|
||||
#[test]
|
||||
fn shared_ids_mean_the_same_over_the_control_channel() {
|
||||
use solidcraft_ui_egui::scenario::Harness;
|
||||
let mut h = Harness::new();
|
||||
h.app.preferences.ai_features = true;
|
||||
h.app.agent_chat.open = false;
|
||||
let (id, params) = id_of(&FileAction::AgentChat);
|
||||
for open in [true, false] {
|
||||
let out = h.call(&id, params.clone().unwrap_or_default());
|
||||
assert_eq!(out["ok"], true, "{out}");
|
||||
assert_eq!(h.app.agent_chat.open, open, "Agent Chat toggles as in the menu");
|
||||
}
|
||||
for action in [FileAction::Settings, FileAction::Shortcuts] {
|
||||
let (id, params) = id_of(&action);
|
||||
let out = h.call(&id, params.unwrap_or_default());
|
||||
assert_eq!(out["ok"], true, "{action:?}: {out}");
|
||||
}
|
||||
// install_cli links into the system; its menu and control paths are the same `help::run`.
|
||||
for item in HELP.into_iter().filter(|h| *h != "install_cli") {
|
||||
let out = h.call(HELP_ID, json!({ "item": item }));
|
||||
assert_eq!(out["ok"], true, "{item}: {out}");
|
||||
}
|
||||
assert_eq!(h.call(HELP_ID, json!({"item": "nothing"}))["ok"], false, "an unknown help item is an error");
|
||||
let out = h.call(&id_of(&FileAction::Quit).0, json!({}));
|
||||
assert_eq!((out["ok"].clone(), h.app.quit_requested), (json!(true), true), "{out}");
|
||||
impl Drop for NativeMenu {
|
||||
fn drop(&mut self) {
|
||||
MenuEvent::set_event_handler(None::<fn(MenuEvent)>);
|
||||
self.menu.remove_for_nsapp();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1250,10 +1250,10 @@
|
||||
"cmd.panel.resize_pair": "تحريك فاصل اللوحة",
|
||||
"cmd.panel.sash.down": "تحريك فاصل اللوحة لأسفل",
|
||||
"cmd.panel.sash.left": "تحريك فاصل اللوحة لليسار",
|
||||
"cmd.panel.sash.reset": "إعادة تعيين فاصل اللوحة المحدد",
|
||||
"cmd.panel.sash.reset": "إعادة ضبط فاصل اللوحة التي عليها التركيز",
|
||||
"cmd.panel.sash.right": "تحريك فاصل اللوحة لليمين",
|
||||
"cmd.panel.sash.up": "تحريك فاصل اللوحة لأعلى",
|
||||
"cmd.panel.toggle": "تبديل طي اللوحة المحددة",
|
||||
"cmd.panel.toggle": "طي اللوحة التي عليها التركيز أو توسيعها",
|
||||
"cmd.parameters_add": "إضافة معامل مستخدم",
|
||||
"cmd.parameters_change": "تغيير المعاملات",
|
||||
"cmd.parameters_comment": "التعليق على المعامل",
|
||||
@@ -1510,7 +1510,7 @@
|
||||
"cmd.timeline_suppress": "تعطيل الميزة",
|
||||
"cmd.ui.assistant": "إظهار المساعد أو إخفاؤه",
|
||||
"cmd.view.navigator": "متصفح العرض المخصص",
|
||||
"cmd.view.render_browser": "متصفح التصيير",
|
||||
"cmd.view.render_browser": "مستعرض التصيير",
|
||||
"cmd.view.render_dock": "نقل لوحة التصيير",
|
||||
"cmd.view.render_focus": "عرض تركيز التصيير",
|
||||
"cmd.view.render_live": "إيقاف التصيير المباشر مؤقتًا أو استئنافه",
|
||||
@@ -2853,7 +2853,7 @@
|
||||
"prefs.select_other_hidden_help": "عند التفعيل: تضمين الأجسام والرسومات المخفية والحواف المحجوبة قرب المؤشر. عند التعطيل: استخدام القائمة الأصلية للعناصر الهندسية المرئية أسفل المؤشر.",
|
||||
"prefs.selecting": "التحديد",
|
||||
"prefs.shared_panel_layout": "استخدام تخطيط اللوحات المشترك",
|
||||
"prefs.shared_panel_layout_help": "استخدم المتصفح والسجل والمساعد داخل لوحات قابلة للنقل. يسري التغيير بعد تطبيق التفضيلات.",
|
||||
"prefs.shared_panel_layout_help": "اعرض المستعرض والسجل والمساعد الحالية في لوحات قابلة للنقل. يسري التغيير عند تطبيق التفضيلات.",
|
||||
"prefs.shift": "Shift",
|
||||
"prefs.shift_add_ctrl_toggle": "Shift يضيف فقط، وCtrl / Cmd يبدّل",
|
||||
"prefs.shift_add_note": "عند التشغيل: Shift يضيف، وCtrl / Cmd يبدّل. عند الإيقاف (الافتراضي): كلاهما يبدّل.",
|
||||
@@ -3614,7 +3614,7 @@
|
||||
"render.flare_streak": "خط ضوئي بأسلوب أنامورفي",
|
||||
"render.flare_strength": "شدة وهج الإضاءات العالية",
|
||||
"render.flare_threshold": "عتبة الوهج الخطية للمشهد",
|
||||
"render.fold_browser": "طيّ المتصفح",
|
||||
"render.fold_browser": "طيّ المستعرض",
|
||||
"render.frame_aspect": "النسبة",
|
||||
"render.frame_centre_mark": "علامة المركز",
|
||||
"render.frame_copy_limit": "لا يوجد اسم متاح للقطة مكررة",
|
||||
@@ -3844,7 +3844,7 @@
|
||||
"render.light_gpu_punctual": "الأضواء النقطية والكشّافة متاحة مع تتبّع الأشعة على المعالج.",
|
||||
"render.light_hard_shadow": "لهذا الضوء ظلال حادة؛ نعومة المصدر غير متاحة.",
|
||||
"render.light_intensity": "الشدة",
|
||||
"render.light_missing": "هذا الضوء لم يعد موجودًا. اختر ضوءًا في المتصفح.",
|
||||
"render.light_missing": "هذا الضوء لم يعد موجودًا. اختر ضوءًا في المستعرض.",
|
||||
"render.light_place_highlight": "وضع بالتمييز",
|
||||
"render.light_place_pending": "الوضع بالتمييز يتطلب اختيار الأضواء في منطقة العرض وهو غير مدعوم بعد.",
|
||||
"render.light_position": "الموضع",
|
||||
@@ -4457,7 +4457,7 @@
|
||||
"render.transmission_strata_help": "القيمة 1 تحافظ على التدفق الأصلي. 2 أو 4 مسارات فرعية تقسّم أول اختيار انعكاس أو نفاذ في الزجاج إلى طبقات مع بقاء قانوني سنل والامتصاص دون تغيير. المزيد من المسارات يكلف عملًا أكثر؛ وهذا ليس إزالة تشويش ولا دعمًا للأوساط المتداخلة ولا وعدًا بتصيير أسرع.",
|
||||
"render.tree_add_light": "إضافة ضوء",
|
||||
"render.tree_add_shot": "إضافة لقطة",
|
||||
"render.tree_browser": "متصفح التصيير",
|
||||
"render.tree_browser": "مستعرض التصيير",
|
||||
"render.tree_by_look": "حسب المظهر",
|
||||
"render.tree_by_object": "حسب الكائن",
|
||||
"render.tree_constant": "بيئة ثابتة",
|
||||
@@ -5889,7 +5889,7 @@
|
||||
"ui.project": "إسقاط",
|
||||
"ui.project_face_edges_sketch": "إسقاط حواف الوجه في رسومات جديدة",
|
||||
"ui.project_references_construction": "إسقاط المراجع كهندسة مساعدة",
|
||||
"ui.project_references_construction_note": "ينشئ الإسقاط والتقاطع وتضمين الهندسة ثلاثية الأبعاد وحواف الوجه لرسم جديد على وجه هندسةً مساعدة: مراجع للرسم عليها لا تدخل في المقاطع. وتبقى مرتبطة بالنموذج.",
|
||||
"ui.project_references_construction_note": "تصبح الهندسة التي تنشئها أوامر «إسقاط» و«تقاطع» و«تضمين هندسة ثلاثية الأبعاد»، وحواف الوجه في رسم جديد على وجه، هندسةً مساعدة: مراجع للرسم عليها لا تدخل في المقاطع. وتبقى مرتبطة بالنموذج.",
|
||||
"ui.properties": "الخصائص",
|
||||
"ui.properties.copy_matrix": "نسخ المصفوفة",
|
||||
"ui.properties.first_moments": "العزوم الأولى",
|
||||
|
||||
+624
-6
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::persistent_name::{Base, FaceName, FeatureId};
|
||||
use crate::persistent_name::{Base, EdgeName, FaceName, FeatureId};
|
||||
use crate::{FeatureKind, PlaneRef};
|
||||
|
||||
/// Remap persistent topology tokens without rewriting arbitrary user expressions or labels.
|
||||
@@ -18,9 +18,20 @@ pub fn remap_topology(name: &str, ids: &BTreeMap<u64, u64>, bodies: &BTreeMap<St
|
||||
}
|
||||
// Imported names belong to the complete canonical body prefix, even when that prefix
|
||||
// happens to contain text resembling a feature token.
|
||||
if let Some((body, suffix)) = name.rsplit_once(":face:")
|
||||
&& let Some(new) = bodies.get(body)
|
||||
let body_face = name.rsplit_once(":face:").and_then(|(body, suffix)| Some((bodies.get(body)?, body, suffix)));
|
||||
// An edge name (`A|B`) is remapped one face at a time, so a body face on one side doesn't
|
||||
// stop the other side's feature or body from being remapped (#13097). A body whose own
|
||||
// name holds a `|` is a single face, not an edge.
|
||||
if !body_face.is_some_and(|(_, body, _)| body.contains('|'))
|
||||
&& let Ok(edge) = EdgeName::parse(name)
|
||||
&& edge.faces.len() > 1
|
||||
&& edge.to_string() == name
|
||||
{
|
||||
let faces: Vec<String> = edge.faces.iter().map(|face| remap_topology(&face.to_string(), ids, bodies)).collect();
|
||||
let tie = edge.tie.map(|k| format!("~{k}")).unwrap_or_default();
|
||||
return format!("{}{tie}", faces.join("|"));
|
||||
}
|
||||
if let Some((new, _, suffix)) = body_face {
|
||||
return format!("{new}:face:{suffix}");
|
||||
}
|
||||
// A name that reads is remapped by its structure: every feature it names, and a body
|
||||
@@ -410,6 +421,19 @@ mod clone_tests {
|
||||
assert_eq!(remap_topology("UserF1:label", &ids, &bodies), "UserF1:label");
|
||||
}
|
||||
#[test]
|
||||
fn an_edge_name_with_a_body_face_maps_every_face() {
|
||||
// #13097: a body face on either side of an edge must not stop the other side's
|
||||
// feature or body from being remapped.
|
||||
let ids: BTreeMap<u64, u64> = [(2, 20)].into_iter().collect();
|
||||
let bodies: BTreeMap<String, String> =
|
||||
[("Plate".to_string(), "Plate (copy)".to_string()), ("A|B".to_string(), "A|B (copy)".to_string())].into_iter().collect();
|
||||
assert_eq!(remap_topology("Plate:face:3|F2:top", &ids, &bodies), "Plate (copy):face:3|F20:top");
|
||||
assert_eq!(remap_topology("F2:top|Plate:face:3", &ids, &bodies), "F20:top|Plate (copy):face:3");
|
||||
assert_eq!(remap_topology("Plate:face:3|F2:top~1", &ids, &bodies), "Plate (copy):face:3|F20:top~1");
|
||||
// A body whose name holds a `|` is still one face.
|
||||
assert_eq!(remap_topology("A|B:face:0", &ids, &bodies), "A|B (copy):face:0");
|
||||
}
|
||||
#[test]
|
||||
fn a_sheet_face_follows_its_body_and_its_flange() {
|
||||
let ids = BTreeMap::from([(1, 2), (10, 20)]);
|
||||
let bodies = BTreeMap::from([("Body1".into(), "Body1 (copy)".into())]);
|
||||
|
||||
@@ -176,7 +176,12 @@ mod tests {
|
||||
let sag = mesh
|
||||
.triangles
|
||||
.iter()
|
||||
.map(|t| t.map(|i| mesh.positions[i as usize]))
|
||||
.zip(&mesh.tri_face)
|
||||
// The side wall's own triangles: the flat caps have corners on the rim too.
|
||||
.filter(|(_, f)| {
|
||||
matches!(solidcraft_kernel::face_surface(&mb.body, **f as usize), Some(solidcraft_kernel::FaceSurface::Cylinder { .. }))
|
||||
})
|
||||
.map(|(t, _)| t.map(|i| mesh.positions[i as usize]))
|
||||
.filter(|t| t.iter().all(|p| (r(*p) - 20.0).abs() < 1e-9))
|
||||
.map(|[a, b, c]| 20.0 - r((a + b + c) * (1.0 / 3.0)))
|
||||
.fold(0.0, f64::max);
|
||||
|
||||
@@ -149,8 +149,14 @@ fn resolve_uncached(
|
||||
LinkKind::Intersect => section(plane, &mesh, Some(f)),
|
||||
_ => {
|
||||
let target = st.bodies.get(bi).ok_or_else(|| lost("the projection body"))?;
|
||||
let g = projected_edges(target, &mesh, plane, &projection_face_edges(target, &mesh, f, plane)?);
|
||||
if certified_planar_construction(target, f as usize) { merge_family_lines(g)? } else { g }
|
||||
// A mesh body is one face with no edges: the flat patch of triangles picked,
|
||||
// traced (#13464).
|
||||
if target.body.is_mesh() {
|
||||
crate::project3d::flat_outline(&mesh, q).iter().flat_map(|outline| fit_mesh_outline(plane, outline)).collect()
|
||||
} else {
|
||||
let g = projected_edges(target, &mesh, plane, &projection_face_edges(target, &mesh, f, plane)?);
|
||||
if certified_planar_construction(target, f as usize) { merge_family_lines(g)? } else { g }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1308,8 +1314,8 @@ fn project_sketch_curve(sk: &Sketch, from: &Plane, ci: usize, to: &Plane) -> Vec
|
||||
}
|
||||
|
||||
/// A circle in space (centre, unit normal, radius) through the polyline's points, and whether
|
||||
/// the polyline closes.
|
||||
fn circle3(pts: &[Vec3]) -> Option<(Vec3, Vec3, f64, bool)> {
|
||||
/// the polyline closes. Its points lie on it within `within`, by default 1e-7 of the radius.
|
||||
fn circle3(pts: &[Vec3], within: Option<f64>) -> Option<(Vec3, Vec3, f64, bool)> {
|
||||
let n = pts.len();
|
||||
if n < 4 {
|
||||
return None;
|
||||
@@ -1326,15 +1332,15 @@ fn circle3(pts: &[Vec3]) -> Option<(Vec3, Vec3, f64, bool)> {
|
||||
let centre = a + (nrm.cross(ab) * ac.len2() + ac.cross(nrm) * ab.len2()) / (2.0 * l2);
|
||||
let r = centre.dist(a);
|
||||
let un = nrm.normalized()?;
|
||||
let tol = 1e-7 * r.max(1.0);
|
||||
let tol = within.unwrap_or(1e-7 * r.max(1.0));
|
||||
pts.iter().all(|p| (p.dist(centre) - r).abs() < tol && (*p - centre).dot(un).abs() < tol).then_some((centre, un, r, closed))
|
||||
}
|
||||
|
||||
/// A circle seen at a slant is an ellipse, exactly: a full circle becomes an ellipse; an arc
|
||||
/// becomes conics (rational quadratic Béziers of at most 90 degrees each — parallel projection
|
||||
/// keeps their weights).
|
||||
fn tilted_circle(plane: &Plane, pts: &[Vec3]) -> Option<Vec<LinkGeom>> {
|
||||
let (c, n, r, closed) = circle3(pts)?;
|
||||
fn tilted_circle(plane: &Plane, pts: &[Vec3], within: Option<f64>) -> Option<Vec<LinkGeom>> {
|
||||
let (c, n, r, closed) = circle3(pts, within)?;
|
||||
let sn = plane.normal();
|
||||
let cosang = n.dot(sn).abs();
|
||||
if !(1e-9..=1.0 - 1e-12).contains(&cosang) {
|
||||
@@ -1429,9 +1435,31 @@ fn plane_trace(sk: &Plane, other: &Plane) -> Option<LinkGeom> {
|
||||
Some(LinkGeom::Line(p0 - u * AXIS_HALF, p0 + u * AXIS_HALF))
|
||||
}
|
||||
|
||||
/// Most corners a mesh face's outline has and is still taken for a polygon.
|
||||
const MESH_POLYGON_CORNERS: usize = 12;
|
||||
|
||||
/// The outline of a mesh body's flat patch (a closed loop of its corners) as lines and arcs. Any
|
||||
/// triangle's corners and any rectangle's lie on one circle, so only a loop of more corners than
|
||||
/// [`MESH_POLYGON_CORNERS`], all on one circle, is a tessellated circle, an ellipse on a slanted
|
||||
/// plane; and the sides of a polygon stay lines, never one fitted spline (#13464). A mesh file
|
||||
/// keeps its corners in single precision, so they lie on the circle within about 1e-7 of their
|
||||
/// size, not of the circle's.
|
||||
fn fit_mesh_outline(plane: &Plane, pts: &[Vec3]) -> Vec<LinkGeom> {
|
||||
let within = pts.iter().map(|p| p.len()).fold(1.0, f64::max) * 1e-6;
|
||||
if pts.len() > MESH_POLYGON_CORNERS + 1
|
||||
&& let Some(e) = tilted_circle(plane, pts, Some(within))
|
||||
{
|
||||
return e;
|
||||
}
|
||||
let p2: Vec<Vec2> = pts.iter().map(|p| plane.to_local(*p)).collect();
|
||||
let mut out = Vec::new();
|
||||
fit_chain(&p2, None, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
/// Project a world polyline and fit lines and arcs to it.
|
||||
fn fit_world(plane: &Plane, pts: &[Vec3]) -> Vec<LinkGeom> {
|
||||
if let Some(e) = tilted_circle(plane, pts) {
|
||||
if let Some(e) = tilted_circle(plane, pts, None) {
|
||||
return e;
|
||||
}
|
||||
let p2: Vec<Vec2> = pts.iter().map(|p| plane.to_local(*p)).collect();
|
||||
|
||||
@@ -278,6 +278,134 @@ fn ray_hit(o: Vec3, d: Vec3, t: &[Vec3; 3]) -> Option<f64> {
|
||||
Some(e2.dot(q) * inv)
|
||||
}
|
||||
|
||||
/// Most triangles [`flat_outline`] looks through.
|
||||
const FLAT_OUTLINE_TRIANGLES: usize = 2_000_000;
|
||||
|
||||
/// The outline of the flat patch of a mesh body's triangles at `at`: the triangle nearest `at`
|
||||
/// and every triangle joined to it edge to edge on its plane, traced as closed loops, each with its
|
||||
/// first point again at its end. An imported mesh is one face, so this flat patch is what a pick
|
||||
/// on one of its faces means (#13464). Empty when the mesh has no triangle or is too large.
|
||||
pub(crate) fn flat_outline(m: &Mesh, at: Vec3) -> Vec<Vec<Vec3>> {
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
if m.triangles.is_empty() || m.triangles.len() > FLAT_OUTLINE_TRIANGLES {
|
||||
return Vec::new();
|
||||
}
|
||||
let size = m.bounds().diagonal().max(1e-9);
|
||||
// Corners at one place are one corner: an STL repeats each corner for every triangle.
|
||||
let weld = size * 1e-9;
|
||||
let mut ids: HashMap<(i64, i64, i64), usize> = HashMap::new();
|
||||
let mut corners: Vec<Vec3> = Vec::new();
|
||||
let mut tri: Vec<[usize; 3]> = Vec::with_capacity(m.triangles.len());
|
||||
for t in &m.triangles {
|
||||
let Some(ps) = m.tri(t) else { continue };
|
||||
let mut out = [0; 3];
|
||||
for (o, p) in out.iter_mut().zip(ps) {
|
||||
if !p.is_finite() {
|
||||
return Vec::new();
|
||||
}
|
||||
let key = ((p.x / weld).round() as i64, (p.y / weld).round() as i64, (p.z / weld).round() as i64);
|
||||
*o = *ids.entry(key).or_insert_with(|| {
|
||||
corners.push(p);
|
||||
corners.len() - 1
|
||||
});
|
||||
}
|
||||
tri.push(out);
|
||||
}
|
||||
let at_of = |t: &[usize; 3]| t.map(|i| corners.get(i).copied().unwrap_or_default());
|
||||
let normal = |t: &[usize; 3]| {
|
||||
let [a, b, c] = at_of(t);
|
||||
(b - a).cross(c - a).normalized()
|
||||
};
|
||||
// The triangle nearest the pick, and its plane.
|
||||
let Some(seed) = (0..tri.len()).filter(|i| tri.get(*i).and_then(normal).is_some()).min_by(|x, y| {
|
||||
let d = |i: &usize| {
|
||||
tri.get(*i).map_or(f64::INFINITY, |t| {
|
||||
let [a, b, c] = at_of(t);
|
||||
crate::project::closest_on_triangle(at, a, b, c).dist(at)
|
||||
})
|
||||
};
|
||||
d(x).total_cmp(&d(y))
|
||||
}) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some((n0, p0)) = tri.get(seed).and_then(|t| Some((normal(t)?, at_of(t)[0]))) else { return Vec::new() };
|
||||
let flat = |t: &[usize; 3]| normal(t).is_some_and(|n| n.dot(n0) > 1.0 - 1e-6) && at_of(t).iter().all(|p| (*p - p0).dot(n0).abs() <= size * 1e-6);
|
||||
// Triangles by the edges they share.
|
||||
let mut by_edge: HashMap<(usize, usize), Vec<usize>> = HashMap::new();
|
||||
for (i, t) in tri.iter().enumerate() {
|
||||
for k in 0..3 {
|
||||
let (a, b) = (t[k], t[(k + 1) % 3]);
|
||||
by_edge.entry((a.min(b), a.max(b))).or_default().push(i);
|
||||
}
|
||||
}
|
||||
// The flat patch: from the seed across shared edges onto triangles on its plane.
|
||||
let mut patch: HashSet<usize> = HashSet::from([seed]);
|
||||
let mut queue = VecDeque::from([seed]);
|
||||
while let Some(i) = queue.pop_front() {
|
||||
let Some(t) = tri.get(i) else { continue };
|
||||
for k in 0..3 {
|
||||
let (a, b) = (t[k], t[(k + 1) % 3]);
|
||||
for j in by_edge.get(&(a.min(b), a.max(b))).into_iter().flatten() {
|
||||
if !patch.contains(j) && tri.get(*j).is_some_and(flat) {
|
||||
patch.insert(*j);
|
||||
queue.push_back(*j);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Its outline: the edges only one of its triangles has, each the way its triangle runs.
|
||||
let mut next: HashMap<usize, Vec<usize>> = HashMap::new();
|
||||
for i in &patch {
|
||||
let Some(t) = tri.get(*i) else { continue };
|
||||
for k in 0..3 {
|
||||
let (a, b) = (t[k], t[(k + 1) % 3]);
|
||||
let inside = by_edge.get(&(a.min(b), a.max(b))).map_or(0, |ts| ts.iter().filter(|j| patch.contains(j)).count());
|
||||
if inside == 1 {
|
||||
next.entry(a).or_default().push(b);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut loops = Vec::new();
|
||||
let mut starts: Vec<usize> = next.keys().copied().collect();
|
||||
starts.sort_unstable();
|
||||
for start in starts {
|
||||
while let Some(mut v) = next.get_mut(&start).and_then(Vec::pop) {
|
||||
let mut pts: Vec<Vec3> = [start, v].iter().filter_map(|i| corners.get(*i).copied()).collect();
|
||||
let mut steps = 0;
|
||||
while v != start && steps < patch.len() * 3 {
|
||||
let Some(w) = next.get_mut(&v).and_then(Vec::pop) else { break };
|
||||
pts.extend(corners.get(w).copied());
|
||||
v = w;
|
||||
steps += 1;
|
||||
}
|
||||
if v == start && pts.len() >= 4 {
|
||||
loops.extend(turning_corners(&pts, size * 1e-9));
|
||||
}
|
||||
}
|
||||
}
|
||||
loops
|
||||
}
|
||||
|
||||
/// A closed loop (its first point again at its end) without the corners where it runs straight
|
||||
/// on: a mesh's outline often has a corner part way along a side, where a triangle of the patch
|
||||
/// meets it. None when fewer than three corners turn.
|
||||
fn turning_corners(pts: &[Vec3], tol: f64) -> Option<Vec<Vec3>> {
|
||||
let ring = pts.get(..pts.len().saturating_sub(1))?;
|
||||
let n = ring.len();
|
||||
let turns = |i: usize| {
|
||||
let (prev, here, next) = (ring.get((i + n - 1) % n), ring.get(i), ring.get((i + 1) % n));
|
||||
let (Some(prev), Some(here), Some(next)) = (prev, here, next) else { return false };
|
||||
let (a, b) = (*here - *prev, *next - *here);
|
||||
a.cross(b).len() > tol * a.len().max(b.len()).max(1.0)
|
||||
};
|
||||
let mut out: Vec<Vec3> = (0..n).filter(|i| turns(*i)).filter_map(|i| ring.get(i).copied()).collect();
|
||||
if out.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
out.extend(out.first().copied());
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Project a world polyline onto face `f` along `dir` (either way, nearest hit). Points that
|
||||
/// miss the face split the result.
|
||||
pub(crate) fn onto_face(m: &Mesh, f: u32, pts: &[Vec3], dir: Vec3) -> Vec<Vec<Vec3>> {
|
||||
|
||||
@@ -12,6 +12,7 @@ use solidcraft_geom::prelude::*;
|
||||
|
||||
use super::{CommandSpec, not_in_sketch};
|
||||
use crate::params::{UNKNOWN_COMMAND, bad, bool_, expr, expr_problem, req_expr, str_, string_list, string_list_or_default, vec2, vec3};
|
||||
use crate::schema::features::SweepParams;
|
||||
use crate::schema::features::{
|
||||
AppliedRadius, BoundingSolidParams, BoxParams, ChamferParams, CombineParams, CombineResult, ConeParams, CylinderParams, ExtrudeParams,
|
||||
FeatureNotice, FeatureResult, FilletParams, FilletResult, NoticeAction, OffsetPlaneParams, PipeParams, PlaneAtAngleParams, ScaleParams,
|
||||
@@ -27,6 +28,7 @@ const SPHERE_CONTRACT: TypedContract<SphereParams> = TypedContract::new();
|
||||
const TORUS_CONTRACT: TypedContract<TorusParams> = TypedContract::new();
|
||||
const CONE_CONTRACT: TypedContract<ConeParams> = TypedContract::new();
|
||||
const PIPE_CONTRACT: TypedContract<PipeParams> = TypedContract::new();
|
||||
const SWEEP_CONTRACT: TypedContract<SweepParams> = TypedContract::new();
|
||||
const BOUNDING_SOLID_CONTRACT: TypedContract<BoundingSolidParams> = TypedContract::new();
|
||||
const SCALE_CONTRACT: TypedContract<ScaleParams> = TypedContract::new();
|
||||
const COMBINE_CONTRACT: TypedContract<CombineParams> = TypedContract::new();
|
||||
@@ -47,9 +49,11 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("revolve")
|
||||
.params("axis: sketch line id | x | y (sketch axes) | X|Y|Z (world) | {type: world, axis: X|Y|Z} (as returned by timeline.inspect) | construction axis name (followed when edited) | {origin, dir} (origin: numbers or length expressions, followed when parameters change) | {edge|face: [x,y,z]} (a straight or circular edge, or a cylinder, cone or torus face: about its axis, followed as the model changes); angle?: expr (default 360 deg); angle2?: expr (Two Sides: the other side's angle); direction?: symmetric (angle split either side, independent of axis direction); convention?: sketch_normal|legacy_axis_start (default sketch_normal; legacy retains older angular starts); to?: [x,y,z] (To Object: turn until that face, vertex or point); sketch?, profiles? | face: [x,y,z] (a planar body face as the profile) and face_body?: name (whose face, where faces of several bodies meet), operation?, touching?, targets?, name?, body_name?"),
|
||||
CommandSpec::new("solid.sweep", "Sweep", sweep)
|
||||
CommandSpec::new("solid.sweep", "Sweep", crate::checked!("solid.sweep", sweep))
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("sweep")
|
||||
.contract(&SWEEP_CONTRACT)
|
||||
.output(output_schema_for::<FeatureResult>)
|
||||
.params("sketch: profile sketch; profiles?; path_sketch: sketch; path: [curve ids in order]; orientation?: perpendicular (default: square to the path, turning with it) | parallel (as drawn: the profile moves without turning; the path must keep crossing its plane) | [x,y,z] (square to the path, its turn about the path fixed by this direction); twist?: angle expr (the profile turns this much about the path over its length; within 4 turns); scale?: expr (the profile's scale at the end, linear along the path; 0.01…100); rails?: [{sketch?: sketch (default path_sketch), curves: [curve ids in order]}] (guide rails, one or two, each meeting every plane square to the path once along it, from the end nearest the path's start: one rail turns the profile so its point where the rail starts stays on the rail and scales it with the rail's distance from the path; two rails carry it between them, its points where they start staying on both; rails take the default orientation, no twist and no scale, and a path without sharp corners); rail_fit?: scale (default: the profile grows and shrinks evenly) | stretch (only across to the rail, between the rails); operation?, touching?, targets?, name?, body_name?"),
|
||||
CommandSpec::new("solid.loft", "Loft", loft).at("SOLID", "CREATE").icon("loft").params("sections: [{sketch, profiles?} | {sketch, point: id} (an apex)] (2…50, in order; matching hole counts, no holes at an apex); rails?: [{sketch, curves: [ids in order]}] (one or two guide rails from the first section to the last, through every section's outline: the loft follows them between the sections; not with sections that have holes); rail_fit?: scale|stretch (default scale: with one rail the sections grow evenly with the rail's distance from their middle, with two with the distance between the rails; stretch: only that way); operation?, touching?, targets? (Join excludes hidden bodies from automatic targets; explicit targets or participants may include them), name?, body_name?"),
|
||||
CommandSpec::new("solid.box", "Box", crate::checked!("solid.box", prim_box))
|
||||
@@ -118,7 +122,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("hole")
|
||||
.key("H")
|
||||
.params("position: [x,y,z] on a face (numbers, or length expressions the hole follows) | sketch + points: [sketch point ids] (one hole each, perpendicular to the sketch, into the material on whichever side of the sketch plane it is) | sketch alone (every sketch point: standalone points and circle centres); flip?: bool (sketch points: drill to the other side); single?: bool (with one sketch point: a Single hole that follows the point, drilled into the face there); direction?: [x,y,z] (default: into the face, against its outward normal; where faces of several bodies meet there, the face of a body in `targets`; one that leaves the face is mirrored into it; ignored with sketch points unless single); diameter | thread (a tapped hole, drilled with the tap maker's tabulated drill: ISO metric (a custom metric size: major minus pitch), UNC/UNF, G, Rp, Rc 1/16 to 4, NPT 1/16 to 3; a `diameter` given with it chooses another drill, from the thread's minor diameter to 5% over the tap maker's) | clearance: \"M6\" with fit?: close|normal|loose (ISO 273); depth? (default through all) | to?: [x,y,z] (To Object: drill to that face or point); references?: [{edge: [x,y,z] on a straight edge of the face, distance: expr}] (at most two; the centre goes that far from each edge's line, in the face's plane, and follows the edges; with a numeric position only); type?: simple|drilled|counterbore|countersink; tip_angle?; cb_diameter?, cb_depth?, bridge?: length (counterbore: two 3D-printing bridge layers this thick below its floor, a slot along X then a square; e.g. 0.2 mm); cs_diameter?, cs_angle?; thread?: \"M6\", \"1/4-20 UNC\", \"G 1/4\", \"Rp 1/4\", \"Rc 1/4\" or \"1/4-18 NPT\" (cosmetic unless modeled); hand?: right|left (the tapped thread's hand; default right; needs thread); thread_depth?: positive length independent of drill depth (default: full useful cylindrical wall; must fit that wall); modeled?: bool (needs thread: the tapped thread cut into the wall, the groove between its teeth, as solid.thread's modelled one; default false: listed only); ease_out?: expr|standard|short (modelled tapped threads: the inner end, towards the bottom, fades out over this length, ISO 3508's runout, standard 2.5 P, short 1.25 P; without it the groove stops square at thread_depth, or clear of a blind hole's bottom); targets?: [body] (the bodies to drill; holes at the points of a sketch on a body's face default to that body, found along the holes' direction on it alone; explicit targets or participants override, [\"*\"] every body)"),
|
||||
.params("position: [x,y,z] on a face (numbers, or length expressions the hole follows) | sketch + points: [sketch point ids] (one hole each, perpendicular to the sketch, into the material on whichever side of the sketch plane it is) | sketch alone (every sketch point: standalone points and circle centres); flip?: bool (sketch points: drill to the other side); single?: bool (with one sketch point: a Single hole that follows the point, drilled into the face there); direction?: [x,y,z] (default: into the face, against its outward normal; where faces of several bodies meet there, the face of a body in `targets`; one that leaves the face is mirrored into it; ignored with sketch points unless single); diameter | thread (a tapped hole, drilled with the tap maker's tabulated drill: ISO metric (a custom metric size: major minus pitch), UNC/UNF, G, Rp, Rc 1/16 to 4, NPT 1/16 to 3; a `diameter` given with it chooses another drill, from the thread's minor diameter to 5% over the tap maker's) | clearance: \"M6\" with fit?: close|normal|loose (ISO 273); head?: \"ISO 4762\" (socket head) | \"ISO 7380-1\" (button head) (with clearance: a counterbore the screw's head sits in, so type defaults to counterbore; its diameter is the head's dk plus head_gap each side, its depth the head's k plus head_gap; a cb_diameter or cb_depth given keeps its value); head_gap?: length (default 0.5 mm; an expression with parameters is kept, so the recess follows them); depth? (default through all) | to?: [x,y,z] (To Object: drill to that face or point); references?: [{edge: [x,y,z] on a straight edge of the face, distance: expr}] (at most two; the centre goes that far from each edge's line, in the face's plane, and follows the edges; with a numeric position only); type?: simple|drilled|counterbore|countersink; tip_angle?; cb_diameter?, cb_depth?, bridge?: length (counterbore: two 3D-printing bridge layers this thick below its floor, a slot along X then a square; e.g. 0.2 mm); cs_diameter?, cs_angle?; thread?: \"M6\", \"1/4-20 UNC\", \"G 1/4\", \"Rp 1/4\", \"Rc 1/4\" or \"1/4-18 NPT\" (cosmetic unless modeled); hand?: right|left (the tapped thread's hand; default right; needs thread); thread_depth?: positive length independent of drill depth (default: full useful cylindrical wall; must fit that wall); modeled?: bool (needs thread: the tapped thread cut into the wall, the groove between its teeth, as solid.thread's modelled one; default false: listed only); ease_out?: expr|standard|short (modelled tapped threads: the inner end, towards the bottom, fades out over this length, ISO 3508's runout, standard 2.5 P, short 1.25 P; without it the groove stops square at thread_depth, or clear of a blind hole's bottom); targets?: [body] (the bodies to drill; holes at the points of a sketch on a body's face default to that body, found along the holes' direction on it alone; explicit targets or participants override, [\"*\"] every body)"),
|
||||
CommandSpec::new("solid.pipe", "Pipe", crate::checked!("solid.pipe", pipe))
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("pipe")
|
||||
@@ -2947,7 +2951,7 @@ fn split_body(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CLEARANCE_FITS: [&str; 6] = ["close", "fine", "normal", "medium", "loose", "coarse"];
|
||||
|
||||
/// A value typed by the user, cut short for a message.
|
||||
fn brief(text: &str) -> String {
|
||||
pub(super) fn brief(text: &str) -> String {
|
||||
const MAX: usize = 32;
|
||||
match text.char_indices().nth(MAX) {
|
||||
Some((end, _)) => format!("{}…", &text[..end]),
|
||||
@@ -2991,6 +2995,35 @@ fn clearance_diameter(size: &str, fit: &str) -> Option<f64> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The counterbore a cap screw's head sits in (`head` with `clearance`): its diameter and depth.
|
||||
/// The head's dk and k come from the standard's table (ISO 4762 socket heads, ISO 7380-1 button
|
||||
/// heads, in `parts`), with `head_gap` (default 0.5 mm) added each side and below. A gap that
|
||||
/// names parameters is kept as an expression, so the recess follows them. `None` without `head`.
|
||||
fn head_recess(s: &Session, cmd: &str, p: &Value) -> Result<Option<(Expr, Expr)>> {
|
||||
let head = match p.get("head") {
|
||||
None | Some(Value::Null) => return Ok(None),
|
||||
Some(Value::String(h)) => h.trim(),
|
||||
Some(_) => return Err(bad(cmd, "`head` must be a text: `ISO 4762` (socket head) or `ISO 7380-1` (button head)")),
|
||||
};
|
||||
let size = str_(p, "clearance").ok_or_else(|| bad(cmd, "`head` needs `clearance`, the screw size the head belongs to (e.g. \"M4\")"))?;
|
||||
let (dk, k) = super::parts::cap_screw_head(head, size).map_err(|e| bad(cmd, e))?;
|
||||
let gap = match (expr(p, "head_gap"), p.get("head_gap")) {
|
||||
(Some(g), _) => g,
|
||||
(None, None | Some(Value::Null)) => Expr::new("0.5 mm"),
|
||||
(None, v) => return Err(bad(cmd, format!("`head_gap` {}", expr_problem(v)))),
|
||||
};
|
||||
let g = s.doc.eval(gap.as_str(), Kind::Length).map_err(|e| bad(cmd, format!("head_gap: {e}")))?;
|
||||
if !(0.0..=dk).contains(&g) {
|
||||
return Err(bad(cmd, format!("`head_gap` must be 0 to {dk} mm (the head's diameter), not {g}")));
|
||||
}
|
||||
let mm = |x: f64| format!("{} mm", (x * 1e6).round() / 1e6);
|
||||
Ok(Some(if solidcraft_doc::expr::references(gap.as_str()).is_empty() {
|
||||
(Expr::new(mm(dk + 2.0 * g)), Expr::new(mm(k + g)))
|
||||
} else {
|
||||
(Expr::new(format!("{dk} mm + 2 * ({})", gap.as_str())), Expr::new(format!("{k} mm + ({})", gap.as_str())))
|
||||
}))
|
||||
}
|
||||
|
||||
/// The pitches to try for a plain `M<d>` that has no ISO coarse pitch, `None` for anything else.
|
||||
fn fine_only_hint(t: &str) -> Option<String> {
|
||||
let diameter = t.trim().strip_prefix(['M', 'm']).and_then(|d| d.trim().parse::<f64>().ok());
|
||||
@@ -3214,18 +3247,28 @@ fn hole(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
(None, None | Some(Value::Null)) => None,
|
||||
(None, v) => return Err(bad(cmd, format!("`bridge` {}", expr_problem(v)))),
|
||||
};
|
||||
let kind = match str_(p, "type").unwrap_or("simple") {
|
||||
// A counterbore sized to the screw's head: `head` with `clearance`.
|
||||
let head = head_recess(s, cmd, p)?;
|
||||
if head.is_some() && str_(p, "type").is_some_and(|t| t != "counterbore") {
|
||||
return Err(bad(cmd, "`head` needs `type: counterbore` (the default with `head`); a countersink for a flat head is not supported yet"));
|
||||
}
|
||||
let kind = match str_(p, "type").unwrap_or(if head.is_some() { "counterbore" } else { "simple" }) {
|
||||
"simple" => match expr(p, "tip_angle") {
|
||||
Some(t) if depth.is_some() => solidcraft_doc::HoleKind::Drilled { tip_angle: t },
|
||||
_ => solidcraft_doc::HoleKind::Simple,
|
||||
},
|
||||
"drilled" => solidcraft_doc::HoleKind::Drilled { tip_angle: expr(p, "tip_angle").unwrap_or_else(|| "118 deg".into()) },
|
||||
"counterbore" => solidcraft_doc::HoleKind::Counterbore {
|
||||
cb_diameter: get("cb_diameter")?,
|
||||
cb_depth: get("cb_depth")?,
|
||||
tip_angle: expr(p, "tip_angle"),
|
||||
bridge: bridge.clone(),
|
||||
},
|
||||
"counterbore" => {
|
||||
// Sized to the screw's head unless given; a value given, even a bad one, is checked.
|
||||
let given = |k: &str| !matches!(p.get(k), None | Some(Value::Null));
|
||||
let (cb_diameter, cb_depth) = match &head {
|
||||
Some((d, h)) => {
|
||||
(if given("cb_diameter") { get("cb_diameter")? } else { d.clone() }, if given("cb_depth") { get("cb_depth")? } else { h.clone() })
|
||||
}
|
||||
None => (get("cb_diameter")?, get("cb_depth")?),
|
||||
};
|
||||
solidcraft_doc::HoleKind::Counterbore { cb_diameter, cb_depth, tip_angle: expr(p, "tip_angle"), bridge: bridge.clone() }
|
||||
}
|
||||
"countersink" => solidcraft_doc::HoleKind::Countersink {
|
||||
cs_diameter: get("cs_diameter")?,
|
||||
// The default only when no angle is given: a supplied one out of range is refused (#9625).
|
||||
@@ -3522,17 +3565,7 @@ fn sweep(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
pub(super) fn sweep_options(s: &Session, p: &Value, cmd: &str) -> Result<(SweepOrientation, Option<Expr>, Option<Expr>)> {
|
||||
let orientation = match p.get("orientation") {
|
||||
None | Some(Value::Null) => SweepOrientation::Perpendicular,
|
||||
Some(Value::String(o)) if o.eq_ignore_ascii_case("perpendicular") => SweepOrientation::Perpendicular,
|
||||
Some(Value::String(o)) if o.eq_ignore_ascii_case("parallel") => SweepOrientation::Parallel,
|
||||
Some(v) => {
|
||||
let d = vec3(v)
|
||||
.or_else(|| v.get("direction").and_then(vec3))
|
||||
.ok_or_else(|| bad(cmd, "`orientation` must be perpendicular, parallel or a direction [x, y, z]"))?;
|
||||
if !(d.len() > 1e-9) {
|
||||
return Err(bad(cmd, "the `orientation` direction must not be zero"));
|
||||
}
|
||||
SweepOrientation::Direction(Dir(d))
|
||||
}
|
||||
Some(v) => sweep_orientation(v).map_err(|e| bad(cmd, e))?,
|
||||
};
|
||||
let twist = match p.get("twist") {
|
||||
None | Some(Value::Null) => None,
|
||||
@@ -3560,6 +3593,24 @@ pub(super) fn sweep_options(s: &Session, p: &Value, cmd: &str) -> Result<(SweepO
|
||||
Ok((orientation, twist, scale))
|
||||
}
|
||||
|
||||
/// A sweep's `orientation` as given (not `null`): perpendicular or parallel (any case), or a
|
||||
/// non-zero direction `[x, y, z]`, `{x, y, z}` or `{direction: …}`. The error is the message.
|
||||
pub(crate) fn sweep_orientation(v: &Value) -> std::result::Result<SweepOrientation, &'static str> {
|
||||
match v {
|
||||
Value::String(o) if o.eq_ignore_ascii_case("perpendicular") => Ok(SweepOrientation::Perpendicular),
|
||||
Value::String(o) if o.eq_ignore_ascii_case("parallel") => Ok(SweepOrientation::Parallel),
|
||||
v => {
|
||||
let d = vec3(v)
|
||||
.or_else(|| v.get("direction").and_then(vec3))
|
||||
.ok_or("`orientation` must be perpendicular, parallel or a direction [x, y, z]")?;
|
||||
if !(d.len() > 1e-9) {
|
||||
return Err("the `orientation` direction must not be zero");
|
||||
}
|
||||
Ok(SweepOrientation::Direction(Dir(d)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A sweep's guide `rails` and `rail_fit` (solid.sweep and surface.sweep), checked: one or two
|
||||
/// rails, each a sketch (default: the path's) and 1…1000 of its curves; a fit of scale or
|
||||
/// stretch; and no orientation, twist or scale besides, since the rails set those.
|
||||
@@ -3580,9 +3631,7 @@ pub(super) fn sweep_rails(s: &Session, p: &Value, cmd: &str, path_sketch: u64) -
|
||||
pub(super) fn guide_rails(s: &Session, p: &Value, cmd: &str, what: &str, default_sketch: Option<u64>) -> Result<(Vec<SweepRail>, RailFit)> {
|
||||
let rail_fit = match p.get("rail_fit") {
|
||||
None | Some(Value::Null) => RailFit::Scale,
|
||||
Some(Value::String(f)) if f.eq_ignore_ascii_case("scale") => RailFit::Scale,
|
||||
Some(Value::String(f)) if f.eq_ignore_ascii_case("stretch") => RailFit::Stretch,
|
||||
Some(_) => return Err(bad(cmd, "`rail_fit` must be scale or stretch")),
|
||||
Some(v) => rail_fit_form(v).map_err(|e| bad(cmd, e))?,
|
||||
};
|
||||
let given = match p.get("rails") {
|
||||
None | Some(Value::Null) => return Ok((Vec::new(), rail_fit)),
|
||||
@@ -3608,6 +3657,16 @@ pub(super) fn guide_rails(s: &Session, p: &Value, cmd: &str, what: &str, default
|
||||
Ok((rails, rail_fit))
|
||||
}
|
||||
|
||||
/// Guide rails' `rail_fit` as given (not `null`): scale or stretch, in any case. The error is
|
||||
/// the message.
|
||||
pub(crate) fn rail_fit_form(v: &Value) -> std::result::Result<RailFit, &'static str> {
|
||||
match v {
|
||||
Value::String(f) if f.eq_ignore_ascii_case("scale") => Ok(RailFit::Scale),
|
||||
Value::String(f) if f.eq_ignore_ascii_case("stretch") => Ok(RailFit::Stretch),
|
||||
_ => Err("`rail_fit` must be scale or stretch"),
|
||||
}
|
||||
}
|
||||
|
||||
fn loft(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "solid.loft";
|
||||
let list = p.get("sections").and_then(Value::as_array).ok_or_else(|| bad(cmd, "`sections` must be a list"))?;
|
||||
|
||||
@@ -23,6 +23,8 @@ use solidcraft_io::Format;
|
||||
use super::CommandSpec;
|
||||
use super::print_face;
|
||||
use crate::params::{bad, bool_, num, str_, string_list};
|
||||
use crate::schema::save::{SaveAsParams, SaveParams, SaveResult};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
@@ -40,15 +42,19 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.at("SOLID", "INSERT")
|
||||
.icon("import")
|
||||
.params("path: .3mf, .stl, .obj, .ply (with faces), .glb or .gltf file (its meshes join the design as mesh bodies in a new component named after the file → component, component_name, occurrence; a glTF file's node tree becomes nested components held as one rigid group until component.unlock, a mesh placed again another occurrence, its materials the bodies' appearances, and its metres millimetres); name?; up?: z|y for a glTF file (default y: glTF has Y up, so the import's component is turned a quarter turn about X, Y becoming Z → stand_up: its occurrence; z: the file's axes are kept); units?: mm|cm|m|in|ft for an STL or OBJ file, or a PLY file with faces (default mm): the unit its coordinates are in, scaled to millimetres as the file is read (a 3MF or glTF file states its own). The design keeps the millimetre mesh and not the unit, so insert the file again to try another; origin?: bool (default false: the mesh keeps its file's coordinates; true: once read, scaled and stood up, the import's occurrence is moved so the box around its bodies is centred on X and Y of the active component with its lowest point on Z = 0 → moved_to_origin: [x, y, z], the shift made)"),
|
||||
CommandSpec::new("file.save", "Save", save)
|
||||
CommandSpec::new("file.save", "Save", crate::checked!("file.save", save))
|
||||
.icon("save")
|
||||
.key("Ctrl+S")
|
||||
.noundo()
|
||||
.contract(&SAVE_CONTRACT)
|
||||
.output(output_schema_for::<SaveResult>)
|
||||
.params("path? (default: current file; a name that opens as another kind of file, such as .step, .stl or .dxf, is refused: file.export writes those); format?: container|json (default: json for .json, otherwise container); compression?: fast|compact (default: fast); cache?: bool (the evaluated model, so the design opens without rebuilding; default: true); preview?: bool (saved display meshes, default: false); overwrite?: bool (replace the design's file even though another program or computer changed it since it was opened or saved; without it that save is refused)"),
|
||||
CommandSpec::new("file.save_as", "Save As", save_as)
|
||||
CommandSpec::new("file.save_as", "Save As", crate::checked!("file.save_as", save_as))
|
||||
.icon("save")
|
||||
.key("Ctrl+Shift+S")
|
||||
.noundo()
|
||||
.contract(&SAVE_AS_CONTRACT)
|
||||
.output(output_schema_for::<SaveResult>)
|
||||
.params("path (a design file name, .sc as a rule; one that opens as another kind of file, such as .step, .stl or .dxf, is refused: file.export writes those); format?: container|json (default: json for .json, otherwise container); compression?: fast|compact (default: fast); cache?: bool (the evaluated model, so the design opens without rebuilding; default: true); preview?: bool (saved display meshes, default: false); overwrite?: bool (as file.save, when `path` is the design's own file)"),
|
||||
CommandSpec::new("doc.recovery_list", "Recoverable Designs", recovery_list).icon("restore")
|
||||
.noundo()
|
||||
@@ -122,6 +128,8 @@ fn new_doc(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"name": name}))
|
||||
}
|
||||
|
||||
const SAVE_CONTRACT: TypedContract<SaveParams> = TypedContract::new();
|
||||
const SAVE_AS_CONTRACT: TypedContract<SaveAsParams> = TypedContract::new();
|
||||
const EXPORT_STEP_CONTRACT: crate::schema::TypedContract<crate::schema::file::ExportStepParams> = crate::schema::TypedContract::new();
|
||||
const EXPORT_STL_CONTRACT: crate::schema::TypedContract<crate::schema::file::ExportMeshParams> = crate::schema::TypedContract::new();
|
||||
const EXPORT_3MF_CONTRACT: crate::schema::TypedContract<crate::schema::file::Export3mfParams> = crate::schema::TypedContract::new();
|
||||
@@ -682,7 +690,15 @@ fn write_bytes(
|
||||
s.disk = solidcraft_io::design_lock::stamp(path);
|
||||
s.read_only = false;
|
||||
s.mark_saved();
|
||||
let mut out = json!({"path": path, "bytes": bytes.len(), "name": s.doc.name});
|
||||
let mut out = SaveResult {
|
||||
path: path.to_string(),
|
||||
bytes: bytes.len(),
|
||||
name: s.doc.name.clone(),
|
||||
kept_format: None,
|
||||
warnings: None,
|
||||
not_kept: None,
|
||||
param_warnings: Vec::new(),
|
||||
};
|
||||
// A newer SolidCraft's file keeps what this version can't read, and its format number. What
|
||||
// couldn't go back, because the design was changed around it, is said.
|
||||
if s.doc.newer_file.is_some() {
|
||||
@@ -690,13 +706,13 @@ fn write_bytes(
|
||||
if format == "json" && s.doc.newer_file.as_ref().is_some_and(|file| file.needs_container()) {
|
||||
lost.push("the parts of the file beside the design, which plain JSON has no room for: save as .sc to keep them".to_string());
|
||||
}
|
||||
out["kept_format"] = json!(s.doc.format);
|
||||
out.kept_format = Some(s.doc.format.clone());
|
||||
if !lost.is_empty() {
|
||||
out["warnings"] = json!(lost.iter().map(|what| format!("Not kept: {what}. The previous file is `{path}.bak`.")).collect::<Vec<_>>());
|
||||
out["not_kept"] = json!(lost);
|
||||
out.warnings = Some(lost.iter().map(|what| format!("Not kept: {what}. The previous file is `{path}.bak`.")).collect());
|
||||
out.not_kept = Some(lost);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
serde_json::to_value(out).map_err(|e| bad("file.save", e.to_string()))
|
||||
}
|
||||
|
||||
impl Session {
|
||||
@@ -1394,12 +1410,11 @@ mod tests {
|
||||
assert!(bytes.starts_with(signature), "{cmd}");
|
||||
}
|
||||
// The triangles `file.export` writes (the 80-byte header holds the file's name), 84 + 50
|
||||
// bytes each. The mesher lays each planar face out as a regular grid whose sides are at
|
||||
// most a fifth of the body's diagonal (kernel test `a_box_is_a_regular_grid_on_every_face`):
|
||||
// 22.9 / 5 = 4.58 mm, so 5 × 3 × 2 cells along 20 × 10 × 5, two triangles each.
|
||||
// bytes each. A planar face is exact for any triangulation of its outline, so the mesher
|
||||
// gives each of the box's six faces two triangles (kernel test `a_box_is_two_triangles_a_face`).
|
||||
s.execute("file.export", &json!({"path": at("box.stl")})).unwrap();
|
||||
assert_eq!(std::fs::read(at("StlBinary.txt")).unwrap()[80..], std::fs::read(at("box.stl")).unwrap()[80..]);
|
||||
let triangles = 2 * 2 * (5 * 3 + 5 * 2 + 3 * 2);
|
||||
let triangles = 6 * 2;
|
||||
let stl = std::fs::read(at("box.stl")).unwrap();
|
||||
assert_eq!(u32::from_le_bytes([stl[80], stl[81], stl[82], stl[83]]), u32::try_from(triangles).unwrap());
|
||||
assert_eq!(stl.len(), 84 + 50 * triangles);
|
||||
|
||||
@@ -45,6 +45,7 @@ pub(crate) use face::{FaceRefForm, face_ref_form};
|
||||
pub(crate) use features::profiles as feature_profiles;
|
||||
pub(crate) use features::{EdgeRefForm, PLANE_FORMS, axis_dir_form, axis_line_object, edge_ref_form, explicit_plane, plane_name_problem, sketch_key};
|
||||
pub use features::{auto_operation, extrude_reach, shown_scope};
|
||||
pub(crate) use features::{rail_fit_form, sweep_orientation};
|
||||
pub use file::open_steps::OpenSteps;
|
||||
pub(crate) use inspect::color_param;
|
||||
pub use join_pieces::{Addition, addition};
|
||||
@@ -131,7 +132,7 @@ mod sketch_import;
|
||||
mod sketch_inspect;
|
||||
mod sketch_modify;
|
||||
mod sketch_project;
|
||||
pub(crate) use sketch_project::{RefForm, ref_form};
|
||||
pub(crate) use sketch_project::{MAX_PROJECTED_ENTITIES, RefForm, model_ref, ref_form};
|
||||
mod sketch_store;
|
||||
mod surface;
|
||||
mod surface_analysis;
|
||||
|
||||
@@ -343,6 +343,27 @@ fn metric(size: &str) -> Option<(f64, f64)> {
|
||||
METRIC.iter().find(|(s, _, _)| s.eq_ignore_ascii_case(size)).map(|(_, d, p)| (*d, *p))
|
||||
}
|
||||
|
||||
/// The head a counterbore seats, for `solid.hole`'s `head`: (head diameter dk, head height k)
|
||||
/// of a metric cap screw, by its standard (`ISO 4762`, `ISO 7380-1`) or the family name
|
||||
/// `parts.insert` takes. The size is written as `clearance` takes it (`M4`, or `M4x0.7` with the
|
||||
/// pitch). The error names what is missing: the standard, or the size in it.
|
||||
pub(crate) fn cap_screw_head(standard: &str, size: &str) -> std::result::Result<(f64, f64), String> {
|
||||
let (name, table): (&str, &[(&str, f64, f64, f64, f64)]) = match standard.trim() {
|
||||
s if s.eq_ignore_ascii_case("ISO 4762") || s.eq_ignore_ascii_case("socket_head_cap_screw") => ("ISO 4762", &SHCS),
|
||||
s if s.eq_ignore_ascii_case("ISO 7380-1") || s.eq_ignore_ascii_case("button_head_cap_screw") => ("ISO 7380-1", &BHCS),
|
||||
other => {
|
||||
return Err(format!("no head standard `{}`: ISO 4762 (socket head) or ISO 7380-1 (button head)", super::features::brief(other)));
|
||||
}
|
||||
};
|
||||
let size = size.trim().split(['x', 'X']).next().unwrap_or("").trim();
|
||||
let (first, last) = (table.first().map_or("", |r| r.0), table.last().map_or("", |r| r.0));
|
||||
table
|
||||
.iter()
|
||||
.find(|r| r.0.eq_ignore_ascii_case(size))
|
||||
.map(|r| (r.1, r.2))
|
||||
.ok_or_else(|| format!("no {name} head for `{}`: {first} to {last}", super::features::brief(size)))
|
||||
}
|
||||
|
||||
fn default_length(d: f64) -> f64 {
|
||||
LENGTHS.iter().copied().find(|l| *l >= 2.5 * d).unwrap_or(100.0)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ use crate::schema::sketch::{
|
||||
EditResult, EqualMade, EqualParams, EqualResult, ExitParams, FinishResult, FixOutcome, FixParams, FixResult, HorizontalVerticalParams,
|
||||
MidpointParams, ParallelParams, PerpendicularParams, SymmetryParams, SymmetryResult, TangentMade, TangentParams, TangentResult,
|
||||
};
|
||||
use crate::schema::sketch_dimension::{DimensionResult, SketchDimensionParams};
|
||||
use crate::schema::sketch_session::{
|
||||
Activated, Attachment, ChainParams, ChainResult, ConstructionParams, ConstructionResult, CreateParams, CreateResult, DeleteParams, DeleteResult,
|
||||
DimensionTextParams, DimensionTextResult, DragEndParams, DragEndResult, DragParams, DragResult, Frame, MovePointParams, MovePointResult,
|
||||
@@ -22,7 +23,8 @@ use crate::schema::sketch_session::{
|
||||
use crate::schema::sketch_shapes::{
|
||||
ArcCenterPointParams, ArcThreePointParams, CircleCenterParams, CircleThreePointParams, CircleTwoPointParams, Created, DrawResult, LineParams,
|
||||
PointParams, PointResult, PolygonCircumscribedParams, PolygonEdgeParams, PolygonInscribedParams, PolygonResult, RectCenterParams, RectCorners,
|
||||
RectResult, RectRoundedParams, RectSides, RectThreePointParams, RectTwoPointParams, RoundedRectResult,
|
||||
RectResult, RectRoundedParams, RectSides, RectThreePointParams, RectTwoPointParams, RoundedRectResult, SlotCenterToCenterParams,
|
||||
SlotOverallParams,
|
||||
};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{EngineError, Result, Session, SketchEntry, Snapshot};
|
||||
@@ -76,10 +78,13 @@ const COLLINEAR_CONTRACT: TypedContract<CollinearParams> = TypedContract::new();
|
||||
const SYMMETRY_CONTRACT: TypedContract<SymmetryParams> = TypedContract::new();
|
||||
const LINE_CONTRACT: TypedContract<LineParams> = TypedContract::new();
|
||||
const POINT_CONTRACT: TypedContract<PointParams> = TypedContract::new();
|
||||
const DIMENSION_CONTRACT: TypedContract<SketchDimensionParams> = TypedContract::new();
|
||||
const CIRCLE_CENTER_CONTRACT: TypedContract<CircleCenterParams> = TypedContract::new();
|
||||
const CIRCLE_TWO_POINT_CONTRACT: TypedContract<CircleTwoPointParams> = TypedContract::new();
|
||||
const CIRCLE_THREE_POINT_CONTRACT: TypedContract<CircleThreePointParams> = TypedContract::new();
|
||||
const ARC_THREE_POINT_CONTRACT: TypedContract<ArcThreePointParams> = TypedContract::new();
|
||||
const SLOT_CENTER_TO_CENTER_CONTRACT: TypedContract<SlotCenterToCenterParams> = TypedContract::new();
|
||||
const SLOT_OVERALL_CONTRACT: TypedContract<SlotOverallParams> = TypedContract::new();
|
||||
const ARC_CENTER_POINT_CONTRACT: TypedContract<ArcCenterPointParams> = TypedContract::new();
|
||||
const RECT_TWO_POINT_CONTRACT: TypedContract<RectTwoPointParams> = TypedContract::new();
|
||||
const RECT_THREE_POINT_CONTRACT: TypedContract<RectThreePointParams> = TypedContract::new();
|
||||
@@ -205,18 +210,29 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.contract(&POLYGON_EDGE_CONTRACT)
|
||||
.output(output_schema_for::<PolygonResult>)
|
||||
.params(concat!("p0, p1: one edge, sides", connect_doc!(), created_doc!())),
|
||||
CommandSpec::new("sketch.slot.center_to_center", "Center to Center Slot", slot_c2c)
|
||||
CommandSpec::new("sketch.slot.center_to_center", "Center to Center Slot", crate::checked!("sketch.slot.center_to_center", slot_c2c))
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("slot")
|
||||
.enabled(in_sketch)
|
||||
.contract(&SLOT_CENTER_TO_CENTER_CONTRACT)
|
||||
.output(output_schema_for::<DrawResult>)
|
||||
.params(concat!("p0, p1: arc centres, width", connect_doc!(), created_doc!())),
|
||||
CommandSpec::new("sketch.slot.overall", "Overall Slot", slot_overall)
|
||||
CommandSpec::new("sketch.slot.overall", "Overall Slot", crate::checked!("sketch.slot.overall", slot_overall))
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("slot_overall")
|
||||
.enabled(in_sketch)
|
||||
.contract(&SLOT_OVERALL_CONTRACT)
|
||||
.output(output_schema_for::<DrawResult>)
|
||||
.params(concat!("p0, p1: overall ends, width", connect_doc!(), created_doc!())),
|
||||
CommandSpec::new("sketch.point", "Point", crate::checked!("sketch.point", draw_point)).at("SKETCH", "CREATE").icon("point").enabled(in_sketch).contract(&POINT_CONTRACT).output(output_schema_for::<PointResult>).params(concat!("point: [x,y] (each a number or an expression over the design's parameters: the point is then fixed where it comes to and follows them) or a snap: \"mid:<line>\" (held at the line's midpoint), \"on:<curve>:x,y\" (on the curve near x,y), {vertex: [x,y,z]} or {on_edge: [x,y,z]} of the model; id?", connect_doc!(), created_doc!())),
|
||||
CommandSpec::new("sketch.dimension", "Sketch Dimension", dimension).at("SKETCH", "CREATE").icon("dimension").key("D").enabled(in_sketch).params(
|
||||
CommandSpec::new("sketch.dimension", "Sketch Dimension", crate::checked!("sketch.dimension", dimension))
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("dimension")
|
||||
.key("D")
|
||||
.enabled(in_sketch)
|
||||
.contract(&DIMENSION_CONTRACT)
|
||||
.output(output_schema_for::<DimensionResult>)
|
||||
.params(
|
||||
"entities: [refs] (a line midpoint is \"mid:line_id\"; a model edge or vertex, {edge: [x,y,z], body?} or {vertex: [x,y,z], body?}, is projected into the sketch first and measured there: a point against the projected edge measures the distance to it), type?: auto|distance|horizontal|distance_x|vertical|distance_y|length|radius|diameter|major|minor|angle, value?: number or expression (default: current; a bare number is in the design's units, e.g. 2 means 2 in in an inch design; give a unit such as \"50 mm\" to override), or `name = expression` (e.g. \"width = 40\"; also `#name = expression`), which adds the user parameter `name` with that expression and dimensions with `name` (refused when the name is taken or reserved; the reply's `defined` names it); text_at?: [x, y]. Two points or a line off the axes: with auto, text_at across from them (between the perpendiculars through them, or a tenth of their distance past) is the distance between them; pulled further past an end, up or down from it horizontal, sideways vertical. Two midpoints: placing text above or below their bounding box measures horizontally, beside it vertically, anywhere else (inside it or past its corners) the distance between them. Two lines: the angle between them (acute, or 180° − it when the value is nearer that) unless text_at picks a sector. A line and a circle or arc: the distance from the line to its edge (the gap, or how far the edge reaches past a line that cuts it; 0 is tangent); dimension the centre point for the distance to the centre. A point and a circle or arc: the distance from the point to its edge (or how far within it a point inside lies). Two circles or arcs: the gap between their edges (or how far they overlap when they cross, or the narrowest gap between them when one lies inside the other), or the centre-to-centre distance when they are the two caps of a slot. An ellipse: a half-axis, the distance from the centre to an axis end (major or minor; with auto, text_at pulled across the major axis gives the major half-axis, pulled out past an end of it the minor one, the minor without text_at; previous_axis?: major|minor, the half-axis an earlier placement chose, kept while text_at is near the boundary between them; the reply's axis names the one chosen; with auto, when a driving dimension already holds one half-axis (or its whole axis), the other one, wherever text_at is; without its major axis line the major half-axis goes to the ellipse's own major end, without its minor axis line the minor half-axis is the minor radius); any other type, radius among them, the minor radius; an ellipse's axis line with auto: its length, the whole axis, wherever text_at is; half_axes?: bool (default true; false: with auto, the minor radius wherever text_at is, as before half-axes, which the Dimension tool sends with Draw ellipses with their axes off). An arc and one of its end points: the arc's length (type arc_length does it from the arc alone). A dimension the sketch already determines is refused (naming what determines it); if_redundant?: \"driven\" places it as a driven (reference) dimension instead. scale_sketch?: bool (the dimension tool sends the Sketch preference, on by default): when this is the sketch's first length dimension and nothing in the sketch fixes a size (no other driving length dimension, nothing fixed or projected), the whole sketch scales about its origin to the value instead of distorting. Returns measured in unit (mm or deg), and scaled: the factor when the sketch was scaled",
|
||||
),
|
||||
CommandSpec::new("sketch.constraint.horizontal_vertical", "Horizontal/Vertical", crate::checked!("sketch.constraint.horizontal_vertical", c_horizontal_vertical))
|
||||
@@ -2053,7 +2069,7 @@ fn polygon_edge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
pub(super) fn slot(s: &mut Session, p: &Value, cmd: &str, c0: Vec2, c1: Vec2, w: f64) -> Result<Value> {
|
||||
let (out, info) = slot_parts(s, p, cmd, c0, c1, w)?;
|
||||
Ok(result(out, info))
|
||||
draw_reply(out, info)
|
||||
}
|
||||
|
||||
/// What [`slot`] adds: the curve and constraint ids, and the sketch's solve.
|
||||
@@ -2797,8 +2813,11 @@ fn tangent_at_end(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
|
||||
let id = p.get(key).and_then(Value::as_str).unwrap_or_default();
|
||||
bad(cmd, format!("`{id}` is not the end of one curve: pick a curve, or the end point of a line or an arc"))
|
||||
};
|
||||
let mut before = None;
|
||||
let mut residual = None;
|
||||
let (out, info) = edit(s, p, cmd, true, |sk, _| {
|
||||
let rep = solve(&mut sk.clone());
|
||||
before = Some(rep.dof);
|
||||
let (pa, pb) = (tangent_end(sk, p, "a"), tangent_end(sk, p, "b"));
|
||||
let ca = if pa.is_none() { Some(line_or_axis_ref(sk, p.get("a"), cmd, "a")?) } else { None };
|
||||
let cb = if pb.is_none() { Some(line_or_axis_ref(sk, p.get("b"), cmd, "b")?) } else { None };
|
||||
@@ -2827,6 +2846,12 @@ fn tangent_at_end(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
|
||||
}
|
||||
let tangent = add_c(sk, ConstraintKind::Tangent { a, b })?;
|
||||
ids.push(tangent.clone());
|
||||
// As in `constrain`: what already holds needs nothing moved, and is then redundant when it
|
||||
// removes no degree of freedom (see `redundancy`).
|
||||
residual = ids.iter().try_fold(0.0_f64, |worst, id| residual_of(sk, id).map(|r| worst.max(r)));
|
||||
if residual.is_some_and(|r| r <= solidcraft_sketch::CONSISTENT) {
|
||||
return Ok((tangent, ids));
|
||||
}
|
||||
// A line's end and a curve, in either order: only that end moves, so the line turns about
|
||||
// its other end and gets longer or shorter to meet the curve (#12092). At a joint that end
|
||||
// can't leave the other curve: the curve it ends stays, and the other curve moves. Otherwise,
|
||||
@@ -2848,7 +2873,12 @@ fn tangent_at_end(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
|
||||
Ok((tangent, ids))
|
||||
})?;
|
||||
let (constraint, constraints) = out;
|
||||
reply(cmd, TangentResult { made: TangentMade::End { constraint, constraints, sketch: info }, param_warnings: Vec::new() })
|
||||
let by = redundancy(s, p, cmd, before, &info, residual, &constraint)?;
|
||||
if let Some(by) = &by {
|
||||
keep_redundant(p, cmd, by)?;
|
||||
}
|
||||
let made = TangentMade::End { constraint, constraints, sketch: info, redundant: by.as_ref().map(|_| true), determined_by: by };
|
||||
reply(cmd, TangentResult { made, param_warnings: Vec::new() })
|
||||
}
|
||||
fn c_equal(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if p.get("curves").is_some() {
|
||||
@@ -3229,6 +3259,11 @@ pub(super) fn line_pts(sk: &Sketch, l: usize) -> Option<(Vec2, Vec2)> {
|
||||
}
|
||||
|
||||
fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
reply("sketch.dimension", dimension_result(s, p)?)
|
||||
}
|
||||
|
||||
/// [`dimension`]'s reply, as the typed value the output schema describes.
|
||||
fn dimension_result(s: &mut Session, p: &Value) -> Result<DimensionResult> {
|
||||
let cmd = "sketch.dimension";
|
||||
// A dimension may measure model geometry (#7912): an `{edge: [x,y,z], body?}` or a
|
||||
// `{vertex: [x,y,z], body?}` among its entities is projected into the sketch first, as
|
||||
@@ -3578,11 +3613,22 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Report what the dimension measures once the sketch is solved, not the geometry before.
|
||||
let current = solved_measure(s, p, cmd).unwrap_or(current);
|
||||
if driven {
|
||||
let mut out = json!({"param": Value::Null, "driven": true, "type": kind_name, "measured": current.0, "unit": current.1, "sketch": info});
|
||||
if let Some(axis) = axis {
|
||||
out["axis"] = json!(axis);
|
||||
}
|
||||
return Ok(out);
|
||||
return Ok(DimensionResult {
|
||||
param: None,
|
||||
driven: Some(true),
|
||||
kind: kind_name.to_string(),
|
||||
expression: None,
|
||||
measured: current.0,
|
||||
unit: current.1.to_string(),
|
||||
sketch: info,
|
||||
scaled: None,
|
||||
axis: axis.map(str::to_string),
|
||||
defined: None,
|
||||
redundant: None,
|
||||
determined_by: None,
|
||||
created: None,
|
||||
param_warnings: Vec::new(),
|
||||
});
|
||||
}
|
||||
// A dimension the sketch already determines cannot drive it: it is refused, or with
|
||||
// `if_redundant: "driven"` placed as a driven (reference) dimension instead.
|
||||
@@ -3597,9 +3643,9 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
o.remove("if_redundant");
|
||||
o.remove("value");
|
||||
}
|
||||
let mut out = dimension(s, &q)?;
|
||||
out["redundant"] = json!(true);
|
||||
out["determined_by"] = json!(by);
|
||||
let mut out = dimension_result(s, &q)?;
|
||||
out.redundant = Some(true);
|
||||
out.determined_by = Some(by);
|
||||
return Ok(out);
|
||||
}
|
||||
return Err(bad(
|
||||
@@ -3608,17 +3654,22 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
));
|
||||
}
|
||||
let v = s.doc.param(¶m).map(|p| p.expr.clone()).unwrap_or_default();
|
||||
let mut out = json!({"param": param, "type": kind_name, "expression": v, "measured": current.0, "unit": current.1, "sketch": info});
|
||||
if let Some(factor) = scaled {
|
||||
out["scaled"] = json!(round6(factor));
|
||||
}
|
||||
if let Some(axis) = axis {
|
||||
out["axis"] = json!(axis);
|
||||
}
|
||||
if let Some((name, _)) = defines {
|
||||
out["defined"] = json!(name);
|
||||
}
|
||||
Ok(out)
|
||||
Ok(DimensionResult {
|
||||
param: Some(param),
|
||||
driven: None,
|
||||
kind: kind_name.to_string(),
|
||||
expression: Some(v),
|
||||
measured: current.0,
|
||||
unit: current.1.to_string(),
|
||||
sketch: info,
|
||||
scaled: scaled.map(round6),
|
||||
axis: axis.map(str::to_string),
|
||||
defined: defines.map(|(name, _)| name),
|
||||
redundant: None,
|
||||
determined_by: None,
|
||||
created: None,
|
||||
param_warnings: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// A dimension's measured value as reported, in the unit its expression is written in.
|
||||
|
||||
@@ -428,8 +428,10 @@ fn add_link_unless_present(
|
||||
if skip_present && r.wires.is_empty() && sk.link_geometry_present(&r.geom) {
|
||||
return Ok(None);
|
||||
}
|
||||
// A mesh body is one face whose name says nothing of the flat patch picked: the link keeps
|
||||
// the point instead (#13464), as an edge of a mesh body keeps no name.
|
||||
let name = if let LinkSource::Face { body, at } = &r.source {
|
||||
st.body(body).and_then(|body| {
|
||||
st.body(body).filter(|body| !body.body.is_mesh()).and_then(|body| {
|
||||
if let Some(index) = r.face_index {
|
||||
solidcraft_doc::naming::face_names(body).get(index).filter(|name| !name.is_empty()).cloned()
|
||||
} else {
|
||||
@@ -582,7 +584,7 @@ pub(super) fn project_operands(s: &mut Session, p: &Value, cmd: &str, keys: &[&s
|
||||
|
||||
/// A model's reference among a command's operands (#6348): an `{edge: [x,y,z], body?}` or a
|
||||
/// `{vertex: [x,y,z], body?}`.
|
||||
fn model_ref(v: &Value) -> bool {
|
||||
pub(crate) fn model_ref(v: &Value) -> bool {
|
||||
v.get("edge").is_some() || v.get("vertex").is_some()
|
||||
}
|
||||
|
||||
@@ -601,7 +603,7 @@ fn project_ref(s: &mut Session, doc: &Document, id: u64, sk: &mut Sketch, v: &Va
|
||||
|
||||
/// Most model references one call may project into a sketch: each one is a link and a resolve,
|
||||
/// so the count is capped before any of them is projected.
|
||||
const MAX_PROJECTED_ENTITIES: usize = 1_000;
|
||||
pub(crate) const MAX_PROJECTED_ENTITIES: usize = 1_000;
|
||||
|
||||
/// A dimension's entities may be model geometry (#7912): an `{edge: [x,y,z], body?}` or
|
||||
/// `{vertex: [x,y,z], body?}` among them is projected into the sketch first, as Project does,
|
||||
|
||||
@@ -1803,3 +1803,87 @@ fn projected_references_can_start_as_construction() {
|
||||
run(&mut s, "sketch.project", json!({"refs": [{"edge": [40, 15, 20]}], "construction": false}));
|
||||
assert_eq!(flags(&mut s), vec![true, false]);
|
||||
}
|
||||
|
||||
/// #13464: an imported mesh is one face with no edges, so Project on one of its faces projected
|
||||
/// nothing. The pick now means the flat patch of triangles there: a box's top projects as its
|
||||
/// rectangle, its side seen edge-on as a line, and the link keeps the pick's point (a mesh's face
|
||||
/// name says nothing of the patch), so the sketch still builds once finished.
|
||||
#[test]
|
||||
fn a_face_of_an_imported_mesh_projects_its_flat_patch() {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-project-mesh-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let stl = dir.join("box.stl");
|
||||
let mut src = Session::default();
|
||||
run(&mut src, "solid.box", json!({"length": 20, "width": 10, "height": 5}));
|
||||
run(&mut src, "file.export_stl", json!({"path": stl.to_string_lossy()}));
|
||||
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "file.insert_mesh", json!({"path": stl.to_string_lossy()}));
|
||||
let body = s.model.state().bodies[0].name.clone();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": 20}));
|
||||
run(&mut s, "sketch.project", json!({"refs": [{"body": body, "face": [10, 5, 5]}]}));
|
||||
let si = inspect(&mut s);
|
||||
let lines = linked_curves(&si);
|
||||
assert_eq!(lines.len(), 4, "{si}");
|
||||
let mut corners: Vec<(i64, i64)> = lines
|
||||
.iter()
|
||||
.flat_map(|c| [&c["start_at"], &c["end_at"]])
|
||||
.map(|p| ((p[0].as_f64().unwrap() * 1e6).round() as i64, (p[1].as_f64().unwrap() * 1e6).round() as i64))
|
||||
.collect();
|
||||
corners.sort_unstable();
|
||||
corners.dedup();
|
||||
assert_eq!(corners, [(0, 0), (0, 10_000_000), (20_000_000, 0), (20_000_000, 10_000_000)], "{si}");
|
||||
assert!(s.active_sketch.is_some());
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let failing: Vec<_> = s.model.results.iter().filter_map(|r| r.error.clone()).collect();
|
||||
assert!(failing.is_empty(), "{failing:?}");
|
||||
|
||||
// A side seen edge-on: the line it makes.
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": 20}));
|
||||
run(&mut s, "sketch.project", json!({"refs": [{"body": body, "face": [10, 0, 2.5]}]}));
|
||||
assert_eq!(linked_curves(&inspect(&mut s)).len(), 1);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// #13464: on a sketch plane at a slant to the face, a flat patch's polygon keeps its straight
|
||||
/// sides. A rectangle's four corners and a triangle's three always lie on one circle, so they must
|
||||
/// not be taken for a circle seen at a slant (an ellipse); a tessellated circle still is one.
|
||||
#[test]
|
||||
fn a_mesh_face_keeps_its_straight_sides_on_a_slanted_plane() {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-project-mesh-slant-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let mut src = Session::default();
|
||||
run(&mut src, "solid.box", json!({"length": 20, "width": 10, "height": 5, "body_name": "box"}));
|
||||
run(&mut src, "sketch.create", json!({"plane": "XY"}));
|
||||
run(&mut src, "sketch.line", json!({"points": [[30, 0], [42, 0], [34, 9], [30, 0]]}));
|
||||
run(&mut src, "sketch.finish", json!({}));
|
||||
let sketch = src.doc.features.last().unwrap().id;
|
||||
run(&mut src, "solid.extrude", json!({"sketch": sketch, "distance": 5, "body_name": "tri"}));
|
||||
run(&mut src, "solid.cylinder", json!({"base": [60, 0, 0], "radius": 7, "height": 5}));
|
||||
let mut stls = Vec::new();
|
||||
for body in src.model.state().bodies.iter().map(|b| b.name.clone()).collect::<Vec<_>>() {
|
||||
let path = dir.join(format!("{body}.stl"));
|
||||
run(&mut src, "file.export_stl", json!({"path": path.to_string_lossy(), "bodies": [body]}));
|
||||
stls.push(path);
|
||||
}
|
||||
|
||||
let mut s = Session::default();
|
||||
for stl in &stls {
|
||||
run(&mut s, "file.insert_mesh", json!({"path": stl.to_string_lossy()}));
|
||||
}
|
||||
let names: Vec<String> = s.model.state().bodies.iter().map(|b| b.name.clone()).collect();
|
||||
run(&mut s, "construct.plane.at_angle", json!({"base": "XY", "axis": "X", "angle": "30 deg", "name": "Tilt"}));
|
||||
let kinds = |s: &mut Session, body: &str, at: [f64; 3]| -> Vec<String> {
|
||||
run(s, "sketch.create", json!({"plane": "Tilt"}));
|
||||
run(s, "sketch.project", json!({"refs": [{"body": body, "face": at}]}));
|
||||
let kinds = linked_curves(&inspect(s)).iter().map(|c| c["type"].as_str().unwrap_or("").to_string()).collect();
|
||||
run(s, "sketch.finish", json!({}));
|
||||
kinds
|
||||
};
|
||||
assert_eq!(kinds(&mut s, &names[0], [10., 5., 5.]), ["line"; 4], "the box's top is a rectangle");
|
||||
assert_eq!(kinds(&mut s, &names[1], [35., 3., 5.]), ["line"; 3], "the prism's top is a triangle");
|
||||
let round = kinds(&mut s, &names[2], [60., 0., 5.]);
|
||||
assert_eq!(round.len(), 1, "the cylinder's top is one curve: {round:?}");
|
||||
assert_eq!(round[0], "ellipse", "the cylinder's round top, seen at a slant: {round:?}");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
@@ -397,6 +397,68 @@ fn tangent_at_a_joint_adds_only_the_tangent() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Tangent on a line's end and a circle the end already touches tangentially, as for two curves
|
||||
/// (`constrain`): what already holds moves nothing. With both fixed the tangent removes no degree
|
||||
/// of freedom, so it is kept and reported as redundant, or refused with `if_redundant: "refuse"`,
|
||||
/// leaving the sketch as it was.
|
||||
#[test]
|
||||
fn tangent_at_an_end_that_already_holds_is_redundant() {
|
||||
let touching = |fixed: bool| {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.line", json!({"points": [[5, 3], [20, 3]]}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [20, 8], "radius": 5}));
|
||||
if fixed {
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1", "c1"], "fixed": true}));
|
||||
}
|
||||
s
|
||||
};
|
||||
for (a, b) in [("l1.end", "c1"), ("c1", "l1.end")] {
|
||||
let mut s = touching(false);
|
||||
let (line, circle) = (snap(&s, "l1"), snap(&s, "c1"));
|
||||
let r = run(&mut s, "sketch.constraint.tangent", json!({"a": a, "b": b}));
|
||||
assert!(same(&snap(&s, "l1"), &line) && same(&snap(&s, "c1"), &circle), "{a}, {b}: something moved");
|
||||
assert!(r.get("redundant").is_none(), "{a}, {b}: free, so not redundant: {r}");
|
||||
|
||||
let mut s = touching(true);
|
||||
let r = run(&mut s, "sketch.constraint.tangent", json!({"a": a, "b": b}));
|
||||
assert_eq!(r["redundant"], true, "{a}, {b}: {r}");
|
||||
// Fixed entities are held, not constrained, so no constraint is listed.
|
||||
assert!(r["determined_by"].is_array(), "{a}, {b}: {r}");
|
||||
assert_eq!(r["constraints"].as_array().map(Vec::len), Some(2), "{a}, {b}: {r}");
|
||||
|
||||
let mut s = touching(true);
|
||||
let count = sketch(&s).constraints.len();
|
||||
let e = s.execute("sketch.constraint.tangent", &json!({"a": a, "b": b, "if_redundant": "refuse"})).unwrap_err().to_string();
|
||||
assert!(e.contains("already determined by"), "{a}, {b}: {e}");
|
||||
assert_eq!(sketch(&s).constraints.len(), count, "{a}, {b}: nothing added");
|
||||
}
|
||||
}
|
||||
|
||||
/// The same with free curves whose own constraints already decide it: circle c2 is concentric
|
||||
/// with c1 and equal to it, the line is tangent to c2 and its end lies on c2. Tangent on that end
|
||||
/// and c1 adds the end on c1 and the tangent, and names what determines the tangent: at the end
|
||||
/// it says the line is square to the radius there, so the shared centre and the tangent on c2
|
||||
/// decide it, and the equal radius does not.
|
||||
#[test]
|
||||
fn tangent_at_an_end_names_the_constraints_that_determine_it() {
|
||||
for (a, b) in [("l1.end", "c1"), ("c1", "l1.end")] {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.line", json!({"points": [[5, 3], [20, 3]]}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [20, 8], "radius": 5}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [40, 30], "radius": 2}));
|
||||
let mut id = |cmd: &str, p: Value| run(&mut s, cmd, p)["constraint"].as_str().unwrap_or_default().to_string();
|
||||
let concentric = id("sketch.constraint.concentric", json!({"a": "c1", "b": "c2"}));
|
||||
id("sketch.constraint.equal", json!({"a": "c1", "b": "c2"}));
|
||||
let tangent = id("sketch.constraint.tangent", json!({"a": "l1", "b": "c2"}));
|
||||
id("sketch.constraint.coincident", json!({"a": "l1.end", "b": "c2"}));
|
||||
let r = run(&mut s, "sketch.constraint.tangent", json!({"a": a, "b": b}));
|
||||
assert_eq!(r["redundant"], true, "{a}, {b}: {r}");
|
||||
assert_eq!(r["constraints"].as_array().map(Vec::len), Some(2), "{a}, {b}: {r}");
|
||||
let by = json!([format!("{concentric} (Concentric)"), format!("{tangent} (Tangent)")]);
|
||||
assert_eq!(r["determined_by"], by, "{a}, {b}: {r}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fixed_second_pick_makes_the_first_move() {
|
||||
let mut s = line_and_circle();
|
||||
|
||||
@@ -14,7 +14,7 @@ use solidcraft_kernel::{Construction, Continuity};
|
||||
use super::CommandSpec;
|
||||
use super::features::{add_feature, axis_param, check_expr, extent_param, feature_sketch, operation, plane_param, profiles, sketch_id};
|
||||
use crate::params::{bad, bool_, expr, num, str_, string_list, vec3};
|
||||
use crate::schema::features::FeatureResult;
|
||||
use crate::schema::features::{FeatureResult, SurfaceSweepParams};
|
||||
use crate::schema::surface::{FairParams, OffsetParams, ReverseNormalParams, StitchParams, ThickenParams, UnstitchParams, UntrimParams};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{Result, Session};
|
||||
@@ -26,6 +26,7 @@ const THICKEN_CONTRACT: TypedContract<ThickenParams> = TypedContract::new();
|
||||
const OFFSET_CONTRACT: TypedContract<OffsetParams> = TypedContract::new();
|
||||
const UNTRIM_CONTRACT: TypedContract<UntrimParams> = TypedContract::new();
|
||||
const FAIR_CONTRACT: TypedContract<FairParams> = TypedContract::new();
|
||||
const SURFACE_SWEEP_CONTRACT: TypedContract<SurfaceSweepParams> = TypedContract::new();
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("surface.patch", "Patch", patch)
|
||||
@@ -60,9 +61,11 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.at("SURFACE", "CREATE")
|
||||
.icon("surface_revolve")
|
||||
.params("sketch?: id|name; curves?: [curve ids] and/or profiles? (as surface.extrude); axis: sketch line id|x|y|X|Y|Z|{type: world, axis: X|Y|Z}|construction axis name|{origin, dir} (origin: numbers or length expressions, followed when parameters change) | {edge|face: [x,y,z]} (a straight or circular edge, or a cylinder, cone or torus face: about its axis, followed as the model changes); angle?: expr (default 360 deg); angle2?: expr (the other side); symmetric?: bool; name?, body_name? (a surface body, no caps; a solid where a full turn closes it)"),
|
||||
CommandSpec::new("surface.sweep", "Surface Sweep", surface_sweep)
|
||||
CommandSpec::new("surface.sweep", "Surface Sweep", crate::checked!("surface.sweep", surface_sweep))
|
||||
.at("SURFACE", "CREATE")
|
||||
.icon("surface_sweep")
|
||||
.contract(&SURFACE_SWEEP_CONTRACT)
|
||||
.output(output_schema_for::<FeatureResult>)
|
||||
.params("sketch?: id|name; curves?: [curve ids] and/or profiles? (as surface.extrude); path_sketch: sketch; path: [curve ids in order] (a path with corners, or 3D curves, gives ruled walls mitred at the corners); orientation?, twist?, scale?, rails?, rail_fit? (as solid.sweep); name?, body_name? (a surface body, no caps)"),
|
||||
CommandSpec::new("surface.loft", "Surface Loft", surface_loft)
|
||||
.at("SURFACE", "CREATE")
|
||||
|
||||
@@ -483,6 +483,16 @@ fn cases() -> Vec<Case> {
|
||||
vec![spot([20.0, 15.0, 20.0], DOWN, 20.0, None)],
|
||||
)
|
||||
.faces(1),
|
||||
// A counterbore for the screw's head: ISO 4762 M4 (dk 7, k 4) plus the 0.5 mm gap.
|
||||
Case::new(
|
||||
"type/counterbore-head-m4/through",
|
||||
block(),
|
||||
json!({"position": [20, 15, 20], "clearance": "M4", "head": "ISO 4762", "name": "H"}),
|
||||
cyl(4.0, 4.5) + cyl(2.25, 15.5),
|
||||
2.25,
|
||||
vec![spot([20.0, 15.0, 20.0], DOWN, 20.0, None)],
|
||||
)
|
||||
.faces(3),
|
||||
// Extents: to an object.
|
||||
Case::new(
|
||||
"extent/to-face/bottom",
|
||||
@@ -1023,6 +1033,24 @@ fn a_refused_hole_names_the_value_at_fault() {
|
||||
assert!(e.contains("`fit` must be close, normal or loose, not `tight`") && !e.contains("M1.6"), "{e}");
|
||||
let e = refused(json!({"clearance": "M100"}));
|
||||
assert!(e.contains("no clearance hole for `M100`") && e.contains("M1.6 to M64"), "{e}");
|
||||
// A counterbore for a screw's head: the standard, the size in it, the screw size, the type
|
||||
// and the gap are each named when they are at fault.
|
||||
let e = refused(json!({"clearance": "M4", "head": "DIN 912"}));
|
||||
assert!(e.contains("no head standard `DIN 912`") && e.contains("ISO 4762") && e.contains("ISO 7380-1"), "{e}");
|
||||
let e = refused(json!({"clearance": "M2.5", "head": "ISO 7380-1"}));
|
||||
assert!(e.contains("no ISO 7380-1 head for `M2.5`") && e.contains("M3 to M16"), "{e}");
|
||||
let e = refused(json!({"diameter": 4.5, "head": "ISO 4762"}));
|
||||
assert!(e.contains("`head` needs `clearance`"), "{e}");
|
||||
let e = refused(json!({"clearance": "M4", "head": "ISO 4762", "type": "countersink", "cs_diameter": 9}));
|
||||
assert!(e.contains("`head` needs `type: counterbore`"), "{e}");
|
||||
let e = refused(json!({"clearance": "M4", "head": "ISO 4762", "head_gap": -1}));
|
||||
assert!(e.contains("`head_gap` must be 0 to 7 mm") && e.contains("not -1"), "{e}");
|
||||
let e = refused(json!({"clearance": "M4", "head": "ISO 4762", "head_gap": "width"}));
|
||||
assert!(e.contains("head_gap:"), "{e}");
|
||||
let e = refused(json!({"clearance": "M4", "head": "ISO 4762", "cb_depth": true}));
|
||||
assert!(e.contains("`cb_depth` must be a number or an expression"), "{e}");
|
||||
let e = refused(json!({"clearance": "M4", "head": 4762}));
|
||||
assert!(e.contains("`head` must be a text"), "{e}");
|
||||
for angle in [json!(0), json!(200), json!(-90), json!(0.1)] {
|
||||
let e = refused(json!({"diameter": 3, "type": "countersink", "cs_diameter": 6, "cs_angle": angle}));
|
||||
assert!(e.contains("the countersink angle must be more than 0.115° and less than 180°") && !e.contains("wider"), "{angle}: {e}");
|
||||
|
||||
@@ -361,6 +361,110 @@ pub struct PipeParams {
|
||||
pub common: FeatureCommon,
|
||||
}
|
||||
|
||||
/// Sweep sketch profiles along a path of sketch curves into a solid.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct SweepParams {
|
||||
/// The sketch holding the profiles, by id or name. Default: the active sketch, else the last
|
||||
/// one.
|
||||
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "IdOrName")]
|
||||
pub sketch: Option<IdOrName>,
|
||||
/// Which of the sketch's profiles to sweep. Default: all of them.
|
||||
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "ProfilesParam")]
|
||||
pub profiles: Option<ProfilesParam>,
|
||||
/// Each index or point pick is exactly the region it names. Without it, a pick on a drawn
|
||||
/// profile that reference geometry (a face's projected outline) cuts into regions takes the
|
||||
/// whole drawn profile. The dialogs send `true`: they highlight the regions they pick.
|
||||
#[serde(default)]
|
||||
pub exact_regions: Option<bool>,
|
||||
/// The sketch holding the path, by id or name.
|
||||
pub path_sketch: SketchRef,
|
||||
/// The path's curve ids, in order along the path.
|
||||
pub path: StringList,
|
||||
#[serde(flatten, deserialize_with = "feature_common")]
|
||||
pub shape: SweepShape,
|
||||
/// How the sweep combines with existing bodies. Default: new.
|
||||
#[serde(default)]
|
||||
pub operation: Option<OperationParam>,
|
||||
/// The bodies a join, cut or intersect works on; given, they win over `participants`.
|
||||
/// Default: `participants`, else for a join from a sketch on a body's face that body, else
|
||||
/// every body in the active component (an intersect removes those the sweep doesn't meet),
|
||||
/// or for a cut or intersect where it has none, every body.
|
||||
#[serde(default)]
|
||||
pub targets: Option<StringList>,
|
||||
/// What a join makes of pieces that share no face. Default: separate.
|
||||
#[serde(default)]
|
||||
pub touching: Option<TouchingParam>,
|
||||
#[serde(flatten, deserialize_with = "feature_common")]
|
||||
pub common: FeatureCommon,
|
||||
}
|
||||
|
||||
/// Sweep sketch curves or profile boundaries along a path of sketch curves into a surface body:
|
||||
/// walls only, no caps. A path with corners, or of 3D curves, gives ruled walls mitred at the
|
||||
/// corners. `participants` changes nothing here.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct SurfaceSweepParams {
|
||||
/// The sketch holding the curves or profiles, by id or name. Default: the active sketch, else
|
||||
/// the last one.
|
||||
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "IdOrName")]
|
||||
pub sketch: Option<IdOrName>,
|
||||
/// Curve ids to sweep, at most 1000: open or closed chains of curves meeting end to end.
|
||||
#[serde(default)]
|
||||
pub curves: Option<StringList>,
|
||||
/// Which of the sketch's profiles to sweep the boundaries of. Default: all of them when no
|
||||
/// `curves` are given, else none.
|
||||
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "ProfilesParam")]
|
||||
pub profiles: Option<ProfilesParam>,
|
||||
/// Each index or point pick is exactly the region it names. Without it, a pick on a drawn
|
||||
/// profile that reference geometry (a face's projected outline) cuts into regions takes the
|
||||
/// whole drawn profile. The dialogs send `true`: they highlight the regions they pick.
|
||||
#[serde(default)]
|
||||
pub exact_regions: Option<bool>,
|
||||
/// The sketch holding the path, by id or name.
|
||||
pub path_sketch: SketchRef,
|
||||
/// The path's curve ids, in order along the path: 1 to 1000.
|
||||
pub path: StringList,
|
||||
#[serde(flatten, deserialize_with = "feature_common")]
|
||||
pub shape: SweepShape,
|
||||
#[serde(flatten, deserialize_with = "feature_common")]
|
||||
pub common: FeatureCommon,
|
||||
}
|
||||
|
||||
/// How a sweep's profile turns and changes size along the path, as `features::sweep_options`
|
||||
/// and `features::sweep_rails` read it (solid.sweep and surface.sweep), flattened. Guide rails
|
||||
/// set the turn and the size themselves: with `rails`, the handler refuses `orientation`,
|
||||
/// `twist` and `scale`.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct SweepShape {
|
||||
/// How the profile turns as it follows the path. Default: perpendicular.
|
||||
#[serde(default)]
|
||||
pub orientation: Option<super::types::SweepOrientationParam>,
|
||||
/// How far the profile turns about the path over its length, from none at the start: within
|
||||
/// four turns (1440 deg) either way. Default: none.
|
||||
#[serde(default)]
|
||||
#[schemars(with = "Option<AngleExpr>")]
|
||||
pub twist: Option<super::types::SweepTwist>,
|
||||
/// The profile's scale at the end of the path, changing linearly along it from 1 at the
|
||||
/// start: 0.01 to 100. Default: 1.
|
||||
#[serde(default)]
|
||||
pub scale: Option<NumberExpr>,
|
||||
/// Guide rails, each meeting every plane square to the path once along it, followed from the
|
||||
/// end nearest the path's start. One rail turns the profile so that its point where the rail
|
||||
/// starts stays on the rail, and scales it with the rail's distance from the path; two rails
|
||||
/// carry it between them, its points where they start staying on both. The path must have no
|
||||
/// sharp corners.
|
||||
#[serde(default)]
|
||||
pub rails: Option<super::types::SweepRails>,
|
||||
/// How the rails reshape the profile. Default: scale. Used only with `rails`.
|
||||
#[serde(default)]
|
||||
pub rail_fit: Option<super::types::RailFitParam>,
|
||||
}
|
||||
|
||||
/// Add a box around bodies, larger by a margin on every side.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
|
||||
@@ -6,6 +6,7 @@ use super::{
|
||||
BoundingSolidParams, BoxParams, ChamferParams, CombineParams, CombineResult, ConeParams, CylinderParams, ExtrudeParams, FeatureResult,
|
||||
FilletParams, FilletResult, OffsetPlaneParams, PipeParams, PlaneAtAngleParams, ScaleParams, SphereParams, TorusParams,
|
||||
};
|
||||
use super::{SurfaceSweepParams, SweepParams};
|
||||
use crate::Session;
|
||||
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms, sketch_rectangle};
|
||||
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
|
||||
@@ -309,6 +310,321 @@ fn combine_replies_with_combine_result() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sweep_schemas_and_the_key_check_agree() {
|
||||
let shape = ["orientation", "rail_fit", "rails", "scale", "twist"];
|
||||
let mut solid = vec![
|
||||
"body_name",
|
||||
"body_names",
|
||||
"exact_regions",
|
||||
"name",
|
||||
"operation",
|
||||
"participants",
|
||||
"path",
|
||||
"path_sketch",
|
||||
"profiles",
|
||||
"sketch",
|
||||
"targets",
|
||||
"touching",
|
||||
];
|
||||
solid.extend(shape);
|
||||
solid.sort_unstable();
|
||||
assert_keys(input_schema_for::<SweepParams>(), &solid);
|
||||
let mut surface = vec!["body_name", "body_names", "curves", "exact_regions", "name", "participants", "path", "path_sketch", "profiles", "sketch"];
|
||||
surface.extend(shape);
|
||||
surface.sort_unstable();
|
||||
assert_keys(input_schema_for::<SurfaceSweepParams>(), &surface);
|
||||
}
|
||||
|
||||
/// The calls both sweeps refuse today, as `sweep` and `surface_sweep` read them, each run on
|
||||
/// `sketch_rectangle`'s sketch (`Sketch1`, which is the path sketch too: the path's curves are
|
||||
/// only looked up when the feature is evaluated).
|
||||
#[test]
|
||||
fn sweep_strict_keeps_todays_errors() {
|
||||
let rail = json!([{"curves": ["l1"]}]);
|
||||
let mut cases = Vec::new();
|
||||
for extra in [
|
||||
json!({"orientation": "sideways"}),
|
||||
json!({"orientation": " parallel"}),
|
||||
json!({"orientation": 5}),
|
||||
json!({"orientation": [0, 0, 0]}),
|
||||
json!({"orientation": [1, 0]}),
|
||||
json!({"orientation": {"direction": [0, 0, 0]}}),
|
||||
json!({"orientation": {"direction": "x"}}),
|
||||
json!({"orientation": {"x": 1, "y": 0}}),
|
||||
json!({"twist": true}),
|
||||
json!({"twist": " "}),
|
||||
json!({"twist": [10]}),
|
||||
json!({"twist": 1e9}),
|
||||
json!({"twist": "no_such_parameter"}),
|
||||
json!({"twist": "1441 deg"}),
|
||||
json!({"scale": true}),
|
||||
json!({"scale": 1e9}),
|
||||
json!({"scale": "x".repeat(4097)}),
|
||||
json!({"scale": 0}),
|
||||
json!({"scale": "no_such_parameter"}),
|
||||
json!({"rail_fit": "wobble"}),
|
||||
json!({"rail_fit": 1}),
|
||||
json!({"rail_fit": "stretch "}),
|
||||
json!({"rails": "l1"}),
|
||||
json!({"rails": {"curves": ["l1"]}}),
|
||||
json!({"rails": []}),
|
||||
json!({"rails": [{"curves": ["l1"]}, {"curves": ["l2"]}, {"curves": ["l3"]}]}),
|
||||
json!({"rails": [{}]}),
|
||||
json!({"rails": [5]}),
|
||||
json!({"rails": [{"curves": []}]}),
|
||||
json!({"rails": [{"curves": null}]}),
|
||||
json!({"rails": [{"curves": 5}]}),
|
||||
json!({"rails": [{"curves": ["l1", 2]}]}),
|
||||
json!({"rails": [{"curves": vec!["l1"; 1001]}]}),
|
||||
json!({"rails": [{"curves": vec!["l1"; 10_001]}]}),
|
||||
json!({"rails": [{"sketch": true, "curves": ["l1"]}]}),
|
||||
json!({"rails": [{"sketch": [], "curves": ["l1"]}]}),
|
||||
json!({"rails": [{"curves": ["l1"]}, {"sketch": {}, "curves": ["l2"]}]}),
|
||||
json!({"rails": [{"sketch": "Missing", "curves": ["l1"]}]}),
|
||||
json!({"rails": rail, "orientation": "parallel"}),
|
||||
json!({"rails": rail, "twist": "10 deg"}),
|
||||
json!({"rails": rail, "scale": 2}),
|
||||
json!({"path_sketch": true}),
|
||||
json!({"path_sketch": null}),
|
||||
json!({"path_sketch": "Missing"}),
|
||||
json!({"path": []}),
|
||||
json!({"sketch": true}),
|
||||
json!({"sketch": null}),
|
||||
json!({"sketch": "Missing"}),
|
||||
json!({"profiles": []}),
|
||||
json!({"profiles": [true]}),
|
||||
json!({"profiles": [{"point": [1]}]}),
|
||||
json!({"profiles": null}),
|
||||
json!({"participants": ["Missing"]}),
|
||||
] {
|
||||
for id in ["solid.sweep", "surface.sweep"] {
|
||||
let mut p = json!({"path_sketch": "Sketch1", "path": ["l1"]});
|
||||
p.as_object_mut().into_iter().for_each(|o| o.extend(extra.as_object().cloned().unwrap_or_default()));
|
||||
cases.push((id, p));
|
||||
}
|
||||
}
|
||||
cases.extend([
|
||||
("solid.sweep", json!({"path_sketch": "Sketch1", "path": ["l1"], "operation": "bogus"})),
|
||||
("solid.sweep", json!({"path_sketch": "Sketch1", "path": ["l1"], "operation": "auto"})),
|
||||
("surface.sweep", json!({"path_sketch": "Sketch1", "path": vec!["l1"; 1001]})),
|
||||
("surface.sweep", json!({"path_sketch": "Sketch1", "path": ["l1"], "curves": vec!["l1"; 1001]})),
|
||||
]);
|
||||
assert_strict_keeps_todays_errors(&cases);
|
||||
}
|
||||
|
||||
/// A circle `C` (radius 10, curve `c`) and a line `L` across it (`l`) on XY; a path `P` on XZ, a
|
||||
/// line 40 up the Z axis (`up`), with a rail beside it (`in`) from the circle's edge in to x = 5
|
||||
/// at the top; a block `Block` the sweep cuts a quarter from, and a plate `Plate` inside it.
|
||||
fn sweep_rig(level: ParamCheck) -> Session {
|
||||
let mut s = Session { param_check: level, ..Session::default() };
|
||||
for (id, p) in [
|
||||
("sketch.create", json!({"plane": "XY", "name": "C"})),
|
||||
("sketch.circle.center", json!({"center": [0, 0], "radius": 10, "id": "c"})),
|
||||
("sketch.finish", json!({})),
|
||||
("sketch.create", json!({"plane": "XY", "name": "L"})),
|
||||
("sketch.line", json!({"points": [[-10, 0], [10, 0]], "ids": ["l"]})),
|
||||
("sketch.finish", json!({})),
|
||||
("sketch.create", json!({"plane": "XZ", "name": "P"})),
|
||||
// The XZ sketch's y runs down world Z.
|
||||
("sketch.line", json!({"points": [[0, 0], [0, -40]], "ids": ["up"]})),
|
||||
("sketch.line", json!({"points": [[10, 0], [5, -40]], "ids": ["in"]})),
|
||||
("sketch.finish", json!({})),
|
||||
("solid.box", json!({"corner": [0, 0, 0], "length": 30, "width": 30, "height": 10, "body_name": "Block"})),
|
||||
("solid.box", json!({"corner": [-3, -3, 20], "length": 6, "width": 6, "height": 5, "body_name": "Plate"})),
|
||||
] {
|
||||
s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}"));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Every form a sweep call takes, each command with its own.
|
||||
fn sweep_calls() -> Vec<(&'static str, Value)> {
|
||||
let on = |p: Value| {
|
||||
let mut call = json!({"path_sketch": "P", "path": ["up"]});
|
||||
call.as_object_mut().into_iter().for_each(|o| o.extend(p.as_object().cloned().unwrap_or_default()));
|
||||
call
|
||||
};
|
||||
let solid = [
|
||||
json!({"sketch": "C", "name": "A", "body_name": "A"}),
|
||||
json!({"sketch": "C", "profiles": "all", "exact_regions": true, "path": "up", "orientation": "Parallel", "body_names": ["B"]}),
|
||||
json!({"sketch": "C", "profiles": [0], "orientation": [1, 0, 0], "twist": "90 deg", "scale": 0.5, "operation": "new"}),
|
||||
json!({"sketch": "C", "orientation": {"direction": {"x": 1, "y": 0, "z": 0}}, "twist": 45, "scale": "1.5"}),
|
||||
json!({"sketch": "C", "orientation": "perpendicular", "twist": null, "scale": null, "rails": null, "rail_fit": null, "touching": null}),
|
||||
json!({"sketch": "C", "rails": [{"curves": ["in"]}]}),
|
||||
json!({"sketch": "C", "rails": [{"sketch": "P", "curves": "in"}], "rail_fit": "Stretch"}),
|
||||
json!({"sketch": "C", "rails": [{"sketch": null, "curves": ["in"]}], "rail_fit": "scale"}),
|
||||
json!({"sketch": "C", "operation": "join", "targets": ["A"], "touching": "one_body", "participants": []}),
|
||||
json!({"sketch": "C", "operation": "cut", "targets": "Block", "name": "Cut"}),
|
||||
json!({"sketch": "C", "operation": "intersect", "participants": ["Plate"]}),
|
||||
];
|
||||
let surface = [
|
||||
json!({"sketch": "L", "curves": ["l"], "body_name": "W"}),
|
||||
json!({"sketch": "L", "curves": "l", "orientation": [1, 0, 0], "twist": "30 deg", "scale": 2, "body_names": ["X"]}),
|
||||
json!({"sketch": "C", "profiles": [0], "exact_regions": false, "orientation": "PARALLEL"}),
|
||||
json!({"sketch": "C", "rails": [{"sketch": "P", "curves": ["in"]}], "rail_fit": "stretch", "name": "Railed"}),
|
||||
json!({"sketch": "C", "profiles": "all", "orientation": null, "rails": null, "participants": []}),
|
||||
];
|
||||
let mut calls: Vec<(&'static str, Value)> = solid.into_iter().map(|p| ("solid.sweep", on(p))).collect();
|
||||
calls.extend(surface.into_iter().map(|p| ("surface.sweep", on(p))));
|
||||
calls
|
||||
}
|
||||
|
||||
/// `solid.sweep` and `surface.sweep` reply with `FeatureResult` in each of their forms, their
|
||||
/// contracts taking each: in Warn a contract that refused one would still run it, so the replies
|
||||
/// are checked for warnings.
|
||||
#[test]
|
||||
fn sweep_replies_match_the_output_schema_and_forms_are_accepted() {
|
||||
let schema = output_schema_for::<FeatureResult>();
|
||||
let schema = schema.as_value();
|
||||
let mut s = sweep_rig(ParamCheck::Warn);
|
||||
for (id, p) in sweep_calls() {
|
||||
let r = s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
assert!(conforms(schema, schema, &r), "{r} doesn't match {schema}");
|
||||
assert!(r.get("param_warnings").is_none(), "{id} {p}: {r}");
|
||||
}
|
||||
// Warn reports what the contracts refuse and runs the call anyway.
|
||||
for id in ["solid.sweep", "surface.sweep"] {
|
||||
let typo = s.execute(id, &json!({"sketch": "C", "path_sketch": "P", "path": ["up"], "twistt": 10})).unwrap_or_else(|e| panic!("{e}"));
|
||||
assert_eq!(typo["param_warnings"], json!(["unknown key `twistt`; did you mean `twist`?"]), "{id}");
|
||||
assert!(conforms(schema, schema, &typo), "{typo}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The check changes nothing about the replies: each call gives the same JSON with the check off
|
||||
/// and in strict (`ms`, the time it took, apart), and its keys are the ones the commands always
|
||||
/// answered with.
|
||||
#[test]
|
||||
fn sweep_replies_keep_their_old_shape_whatever_the_check() {
|
||||
let (mut off, mut strict) = (sweep_rig(ParamCheck::Off), sweep_rig(ParamCheck::Strict));
|
||||
for (id, p) in sweep_calls() {
|
||||
let run = |s: &mut Session| {
|
||||
let mut r = s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
let keys: BTreeSet<String> = r.as_object().into_iter().flat_map(|o| o.keys().cloned()).collect();
|
||||
assert!(r["ms"].is_null() || r["ms"].is_number(), "{r}");
|
||||
r["ms"] = Value::Null;
|
||||
(r, keys)
|
||||
};
|
||||
let (a, a_keys) = run(&mut off);
|
||||
let (b, _) = run(&mut strict);
|
||||
assert_eq!(a, b, "{id} {p}");
|
||||
let old = ["bodies", "changed_bodies", "created_bodies", "feature", "ms", "name", "removed_bodies", "warning"];
|
||||
assert!(old.iter().all(|k| a_keys.contains(*k)), "{a}");
|
||||
assert!(a_keys.iter().all(|k| old.contains(&k.as_str()) || matches!(k.as_str(), "component" | "notice")), "{a}");
|
||||
}
|
||||
}
|
||||
|
||||
/// What the handlers ignore or read as left out, strict refuses; off and warn run it as before.
|
||||
#[test]
|
||||
fn sweep_wrong_typed_keys_are_reported() {
|
||||
let mut cases: Vec<(&str, Value, Value)> = Vec::new();
|
||||
for id in ["solid.sweep", "surface.sweep"] {
|
||||
for (key, value) in
|
||||
[("exact_regions", json!(1)), ("name", json!(5)), ("body_name", json!(5)), ("colour", json!("red")), ("orient", json!("parallel"))]
|
||||
{
|
||||
cases.push((id, json!({ key: value }), json!(1)));
|
||||
}
|
||||
// A misspelt key inside a rail: the handler reads the rail as in the path's sketch.
|
||||
cases.push((id, json!({"rails": [{"curves": ["in"], "skecth": "P"}]}), json!(1)));
|
||||
}
|
||||
cases.push(("solid.sweep", json!({"operation": 5}), json!(1)));
|
||||
// The surface sweep doesn't combine bodies: its handler leaves these alone.
|
||||
cases.push(("surface.sweep", json!({"operation": "join", "targets": ["Block"]}), json!(2)));
|
||||
cases.push(("surface.sweep", json!({"touching": "one_body"}), json!(1)));
|
||||
for (id, extra, warnings) in cases {
|
||||
let mut p = json!({"sketch": "C", "path_sketch": "P", "path": ["up"]});
|
||||
p.as_object_mut().into_iter().for_each(|o| o.extend(extra.as_object().cloned().unwrap_or_default()));
|
||||
let mut off = sweep_rig(ParamCheck::Off);
|
||||
let r = off.execute(id, &p);
|
||||
assert!(r.is_ok(), "{id} {p}: {r:?}");
|
||||
let mut warn = sweep_rig(ParamCheck::Warn);
|
||||
let r = warn.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
assert_eq!(r["param_warnings"].as_array().map(Vec::len), warnings.as_u64().map(|n| n as usize), "{id} {p}: {r}");
|
||||
let mut strict = sweep_rig(ParamCheck::Strict);
|
||||
assert!(strict.execute(id, &p).is_err(), "{id} {p}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The sweep value types take what the handlers' readers take, and their schemas say the same,
|
||||
/// apart from what a schema can't say (case, a zero direction, the rail curves' 10 000 cap).
|
||||
#[test]
|
||||
fn sweep_values_match_the_handler_readers() {
|
||||
use crate::schema::types::{RailFitParam, SweepOrientationParam, SweepRails, SweepTwist};
|
||||
fn accepts<T: serde::de::DeserializeOwned>(v: &Value) -> bool {
|
||||
serde_json::from_value::<T>(v.clone()).is_ok()
|
||||
}
|
||||
let orientation = input_schema_for::<SweepOrientationParam>();
|
||||
for (v, accepted, schema) in [
|
||||
(json!("perpendicular"), true, true),
|
||||
(json!("parallel"), true, true),
|
||||
(json!("Parallel"), true, false),
|
||||
(json!(" parallel"), false, false),
|
||||
(json!("sideways"), false, false),
|
||||
(json!([0, 0, 1]), true, true),
|
||||
(json!({"x": 0, "y": 1, "z": 0}), true, true),
|
||||
(json!({"direction": [1, 0, 0]}), true, true),
|
||||
(json!({"direction": {"x": 1, "y": 0, "z": 0}, "extra": 1}), true, true),
|
||||
(json!([0, 0, 0]), false, true),
|
||||
(json!({"direction": [0, 0, 0]}), false, true),
|
||||
(json!([0, 1]), false, false),
|
||||
(json!([0, 0, 1e9]), false, false),
|
||||
(json!({"direction": "x"}), false, false),
|
||||
(json!({}), false, false),
|
||||
(json!(5), false, false),
|
||||
(json!(true), false, false),
|
||||
] {
|
||||
let reader = crate::cmd::sweep_orientation(&v).is_ok();
|
||||
assert_eq!(accepts::<SweepOrientationParam>(&v), reader, "{v}");
|
||||
assert_eq!(reader, accepted, "{v}");
|
||||
assert_eq!(conforms(orientation.as_value(), orientation.as_value(), &v), schema, "schema {v}");
|
||||
}
|
||||
let fit = input_schema_for::<RailFitParam>();
|
||||
for (v, accepted, schema) in [
|
||||
(json!("scale"), true, true),
|
||||
(json!("stretch"), true, true),
|
||||
(json!("STRETCH"), true, false),
|
||||
(json!("stretch "), false, false),
|
||||
(json!("wobble"), false, false),
|
||||
(json!(1), false, false),
|
||||
] {
|
||||
let reader = crate::cmd::rail_fit_form(&v).is_ok();
|
||||
assert_eq!(accepts::<RailFitParam>(&v), reader, "{v}");
|
||||
assert_eq!(reader, accepted, "{v}");
|
||||
assert_eq!(conforms(fit.as_value(), fit.as_value(), &v), schema, "schema {v}");
|
||||
}
|
||||
for (v, accepted) in
|
||||
[(json!(10), true), (json!("90 deg"), true), (json!(" a / 2 "), true), (json!(" "), false), (json!(true), false), (json!(1e9), false)]
|
||||
{
|
||||
let want = crate::params::expr(&json!({ "v": v.clone() }), "v");
|
||||
assert_eq!(serde_json::from_value::<SweepTwist>(v.clone()).ok().map(|t| t.0), want, "{v}");
|
||||
assert_eq!(want.is_some(), accepted, "{v}");
|
||||
}
|
||||
let rails = input_schema_for::<SweepRails>();
|
||||
for (v, accepted) in [
|
||||
(json!([{"curves": ["a"]}]), true),
|
||||
(json!([{"curves": "a"}]), true),
|
||||
(json!([{"sketch": "R", "curves": ["a", "b"]}, {"sketch": 3, "curves": ["c"]}]), true),
|
||||
(json!([{"sketch": null, "curves": ["a"]}]), true),
|
||||
(json!([{"curves": vec!["a"; 1000]}]), true),
|
||||
(json!([{"curves": vec!["a"; 1001]}]), false),
|
||||
(json!([]), false),
|
||||
(json!([{"curves": ["a"]}, {"curves": ["b"]}, {"curves": ["c"]}]), false),
|
||||
(json!([{}]), false),
|
||||
(json!([{"curves": []}]), false),
|
||||
(json!([{"curves": [1]}]), false),
|
||||
(json!([{"sketch": true, "curves": ["a"]}]), false),
|
||||
(json!([{"curves": ["a"], "skecth": "R"}]), false),
|
||||
(json!(["a"]), false),
|
||||
(json!({"curves": ["a"]}), false),
|
||||
(json!("a"), false),
|
||||
] {
|
||||
assert_eq!(accepts::<SweepRails>(&v), accepted, "{v}");
|
||||
assert_eq!(conforms(rails.as_value(), rails.as_value(), &v), accepted, "schema {v}");
|
||||
}
|
||||
let issue = serde_json::from_value::<SweepRails>(json!([{"curves": ["a"]}, {"curves": ["b"], "skecth": "R"}])).err().map(|e| e.to_string());
|
||||
assert!(issue.is_some_and(|e| e.ends_with("unknown key `skecth` in `rails[1]`: a rail takes `sketch` and `curves`")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feature_result_serializes_like_the_old_reply() {
|
||||
for (warning, ms) in [(None, None), (Some("a Boolean needed a retry".to_string()), Some(12.5)), (None, Some(0.0)), (None, Some(1.0 / 3.0))] {
|
||||
|
||||
@@ -88,7 +88,7 @@ fn describe_returns_both_schemas() {
|
||||
// Its own output schema lists the warnings warn mode can add.
|
||||
let own = s.execute("engine.describe", &json!({"command": "engine.describe"})).unwrap_or_else(|e| panic!("{e}"));
|
||||
assert!(own["output"]["properties"].get("param_warnings").is_some(), "{own}");
|
||||
let d = s.execute("engine.describe", &json!({"command": "solid.revolve"})).unwrap_or_else(|e| panic!("{e}"));
|
||||
let d = s.execute("engine.describe", &json!({"command": "solid.emboss"})).unwrap_or_else(|e| panic!("{e}"));
|
||||
assert!(d["input"].is_null() && d["output"].is_null(), "unconverted commands have no schemas yet");
|
||||
let e = s.execute("engine.describe", &json!({"command": "solid.extrud"})).err().map(|e| e.to_string());
|
||||
assert!(e.as_deref().is_some_and(|e| e.contains("did you mean `solid.extrude`")), "{e:?}");
|
||||
|
||||
@@ -104,6 +104,7 @@ pub mod render_shots;
|
||||
pub mod render_stage;
|
||||
pub mod render_surface_resources;
|
||||
pub mod render_ui;
|
||||
pub mod save;
|
||||
pub mod section;
|
||||
pub mod select;
|
||||
pub mod sheet;
|
||||
@@ -113,6 +114,7 @@ pub mod sketch_3d;
|
||||
pub mod sketch_clipboard;
|
||||
pub mod sketch_constraints;
|
||||
pub mod sketch_create;
|
||||
pub mod sketch_dimension;
|
||||
pub mod sketch_ends;
|
||||
pub mod sketch_equation;
|
||||
pub mod sketch_import;
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
//! Contracts and result types for the commands in `cmd/file.rs` that save the design:
|
||||
//! `file.save` and `file.save_as` (#13307).
|
||||
//!
|
||||
//! The handlers read their parameters from the raw JSON, so each key here reads as its handler
|
||||
//! reads it. `file.save_as` refuses a missing, blank or over-long `path` with its own message,
|
||||
//! so there `path` is optional in the type and required in the schema. `format` and
|
||||
//! `compression` are text in the type: the handler refuses a word it doesn't know, whichever of
|
||||
//! the two commands was called, with a message that names `file.save` (kept as it is), so the
|
||||
//! schema lists the words and the handler stays the one to refuse them. Whether the path can be
|
||||
//! written, or the design has moved or changed on disk, stays the handler's to check.
|
||||
|
||||
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
|
||||
use serde::de::Error as _;
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use super::check::whole;
|
||||
use super::motion::require;
|
||||
use super::types::not_blank_pattern;
|
||||
|
||||
/// `file.save_as`' `path` as `path_arg` reads it: text with something in it, under 4096 bytes;
|
||||
/// anything else, `null` included, is refused with the handler's message.
|
||||
fn save_as_path<'de, D: Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::String(s) if !s.trim().is_empty() && s.len() < 4096 => Ok(Some(s)),
|
||||
_ => Err(D::Error::custom(whole("`path` is required"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn save_as_path_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"type": "string", "pattern": not_blank_pattern(), "maxLength": 4095})
|
||||
}
|
||||
|
||||
fn path_required(schema: &mut Schema) {
|
||||
require(schema, "path");
|
||||
}
|
||||
|
||||
fn format_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"type": "string", "enum": ["container", "json"]})
|
||||
}
|
||||
|
||||
fn compression_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"type": "string", "enum": ["fast", "compact"]})
|
||||
}
|
||||
|
||||
/// Write the design to a file and make that file the design's own: the next `file.save` writes
|
||||
/// it again. With no `path`, the design's current file is written; a design that has none yet
|
||||
/// needs a `path`. A name that opens as another kind of file (`.step`, `.stl`, `.dxf` and the
|
||||
/// like) is refused: `file.export` writes those. Not an undo step.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct SaveParams {
|
||||
/// The design file to write. Default: the design's current file. A blank value counts as
|
||||
/// left out.
|
||||
#[serde(default)]
|
||||
pub path: Option<String>,
|
||||
/// container (`.sc`) or json. Default: json for a path ending in `.json`, otherwise
|
||||
/// container. The handler refuses any other word.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(schema_with = "format_schema")]
|
||||
pub format: Option<String>,
|
||||
/// fast or compact. Default: fast. The handler refuses any other word.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(schema_with = "compression_schema")]
|
||||
pub compression: Option<String>,
|
||||
/// Keep the evaluated model in the file, so the design opens without rebuilding. Default:
|
||||
/// true. Plain json has no room for it.
|
||||
#[serde(default)]
|
||||
pub cache: Option<bool>,
|
||||
/// Keep the display meshes in the file. Default: false.
|
||||
#[serde(default)]
|
||||
pub preview: Option<bool>,
|
||||
/// Replace the design's file even though another program or computer changed it since it was
|
||||
/// opened or saved; without it that save is refused. Only matters when the path is the
|
||||
/// design's own file. Default: false.
|
||||
#[serde(default)]
|
||||
pub overwrite: Option<bool>,
|
||||
}
|
||||
|
||||
/// Write the design to a new file (a design file name, `.sc` as a rule) and make that file the
|
||||
/// design's own. A name that opens as another kind of file (`.step`, `.stl`, `.dxf` and the like)
|
||||
/// is refused: `file.export` writes those. A design with no name of its own, or one borrowed from
|
||||
/// the file it was saved as before, takes the new file's name. Not an undo step.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = path_required)]
|
||||
pub struct SaveAsParams {
|
||||
// Optional in the type and required in the schema (`path_required`).
|
||||
/// The design file to write: text, not blank, under 4096 bytes.
|
||||
#[serde(default, deserialize_with = "save_as_path", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(schema_with = "save_as_path_schema")]
|
||||
pub path: Option<String>,
|
||||
/// container (`.sc`) or json. Default: json for a path ending in `.json`, otherwise
|
||||
/// container. The handler refuses any other word.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(schema_with = "format_schema")]
|
||||
pub format: Option<String>,
|
||||
/// fast or compact. Default: fast. The handler refuses any other word.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(schema_with = "compression_schema")]
|
||||
pub compression: Option<String>,
|
||||
/// Keep the evaluated model in the file, so the design opens without rebuilding. Default:
|
||||
/// true. Plain json has no room for it.
|
||||
#[serde(default)]
|
||||
pub cache: Option<bool>,
|
||||
/// Keep the display meshes in the file. Default: false.
|
||||
#[serde(default)]
|
||||
pub preview: Option<bool>,
|
||||
/// As for `file.save`, when `path` is the design's own file: replace it even though another
|
||||
/// program or computer changed it. Default: false.
|
||||
#[serde(default)]
|
||||
pub overwrite: Option<bool>,
|
||||
}
|
||||
|
||||
/// What `file.save` and `file.save_as` return.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct SaveResult {
|
||||
/// The file written.
|
||||
pub path: String,
|
||||
/// Its size in bytes.
|
||||
pub bytes: usize,
|
||||
/// The design's name once saved (a design with no name of its own takes the file's).
|
||||
pub name: String,
|
||||
/// A file a newer SolidCraft saved: the format number it keeps.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub kept_format: Option<String>,
|
||||
/// A newer file: what could not be kept, in sentences, when something could not.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub warnings: Option<Vec<String>>,
|
||||
/// A newer file: what could not be kept, one item each, when something could not.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub not_kept: Option<Vec<String>>,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "save_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,219 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{SaveAsParams, SaveParams, SaveResult};
|
||||
use crate::Session;
|
||||
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms};
|
||||
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
|
||||
|
||||
#[test]
|
||||
fn schemas_and_the_key_check_agree() {
|
||||
let keys = ["cache", "compression", "format", "overwrite", "path", "preview"];
|
||||
assert_keys(input_schema_for::<SaveParams>(), &keys);
|
||||
assert_keys(input_schema_for::<SaveAsParams>(), &keys);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_keeps_todays_errors() {
|
||||
assert_strict_keeps_todays_errors(&[
|
||||
("file.save", json!({})),
|
||||
("file.save", json!({"path": ""})),
|
||||
("file.save", json!({"path": " "})),
|
||||
("file.save", json!({"path": null})),
|
||||
("file.save_as", json!({})),
|
||||
("file.save_as", json!({"path": ""})),
|
||||
("file.save_as", json!({"path": " "})),
|
||||
("file.save_as", json!({"path": 5})),
|
||||
("file.save_as", json!({"path": null})),
|
||||
("file.save_as", json!({"path": ["a.sc"]})),
|
||||
("file.save_as", json!({"path": "x".repeat(4096)})),
|
||||
// Words the handler refuses itself, in its own wording (which names `file.save`).
|
||||
("file.save_as", json!({"path": "/tmp/solidcraft-save-schema-never.sc", "format": "xml"})),
|
||||
("file.save_as", json!({"path": "/tmp/solidcraft-save-schema-never.sc", "compression": "max"})),
|
||||
("file.save", json!({"path": "/tmp/solidcraft-save-schema-never.sc", "format": "xml"})),
|
||||
("file.save", json!({"path": "/tmp/solidcraft-save-schema-never.sc", "compression": "max"})),
|
||||
// A name that opens as another kind of file.
|
||||
("file.save_as", json!({"path": "/tmp/solidcraft-save-schema-never.step"})),
|
||||
("file.save", json!({"path": "/tmp/solidcraft-save-schema-never.stl"})),
|
||||
]);
|
||||
}
|
||||
|
||||
fn parse<T: serde::de::DeserializeOwned>(v: Value) -> Result<T, String> {
|
||||
serde_path_to_error::deserialize::<_, T>(&v).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// The types read each key as the handlers do: `null` is absent, `file.save` takes any text for
|
||||
/// `path` (blank means the current file) and `file.save_as` takes not-blank text under 4096
|
||||
/// bytes, with the handler's message for the rest.
|
||||
#[test]
|
||||
fn the_types_read_each_key_as_the_handlers_do() {
|
||||
let all = json!({"format": "json", "compression": "compact", "cache": false, "preview": true, "overwrite": true});
|
||||
for key in ["path", "format", "compression", "cache", "preview", "overwrite"] {
|
||||
assert!(parse::<SaveParams>(json!({ key: null })).is_ok(), "{key}: null reads as left out");
|
||||
}
|
||||
assert!(parse::<SaveParams>(json!({})).is_ok());
|
||||
assert!(parse::<SaveParams>(json!({"path": ""})).is_ok());
|
||||
assert!(parse::<SaveParams>(json!({"path": "a.sc"})).is_ok());
|
||||
let mut with_path = all.clone();
|
||||
with_path["path"] = json!("a.sc");
|
||||
assert!(parse::<SaveParams>(with_path.clone()).is_ok());
|
||||
assert!(parse::<SaveAsParams>(with_path).is_ok());
|
||||
assert!(parse::<SaveParams>(json!({"path": 5})).is_err());
|
||||
assert!(parse::<SaveParams>(json!({"cache": "yes"})).is_err());
|
||||
|
||||
assert!(parse::<SaveAsParams>(json!({})).is_ok(), "a missing path is the handler's to refuse");
|
||||
assert!(parse::<SaveAsParams>(json!({"path": "x".repeat(4095)})).is_ok());
|
||||
for bad in [json!(""), json!(" \t"), json!(7), json!(null), json!(["a"]), json!("x".repeat(4096))] {
|
||||
assert_eq!(parse::<SaveAsParams>(json!({"path": bad})).err().as_deref().map(|e| e.ends_with("`path` is required")), Some(true), "{bad}");
|
||||
}
|
||||
// The words the handler checks are left to it, in both commands.
|
||||
assert!(parse::<SaveAsParams>(json!({"path": "a.sc", "format": "xml", "compression": "max"})).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_schemas_take_what_the_handlers_read() {
|
||||
let takes = |schema: schemars::Schema, cases: &[(Value, bool)]| {
|
||||
let schema = schema.as_value();
|
||||
for (p, takes) in cases {
|
||||
assert_eq!(conforms(schema, schema, p), *takes, "{p}");
|
||||
}
|
||||
};
|
||||
takes(
|
||||
input_schema_for::<SaveParams>(),
|
||||
&[
|
||||
(json!({}), true),
|
||||
(json!({"path": "a.sc", "format": "container", "compression": "fast", "cache": true, "preview": false, "overwrite": false}), true),
|
||||
(json!({"format": "json", "compression": "compact"}), true),
|
||||
(json!({"format": "xml"}), false),
|
||||
(json!({"compression": "max"}), false),
|
||||
(json!({"cache": 1}), false),
|
||||
(json!({"path": 1}), false),
|
||||
],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<SaveAsParams>(),
|
||||
&[
|
||||
(json!({"path": "a.sc"}), true),
|
||||
(json!({"path": "a.json", "format": "json", "compression": "compact", "cache": false, "preview": true, "overwrite": true}), true),
|
||||
(json!({}), false),
|
||||
(json!({"path": "a.sc", "format": "xml"}), false),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// The `path` pattern is the not-blank one (`conforms` doesn't read patterns), with the length cap.
|
||||
#[test]
|
||||
fn save_as_path_schema_is_pinned() {
|
||||
let schema = input_schema_for::<SaveAsParams>();
|
||||
let path = &schema.as_value()["properties"]["path"];
|
||||
assert_eq!(path["pattern"], json!(crate::schema::types::not_blank_pattern()));
|
||||
assert_eq!(path["maxLength"], json!(4095));
|
||||
assert_eq!(schema.as_value()["required"], json!(["path"]));
|
||||
assert!(input_schema_for::<SaveParams>().as_value().get("required").is_none());
|
||||
}
|
||||
|
||||
/// A misspelt key is reported by the check, with the key it probably meant.
|
||||
#[test]
|
||||
fn the_check_names_what_is_wrong() {
|
||||
let issues = |id: &str, p: Value| crate::schema::issues(id, &p).into_iter().map(|i| i.message).collect::<Vec<_>>();
|
||||
assert_eq!(issues("file.save", json!({"overwite": true})), ["unknown key `overwite`; did you mean `overwrite`?"]);
|
||||
assert_eq!(issues("file.save_as", json!({"path": "a.sc", "cache": "no"})), ["`cache` must be true or false"]);
|
||||
assert_eq!(issues("file.save_as", json!({"path": " "})), ["`path` is required"]);
|
||||
assert_eq!(issues("file.save", json!({"path": "a.sc", "format": "container"})), Vec::<String>::new());
|
||||
}
|
||||
|
||||
fn folder(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-save-schema-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap_or_else(|e| panic!("{e}"));
|
||||
dir
|
||||
}
|
||||
|
||||
fn matches(id: &str, reply: &Value) {
|
||||
let schema = output_schema_for::<SaveResult>();
|
||||
let schema = schema.as_value();
|
||||
assert!(conforms(schema, schema, reply), "{id}: {reply} doesn't match {schema}");
|
||||
assert!(reply.get("param_warnings").is_none(), "{id}: {reply}");
|
||||
}
|
||||
|
||||
/// Every form of both commands, in Warn: no `param_warnings`, and each reply matches the output
|
||||
/// schema and is what the handler returned before (the keys a plain save has, nothing else).
|
||||
#[test]
|
||||
fn save_replies_match_the_output_schema() {
|
||||
let dir = folder("replies");
|
||||
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
|
||||
let mut run = |id: &str, p: Value| s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
run("solid.box", json!({"length": 10, "width": 20, "height": 5}));
|
||||
let design = dir.join("part.sc");
|
||||
let first = run("file.save_as", json!({"path": design}));
|
||||
assert_eq!(first["path"], json!(design), "{first}");
|
||||
assert!(first["bytes"].as_u64().is_some_and(|b| b > 0) && first["name"] == "part", "{first}");
|
||||
let mut keys: Vec<_> = first.as_object().map(|o| o.keys().cloned().collect()).unwrap_or_default();
|
||||
keys.sort();
|
||||
assert_eq!(keys, ["bytes", "name", "path"], "{first}");
|
||||
matches("file.save_as", &first);
|
||||
matches("file.save", &run("file.save", json!({})));
|
||||
matches("file.save", &run("file.save", json!({"path": null, "format": null})));
|
||||
matches("file.save", &run("file.save", json!({"path": ""})));
|
||||
matches("file.save", &run("file.save", json!({"path": design, "compression": "compact", "cache": false, "preview": true, "overwrite": true})));
|
||||
matches("file.save", &run("file.save", json!({"path": design, "format": "container", "compression": "fast"})));
|
||||
let json_file = dir.join("part.json");
|
||||
matches("file.save_as", &run("file.save_as", json!({"path": json_file})));
|
||||
matches("file.save_as", &run("file.save_as", json!({"path": dir.join("other.sc"), "format": "json", "overwrite": false})));
|
||||
matches("file.save", &run("file.save", json!({"path": dir.join("again.sc"), "preview": false, "cache": true})));
|
||||
|
||||
// A misspelt key still runs in Warn, says so, and the reply still matches the schema.
|
||||
let warned = run("file.save", json!({"path": design, "overwite": true}));
|
||||
let warnings = warned["param_warnings"].as_array().cloned().unwrap_or_default();
|
||||
assert_eq!(warnings.len(), 1, "{warned}");
|
||||
assert!(warnings[0].as_str().is_some_and(|w| w.contains("overwite") && w.contains("overwrite")), "{warned}");
|
||||
let schema = output_schema_for::<SaveResult>();
|
||||
assert!(conforms(schema.as_value(), schema.as_value(), &warned), "{warned}");
|
||||
let warned = run("file.save_as", json!({"path": design, "compresion": "fast"}));
|
||||
assert!(warned["param_warnings"].as_array().is_some_and(|w| w.len() == 1), "{warned}");
|
||||
// In Strict the same misspelling is refused.
|
||||
let mut strict = Session { param_check: ParamCheck::Strict, ..Session::default() };
|
||||
assert!(strict.execute("file.save_as", &json!({"path": design, "overwite": true})).is_err());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A file a newer SolidCraft saved keeps its format number: the reply carries it and matches. What
|
||||
/// could not be kept (`warnings`, `not_kept`) is part of the type, and serializes as the handler
|
||||
/// has always written it.
|
||||
#[test]
|
||||
fn a_newer_files_reply_matches_the_output_schema() {
|
||||
let dir = folder("newer");
|
||||
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
|
||||
let run = |s: &mut Session, id: &str, p: Value| s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 20, "height": 5}));
|
||||
let plain = dir.join("plain.json");
|
||||
run(&mut s, "file.save_as", json!({"path": plain}));
|
||||
let mut file: Value = serde_json::from_str(&std::fs::read_to_string(&plain).unwrap_or_default()).unwrap_or_default();
|
||||
file["format"] = json!(solidcraft_doc::format::format_string(solidcraft_doc::format::FORMAT + 1));
|
||||
file["future_section"] = json!({"anything": [1, 2, 3]});
|
||||
file["features"][0]["draft_v2"] = json!({"angle": 3.0});
|
||||
let newer = dir.join("newer.json");
|
||||
std::fs::write(&newer, file.to_string()).unwrap_or_else(|e| panic!("{e}"));
|
||||
run(&mut s, "doc.open", json!({"path": newer}));
|
||||
let saved = run(&mut s, "file.save", json!({}));
|
||||
assert_eq!(saved["kept_format"], file["format"], "{saved}");
|
||||
assert!(saved.get("warnings").is_none() && saved.get("not_kept").is_none(), "{saved}");
|
||||
matches("file.save", &saved);
|
||||
let lost = serde_json::to_value(SaveResult {
|
||||
path: "a.sc".into(),
|
||||
bytes: 3,
|
||||
name: "a".into(),
|
||||
kept_format: Some("solidcraft/99".into()),
|
||||
warnings: Some(vec!["Not kept: x. The previous file is `a.sc.bak`.".into()]),
|
||||
not_kept: Some(vec!["x".into()]),
|
||||
param_warnings: Vec::new(),
|
||||
})
|
||||
.unwrap_or_default();
|
||||
assert_eq!(
|
||||
lost,
|
||||
json!({"path": "a.sc", "bytes": 3, "name": "a", "kept_format": "solidcraft/99", "warnings": ["Not kept: x. The previous file is `a.sc.bak`."], "not_kept": ["x"]})
|
||||
);
|
||||
matches("file.save", &lost);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -375,6 +375,14 @@ pub enum TangentMade {
|
||||
constraints: Vec<String>,
|
||||
/// The sketch's solve: `{solved, dof}`.
|
||||
sketch: Value,
|
||||
/// `true` when they already held and other constraints already determined them: they are
|
||||
/// kept, shown as redundant (not when `if_redundant` is `refuse`, which refuses the call
|
||||
/// instead). Absent otherwise.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
redundant: Option<bool>,
|
||||
/// With `redundant`: the constraints that determine the Tangent, as "id (Name)".
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
determined_by: Option<Vec<String>>,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
//! Contract and result type for the command in `cmd/sketch.rs`: `sketch.dimension`.
|
||||
//!
|
||||
//! The handler reads its parameters from the raw JSON, so each key here reads as the handler reads
|
||||
//! it and fails with the handler's message. What only the design can tell (a sketch, curve or point
|
||||
//! that exists, a model edge that projects to something, a name that is free, whether the
|
||||
//! combination of entities can be dimensioned) stays the handler's to check. `entities` is
|
||||
//! optional in the type, so the handler keeps its own message when it is left out, and required in
|
||||
//! the schema.
|
||||
//!
|
||||
//! The handler reads some keys only in some cases, and so does the contract: a driven dimension
|
||||
//! (`driven: true`) measures and has no value, so its `value` (apart from a definition, which
|
||||
//! the handler refuses by itself), `scale_sketch` and `if_redundant` are not read and not checked.
|
||||
//! `if_redundant` is read only once the dimension turns out to be determined by the sketch.
|
||||
//!
|
||||
//! The handler reads a few keys more leniently than a type can (a `type` that is not text counts
|
||||
//! as `auto`, a `driven` that is not true or false as false): the type refuses them, which is what
|
||||
//! warn reports and strict refuses. A call with several problems may get a different one first in
|
||||
//! strict than with the check off (the check goes key by key, the handler in its own order).
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
|
||||
use serde::de::Error as _;
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use super::check::whole;
|
||||
use super::motion::require;
|
||||
use super::sketch_ends::SketchTarget;
|
||||
use super::types::{LengthExpr, Point2, Point3};
|
||||
use crate::cmd::{MAX_PROJECTED_ENTITIES, model_ref};
|
||||
use crate::params::json_brief;
|
||||
|
||||
/// One or two entities to dimension: a curve or point id (`l1`, `p3`, `l1.start`), `x` or `y` (a
|
||||
/// sketch axis), `mid:<line id>` (a line's midpoint), or a model edge `{edge: [x, y, z], body?}`
|
||||
/// or vertex `{vertex: [x, y, z], body?}`, which the handler projects into the sketch first. One
|
||||
/// id may be given as text rather than a list.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct DimensionEntities(pub Vec<Value>);
|
||||
|
||||
impl DimensionEntities {
|
||||
/// The entities `v` lists, or the handler's message. In the handler's order: a list of more
|
||||
/// than 1000 is refused before anything, then the entries that are not names, then the count.
|
||||
/// A model reference is left to the handler: it looks up the sketch before it reads the form
|
||||
/// of the reference, so a reference that is no form of one is its to refuse (with "no sketch
|
||||
/// is being edited" when there is no sketch, which only the session can tell).
|
||||
fn read(v: &Value) -> Result<DimensionEntities, String> {
|
||||
let list: Vec<Value> = match v {
|
||||
Value::String(_) => vec![v.clone()],
|
||||
Value::Array(list) => {
|
||||
if list.len() > MAX_PROJECTED_ENTITIES {
|
||||
return Err(format!("`entities` may list at most {MAX_PROJECTED_ENTITIES} entities"));
|
||||
}
|
||||
if let Some((i, e)) = list.iter().enumerate().find(|(_, e)| !e.is_string() && !model_ref(e)) {
|
||||
return Err(format!("`entities` must list names; the entry {} is {}", i + 1, json_brief(e)));
|
||||
}
|
||||
list.clone()
|
||||
}
|
||||
other => return Err(format!("`entities` must be a list of names; it is {}", json_brief(other))),
|
||||
};
|
||||
if list.is_empty() || list.len() > 2 {
|
||||
return Err("`entities` must list one or two sketch entities".into());
|
||||
}
|
||||
Ok(DimensionEntities(list))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for DimensionEntities {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"DimensionEntities".into()
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
let point = generator.subschema_for::<Point3>();
|
||||
let model = |key: &str| {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {key: point, "body": {"type": "string", "description": "The body's name. Default: any body. Text only; anything else is read as left out."}},
|
||||
"required": [key]
|
||||
})
|
||||
};
|
||||
let one = serde_json::json!({"anyOf": [{"type": "string"}, model("edge"), model("vertex")]});
|
||||
json_schema!({
|
||||
"description": "One or two entities: a curve or point id (`l1`, `p3`, `l1.start`, `c1.center`), `x` or `y` (a sketch axis), `mid:<line id>` (a line's midpoint), or a model edge `{edge: [x, y, z], body?}` or vertex `{vertex: [x, y, z], body?}`, projected into the sketch first and measured there. One id may be given as text instead of a list.",
|
||||
"anyOf": [{"type": "string"}, {"type": "array", "items": one, "minItems": 1, "maxItems": 2}]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Dimension one or two sketch entities: a length, distance, radius, diameter, angle or ellipse
|
||||
/// half-axis, driving the sketch (it adds a parameter) or only measuring (`driven: true`). The
|
||||
/// kind follows from the entities, `type` and where `text_at` is. One undo step.
|
||||
#[derive(Debug, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = entities_required)]
|
||||
pub struct SketchDimensionParams {
|
||||
// Optional in the type and required in the schema (`entities_required`): left out, the
|
||||
// handler refuses it with its own message, not the check's "is required".
|
||||
/// The entities to dimension, one or two.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "DimensionEntities")]
|
||||
pub entities: Option<DimensionEntities>,
|
||||
/// The sketch (default: the one being edited).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "SketchTarget")]
|
||||
pub sketch: Option<SketchTarget>,
|
||||
/// What to measure, in any case: auto (the default: from the entities and where `text_at`
|
||||
/// is), distance, horizontal (or distance_x), vertical (or distance_y), length, radius,
|
||||
/// diameter, major or minor (an ellipse's half-axis), angle, sweep (an arc's angle),
|
||||
/// arc_length (arclength), overall_length (a slot's overall length), offset. Other text is no
|
||||
/// known type: the entities' default measure (a length or a radius), with no choice by
|
||||
/// `text_at`.
|
||||
#[serde(default, rename = "type", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "String")]
|
||||
pub kind: Option<String>,
|
||||
/// With type auto or distance: `horizontal` or `vertical` measures along that axis, as
|
||||
/// type does. Any other text is ignored.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "String")]
|
||||
pub orientation: Option<String>,
|
||||
/// The dimension's value: a number (in the design's units, or degrees for an angle) or an
|
||||
/// expression such as `"width / 2"` or `"50 mm"`; or `name = expression` (also `#name =
|
||||
/// expression`), which adds the user parameter `name` and dimensions with it (refused when the
|
||||
/// name is taken or reserved; the reply's `defined` names it). Default: what the geometry
|
||||
/// measures now. Not read for a driven dimension, which has no value (a definition is refused
|
||||
/// there).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "LengthExpr")]
|
||||
pub value: Option<LengthExpr>,
|
||||
/// Where the dimension's text goes, in sketch coordinates [x, y] (mm): with type auto it also
|
||||
/// chooses what is measured (horizontal or vertical distance, an angle's sector, an ellipse's
|
||||
/// half-axis).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "Point2")]
|
||||
pub text_at: Option<Point2>,
|
||||
/// True to place a driven (reference) dimension: it measures, has no parameter and does not
|
||||
/// constrain. Default: false.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "bool")]
|
||||
pub driven: Option<bool>,
|
||||
/// When this is the sketch's first length dimension and nothing in the sketch fixes a size
|
||||
/// (no other driving length dimension, nothing fixed or projected), scale the whole sketch
|
||||
/// about its origin to the value instead of distorting it (the reply's `scaled` is the factor).
|
||||
/// Default: false. Not read for a driven dimension.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "bool")]
|
||||
pub scale_sketch: Option<bool>,
|
||||
/// For an ellipse with type auto: `major` or `minor`, the half-axis an earlier placement
|
||||
/// chose, kept while `text_at` is near the boundary between them (any case; other text is
|
||||
/// ignored).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "String")]
|
||||
pub previous_axis: Option<String>,
|
||||
/// For an ellipse with type auto: true gives the half-axis `text_at` picks, false the minor
|
||||
/// radius wherever `text_at` is. Default: true.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "bool")]
|
||||
pub half_axes: Option<bool>,
|
||||
/// `driven` (any case): when the sketch already determines the dimension, place it as a
|
||||
/// driven (reference) dimension instead of refusing it; the reply then has `redundant` and
|
||||
/// `determined_by`. Any other text refuses it. Read only when the dimension turns out to be
|
||||
/// determined, and not for a driven dimension.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "String")]
|
||||
pub if_redundant: Option<String>,
|
||||
}
|
||||
|
||||
fn entities_required(schema: &mut Schema) {
|
||||
require(schema, "entities");
|
||||
}
|
||||
|
||||
/// `null` is a key left out, as the handler's readers read it.
|
||||
fn given<'a>(o: &'a Map<String, Value>, key: &str) -> Option<&'a Value> {
|
||||
o.get(key).filter(|v| !v.is_null())
|
||||
}
|
||||
|
||||
/// A key's text, or its message.
|
||||
fn text(o: &Map<String, Value>, key: &str) -> Result<Option<String>, String> {
|
||||
match given(o, key) {
|
||||
None => Ok(None),
|
||||
Some(Value::String(s)) => Ok(Some(s.clone())),
|
||||
Some(_) => Err(format!("`{key}` must be text")),
|
||||
}
|
||||
}
|
||||
|
||||
/// A key's true or false, or its message (`message`, when the handler has its own).
|
||||
fn flag(o: &Map<String, Value>, key: &str, message: Option<&str>) -> Result<Option<bool>, String> {
|
||||
match given(o, key) {
|
||||
None => Ok(None),
|
||||
Some(Value::Bool(b)) => Ok(Some(*b)),
|
||||
Some(_) => Err(message.map_or_else(|| format!("`{key}` must be true or false"), str::to_string)),
|
||||
}
|
||||
}
|
||||
|
||||
/// A key read by a type's own `Deserialize`, which says what is wrong ("must be …").
|
||||
fn typed<'a, T: Deserialize<'a>>(o: &'a Map<String, Value>, key: &str) -> Result<Option<T>, String> {
|
||||
match given(o, key) {
|
||||
None => Ok(None),
|
||||
Some(v) => T::deserialize(v).map(Some).map_err(|e| format!("`{key}` {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
impl SketchDimensionParams {
|
||||
/// The keys the handler reads, in its order and in the cases it reads them.
|
||||
fn read(o: &Map<String, Value>) -> Result<SketchDimensionParams, String> {
|
||||
let entities = given(o, "entities").map(DimensionEntities::read).transpose()?;
|
||||
// `entities: null` and a missing one are both "must list one or two sketch entities".
|
||||
let kind = text(o, "type")?;
|
||||
let orientation = text(o, "orientation")?;
|
||||
let text_at = typed::<Point2>(o, "text_at")?;
|
||||
let driven = flag(o, "driven", None)?;
|
||||
let previous_axis = text(o, "previous_axis")?;
|
||||
let half_axes = flag(o, "half_axes", Some("`half_axes` must be true or false"))?;
|
||||
// The sketch is read after the entities; a model reference among them is left to the
|
||||
// handler, which looks the sketch up first (see `DimensionEntities::read`).
|
||||
let sketch = typed::<SketchTarget>(o, "sketch")?;
|
||||
let (mut value, mut scale_sketch, mut if_redundant) = (None, None, None);
|
||||
// A driven dimension measures: it has no value to read, nothing to scale and nothing it
|
||||
// could be redundant with.
|
||||
if driven != Some(true) {
|
||||
value = typed::<LengthExpr>(o, "value")?;
|
||||
scale_sketch = flag(o, "scale_sketch", None)?;
|
||||
if_redundant = text(o, "if_redundant")?;
|
||||
}
|
||||
Ok(SketchDimensionParams {
|
||||
entities,
|
||||
sketch,
|
||||
kind,
|
||||
orientation,
|
||||
value,
|
||||
text_at,
|
||||
driven,
|
||||
scale_sketch,
|
||||
previous_axis,
|
||||
half_axes,
|
||||
if_redundant,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for SketchDimensionParams {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let o = Map::<String, Value>::deserialize(d)?;
|
||||
SketchDimensionParams::read(&o).map_err(|why| D::Error::custom(whole(why)))
|
||||
}
|
||||
}
|
||||
|
||||
/// What `sketch.dimension` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct DimensionResult {
|
||||
/// The parameter that holds the dimension's value (`d1`, or the name a `name = expression`
|
||||
/// value defined); null for a driven dimension, which has none.
|
||||
pub param: Option<String>,
|
||||
/// `true` for a driven (reference) dimension, as asked or placed instead of a redundant one.
|
||||
/// Absent for a driving one.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub driven: Option<bool>,
|
||||
/// What the dimension is: `LinearDimension`, `RadialDimension`, `DiameterDimension`,
|
||||
/// `AngularDimension`, `ArcLengthDimension` or `LinearDiameterDimension`.
|
||||
#[serde(rename = "type")]
|
||||
pub kind: String,
|
||||
/// The parameter's expression, as the design stores it, in the design's units (a driving
|
||||
/// dimension only).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub expression: Option<String>,
|
||||
/// What the dimension measures on the solved sketch, in `unit`.
|
||||
pub measured: f64,
|
||||
/// `mm` for a length or `deg` for an angle.
|
||||
pub unit: String,
|
||||
/// The sketch's solve: `{solved, dof}`.
|
||||
pub sketch: Value,
|
||||
/// The factor the whole sketch was scaled by (`scale_sketch`), when it was.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub scaled: Option<f64>,
|
||||
/// For an ellipse: the half-axis the dimension measures, `major` or `minor`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub axis: Option<String>,
|
||||
/// The user parameter a `name = expression` value added.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub defined: Option<String>,
|
||||
/// `true` when the sketch already determined the dimension and `if_redundant` was `driven`:
|
||||
/// it was placed as a driven dimension.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub redundant: Option<bool>,
|
||||
/// With `redundant`: the constraints that determine it, as "id (Name)".
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub determined_by: Option<Vec<String>>,
|
||||
/// What the command added to its sketch, `{points, curves, constraints, dimensions}` (lists
|
||||
/// of ids): the session adds it to the reply of a sketch command, so the handler leaves it out.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub created: Option<Value>,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sketch_dimension_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,352 @@
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{DimensionResult, SketchDimensionParams};
|
||||
use crate::Session;
|
||||
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms};
|
||||
use crate::schema::{ParamCheck, input_schema_for, issues, output_schema_for};
|
||||
|
||||
#[test]
|
||||
fn schemas_and_the_key_check_agree() {
|
||||
assert_keys(
|
||||
input_schema_for::<SketchDimensionParams>(),
|
||||
&["driven", "entities", "half_axes", "if_redundant", "orientation", "previous_axis", "scale_sketch", "sketch", "text_at", "type", "value"],
|
||||
);
|
||||
// Only `entities` is required, and the type leaves it to the handler's message.
|
||||
let schema = input_schema_for::<SketchDimensionParams>();
|
||||
assert_eq!(schema.as_value()["required"], json!(["entities"]));
|
||||
assert!(serde_json::from_value::<SketchDimensionParams>(json!({})).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_keeps_todays_errors() {
|
||||
assert_strict_keeps_todays_errors(&[
|
||||
("sketch.dimension", json!({})),
|
||||
("sketch.dimension", json!({"entities": null})),
|
||||
("sketch.dimension", json!({"entities": []})),
|
||||
("sketch.dimension", json!({"entities": ["l1", "l2", "l3"]})),
|
||||
("sketch.dimension", json!({"entities": 5})),
|
||||
("sketch.dimension", json!({"entities": true})),
|
||||
("sketch.dimension", json!({"entities": {"edge": [0, 0, 0]}})),
|
||||
("sketch.dimension", json!({"entities": [1]})),
|
||||
("sketch.dimension", json!({"entities": ["l1", null]})),
|
||||
("sketch.dimension", json!({"entities": [{"foo": 1}]})),
|
||||
("sketch.dimension", json!({"entities": [["l1"]]})),
|
||||
("sketch.dimension", json!({"entities": vec!["l1"; 1001]})),
|
||||
("sketch.dimension", json!({"entities": [{"edge": 5}]})),
|
||||
("sketch.dimension", json!({"entities": [{"vertex": [1, 2]}]})),
|
||||
// The handler looks the sketch up before it reads a model reference's form.
|
||||
("sketch.dimension", json!({"entities": [{"edge": 5}], "sketch": true})),
|
||||
("sketch.dimension", json!({"entities": ["l1"], "half_axes": "no"})),
|
||||
("sketch.dimension", json!({"entities": ["l1"], "half_axes": 1})),
|
||||
("sketch.dimension", json!({"entities": ["l1"], "half_axes": []})),
|
||||
("sketch.dimension", json!({"entities": ["l1"], "sketch": true})),
|
||||
("sketch.dimension", json!({"entities": ["l1"], "sketch": -1})),
|
||||
("sketch.dimension", json!({"entities": ["l1"], "sketch": 2.5})),
|
||||
("sketch.dimension", json!({"entities": ["l1"], "sketch": [1]})),
|
||||
]);
|
||||
}
|
||||
|
||||
/// A session that is editing a sketch "Plan" with two lines, a circle and two more lines (l3, l4).
|
||||
fn editing(level: ParamCheck) -> Session {
|
||||
let mut s = Session { param_check: level, ..Session::default() };
|
||||
for (id, p) in [
|
||||
("sketch.create", json!({"plane": "XY", "name": "Plan"})),
|
||||
("sketch.line", json!({"points": [[0, 0], [10, 0.5]]})),
|
||||
("sketch.line", json!({"points": [[0, 20], [10, 30]]})),
|
||||
("sketch.circle.center", json!({"center": [50, 0], "radius": 5})),
|
||||
("sketch.line", json!({"points": [[0, 40], [10, 40]]})),
|
||||
("sketch.line", json!({"points": [[0, 40], [5, 50]]})),
|
||||
] {
|
||||
s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}"));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// The same refusals in a sketch being edited, where the handler goes on to read the sketch and
|
||||
/// its entities, and the ones the design settles: strict says what the handler says.
|
||||
#[test]
|
||||
fn strict_keeps_todays_errors_in_a_sketch() {
|
||||
let cases = [
|
||||
json!({}),
|
||||
json!({"entities": null}),
|
||||
json!({"entities": []}),
|
||||
json!({"entities": ["l1", "l2", "c1"]}),
|
||||
json!({"entities": 5}),
|
||||
json!({"entities": [1]}),
|
||||
json!({"entities": ["l1", {"x": 1}]}),
|
||||
json!({"entities": vec!["l1"; 1001]}),
|
||||
json!({"entities": [{"edge": [1, 2]}]}),
|
||||
json!({"entities": [{"edge": 5}], "sketch": "Plan"}),
|
||||
json!({"entities": ["l1"], "half_axes": "no"}),
|
||||
json!({"entities": ["l1"], "half_axes": 0, "sketch": true}),
|
||||
json!({"entities": ["l1"], "sketch": true}),
|
||||
json!({"entities": ["l1"], "sketch": -1}),
|
||||
json!({"entities": ["l1"], "sketch": 1.5}),
|
||||
// What the design settles: the contract passes these on.
|
||||
json!({"entities": ["nope"]}),
|
||||
json!({"entities": ["l1"], "sketch": "Missing"}),
|
||||
json!({"entities": ["l1", "c1", "l2"]}),
|
||||
json!({"entities": ["l1"], "value": "w ="}),
|
||||
json!({"entities": ["l1"], "value": "w = 5", "driven": true}),
|
||||
json!({"entities": ["l1"], "value": "w = 5 +"}),
|
||||
json!({"entities": ["c1", "c1"]}),
|
||||
];
|
||||
for (index, p) in cases.iter().enumerate() {
|
||||
let error = |level: ParamCheck| editing(level).execute("sketch.dimension", p).err().map(|e| e.to_string());
|
||||
let off = error(ParamCheck::Off);
|
||||
assert!(off.is_some(), "{index} {p} should fail today");
|
||||
assert_eq!(error(ParamCheck::Strict), off, "{index} {p}");
|
||||
}
|
||||
}
|
||||
|
||||
/// What strict refuses on purpose: a value of the wrong type that the handler reads as if the key
|
||||
/// were left out. Warn names each one and the call still runs.
|
||||
#[test]
|
||||
fn strict_refuses_what_the_handler_ignores() {
|
||||
let cases = [
|
||||
(json!({"entities": ["l1"], "type": 5}), "`type` must be text"),
|
||||
(json!({"entities": ["l1"], "orientation": true}), "`orientation` must be text"),
|
||||
(json!({"entities": ["l1"], "value": true}), "`value` must be a number or an expression"),
|
||||
(json!({"entities": ["l1"], "value": ""}), "`value` must be a number or an expression"),
|
||||
(json!({"entities": ["l1"], "value": 1e12}), "`value` must be a number between -1e9 and 1e9"),
|
||||
(json!({"entities": ["l1"], "text_at": [1]}), "`text_at` must be a point [x, y] of numbers"),
|
||||
(json!({"entities": ["l1"], "driven": "yes"}), "`driven` must be true or false"),
|
||||
(json!({"entities": ["l1"], "scale_sketch": 1}), "`scale_sketch` must be true or false"),
|
||||
(json!({"entities": ["l1"], "previous_axis": 1}), "`previous_axis` must be text"),
|
||||
(json!({"entities": ["l1"], "if_redundant": 5}), "`if_redundant` must be text"),
|
||||
(json!({"entities": ["l1"], "drivn": true}), "unknown key `drivn`; did you mean `driven`?"),
|
||||
];
|
||||
for (p, message) in cases {
|
||||
let found = issues("sketch.dimension", &p).into_iter().map(|i| i.message).collect::<Vec<_>>();
|
||||
assert_eq!(found, [message], "{p}");
|
||||
let strict = editing(ParamCheck::Strict).execute("sketch.dimension", &p).err().map(|e| e.to_string()).unwrap_or_default();
|
||||
assert!(strict.contains(message), "{p}: {strict}");
|
||||
let off = editing(ParamCheck::Off).execute("sketch.dimension", &p);
|
||||
assert!(off.is_ok(), "{p} works with the check off: {off:?}");
|
||||
}
|
||||
}
|
||||
|
||||
fn parses(v: Value) -> bool {
|
||||
serde_json::from_value::<SketchDimensionParams>(v).is_ok()
|
||||
}
|
||||
|
||||
/// A driven dimension has no value, no scale and nothing it could be redundant with: those keys
|
||||
/// are not read for it (the handler never looks at them), and are for a driving one. The other
|
||||
/// keys are read either way.
|
||||
#[test]
|
||||
fn a_driven_dimension_reads_only_what_the_handler_reads() {
|
||||
for key in [("value", json!(true)), ("scale_sketch", json!("yes")), ("if_redundant", json!(7)), ("value", json!(""))] {
|
||||
let (k, v) = key;
|
||||
let driving = json!({"entities": ["l1"], k: v.clone()});
|
||||
let driven = json!({"entities": ["l1"], "driven": true, k: v.clone()});
|
||||
assert!(!parses(driving), "{k} {v} is read for a driving dimension");
|
||||
assert!(parses(driven), "{k} {v} is not read for a driven one");
|
||||
assert!(issues("sketch.dimension", &json!({"entities": ["l1"], "driven": true, k: v})).is_empty());
|
||||
}
|
||||
// `driven: false` and `null` are driving.
|
||||
for driven in [json!(false), json!(null)] {
|
||||
assert!(!parses(json!({"entities": ["l1"], "driven": driven, "value": true})));
|
||||
}
|
||||
// Read in both: the type, where the text goes, the ellipse keys and the sketch.
|
||||
for key in [
|
||||
("type", json!(1)),
|
||||
("orientation", json!(1)),
|
||||
("text_at", json!("x")),
|
||||
("previous_axis", json!([])),
|
||||
("half_axes", json!(1)),
|
||||
("sketch", json!(true)),
|
||||
] {
|
||||
let (k, v) = key;
|
||||
assert!(!parses(json!({"entities": ["l1"], "driven": true, k: v.clone()})), "{k} {v} when driven");
|
||||
assert!(!parses(json!({"entities": ["l1"], k: v.clone()})), "{k} {v}");
|
||||
}
|
||||
// The handler's own refusal of a definition on a driven dimension is left to it.
|
||||
assert!(parses(json!({"entities": ["l1"], "driven": true, "value": "w = 5"})));
|
||||
}
|
||||
|
||||
/// Each key reads as the handler reads it: `null` is left out, entities are names or model
|
||||
/// references, a value is a number or an expression, a sketch an id or a name.
|
||||
#[test]
|
||||
fn the_types_read_each_key_like_the_handler() {
|
||||
assert!(parses(json!({"entities": null, "sketch": null, "type": null, "orientation": null, "value": null, "text_at": null})));
|
||||
assert!(parses(json!({"driven": null, "scale_sketch": null, "previous_axis": null, "half_axes": null, "if_redundant": null})));
|
||||
for entities in [
|
||||
json!("l1"),
|
||||
json!(["l1"]),
|
||||
json!(["l1", "p2"]),
|
||||
json!(["l1.start", "mid:l2"]),
|
||||
json!(["x", "c1.center"]),
|
||||
json!(["p1", {"edge": [1, 2, 3], "body": "Flange"}]),
|
||||
json!([{"vertex": [1, 2, 3]}, {"edge": {"x": 1, "y": 2, "z": 3}}]),
|
||||
// Forms the handler passes on to the design: a body, a sketch or a plane in place of a point.
|
||||
json!([{"edge": 5, "body": "B"}]),
|
||||
] {
|
||||
assert!(parses(json!({"entities": entities})), "{entities}");
|
||||
}
|
||||
for entities in [json!(5), json!([5]), json!([null]), json!([{"face": [1, 2, 3]}]), json!([{}]), json!({"edge": [1, 2, 3]}), json!([["a"]])] {
|
||||
assert!(!parses(json!({"entities": entities})), "{entities}");
|
||||
}
|
||||
// The count is the handler's: one or two.
|
||||
assert!(!parses(json!({"entities": []})) && !parses(json!({"entities": ["a", "b", "c"]})));
|
||||
// A value: a number, or an expression of up to 4096 characters, including `name = expression`.
|
||||
for value in [json!(2), json!(-1.5), json!("width / 2"), json!("50 mm"), json!("w = 40"), json!("#w = 4 mm")] {
|
||||
assert!(parses(json!({"entities": ["l1"], "value": value})), "{value}");
|
||||
}
|
||||
for value in [json!(true), json!([1]), json!({}), json!(""), json!(" "), json!("x".repeat(4097))] {
|
||||
assert!(!parses(json!({"entities": ["l1"], "value": value})), "{value}");
|
||||
}
|
||||
// Points as `params::vec2` reads them.
|
||||
assert!(parses(json!({"entities": ["l1"], "text_at": [1, 2]})) && parses(json!({"entities": ["l1"], "text_at": {"x": 1, "y": 2}})));
|
||||
assert!(!parses(json!({"entities": ["l1"], "text_at": [1, 2, 3]})) && !parses(json!({"entities": ["l1"], "text_at": [1, "2"]})));
|
||||
// The type is text of any content: an unknown word is never refused (the handler measures the
|
||||
// entities' default for it).
|
||||
assert!(parses(json!({"entities": ["l1"], "type": "ARC_LENGTH", "orientation": "Horizontal", "previous_axis": "major"})));
|
||||
assert!(parses(json!({"entities": ["l1"], "type": "no such type", "if_redundant": "refuse"})));
|
||||
// A sketch: a whole number or text.
|
||||
for sketch in [json!(3), json!("Plan"), json!("7")] {
|
||||
assert!(parses(json!({"entities": ["l1"], "sketch": sketch})), "{sketch}");
|
||||
}
|
||||
for sketch in [json!(-1), json!(1.5), json!(false), json!([1])] {
|
||||
assert!(!parses(json!({"entities": ["l1"], "sketch": sketch})), "{sketch}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The schema says what the handler takes: `entities` is required, and a driven dimension may
|
||||
/// carry the keys that only a driving one reads.
|
||||
#[test]
|
||||
fn the_schema_takes_what_the_handler_reads() {
|
||||
let schema = input_schema_for::<SketchDimensionParams>();
|
||||
let schema = schema.as_value();
|
||||
for (p, takes) in [
|
||||
(json!({"entities": ["l1"]}), true),
|
||||
(json!({"entities": "l1", "type": "radius", "value": "r = 5", "text_at": [1, 2], "sketch": 3}), true),
|
||||
(json!({"entities": ["a", {"edge": [1, 2, 3], "body": "B"}], "driven": true, "if_redundant": "driven", "scale_sketch": true}), true),
|
||||
(json!({"entities": ["e"], "half_axes": false, "previous_axis": "minor", "orientation": "vertical"}), true),
|
||||
(json!({}), false),
|
||||
(json!({"value": 3}), false),
|
||||
(json!({"entities": []}), false),
|
||||
(json!({"entities": ["a", "b", "c"]}), false),
|
||||
(json!({"entities": ["a"], "value": true}), false),
|
||||
(json!({"entities": ["a"], "driven": 1}), false),
|
||||
(json!({"entities": ["a"], "colour": 1}), false),
|
||||
] {
|
||||
assert_eq!(conforms(schema, schema, &p), takes, "{p}");
|
||||
}
|
||||
}
|
||||
|
||||
fn matches(r: &Value) {
|
||||
let schema = output_schema_for::<DimensionResult>();
|
||||
let schema = schema.as_value();
|
||||
assert!(conforms(schema, schema, r), "{r} doesn't match {schema}");
|
||||
assert!(r.get("param_warnings").is_none(), "{r}");
|
||||
}
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
|
||||
}
|
||||
|
||||
/// Each form of the command, run in Warn: the reply has no warning and matches its output schema.
|
||||
#[test]
|
||||
fn replies_match_the_output_schema() {
|
||||
let mut s = editing(ParamCheck::Warn);
|
||||
let dim = |s: &mut Session, p: Value| {
|
||||
let r = run(s, "sketch.dimension", p);
|
||||
matches(&r);
|
||||
r
|
||||
};
|
||||
// A length, with a value, and a distance chosen by `type` and by `orientation`.
|
||||
let r = dim(&mut s, json!({"entities": ["l1"], "value": 12}));
|
||||
assert!(r["type"] == "LinearDimension" && r["unit"] == "mm" && r["expression"] == "12" && r.get("driven").is_none(), "{r}");
|
||||
assert!(r["param"].is_string() && r["sketch"]["solved"] == true, "{r}");
|
||||
let r = dim(&mut s, json!({"entities": ["l2"], "type": "horizontal", "value": "8 mm", "text_at": [5, 35]}));
|
||||
assert!((r["measured"].as_f64().unwrap_or_default() - 8.0).abs() < 1e-6, "{r}");
|
||||
dim(&mut s, json!({"entities": ["l3.start", "l3.end"], "orientation": "horizontal"}));
|
||||
dim(&mut s, json!({"entities": ["l4.start", "l4.end"], "type": "vertical", "value": null, "sketch": "Plan"}));
|
||||
// A circle's diameter, and a definition of a parameter by the value.
|
||||
let r = dim(&mut s, json!({"entities": ["c1"], "value": "d = 12"}));
|
||||
assert!(r["type"] == "DiameterDimension" && r["defined"] == "d" && r["expression"] == "d", "{r}");
|
||||
// An angle between two lines, in degrees.
|
||||
let r = dim(&mut s, json!({"entities": ["l3", "l4"], "type": "angle", "value": "45 deg"}));
|
||||
assert!(r["type"] == "AngularDimension" && r["unit"] == "deg", "{r}");
|
||||
// A driven dimension: no parameter, the keys only a driving one reads are not read.
|
||||
let r = dim(&mut s, json!({"entities": ["l2"], "driven": true, "value": true, "scale_sketch": "yes", "if_redundant": 3, "type": "length"}));
|
||||
assert!(r["param"].is_null() && r["driven"] == true && r.get("expression").is_none(), "{r}");
|
||||
// One the sketch already determines, placed as driven instead.
|
||||
let r = dim(&mut s, json!({"entities": ["l1.start", "l1.end"], "if_redundant": "driven"}));
|
||||
assert!(r["redundant"] == true && r["driven"] == true && r["determined_by"].as_array().is_some_and(|a| !a.is_empty()), "{r}");
|
||||
let r = s.execute("sketch.dimension", &json!({"entities": ["l1"], "value": 13}));
|
||||
assert!(r.as_ref().is_err_and(|e| e.to_string().contains("over-constrain")), "{r:?}");
|
||||
// The midpoints of two lines.
|
||||
let mut s = editing(ParamCheck::Warn);
|
||||
dim(&mut s, json!({"entities": ["mid:l1", "mid:l2"], "text_at": [30, 15], "type": "auto"}));
|
||||
// Scaling a sketch's first dimension.
|
||||
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 5}));
|
||||
let r = dim(&mut s, json!({"entities": ["c1"], "value": "30 mm", "scale_sketch": true}));
|
||||
assert!(r["scaled"].as_f64().is_some_and(|f| f > 1.0), "{r}");
|
||||
// An ellipse's half-axis.
|
||||
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
let e = run(&mut s, "sketch.ellipse", json!({"center": [0, 0], "major": [20, 0], "minor_radius": 8}));
|
||||
let e = e["curves"][0].as_str().unwrap_or_default().to_string();
|
||||
let r = dim(&mut s, json!({"entities": [e], "text_at": [3, 16], "previous_axis": "Minor", "half_axes": true}));
|
||||
assert!(r["axis"] == "major", "{r}");
|
||||
let r = dim(&mut s, json!({"entities": [e], "driven": true, "half_axes": false}));
|
||||
assert!(r["driven"] == true && r.get("axis").is_none(), "{r}");
|
||||
}
|
||||
|
||||
/// A dimension that measures a model edge: the edge is projected first, and the call is read in
|
||||
/// Warn without a warning.
|
||||
#[test]
|
||||
fn a_model_edge_is_an_entity() {
|
||||
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Base"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [100, 50]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "sheet.flange", json!({"sketch": "Base", "body_name": "Flange"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Near"}));
|
||||
let point = run(&mut s, "sketch.point", json!({"point": [50, 25]}))["point"].as_str().unwrap_or_default().to_string();
|
||||
let r = run(&mut s, "sketch.dimension", json!({"entities": [point, {"edge": [50, 0, 0], "body": "Flange"}], "sketch": "Near"}));
|
||||
matches(&r);
|
||||
assert!((r["measured"].as_f64().unwrap_or_default() - 25.0).abs() < 1e-6, "{r}");
|
||||
// A model reference that is no form of one is refused with the handler's words, in strict too.
|
||||
let mut error = |level: ParamCheck| {
|
||||
s.param_check = level;
|
||||
s.execute("sketch.dimension", &json!({"entities": [point, {"edge": 5}]})).err().map(|e| e.to_string())
|
||||
};
|
||||
let off = error(ParamCheck::Off);
|
||||
assert!(off.as_deref().is_some_and(|e| e.contains("cannot project")), "{off:?}");
|
||||
assert_eq!(error(ParamCheck::Strict), off);
|
||||
}
|
||||
|
||||
/// A misspelt key still runs in Warn, and says so, with a reply that still matches its schema.
|
||||
#[test]
|
||||
fn a_misspelt_key_is_reported_in_warn() {
|
||||
let mut s = editing(ParamCheck::Warn);
|
||||
let r = run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "valeu": 12}));
|
||||
let warnings = r["param_warnings"].as_array().cloned().unwrap_or_default();
|
||||
assert_eq!(warnings, [json!("unknown key `valeu`")], "{r}");
|
||||
let schema = output_schema_for::<DimensionResult>();
|
||||
assert!(conforms(schema.as_value(), schema.as_value(), &r), "{r}");
|
||||
let mut s = editing(ParamCheck::Strict);
|
||||
let e = s.execute("sketch.dimension", &json!({"entities": ["l1"], "text_t": [1, 1]})).err().map(|e| e.to_string());
|
||||
assert!(e.is_some_and(|e| e.contains("unknown key `text_t`")));
|
||||
}
|
||||
|
||||
/// The reply serializes as the handler's `json!` literal did: what is not there is left out.
|
||||
#[test]
|
||||
fn the_reply_leaves_out_what_is_absent() {
|
||||
let mut s = editing(ParamCheck::Off);
|
||||
let keys = |r: &Value| {
|
||||
let mut k: Vec<&str> = r.as_object().map(|o| o.keys().map(String::as_str).collect()).unwrap_or_default();
|
||||
k.sort_unstable();
|
||||
k.join(",")
|
||||
};
|
||||
let driving = run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 12}));
|
||||
assert_eq!(keys(&driving), "created,expression,measured,param,sketch,type,unit", "{driving}");
|
||||
let driven = run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "driven": true}));
|
||||
assert_eq!(keys(&driven), "created,driven,measured,param,sketch,type,unit", "{driven}");
|
||||
let defined = run(&mut s, "sketch.dimension", json!({"entities": ["c1"], "value": "d = 12"}));
|
||||
assert_eq!(keys(&defined), "created,defined,expression,measured,param,sketch,type,unit", "{defined}");
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Contracts and result types for the drawing commands in `cmd/sketch.rs`: a line, a point, the
|
||||
//! circles and the arcs (#12953), the rectangles and the polygons (#13196).
|
||||
//! circles and the arcs (#12953), the rectangles and the polygons (#13196), and the two slots.
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
@@ -532,6 +532,93 @@ pub struct ArcCenterPointParams {
|
||||
pub connect: Option<bool>,
|
||||
}
|
||||
|
||||
/// A slot's `width`, as `slot_c2c` and `slot_overall` read it: a number above 1e-9 and below 1e9.
|
||||
/// A value that is missing, not a number or too small is the handler's one message.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct SlotWidth(pub f64);
|
||||
|
||||
impl<'de> Deserialize<'de> for SlotWidth {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
Value::deserialize(d)?
|
||||
.as_f64()
|
||||
.and_then(params::finite)
|
||||
.filter(|w| *w > 1e-9)
|
||||
.map(SlotWidth)
|
||||
.ok_or_else(|| D::Error::custom(whole("`width` must be positive")))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for SlotWidth {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"SlotWidth".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "The slot's width in mm: the distance between its two straight sides, and the diameter of its round ends. Positive.",
|
||||
"type": "number", "exclusiveMinimum": 0, "exclusiveMaximum": 1e9
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks `p0`, `p1` and `width` required in a slot's schema. The types hold them as optional
|
||||
/// (`#[schemars(required)]` doesn't do this for a key with `serde(default)`), so that a call
|
||||
/// that leaves one out reaches the handler, which refuses it with its own message.
|
||||
fn slot_required(schema: &mut Schema) {
|
||||
schema.insert("required".into(), json!(["p0", "p1", "width"]));
|
||||
}
|
||||
|
||||
/// Draw a slot from the centres of its two round ends and its width.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = slot_required)]
|
||||
pub struct SlotCenterToCenterParams {
|
||||
/// The sketch to draw in: its feature id or name. Default: the one being edited.
|
||||
#[serde(default)]
|
||||
pub sketch: Option<SketchTarget>,
|
||||
/// The centre of one round end, as [x, y] or {x, y} in mm. An expression, a point's id or a
|
||||
/// snap is not taken.
|
||||
#[serde(default, deserialize_with = "present")]
|
||||
#[schemars(with = "PlainPoint")]
|
||||
pub p0: Option<PlainPoint>,
|
||||
/// The centre of the other round end. Not the same point as `p0`.
|
||||
#[serde(default, deserialize_with = "present")]
|
||||
#[schemars(with = "PlainPoint")]
|
||||
pub p1: Option<PlainPoint>,
|
||||
/// The width of the slot, in mm.
|
||||
#[serde(default, deserialize_with = "present")]
|
||||
#[schemars(with = "SlotWidth")]
|
||||
pub width: Option<SlotWidth>,
|
||||
/// Make a new point that lands exactly on an existing point (the origin too) coincident with
|
||||
/// it. Default: true.
|
||||
#[serde(default)]
|
||||
pub connect: Option<bool>,
|
||||
}
|
||||
|
||||
/// Draw a slot from the two ends of its whole length and its width.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = slot_required)]
|
||||
pub struct SlotOverallParams {
|
||||
/// The sketch to draw in: its feature id or name. Default: the one being edited.
|
||||
#[serde(default)]
|
||||
pub sketch: Option<SketchTarget>,
|
||||
/// The outer end of one round end, on the slot's axis, as [x, y] or {x, y} in mm. An
|
||||
/// expression, a point's id or a snap is not taken.
|
||||
#[serde(default, deserialize_with = "present")]
|
||||
#[schemars(with = "PlainPoint")]
|
||||
pub p0: Option<PlainPoint>,
|
||||
/// The outer end of the other round end. The distance from `p0` must be more than `width`.
|
||||
#[serde(default, deserialize_with = "present")]
|
||||
#[schemars(with = "PlainPoint")]
|
||||
pub p1: Option<PlainPoint>,
|
||||
/// The width of the slot, in mm.
|
||||
#[serde(default, deserialize_with = "present")]
|
||||
#[schemars(with = "SlotWidth")]
|
||||
pub width: Option<SlotWidth>,
|
||||
/// Make a new point that lands exactly on an existing point (the origin too) coincident with
|
||||
/// it. Default: true.
|
||||
#[serde(default)]
|
||||
pub connect: Option<bool>,
|
||||
}
|
||||
|
||||
/// What a command added to its sketch: the ids that the sketch has now and did not before.
|
||||
#[derive(Debug, Default, Serialize, JsonSchema)]
|
||||
pub struct Created {
|
||||
@@ -548,7 +635,7 @@ pub struct Created {
|
||||
pub wires: Vec<String>,
|
||||
}
|
||||
|
||||
/// What a drawing command returns for a line, a circle or an arc.
|
||||
/// What a drawing command returns for a line, a circle, an arc or a slot.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct DrawResult {
|
||||
/// The ids of the curves drawn, in order.
|
||||
|
||||
@@ -3,7 +3,7 @@ use serde_json::{Value, json};
|
||||
use super::{
|
||||
ArcCenterPointParams, ArcThreePointParams, CircleCenterParams, CircleThreePointParams, CircleTwoPointParams, DrawResult, LineParams, PointParams,
|
||||
PointResult, PolygonCircumscribedParams, PolygonEdgeParams, PolygonInscribedParams, PolygonResult, RectCenterParams, RectResult,
|
||||
RectRoundedParams, RectThreePointParams, RectTwoPointParams, RoundedRectResult,
|
||||
RectRoundedParams, RectThreePointParams, RectTwoPointParams, RoundedRectResult, SlotCenterToCenterParams, SlotOverallParams,
|
||||
};
|
||||
use crate::Session;
|
||||
use crate::schema::tests::{assert_keys, conforms};
|
||||
@@ -29,6 +29,8 @@ fn schemas_and_the_key_check_agree() {
|
||||
assert_keys(input_schema_for::<CircleTwoPointParams>(), &["connect", "construction", "p0", "p1", "sketch"]);
|
||||
assert_keys(input_schema_for::<CircleThreePointParams>(), &["connect", "construction", "p0", "p1", "p2", "sketch"]);
|
||||
assert_keys(input_schema_for::<ArcThreePointParams>(), &["connect", "construction", "end", "id", "sketch", "start", "through"]);
|
||||
assert_keys(input_schema_for::<SlotCenterToCenterParams>(), &["connect", "p0", "p1", "sketch", "width"]);
|
||||
assert_keys(input_schema_for::<SlotOverallParams>(), &["connect", "p0", "p1", "sketch", "width"]);
|
||||
assert_keys(
|
||||
input_schema_for::<ArcCenterPointParams>(),
|
||||
&["center", "clockwise", "connect", "construction", "end", "id", "sketch", "start", "sweep"],
|
||||
@@ -114,6 +116,21 @@ fn strict_keeps_todays_errors() {
|
||||
("sketch.polygon.edge", json!({"p0": [0, 0], "p1": [0, 0]})),
|
||||
("sketch.polygon.edge", json!({"p0": [0, 0], "p1": [5, 0], "sides": 2.5})),
|
||||
("sketch.polygon.edge", json!({"p0": [0, 0], "p1": ["5", 0]})),
|
||||
("sketch.slot.center_to_center", json!({"p1": [10, 0], "width": 4})),
|
||||
("sketch.slot.center_to_center", json!({"p0": "l1.end", "p1": [10, 0], "width": 4})),
|
||||
("sketch.slot.center_to_center", json!({"p0": ["1", 0], "p1": [10, 0], "width": 4})),
|
||||
("sketch.slot.center_to_center", json!({"p0": [0, 5], "p1": [10, 5]})),
|
||||
("sketch.slot.center_to_center", json!({"p0": [0, 5], "p1": [10, 5], "width": 0})),
|
||||
("sketch.slot.center_to_center", json!({"p0": [0, 5], "p1": [10, 5], "width": -2})),
|
||||
("sketch.slot.center_to_center", json!({"p0": [0, 5], "p1": [10, 5], "width": "4"})),
|
||||
("sketch.slot.center_to_center", json!({"p0": [0, 5], "p1": [10, 5], "width": 1e12})),
|
||||
("sketch.slot.center_to_center", json!({"p0": [3, 5], "p1": [3, 5], "width": 4})),
|
||||
("sketch.slot.overall", json!({"p0": [0, 5], "p1": [10, 5]})),
|
||||
("sketch.slot.overall", json!({"p0": [0, 5], "p1": 7, "width": 4})),
|
||||
("sketch.slot.overall", json!({"p0": [0, 5], "p1": [10, 5], "width": true})),
|
||||
("sketch.slot.overall", json!({"p0": [3, 5], "p1": [3, 5], "width": 4})),
|
||||
("sketch.slot.overall", json!({"p0": [0, 5], "p1": [4, 5], "width": 4})),
|
||||
("sketch.slot.overall", json!({"p0": [0, 5], "p1": [3, 5], "width": 4})),
|
||||
];
|
||||
for (id, p) in &cases {
|
||||
let error = |level| session(level).execute(id, p).err().map(|e| e.to_string());
|
||||
@@ -151,6 +168,10 @@ fn replies_match_the_output_schema_and_forms_are_accepted() {
|
||||
("sketch.arc.center_point", json!({"center": [0, 50], "start": [5, 50], "end": [0, 55], "clockwise": true, "id": "arc_two"})),
|
||||
("sketch.arc.center_point", json!({"center": [0, 60], "start": [5, 60], "sweep": 90})),
|
||||
("sketch.arc.center_point", json!({"center": [0, 70], "start": [5, 70], "sweep": -45, "end": [0, 75]})),
|
||||
("sketch.slot.center_to_center", json!({"p0": [0, 100], "p1": [20, 100], "width": 6})),
|
||||
("sketch.slot.center_to_center", json!({"p0": {"x": 0, "y": 120}, "p1": {"x": 20, "y": 120}, "width": 0.5, "connect": false})),
|
||||
("sketch.slot.overall", json!({"p0": [0, 140], "p1": [20, 140], "width": 6})),
|
||||
("sketch.slot.overall", json!({"p0": [0, 160], "p1": [20, 170], "width": 6, "connect": true, "sketch": null})),
|
||||
];
|
||||
let point_calls = [
|
||||
json!({"point": [30, 30]}),
|
||||
@@ -192,6 +213,8 @@ fn the_check_does_not_change_the_reply() {
|
||||
("sketch.circle.three_point", json!({"p0": [90, 0], "p1": [100, 10], "p2": [110, 0]})),
|
||||
("sketch.arc.three_point", json!({"start": [0, 20], "end": [10, 20], "through": [5, 25]})),
|
||||
("sketch.arc.center_point", json!({"center": [0, 40], "start": [5, 40], "sweep": 90})),
|
||||
("sketch.slot.center_to_center", json!({"p0": [0, 100], "p1": [20, 100], "width": 6})),
|
||||
("sketch.slot.overall", json!({"p0": [0, 140], "p1": [20, 140], "width": 6})),
|
||||
];
|
||||
let (mut off, mut strict) = (session(ParamCheck::Off), session(ParamCheck::Strict));
|
||||
for (id, p) in &calls {
|
||||
@@ -219,6 +242,8 @@ fn wrong_typed_keys_are_reported() {
|
||||
("sketch.circle.three_point", json!({"p0": [0, 0], "p1": [5, 5], "p2": [10, 0]})),
|
||||
("sketch.arc.three_point", json!({"start": [0, 0], "end": [10, 0], "through": [5, 5]})),
|
||||
("sketch.arc.center_point", json!({"center": [0, 0], "start": [5, 0], "sweep": 90})),
|
||||
("sketch.slot.center_to_center", json!({"p0": [0, 5], "p1": [10, 5], "width": 4})),
|
||||
("sketch.slot.overall", json!({"p0": [0, 5], "p1": [10, 5], "width": 4})),
|
||||
];
|
||||
for (id, p) in &base {
|
||||
assert!(issues(id, p).is_empty(), "{id} {p}");
|
||||
@@ -330,3 +355,34 @@ fn rectangles_and_polygons_report_wrong_typed_keys() {
|
||||
assert_eq!(message("sketch.rectangle.rounded", json!({"p0": ["w / 2", 0], "p1": [5, 5], "radius": "r"})), None);
|
||||
assert_eq!(message("sketch.polygon.edge", json!({"p0": [0, 0], "p1": [5, 0], "sides": 4})), None);
|
||||
}
|
||||
|
||||
/// The slot replies keep the shape the handlers returned before the schemas: exactly the keys
|
||||
/// `curves`, `constraints` and `sketch` of the old `result()` helper, plus the `created` summary
|
||||
/// that `Session::execute` adds to every sketch command, and no `param_warnings`. Pinned by hand
|
||||
/// from the code on `origin/main`, so it does not depend on the checked path under test.
|
||||
#[test]
|
||||
fn slot_replies_keep_their_old_shape() {
|
||||
let mut s = session(ParamCheck::Off);
|
||||
for (id, p) in [
|
||||
("sketch.slot.center_to_center", json!({"p0": [0, 5], "p1": [10, 5], "width": 4})),
|
||||
("sketch.slot.overall", json!({"p0": [0, 20], "p1": [10, 20], "width": 4})),
|
||||
] {
|
||||
let r = run(&mut s, id, p);
|
||||
let o = r.as_object().unwrap_or_else(|| panic!("{id}: {r}"));
|
||||
let mut keys: Vec<&str> = o.keys().map(String::as_str).collect();
|
||||
keys.sort_unstable();
|
||||
assert_eq!(keys, ["constraints", "created", "curves", "sketch"], "{id}: {r}");
|
||||
for k in ["curves", "constraints"] {
|
||||
let list = o[k].as_array().unwrap_or_else(|| panic!("{id} {k}: {r}"));
|
||||
assert!(!list.is_empty() && list.iter().all(Value::is_string), "{id} {k}: {r}");
|
||||
}
|
||||
assert_eq!(o["curves"].as_array().map(Vec::len), Some(4), "{id}: {r}");
|
||||
assert_eq!(sorted_keys(&o["sketch"]), Some(vec!["dof", "solved"]), "{id}: {r}");
|
||||
assert_eq!(sorted_keys(&o["created"]), Some(vec!["constraints", "curves", "dimensions", "points"]), "{id}: {r}");
|
||||
assert_eq!(o["created"]["curves"], o["curves"], "{id}: {r}");
|
||||
}
|
||||
}
|
||||
|
||||
fn sorted_keys(v: &Value) -> Option<Vec<&str>> {
|
||||
v.as_object().map(|m| m.keys().map(String::as_str).collect())
|
||||
}
|
||||
|
||||
@@ -999,6 +999,140 @@ impl JsonSchema for SketchRef {
|
||||
}
|
||||
}
|
||||
|
||||
/// A sweep's `orientation`, as `features::sweep_orientation` reads it, with its messages.
|
||||
/// `null` is the default, as the handler reads it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct SweepOrientationParam(pub solidcraft_doc::SweepOrientation);
|
||||
|
||||
impl<'de> Deserialize<'de> for SweepOrientationParam {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
crate::cmd::sweep_orientation(&Value::deserialize(d)?).map(SweepOrientationParam).map_err(|e| D::Error::custom(whole(e)))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for SweepOrientationParam {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"SweepOrientation".into()
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
let point = generator.subschema_for::<Point3>();
|
||||
json_schema!({
|
||||
"description": "How the profile turns as it follows the path: perpendicular (the default), square to the path and turning with it; parallel, as drawn, moving without turning (the path must keep crossing the profile's plane); or a direction, [x, y, z], {\"x\", \"y\", \"z\"} or {\"direction\": [x, y, z]}, not zero: square to the path, its turn about the path fixed so that the direction's projection onto the profile stays where it was at the start. Matching ignores case.",
|
||||
"anyOf": [
|
||||
{"type": "string", "enum": ["perpendicular", "parallel"]},
|
||||
point,
|
||||
{"type": "object", "properties": {"direction": point}, "required": ["direction"]}
|
||||
]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A sweep's `twist`: an expression as [`AngleExpr`] reads one, with the handler's message for
|
||||
/// anything else. Whether it evaluates, and to within four turns, is the handler's to check.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SweepTwist(pub Expr);
|
||||
|
||||
impl<'de> Deserialize<'de> for SweepTwist {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
params::expr(&wrap(Value::deserialize(d)?), "v")
|
||||
.map(SweepTwist)
|
||||
.ok_or_else(|| D::Error::custom(whole("`twist` must be an angle or an expression")))
|
||||
}
|
||||
}
|
||||
|
||||
/// Guide rails' `rail_fit`, as `features::rail_fit_form` reads it, with its message.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct RailFitParam(pub solidcraft_doc::RailFit);
|
||||
|
||||
impl<'de> Deserialize<'de> for RailFitParam {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
crate::cmd::rail_fit_form(&Value::deserialize(d)?).map(RailFitParam).map_err(|e| D::Error::custom(whole(e)))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for RailFitParam {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"RailFit".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
enum_schema(
|
||||
&["scale", "stretch"],
|
||||
"How guide rails reshape the profile: scale (the default) grows and shrinks it evenly with the distance from the path to the rail (between the rails); stretch only across to the rail (between the rails). Matching ignores case.",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// What `features::guide_rails` says about a sweep's or a loft's (`what`) `rails`, as given (not
|
||||
/// `null`), before it looks a sketch up: its message, or `None`. `default_sketch`: a rail may
|
||||
/// leave its `sketch` out. A key a rail doesn't take, which the handler ignores, is refused too.
|
||||
fn guide_rails_problem(v: &Value, what: &str, default_sketch: bool) -> Option<String> {
|
||||
let Value::Array(given) = v else { return Some(whole("`rails` must list one or two rails: [{sketch, curves: [ids]}]")) };
|
||||
if !(1..=2).contains(&given.len()) {
|
||||
return Some(whole(format!("a {what} takes one or two guide rails")));
|
||||
}
|
||||
for (i, rail) in given.iter().enumerate() {
|
||||
match rail.get("sketch").filter(|v| !v.is_null()) {
|
||||
None if !default_sketch => return Some(whole("each rail needs the `sketch` its curves are in")),
|
||||
Some(sketch) if crate::cmd::sketch_key(Some(sketch)).is_none() => return Some(whole("`rails[].sketch` must be a sketch id or name")),
|
||||
_ => {}
|
||||
}
|
||||
let curves = match params::string_list(params::UNKNOWN_COMMAND, rail, "curves") {
|
||||
Ok(curves) => curves,
|
||||
Err(crate::EngineError::BadParams { msg, .. }) => return Some(whole(msg)),
|
||||
Err(e) => return Some(whole(e)),
|
||||
};
|
||||
if curves.is_empty() || curves.len() > 1000 {
|
||||
return Some(whole("each rail's `curves` must list 1…1000 curve ids in order"));
|
||||
}
|
||||
if let Some(key) = rail.as_object().and_then(|o| o.keys().find(|k| !matches!(k.as_str(), "sketch" | "curves"))) {
|
||||
let key = super::check::truncate(key, 64);
|
||||
return Some(whole(format!("unknown key `{key}` in `rails[{i}]`: a rail takes `sketch` and `curves`")));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// A sweep's guide `rails`, as `features::guide_rails` reads them before it looks the sketches up,
|
||||
/// with its messages: one or two `{sketch?, curves}`, each rail's sketch the path's when it
|
||||
/// names none. Kept as given, for the handler. Whether the sketches and curves exist, and that
|
||||
/// `orientation`, `twist` and `scale` are left out, is the handler's to check.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SweepRails(pub Value);
|
||||
|
||||
impl<'de> Deserialize<'de> for SweepRails {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let v = Value::deserialize(d)?;
|
||||
match guide_rails_problem(&v, "sweep", true) {
|
||||
Some(why) => Err(D::Error::custom(why)),
|
||||
None => Ok(SweepRails(v)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for SweepRails {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"SweepRails".into()
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
let sketch = generator.subschema_for::<SketchRef>();
|
||||
json_schema!({
|
||||
"description": "One or two guide rails, each {\"sketch\"?: the sketch its curves are in (default: the path's sketch), \"curves\": its curve ids in order, 1 to 1000 (one id may be given as text)}.",
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 2,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sketch": {"anyOf": [sketch, {"type": "null"}]},
|
||||
"curves": {"anyOf": [{"type": "string"}, {"type": "array", "items": {"type": "string"}, "minItems": 1, "maxItems": 1000}]}
|
||||
},
|
||||
"required": ["curves"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// What `features::plane_param` says about `v` as a plane before it looks anything up, as a
|
||||
/// complete message, or `None`. Names and ids are the handler's to look up.
|
||||
fn plane_problem(v: &Value) -> Option<String> {
|
||||
|
||||
@@ -11,8 +11,6 @@ file.export
|
||||
file.insert_mesh
|
||||
file.insert_step
|
||||
file.new
|
||||
file.save
|
||||
file.save_as
|
||||
file.save_mesh
|
||||
file.slicers
|
||||
motion.export_animation
|
||||
@@ -26,10 +24,7 @@ render.preflight
|
||||
render.scene
|
||||
render.start
|
||||
sheet.export_dxf
|
||||
sketch.dimension
|
||||
sketch.draw.dimensioned
|
||||
sketch.slot.center_to_center
|
||||
sketch.slot.overall
|
||||
solid.align
|
||||
solid.coil
|
||||
solid.draft
|
||||
@@ -48,7 +43,6 @@ solid.revolve
|
||||
solid.shell
|
||||
solid.split_body
|
||||
solid.split_face
|
||||
solid.sweep
|
||||
solid.thread
|
||||
surface.blend
|
||||
surface.extend
|
||||
@@ -57,5 +51,4 @@ surface.loft
|
||||
surface.patch
|
||||
surface.revolve
|
||||
surface.ruled
|
||||
surface.sweep
|
||||
surface.trim
|
||||
|
||||
@@ -1137,6 +1137,8 @@ fn hostile_params_never_panic() {
|
||||
"density",
|
||||
"names",
|
||||
"references",
|
||||
"head",
|
||||
"head_gap",
|
||||
];
|
||||
// Commands are independent; worker threads take them one at a time (their costs vary a lot).
|
||||
let ids: Vec<&str> = command_specs()
|
||||
@@ -3307,6 +3309,48 @@ fn tapped_and_clearance_holes() {
|
||||
assert!(s.execute("solid.hole", &json!({"position": [20, 25, 10]})).is_err());
|
||||
}
|
||||
|
||||
/// A counterbore sized to the screw's head: `head` names the standard and `clearance` the size.
|
||||
/// ISO 4762 M4 has dk 7 and k 4; with the default 0.5 mm gap the recess is Ø8, 4.5 deep.
|
||||
#[test]
|
||||
fn counterbore_for_a_cap_screw_head() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 80, "width": 30, "height": 10}));
|
||||
let v0 = volume(&mut s);
|
||||
// The material a through hole of diameter `hole` with a counterbore `dk` wide and `k` deep removes.
|
||||
let removed = |hole: f64, dk: f64, k: f64| PI * hole * hole / 4.0 * (10.0 - k) + PI * dk * dk / 4.0 * k;
|
||||
let recess = |s: &Session| match s.doc.features.last().map(|f| &f.kind) {
|
||||
Some(solidcraft_doc::FeatureKind::Hole { hole: solidcraft_doc::HoleKind::Counterbore { cb_diameter, cb_depth, .. }, .. }) => {
|
||||
(cb_diameter.as_str().to_string(), cb_depth.as_str().to_string())
|
||||
}
|
||||
other => panic!("not a counterbore: {other:?}"),
|
||||
};
|
||||
run(&mut s, "solid.hole", json!({"position": [10, 15, 10], "clearance": "M4", "head": "ISO 4762"}));
|
||||
let v1 = volume(&mut s);
|
||||
assert!(rel(v0 - v1, removed(4.5, 8.0, 4.5)) < 1e-3, "{}", v0 - v1);
|
||||
assert_eq!(recess(&s), ("8 mm".to_string(), "4.5 mm".to_string()));
|
||||
// A button head (ISO 7380-1 M4: dk 7.6, k 2.2), a closer gap, and the depth given by hand.
|
||||
run(&mut s, "solid.hole", json!({"position": [30, 15, 10], "clearance": "M4", "head": "ISO 7380-1", "head_gap": 0.25, "cb_depth": 3}));
|
||||
let v2 = volume(&mut s);
|
||||
assert!(rel(v1 - v2, removed(4.5, 8.1, 3.0)) < 1e-3, "{}", v1 - v2);
|
||||
assert_eq!(recess(&s), ("8.1 mm".to_string(), "3".to_string()));
|
||||
// The family name parts.insert takes, a close fit, the pitch written out, and no gap at all.
|
||||
run(
|
||||
&mut s,
|
||||
"solid.hole",
|
||||
json!({"position": [50, 15, 10], "clearance": "M5x0.8", "fit": "close", "head": "socket_head_cap_screw", "head_gap": "0 mm"}),
|
||||
);
|
||||
let v3 = volume(&mut s);
|
||||
assert!(rel(v2 - v3, removed(5.3, 8.5, 5.0)) < 1e-3, "{}", v2 - v3);
|
||||
// A gap that names a parameter: the recess follows the parameter.
|
||||
run(&mut s, "parameters.add", json!({"name": "gap", "expression": "0.5 mm"}));
|
||||
run(&mut s, "solid.hole", json!({"position": [70, 15, 10], "clearance": "M4", "head": "ISO 4762", "head_gap": "gap"}));
|
||||
let v4 = volume(&mut s);
|
||||
assert!(rel(v3 - v4, removed(4.5, 8.0, 4.5)) < 1e-3, "{}", v3 - v4);
|
||||
assert_eq!(recess(&s), ("7 mm + 2 * (gap)".to_string(), "4 mm + (gap)".to_string()));
|
||||
run(&mut s, "parameters.change", json!({"name": "gap", "expression": "1 mm"}));
|
||||
assert!(rel(v3 - volume(&mut s), removed(4.5, 9.0, 5.0)) < 1e-3);
|
||||
}
|
||||
|
||||
/// In an inch design, a bare length typed into a feature means inches (stored with the unit);
|
||||
/// lengths with units and angles are left alone.
|
||||
#[test]
|
||||
|
||||
@@ -1185,11 +1185,8 @@ mod tests {
|
||||
}
|
||||
|
||||
/// OBJ shares the vertices faces meet at and keeps the body name as written, spaces included.
|
||||
/// The 10 × 20 × 30 box meshes as the planner's grids, no side longer than a fifth of its
|
||||
/// extent (the 37.4 mm diagonal): its edges in 2, 3 and 5 pieces, so its faces are grids of
|
||||
/// 2 × 3, 2 × 5 and 3 × 5 cells, 2 · (6 + 10 + 15) · 2 = 124 triangles. Welded, their
|
||||
/// corners are the 3 · 4 · 6 = 72 nodes of the box's lattice less the 1 · 2 · 4 inside it: 64
|
||||
/// positions (unwelded, 372), and one normal per face.
|
||||
/// The 10 × 20 × 30 box meshes as two triangles a face (a flat face needs no interior
|
||||
/// nodes), 12 triangles on its 8 corners (unwelded, 36), and one normal per face.
|
||||
#[test]
|
||||
fn obj_export_is_welded_and_keeps_names() {
|
||||
let (_, m) = model();
|
||||
@@ -1200,10 +1197,10 @@ mod tests {
|
||||
let o = String::from_utf8(export(&st, &[], Format::Obj, "t").unwrap()).unwrap();
|
||||
let count = |p: &str| o.lines().filter(|l| l.starts_with(p)).count();
|
||||
assert!(o.contains("\no Red Block\n"), "{o}");
|
||||
assert_eq!((count("v "), count("vn "), count("f ")), (64, 6, 124), "{o}");
|
||||
assert_eq!((count("v "), count("vn "), count("f ")), (8, 6, 12), "{o}");
|
||||
let ids: Vec<usize> =
|
||||
o.lines().filter(|l| l.starts_with("f ")).flat_map(|l| l.split([' ', '/']).skip(1)).filter_map(|x| x.parse().ok()).collect();
|
||||
assert!(ids.iter().all(|i| (1..=64).contains(i)));
|
||||
assert!(ids.iter().all(|i| (1..=8).contains(i)));
|
||||
}
|
||||
|
||||
/// The OBJ export carries the colours of the bodies: the mesh file names the material
|
||||
|
||||
@@ -179,7 +179,10 @@ pub(crate) fn plan(b: &brep::Brep, tol: f64, turn: Option<size::Turn>) -> Result
|
||||
.into_iter()
|
||||
.collect::<Result<_>>()?;
|
||||
let face_h = |f: usize| sizes.get(f).map_or(rules.max_len, |(h, _)| *h);
|
||||
let edge_h: Vec<f64> = edge_faces.iter().map(|fs| fs.iter().map(|&f| face_h(f)).fold(rules.max_len, f64::min)).collect();
|
||||
// A flat face asks for no size (infinite): an edge between flat faces is whole, one beside
|
||||
// a curved face takes the curved face's size, so both meet at the same nodes.
|
||||
let edge_h: Vec<f64> =
|
||||
edge_faces.iter().map(|fs| if fs.is_empty() { rules.max_len } else { fs.iter().map(|&f| face_h(f)).fold(f64::INFINITY, f64::min) }).collect();
|
||||
let jobs: Vec<(usize, f64)> = edge_h.into_iter().enumerate().collect();
|
||||
let solved = b.edges.iter().filter(|e| matches!(e.curve, Curve::SurfaceIntersection { .. })).count();
|
||||
let mut nodes: Vec<edges::Nodes> = crate::par::map("edge nodes", &jobs, solved >= PARALLEL_CURVES || jobs.len() >= PARALLEL_EDGES, |(e, h)| {
|
||||
@@ -1027,15 +1030,18 @@ fn plan_face(
|
||||
// Constrained Delaunay in (u, v) scaled to millimetres: equilateral seeds of side `h`,
|
||||
// refined where triangles leave the surface, are too long or bend too much, then shaped by
|
||||
// Ruppert refinement.
|
||||
let planar = matches!(s, EvalSurface::Plane { .. });
|
||||
let bare = planar && h.is_infinite();
|
||||
let polys: Vec<&[[f64; 2]]> = ranges.iter().filter_map(|r| uvs.get(r.clone())).collect();
|
||||
let step = [h / speed[0], h / speed[1]];
|
||||
let delaunay = || {
|
||||
let seeds = cdt::lattice(bbox, step, &polys, speed);
|
||||
// A flat face with no size asked of it: the boundary nodes alone, no interior seeds.
|
||||
let seeds = if bare { Vec::new() } else { cdt::lattice(bbox, step, &polys, speed) };
|
||||
let eval = |u: f64, v: f64| s.derivs(u, v).map(|(p, su, sv)| (p, su.cross(sv).normalized()));
|
||||
let sag = cdt::Sag { eval: &eval, tol, h, turn: rules.turn, scale: speed };
|
||||
let fill = cdt::Fill {
|
||||
seeds: &seeds,
|
||||
refine: Some((sag, MAX_REFINE)),
|
||||
refine: if bare { None } else { Some((sag, MAX_REFINE)) },
|
||||
shape: !matches!(s, EvalSurface::Plane { .. }),
|
||||
lenient: lenient == Lenient::Yes,
|
||||
};
|
||||
@@ -1043,7 +1049,6 @@ fn plan_face(
|
||||
};
|
||||
// Planar and curved faces alike: a structured grid where the boundary runs along the
|
||||
// parameter box (a rectangle, a cylinder half), a Coons grid on four corners, else Delaunay.
|
||||
let planar = matches!(s, EvalSurface::Plane { .. });
|
||||
// The lenient fill is the constrained Delaunay alone: the caller names the face in the stats.
|
||||
let (extra, triangles) = if lenient == Lenient::Yes {
|
||||
delaunay()?
|
||||
@@ -1051,7 +1056,7 @@ fn plan_face(
|
||||
let mut us = uv::divide(s, 0, bbox[0], bbox[1], rules, h, MAX_LINES);
|
||||
let mut vs = uv::divide(s, 1, bbox[1], bbox[0], rules, h, MAX_LINES);
|
||||
grid::thin(&mut us, &mut vs, grid::MAX_FACE_GRID);
|
||||
let structured = match (ranges.len(), if seamed { None } else { grid::quad(&uvs) }) {
|
||||
let structured = match (ranges.len(), if seamed || bare { None } else { grid::quad(&uvs) }) {
|
||||
(1, Some(q)) => {
|
||||
// Interior lines: the boundary's own where opposite sides agree (refined where the
|
||||
// surface turns more between them inside the face), else the division.
|
||||
@@ -1076,7 +1081,8 @@ fn plan_face(
|
||||
_ => None,
|
||||
};
|
||||
let structured = match structured {
|
||||
None if ranges.len() == 1 && !seamed => {
|
||||
// A flat face with no size asked of it is its boundary alone (the Delaunay below).
|
||||
None if ranges.len() == 1 && !seamed && !bare => {
|
||||
let fine = |a: [f64; 2], b: [f64; 2]| uv::fine_between(s, rules, h, a, b);
|
||||
coons::coons(&uvs, &fixed, speed, grid::MAX_FACE_GRID, h, Some(&fine))
|
||||
}
|
||||
@@ -1108,7 +1114,7 @@ fn plan_face(
|
||||
let trimmed = if seamed || strip {
|
||||
None
|
||||
} else if planar {
|
||||
if ranges.len() == 1 { plan_pieces(&face) } else { None }
|
||||
if ranges.len() == 1 && h.is_finite() { plan_pieces(&face) } else { None }
|
||||
} else {
|
||||
plan_pieces(&face).or_else(|| {
|
||||
let traces = motorcycle::traces(&uvs, &ranges, speed, 0.25 * h);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
//! Target triangle size: one side length per face, so triangles come out about equilateral
|
||||
//! (well-shaped triangles shade evenly) and as small as the surface needs.
|
||||
//!
|
||||
//! A face's size is the smallest of the body's longest side, the length over which the surface
|
||||
//! A flat face (a plane, or a spline lying in one) has no size: any triangulation of its
|
||||
//! boundary is exact, so it gets no interior cells and its straight edges no extra nodes.
|
||||
//! A curved face's size is the smallest of the body's longest side, the length over which the surface
|
||||
//! normal turns the caller's facet angle (when one is given, see [`Turn`]), and the chord that
|
||||
//! sags the tolerance; an edge takes the smaller size of its two faces, so a curved face and
|
||||
//! its flat neighbour meet at the same spacing.
|
||||
@@ -107,11 +109,12 @@ pub(crate) fn extent(points: impl Iterator<Item = Vec3>) -> f64 {
|
||||
/// so the size is never larger than the face needs).
|
||||
pub(crate) fn face_size(s: &EvalSurface, dom: &Domain, rules: &Rules) -> (f64, [f64; 2]) {
|
||||
if let EvalSurface::Plane { du, dv, .. } = s {
|
||||
return (rules.max_len, [du.len(), dv.len()]);
|
||||
return (f64::INFINITY, [du.len(), dv.len()]);
|
||||
}
|
||||
let (Some(ru), Some(rv)) = (dom.range[0], dom.range[1]) else { return (rules.max_len, [1.0, 1.0]) };
|
||||
let flat = planar(s);
|
||||
// A flat spline (sheet-metal walls, chamfers): no curvature to look for, only the speeds.
|
||||
let samples = if planar(s) { 3 } else { SAMPLES };
|
||||
let samples = if flat { 3 } else { SAMPLES };
|
||||
let normal = |u: f64, v: f64| -> Option<(Vec3, Vec3, [f64; 2])> {
|
||||
let (p, su, sv) = s.derivs(u, v)?;
|
||||
Some((p, su.cross(sv).normalized()?, [su.len(), sv.len()]))
|
||||
@@ -141,7 +144,9 @@ pub(crate) fn face_size(s: &EvalSurface, dom: &Domain, rules: &Rules) -> (f64, [
|
||||
}
|
||||
}
|
||||
let speed = if count > 0 { [speed[0] / count as f64, speed[1] / count as f64] } else { [1.0, 1.0] };
|
||||
(rules.for_curvature(k_max), speed.map(|x| if x.is_finite() && x > 0.0 { x } else { 1.0 }))
|
||||
// A flat face is exact for any triangulation of its boundary: no length cap.
|
||||
let size = if flat { f64::INFINITY } else { rules.for_curvature(k_max) };
|
||||
(size, speed.map(|x| if x.is_finite() && x > 0.0 { x } else { 1.0 }))
|
||||
}
|
||||
|
||||
/// A spline surface whose control points lie in one plane, with positive weights: the
|
||||
@@ -211,7 +216,7 @@ mod tests {
|
||||
assert!(!planar(&patch(0.5)));
|
||||
let rules = Rules::new(0.01, 100.0, THIRTY);
|
||||
let (h, speed) = face_size(&patch(0.0), &super::super::uv::domain(&patch(0.0)), &rules);
|
||||
assert_eq!(h, rules.max_len);
|
||||
assert_eq!(h, f64::INFINITY);
|
||||
assert!((speed[0] - 4.0).abs() < 1e-9 && (speed[1] - 3.0).abs() < 1e-9, "{speed:?}");
|
||||
// The twisted patch (a hyperbolic paraboloid) has curvature: a smaller side.
|
||||
assert!(face_size(&patch(0.5), &super::super::uv::domain(&patch(0.5)), &rules).0 < rules.max_len);
|
||||
@@ -232,9 +237,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_plane_takes_the_longest_side() {
|
||||
fn a_plane_has_no_size_cap() {
|
||||
let s = EvalSurface::Plane { origin: Vec3::ZERO, du: Vec3::X, dv: Vec3::Y };
|
||||
let rules = Rules::new(0.01, 100.0, THIRTY);
|
||||
assert_eq!(face_size(&s, &super::super::uv::domain(&s), &rules).0, 20.0);
|
||||
assert_eq!(face_size(&s, &super::super::uv::domain(&s), &rules).0, f64::INFINITY);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,31 +69,46 @@ fn cut(a: &Body, b: &Body) -> Body {
|
||||
|
||||
const PI: f64 = std::f64::consts::PI;
|
||||
|
||||
/// Smallest corner angle (degrees) over the mesh.
|
||||
fn min_angle(m: &Mesh) -> f64 {
|
||||
let mut least = 180.0f64;
|
||||
for t in &m.triangles {
|
||||
let [a, b, c] = t.map(|i| m.positions[i as usize]);
|
||||
for (p, q, r) in [(a, b, c), (b, c, a), (c, a, b)] {
|
||||
let (u, v) = (q - p, r - p);
|
||||
if let (Some(u), Some(v)) = (u.normalized(), v.normalized()) {
|
||||
least = least.min(u.dot(v).clamp(-1.0, 1.0).acos().to_degrees());
|
||||
}
|
||||
}
|
||||
/// A flat face is exact for any triangulation of its boundary, so a box is two triangles a
|
||||
/// face whatever its size (it was a grid capped at a fifth of the body's diagonal: 124
|
||||
/// triangles for 20 x 10 x 5).
|
||||
#[test]
|
||||
fn a_box_is_two_triangles_a_face() {
|
||||
for (name, size) in [("20 x 10 x 5", Vec3::new(20.0, 10.0, 5.0)), ("40 x 30 x 20", Vec3::new(40.0, 30.0, 20.0))] {
|
||||
let b = ok(crate::box_solid(Vec3::ZERO, size));
|
||||
let (m, s) = plan_body(&b, 0.05);
|
||||
assert_eq!(s.planar, 6);
|
||||
assert_watertight(&m, name);
|
||||
assert_eq!(m.triangles.len(), 12, "{name}: {:?}", (0..6).map(|i| m.tri_face.iter().filter(|t| **t == i).count()).collect::<Vec<_>>());
|
||||
assert!(max_chord(&b, &m) <= 1e-9, "{name}: flat faces are exact");
|
||||
}
|
||||
least
|
||||
}
|
||||
|
||||
/// Flat faces beside curved ones stay joined: the straight edge between a plane and a fillet
|
||||
/// takes the fillet's spacing, and the plane's boundary uses those nodes.
|
||||
#[test]
|
||||
fn a_box_is_a_regular_grid_on_every_face() {
|
||||
// 40 × 30 × 20: sides at most 1/5 of the diagonal (10.77 mm), so 4 × 3 × 2 cells.
|
||||
let b = ok(crate::box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 20.0)));
|
||||
let (m, s) = plan_body(&b, 0.05);
|
||||
assert_eq!(s.planar, 6);
|
||||
assert_watertight(&m, "box");
|
||||
let cells = 2 * (4 * 3 + 4 * 2 + 3 * 2);
|
||||
assert_eq!(m.triangles.len(), 2 * cells, "two triangles per grid cell");
|
||||
assert!(min_angle(&m) > 40.0, "{}", min_angle(&m));
|
||||
fn flat_faces_beside_curved_ones_stay_watertight_and_within_the_chord() {
|
||||
let tol = 0.01;
|
||||
let bx = ok(crate::box_solid(Vec3::ZERO, Vec3::new(20.0, 10.0, 5.0)));
|
||||
let rounded =
|
||||
ok(crate::fillet(&bx, &[Vec3::new(10.0, 0.0, 5.0), Vec3::new(20.0, 5.0, 5.0), Vec3::new(10.0, 10.0, 5.0), Vec3::new(0.0, 5.0, 5.0)], 1.5));
|
||||
let pin = ok(crate::cylinder(Vec3::new(10.0, 5.0, -1.0), Vec3::Z, 2.0, 7.0));
|
||||
let holed = cut(&bx, &pin);
|
||||
for (name, body) in [("filleted box", &rounded), ("box with a hole", &holed)] {
|
||||
let m = closed_display_mesh(name, body, tol);
|
||||
let chord = max_chord(body, &m);
|
||||
assert!(chord <= tol * 1.05, "{name}: chord {chord}");
|
||||
// The flat faces carry no interior nodes: far fewer triangles than a grid at the
|
||||
// curved neighbours' spacing would give.
|
||||
let (flat, curved) = (0..body.face_count()).fold((0, 0), |(f, c), i| {
|
||||
let n = m.tri_face.iter().filter(|t| **t as usize == i).count();
|
||||
match crate::face_surface(body, i) {
|
||||
Some(crate::FaceSurface::Plane { .. }) => (f + n, c),
|
||||
_ => (f, c + n),
|
||||
}
|
||||
});
|
||||
assert!(flat < curved, "{name}: {flat} flat vs {curved} curved triangles");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -3903,3 +3903,45 @@ fn compact_output_follows_the_apps_preference() {
|
||||
let mut s = Server::new(app(vec![not_running])).with_options(crate::Options { compact: true });
|
||||
assert!(inspect_design_is_compact(&mut s));
|
||||
}
|
||||
|
||||
/// A screw's counterbore through MCP alone (#12321): `describe_command` lists `head` and
|
||||
/// `head_gap`, `execute` forwards them to `solid.hole` unchanged (the command has no typed
|
||||
/// contract), the recess measures as the head table says, and a wrong head or gap is refused
|
||||
/// with the value named, leaving the part as it was.
|
||||
#[test]
|
||||
fn execute_forwards_a_screw_head_to_a_hole_and_describes_it() {
|
||||
let mut s = server();
|
||||
let d = tool(&mut s, "describe_command", json!({"id": "solid.hole"}));
|
||||
let params = d["params"].as_str().unwrap();
|
||||
assert!(params.contains("head?: \"ISO 4762\"") && params.contains("head_gap?"), "{params}");
|
||||
assert!(d["input"].is_null(), "no typed contract: execute forwards the parameters to the command unchanged: {d}");
|
||||
tool(&mut s, "execute", json!({"command": "solid.box", "params": {"length": 80, "width": 30, "height": 10, "body_name": "Plate"}}));
|
||||
// ISO 4762 M4 (dk 7, k 4) with the default 0.5 mm gap: a Ø4.5 bore under a Ø8 × 4.5 recess.
|
||||
let want = 24000.0 - PI * 2.25 * 2.25 * (10.0 - 4.5) - PI * 4.0 * 4.0 * 4.5;
|
||||
let r = tool(
|
||||
&mut s,
|
||||
"execute",
|
||||
json!({"command": "solid.hole", "params": {"position": [15, 15, 10], "clearance": "M4", "head": "ISO 4762"}, "expect": {"volume_mm3": want, "tol": 0.01}}),
|
||||
);
|
||||
assert_eq!(r["expectation"]["mismatch"], json!([]), "{r}");
|
||||
assert!(rel(volume(&mut s), want) < 1e-6, "{want}");
|
||||
// ISO 7380-1 M4 (dk 7.6, k 2.2) with a 0.25 mm gap: Ø8.1 × 2.45.
|
||||
tool(
|
||||
&mut s,
|
||||
"execute",
|
||||
json!({"command": "solid.hole", "params": {"position": [40, 15, 10], "clearance": "M4", "head": "ISO 7380-1", "head_gap": "0.25 mm"}}),
|
||||
);
|
||||
let want = want - PI * 2.25 * 2.25 * (10.0 - 2.45) - PI * 4.05 * 4.05 * 2.45;
|
||||
assert!(rel(volume(&mut s), want) < 1e-6, "{want}");
|
||||
for (params, names) in [
|
||||
(json!({"position": [65, 15, 10], "clearance": "M4", "head": "DIN 912"}), "no head standard `DIN 912`"),
|
||||
(json!({"position": [65, 15, 10], "clearance": "M4", "head": "ISO 4762", "head_gap": -1}), "`head_gap` must be 0 to 7 mm"),
|
||||
(json!({"position": [65, 15, 10], "diameter": 4.5, "head": "ISO 4762"}), "`head` needs `clearance`"),
|
||||
] {
|
||||
let r = raw_tool(&mut s, "execute", json!({"command": "solid.hole", "params": params}));
|
||||
assert_eq!(r["result"]["isError"], true, "{r}");
|
||||
let text = r["result"]["content"][0]["text"].as_str().unwrap();
|
||||
assert!(text.contains(names), "{text}");
|
||||
}
|
||||
assert!(rel(volume(&mut s), want) < 1e-6, "a refused hole changes nothing");
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! Dimension continuation (`solve_after_value_edit`): the triconnected linkage family (two
|
||||
//! triangles joined by three links, left to the iteration by the construction) under random
|
||||
//! dimension edits from its previous solved state, with every failure that remains proven
|
||||
//! infeasible; and the solve without continuation when nothing it could help with changed.
|
||||
//! infeasible; an edit the continuation cannot reach, solved in steps; and the solve without
|
||||
//! continuation when nothing it could help with changed.
|
||||
use solidcore_record::Vec2;
|
||||
use solidcraft_geom::prelude::*;
|
||||
|
||||
@@ -342,3 +343,25 @@ fn hostile_old_values_never_panic() {
|
||||
assert!(s.points.iter().all(|p| p.pos.x.is_finite() && p.pos.y.is_finite()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_edit_continuation_cannot_reach_is_solved_in_steps() {
|
||||
// Every distance of the linkage changed at once, by 0.6× to 2.3×. The one-jump solve fails
|
||||
// and so does the continuation from the layout (its branch ends); the edit made in steps
|
||||
// reaches the new values with both triangles turning as before.
|
||||
let new = [54.0, 59.0, 72.0, 31.0, 78.0, 76.0, 33.0, 61.0, 82.0];
|
||||
let mut rng = Lcg(1);
|
||||
let (mut sk, old) = linkage(&mut rng, 1.0, 1.0, false);
|
||||
set_distances(&mut sk, &new);
|
||||
let before = turns(&sk);
|
||||
let mut jump = sk.clone();
|
||||
assert!(!switch::without_differential(|| solve(&mut jump)).ok());
|
||||
let mut edit = sk.clone();
|
||||
let report = switch::without_differential(|| solve_after_value_edit(&mut edit, &old));
|
||||
assert!(report.keeps_orientation(), "{report:?}");
|
||||
assert_eq!(turns(&edit), before);
|
||||
for (&(a, b), d) in PAIRS.iter().zip(new) {
|
||||
let measured = edit.points[a + 1].pos.dist(edit.points[b + 1].pos);
|
||||
assert!((measured - d).abs() < 1e-6, "{a}-{b}: {measured} for {d}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1839,7 +1839,84 @@ pub fn solve_after_edit(sk: &mut Sketch, old: &DrivingValues, stored: Option<&[V
|
||||
/// geometry restoration are identical at every analysis level.
|
||||
pub fn solve_after_edit_with_analysis(sk: &mut Sketch, old: &DrivingValues, stored: Option<&[Vec2]>, analysis: Analysis) -> SolveReport {
|
||||
let old_values: Vec<Option<f64>> = sk.constraints.iter().map(|c| old.get(&c.id).copied()).collect();
|
||||
solve_from(sk, &Hold::default(), stored, analysis, Some(&old_values), &mut Budget::full())
|
||||
let before = stored.is_none().then(|| sk.clone());
|
||||
let report = solve_from(sk, &Hold::default(), stored, analysis, Some(&old_values), &mut Budget::full());
|
||||
if report.keeps_orientation() {
|
||||
return report;
|
||||
}
|
||||
match before.and_then(|before| solve_in_steps(&before, old, analysis)) {
|
||||
Some((stepped, stepped_report)) => {
|
||||
*sk = stepped;
|
||||
stepped_report
|
||||
}
|
||||
None => report,
|
||||
}
|
||||
}
|
||||
|
||||
/// A value edit solved in [`EDIT_STEPS`] steps (each a few times more of them than the last
|
||||
/// try), the whole sketch at a time, each step a full solve (seed and iteration) from the last
|
||||
/// step's solution. Continuation follows one solution branch by the iteration alone, from
|
||||
/// sub-step to sub-step; that branch can end before the new values where a solve from the
|
||||
/// values a step before still reaches them keeping every turn (the linkage family in
|
||||
/// `continuation_tests`). `None` when no try reaches the new values keeping every orientation,
|
||||
/// when nothing that can be stepped changed, or when the sketch is not solved for the old values.
|
||||
fn solve_in_steps(start: &Sketch, old: &DrivingValues, analysis: Analysis) -> Option<(Sketch, SolveReport)> {
|
||||
let changed: Vec<(usize, f64, f64)> = start
|
||||
.constraints
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, c)| {
|
||||
let new = c.kind.value()?;
|
||||
let was = *old.get(&c.id)?;
|
||||
(was != new && was.is_finite() && new.is_finite()).then_some((i, was, new))
|
||||
})
|
||||
.collect();
|
||||
let steppable = |&(i, was, new): &(usize, f64, f64)| start.constraints.get(i).is_some_and(|c| steppable(&c.kind, was, new));
|
||||
if changed.is_empty() || !changed.iter().all(steppable) || !solved_for(start, old) {
|
||||
return None;
|
||||
}
|
||||
'tries: for steps in EDIT_STEPS {
|
||||
let mut at = start.clone();
|
||||
let mut values = old.clone();
|
||||
let mut last = None;
|
||||
for k in 1..=steps {
|
||||
let t = k as f64 / steps as f64;
|
||||
for &(i, was, new) in &changed {
|
||||
if let Some(c) = at.constraints.get_mut(i) {
|
||||
c.kind.set_value(if k == steps { new } else { was + (new - was) * t });
|
||||
}
|
||||
}
|
||||
let from: Vec<Option<f64>> = at.constraints.iter().map(|c| values.get(&c.id).copied()).collect();
|
||||
let step_analysis = if k == steps { analysis } else { Analysis::StatusOnly };
|
||||
let report = solve_from(&mut at, &Hold::default(), None, step_analysis, Some(&from), &mut Budget::full());
|
||||
if !report.keeps_orientation() {
|
||||
continue 'tries;
|
||||
}
|
||||
values = at.driving_values();
|
||||
last = Some(report);
|
||||
}
|
||||
return last.map(|report| (at, report));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// How many steps [`solve_in_steps`] tries a value edit in, fewest first.
|
||||
const EDIT_STEPS: [usize; 4] = [2, 4, 8, 16];
|
||||
|
||||
/// The sketch's geometry is a solved state for the driving values `old`: there is a state to
|
||||
/// step from and orientations to keep, as `ValueEdit::starts_solved` decides it per component
|
||||
/// (a sketch stored unsolved has neither, and its one-jump result stands).
|
||||
fn solved_for(sk: &Sketch, old: &DrivingValues) -> bool {
|
||||
let mut was = sk.clone();
|
||||
for c in &mut was.constraints {
|
||||
if let (Some(_), Some(v)) = (c.kind.value(), old.get(&c.id)) {
|
||||
c.kind.set_value(*v);
|
||||
}
|
||||
}
|
||||
let lay = layout(&was, &Hold::default());
|
||||
let mut x = initial_x(&was, &lay);
|
||||
let (blocks, _) = build_blocks(&was, &lay, &points_on_curves(&was));
|
||||
max_abs(&system(&was, &lay, &blocks, &mut x, false).0) < ACCEPT
|
||||
}
|
||||
|
||||
/// The orientations of `stored` that `solved` (the same sketch, its links moved and solved
|
||||
|
||||
@@ -53,11 +53,6 @@ nix = { workspace = true }
|
||||
# updated by its installer (MIT; already in the tree as a build dependency).
|
||||
winreg = "0.56"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
# The macOS menu bar (window_chrome::native_menu; MIT OR Apache-2.0, already in the tree through
|
||||
# the desktop app).
|
||||
muda = { version = "0.21.2", default-features = false }
|
||||
|
||||
[dev-dependencies]
|
||||
solidcraft-engine = { workspace = true, features = ["bundled-render-library", "profile"] }
|
||||
# Parse the source guard structurally, including code after inline test modules.
|
||||
|
||||
@@ -2387,8 +2387,8 @@ fn set_config_option_checked(app: &mut SolveApp, id: &str, value: &str, listed_o
|
||||
|
||||
/// `ui.agent_chat` on the control channel (tests and screenshots): `enabled`, `command`,
|
||||
/// `connect_at_start` and `open_at_start` (the launch preferences), `instructions` (the custom
|
||||
/// instructions, #8220), `open`, `toggle` (show or hide the panel as its button does, unfolding a
|
||||
/// folded one), `side`, `minimized`, `width`, `refresh`, `connect`, `stop`, `attach` (a file's path, for the
|
||||
/// instructions, #8220), `open`,
|
||||
/// `side`, `minimized`, `width`, `refresh`, `connect`, `stop`, `attach` (a file's path, for the
|
||||
/// next message), `remove_file` (index), `send`, `attach_view`, `attach_selection`, `pick`,
|
||||
/// `permission` (an option id, or `"cancel"`), `approve` (`true`/`false`, the oldest held
|
||||
/// request), `cancel` (Stop), `send_now` (steer: stop, then send the queue), `remove_pending` (index), `undo` (the newest message's changes); returns the panel's state.
|
||||
@@ -2503,9 +2503,6 @@ pub fn control(app: &mut SolveApp, ctx: &egui::Context, p: &Value) -> Result<Val
|
||||
if let Some(o) = p.get("open").and_then(Value::as_bool) {
|
||||
app.agent_chat.open = o;
|
||||
}
|
||||
if p.get("toggle").and_then(Value::as_bool) == Some(true) {
|
||||
toggle(app);
|
||||
}
|
||||
if let Some(s) = p.get("side").and_then(Value::as_str) {
|
||||
if !matches!(s, "left" | "right") {
|
||||
return Err("side must be left or right".into());
|
||||
|
||||
@@ -1342,18 +1342,17 @@ fn dispatch(app: &mut SolveApp, ctx: &egui::Context, method: &str, p: &Value) ->
|
||||
},
|
||||
"ui.prefs" => wrap(crate::prefs::control(app, p)),
|
||||
"ui.agent_chat" => wrap(crate::agent_chat::control(app, ctx, p)),
|
||||
"ui.help" => {
|
||||
// The Help menu's items (the macOS menu bar sends the same); none only reports.
|
||||
const ITEMS: [&str; 8] = ["about", "updates", "shortcuts", "report", "crashes", "docs", "messages", "install_cli"];
|
||||
match s("item") {
|
||||
Some(i) if !ITEMS.contains(&i) => return err(format!("unknown help item `{i}` ({})", ITEMS.join(", "))),
|
||||
Some(i) => crate::help::run(app, ctx, i),
|
||||
None => {}
|
||||
"ui.help" => match s("item") {
|
||||
Some(i @ ("about" | "updates" | "shortcuts" | "report" | "crashes")) => {
|
||||
crate::help::run(app, ctx, i);
|
||||
ok(
|
||||
json!({"diagnostics": crate::help::diagnostics(app), "version": crate::help::VERSION, "commit": crate::help::COMMIT, "built": crate::help::BUILD_DATE}),
|
||||
)
|
||||
}
|
||||
ok(
|
||||
_ => ok(
|
||||
json!({"diagnostics": crate::help::diagnostics(app), "version": crate::help::VERSION, "commit": crate::help::COMMIT, "built": crate::help::BUILD_DATE}),
|
||||
)
|
||||
}
|
||||
),
|
||||
},
|
||||
"ui.selectOther" => wrap(crate::select_other::control(app, p)),
|
||||
"ui.menuPick" => {
|
||||
let Some(key) = s("item") else { return err("missing `item` (id or label)") };
|
||||
|
||||
@@ -330,6 +330,40 @@ fn project_dialog_keeps_geometry_targets_and_can_project_whole_bodies_unlinked()
|
||||
assert!(!sketch.curves.is_empty());
|
||||
}
|
||||
|
||||
/// Project takes a point of another sketch (#13246): a point on XY, a sketch on a plane above
|
||||
/// it, and a click on the point in the Project dialog picks it, where it used to do nothing.
|
||||
#[test]
|
||||
fn project_dialog_takes_a_point_of_another_sketch_13246() {
|
||||
let mut h = Harness::new();
|
||||
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
|
||||
h.app.run("sketch.point", json!({"point":[30,20],"id":"pt"})).unwrap();
|
||||
h.app.run("sketch.finish", json!({})).unwrap();
|
||||
let first = h.app.session.doc.features.last().unwrap().id;
|
||||
h.app.run("sketch.create", json!({"plane":"XY","offset":20})).unwrap();
|
||||
h.app.run("sketch.point", json!({"point":[-30,10],"id":"own"})).unwrap();
|
||||
h.app.run("view.home", json!({})).unwrap();
|
||||
h.app.start("sketch.project");
|
||||
h.settle();
|
||||
assert!(matches!(h.app.dialog.as_ref().unwrap().kind, Kind::SketchProject { bodies: false, .. }));
|
||||
// The sketch's own point is not something Project takes.
|
||||
h.step(&json!({"click":{"world":[-30,10,20]}})).unwrap();
|
||||
assert!(h.app.dialog.as_ref().unwrap().inputs[0].items.is_empty(), "{:?}", h.app.dialog.as_ref().unwrap().inputs[0].items);
|
||||
h.step(&json!({"click":{"world":[30,20,0]}})).unwrap();
|
||||
let d = h.app.dialog.as_ref().unwrap();
|
||||
assert_eq!(d.inputs[0].items, vec![Sel::ExternalSketchPoint { sketch: first, id: "pt".into() }], "the point is picked");
|
||||
let cmds = apply_commands(&h.app, d).unwrap();
|
||||
assert_eq!(cmds, vec![("sketch.project".into(), json!({"refs":[{"sketch":first,"point":"pt"}],"projection_link":true}))]);
|
||||
h.step(&json!({"click":{"handle":"dialog:ok"}})).unwrap();
|
||||
h.settle();
|
||||
assert!(h.app.dialog.is_none());
|
||||
let state = h.app.session.world_state().unwrap();
|
||||
let sk = state.sketch(h.app.session.active_sketch.unwrap()).unwrap();
|
||||
assert!(
|
||||
sk.sketch.points.iter().any(|p| p.id != "origin" && (p.pos.x - 30.0).abs() < 1e-6 && (p.pos.y - 20.0).abs() < 1e-6),
|
||||
"the point is projected"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_commits_once_keeps_values_and_waits_for_new_selection_ok_closes() {
|
||||
for command in ["sketch.offset", "sketch.mirror", "sketch.pattern.rectangular", "sketch.pattern.circular"] {
|
||||
|
||||
@@ -468,7 +468,15 @@ mod tests {
|
||||
let sag = mesh
|
||||
.triangles
|
||||
.iter()
|
||||
.map(|t| t.map(|i| mesh.positions[i as usize]))
|
||||
.zip(&mesh.tri_face)
|
||||
// The side wall's own triangles: the flat caps have corners on the rim too.
|
||||
.filter(|(_, f)| {
|
||||
matches!(
|
||||
solidcraft_engine::kernel::face_surface(&b.body, **f as usize),
|
||||
Some(solidcraft_engine::kernel::FaceSurface::Cylinder { .. })
|
||||
)
|
||||
})
|
||||
.map(|(t, _)| t.map(|i| mesh.positions[i as usize]))
|
||||
.filter(|t| t.iter().all(|p| (r(*p) - 10.0).abs() < 1e-9))
|
||||
.map(|[a, b, c]| 10.0 - r((a + b + c) * (1.0 / 3.0)))
|
||||
.fold(0.0, f64::max);
|
||||
|
||||
@@ -466,7 +466,7 @@ fn extrusion_arrow_tracks_the_current_drag_frame() {
|
||||
open(&mut h);
|
||||
let at = h.at(&json!({"handle":"arrow"})).unwrap();
|
||||
let at = egui::pos2(at[0] as f32, at[1] as f32);
|
||||
h.app.synthetic_mods.ctrl = true;
|
||||
hold_free_drag_key(&mut h);
|
||||
h.app.synthetic.push(egui::Event::PointerMoved(at));
|
||||
h.frame();
|
||||
h.app.synthetic.push(egui::Event::PointerButton {
|
||||
@@ -710,6 +710,16 @@ fn failed_face_extrusion_edit_keeps_context_until_escape_and_preserves_visibilit
|
||||
assert_eq!(h.app.session.visibility, visibility);
|
||||
}
|
||||
|
||||
/// Hold the free-drag key, the platform's primary modifier: Ctrl on Windows and Linux, Cmd on
|
||||
/// macOS (#13274). Physical Ctrl alone is not the primary key on macOS.
|
||||
fn hold_free_drag_key(h: &mut Harness) {
|
||||
use crate::tool_modifiers::{Held, Platform};
|
||||
h.app.synthetic_mods.command = true;
|
||||
for platform in [Platform::Mac, Platform::Other] {
|
||||
assert!(Held::new(h.app.synthetic_mods, platform).primary, "{platform:?} sees no primary key in {:?}", h.app.synthetic_mods);
|
||||
}
|
||||
}
|
||||
|
||||
/// A block 12.345678 mm thick under XY (so no round step lands on its underside), a 10 mm
|
||||
/// square sketched on its top and cut 5 mm into it, a second block beside it whose underside is
|
||||
/// at -7.777 mm, and the cut open in its editor.
|
||||
@@ -792,7 +802,7 @@ fn the_extrude_arrow_passes_faces_beside_the_profile_and_in_a_free_drag() {
|
||||
assert_eq!(hold_arrow_at(&mut h, json!({"world":[5,5,-7.75]})), None);
|
||||
assert!((dialog_distance(&h) - 7.777).abs() > 1e-6, "{}", dialog_distance(&h));
|
||||
let_go(&mut h);
|
||||
h.app.synthetic_mods.ctrl = true;
|
||||
hold_free_drag_key(&mut h);
|
||||
assert_eq!(hold_arrow_at(&mut h, json!({"world":[5,5,-12.2]})), None);
|
||||
assert!((dialog_distance(&h) - 12.345678).abs() > 1e-3, "{}", dialog_distance(&h));
|
||||
let_go(&mut h);
|
||||
|
||||
@@ -516,11 +516,28 @@ fn offscreen_adapter(policy: solidcraft_gpu::select::Policy) -> Option<wgpu::Ada
|
||||
}
|
||||
}
|
||||
|
||||
/// A device of its own for an offscreen render or check (`None` without a GPU adapter the
|
||||
/// `SOLIDCRAFT_GPU` policy admits, or when the variable names no policy), on [`offscreen_backends`]. One test at a time starts its
|
||||
/// instance, adapter and device ([`offscreen_starting`]).
|
||||
/// The test process's offscreen device, for an offscreen render or check: started once, on
|
||||
/// [`offscreen_own_device`], and shared by every test after it (#13395). A new device for every
|
||||
/// render also compiled the viewport's pipelines anew: 28 renders took 55 s, on one device 4 s.
|
||||
/// `None` when [`offscreen_own_device`] is; only a device is kept, so after a failed start (a GPU
|
||||
/// briefly out of memory) the next test tries again rather than every later one skipping. A test
|
||||
/// that loses its device, or changes how the device reports errors or its loss, takes
|
||||
/// [`offscreen_own_device`] instead.
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
pub(crate) fn offscreen_device() -> Option<(wgpu::Adapter, wgpu::Device, wgpu::Queue)> {
|
||||
static SHARED: std::sync::Mutex<Option<(wgpu::Adapter, wgpu::Device, wgpu::Queue)>> = std::sync::Mutex::new(None);
|
||||
let mut shared = SHARED.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if shared.is_none() {
|
||||
*shared = offscreen_own_device();
|
||||
}
|
||||
shared.clone()
|
||||
}
|
||||
|
||||
/// A device of its own for an offscreen test (`None` without a GPU adapter the `SOLIDCRAFT_GPU`
|
||||
/// policy admits, or when the variable names no policy), on [`offscreen_backends`]. One test at a
|
||||
/// time starts its instance, adapter and device ([`offscreen_starting`]).
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
pub(crate) fn offscreen_own_device() -> Option<(wgpu::Adapter, wgpu::Device, wgpu::Queue)> {
|
||||
let policy = solidcraft_gpu::select::Policy::from_env().map_err(|e| eprintln!("no offscreen device: {e}")).ok()?;
|
||||
let _one_at_a_time = offscreen_starting();
|
||||
let adapter = offscreen_adapter(policy)?;
|
||||
@@ -1709,7 +1726,7 @@ mod tests {
|
||||
#[test]
|
||||
fn a_lost_device_stops_drawing_and_pictures_fall_back_to_the_cpu() {
|
||||
use egui_wgpu::wgpu;
|
||||
let Some((adapter, device, queue)) = super::offscreen_device() else {
|
||||
let Some((adapter, device, queue)) = super::offscreen_own_device() else {
|
||||
eprintln!("skipping: no GPU adapter");
|
||||
return;
|
||||
};
|
||||
@@ -1749,7 +1766,7 @@ mod tests {
|
||||
#[test]
|
||||
fn the_lost_device_probe_is_clean_on_a_working_device() {
|
||||
use egui_wgpu::wgpu;
|
||||
let Some((adapter, device, queue)) = super::offscreen_device() else {
|
||||
let Some((adapter, device, queue)) = super::offscreen_own_device() else {
|
||||
eprintln!("skipping: no GPU adapter");
|
||||
return;
|
||||
};
|
||||
@@ -1783,7 +1800,7 @@ mod tests {
|
||||
#[test]
|
||||
fn a_frame_that_meets_a_lost_device_is_survived() {
|
||||
use egui_wgpu::wgpu;
|
||||
let Some((_, device, queue)) = super::offscreen_device() else {
|
||||
let Some((_, device, queue)) = super::offscreen_own_device() else {
|
||||
eprintln!("skipping: no GPU adapter");
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -785,7 +785,8 @@ fn cylinder_with_cluster(sketch: Value, at: [f64; 2], view: &str) -> Cluster {
|
||||
gap.x.max(gap.y).max(gap.z) <= 1e-3
|
||||
};
|
||||
let (body_tris, face_tris, near_tris) = (m.triangles.len(), face.len(), face.iter().filter(|t| near_points(t)).count());
|
||||
assert!(body_tris >= 2 * face_tris, "the body has other faces: {body_tris} and {face_tris} triangles");
|
||||
// Other faces exist (the flat caps are two triangles each now, so the curved side can be most of the body).
|
||||
assert!(body_tris > face_tris, "the body has other faces: {body_tris} and {face_tris} triangles");
|
||||
Cluster { h, many, click, body_tris, face_tris, near_tris }
|
||||
}
|
||||
|
||||
|
||||
@@ -326,7 +326,6 @@ mod viewport_details;
|
||||
mod viewport_layout;
|
||||
mod viewport_status;
|
||||
mod viewport_status_fixture;
|
||||
pub mod window_chrome;
|
||||
pub mod workspace;
|
||||
|
||||
use std::sync::mpsc::Receiver;
|
||||
|
||||
@@ -41,7 +41,7 @@ fn an_error_under_capture_is_returned_not_a_panic() {
|
||||
#[test]
|
||||
fn a_configure_that_meets_a_lost_device_is_not_taken() {
|
||||
// The queue is kept: wgpu calls a destroyed device's lost callback only while it has one.
|
||||
let Some((_, device, _queue)) = crate::gpu::offscreen_device() else { return };
|
||||
let Some((_, device, _queue)) = crate::gpu::offscreen_own_device() else { return };
|
||||
let lost = DeviceLost::watch(&device);
|
||||
let bad = wgpu::BufferDescriptor { label: Some("bad"), size: 3, usage: wgpu::BufferUsages::MAP_WRITE, mapped_at_creation: true };
|
||||
// On a working device a clean configure is taken and a refused one returns its error.
|
||||
@@ -88,7 +88,7 @@ fn an_acquire_wgpu_refuses_configures_the_surface_again() {
|
||||
/// the device goes on working.
|
||||
#[test]
|
||||
fn an_uncaught_gpu_error_is_not_a_panic() {
|
||||
let Some((_, device, queue)) = crate::gpu::offscreen_device() else { return };
|
||||
let Some((_, device, queue)) = crate::gpu::offscreen_own_device() else { return };
|
||||
crate::gpu::log_uncaptured_errors(&device);
|
||||
// No error scope around it: mapped at creation with a size that isn't a multiple of 4.
|
||||
let _bad =
|
||||
|
||||
+264
-18
@@ -1,39 +1,211 @@
|
||||
//! SolidCraft's window title bar: the shared window chrome ([`crate::window_chrome`]) with the
|
||||
//! app's own answers. Closing asks about unsaved designs first ([`window_action`]); on Windows and
|
||||
//! Linux without OS decorations ([`SolveApp::custom_titlebar`]) the application bar is the title
|
||||
//! bar, and on macOS it runs under the traffic lights ([`SolveApp::integrated_titlebar`]).
|
||||
//! The window's own title bar on Windows and Linux, where the window has no OS decorations
|
||||
//! ([`SolveApp::custom_titlebar`]): empty space in the application bar drags the window and a
|
||||
//! double-click there maximizes or restores it, caption buttons at the far right minimize,
|
||||
//! maximize/restore and close, and thin invisible zones along the window's edges resize it.
|
||||
//! Dragging uses the OS's own move loop, so Windows snapping (to the screen edges and corners)
|
||||
//! works as with a native title bar.
|
||||
//!
|
||||
//! The resize zones are as wide as Windows' own resize border at the window's scale
|
||||
//! ([`resize_border_px`]), corners win where they overlap the edges ([`resize_hit`]). On Windows
|
||||
//! the desktop app answers the system's hit test with them (`apps/solidcraft/src/native_frame.rs`),
|
||||
//! so the OS shows its resize cursors and resizes from the first frame; elsewhere egui zones above
|
||||
//! everything else start an OS resize ([`resize_zones`]). macOS keeps its traffic lights over the bar
|
||||
//! ([`SolveApp::integrated_titlebar`]); the web build has neither.
|
||||
//!
|
||||
//! Adapted from VectorCraft's `crates/ui-egui/src/titlebar.rs` (MIT OR Apache-2.0).
|
||||
|
||||
use crate::window_chrome::WindowAction;
|
||||
pub use crate::window_chrome::{Caption, dpi_scaled_size, resize_border_px, resize_hit, resize_zones, toggle_maximize};
|
||||
use crate::icons::glyphs::paint_glyph;
|
||||
use crate::ui_kit::BackingTooltipExt as _;
|
||||
use egui::{Color32, CursorIcon, Id, LayerId, Order, PointerButton, Pos2, Rect, ResizeDirection, Sense, Stroke, Ui, ViewportCommand, pos2, vec2};
|
||||
|
||||
use crate::SolveApp;
|
||||
use solidcraft_theme::Tokens;
|
||||
|
||||
/// One caption button: as wide as the native Windows ones.
|
||||
pub const BUTTON_WIDTH: f32 = 46.0;
|
||||
/// The three caption buttons together.
|
||||
pub const WIDTH: f32 = 3.0 * BUTTON_WIDTH;
|
||||
/// Room for the macOS traffic lights at the bar's left end.
|
||||
pub const TRAFFIC_LIGHTS: f32 = 76.0;
|
||||
/// Windows' resize border at 100 % (96 DPI): `SM_CXSIZEFRAME` + `SM_CXPADDEDBORDER`, 4 + 4 px.
|
||||
pub const RESIZE_BORDER_AT_96_DPI: f32 = 8.0;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Debug)]
|
||||
pub enum Caption {
|
||||
Minimize,
|
||||
Maximize,
|
||||
Close,
|
||||
}
|
||||
|
||||
/// Left to right.
|
||||
pub const CAPTIONS: [Caption; 3] = [Caption::Minimize, Caption::Maximize, Caption::Close];
|
||||
|
||||
/// Caption button ids (stable, so tests and agents can find them).
|
||||
pub fn caption_id(c: Caption) -> Id {
|
||||
Id::unique(("titlebar-caption", c as u8))
|
||||
}
|
||||
|
||||
fn maximized(ctx: &egui::Context) -> bool {
|
||||
ctx.input(|i| i.viewport().maximized.unwrap_or(false))
|
||||
}
|
||||
|
||||
pub fn toggle_maximize(ctx: &egui::Context) {
|
||||
ctx.send_viewport_cmd(ViewportCommand::Maximized(!maximized(ctx)));
|
||||
}
|
||||
|
||||
/// Window actions for the control channel (`ui.window`): minimize, maximize, restore, toggle
|
||||
/// (maximize or restore), close.
|
||||
pub fn window_action(app: &mut SolveApp, ctx: &egui::Context, action: &str) -> Result<(), String> {
|
||||
run(app, ctx, WindowAction::parse(action)?);
|
||||
match action {
|
||||
"minimize" => ctx.send_viewport_cmd(ViewportCommand::Minimized(true)),
|
||||
"maximize" => ctx.send_viewport_cmd(ViewportCommand::Maximized(true)),
|
||||
"restore" => ctx.send_viewport_cmd(ViewportCommand::Maximized(false)),
|
||||
"toggle" => toggle_maximize(ctx),
|
||||
"close" => {
|
||||
// Unsaved designs are asked about first; the window closes once they are answered.
|
||||
crate::documents::request_quit(app);
|
||||
if app.quit_requested {
|
||||
ctx.send_viewport_cmd(ViewportCommand::Close);
|
||||
}
|
||||
}
|
||||
other => return Err(format!("unknown window action `{other}` (minimize, maximize, restore, toggle, close)")),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Runs a title-bar action as SolidCraft does.
|
||||
pub(crate) fn run(app: &mut SolveApp, ctx: &egui::Context, action: WindowAction) {
|
||||
if action == WindowAction::Close {
|
||||
// Unsaved designs are asked about first; the window closes once they are answered.
|
||||
crate::documents::request_quit(app);
|
||||
if !app.quit_requested {
|
||||
return;
|
||||
/// The bar's background: a press-and-drag on empty space moves the window, a double-click
|
||||
/// toggles maximize. Call it before laying out the bar so its buttons, registered later, take
|
||||
/// their clicks.
|
||||
pub fn drag_area(ui: &Ui, rect: Rect) {
|
||||
let ctx = ui.ctx();
|
||||
let resp = ui.interact(rect, Id::unique("titlebar-drag"), Sense::click_and_drag());
|
||||
// egui hands a drag that starts on a click-only widget to the drag-sensing background below
|
||||
// it, so remember whether the press itself landed on bare background.
|
||||
if ctx.input(|i| i.pointer.any_pressed()) {
|
||||
let bare = ctx.viewport(|v| v.hits.click.is_some_and(|w| w.id == resp.id));
|
||||
ctx.data_mut(|d| d.insert_temp(resp.id, bare));
|
||||
}
|
||||
if resp.double_clicked() {
|
||||
toggle_maximize(ctx);
|
||||
} else if resp.drag_started_by(PointerButton::Primary) && ctx.data(|d| d.get_temp(resp.id).unwrap_or(false)) {
|
||||
ctx.send_viewport_cmd(ViewportCommand::StartDrag);
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimize, Maximize/Restore and Close at the right end of `bar` (the application bar's
|
||||
/// rect), full bar height, flush with the window's edge so a maximized window's corner hits
|
||||
/// Close.
|
||||
pub fn caption_buttons(app: &mut SolveApp, ui: &mut Ui, bar: Rect) {
|
||||
let t = Tokens::get();
|
||||
let max = maximized(ui.ctx());
|
||||
let mut clicked = None;
|
||||
for (i, c) in CAPTIONS.into_iter().enumerate() {
|
||||
let r = Rect::from_min_size(pos2(bar.right() - WIDTH + i as f32 * BUTTON_WIDTH, bar.top()), vec2(BUTTON_WIDTH, bar.height()));
|
||||
let resp = ui.interact(r, caption_id(c), Sense::click());
|
||||
let fill = match (resp.is_pointer_button_down_on(), resp.hovered()) {
|
||||
(true, _) => t.app_bar_hover_subtle,
|
||||
(_, true) => t.app_bar_hover_button,
|
||||
_ if resp.has_focus() => t.app_bar_hover_button,
|
||||
_ => Color32::TRANSPARENT,
|
||||
};
|
||||
ui.painter().rect_filled(r, 0.0, fill);
|
||||
let ink = t.app_bar_text;
|
||||
paint_glyph(ui, c, max, r.center(), Stroke::new(1.0, ink));
|
||||
let tip = match c {
|
||||
Caption::Minimize => "Minimize",
|
||||
Caption::Maximize if max => "Restore",
|
||||
Caption::Maximize => "Maximize",
|
||||
Caption::Close => "Close",
|
||||
};
|
||||
if resp.backing_on_hover_text(tip).clicked() {
|
||||
clicked = Some(c);
|
||||
}
|
||||
}
|
||||
let action = match clicked {
|
||||
Some(Caption::Minimize) => "minimize",
|
||||
Some(Caption::Maximize) => "toggle",
|
||||
Some(Caption::Close) => "close",
|
||||
None => return,
|
||||
};
|
||||
let _ = window_action(app, &ui.ctx().clone(), action);
|
||||
}
|
||||
|
||||
/// The resize border in physical pixels at `scale` (1.0 = 96 DPI): Windows' default, scaled and
|
||||
/// rounded, never less than a pixel.
|
||||
pub fn resize_border_px(scale: f32) -> f32 {
|
||||
let s = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 };
|
||||
(RESIZE_BORDER_AT_96_DPI * s).round().max(1.0)
|
||||
}
|
||||
|
||||
/// The side of a corner zone for an edge `edge` wide: twice the edge, so a corner is easy to hit.
|
||||
pub fn corner_size(edge: f32) -> f32 {
|
||||
2.0 * edge
|
||||
}
|
||||
|
||||
/// The edge and corner zones of a window with rect `w`, edges `edge` wide: (zone, direction,
|
||||
/// cursor). Corners come last so they win where they overlap the edges.
|
||||
pub fn zones(w: Rect, edge: f32) -> [(Rect, ResizeDirection, CursorIcon); 8] {
|
||||
use ResizeDirection::*;
|
||||
let (e, c) = (edge, corner_size(edge));
|
||||
[
|
||||
(Rect::from_min_max(w.left_top(), pos2(w.right(), w.top() + e)), North, CursorIcon::ResizeVertical),
|
||||
(Rect::from_min_max(pos2(w.left(), w.bottom() - e), w.right_bottom()), South, CursorIcon::ResizeVertical),
|
||||
(Rect::from_min_max(w.left_top(), pos2(w.left() + e, w.bottom())), West, CursorIcon::ResizeHorizontal),
|
||||
(Rect::from_min_max(pos2(w.right() - e, w.top()), w.right_bottom()), East, CursorIcon::ResizeHorizontal),
|
||||
(Rect::from_min_size(w.left_top(), vec2(c, c)), NorthWest, CursorIcon::ResizeNwSe),
|
||||
(Rect::from_min_size(w.right_top() - vec2(c, 0.0), vec2(c, c)), NorthEast, CursorIcon::ResizeNeSw),
|
||||
(Rect::from_min_size(w.left_bottom() - vec2(0.0, c), vec2(c, c)), SouthWest, CursorIcon::ResizeNeSw),
|
||||
(Rect::from_min_size(w.right_bottom() - vec2(c, c), vec2(c, c)), SouthEast, CursorIcon::ResizeNwSe),
|
||||
]
|
||||
}
|
||||
|
||||
/// The resize direction at `p` in a window with rect `w` and edges `edge` wide, `None` away
|
||||
/// from the edges and outside the window. A point is in the window when `min <= p < max` (pixel
|
||||
/// rects: the right and bottom pixels are at `max - 1`). Corners win over edges.
|
||||
pub fn resize_hit(w: Rect, p: Pos2, edge: f32) -> Option<ResizeDirection> {
|
||||
if !(w.is_positive() && edge > 0.0 && p.x >= w.left() && p.x < w.right() && p.y >= w.top() && p.y < w.bottom()) {
|
||||
return None;
|
||||
}
|
||||
zones(w, edge).into_iter().rev().find(|(r, ..)| p.x >= r.left() && p.x < r.right() && p.y >= r.top() && p.y < r.bottom()).map(|(_, d, _)| d)
|
||||
}
|
||||
|
||||
/// The window's new physical size when it moves from `old_dpi` to `new_dpi` (Windows'
|
||||
/// `WM_GETDPISCALEDSIZE`): the same logical size, rounded as winit rounds the size it then
|
||||
/// applies, so the rect the OS suggests mid-move is exactly the rect the window gets.
|
||||
pub fn dpi_scaled_size(size: (i32, i32), old_dpi: u32, new_dpi: u32) -> Option<(i32, i32)> {
|
||||
if old_dpi == 0 || new_dpi == 0 || size.0 <= 0 || size.1 <= 0 {
|
||||
return None;
|
||||
}
|
||||
let (old, new) = (f64::from(old_dpi) / 96.0, f64::from(new_dpi) / 96.0);
|
||||
let scale = |v: i32| (f64::from(v) / old * new).round().clamp(1.0, f64::from(i32::MAX)) as i32;
|
||||
Some((scale(size.0), scale(size.1)))
|
||||
}
|
||||
|
||||
/// Invisible resize zones along the window's edges, above everything else; a press in one
|
||||
/// starts an OS resize in that direction. None while the window is maximized or fullscreen.
|
||||
pub fn resize_zones(ui: &mut Ui) {
|
||||
let ctx = ui.ctx().clone();
|
||||
if maximized(&ctx) || ctx.input(|i| i.viewport().fullscreen.unwrap_or(false)) {
|
||||
return;
|
||||
}
|
||||
let w = ctx.content_rect();
|
||||
let ppp = ctx.pixels_per_point();
|
||||
let edge = resize_border_px(ctx.input(|i| i.viewport().native_pixels_per_point).unwrap_or(ppp)) / ppp;
|
||||
let layer = LayerId::new(Order::Foreground, Id::unique("titlebar-resize"));
|
||||
let zui = ui.new_child(egui::UiBuilder::new().layer_id(layer).max_rect(w));
|
||||
for (i, (r, dir, cursor)) in zones(w, edge).into_iter().enumerate() {
|
||||
let resp = zui.interact(r, Id::unique(("titlebar-resize", i)), Sense::drag()).on_hover_cursor(cursor);
|
||||
if resp.drag_started_by(PointerButton::Primary) {
|
||||
ctx.send_viewport_cmd(ViewportCommand::BeginResize(dir));
|
||||
}
|
||||
}
|
||||
action.send(ctx);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::window_chrome::{BUTTON_WIDTH, CAPTIONS, caption_id};
|
||||
use egui::{Event, PointerButton, Pos2, RawInput, Rect, ResizeDirection, ViewportCommand, ViewportId, pos2, vec2};
|
||||
use egui::{Event, Pos2, RawInput, ViewportId};
|
||||
use solidcraft_engine::Session;
|
||||
use solidcraft_theme::Tokens;
|
||||
|
||||
/// A headless window `width` pt wide showing the application bar (as the title bar) and the
|
||||
/// resize zones.
|
||||
@@ -246,6 +418,80 @@ mod tests {
|
||||
assert!(!cmds.iter().any(|c| matches!(c, ViewportCommand::BeginResize(_))), "{cmds:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resize_border_is_windows_default_for_the_scale() {
|
||||
for (dpi, px) in [(96.0, 8.0), (120.0, 10.0), (144.0, 12.0), (168.0, 14.0), (192.0, 16.0), (288.0, 24.0)] {
|
||||
assert_eq!(resize_border_px(dpi / 96.0), px, "at {dpi} DPI");
|
||||
}
|
||||
// Never 0 px, whatever the scale says.
|
||||
for bad in [0.0, -1.0, f32::NAN, f32::INFINITY] {
|
||||
assert_eq!(resize_border_px(bad), 8.0, "{bad}");
|
||||
}
|
||||
assert_eq!(resize_border_px(0.01), 1.0);
|
||||
assert_eq!(corner_size(resize_border_px(1.5)), 24.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hit_test_edges_corners_and_inside() {
|
||||
use ResizeDirection::*;
|
||||
// A window at (100, 50), 1000 x 700 px, at 150 %: 12 px edges, 24 px corners.
|
||||
let w = Rect::from_min_size(pos2(100.0, 50.0), vec2(1000.0, 700.0));
|
||||
let e = resize_border_px(1.5);
|
||||
let (l, t, r, b) = (100.0, 50.0, 1099.0, 749.0); // the outermost pixels
|
||||
for (p, want) in [
|
||||
((l, 400.0), Some(West)),
|
||||
((l + 11.0, 400.0), Some(West)),
|
||||
((l + 12.0, 400.0), None),
|
||||
((r, 400.0), Some(East)),
|
||||
((r - 11.0, 400.0), Some(East)),
|
||||
((r - 12.0, 400.0), None),
|
||||
((600.0, t), Some(North)),
|
||||
((600.0, t + 12.0), None),
|
||||
((600.0, b), Some(South)),
|
||||
((600.0, b - 11.0), Some(South)),
|
||||
((l, t), Some(NorthWest)),
|
||||
((r, t), Some(NorthEast)),
|
||||
((l, b), Some(SouthWest)),
|
||||
((r, b), Some(SouthEast)),
|
||||
// Corners win over the edges, along both edges for the corner's length.
|
||||
((l + 23.0, t), Some(NorthWest)),
|
||||
((l, t + 23.0), Some(NorthWest)),
|
||||
((l + 24.0, t), Some(North)),
|
||||
((l, t + 24.0), Some(West)),
|
||||
((r - 23.0, b), Some(SouthEast)),
|
||||
((r, b - 23.0), Some(SouthEast)),
|
||||
((r - 23.0, t), Some(NorthEast)),
|
||||
((l, b - 23.0), Some(SouthWest)),
|
||||
((600.0, 400.0), None),
|
||||
// Outside the window: not ours.
|
||||
((l - 1.0, 400.0), None),
|
||||
((r + 1.0, 400.0), None),
|
||||
((600.0, t - 1.0), None),
|
||||
((600.0, b + 1.0), None),
|
||||
] {
|
||||
assert_eq!(resize_hit(w, pos2(p.0, p.1), e), want, "at {p:?}");
|
||||
}
|
||||
assert_eq!(resize_hit(w, pos2(l, 400.0), 0.0), None);
|
||||
assert_eq!(resize_hit(Rect::NOTHING, pos2(l, 400.0), e), None);
|
||||
// Every DPI has a zone on every edge.
|
||||
for dpi in [96.0, 120.0, 144.0, 192.0] {
|
||||
let e = resize_border_px(dpi / 96.0);
|
||||
for (p, d) in [((l, 400.0), West), ((r, 400.0), East), ((600.0, t), North), ((600.0, b), South)] {
|
||||
assert_eq!(resize_hit(w, pos2(p.0, p.1), e), Some(d), "{d:?} at {dpi} DPI");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dpi_change_keeps_the_logical_size() {
|
||||
assert_eq!(dpi_scaled_size((1600, 1000), 96, 144), Some((2400, 1500)));
|
||||
assert_eq!(dpi_scaled_size((2400, 1500), 144, 96), Some((1600, 1000)));
|
||||
assert_eq!(dpi_scaled_size((1001, 777), 120, 192), Some((1602, 1243)));
|
||||
assert_eq!(dpi_scaled_size((1001, 777), 96, 96), Some((1001, 777)));
|
||||
assert_eq!(dpi_scaled_size((1001, 777), 0, 96), None);
|
||||
assert_eq!(dpi_scaled_size((0, 777), 96, 144), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edges_resize_on_the_start_page() {
|
||||
// At launch without a design the start page shows; its layout returned before the
|
||||
|
||||
+230
-231
@@ -405,239 +405,238 @@ pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let frame = Container::new(ui.ctx(), Surface::ApplicationBar).edge_frame(t.app_bar);
|
||||
Container::new(ui.ctx(), Surface::ApplicationBar).show_edge(ui, app_bar.exact_size(APP_BAR_HEIGHT).show_separator_line(false), frame, |ui| {
|
||||
let r = ui.max_rect();
|
||||
// The bar is the window's title bar: its empty space moves the window, the traffic lights
|
||||
// or the caption buttons take its ends.
|
||||
let chrome = crate::window_chrome::TitleBar { traffic_lights: app.integrated_titlebar, caption_buttons: app.custom_titlebar };
|
||||
let action = chrome.show(ui, r, |ui, content| {
|
||||
let left = content.left();
|
||||
// Keep the current header controls and product name when merging the new mark.
|
||||
let compact_actions = r.right() - left < 640.0;
|
||||
// Brand mark: the SolidCraft logo, drawn in code from the SVG's own coordinates
|
||||
// (viewBox 357 x 412). Same 20 px height and centre as the old block.
|
||||
{
|
||||
let c = pos2(left + 20.0, r.center().y);
|
||||
crate::icons::glyphs::brand_mark(ui.painter(), c);
|
||||
// The bar is the window's title bar: its empty space moves the window.
|
||||
if app.custom_titlebar || app.integrated_titlebar {
|
||||
crate::titlebar::drag_area(ui, r);
|
||||
}
|
||||
let left = r.left() + if app.integrated_titlebar { crate::titlebar::TRAFFIC_LIGHTS } else { 0.0 };
|
||||
// Keep the current header controls and product name when merging the new mark.
|
||||
let compact_actions = r.right() - left < 640.0;
|
||||
// Brand mark: the SolidCraft logo, drawn in code from the SVG's own coordinates
|
||||
// (viewBox 357 x 412). Same 20 px height and centre as the old block.
|
||||
{
|
||||
let c = pos2(left + 20.0, r.center().y);
|
||||
crate::icons::glyphs::brand_mark(ui.painter(), c);
|
||||
}
|
||||
if !compact_actions {
|
||||
ui.painter().text(
|
||||
pos2(left + 36.0, r.center().y),
|
||||
Align2::LEFT_CENTER,
|
||||
"SolidCraft",
|
||||
crate::fonts::semibold(t.font_heading),
|
||||
t.app_bar_text,
|
||||
);
|
||||
}
|
||||
let mut x = left + if compact_actions { 56.0 } else { 128.0 };
|
||||
let home = Rect::from_center_size(pos2(x - 4.0, r.center().y), vec2(32.0, 32.0));
|
||||
let idle = !app.file_load.busy();
|
||||
let home_response = ui.interact(home, ui.scope_id().with("appbar_home"), if idle { Sense::click() } else { Sense::hover() });
|
||||
let home_tip = app.tr("ui.start_page");
|
||||
home_response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, idle, home_tip));
|
||||
crate::scenario::publish_handle("appbar:home", home.center());
|
||||
crate::scenario::publish_handle("titlebar:home", home.center());
|
||||
// Compatibility for scenarios that used the former Home tab.
|
||||
crate::scenario::publish_target_as("tab:home", "titlebar:home", home);
|
||||
crate::scenario::publish_rect("appbar:home", home);
|
||||
if idle && (home_response.hovered() || home_response.has_focus()) {
|
||||
ui.painter().rect_filled(home.shrink(3.0), t.radius_sm, t.app_bar_hover_button);
|
||||
}
|
||||
icons::paint_app_bar(ui.painter(), home.shrink(7.0), "home", if idle { 1.0 } else { 0.35 });
|
||||
if home_response.backing_on_hover_text(home_tip).clicked() && idle {
|
||||
app.home.show(app.session.revision);
|
||||
}
|
||||
x = home.right() + 8.0;
|
||||
app_bar_separator(ui, r, x, "brand");
|
||||
x += 16.0;
|
||||
// While a file loads the design waits: no menus or commands from the bar.
|
||||
// The File menu: a text button (New, Open, Save, Save As, Export…). Its handle keeps the
|
||||
// old `appbar:folder` name too, for scripts written against the folder button.
|
||||
let file = {
|
||||
let label = app.tr("ui.file").to_string();
|
||||
let galley = ui.painter().layout_no_wrap(label.clone(), FontId::proportional(t.font_button), t.app_bar_text);
|
||||
let br = Rect::from_min_size(pos2(x - 13.0, r.center().y - 13.0), vec2(galley.size().x + 16.0, 26.0));
|
||||
x = br.right() + 17.0;
|
||||
let resp = ui.interact(br, ui.scope_id().with(("ab", "file")), if idle { Sense::click() } else { Sense::hover() });
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, idle, &label));
|
||||
crate::scenario::publish_handle("appbar:file", br.center());
|
||||
crate::scenario::publish_target_as("appbar:folder", "appbar:file", br);
|
||||
let open = ui.ctx().data(|d| d.get_temp::<bool>(egui::Id::unique("sc_file_menu")).unwrap_or(false));
|
||||
if idle && (resp.hovered() || resp.has_focus() || open) {
|
||||
ui.painter().rect_filled(br, t.radius_sm, t.app_bar_hover);
|
||||
}
|
||||
if !compact_actions {
|
||||
let ink = if idle { t.app_bar_text } else { t.app_bar_text.gamma_multiply(0.35) };
|
||||
ui.painter().galley(pos2(br.left() + 8.0, br.center().y - galley.size().y / 2.0), galley, ink);
|
||||
ui.ctx().data_mut(|d| d.insert_temp(egui::Id::unique("sc_file_menu_x"), br.left()));
|
||||
idle && resp.clicked()
|
||||
};
|
||||
// A disabled button is dimmed and ignores clicks (its tooltip still shows).
|
||||
let click = |ui: &mut egui::Ui, at: f32, icon: &str, tip: &str, enabled: bool| -> bool {
|
||||
let width = if compact_actions && matches!(icon, "save" | "undo" | "redo") { 22.0 } else { 32.0 };
|
||||
let br = Rect::from_center_size(pos2(at, r.center().y), vec2(width, 32.0));
|
||||
let resp = ui.interact(br, ui.scope_id().with(("ab", icon)), if enabled { Sense::click() } else { Sense::hover() });
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, tip));
|
||||
crate::scenario::publish_target(&format!("appbar:{icon}"), br);
|
||||
crate::scenario::publish_target(&format!("titlebar:{icon}"), br);
|
||||
if enabled && (resp.hovered() || resp.has_focus()) {
|
||||
ui.painter().rect_filled(br.shrink(3.0), t.radius_sm, t.app_bar_hover_button);
|
||||
}
|
||||
icons::paint_app_bar(ui.painter(), Rect::from_center_size(br.center(), vec2(18.0, 18.0)), icon, if enabled { 1.0 } else { 0.35 });
|
||||
let clicked = resp.backing_on_hover_text(tip).clicked();
|
||||
enabled && clicked
|
||||
};
|
||||
let step = if compact_actions { 22.0 } else { 34.0 };
|
||||
// Save, Undo and Redo use the same actions as the File menu and shortcuts.
|
||||
let shown = design_shown(app) && idle;
|
||||
let tip = |id: &str, key: &str| {
|
||||
let label = app.tr(id);
|
||||
if shown { format!("{label} ({key})") } else { solidcraft_ui_text::format("ui.needs_design", &[("command", label)]) }
|
||||
};
|
||||
let (save_tip, undo_tip, redo_tip) = (tip("ui.save", "Ctrl+S"), tip("ui.undo", "Ctrl+Z"), tip("ui.redo", "Ctrl+Y"));
|
||||
if click(ui, x, "save", &save_tip, shown) {
|
||||
save(app);
|
||||
}
|
||||
x += step;
|
||||
// Narrow windows retain Save; Undo and Redo remain available from their shortcuts and menus.
|
||||
if !compact_actions {
|
||||
if click(ui, x, "undo", &undo_tip, shown) {
|
||||
FileAction::Undo.run(app);
|
||||
}
|
||||
x += step;
|
||||
if click(ui, x, "redo", &redo_tip, shown) {
|
||||
FileAction::Redo.run(app);
|
||||
}
|
||||
x += step;
|
||||
x = quick_access_bar(app, ui, r, x, step, shown);
|
||||
}
|
||||
if file {
|
||||
app.ui.palette_open = false;
|
||||
let ctx = ui.ctx().clone();
|
||||
let open = ctx.data(|d| d.get_temp::<bool>(egui::Id::unique("sc_file_menu")).unwrap_or(false));
|
||||
let pass = ctx.cumulative_pass_nr();
|
||||
// The File button toggles the menu. Remember the pass it opened on so the same
|
||||
// click isn't also treated as a click outside the menu (which would close it at once).
|
||||
ctx.data_mut(|d| {
|
||||
d.insert_temp(egui::Id::unique("sc_file_menu"), !open);
|
||||
d.insert_temp(egui::Id::unique("sc_file_menu_opened"), pass);
|
||||
});
|
||||
}
|
||||
// Document tabs follow the file actions; the house opens the start page.
|
||||
let captions = if app.custom_titlebar { crate::titlebar::WIDTH } else { 0.0 };
|
||||
let separator_x = x - step + if compact_actions { 19.0 } else { 24.0 };
|
||||
app_bar_separator(ui, r, separator_x, "documents");
|
||||
let tabs_start = separator_x + 10.0;
|
||||
let report_text = solidcraft_ui_text::tr("ui.report_a_bug");
|
||||
let report_font = FontId::proportional(t.font_button);
|
||||
let label_width = ui.painter().layout_no_wrap(report_text.to_string(), report_font.clone(), t.app_bar_text).size().x;
|
||||
// The version links to its release notes; measured here so Report a bug leaves it room.
|
||||
let version =
|
||||
ui.painter().layout_no_wrap(format!("v{}", crate::update::current_version()), FontId::proportional(t.font_small), t.app_bar_text);
|
||||
let (report, report_label) = report_rect(r, captions, tabs_start, label_width, version.size().x, app.header);
|
||||
// Settings' left edge; command search, when the bar holds it, sits just before it and
|
||||
// moves the assistant button and the end of the document tabs left by its width.
|
||||
let gear_left = report.left() - 68.0;
|
||||
let search = (search_in_header(app) && design_shown(app))
|
||||
.then(|| header_search_rect(ui.ctx(), r, gear_left - HEADER_SEARCH_GAP, report.left() - CLUSTER_BUTTONS - tabs_start));
|
||||
let search_shift = search.map_or(0.0, |s| s.width() + HEADER_SEARCH_GAP);
|
||||
if let Some(search) = search {
|
||||
// shortcut_box::show draws the one search editor wherever this rectangle is.
|
||||
ui.ctx().data_mut(|d| d.insert_temp(egui::Id::unique("sc_ribbon_search"), search));
|
||||
crate::scenario::publish_target("titlebar:search", search);
|
||||
}
|
||||
// Assistant, Settings, Help and Report a bug form the right-hand cluster.
|
||||
if click(ui, help_x(report), "help", app.tr("ui.help"), true) {
|
||||
app.help.menu = !app.help.menu;
|
||||
let at = help_x(report);
|
||||
ui.ctx().data_mut(|d| {
|
||||
d.insert_temp(egui::Id::unique("sc_help_opened"), true);
|
||||
d.insert_temp(egui::Id::unique("sc_help_anchor"), at);
|
||||
});
|
||||
}
|
||||
// A newer release: its Update button stays before the right cluster, in the space up to
|
||||
// the bar's own file actions on the left.
|
||||
let controls_left = report.left() - CLUSTER_BUTTONS - search_shift;
|
||||
let span = Rect::from_min_max(pos2(x, report.top()), pos2(controls_left - 2.0, report.bottom()));
|
||||
// Labelled where Report a bug is (an icon-only Report a bug leaves the pill to fit by room).
|
||||
let update_labeled = report_label || (app.header.report == ReportButton::Icon && r.width() >= 640.0);
|
||||
let update = crate::update::button(app, ui, span, update_labeled);
|
||||
let tabs_end = update.map_or(controls_left, |u| u.left()) - 8.0;
|
||||
// Narrow title bars can fit only part of the tab strip. Its hit regions must not
|
||||
// extend into the persistent report and window controls.
|
||||
let tabs_rect = Rect::from_min_max(pos2(tabs_start.min(tabs_end), r.top()), pos2(tabs_end, r.bottom()));
|
||||
let mut tabs_ui = ui.new_child(egui::UiBuilder::new().max_rect(tabs_rect));
|
||||
tabs_ui.set_clip_rect(tabs_rect.intersect(ui.clip_rect()));
|
||||
crate::documents::tabs(app, &mut tabs_ui, r, tabs_start, tabs_end);
|
||||
let response = ui.interact(report, ui.scope_id().with("sc_report_button"), Sense::click());
|
||||
response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), report_text));
|
||||
crate::scenario::publish_target("titlebar:report", report);
|
||||
let hot = response.hovered() || response.has_focus();
|
||||
let tip = if app.header.report == ReportButton::Icon {
|
||||
// Drawn like Settings and Help beside it.
|
||||
if hot {
|
||||
ui.painter().rect_filled(report.shrink(3.0), t.radius_sm, t.app_bar_hover_button);
|
||||
}
|
||||
icons::paint_app_bar(ui.painter(), report.shrink(7.0), "bug_report", 1.0);
|
||||
report_text
|
||||
} else {
|
||||
ui.painter().rect_filled(report, t.radius_sm, if hot { t.app_bar_hover_button } else { t.app_bar });
|
||||
ui.painter().rect_stroke(report, t.radius_sm, Stroke::new(1.0, t.app_bar_separator), egui::StrokeKind::Inside);
|
||||
let icon = Rect::from_center_size(pos2(report.left() + 14.0, report.center().y), vec2(18.0, 18.0));
|
||||
icons::paint_app_bar(ui.painter(), icon, "bug_report", 1.0);
|
||||
if report_label {
|
||||
ui.painter().text(
|
||||
pos2(left + 36.0, r.center().y),
|
||||
pos2(report.left() + REPORT_TEXT_X, report.center().y),
|
||||
Align2::LEFT_CENTER,
|
||||
"SolidCraft",
|
||||
crate::fonts::semibold(t.font_heading),
|
||||
report_text,
|
||||
report_font,
|
||||
t.app_bar_text,
|
||||
);
|
||||
}
|
||||
let mut x = left + if compact_actions { 56.0 } else { 128.0 };
|
||||
let home = Rect::from_center_size(pos2(x - 4.0, r.center().y), vec2(32.0, 32.0));
|
||||
let idle = !app.file_load.busy();
|
||||
let home_response = ui.interact(home, ui.scope_id().with("appbar_home"), if idle { Sense::click() } else { Sense::hover() });
|
||||
let home_tip = app.tr("ui.start_page");
|
||||
home_response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, idle, home_tip));
|
||||
crate::scenario::publish_handle("appbar:home", home.center());
|
||||
crate::scenario::publish_handle("titlebar:home", home.center());
|
||||
// Compatibility for scenarios that used the former Home tab.
|
||||
crate::scenario::publish_target_as("tab:home", "titlebar:home", home);
|
||||
crate::scenario::publish_rect("appbar:home", home);
|
||||
if idle && (home_response.hovered() || home_response.has_focus()) {
|
||||
ui.painter().rect_filled(home.shrink(3.0), t.radius_sm, t.app_bar_hover_button);
|
||||
solidcraft_ui_text::tr_label("Report a bug — review a screenshot and open a GitHub draft")
|
||||
};
|
||||
if response.backing_on_hover_text(tip).clicked() {
|
||||
crate::help::run(app, &ui.ctx().clone(), "report");
|
||||
}
|
||||
// Settings sits immediately left of Help (also Ctrl+,).
|
||||
let gear = Rect::from_center_size(pos2(report.left() - 52.0, r.center().y), vec2(32.0, 32.0));
|
||||
let gresp = ui.interact(gear, ui.scope_id().with("sc_prefs_button"), Sense::click());
|
||||
if gresp.hovered() || gresp.has_focus() {
|
||||
ui.painter().rect_filled(gear.shrink(3.0), t.radius_sm, t.app_bar_hover_button);
|
||||
}
|
||||
icons::paint_app_bar(ui.painter(), gear.shrink(7.0), "settings", 1.0);
|
||||
crate::scenario::publish_rect("titlebar:preferences", gear);
|
||||
if crate::agent_chat::app_bar_button_shown(app) {
|
||||
assistant_button(app, ui, Rect::from_center_size(pos2(report.left() - 86.0 - search_shift, r.center().y), vec2(32.0, 32.0)));
|
||||
}
|
||||
crate::scenario::publish_handle("titlebar:preferences", gear.center());
|
||||
let prefs_tip = if cfg!(target_os = "macos") {
|
||||
format!("{} (Cmd+,)", app.tr("ui.preferences"))
|
||||
} else {
|
||||
format!("{} (Ctrl+,)", app.tr("ui.preferences"))
|
||||
};
|
||||
if gresp.backing_on_hover_text(prefs_tip).clicked() {
|
||||
crate::prefs::open_at(app, 0);
|
||||
}
|
||||
if app.header.version == VersionPlace::Header && version_room(r, captions, tabs_start, version.size().x).is_some() {
|
||||
let galley = version;
|
||||
let vrect = Align2::RIGHT_CENTER.anchor_size(pos2(r.right() - 12.0 - captions, r.center().y), galley.size());
|
||||
let vresp = ui.interact(
|
||||
vrect.expand2(vec2(2.0, 4.0)).intersect(Rect::everything_right_of(report.right())),
|
||||
ui.scope_id().with("sc_version_link"),
|
||||
Sense::click(),
|
||||
);
|
||||
let url = crate::update::release_notes_url(crate::update::current_version(), crate::update::release_build());
|
||||
let vresp = vresp.on_hover_cursor(egui::CursorIcon::PointingHand).backing_on_hover_text(&url);
|
||||
if vresp.hovered() || vresp.has_focus() {
|
||||
ui.painter().hline(vrect.x_range(), vrect.bottom(), Stroke::new(1.0, t.app_bar_text));
|
||||
}
|
||||
icons::paint_app_bar(ui.painter(), home.shrink(7.0), "home", if idle { 1.0 } else { 0.35 });
|
||||
if home_response.backing_on_hover_text(home_tip).clicked() && idle {
|
||||
app.home.show(app.session.revision);
|
||||
ui.painter().galley(vrect.min, galley, t.app_bar_text);
|
||||
crate::scenario::publish_handle("titlebar:version", vrect.center());
|
||||
if vresp.clicked() {
|
||||
ui.ctx().open_url(egui::OpenUrl::new_tab(url));
|
||||
}
|
||||
x = home.right() + 8.0;
|
||||
app_bar_separator(ui, r, x, "brand");
|
||||
x += 16.0;
|
||||
// While a file loads the design waits: no menus or commands from the bar.
|
||||
// The File menu: a text button (New, Open, Save, Save As, Export…). Its handle keeps the
|
||||
// old `appbar:folder` name too, for scripts written against the folder button.
|
||||
let file = {
|
||||
let label = app.tr("ui.file").to_string();
|
||||
let galley = ui.painter().layout_no_wrap(label.clone(), FontId::proportional(t.font_button), t.app_bar_text);
|
||||
let br = Rect::from_min_size(pos2(x - 13.0, r.center().y - 13.0), vec2(galley.size().x + 16.0, 26.0));
|
||||
x = br.right() + 17.0;
|
||||
let resp = ui.interact(br, ui.scope_id().with(("ab", "file")), if idle { Sense::click() } else { Sense::hover() });
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, idle, &label));
|
||||
crate::scenario::publish_handle("appbar:file", br.center());
|
||||
crate::scenario::publish_target_as("appbar:folder", "appbar:file", br);
|
||||
let open = ui.ctx().data(|d| d.get_temp::<bool>(egui::Id::unique("sc_file_menu")).unwrap_or(false));
|
||||
if idle && (resp.hovered() || resp.has_focus() || open) {
|
||||
ui.painter().rect_filled(br, t.radius_sm, t.app_bar_hover);
|
||||
}
|
||||
let ink = if idle { t.app_bar_text } else { t.app_bar_text.gamma_multiply(0.35) };
|
||||
ui.painter().galley(pos2(br.left() + 8.0, br.center().y - galley.size().y / 2.0), galley, ink);
|
||||
ui.ctx().data_mut(|d| d.insert_temp(egui::Id::unique("sc_file_menu_x"), br.left()));
|
||||
idle && resp.clicked()
|
||||
};
|
||||
// A disabled button is dimmed and ignores clicks (its tooltip still shows).
|
||||
let click = |ui: &mut egui::Ui, at: f32, icon: &str, tip: &str, enabled: bool| -> bool {
|
||||
let width = if compact_actions && matches!(icon, "save" | "undo" | "redo") { 22.0 } else { 32.0 };
|
||||
let br = Rect::from_center_size(pos2(at, r.center().y), vec2(width, 32.0));
|
||||
let resp = ui.interact(br, ui.scope_id().with(("ab", icon)), if enabled { Sense::click() } else { Sense::hover() });
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, tip));
|
||||
crate::scenario::publish_target(&format!("appbar:{icon}"), br);
|
||||
crate::scenario::publish_target(&format!("titlebar:{icon}"), br);
|
||||
if enabled && (resp.hovered() || resp.has_focus()) {
|
||||
ui.painter().rect_filled(br.shrink(3.0), t.radius_sm, t.app_bar_hover_button);
|
||||
}
|
||||
icons::paint_app_bar(ui.painter(), Rect::from_center_size(br.center(), vec2(18.0, 18.0)), icon, if enabled { 1.0 } else { 0.35 });
|
||||
let clicked = resp.backing_on_hover_text(tip).clicked();
|
||||
enabled && clicked
|
||||
};
|
||||
let step = if compact_actions { 22.0 } else { 34.0 };
|
||||
// Save, Undo and Redo use the same actions as the File menu and shortcuts.
|
||||
let shown = design_shown(app) && idle;
|
||||
let tip = |id: &str, key: &str| {
|
||||
let label = app.tr(id);
|
||||
if shown { format!("{label} ({key})") } else { solidcraft_ui_text::format("ui.needs_design", &[("command", label)]) }
|
||||
};
|
||||
let (save_tip, undo_tip, redo_tip) = (tip("ui.save", "Ctrl+S"), tip("ui.undo", "Ctrl+Z"), tip("ui.redo", "Ctrl+Y"));
|
||||
if click(ui, x, "save", &save_tip, shown) {
|
||||
save(app);
|
||||
}
|
||||
x += step;
|
||||
// Narrow windows retain Save; Undo and Redo remain available from their shortcuts and menus.
|
||||
if !compact_actions {
|
||||
if click(ui, x, "undo", &undo_tip, shown) {
|
||||
FileAction::Undo.run(app);
|
||||
}
|
||||
x += step;
|
||||
if click(ui, x, "redo", &redo_tip, shown) {
|
||||
FileAction::Redo.run(app);
|
||||
}
|
||||
x += step;
|
||||
x = quick_access_bar(app, ui, r, x, step, shown);
|
||||
}
|
||||
if file {
|
||||
app.ui.palette_open = false;
|
||||
let ctx = ui.ctx().clone();
|
||||
let open = ctx.data(|d| d.get_temp::<bool>(egui::Id::unique("sc_file_menu")).unwrap_or(false));
|
||||
let pass = ctx.cumulative_pass_nr();
|
||||
// The File button toggles the menu. Remember the pass it opened on so the same
|
||||
// click isn't also treated as a click outside the menu (which would close it at once).
|
||||
ctx.data_mut(|d| {
|
||||
d.insert_temp(egui::Id::unique("sc_file_menu"), !open);
|
||||
d.insert_temp(egui::Id::unique("sc_file_menu_opened"), pass);
|
||||
});
|
||||
}
|
||||
// Document tabs follow the file actions; the house opens the start page.
|
||||
let captions = r.right() - content.right();
|
||||
let separator_x = x - step + if compact_actions { 19.0 } else { 24.0 };
|
||||
app_bar_separator(ui, r, separator_x, "documents");
|
||||
let tabs_start = separator_x + 10.0;
|
||||
let report_text = solidcraft_ui_text::tr("ui.report_a_bug");
|
||||
let report_font = FontId::proportional(t.font_button);
|
||||
let label_width = ui.painter().layout_no_wrap(report_text.to_string(), report_font.clone(), t.app_bar_text).size().x;
|
||||
// The version links to its release notes; measured here so Report a bug leaves it room.
|
||||
let version =
|
||||
ui.painter().layout_no_wrap(format!("v{}", crate::update::current_version()), FontId::proportional(t.font_small), t.app_bar_text);
|
||||
let (report, report_label) = report_rect(r, captions, tabs_start, label_width, version.size().x, app.header);
|
||||
// Settings' left edge; command search, when the bar holds it, sits just before it and
|
||||
// moves the assistant button and the end of the document tabs left by its width.
|
||||
let gear_left = report.left() - 68.0;
|
||||
let search = (search_in_header(app) && design_shown(app))
|
||||
.then(|| header_search_rect(ui.ctx(), r, gear_left - HEADER_SEARCH_GAP, report.left() - CLUSTER_BUTTONS - tabs_start));
|
||||
let search_shift = search.map_or(0.0, |s| s.width() + HEADER_SEARCH_GAP);
|
||||
if let Some(search) = search {
|
||||
// shortcut_box::show draws the one search editor wherever this rectangle is.
|
||||
ui.ctx().data_mut(|d| d.insert_temp(egui::Id::unique("sc_ribbon_search"), search));
|
||||
crate::scenario::publish_target("titlebar:search", search);
|
||||
}
|
||||
// Assistant, Settings, Help and Report a bug form the right-hand cluster.
|
||||
if click(ui, help_x(report), "help", app.tr("ui.help"), true) {
|
||||
app.help.menu = !app.help.menu;
|
||||
let at = help_x(report);
|
||||
ui.ctx().data_mut(|d| {
|
||||
d.insert_temp(egui::Id::unique("sc_help_opened"), true);
|
||||
d.insert_temp(egui::Id::unique("sc_help_anchor"), at);
|
||||
});
|
||||
}
|
||||
// A newer release: its Update button stays before the right cluster, in the space up to
|
||||
// the bar's own file actions on the left.
|
||||
let controls_left = report.left() - CLUSTER_BUTTONS - search_shift;
|
||||
let span = Rect::from_min_max(pos2(x, report.top()), pos2(controls_left - 2.0, report.bottom()));
|
||||
// Labelled where Report a bug is (an icon-only Report a bug leaves the pill to fit by room).
|
||||
let update_labeled = report_label || (app.header.report == ReportButton::Icon && r.width() >= 640.0);
|
||||
let update = crate::update::button(app, ui, span, update_labeled);
|
||||
let tabs_end = update.map_or(controls_left, |u| u.left()) - 8.0;
|
||||
// Narrow title bars can fit only part of the tab strip. Its hit regions must not
|
||||
// extend into the persistent report and window controls.
|
||||
let tabs_rect = Rect::from_min_max(pos2(tabs_start.min(tabs_end), r.top()), pos2(tabs_end, r.bottom()));
|
||||
let mut tabs_ui = ui.new_child(egui::UiBuilder::new().max_rect(tabs_rect));
|
||||
tabs_ui.set_clip_rect(tabs_rect.intersect(ui.clip_rect()));
|
||||
crate::documents::tabs(app, &mut tabs_ui, r, tabs_start, tabs_end);
|
||||
let response = ui.interact(report, ui.scope_id().with("sc_report_button"), Sense::click());
|
||||
response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), report_text));
|
||||
crate::scenario::publish_target("titlebar:report", report);
|
||||
let hot = response.hovered() || response.has_focus();
|
||||
let tip = if app.header.report == ReportButton::Icon {
|
||||
// Drawn like Settings and Help beside it.
|
||||
if hot {
|
||||
ui.painter().rect_filled(report.shrink(3.0), t.radius_sm, t.app_bar_hover_button);
|
||||
}
|
||||
icons::paint_app_bar(ui.painter(), report.shrink(7.0), "bug_report", 1.0);
|
||||
report_text
|
||||
} else {
|
||||
ui.painter().rect_filled(report, t.radius_sm, if hot { t.app_bar_hover_button } else { t.app_bar });
|
||||
ui.painter().rect_stroke(report, t.radius_sm, Stroke::new(1.0, t.app_bar_separator), egui::StrokeKind::Inside);
|
||||
let icon = Rect::from_center_size(pos2(report.left() + 14.0, report.center().y), vec2(18.0, 18.0));
|
||||
icons::paint_app_bar(ui.painter(), icon, "bug_report", 1.0);
|
||||
if report_label {
|
||||
ui.painter().text(
|
||||
pos2(report.left() + REPORT_TEXT_X, report.center().y),
|
||||
Align2::LEFT_CENTER,
|
||||
report_text,
|
||||
report_font,
|
||||
t.app_bar_text,
|
||||
);
|
||||
}
|
||||
solidcraft_ui_text::tr_label("Report a bug — review a screenshot and open a GitHub draft")
|
||||
};
|
||||
if response.backing_on_hover_text(tip).clicked() {
|
||||
crate::help::run(app, &ui.ctx().clone(), "report");
|
||||
}
|
||||
// Settings sits immediately left of Help (also Ctrl+,).
|
||||
let gear = Rect::from_center_size(pos2(report.left() - 52.0, r.center().y), vec2(32.0, 32.0));
|
||||
let gresp = ui.interact(gear, ui.scope_id().with("sc_prefs_button"), Sense::click());
|
||||
if gresp.hovered() || gresp.has_focus() {
|
||||
ui.painter().rect_filled(gear.shrink(3.0), t.radius_sm, t.app_bar_hover_button);
|
||||
}
|
||||
icons::paint_app_bar(ui.painter(), gear.shrink(7.0), "settings", 1.0);
|
||||
crate::scenario::publish_rect("titlebar:preferences", gear);
|
||||
if crate::agent_chat::app_bar_button_shown(app) {
|
||||
assistant_button(app, ui, Rect::from_center_size(pos2(report.left() - 86.0 - search_shift, r.center().y), vec2(32.0, 32.0)));
|
||||
}
|
||||
crate::scenario::publish_handle("titlebar:preferences", gear.center());
|
||||
let prefs_tip = if cfg!(target_os = "macos") {
|
||||
format!("{} (Cmd+,)", app.tr("ui.preferences"))
|
||||
} else {
|
||||
format!("{} (Ctrl+,)", app.tr("ui.preferences"))
|
||||
};
|
||||
if gresp.backing_on_hover_text(prefs_tip).clicked() {
|
||||
crate::prefs::open_at(app, 0);
|
||||
}
|
||||
if app.header.version == VersionPlace::Header && version_room(r, captions, tabs_start, version.size().x).is_some() {
|
||||
let galley = version;
|
||||
let vrect = Align2::RIGHT_CENTER.anchor_size(pos2(r.right() - 12.0 - captions, r.center().y), galley.size());
|
||||
let vresp = ui.interact(
|
||||
vrect.expand2(vec2(2.0, 4.0)).intersect(Rect::everything_right_of(report.right())),
|
||||
ui.scope_id().with("sc_version_link"),
|
||||
Sense::click(),
|
||||
);
|
||||
let url = crate::update::release_notes_url(crate::update::current_version(), crate::update::release_build());
|
||||
let vresp = vresp.on_hover_cursor(egui::CursorIcon::PointingHand).backing_on_hover_text(&url);
|
||||
if vresp.hovered() || vresp.has_focus() {
|
||||
ui.painter().hline(vrect.x_range(), vrect.bottom(), Stroke::new(1.0, t.app_bar_text));
|
||||
}
|
||||
ui.painter().galley(vrect.min, galley, t.app_bar_text);
|
||||
crate::scenario::publish_handle("titlebar:version", vrect.center());
|
||||
if vresp.clicked() {
|
||||
ui.ctx().open_url(egui::OpenUrl::new_tab(url));
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Some(action) = action {
|
||||
crate::titlebar::run(app, ui.ctx(), action);
|
||||
}
|
||||
if app.custom_titlebar {
|
||||
crate::titlebar::caption_buttons(app, ui, r);
|
||||
}
|
||||
});
|
||||
file_menu(app, ui.ctx());
|
||||
@@ -4336,7 +4335,7 @@ mod header_report_tests {
|
||||
for scale in [1.0, 2.0] {
|
||||
let width = physical_width / scale;
|
||||
let bar = Rect::from_min_size(egui::Pos2::ZERO, vec2(width, APP_BAR_HEIGHT));
|
||||
for captions in [0.0, crate::window_chrome::WIDTH] {
|
||||
for captions in [0.0, crate::titlebar::WIDTH] {
|
||||
// The English label, and a longer one (Russian is about 125 px).
|
||||
for (label_width, layout) in [80.0, 125.0].into_iter().flat_map(|w| [(w, HeaderLayout::DESKTOP), (w, HeaderLayout::COMPACT)]) {
|
||||
let (report, labeled) = report_rect(bar, captions, 354.0, label_width, 40.0, layout);
|
||||
@@ -4427,8 +4426,8 @@ mod header_report_tests {
|
||||
let version = h.ctx.fonts_mut(|f| f.layout_no_wrap(version, FontId::proportional(t.font_small), t.app_bar_text).size().x);
|
||||
// The button's right edge is fixed; its width is twice the distance to its centre.
|
||||
let bar = Rect::from_min_size(egui::Pos2::ZERO, vec2(1400.0, APP_BAR_HEIGHT));
|
||||
let room = version_room(bar, crate::window_chrome::WIDTH, 354.0, version).unwrap_or(8.0);
|
||||
2.0 * (1400.0 - crate::window_chrome::WIDTH - room - center.x)
|
||||
let room = version_room(bar, crate::titlebar::WIDTH, 354.0, version).unwrap_or(8.0);
|
||||
2.0 * (1400.0 - crate::titlebar::WIDTH - room - center.x)
|
||||
};
|
||||
let (en, ru) = (width_in("en"), width_in("ru"));
|
||||
assert!((en - 114.34375).abs() < 0.05, "Plex Sans report button width: {en}");
|
||||
@@ -4481,7 +4480,7 @@ mod header_report_tests {
|
||||
let t = Tokens::get();
|
||||
let version = format!("v{}", crate::update::current_version());
|
||||
let version = h.ctx.fonts_mut(|f| f.layout_no_wrap(version, FontId::proportional(t.font_small), t.app_bar_text).size().x);
|
||||
assert_eq!(report.right(), 1400.0 - crate::window_chrome::WIDTH - RIGHT_GROUP.max(12.0 + version + 8.0));
|
||||
assert_eq!(report.right(), 1400.0 - crate::titlebar::WIDTH - RIGHT_GROUP.max(12.0 + version + 8.0));
|
||||
assert_eq!(report.height(), 26.0);
|
||||
assert!(report.width() > 100.0, "Report a bug carries its label: {report:?}");
|
||||
assert_eq!(rect("titlebar:preferences").center().x, report.left() - 52.0);
|
||||
@@ -4512,7 +4511,7 @@ mod header_report_tests {
|
||||
// Report a bug: an icon the size of Settings and Help, still named for assistive tools.
|
||||
let report = rect("titlebar:report");
|
||||
assert_eq!(report.size(), vec2(32.0, 32.0));
|
||||
assert_eq!(report.right(), 1400.0 - crate::window_chrome::WIDTH - 8.0, "no room is kept for the version");
|
||||
assert_eq!(report.right(), 1400.0 - crate::titlebar::WIDTH - 8.0, "no room is kept for the version");
|
||||
assert_eq!(rect("appbar:help").size(), report.size());
|
||||
assert!(h.widget("Report a bug").is_some(), "the icon keeps its accessible name");
|
||||
assert!(crate::scenario::handle_at("titlebar:version").is_none(), "the version left the bar");
|
||||
@@ -4572,7 +4571,7 @@ mod header_report_tests {
|
||||
assert!(candidate > release + 15.0, "{release} {candidate}");
|
||||
for width in [700.0, 900.0, 1400.0] {
|
||||
let bar = Rect::from_min_size(egui::Pos2::ZERO, vec2(width, APP_BAR_HEIGHT));
|
||||
for captions in [0.0, crate::window_chrome::WIDTH] {
|
||||
for captions in [0.0, crate::titlebar::WIDTH] {
|
||||
// A release keeps the button where it always was.
|
||||
assert_eq!(version_room(bar, captions, 354.0, release), Some(RIGHT_GROUP), "{width} {captions}");
|
||||
for (k, version_width) in [release, candidate, 2.0 * candidate].into_iter().enumerate() {
|
||||
|
||||
@@ -2777,13 +2777,19 @@ fn pick_hits(app: &SolveApp, proj: &Proj, pos: Pos2, other_sketch_points: bool,
|
||||
}
|
||||
// Sketch points of the active sketch (of every shown sketch while a command takes points,
|
||||
// while idle or projecting, and while 3D drawing snaps to points on other visible sketches).
|
||||
let wants_points = app.dialog.as_ref().and_then(|d| d.active_input()).is_some_and(|i| i.accept & crate::selection::POINTS != 0);
|
||||
let external_points =
|
||||
wants_points || other_sketch_points || (app.dialog.is_none() && app.tool.as_ref().is_none_or(|tool| tool.cmd == "sketch.project"));
|
||||
let accept = app.dialog.as_ref().and_then(|d| d.active_input()).map_or(0, |i| i.accept);
|
||||
let wants_points = accept & crate::selection::POINTS != 0;
|
||||
// An input that takes only points of other sketches (Project, #13246) is offered those and
|
||||
// not the active sketch's own, which it can't take.
|
||||
let only_other_points = !wants_points && accept & crate::selection::EXTERNAL_POINTS != 0 && accept & crate::selection::SKETCH_POINTS == 0;
|
||||
let external_points = wants_points
|
||||
|| only_other_points
|
||||
|| other_sketch_points
|
||||
|| (app.dialog.is_none() && app.tool.as_ref().is_none_or(|tool| tool.cmd == "sketch.project"));
|
||||
let point_sketches: Vec<u64> = if crate::render_workspace::active(app) {
|
||||
Vec::new()
|
||||
} else if external_points {
|
||||
sketches.clone()
|
||||
sketches.iter().copied().filter(|sid| !(only_other_points && s.active_sketch == Some(*sid))).collect()
|
||||
} else {
|
||||
s.active_sketch.into_iter().collect()
|
||||
};
|
||||
@@ -9116,10 +9122,9 @@ mod tests {
|
||||
for style in 0..4 {
|
||||
app.ui.visual_style = style;
|
||||
let scene = build_scene(&app).merged();
|
||||
// Twelve edges per placed box, in the saved mesh's pieces: no side longer than a fifth
|
||||
// of the 30 mm diagonal, so the eight 20 mm edges in four and the four 10 mm ones in
|
||||
// two, 40 segments.
|
||||
let lines = if style == 1 { 0 } else { 2 * 40 * crate::gpu::LINE_SIZE };
|
||||
// Twelve edges per placed box, each one segment (a flat face needs no nodes along its
|
||||
// edges), 24 segments.
|
||||
let lines = if style == 1 { 0 } else { 2 * 12 * crate::gpu::LINE_SIZE };
|
||||
assert_eq!(scene.lines.len(), lines, "style {style}: twelve edges per placed box");
|
||||
assert_eq!(scene.hidden.len(), if style == 3 { lines } else { 0 });
|
||||
assert_eq!(scene.tris.is_empty(), style == 2, "wireframe draws only the edges");
|
||||
|
||||
@@ -243,9 +243,8 @@ fn an_opacity_class_change_keeps_vertices_and_rewrites_only_the_runs() {
|
||||
assert!(block.bytes.opaque.is_empty() && !block.bytes.glass.is_empty(), "the triangles moved to the glass runs");
|
||||
let triangles: u32 = block.bytes.glass.iter().map(|r| r.len() as u32).sum::<u32>() / 3;
|
||||
assert_eq!(block.bytes.glass.len(), 1, "one body of one class is one run, drawn with no index buffer");
|
||||
// The planner's grids, no side longer than a fifth of the box's 26.9 mm diagonal: edges in
|
||||
// 4, 3 and 2 pieces, faces of 4 × 3, 4 × 2 and 3 × 2 cells, 2 · (12 + 8 + 6) · 2 triangles.
|
||||
assert_eq!(triangles, 104, "every triangle of the box");
|
||||
// Flat faces are their outline alone: two triangles a face.
|
||||
assert_eq!(triangles, 12, "every triangle of the box");
|
||||
let vertex = block.bytes.glass.first().map_or(0, |r| r.start) as usize;
|
||||
let at = vertex * crate::gpu::TRI_SIZE;
|
||||
let colour = first_vertex(&second, block.bytes.vertices.get(at..).unwrap_or_default()).1;
|
||||
|
||||
@@ -1,506 +0,0 @@
|
||||
//! Window chrome any Craft app shares: the window's settings ([`viewport`]) and its title bar,
|
||||
//! drawn from plain inputs ([`TitleBar`]) and answering with [`WindowAction`]s for the app to run.
|
||||
//!
|
||||
//! The OS window frame is kept. On macOS the OS title bar is hidden and the content extends under
|
||||
//! it, with room left for the traffic lights ([`TRAFFIC_LIGHTS`]). On Windows and Linux an app
|
||||
//! can drop the OS decorations: then empty space in the bar drags the window and a double-click
|
||||
//! there maximizes or restores it, caption buttons at the far right minimize, maximize/restore and
|
||||
//! close, and thin invisible zones along the window's edges resize it. Dragging uses the OS's own
|
||||
//! move loop, so Windows snapping (to the screen edges and corners) works as with a native title
|
||||
//! bar.
|
||||
//!
|
||||
//! The resize zones are as wide as Windows' own resize border at the window's scale
|
||||
//! ([`resize_border_px`]), corners win where they overlap the edges ([`resize_hit`]). An app that
|
||||
//! answers the system's hit test itself (SolidCraft's `apps/solidcraft/src/native_frame.rs`) uses
|
||||
//! them there, so the OS shows its resize cursors and resizes from the first frame; elsewhere egui
|
||||
//! zones above everything else start an OS resize ([`resize_zones`]). The web build has neither.
|
||||
//!
|
||||
//! Nothing here knows an app's state: closing is a [`WindowAction::Close`] the app answers (it may
|
||||
//! ask about unsaved work first).
|
||||
//!
|
||||
//! Adapted from VectorCraft's `crates/ui-egui/src/titlebar.rs` (MIT OR Apache-2.0).
|
||||
|
||||
use crate::icons::glyphs::paint_glyph;
|
||||
use crate::ui_kit::BackingTooltipExt as _;
|
||||
use egui::{
|
||||
Color32, CursorIcon, Id, LayerId, Order, PointerButton, Pos2, Rect, ResizeDirection, Sense, Stroke, Ui, ViewportBuilder, ViewportCommand, pos2,
|
||||
vec2,
|
||||
};
|
||||
use solidcraft_theme::Tokens;
|
||||
|
||||
pub mod native_menu;
|
||||
|
||||
/// One caption button: as wide as the native Windows ones.
|
||||
pub const BUTTON_WIDTH: f32 = 46.0;
|
||||
/// The three caption buttons together.
|
||||
pub const WIDTH: f32 = 3.0 * BUTTON_WIDTH;
|
||||
/// Room for the macOS traffic lights at the bar's left end.
|
||||
pub const TRAFFIC_LIGHTS: f32 = 76.0;
|
||||
/// Windows' resize border at 100 % (96 DPI): `SM_CXSIZEFRAME` + `SM_CXPADDEDBORDER`, 4 + 4 px.
|
||||
pub const RESIZE_BORDER_AT_96_DPI: f32 = 8.0;
|
||||
|
||||
/// The window's settings: `title` and `app_id` (the desktop entry's reverse-DNS id) are the
|
||||
/// app's; `custom_titlebar` drops the OS decorations so the app's bar is the title bar. On macOS
|
||||
/// the OS title bar is hidden and the content runs under it. The app adds its own icon.
|
||||
pub fn viewport(title: &str, app_id: &str, custom_titlebar: bool) -> ViewportBuilder {
|
||||
ViewportBuilder::default()
|
||||
.with_title(title)
|
||||
.with_inner_size([1600.0, 1000.0])
|
||||
.with_min_inner_size([960.0, 600.0])
|
||||
.with_drag_and_drop(true)
|
||||
.with_decorations(!custom_titlebar)
|
||||
.with_fullsize_content_view(true)
|
||||
.with_titlebar_shown(false)
|
||||
.with_title_shown(false)
|
||||
.with_app_id(app_id)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Debug)]
|
||||
pub enum Caption {
|
||||
Minimize,
|
||||
Maximize,
|
||||
Close,
|
||||
}
|
||||
|
||||
/// Left to right.
|
||||
pub const CAPTIONS: [Caption; 3] = [Caption::Minimize, Caption::Maximize, Caption::Close];
|
||||
|
||||
/// Caption button ids (stable, so tests and agents can find them).
|
||||
pub fn caption_id(c: Caption) -> Id {
|
||||
Id::unique(("titlebar-caption", c as u8))
|
||||
}
|
||||
|
||||
/// What the window is asked to do, by a caption button or by name (an app's control channel).
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum WindowAction {
|
||||
Minimize,
|
||||
Maximize,
|
||||
Restore,
|
||||
/// Maximize or restore.
|
||||
Toggle,
|
||||
Close,
|
||||
}
|
||||
|
||||
impl WindowAction {
|
||||
pub const ALL: [Self; 5] = [Self::Minimize, Self::Maximize, Self::Restore, Self::Toggle, Self::Close];
|
||||
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Minimize => "minimize",
|
||||
Self::Maximize => "maximize",
|
||||
Self::Restore => "restore",
|
||||
Self::Toggle => "toggle",
|
||||
Self::Close => "close",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(name: &str) -> Result<Self, String> {
|
||||
Self::ALL
|
||||
.into_iter()
|
||||
.find(|a| a.name() == name)
|
||||
.ok_or_else(|| format!("unknown window action `{name}` (minimize, maximize, restore, toggle, close)"))
|
||||
}
|
||||
|
||||
/// Sends the action's viewport command. [`Self::Close`] closes the window at once: an app
|
||||
/// with unsaved work asks first and sends it once answered.
|
||||
pub fn send(self, ctx: &egui::Context) {
|
||||
match self {
|
||||
Self::Minimize => ctx.send_viewport_cmd(ViewportCommand::Minimized(true)),
|
||||
Self::Maximize => ctx.send_viewport_cmd(ViewportCommand::Maximized(true)),
|
||||
Self::Restore => ctx.send_viewport_cmd(ViewportCommand::Maximized(false)),
|
||||
Self::Toggle => toggle_maximize(ctx),
|
||||
Self::Close => ctx.send_viewport_cmd(ViewportCommand::Close),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn maximized(ctx: &egui::Context) -> bool {
|
||||
ctx.input(|i| i.viewport().maximized.unwrap_or(false))
|
||||
}
|
||||
|
||||
pub fn toggle_maximize(ctx: &egui::Context) {
|
||||
ctx.send_viewport_cmd(ViewportCommand::Maximized(!maximized(ctx)));
|
||||
}
|
||||
|
||||
/// How the window's title bar behaves: which of the OS's controls share the bar.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
|
||||
pub struct TitleBar {
|
||||
/// macOS: the OS draws its traffic lights over the bar's left end.
|
||||
pub traffic_lights: bool,
|
||||
/// No OS decorations (Windows, Linux): the bar has its own caption buttons at the right end.
|
||||
pub caption_buttons: bool,
|
||||
}
|
||||
|
||||
/// Where a [`TitleBar`] puts things in the bar's rect.
|
||||
#[derive(Clone, Copy, PartialEq, Debug)]
|
||||
pub struct TitleBarLayout {
|
||||
/// Empty space here moves the window; `None` under the OS's own title bar.
|
||||
pub drag: Option<Rect>,
|
||||
/// What is left for the app's contents.
|
||||
pub content: Rect,
|
||||
/// The caption buttons, flush with the window's right edge.
|
||||
pub captions: Option<Rect>,
|
||||
}
|
||||
|
||||
impl TitleBar {
|
||||
pub fn layout(self, bar: Rect) -> TitleBarLayout {
|
||||
let left = bar.left() + if self.traffic_lights { TRAFFIC_LIGHTS } else { 0.0 };
|
||||
let right = bar.right() - if self.caption_buttons { WIDTH } else { 0.0 };
|
||||
TitleBarLayout {
|
||||
drag: (self.traffic_lights || self.caption_buttons).then_some(bar),
|
||||
content: Rect::from_min_max(pos2(left, bar.top()), pos2(right, bar.bottom())),
|
||||
captions: self.caption_buttons.then(|| Rect::from_min_max(pos2(right, bar.top()), bar.right_bottom())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Lays out the bar in `bar`: the drag area first (so the contents' widgets, registered
|
||||
/// later, take their clicks), `contents` in the rect left for them, then the caption buttons.
|
||||
/// The caption button clicked, as the action for the app to run.
|
||||
pub fn show(self, ui: &mut Ui, bar: Rect, contents: impl FnOnce(&mut Ui, Rect)) -> Option<WindowAction> {
|
||||
let layout = self.layout(bar);
|
||||
if let Some(r) = layout.drag {
|
||||
drag_area(ui, r);
|
||||
}
|
||||
contents(ui, layout.content);
|
||||
if self.caption_buttons { caption_buttons(ui, bar) } else { None }
|
||||
}
|
||||
}
|
||||
|
||||
/// The bar's background: a press-and-drag on empty space moves the window, a double-click
|
||||
/// toggles maximize. Call it before laying out the bar so its buttons, registered later, take
|
||||
/// their clicks.
|
||||
pub fn drag_area(ui: &Ui, rect: Rect) {
|
||||
let ctx = ui.ctx();
|
||||
let resp = ui.interact(rect, Id::unique("titlebar-drag"), Sense::click_and_drag());
|
||||
// egui hands a drag that starts on a click-only widget to the drag-sensing background below
|
||||
// it, so remember whether the press itself landed on bare background.
|
||||
if ctx.input(|i| i.pointer.any_pressed()) {
|
||||
let bare = ctx.viewport(|v| v.hits.click.is_some_and(|w| w.id == resp.id));
|
||||
ctx.data_mut(|d| d.insert_temp(resp.id, bare));
|
||||
}
|
||||
if resp.double_clicked() {
|
||||
toggle_maximize(ctx);
|
||||
} else if resp.drag_started_by(PointerButton::Primary) && ctx.data(|d| d.get_temp(resp.id).unwrap_or(false)) {
|
||||
ctx.send_viewport_cmd(ViewportCommand::StartDrag);
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimize, Maximize/Restore and Close at the right end of `bar`, full bar height, flush with
|
||||
/// the window's edge so a maximized window's corner hits Close. The one clicked, as the action
|
||||
/// for the app to run.
|
||||
pub fn caption_buttons(ui: &mut Ui, bar: Rect) -> Option<WindowAction> {
|
||||
let t = Tokens::get();
|
||||
let max = maximized(ui.ctx());
|
||||
let mut clicked = None;
|
||||
for (i, c) in CAPTIONS.into_iter().enumerate() {
|
||||
let r = Rect::from_min_size(pos2(bar.right() - WIDTH + i as f32 * BUTTON_WIDTH, bar.top()), vec2(BUTTON_WIDTH, bar.height()));
|
||||
let resp = ui.interact(r, caption_id(c), Sense::click());
|
||||
let fill = match (resp.is_pointer_button_down_on(), resp.hovered()) {
|
||||
(true, _) => t.app_bar_hover_subtle,
|
||||
(_, true) => t.app_bar_hover_button,
|
||||
_ if resp.has_focus() => t.app_bar_hover_button,
|
||||
_ => Color32::TRANSPARENT,
|
||||
};
|
||||
ui.painter().rect_filled(r, 0.0, fill);
|
||||
paint_glyph(ui, c, max, r.center(), Stroke::new(1.0, t.app_bar_text));
|
||||
let tip = match c {
|
||||
Caption::Minimize => "Minimize",
|
||||
Caption::Maximize if max => "Restore",
|
||||
Caption::Maximize => "Maximize",
|
||||
Caption::Close => "Close",
|
||||
};
|
||||
if resp.backing_on_hover_text(tip).clicked() {
|
||||
clicked = Some(match c {
|
||||
Caption::Minimize => WindowAction::Minimize,
|
||||
Caption::Maximize => WindowAction::Toggle,
|
||||
Caption::Close => WindowAction::Close,
|
||||
});
|
||||
}
|
||||
}
|
||||
clicked
|
||||
}
|
||||
|
||||
/// The resize border in physical pixels at `scale` (1.0 = 96 DPI): Windows' default, scaled and
|
||||
/// rounded, never less than a pixel.
|
||||
pub fn resize_border_px(scale: f32) -> f32 {
|
||||
let s = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 };
|
||||
(RESIZE_BORDER_AT_96_DPI * s).round().max(1.0)
|
||||
}
|
||||
|
||||
/// The side of a corner zone for an edge `edge` wide: twice the edge, so a corner is easy to hit.
|
||||
pub fn corner_size(edge: f32) -> f32 {
|
||||
2.0 * edge
|
||||
}
|
||||
|
||||
/// The edge and corner zones of a window with rect `w`, edges `edge` wide: (zone, direction,
|
||||
/// cursor). Corners come last so they win where they overlap the edges.
|
||||
pub fn zones(w: Rect, edge: f32) -> [(Rect, ResizeDirection, CursorIcon); 8] {
|
||||
use ResizeDirection::*;
|
||||
let (e, c) = (edge, corner_size(edge));
|
||||
[
|
||||
(Rect::from_min_max(w.left_top(), pos2(w.right(), w.top() + e)), North, CursorIcon::ResizeVertical),
|
||||
(Rect::from_min_max(pos2(w.left(), w.bottom() - e), w.right_bottom()), South, CursorIcon::ResizeVertical),
|
||||
(Rect::from_min_max(w.left_top(), pos2(w.left() + e, w.bottom())), West, CursorIcon::ResizeHorizontal),
|
||||
(Rect::from_min_max(pos2(w.right() - e, w.top()), w.right_bottom()), East, CursorIcon::ResizeHorizontal),
|
||||
(Rect::from_min_size(w.left_top(), vec2(c, c)), NorthWest, CursorIcon::ResizeNwSe),
|
||||
(Rect::from_min_size(w.right_top() - vec2(c, 0.0), vec2(c, c)), NorthEast, CursorIcon::ResizeNeSw),
|
||||
(Rect::from_min_size(w.left_bottom() - vec2(0.0, c), vec2(c, c)), SouthWest, CursorIcon::ResizeNeSw),
|
||||
(Rect::from_min_size(w.right_bottom() - vec2(c, c), vec2(c, c)), SouthEast, CursorIcon::ResizeNwSe),
|
||||
]
|
||||
}
|
||||
|
||||
/// The resize direction at `p` in a window with rect `w` and edges `edge` wide, `None` away
|
||||
/// from the edges and outside the window. A point is in the window when `min <= p < max` (pixel
|
||||
/// rects: the right and bottom pixels are at `max - 1`). Corners win over edges.
|
||||
pub fn resize_hit(w: Rect, p: Pos2, edge: f32) -> Option<ResizeDirection> {
|
||||
if !(w.is_positive() && edge > 0.0 && p.x >= w.left() && p.x < w.right() && p.y >= w.top() && p.y < w.bottom()) {
|
||||
return None;
|
||||
}
|
||||
zones(w, edge).into_iter().rev().find(|(r, ..)| p.x >= r.left() && p.x < r.right() && p.y >= r.top() && p.y < r.bottom()).map(|(_, d, _)| d)
|
||||
}
|
||||
|
||||
/// The window's new physical size when it moves from `old_dpi` to `new_dpi` (Windows'
|
||||
/// `WM_GETDPISCALEDSIZE`): the same logical size, rounded as winit rounds the size it then
|
||||
/// applies, so the rect the OS suggests mid-move is exactly the rect the window gets.
|
||||
pub fn dpi_scaled_size(size: (i32, i32), old_dpi: u32, new_dpi: u32) -> Option<(i32, i32)> {
|
||||
if old_dpi == 0 || new_dpi == 0 || size.0 <= 0 || size.1 <= 0 {
|
||||
return None;
|
||||
}
|
||||
let (old, new) = (f64::from(old_dpi) / 96.0, f64::from(new_dpi) / 96.0);
|
||||
let scale = |v: i32| (f64::from(v) / old * new).round().clamp(1.0, f64::from(i32::MAX)) as i32;
|
||||
Some((scale(size.0), scale(size.1)))
|
||||
}
|
||||
|
||||
/// Invisible resize zones along the window's edges, above everything else; a press in one
|
||||
/// starts an OS resize in that direction. None while the window is maximized or fullscreen.
|
||||
pub fn resize_zones(ui: &mut Ui) {
|
||||
let ctx = ui.ctx().clone();
|
||||
if maximized(&ctx) || ctx.input(|i| i.viewport().fullscreen.unwrap_or(false)) {
|
||||
return;
|
||||
}
|
||||
let w = ctx.content_rect();
|
||||
let ppp = ctx.pixels_per_point();
|
||||
let edge = resize_border_px(ctx.input(|i| i.viewport().native_pixels_per_point).unwrap_or(ppp)) / ppp;
|
||||
let layer = LayerId::new(Order::Foreground, Id::unique("titlebar-resize"));
|
||||
let zui = ui.new_child(egui::UiBuilder::new().layer_id(layer).max_rect(w));
|
||||
for (i, (r, dir, cursor)) in zones(w, edge).into_iter().enumerate() {
|
||||
let resp = zui.interact(r, Id::unique(("titlebar-resize", i)), Sense::drag()).on_hover_cursor(cursor);
|
||||
if resp.drag_started_by(PointerButton::Primary) {
|
||||
ctx.send_viewport_cmd(ViewportCommand::BeginResize(dir));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use egui::{Event, PointerButton, Pos2, RawInput, Rect, ViewportCommand, ViewportId, pos2, vec2};
|
||||
|
||||
const BAR: f32 = 34.0;
|
||||
|
||||
/// A headless window `width` pt wide that draws only a title bar, from plain inputs.
|
||||
struct Bar {
|
||||
ctx: egui::Context,
|
||||
bar: TitleBar,
|
||||
width: f32,
|
||||
time: f64,
|
||||
maximized: bool,
|
||||
/// The rect the bar handed its contents, and the action it returned, last frame.
|
||||
content: Option<Rect>,
|
||||
action: Option<WindowAction>,
|
||||
}
|
||||
|
||||
impl Bar {
|
||||
fn new(bar: TitleBar, width: f32) -> Self {
|
||||
Self { ctx: crate::fonts::test_context(), bar, width, time: 0.0, maximized: false, content: None, action: None }
|
||||
}
|
||||
|
||||
fn rect(&self) -> Rect {
|
||||
Rect::from_min_size(Pos2::ZERO, vec2(self.width, BAR))
|
||||
}
|
||||
|
||||
fn frame(&mut self, events: Vec<Event>) -> Vec<ViewportCommand> {
|
||||
self.time += 0.05;
|
||||
let mut input = RawInput {
|
||||
screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(self.width, 700.0))),
|
||||
time: Some(self.time),
|
||||
events,
|
||||
..Default::default()
|
||||
};
|
||||
if let Some(v) = input.viewports.get_mut(&ViewportId::ROOT) {
|
||||
v.maximized = Some(self.maximized);
|
||||
}
|
||||
let (bar, rect) = (self.bar, self.rect());
|
||||
let (mut content, mut action) = (None, None);
|
||||
let mut out = self.ctx.run_ui(input, |ui| {
|
||||
action = bar.show(ui, rect, |_, r| content = Some(r));
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
(self.content, self.action) = (content, action);
|
||||
out.viewport_output.remove(&ViewportId::ROOT).map(|v| v.commands).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Move to `path[0]`, press, move along `path`, release: the commands sent and the
|
||||
/// actions returned.
|
||||
fn gesture(&mut self, path: &[Pos2]) -> (Vec<ViewportCommand>, Vec<WindowAction>) {
|
||||
let button = |p: Pos2, pressed| Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
let mut events = vec![vec![Event::PointerMoved(path[0])], vec![button(path[0], true)]];
|
||||
events.extend(path[1..].iter().map(|p| vec![Event::PointerMoved(*p)]));
|
||||
events.push(vec![button(*path.last().unwrap_or(&path[0]), false)]);
|
||||
let (mut cmds, mut actions) = (vec![], vec![]);
|
||||
for e in events {
|
||||
cmds.extend(self.frame(e));
|
||||
actions.extend(self.action);
|
||||
}
|
||||
(cmds, actions)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn title_bar_from_plain_inputs_insets_for_the_traffic_lights_and_drags_from_empty_space() {
|
||||
let bar = Rect::from_min_size(Pos2::ZERO, vec2(1200.0, BAR));
|
||||
// macOS: the OS draws the traffic lights over the bar's left end; no caption buttons.
|
||||
let mac = TitleBar { traffic_lights: true, caption_buttons: false };
|
||||
let layout = mac.layout(bar);
|
||||
assert_eq!(layout.drag, Some(bar));
|
||||
assert_eq!(layout.content, Rect::from_min_max(pos2(TRAFFIC_LIGHTS, 0.0), pos2(1200.0, BAR)));
|
||||
assert_eq!(layout.captions, None);
|
||||
// Windows and Linux without OS decorations: caption buttons at the right end.
|
||||
let own = TitleBar { traffic_lights: false, caption_buttons: true };
|
||||
let layout = own.layout(bar);
|
||||
assert_eq!(layout.drag, Some(bar));
|
||||
assert_eq!(layout.content, Rect::from_min_max(pos2(0.0, 0.0), pos2(1200.0 - WIDTH, BAR)));
|
||||
assert_eq!(layout.captions, Some(Rect::from_min_max(pos2(1200.0 - WIDTH, 0.0), pos2(1200.0, BAR))));
|
||||
// The OS's own title bar: the bar is plain content and moves nothing.
|
||||
let os = TitleBar { traffic_lights: false, caption_buttons: false };
|
||||
assert_eq!(os.layout(bar), TitleBarLayout { drag: None, content: bar, captions: None });
|
||||
|
||||
let mut w = Bar::new(mac, 1200.0);
|
||||
w.frame(vec![]);
|
||||
assert_eq!(w.content, Some(mac.layout(bar).content), "the contents get the inset rect");
|
||||
let empty = pos2(600.0, 17.0);
|
||||
let (cmds, _) = w.gesture(&[empty, empty + vec2(12.0, 4.0), empty + vec2(30.0, 8.0)]);
|
||||
assert_eq!(cmds.iter().filter(|c| matches!(c, ViewportCommand::StartDrag)).count(), 1, "{cmds:?}");
|
||||
w.time += 1.0;
|
||||
let (mut cmds, _) = w.gesture(&[empty]);
|
||||
cmds.extend(w.gesture(&[empty]).0);
|
||||
assert!(cmds.contains(&ViewportCommand::Maximized(true)), "double-click maximizes: {cmds:?}");
|
||||
|
||||
let mut w = Bar::new(TitleBar { traffic_lights: false, caption_buttons: false }, 1200.0);
|
||||
w.frame(vec![]);
|
||||
let (cmds, _) = w.gesture(&[empty, empty + vec2(30.0, 8.0)]);
|
||||
assert!(cmds.is_empty(), "no drag area under the OS's own title bar: {cmds:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caption_buttons_return_actions_and_leave_close_to_the_app() {
|
||||
let mut w = Bar::new(TitleBar { traffic_lights: false, caption_buttons: true }, 1200.0);
|
||||
w.frame(vec![]);
|
||||
let at = |c: Caption| w.ctx.read_response(caption_id(c)).map(|r| r.rect.center()).unwrap_or(Pos2::ZERO);
|
||||
let (min, max, close) = (at(Caption::Minimize), at(Caption::Maximize), at(Caption::Close));
|
||||
assert_eq!(close, pos2(1200.0 - BUTTON_WIDTH / 2.0, BAR / 2.0));
|
||||
assert_eq!(w.gesture(&[min]).1, vec![WindowAction::Minimize]);
|
||||
assert_eq!(w.gesture(&[max]).1, vec![WindowAction::Toggle]);
|
||||
let (cmds, actions) = w.gesture(&[close]);
|
||||
assert_eq!(actions, vec![WindowAction::Close]);
|
||||
assert!(!cmds.contains(&ViewportCommand::Close), "the app decides whether the window closes: {cmds:?}");
|
||||
// A drag from a caption button doesn't move the window.
|
||||
let (cmds, _) = w.gesture(&[min, min + vec2(-30.0, 8.0)]);
|
||||
assert!(!cmds.iter().any(|c| matches!(c, ViewportCommand::StartDrag)), "{cmds:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_actions_parse_their_names() {
|
||||
for a in WindowAction::ALL {
|
||||
assert_eq!(WindowAction::parse(a.name()), Ok(a));
|
||||
}
|
||||
assert_eq!(WindowAction::parse("shrink"), Err("unknown window action `shrink` (minimize, maximize, restore, toggle, close)".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn viewport_has_the_shared_window_settings() {
|
||||
for custom in [false, true] {
|
||||
let v = viewport("Test", "org.example.Test", custom);
|
||||
assert_eq!(v.title.as_deref(), Some("Test"));
|
||||
assert_eq!(v.app_id.as_deref(), Some("org.example.Test"));
|
||||
assert_eq!(v.decorations, Some(!custom));
|
||||
assert_eq!((v.fullsize_content_view, v.titlebar_shown, v.title_shown), (Some(true), Some(false), Some(false)));
|
||||
assert_eq!((v.inner_size, v.min_inner_size), (Some(vec2(1600.0, 1000.0)), Some(vec2(960.0, 600.0))));
|
||||
assert_eq!(v.drag_and_drop, Some(true));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resize_border_is_windows_default_for_the_scale() {
|
||||
for (dpi, px) in [(96.0, 8.0), (120.0, 10.0), (144.0, 12.0), (168.0, 14.0), (192.0, 16.0), (288.0, 24.0)] {
|
||||
assert_eq!(resize_border_px(dpi / 96.0), px, "at {dpi} DPI");
|
||||
}
|
||||
// Never 0 px, whatever the scale says.
|
||||
for bad in [0.0, -1.0, f32::NAN, f32::INFINITY] {
|
||||
assert_eq!(resize_border_px(bad), 8.0, "{bad}");
|
||||
}
|
||||
assert_eq!(resize_border_px(0.01), 1.0);
|
||||
assert_eq!(corner_size(resize_border_px(1.5)), 24.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hit_test_edges_corners_and_inside() {
|
||||
use ResizeDirection::*;
|
||||
// A window at (100, 50), 1000 x 700 px, at 150 %: 12 px edges, 24 px corners.
|
||||
let w = Rect::from_min_size(pos2(100.0, 50.0), vec2(1000.0, 700.0));
|
||||
let e = resize_border_px(1.5);
|
||||
let (l, t, r, b) = (100.0, 50.0, 1099.0, 749.0); // the outermost pixels
|
||||
for (p, want) in [
|
||||
((l, 400.0), Some(West)),
|
||||
((l + 11.0, 400.0), Some(West)),
|
||||
((l + 12.0, 400.0), None),
|
||||
((r, 400.0), Some(East)),
|
||||
((r - 11.0, 400.0), Some(East)),
|
||||
((r - 12.0, 400.0), None),
|
||||
((600.0, t), Some(North)),
|
||||
((600.0, t + 12.0), None),
|
||||
((600.0, b), Some(South)),
|
||||
((600.0, b - 11.0), Some(South)),
|
||||
((l, t), Some(NorthWest)),
|
||||
((r, t), Some(NorthEast)),
|
||||
((l, b), Some(SouthWest)),
|
||||
((r, b), Some(SouthEast)),
|
||||
// Corners win over the edges, along both edges for the corner's length.
|
||||
((l + 23.0, t), Some(NorthWest)),
|
||||
((l, t + 23.0), Some(NorthWest)),
|
||||
((l + 24.0, t), Some(North)),
|
||||
((l, t + 24.0), Some(West)),
|
||||
((r - 23.0, b), Some(SouthEast)),
|
||||
((r, b - 23.0), Some(SouthEast)),
|
||||
((r - 23.0, t), Some(NorthEast)),
|
||||
((l, b - 23.0), Some(SouthWest)),
|
||||
((600.0, 400.0), None),
|
||||
// Outside the window: not ours.
|
||||
((l - 1.0, 400.0), None),
|
||||
((r + 1.0, 400.0), None),
|
||||
((600.0, t - 1.0), None),
|
||||
((600.0, b + 1.0), None),
|
||||
] {
|
||||
assert_eq!(resize_hit(w, pos2(p.0, p.1), e), want, "at {p:?}");
|
||||
}
|
||||
assert_eq!(resize_hit(w, pos2(l, 400.0), 0.0), None);
|
||||
assert_eq!(resize_hit(Rect::NOTHING, pos2(l, 400.0), e), None);
|
||||
// Every DPI has a zone on every edge.
|
||||
for dpi in [96.0, 120.0, 144.0, 192.0] {
|
||||
let e = resize_border_px(dpi / 96.0);
|
||||
for (p, d) in [((l, 400.0), West), ((r, 400.0), East), ((600.0, t), North), ((600.0, b), South)] {
|
||||
assert_eq!(resize_hit(w, pos2(p.0, p.1), e), Some(d), "{d:?} at {dpi} DPI");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dpi_change_keeps_the_logical_size() {
|
||||
assert_eq!(dpi_scaled_size((1600, 1000), 96, 144), Some((2400, 1500)));
|
||||
assert_eq!(dpi_scaled_size((2400, 1500), 144, 96), Some((1600, 1000)));
|
||||
assert_eq!(dpi_scaled_size((1001, 777), 120, 192), Some((1602, 1243)));
|
||||
assert_eq!(dpi_scaled_size((1001, 777), 96, 96), Some((1001, 777)));
|
||||
assert_eq!(dpi_scaled_size((1001, 777), 0, 96), None);
|
||||
assert_eq!(dpi_scaled_size((0, 777), 96, 144), None);
|
||||
}
|
||||
}
|
||||
@@ -1,427 +0,0 @@
|
||||
//! The macOS menu bar, built from the kit's menu entries ([`Item`]) and answering with the id
|
||||
//! (and parameters) of the entry chosen, for the app to run through its own command table.
|
||||
//!
|
||||
//! The bar follows the system's layout: the app menu (About, the app's entries, Services, Hide,
|
||||
//! Hide Others, Show All, Quit), then the app's menus. The menu whose id is [`WINDOW`] also gets
|
||||
//! the system's window entries (Minimize, Zoom, Enter Full Screen, Bring All to Front). An entry's
|
||||
//! `shortcut` is a menu accelerator (`Cmd+Shift+S`, `Cmd+,`). A menu's own id (`menu.file`, ...)
|
||||
//! names it for [`MenuTable::replace`] and is never dispatched. The layout ([`MenuTable`]) is the
|
||||
//! same everywhere; only macOS turns it into native menus ([`NativeMenu`]); Windows and Linux keep
|
||||
//! their default (no menu bar).
|
||||
|
||||
use crate::ui_kit::Item;
|
||||
use serde_json::Value;
|
||||
|
||||
/// The id of the app's Window menu: the system's window entries are added to it.
|
||||
pub const WINDOW: &str = "menu.window";
|
||||
/// The id of the app menu (the one titled with the app's name).
|
||||
pub const APP: &str = "menu.app";
|
||||
|
||||
/// What the app puts in its menu bar.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct MenuBar {
|
||||
/// The app's name: the app menu's title, About and Hide name it.
|
||||
pub title: String,
|
||||
/// Shown in About.
|
||||
pub version: String,
|
||||
/// The app menu's own entries, between About and Services (Settings, ...).
|
||||
pub app: Vec<Item>,
|
||||
/// The app menu's last entry.
|
||||
pub quit: Item,
|
||||
/// The menus after the app menu, left to right: each `Item`'s children are its entries.
|
||||
pub menus: Vec<Item>,
|
||||
}
|
||||
|
||||
/// An entry the system provides and runs itself.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum System {
|
||||
About,
|
||||
Services,
|
||||
Hide,
|
||||
HideOthers,
|
||||
ShowAll,
|
||||
Minimize,
|
||||
Zoom,
|
||||
FullScreen,
|
||||
BringAllToFront,
|
||||
}
|
||||
|
||||
/// One menu row. An entry carries the key its native item is made with; the [`MenuTable`] maps
|
||||
/// it back to the entry's id and parameters.
|
||||
#[derive(Debug)]
|
||||
pub enum Row<'a> {
|
||||
Separator,
|
||||
/// A section heading: a row that can't be chosen.
|
||||
Heading(&'a str),
|
||||
Entry {
|
||||
key: String,
|
||||
item: &'a Item,
|
||||
},
|
||||
Submenu {
|
||||
item: &'a Item,
|
||||
rows: Vec<Row<'a>>,
|
||||
},
|
||||
System(System),
|
||||
}
|
||||
|
||||
/// One menu of the bar, left to right.
|
||||
#[derive(Debug)]
|
||||
pub struct Menu<'a> {
|
||||
pub id: &'a str,
|
||||
pub label: &'a str,
|
||||
/// Off: the menu shows greyed and does not open.
|
||||
pub enabled: bool,
|
||||
pub rows: Vec<Row<'a>>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Entry {
|
||||
key: String,
|
||||
id: String,
|
||||
params: Option<Value>,
|
||||
/// The ids of the menus it sits in, outermost first.
|
||||
within: Vec<String>,
|
||||
}
|
||||
|
||||
/// The bar's entries by the key of their native item, and its submenus.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct MenuTable {
|
||||
entries: Vec<Entry>,
|
||||
/// Every submenu's id with the ids of the menus it sits in.
|
||||
submenus: Vec<(String, Vec<String>)>,
|
||||
next: u64,
|
||||
}
|
||||
|
||||
impl MenuTable {
|
||||
/// Lays out the bar, registering every entry that can be chosen.
|
||||
pub fn layout<'a>(&mut self, bar: &'a MenuBar) -> Vec<Menu<'a>> {
|
||||
let app = [APP.to_string()];
|
||||
let mut rows = vec![Row::System(System::About), Row::Separator];
|
||||
rows.extend(self.rows(&bar.app, &app));
|
||||
rows.extend([Row::Separator, Row::System(System::Services), Row::Separator]);
|
||||
rows.extend([Row::System(System::Hide), Row::System(System::HideOthers), Row::System(System::ShowAll), Row::Separator]);
|
||||
rows.extend(self.rows(std::slice::from_ref(&bar.quit), &app));
|
||||
let mut menus = vec![Menu { id: APP, label: &bar.title, enabled: true, rows }];
|
||||
for menu in &bar.menus {
|
||||
let within = [menu.id.clone()];
|
||||
let mut rows = self.rows(&menu.children, &within);
|
||||
if menu.id == WINDOW {
|
||||
rows.extend([Row::Separator, Row::System(System::Minimize), Row::System(System::Zoom), Row::System(System::FullScreen)]);
|
||||
rows.extend([Row::Separator, Row::System(System::BringAllToFront)]);
|
||||
}
|
||||
menus.push(Menu { id: &menu.id, label: &menu.label, enabled: menu.enabled, rows });
|
||||
}
|
||||
menus
|
||||
}
|
||||
|
||||
/// Replaces what the submenu with id `id` holds (a recent-files list): forgets its old
|
||||
/// entries and submenus and lays out `children` in their place. For submenus inside the
|
||||
/// bar's menus only: a menu of the bar itself (the app menu, File, Window, ...) keeps the
|
||||
/// system's entries the layout gave it, so it is built once and `replace` refuses it.
|
||||
pub fn replace<'a>(&mut self, id: &str, children: &'a [Item]) -> Result<Vec<Row<'a>>, String> {
|
||||
let Some((_, outer)) = self.submenus.iter().find(|(sid, _)| sid == id) else {
|
||||
return Err(format!("no submenu `{id}` in the menu bar"));
|
||||
};
|
||||
let mut within = outer.clone();
|
||||
within.push(id.to_string());
|
||||
let inside = |w: &[String]| w.iter().any(|m| m == id);
|
||||
self.entries.retain(|e| !inside(&e.within));
|
||||
self.submenus.retain(|(_, w)| !inside(w));
|
||||
Ok(self.rows(children, &within))
|
||||
}
|
||||
|
||||
fn rows<'a>(&mut self, items: &'a [Item], within: &[String]) -> Vec<Row<'a>> {
|
||||
items
|
||||
.iter()
|
||||
.map(|item| {
|
||||
if item.is_sep() {
|
||||
Row::Separator
|
||||
} else if item.is_heading() {
|
||||
Row::Heading(&item.label)
|
||||
} else if !item.children.is_empty() {
|
||||
self.submenus.push((item.id.clone(), within.to_vec()));
|
||||
let mut inner = within.to_vec();
|
||||
inner.push(item.id.clone());
|
||||
Row::Submenu { item, rows: self.rows(&item.children, &inner) }
|
||||
} else {
|
||||
self.next += 1;
|
||||
let key = format!("menu-{}", self.next);
|
||||
self.entries.push(Entry { key: key.clone(), id: item.id.clone(), params: item.params.clone(), within: within.to_vec() });
|
||||
Row::Entry { key, item }
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The id and parameters of the entry whose native item has `key`.
|
||||
pub fn find(&self, key: &str) -> Option<(&str, Option<&Value>)> {
|
||||
self.entries.iter().find(|e| e.key == key).map(|e| (e.id.as_str(), e.params.as_ref()))
|
||||
}
|
||||
|
||||
/// Every entry: (key, id, parameters).
|
||||
pub fn entries(&self) -> impl Iterator<Item = (&str, &str, Option<&Value>)> {
|
||||
self.entries.iter().map(|e| (e.key.as_str(), e.id.as_str(), e.params.as_ref()))
|
||||
}
|
||||
|
||||
/// The menus the submenu with id `id` sits in, outermost first; `None` for one the bar
|
||||
/// doesn't have.
|
||||
pub fn within(&self, id: &str) -> Option<&[String]> {
|
||||
self.submenus.iter().find(|(sid, _)| sid == id).map(|(_, w)| w.as_slice())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub use native::NativeMenu;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod native {
|
||||
use super::{MenuBar, MenuTable, Row, System};
|
||||
use crate::ui_kit::Item;
|
||||
use muda::{AboutMetadata, IsMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu, accelerator::Accelerator};
|
||||
use serde_json::Value;
|
||||
use std::sync::mpsc::{Receiver, sync_channel};
|
||||
|
||||
/// The app's menu bar while it lives; dropping it takes the bar down.
|
||||
pub struct NativeMenu {
|
||||
menu: Menu,
|
||||
rows: Rows,
|
||||
events: Receiver<MenuEvent>,
|
||||
}
|
||||
|
||||
/// The native rows of a laid-out bar.
|
||||
struct Rows {
|
||||
table: MenuTable,
|
||||
title: String,
|
||||
version: String,
|
||||
items: Vec<(String, MenuItem)>,
|
||||
/// Every submenu by its id.
|
||||
submenus: Vec<(String, Submenu)>,
|
||||
}
|
||||
|
||||
fn err(e: muda::Error) -> String {
|
||||
e.to_string()
|
||||
}
|
||||
|
||||
impl NativeMenu {
|
||||
/// Builds `bar` and installs it as the app's menu bar. A chosen entry repaints `ctx`; read
|
||||
/// it with [`Self::events`]. On the main thread only, as every AppKit menu.
|
||||
pub fn new(bar: &MenuBar, ctx: &egui::Context) -> Result<Self, String> {
|
||||
// Rows first: the bar is only installed, and taken down on drop, once it is whole.
|
||||
let mut table = MenuTable::default();
|
||||
let menus = table.layout(bar);
|
||||
let mut rows = Rows { table, title: bar.title.clone(), version: bar.version.clone(), items: Vec::new(), submenus: Vec::new() };
|
||||
let menu = Menu::new();
|
||||
for m in menus {
|
||||
let sub = Submenu::new(m.label, m.enabled);
|
||||
rows.append(&sub, m.rows)?;
|
||||
menu.append(&sub).map_err(err)?;
|
||||
}
|
||||
let (sender, events) = sync_channel(64);
|
||||
let ctx = ctx.clone();
|
||||
MenuEvent::set_event_handler(Some(move |event| {
|
||||
let _ = sender.try_send(event);
|
||||
ctx.request_repaint();
|
||||
}));
|
||||
menu.init_for_nsapp();
|
||||
Ok(Self { menu, rows, events })
|
||||
}
|
||||
|
||||
/// The entries chosen since the last call: (id, parameters).
|
||||
pub fn events(&mut self) -> Vec<(String, Option<Value>)> {
|
||||
let events: Vec<_> = self.events.try_iter().take(64).collect();
|
||||
events.iter().filter_map(|e| self.rows.table.find(e.id.as_ref())).map(|(id, params)| (id.to_string(), params.cloned())).collect()
|
||||
}
|
||||
|
||||
/// Turns every entry on or off: `enabled(id, parameters)`.
|
||||
pub fn update(&self, enabled: impl Fn(&str, Option<&Value>) -> bool) {
|
||||
for (key, id, params) in self.rows.table.entries() {
|
||||
if let Some((_, item)) = self.rows.items.iter().find(|(k, _)| k == key) {
|
||||
let on = enabled(id, params);
|
||||
if item.is_enabled() != on {
|
||||
item.set_enabled(on);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Replaces the entries of the submenu with id `id` (a recent-files list).
|
||||
pub fn replace(&mut self, id: &str, children: &[Item]) -> Result<(), String> {
|
||||
let sub = self
|
||||
.rows
|
||||
.submenus
|
||||
.iter()
|
||||
.find(|(sid, _)| sid == id)
|
||||
.map(|(_, s)| s.clone())
|
||||
.ok_or_else(|| format!("no submenu `{id}` in the menu bar"))?;
|
||||
let mut table = std::mem::take(&mut self.rows.table);
|
||||
let laid = table.replace(id, children);
|
||||
// What the table forgot goes from the native rows too.
|
||||
self.rows.items.retain(|(k, _)| table.find(k).is_some());
|
||||
self.rows.submenus.retain(|(sid, _)| sid == id || table.within(sid).is_some());
|
||||
self.rows.table = table;
|
||||
let laid = laid?;
|
||||
while sub.remove_at(0).is_some() {}
|
||||
self.rows.append(&sub, laid)
|
||||
}
|
||||
}
|
||||
|
||||
impl Rows {
|
||||
fn system(&self, s: System) -> PredefinedMenuItem {
|
||||
let title = self.title.as_str();
|
||||
match s {
|
||||
System::About => PredefinedMenuItem::about(
|
||||
Some(&format!("About {title}")),
|
||||
Some(AboutMetadata { name: Some(title.into()), version: Some(self.version.clone()), ..Default::default() }),
|
||||
),
|
||||
System::Services => PredefinedMenuItem::services(None),
|
||||
System::Hide => PredefinedMenuItem::hide(Some(&format!("Hide {title}"))),
|
||||
System::HideOthers => PredefinedMenuItem::hide_others(None),
|
||||
System::ShowAll => PredefinedMenuItem::show_all(None),
|
||||
System::Minimize => PredefinedMenuItem::minimize(None),
|
||||
System::Zoom => PredefinedMenuItem::maximize(None),
|
||||
System::FullScreen => PredefinedMenuItem::fullscreen(None),
|
||||
System::BringAllToFront => PredefinedMenuItem::bring_all_to_front(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn append(&mut self, parent: &Submenu, rows: Vec<Row<'_>>) -> Result<(), String> {
|
||||
for row in rows {
|
||||
let native: Box<dyn IsMenuItem> = match row {
|
||||
Row::Separator => Box::new(PredefinedMenuItem::separator()),
|
||||
Row::Heading(label) => Box::new(MenuItem::new(label, false, None)),
|
||||
Row::System(s) => Box::new(self.system(s)),
|
||||
Row::Entry { key, item } => {
|
||||
let shortcut = match item.shortcut.trim() {
|
||||
"" => None,
|
||||
s => Some(s.parse::<Accelerator>().map_err(|e| format!("menu entry `{}`: shortcut `{s}`: {e}", item.id))?),
|
||||
};
|
||||
let native = MenuItem::with_id(key.as_str(), &item.label, item.enabled, shortcut);
|
||||
self.items.push((key, native.clone()));
|
||||
Box::new(native)
|
||||
}
|
||||
Row::Submenu { item, rows } => {
|
||||
let sub = Submenu::new(&item.label, item.enabled);
|
||||
self.append(&sub, rows)?;
|
||||
self.submenus.push((item.id.clone(), sub.clone()));
|
||||
Box::new(sub)
|
||||
}
|
||||
};
|
||||
parent.append(native.as_ref()).map_err(err)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for NativeMenu {
|
||||
fn drop(&mut self) {
|
||||
MenuEvent::set_event_handler(None::<fn(MenuEvent)>);
|
||||
self.menu.remove_for_nsapp();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn entry(id: &str, label: &str) -> Item {
|
||||
Item::action(id, label, "")
|
||||
}
|
||||
|
||||
fn submenu(id: &str, label: &str, children: Vec<Item>) -> Item {
|
||||
let mut menu = entry(id, label);
|
||||
menu.children = children;
|
||||
menu
|
||||
}
|
||||
|
||||
/// The rows as words: the entry's id, `-`, `#heading`, `[submenu: ...]` or the system entry.
|
||||
fn shape(rows: &[Row<'_>]) -> Vec<String> {
|
||||
rows.iter()
|
||||
.map(|r| match r {
|
||||
Row::Separator => "-".to_string(),
|
||||
Row::Heading(h) => format!("#{h}"),
|
||||
Row::Entry { item, .. } => item.id.clone(),
|
||||
Row::Submenu { item, rows } => format!("[{}: {}]", item.id, shape(rows).join(" ")),
|
||||
Row::System(s) => format!("{s:?}"),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn keys<'a>(rows: &'a [Row<'a>], out: &mut Vec<&'a str>) {
|
||||
for r in rows {
|
||||
match r {
|
||||
Row::Entry { key, .. } => out.push(key),
|
||||
Row::Submenu { rows, .. } => keys(rows, out),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn bar() -> MenuBar {
|
||||
let recent = vec![
|
||||
entry("ui.file.open_recent", "/a.solidcraft").with(json!({"path": "/a.solidcraft"})),
|
||||
Item::sep(),
|
||||
entry("ui.file.clear_recent", "Clear"),
|
||||
];
|
||||
MenuBar {
|
||||
title: "Craft".into(),
|
||||
version: "1.0".into(),
|
||||
app: vec![entry("ui.prefs", "Settings…").with(json!({"open": "General"})).key("Cmd+,")],
|
||||
quit: entry("app.quit", "Quit Craft").key("Cmd+Q"),
|
||||
menus: vec![
|
||||
submenu(
|
||||
"menu.file",
|
||||
"File",
|
||||
vec![entry("file.new", "New").key("Cmd+N"), submenu("menu.file.recent", "Open Recent", recent), Item::sep()],
|
||||
),
|
||||
submenu(WINDOW, "Window", vec![entry("ui.agent_chat", "Agent Chat")]).on_if(false),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_bar_follows_the_system_layout() {
|
||||
let bar = bar();
|
||||
let mut table = MenuTable::default();
|
||||
let menus = table.layout(&bar);
|
||||
let titles: Vec<_> = menus.iter().map(|m| (m.id, m.label, m.enabled)).collect();
|
||||
assert_eq!(titles, [(APP, "Craft", true), ("menu.file", "File", true), (WINDOW, "Window", false)], "a menu that is off stays off");
|
||||
assert_eq!(shape(&menus[0].rows), ["About", "-", "ui.prefs", "-", "Services", "-", "Hide", "HideOthers", "ShowAll", "-", "app.quit"]);
|
||||
assert_eq!(shape(&menus[1].rows), ["file.new", "[menu.file.recent: ui.file.open_recent - ui.file.clear_recent]", "-"]);
|
||||
assert_eq!(shape(&menus[2].rows), ["ui.agent_chat", "-", "Minimize", "Zoom", "FullScreen", "-", "BringAllToFront"]);
|
||||
// Menu ids are never entries.
|
||||
assert!(table.entries().all(|(_, id, _)| !id.starts_with("menu.")));
|
||||
assert_eq!(table.entries().count(), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chosen_entry_maps_back_to_its_id_and_params_and_replace_forgets_the_old_ones() {
|
||||
let bar = bar();
|
||||
let mut table = MenuTable::default();
|
||||
let menus = table.layout(&bar);
|
||||
let mut file = vec![];
|
||||
keys(&menus[1].rows, &mut file);
|
||||
let file: Vec<String> = file.into_iter().map(str::to_string).collect();
|
||||
assert_eq!(table.find(&file[0]), Some(("file.new", None)));
|
||||
assert_eq!(table.find(&file[1]), Some(("ui.file.open_recent", Some(&json!({"path": "/a.solidcraft"})))));
|
||||
assert_eq!(table.find(&file[2]), Some(("ui.file.clear_recent", None)));
|
||||
assert_eq!(table.find("menu-999"), None);
|
||||
assert_eq!(table.within("menu.file.recent"), Some(&["menu.file".to_string()][..]));
|
||||
|
||||
let fresh = vec![entry("ui.file.open_recent", "/b.solidcraft").with(json!({"path": "/b.solidcraft"}))];
|
||||
let rows = table.replace("menu.file.recent", &fresh).unwrap_or_default();
|
||||
let mut new = vec![];
|
||||
keys(&rows, &mut new);
|
||||
assert_eq!(new.len(), 1);
|
||||
assert_eq!(table.find(new[0]), Some(("ui.file.open_recent", Some(&json!({"path": "/b.solidcraft"})))));
|
||||
assert_eq!((table.find(&file[1]), table.find(&file[2])), (None, None), "the old recent entries are gone");
|
||||
assert_eq!(table.find(&file[0]), Some(("file.new", None)), "the rest of File stays");
|
||||
assert_eq!(table.entries().count(), 5);
|
||||
assert!(table.replace("menu.nothing", &fresh).is_err());
|
||||
for menu in [APP, "menu.file", WINDOW] {
|
||||
assert!(table.replace(menu, &fresh).is_err(), "{menu} is a menu of the bar, built once");
|
||||
}
|
||||
assert_eq!(table.entries().count(), 5, "a refused replace forgets nothing");
|
||||
}
|
||||
}
|
||||
@@ -836,7 +836,7 @@ no box selection.
|
||||
| `ui.file` | `path`; `insert?`: bool | starts an interactive background Open or Insert; poll `ui.inspect.file_busy` for completion; the window stays responsive |
|
||||
| `ui.home` | `open?`: bool; `sample?`: index | the start page: show or hide it, open a built-in sample (once built); returns `open` and the recent designs |
|
||||
| `ui.shortcut` | `command`, `key?`, `slot?`, `replace?` | a command's keys (`key`: the first, shown in menus; `keys`: all), or rebinds it: `key` becomes its only key, or with `slot` replaces its key there (one past the last adds a key, `""` there removes that key); `""` without `slot` clears them; key names are case-insensitive and modifiers are normalized (`shift+ctrl+t` becomes `Ctrl+Shift+T`); unknown and reserved keys are refused; a key another command uses is refused unless `replace` |
|
||||
| `ui.help` | `item?`: `about`, `updates` (checks for updates and shows the result), `shortcuts` (read only), `report` (opens a report draft for review), `crashes` (the Crash Reports window), `docs` (the documentation in the browser), `messages` (the Messages window), `install_cli` (macOS: links `solidcraft-cli` into `/usr/local/bin`); another item is an error | the version, commit, build date and diagnostic text |
|
||||
| `ui.help` | `item?`: `about`, `updates` (checks for updates and shows the result), `shortcuts` (read only), `report` (opens a report draft for review), `crashes` (the Crash Reports window) | the version, commit, build date and diagnostic text |
|
||||
| `ui.prefs` | any of the [`ui.prefs` settings](#uiprefs-settings) below; also `open`, `button` and `stand_up` (listed there) | sets preferences (kept between runs), returns them all plus `dialog` (the open page or null), `apply_enabled` (whether the dialog's Apply has a change to keep), `dark`, `viewport_dark` (whether the 3D view is dark), `stand_up_offer` (the offered bodies, or null), `stand_up_note` (whether that offer is the note), `theme_followed`, `theme_follow_error`, `theme_option_defs`, `theme_option_values` and `theme_option_notices` |
|
||||
| `ui.capture` | `action?`: `capture`, `revert`, `cancel` | answers the Capture Position question (asked when a command starts or the design is saved while components were moved without capturing); without `action`, what waits |
|
||||
| `ui.window` | `action`: minimize, maximize, restore, toggle, close | what the title bar's caption buttons do (on Windows and Linux the application bar is the title bar); close asks about unsaved designs first |
|
||||
@@ -845,7 +845,7 @@ no box selection.
|
||||
| `engine.execute` → `browser.expand_all` | `path?`: occurrence ids; `component?`: id; omit both for the whole tree | Open every Browser row under that scope, including folders that start closed (Origin, Named Views, Features). Display only: not saved in the design, not an undo step. |
|
||||
| `engine.execute` → `browser.collapse_all` | same | Close those rows. The design row stays open when the whole tree is collapsed. |
|
||||
| `engine.execute` → `view.history_layout` | `width?`, `dock?`: `bottom`, `left`, `under_browser`, `collapsed?` | Resize/dock the history without opening a dialog or changing the design. Contents respond to measured width; browser-stacked history shares its width and splits the height at the remembered share (`browser_share`, half until the seam between the two cards is dragged). One folded header gives the expanded card the rest; two folded headers stay at the top. |
|
||||
| `ui.agent_chat` | `features?`, `toolbar_button?`, `connect_at_start?` and `open_at_start?` (connect the agent started last, and open the panel, when SolidCraft starts; both off by default, never with `--control`), `instructions?` (the custom instructions every new chat's system note carries, Preferences → AI; at most 4000 characters are kept), `permissions?` (`ask`, `edits`, `all`), `add_agent?` ({`name`, `kind`: `acp`, `api` or `anthropic` (`api` is OpenAI-compatible, `anthropic` Anthropic-compatible), `command` or `base_url` + `model` + `request_timeout_s?` (seconds one model request may take, 300 by default, rounded and clamped to 30 to 3600), `allow_insecure_http?` (OpenAI-compatible endpoints only; false by default; explicitly permits sending the key over unencrypted HTTP to this endpoint, without opening a confirmation dialog)}; no keys), `add_dialog?` (`installed`, `custom`, `api`, `anthropic`, or `false` to close), `edit_dialog?` (the id of an agent you added: the same dialog filled with what it holds, as its **Edit** button opens it; the key is never shown), `default_agent?`, `agent?` (the conversation's agent, by id), `mode?` (the agent's own mode), `provider?` (which provider's group the model picker opens first, when the models come from several), `model?` (one of its models, or see below), `open?`, `toggle?` (true: show or hide the panel as its button does, unfolding a folded one), `side?` (`left`, `right`), `minimized?`, `popped?` (in its own window), `width?`, `picker?`, `refresh?`, `connect?`, `stop?`, `attach?`: a file's path (a picture, a text file or a CAD file, for the next message; CAD files are imported at once), `remove_file?` (index of a file on the next message), `send?`: text (files alone make a message too), `attach_view?`, `attach_selection?`, `pick?` (true: open the context picker), `permission?`: an option id or `"cancel"` (the newest question of the agent's own), `approve?`: true, false or `"always"` (the oldest request held for Allow/Deny), `cancel?` (Stop: the queue stays), `send_now?` (stop the running step, then send the queued messages), `remove_pending?` (index), `new_chat?` (clear the chat, new session), `undo?` (the last message's changes) | the chat's state: `features`, `agents` (found and added), `currentAgent`, `state` (off, starting, ready, busy, auth, exited, failed: the agent never got ready, and Connect reads Retry), `connectAtStart`, `openAtStart`, `instructions`, `lastAgent` (the agent started last), `popped`, `window` (its place and size), `agent`, `modes`, `mode`, `models`, `model`, `modelWarning` (the model capability warning text, or `null` when unknown or tool support is reported; checked asynchronously and never blocks sending), `turnWarning` (the latest completed turn's no-tool notice, otherwise `null`; cleared on the next turn or new chat), `providers` (`name` and `count` of each, empty with one provider), `provider` (the one listed), `authMethods`, `toolsPort`, `pid`, `waiting`, `turn`, `pending` (queued messages, with their `files`), `attachments` (the files on the next message, by name), `loading` (pictures still being read for it, by name), `stopped`, `detection` and the conversation (`items`). With `features` off only the settings apply. Desktop only; see [Agent chat](agent-chat.md) |
|
||||
| `ui.agent_chat` | `features?`, `toolbar_button?`, `connect_at_start?` and `open_at_start?` (connect the agent started last, and open the panel, when SolidCraft starts; both off by default, never with `--control`), `instructions?` (the custom instructions every new chat's system note carries, Preferences → AI; at most 4000 characters are kept), `permissions?` (`ask`, `edits`, `all`), `add_agent?` ({`name`, `kind`: `acp`, `api` or `anthropic` (`api` is OpenAI-compatible, `anthropic` Anthropic-compatible), `command` or `base_url` + `model` + `request_timeout_s?` (seconds one model request may take, 300 by default, rounded and clamped to 30 to 3600), `allow_insecure_http?` (OpenAI-compatible endpoints only; false by default; explicitly permits sending the key over unencrypted HTTP to this endpoint, without opening a confirmation dialog)}; no keys), `add_dialog?` (`installed`, `custom`, `api`, `anthropic`, or `false` to close), `edit_dialog?` (the id of an agent you added: the same dialog filled with what it holds, as its **Edit** button opens it; the key is never shown), `default_agent?`, `agent?` (the conversation's agent, by id), `mode?` (the agent's own mode), `provider?` (which provider's group the model picker opens first, when the models come from several), `model?` (one of its models, or see below), `open?`, `side?` (`left`, `right`), `minimized?`, `popped?` (in its own window), `width?`, `picker?`, `refresh?`, `connect?`, `stop?`, `attach?`: a file's path (a picture, a text file or a CAD file, for the next message; CAD files are imported at once), `remove_file?` (index of a file on the next message), `send?`: text (files alone make a message too), `attach_view?`, `attach_selection?`, `pick?` (true: open the context picker), `permission?`: an option id or `"cancel"` (the newest question of the agent's own), `approve?`: true, false or `"always"` (the oldest request held for Allow/Deny), `cancel?` (Stop: the queue stays), `send_now?` (stop the running step, then send the queued messages), `remove_pending?` (index), `new_chat?` (clear the chat, new session), `undo?` (the last message's changes) | the chat's state: `features`, `agents` (found and added), `currentAgent`, `state` (off, starting, ready, busy, auth, exited, failed: the agent never got ready, and Connect reads Retry), `connectAtStart`, `openAtStart`, `instructions`, `lastAgent` (the agent started last), `popped`, `window` (its place and size), `agent`, `modes`, `mode`, `models`, `model`, `modelWarning` (the model capability warning text, or `null` when unknown or tool support is reported; checked asynchronously and never blocks sending), `turnWarning` (the latest completed turn's no-tool notice, otherwise `null`; cleared on the next turn or new chat), `providers` (`name` and `count` of each, empty with one provider), `provider` (the one listed), `authMethods`, `toolsPort`, `pid`, `waiting`, `turn`, `pending` (queued messages, with their `files`), `attachments` (the files on the next message, by name), `loading` (pictures still being read for it, by name), `stopped`, `detection` and the conversation (`items`). With `features` off only the settings apply. Desktop only; see [Agent chat](agent-chat.md) |
|
||||
| `ui.resize` | `width`, `height` | |
|
||||
| `app.quit` | `force?` (quit without asking about unsaved designs) | `quitting` (the window closes), `asking` (a "save changes?" prompt is open) |
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ L0 `geom` -> L1 `sketch`, `kernel`, `render` -> L2 `doc` -> L3 `io` -> L4 `engin
|
||||
|
||||
- Nothing below L5 depends on egui, eframe, winit, wgpu or rfd. The UI crate is swappable.
|
||||
- Only `crates/kernel` may use `truck`.
|
||||
- Solidcore only through `solidcore-ops` (STEP too; `solidcore_ops::Order`), `-tess`, `-record`; exceptions: `xtask/solidcore-seam.txt`, shrink only.
|
||||
- The UI is thin: panels read engine state and act through `app.run(id, params)` or `app.start(id)`.
|
||||
- A new module's `mod` line goes into the existing sorted group, not a new group at the end (`cargo fmt` keeps each group sorted).
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ Distances are unsigned. Draw the geometry on the correct side first, and the dim
|
||||
| `sketch.slot.overall` / `sketch.slot.center_to_center` | `p0`, `p1` (overall ends / arc centres), `width` | Ids and dof are in the "Ids" section above. |
|
||||
| `solid.fillet` / `solid.chamfer` | `edges:[[x,y,z]]`, `radius` / `distance` | The pick snaps to the **nearest** edge. A pick farther than 5% of the body's size from every edge is refused, so check that the face count changed. A pick where two bodies have an edge, shown or hidden (parts touching, the halves of a split), is refused naming both: give `body`. One pick takes the whole tangent chain: a rounded rim needs 1 point, not 8. |
|
||||
| `solid.shell` | `faces:[[x,y,z]]`, `thickness` | A point on each face to remove, or its name from `body_topology`. Fillet first, except an edge of a removed face or a fillet between curved faces (see Order in `SKILL.md`). Bodies with lofted or swept faces are refused (`offset: only bodies with planes, cylinders, cones, spheres and tori`): hollow a loft with a smaller loft and `operation: cut`. |
|
||||
| `solid.hole` | `position:[x,y,z]` on a face (numbers or length expressions such as `["40", "width/2", "t"]`, which the hole then follows) + `diameter`, or `sketch` (+ `points`) | `clearance:"M3"`. `depth` defaults to through all. `type` is one of simple/drilled/counterbore/countersink. A `countersink` needs `cs_diameter` and `cs_angle`; a `counterbore` needs `cb_diameter` and `cb_depth`. `position` is a fixed point; to tie it to the part, add `references: [{edge:[x,y,z], distance}]` (up to two straight edges of the face): the centre then sits at those distances from the edges and follows them. |
|
||||
| `solid.hole` | `position:[x,y,z]` on a face (numbers or length expressions such as `["40", "width/2", "t"]`, which the hole then follows) + `diameter`, or `sketch` (+ `points`) | `clearance:"M3"`. `depth` defaults to through all. `type` is one of simple/drilled/counterbore/countersink. A `countersink` needs `cs_diameter` and `cs_angle`; a `counterbore` needs `cb_diameter` and `cb_depth`, or `head:"ISO 4762"` (socket head) / `"ISO 7380-1"` (button head) with `clearance`: the recess is then the head's diameter plus `head_gap` (default 0.5 mm) each side, and the head's height plus `head_gap` deep. `position` is a fixed point; to tie it to the part, add `references: [{edge:[x,y,z], distance}]` (up to two straight edges of the face): the centre then sits at those distances from the edges and follows them. |
|
||||
| `solid.pattern.rectangular` | `features`, `dir1`, `count1`, `spacing1` (+ `dir2`, `count2`, `spacing2`) | Spacing takes expressions and follows parameters. |
|
||||
| `solid.pattern.circular` | `features`, `axis` (`"Z"`, `{origin, dir}` whose origin may be length expressions the pattern follows, or `{face: [x,y,z]}` / `{edge: [x,y,z]}` on a cylinder wall or circular edge, which the pattern follows), `count` | `angle` defaults to 360 deg. A full pattern keeps the COM on the axis, so check its radius with a top-view screenshot. |
|
||||
| `solid.mirror` | `features:[names]` or `bodies:[names]`, `plane` (`XY`/`XZ`/`YZ`, a construction plane, or `{origin, normal}` whose origin may use length expressions) | Follows parameters. A mirror or pattern feature cannot be listed in `features` of another mirror or pattern, but `bodies` can mirror the body it made. The mirror is a new body with the first free default name: `Body1`, `Body2` from `features`, and `Side (2)`, `Side (3)` from `bodies:["Side"]`. Read the name from `created_bodies`. With `combine:true`, a mirror that overlaps the original joins it and makes no new body. `features` copies only the features listed, so a later cut is left out unless you list it too. `bodies` copies the finished body. To name the mirror, use `body.rename {"body":"Side (2)","name":"Side_L"}`. The new name stays when parameters change. |
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# The solidcore crates SolidCraft may use directly, one a line (solidcore docs/layers.md §1).
|
||||
# `cargo xtask layers` reads this list; any other solidcore crate needs a line in
|
||||
# xtask/solidcore-seam.txt. After vendoring, solidcore owns the list as
|
||||
# solidcore/public-crates.txt (its CODEOWNERS), and the check reads that copy first.
|
||||
solidcore-ops
|
||||
solidcore-tess
|
||||
solidcore-record
|
||||
@@ -0,0 +1,6 @@
|
||||
# Direct uses of solidcore crates that are not in the public list (xtask/solidcore-public.txt),
|
||||
# one a line: `<crate path> -> <solidcore crate> <reason, naming the solidcore issue or plan step
|
||||
# that removes it>`. `cargo xtask layers` (xtask/src/layers.rs, seam_check) fails on a use this
|
||||
# list does not name, and on a listed use that is gone: remove its line in the same pull request.
|
||||
# Never add a line: call solidcore-ops or solidcore-tess (solidcore docs/layers.md).
|
||||
crates/smlib -> solidcore the kernel wrapper calls the kernel crate directly until the switch commit of the solidcore ops stage 3b plan
|
||||
+207
-5
@@ -136,11 +136,16 @@ pub struct Dep {
|
||||
pub kind: DepKind,
|
||||
/// `true` if the dependency is a workspace member.
|
||||
pub workspace: bool,
|
||||
/// `true` if the package comes from tangency-labs/solidcore (its git URL, or the vendored
|
||||
/// `solidcore/` directory), whatever it is called.
|
||||
pub from_solidcore: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Crate {
|
||||
pub name: String,
|
||||
/// The crate's directory relative to the workspace root (`crates/smlib`).
|
||||
pub path: String,
|
||||
pub deps: Vec<Dep>,
|
||||
}
|
||||
|
||||
@@ -247,12 +252,17 @@ fn check_with(crates: &[Crate], classify: impl Fn(&str) -> Option<Class>) -> Vec
|
||||
}
|
||||
|
||||
/// Build the model from `cargo metadata --format-version 1 --no-deps`.
|
||||
pub fn from_metadata(meta: &Value) -> Result<Vec<Crate>, String> {
|
||||
pub fn from_metadata(meta: &Value, root: Option<&std::path::Path>) -> Result<Vec<Crate>, String> {
|
||||
let pkgs = meta["packages"].as_array().ok_or("metadata: no packages array")?;
|
||||
let members: Vec<&str> = pkgs.iter().filter_map(|p| p["name"].as_str()).collect();
|
||||
let mut out = Vec::new();
|
||||
for p in pkgs {
|
||||
let name = p["name"].as_str().ok_or("package without name")?.to_owned();
|
||||
let manifest = p["manifest_path"].as_str().unwrap_or_default();
|
||||
let path = match (root, std::path::Path::new(manifest).parent()) {
|
||||
(Some(r), Some(dir)) => dir.strip_prefix(r).map(|d| d.to_string_lossy().replace('\\', "/")).unwrap_or_else(|_| name.clone()),
|
||||
_ => name.clone(),
|
||||
};
|
||||
let mut deps = Vec::new();
|
||||
for d in p["dependencies"].as_array().into_iter().flatten() {
|
||||
let dname = d["name"].as_str().unwrap_or_default().to_owned();
|
||||
@@ -262,14 +272,113 @@ pub fn from_metadata(meta: &Value) -> Result<Vec<Crate>, String> {
|
||||
_ => DepKind::Normal,
|
||||
};
|
||||
let workspace = members.contains(&dname.as_str()) || d["path"].is_string();
|
||||
deps.push(Dep { name: dname, kind, workspace });
|
||||
let from_git = d["source"].as_str().is_some_and(|u| u.contains("tangency-labs/solidcore"));
|
||||
let vendored = match (root, d["path"].as_str()) {
|
||||
(Some(r), Some(p)) => std::path::Path::new(p).strip_prefix(r).is_ok_and(|rel| rel.starts_with("solidcore")),
|
||||
_ => false,
|
||||
};
|
||||
deps.push(Dep { name: dname, kind, workspace, from_solidcore: from_git || vendored });
|
||||
}
|
||||
out.push(Crate { name, deps });
|
||||
out.push(Crate { name, path, deps });
|
||||
}
|
||||
out.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The allowlist of today's direct uses of solidcore's inner crates.
|
||||
pub const SEAM_LIST: &str = "xtask/solidcore-seam.txt";
|
||||
|
||||
/// The solidcore crates apps may use, one a line (solidcore docs/layers.md §1). After vendoring
|
||||
/// solidcore owns the list at `solidcore/public-crates.txt`; the rule reads that first and falls
|
||||
/// back to the xtask copy, so cutover changes no code.
|
||||
pub const SEAM_PUBLIC: &[&str] = &["solidcore/public-crates.txt", "xtask/solidcore-public.txt"];
|
||||
|
||||
/// The crate names in a public-crates file (`#` comments, blank lines skipped).
|
||||
fn parse_public(text: &str) -> Vec<String> {
|
||||
text.lines()
|
||||
.map(str::trim)
|
||||
.filter(|l| !l.is_empty() && !l.starts_with('#'))
|
||||
.map(|l| l.split_whitespace().next().unwrap_or(l).to_owned())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The public-crates text under `root`: the first of [`SEAM_PUBLIC`] that exists.
|
||||
pub fn read_public(root: &std::path::Path) -> Result<String, String> {
|
||||
for p in SEAM_PUBLIC {
|
||||
if let Ok(t) = std::fs::read_to_string(root.join(p)) {
|
||||
return Ok(t);
|
||||
}
|
||||
}
|
||||
Err(format!("none of {SEAM_PUBLIC:?} exists"))
|
||||
}
|
||||
|
||||
/// Package names that are solidcore's, whichever way they come in. Anything else from
|
||||
/// tangency-labs/solidcore is caught by its source (`Dep::from_solidcore`).
|
||||
fn is_solidcore_name(name: &str) -> bool {
|
||||
name == "solidcore" || name.starts_with("solidcore-") || name == "ssi" || name.starts_with("step-") || name == "nurbs-grid-mesh"
|
||||
}
|
||||
|
||||
/// The seam rule: SolidCraft crosses into solidcore only through the public crates ([`SEAM_PUBLIC`]). Every other
|
||||
/// solidcore crate, in any dependency kind, must be named in `allow` (lines
|
||||
/// `crate-path -> solidcore-crate reason`; `#` comments). The list only shrinks: a listed use
|
||||
/// that is gone is an error too. Returns the messages, sorted.
|
||||
pub fn seam_check(crates: &[Crate], allow: &str, public: &str) -> Vec<String> {
|
||||
let public = parse_public(public);
|
||||
let mut errors = Vec::new();
|
||||
let mut listed: Vec<(String, String)> = Vec::new();
|
||||
for (n, line) in allow.lines().enumerate() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
let mut it = line.splitn(2, " -> ");
|
||||
let (Some(from), Some(rest)) = (it.next(), it.next()) else {
|
||||
errors.push(format!("{SEAM_LIST}:{}: expected `crate-path -> solidcore-crate reason`", n + 1));
|
||||
continue;
|
||||
};
|
||||
let mut parts = rest.split_whitespace();
|
||||
let (Some(to), Some(_reason)) = (parts.next(), parts.next()) else {
|
||||
errors.push(format!(
|
||||
"{SEAM_LIST}:{}: `{line}` needs a one-line reason after the crate name, naming the solidcore issue or plan step that removes it",
|
||||
n + 1
|
||||
));
|
||||
continue;
|
||||
};
|
||||
listed.push((from.trim().to_owned(), to.to_owned()));
|
||||
}
|
||||
let mut used: Vec<(String, String)> = Vec::new();
|
||||
for c in crates {
|
||||
// After vendoring, solidcore's own crates are workspace members; their rules are its own.
|
||||
if is_solidcore_name(&c.name) {
|
||||
continue;
|
||||
}
|
||||
for d in &c.deps {
|
||||
if !(is_solidcore_name(&d.name) || d.from_solidcore) || public.contains(&d.name) {
|
||||
continue;
|
||||
}
|
||||
let key = (c.path.clone(), d.name.clone());
|
||||
if used.contains(&key) {
|
||||
continue;
|
||||
}
|
||||
used.push(key.clone());
|
||||
if !listed.contains(&key) {
|
||||
errors.push(format!(
|
||||
"{} ({}) uses solidcore crate `{}` [{:?}]: call solidcore-ops or solidcore-tess instead (solidcore docs/layers.md; `Order` comes via solidcore_ops::Order, not solidcore-convert)",
|
||||
c.path, c.name, d.name, d.kind
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (from, to) in &listed {
|
||||
if !used.contains(&(from.clone(), to.clone())) {
|
||||
errors.push(format!("{SEAM_LIST}: `{from} -> {to}` is no longer used; remove it from the list"));
|
||||
}
|
||||
}
|
||||
errors.sort();
|
||||
errors.dedup();
|
||||
errors
|
||||
}
|
||||
|
||||
pub fn describe(class: Option<Class>) -> String {
|
||||
match class {
|
||||
Some(Class::Layer(l)) => format!("L{l}"),
|
||||
@@ -286,7 +395,11 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
fn c(name: &str, deps: &[(&str, DepKind, bool)]) -> Crate {
|
||||
Crate { name: name.into(), deps: deps.iter().map(|(n, k, w)| Dep { name: (*n).into(), kind: *k, workspace: *w }).collect() }
|
||||
Crate {
|
||||
name: name.into(),
|
||||
path: format!("crates/{}", short_name(name)),
|
||||
deps: deps.iter().map(|(n, k, w)| Dep { name: (*n).into(), kind: *k, workspace: *w, from_solidcore: false }).collect(),
|
||||
}
|
||||
}
|
||||
use DepKind::*;
|
||||
|
||||
@@ -395,6 +508,95 @@ mod tests {
|
||||
assert!(matches!(v[..], [Violation::SharedDependsOnApp { .. }]));
|
||||
}
|
||||
|
||||
const PUB: &str = "# public\nsolidcore-ops\nsolidcore-tess\nsolidcore-record\n";
|
||||
|
||||
fn seam_crate(dep: &str, kind: DepKind, from_solidcore: bool) -> Crate {
|
||||
Crate {
|
||||
name: "solidcraft-smlib".into(),
|
||||
path: "crates/smlib".into(),
|
||||
deps: vec![Dep { name: dep.into(), kind, workspace: false, from_solidcore }],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seam_direct_kernel_use_fails() {
|
||||
for kind in [Normal, Dev, Build] {
|
||||
let e = seam_check(&[seam_crate("solidcore", kind, true)], "", PUB);
|
||||
assert_eq!(e.len(), 1, "{kind:?}");
|
||||
assert!(e[0].contains("crates/smlib") && e[0].contains("`solidcore`"), "{}", e[0]);
|
||||
assert!(e[0].contains("call solidcore-ops or solidcore-tess instead (solidcore docs/layers.md") && e[0].contains("solidcore_ops::Order"));
|
||||
}
|
||||
for dep in ["solidcore-convert", "solidcore-ssi", "ssi", "step-import", "step-reader", "nurbs-grid-mesh"] {
|
||||
assert_eq!(seam_check(&[seam_crate(dep, Normal, false)], "", PUB).len(), 1, "{dep}");
|
||||
}
|
||||
// a solidcore crate with a name this list does not know, found by where it comes from
|
||||
assert_eq!(seam_check(&[seam_crate("brand-new-kernel-crate", Normal, true)], "", PUB).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seam_public_crates_pass() {
|
||||
for dep in ["solidcore-ops", "solidcore-tess", "solidcore-record"] {
|
||||
assert!(seam_check(&[seam_crate(dep, Normal, true)], "", PUB).is_empty(), "{dep}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seam_allowlisted_use_passes() {
|
||||
let list = "# today\ncrates/smlib -> solidcore the kernel wrapper, until the switch\n";
|
||||
assert!(seam_check(&[seam_crate("solidcore", Normal, true)], list, PUB).is_empty());
|
||||
// listing one crate does not cover another solidcore crate
|
||||
assert_eq!(seam_check(&[seam_crate("solidcore-convert", Normal, true)], list, PUB).len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seam_removed_use_must_leave_the_list() {
|
||||
let list = "crates/smlib -> solidcore the kernel wrapper\n";
|
||||
let e = seam_check(&[seam_crate("solidcore-ops", Normal, true)], list, PUB);
|
||||
assert_eq!(e.len(), 1);
|
||||
assert!(e[0].contains("remove it from the list"), "{}", e[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seam_public_list_is_data() {
|
||||
// a crate added to the public list passes; one left off it fails
|
||||
let c = [seam_crate("solidcore-newthing", Normal, true)];
|
||||
assert_eq!(seam_check(&c, "", PUB).len(), 1);
|
||||
assert!(seam_check(&c, "", "solidcore-newthing # ops gained it\n").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seam_public_list_prefers_solidcore_copy() {
|
||||
let dir = std::env::temp_dir().join(format!("seam-public-{}", std::process::id()));
|
||||
std::fs::create_dir_all(dir.join("solidcore")).unwrap();
|
||||
std::fs::create_dir_all(dir.join("xtask")).unwrap();
|
||||
std::fs::write(dir.join("xtask/solidcore-public.txt"), "old\n").unwrap();
|
||||
assert_eq!(read_public(&dir).unwrap(), "old\n");
|
||||
std::fs::write(dir.join("solidcore/public-crates.txt"), "new\n").unwrap();
|
||||
assert_eq!(read_public(&dir).unwrap(), "new\n");
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seam_list_lines_need_a_reason() {
|
||||
let e = seam_check(&[seam_crate("solidcore", Normal, true)], "crates/smlib -> solidcore\n", PUB);
|
||||
assert!(e.iter().any(|m| m.contains("needs a one-line reason")), "{e:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seam_matches_by_package_name_not_source() {
|
||||
let meta: Value = serde_json::from_str(
|
||||
r#"{"packages":[{"name":"solidcraft-smlib","manifest_path":"/w/crates/smlib/Cargo.toml","dependencies":[
|
||||
{"name":"solidcore","kind":null,"path":"/w/solidcore/crates/solidcore"},
|
||||
{"name":"solidcore-ops","kind":null,"source":"git+https://github.com/tangency-labs/solidcore?rev=abc"}]}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let g = from_metadata(&meta, Some(std::path::Path::new("/w"))).unwrap();
|
||||
assert_eq!(g[0].path, "crates/smlib");
|
||||
let e = seam_check(&g, "", PUB);
|
||||
assert_eq!(e.len(), 1, "{e:?}");
|
||||
assert!(e[0].contains("`solidcore`"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_parsing() {
|
||||
let meta: Value = serde_json::from_str(
|
||||
@@ -407,7 +609,7 @@ mod tests {
|
||||
]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let g = from_metadata(&meta).unwrap();
|
||||
let g = from_metadata(&meta, None).unwrap();
|
||||
assert_eq!(g.len(), 2);
|
||||
let doc = g.iter().find(|c| c.name == "solidcraft-doc").unwrap();
|
||||
assert!(doc.deps[0].workspace && !doc.deps[1].workspace);
|
||||
|
||||
+42
-6
@@ -29,6 +29,7 @@ mod ui_containers;
|
||||
mod web_offline;
|
||||
mod web_offline_test;
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, ExitCode};
|
||||
|
||||
@@ -197,9 +198,23 @@ fn main() -> ExitCode {
|
||||
}
|
||||
|
||||
/// Workspace root (parent of the xtask crate).
|
||||
///
|
||||
/// `cargo xtask` (`cargo run`) sets `CARGO_MANIFEST_DIR` for the process, so that names the
|
||||
/// checkout the command runs in. The compile-time value is only a fallback for running the
|
||||
/// binary directly: git worktrees that share one `CARGO_TARGET_DIR` share one `xtask` binary,
|
||||
/// and its baked-in path names whichever worktree built it last.
|
||||
pub fn root() -> PathBuf {
|
||||
let here = Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
here.parent().unwrap_or(here).to_path_buf()
|
||||
root_from(std::env::var_os("CARGO_MANIFEST_DIR"), Path::new(env!("CARGO_MANIFEST_DIR")))
|
||||
}
|
||||
|
||||
/// The workspace root from the runtime `CARGO_MANIFEST_DIR` (when set and not empty), else from
|
||||
/// the compile-time one.
|
||||
fn root_from(runtime: Option<OsString>, compiled: &Path) -> PathBuf {
|
||||
let here = match runtime.filter(|dir| !dir.is_empty()) {
|
||||
Some(dir) => PathBuf::from(dir),
|
||||
None => compiled.to_path_buf(),
|
||||
};
|
||||
here.parent().map(Path::to_path_buf).unwrap_or(here)
|
||||
}
|
||||
|
||||
pub fn cargo() -> Command {
|
||||
@@ -254,7 +269,7 @@ fn cmd_action_matrix(bless: bool, results: Option<&str>, from: Option<&str>) ->
|
||||
}
|
||||
|
||||
fn cmd_layers() -> Result<(), String> {
|
||||
let crates = layers::from_metadata(&metadata()?)?;
|
||||
let crates = layers::from_metadata(&metadata()?, Some(&root()))?;
|
||||
println!("Dependency layering (plan/architecture.md §3)\n");
|
||||
println!("{:<28} {:<14} workspace deps", "crate", "layer");
|
||||
for c in &crates {
|
||||
@@ -273,10 +288,12 @@ fn cmd_layers() -> Result<(), String> {
|
||||
.collect();
|
||||
println!("{:<28} {:<14} {}", c.name, layers::describe(layers::classify(&c.name)), ws.join(", "));
|
||||
}
|
||||
let violations = layers::check(&crates);
|
||||
let mut violations: Vec<String> = layers::check(&crates).iter().map(ToString::to_string).collect();
|
||||
let allow = std::fs::read_to_string(root().join(layers::SEAM_LIST)).map_err(|e| format!("{}: {e}", layers::SEAM_LIST))?;
|
||||
violations.extend(layers::seam_check(&crates, &allow, &layers::read_public(&root())?));
|
||||
println!();
|
||||
if violations.is_empty() {
|
||||
println!("OK: {} crates, no layering violations.", crates.len());
|
||||
println!("OK: {} crates, no layering or solidcore seam violations.", crates.len());
|
||||
Ok(())
|
||||
} else {
|
||||
println!("{} violation(s):", violations.len());
|
||||
@@ -430,7 +447,7 @@ fn cmd_wasm() -> Result<(), String> {
|
||||
/// The wasm check and how many crates it covers. It uses the ci profile so the host build
|
||||
/// scripts and proc macros built for clippy and the tests are reused.
|
||||
fn wasm_check() -> Result<(Command, usize), String> {
|
||||
let crates = layers::from_metadata(&metadata()?)?;
|
||||
let crates = layers::from_metadata(&metadata()?, Some(&root()))?;
|
||||
let mut set: Vec<String> = crates
|
||||
.iter()
|
||||
.filter(|c| matches!(layers::classify(&c.name), Some(layers::Class::Layer(_) | layers::Class::Shared)))
|
||||
@@ -469,3 +486,22 @@ fn cmd_ci() -> Result<(), String> {
|
||||
eprintln!("ci: all gates passed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn root_prefers_the_runtime_manifest_dir() {
|
||||
let compiled = Path::new("/built/here/xtask");
|
||||
let runtime = Some(OsString::from("/run/there/xtask"));
|
||||
assert_eq!(root_from(runtime, compiled), PathBuf::from("/run/there"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_falls_back_to_the_compiled_manifest_dir() {
|
||||
let compiled = Path::new("/built/here/xtask");
|
||||
assert_eq!(root_from(None, compiled), PathBuf::from("/built/here"));
|
||||
assert_eq!(root_from(Some(OsString::new()), compiled), PathBuf::from("/built/here"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +81,7 @@ pub const SKIPPED_FUNCTIONS: &[(&str, &str)] = &[
|
||||
("crates/doc/src/eval.rs:feature_json", "fingerprints feature inputs; triangles are only embedded mesh-body source data"),
|
||||
("crates/doc/src/eval.rs:mesh_bodies", "mesh bodies: a mesh import's triangles"),
|
||||
("crates/doc/src/eval.rs:mesh_content_digest", "mesh-body imports: hashes source arrays without geometric classification"),
|
||||
("crates/doc/src/project3d.rs:flat_outline", "mesh bodies: the flat patch of an imported mesh's triangles that a face pick on it means"),
|
||||
("crates/engine/src/cmd/file.rs:saved_preview", "the preview picture saved in a design"),
|
||||
("crates/kernel/src/body.rs:Body::classify_point", "mesh bodies: its mesh-body branch (a B-rep is classified by SMLib)"),
|
||||
("crates/kernel/src/body.rs:Body::mesh_untimed", "the mesher: a body's tessellation"),
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ const SCRATCH_TARGET: &str = "target/xtask-shared-target";
|
||||
|
||||
pub fn run(root: &Path) -> Result<(), String> {
|
||||
let meta = crate::metadata()?;
|
||||
let crates = layers::from_metadata(&meta)?;
|
||||
let crates = layers::from_metadata(&meta, None)?;
|
||||
let dirs = manifest_dirs(&meta)?;
|
||||
let shared: BTreeSet<&str> = crates.iter().filter(|c| layers::classify(&c.name) == Some(Class::Shared)).map(|c| c.name.as_str()).collect();
|
||||
if shared.is_empty() {
|
||||
|
||||
Reference in New Issue
Block a user