mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-08 21:11:56 +00:00
Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 44525da669 | |||
| 33c442f031 | |||
| 8d47023049 | |||
| 3c619790ce | |||
| f4d9519bb9 | |||
| 9890abae36 | |||
| 28cfed5e07 | |||
| 01042ca81e | |||
| 42cd362748 | |||
| bfee9bcc19 | |||
| 1210ac5080 | |||
| 6e27cfa7ff | |||
| 5c95fb027c | |||
| 1dcb1066e5 | |||
| 94cb149856 | |||
| 9e3e441a2d | |||
| 3d78129af6 | |||
| 3fb1040164 | |||
| 746b43878e | |||
| 674e72ee8d | |||
| d0da8047a7 | |||
| 72a22b3338 | |||
| f0f0b58a94 | |||
| cfb0240869 | |||
| 16204d4c96 | |||
| 419bbcda0e | |||
| 28efdb6681 | |||
| 8c197e4d18 | |||
| b76f681f01 | |||
| 82660d4ee2 | |||
| fcd3186798 |
@@ -505,9 +505,15 @@ jobs:
|
||||
key: native-tests
|
||||
save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' }}
|
||||
- name: Set up compiler cache
|
||||
id: sccache
|
||||
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
|
||||
with:
|
||||
version: v0.16.0
|
||||
# A failed download of sccache (GitHub release outages answer 504) must not fail the build.
|
||||
continue-on-error: true
|
||||
- name: Build without the compiler cache if it could not be set up
|
||||
if: steps.sccache.outcome == 'failure'
|
||||
run: echo "RUSTC_WRAPPER=" >> "$GITHUB_ENV"
|
||||
- uses: taiki-e/install-action@1168f6810a3cb30a3514bf5756a3b7f0422c6df8 # v2.87.28
|
||||
with:
|
||||
tool: cargo-nextest
|
||||
@@ -528,7 +534,7 @@ jobs:
|
||||
compression-level: 0
|
||||
- name: Compiler cache statistics
|
||||
if: always()
|
||||
run: sccache --show-stats
|
||||
run: if command -v sccache >/dev/null; then sccache --show-stats; else echo "sccache was not set up; built without it"; fi
|
||||
|
||||
tests:
|
||||
name: Native Linux tests
|
||||
@@ -576,9 +582,15 @@ jobs:
|
||||
key: native-tests
|
||||
save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' }}
|
||||
- name: Set up compiler cache
|
||||
id: sccache
|
||||
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
|
||||
with:
|
||||
version: v0.16.0
|
||||
# A failed download of sccache (GitHub release outages answer 504) must not fail the build.
|
||||
continue-on-error: true
|
||||
- name: Build without the compiler cache if it could not be set up
|
||||
if: steps.sccache.outcome == 'failure'
|
||||
run: echo "RUSTC_WRAPPER=" >> "$GITHUB_ENV"
|
||||
- uses: taiki-e/install-action@1168f6810a3cb30a3514bf5756a3b7f0422c6df8 # v2.87.28
|
||||
with:
|
||||
tool: cargo-nextest
|
||||
@@ -619,7 +631,7 @@ jobs:
|
||||
cargo xtask known-failing --junit "$junit"
|
||||
- name: Compiler cache statistics
|
||||
if: always()
|
||||
run: sccache --show-stats
|
||||
run: if command -v sccache >/dev/null; then sccache --show-stats; else echo "sccache was not set up; built without it"; fi
|
||||
- name: Upload test timings
|
||||
if: always()
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
@@ -692,9 +704,15 @@ jobs:
|
||||
key: native-gpu
|
||||
save-if: ${{ (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop') && matrix.shard == 1 }}
|
||||
- name: Set up compiler cache
|
||||
id: sccache
|
||||
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
|
||||
with:
|
||||
version: v0.16.0
|
||||
# A failed download of sccache (GitHub release outages answer 504) must not fail the build.
|
||||
continue-on-error: true
|
||||
- name: Build without the compiler cache if it could not be set up
|
||||
if: steps.sccache.outcome == 'failure'
|
||||
run: echo "RUSTC_WRAPPER=" >> "$GITHUB_ENV"
|
||||
- uses: taiki-e/install-action@1168f6810a3cb30a3514bf5756a3b7f0422c6df8 # v2.87.28
|
||||
with:
|
||||
tool: cargo-nextest
|
||||
@@ -715,7 +733,7 @@ jobs:
|
||||
cargo xtask known-failing --junit "$junit"
|
||||
- name: Compiler cache statistics
|
||||
if: always()
|
||||
run: sccache --show-stats
|
||||
run: if command -v sccache >/dev/null; then sccache --show-stats; else echo "sccache was not set up; built without it"; fi
|
||||
- name: Upload test timings
|
||||
if: always()
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
@@ -848,9 +866,15 @@ jobs:
|
||||
key: doc-tests
|
||||
save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' }}
|
||||
- name: Set up compiler cache
|
||||
id: sccache
|
||||
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
|
||||
with:
|
||||
version: v0.16.0
|
||||
# A failed download of sccache (GitHub release outages answer 504) must not fail the build.
|
||||
continue-on-error: true
|
||||
- name: Build without the compiler cache if it could not be set up
|
||||
if: steps.sccache.outcome == 'failure'
|
||||
run: echo "RUSTC_WRAPPER=" >> "$GITHUB_ENV"
|
||||
# nextest does not run doc tests.
|
||||
- name: Doc tests
|
||||
env:
|
||||
@@ -906,9 +930,15 @@ jobs:
|
||||
key: oracle
|
||||
save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' }}
|
||||
- name: Set up compiler cache
|
||||
id: sccache
|
||||
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
|
||||
with:
|
||||
version: v0.16.0
|
||||
# A failed download of sccache (GitHub release outages answer 504) must not fail the build.
|
||||
continue-on-error: true
|
||||
- name: Build without the compiler cache if it could not be set up
|
||||
if: steps.sccache.outcome == 'failure'
|
||||
run: echo "RUSTC_WRAPPER=" >> "$GITHUB_ENV"
|
||||
- name: Build the command line
|
||||
run: cargo build -p solidcraft-cli --profile ci --locked
|
||||
- name: Replay tests/oracle and compare with expected.json
|
||||
|
||||
@@ -85,9 +85,15 @@ jobs:
|
||||
key: fillet-rig
|
||||
save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' }}
|
||||
- name: Set up compiler cache
|
||||
id: sccache
|
||||
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
|
||||
with:
|
||||
version: v0.16.0
|
||||
# A failed download of sccache (GitHub release outages answer 504) must not fail the build.
|
||||
continue-on-error: true
|
||||
- name: Build without the compiler cache if it could not be set up
|
||||
if: steps.sccache.outcome == 'failure'
|
||||
run: echo "RUSTC_WRAPPER=" >> "$GITHUB_ENV"
|
||||
- name: Build the CLI (release)
|
||||
run: cargo build --release --locked -p solidcraft-cli
|
||||
- name: Run the fillet cases against the known-failing list
|
||||
|
||||
@@ -149,9 +149,15 @@ jobs:
|
||||
key: kernel-rig
|
||||
save-if: ${{ github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop') }}
|
||||
- name: Set up compiler cache
|
||||
id: sccache
|
||||
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
|
||||
with:
|
||||
version: v0.16.0
|
||||
# A failed download of sccache (GitHub release outages answer 504) must not fail the build.
|
||||
continue-on-error: true
|
||||
- name: Build without the compiler cache if it could not be set up
|
||||
if: steps.sccache.outcome == 'failure'
|
||||
run: echo "RUSTC_WRAPPER=" >> "$GITHUB_ENV"
|
||||
- name: Build the CLI (release) and the rig's runner
|
||||
run: |
|
||||
cargo build --release --locked -p solidcraft-cli
|
||||
@@ -174,7 +180,7 @@ jobs:
|
||||
retention-days: 1
|
||||
- name: Compiler cache statistics
|
||||
if: always()
|
||||
run: sccache --show-stats
|
||||
run: if command -v sccache >/dev/null; then sccache --show-stats; else echo "sccache was not set up; built without it"; fi
|
||||
|
||||
shard:
|
||||
name: Kernel rig (${{ matrix.shard }})
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -360,7 +360,7 @@ mod web {
|
||||
/// The start page's recent designs were filled from the stored ones.
|
||||
recents_synced: bool,
|
||||
/// On-demand script fonts: fetched only when a CJK language is selected.
|
||||
font: solidcraft_ui_egui::ui_kit::type_face::CjkFontLoader,
|
||||
font: solidcraft_ui_egui::fonts::CjkFontLoader,
|
||||
font_result: FontLoadResult,
|
||||
/// The fetched Japanese font's bytes: kept so a language change can re-order the fallbacks.
|
||||
cjk_bytes: Option<Vec<u8>>,
|
||||
@@ -1059,7 +1059,7 @@ mod web {
|
||||
}
|
||||
};
|
||||
let effective = solidcraft_ui_text::effective_language(&self.app.preferences.language);
|
||||
if !installed && solidcraft_ui_egui::ui_kit::type_face::needs_cjk_font(effective) {
|
||||
if !installed && solidcraft_ui_egui::fonts::needs_cjk_font(effective) {
|
||||
log::warn!("script font unavailable; showing English for this session");
|
||||
}
|
||||
self.font.finished(installed, &mut self.app.preferences.language);
|
||||
@@ -1093,7 +1093,7 @@ mod web {
|
||||
/// CJK text on screen with no Japanese font yet (an English UI showing a Japanese name):
|
||||
/// fetch the Japanese font, so the text does not stay tofu (#720).
|
||||
fn watch_text_for_font(&mut self, ctx: &egui::Context) {
|
||||
if self.font.watching_text() && solidcraft_ui_egui::ui_kit::type_face::painted_cjk(ctx) && self.font.text_needs_font() {
|
||||
if self.font.watching_text() && solidcraft_ui_egui::fonts::painted_cjk(ctx) && self.font.text_needs_font() {
|
||||
self.fetch_font(ctx, "ja");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,16 +150,16 @@ pub(crate) fn curved_arrow(painter: &egui::Painter, at: Pos2, clockwise: bool, h
|
||||
}
|
||||
/// The pointer target around a compass end: larger than the drawn disc, so it is easy to hit.
|
||||
pub(crate) const AXIS_RADIUS: f32 = 15.0;
|
||||
pub(crate) fn axis_tip(painter: &egui::Painter, c: Pos2, name: &str, d: [f64; 3], at: Pos2, depth: f64, hovered: bool, t: Tokens) {
|
||||
pub(crate) fn axis_tip(painter: &egui::Painter, c: Pos2, name: &str, d: &Vec3, at: Pos2, depth: f64, hovered: bool, t: Tokens) {
|
||||
axis_tip_scaled(painter, c, name, d, at, depth, hovered, t, 1.0);
|
||||
}
|
||||
|
||||
/// The same compass artwork at a uniform scale, including its lettering and strokes.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn axis_tip_scaled(painter: &egui::Painter, c: Pos2, name: &str, d: [f64; 3], at: Pos2, depth: f64, hovered: bool, t: Tokens, scale: f32) {
|
||||
let col = if d[0] != 0.0 {
|
||||
pub(crate) fn axis_tip_scaled(painter: &egui::Painter, c: Pos2, name: &str, d: &Vec3, at: Pos2, depth: f64, hovered: bool, t: Tokens, scale: f32) {
|
||||
let col = if d.x != 0.0 {
|
||||
t.axis_x
|
||||
} else if d[1] != 0.0 {
|
||||
} else if d.y != 0.0 {
|
||||
t.axis_y
|
||||
} else {
|
||||
t.axis_z
|
||||
@@ -196,7 +196,7 @@ pub(crate) fn axis_tip_scaled(painter: &egui::Painter, c: Pos2, name: &str, d: [
|
||||
// On a hovered (white) disc the letter takes the axis colour.
|
||||
let letter = if hovered { col } else { t.on_accent };
|
||||
let label = name.to_uppercase();
|
||||
let galley = painter.layout_no_wrap(label, crate::ui_kit::type_face::bold(solidcraft_theme::Tokens::get().font_compass * scale), letter);
|
||||
let galley = painter.layout_no_wrap(label, crate::fonts::bold(solidcraft_theme::Tokens::get().font_compass * scale), letter);
|
||||
let origin = at - galley.size() / 2.0;
|
||||
let ppp = painter.ctx().pixels_per_point().max(0.5);
|
||||
let origin = pos2((origin.x * ppp).round() / ppp, (origin.y * ppp).round() / ppp);
|
||||
|
||||
@@ -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": "إيقاف التصيير المباشر مؤقتًا أو استئنافه",
|
||||
@@ -2218,6 +2218,7 @@
|
||||
"material.editor.preserved_appearance": "المظهر المحفوظ",
|
||||
"material.editor.public_reference": "مرجع عام",
|
||||
"material.editor.reference": "مرجع",
|
||||
"material.editor.remove": "إزالة",
|
||||
"material.editor.save_or_discard": "احفظ مسودة المادة أو تجاهلها قبل تعيين مادة.",
|
||||
"material.editor.saved": "حُفظت المادة. تعيينات الأجسام لم تتغير.",
|
||||
"material.editor.scope": "الحفظ يخزّن هذه المادة دون تعيينها للأجسام. الخصائص والشروط الفارغة تبقى غير معروفة. كثافة البطاقة لا تغيّر الكثافة المعيّنة ولا الكتلة.",
|
||||
@@ -2852,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 يبدّل. عند الإيقاف (الافتراضي): كلاهما يبدّل.",
|
||||
@@ -3613,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": "لا يوجد اسم متاح للقطة مكررة",
|
||||
@@ -3843,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": "الموضع",
|
||||
@@ -4456,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": "بيئة ثابتة",
|
||||
@@ -5888,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": "العزوم الأولى",
|
||||
|
||||
@@ -1395,6 +1395,7 @@
|
||||
"material.editor.preserved_appearance": "Preserved appearance",
|
||||
"material.editor.public_reference": "Public reference",
|
||||
"material.editor.reference": "Reference",
|
||||
"material.editor.remove": "Remove",
|
||||
"material.editor.save_or_discard": "Save or discard the material draft before assigning a material.",
|
||||
"material.editor.saved": "Material saved. Body assignments are unchanged.",
|
||||
"material.editor.scope": "Save stores this material without assigning it to bodies. Blank properties and conditions remain unknown. Card density does not change assigned density or mass.",
|
||||
|
||||
@@ -1889,6 +1889,7 @@
|
||||
"material.editor.preserved_appearance": "Sačuvani izgled",
|
||||
"material.editor.public_reference": "Javna referenca",
|
||||
"material.editor.reference": "Referenca",
|
||||
"material.editor.remove": "Ukloni",
|
||||
"material.editor.save_or_discard": "Sačuvajte ili odbacite nacrt materijala prije dodjele materijala.",
|
||||
"material.editor.saved": "Materijal je sačuvan. Dodjele tijelima ostaju nepromijenjene.",
|
||||
"material.editor.scope": "Sačuvaj sprema ovaj materijal bez dodjele tijelima. Prazna svojstva i uslovi ostaju nepoznati. Gustina s kartice ne mijenja dodijeljenu gustinu ni masu.",
|
||||
|
||||
@@ -2204,6 +2204,7 @@
|
||||
"material.editor.preserved_appearance": "Aparença conservada",
|
||||
"material.editor.public_reference": "Referència pública",
|
||||
"material.editor.reference": "Referència",
|
||||
"material.editor.remove": "Eliminar",
|
||||
"material.editor.save_or_discard": "Deseu o descarteu l'esborrany de material abans d'assignar un material.",
|
||||
"material.editor.saved": "Material desat. Les assignacions als cossos no han canviat.",
|
||||
"material.editor.scope": "Desar emmagatzema aquest material sense assignar-lo a cap cos. Les propietats i condicions buides continuen sent desconegudes. La densitat de la fitxa no canvia la densitat assignada ni la massa.",
|
||||
|
||||
@@ -2204,6 +2204,7 @@
|
||||
"material.editor.preserved_appearance": "Zachovaný vzhled",
|
||||
"material.editor.public_reference": "Veřejná reference",
|
||||
"material.editor.reference": "Reference",
|
||||
"material.editor.remove": "Odebrat",
|
||||
"material.editor.save_or_discard": "Před přiřazením materiálu uložte nebo zahoďte koncept materiálu.",
|
||||
"material.editor.saved": "Materiál byl uložen. Přiřazení k tělesům se nezměnila.",
|
||||
"material.editor.scope": "Uložením se materiál uloží bez přiřazení k tělesům. Prázdné vlastnosti a podmínky zůstávají neznámé. Hustota z karty nemění přiřazenou hustotu ani hmotnost.",
|
||||
|
||||
@@ -1889,6 +1889,7 @@
|
||||
"material.editor.preserved_appearance": "Bevaret udseende",
|
||||
"material.editor.public_reference": "Offentlig reference",
|
||||
"material.editor.reference": "Reference",
|
||||
"material.editor.remove": "Fjern",
|
||||
"material.editor.save_or_discard": "Gem eller kassér materialeudkastet, før du tildeler et materiale.",
|
||||
"material.editor.saved": "Materialet er gemt. Tildelinger til emner er uændrede.",
|
||||
"material.editor.scope": "Gem gemmer materialet uden at tildele det til emner. Tomme egenskaber og betingelser forbliver ukendte. Kortets densitet ændrer ikke den tildelte densitet eller massen.",
|
||||
|
||||
@@ -2010,6 +2010,7 @@
|
||||
"material.editor.preserved_appearance": "Beibehaltenes Erscheinungsbild",
|
||||
"material.editor.public_reference": "Öffentliche Quelle",
|
||||
"material.editor.reference": "Referenz",
|
||||
"material.editor.remove": "Entfernen",
|
||||
"material.editor.save_or_discard": "Speichern oder verwerfen Sie den Materialentwurf, bevor Sie ein Material zuweisen.",
|
||||
"material.editor.saved": "Material gespeichert. Körperzuweisungen bleiben unverändert.",
|
||||
"material.editor.scope": "Speichern legt dieses Material ab, ohne es Körpern zuzuweisen. Leere Eigenschaften und Bedingungen bleiben unbekannt. Die Dichte im Datenblatt ändert weder die zugewiesene Dichte noch die Masse.",
|
||||
|
||||
@@ -2218,6 +2218,7 @@
|
||||
"material.editor.preserved_appearance": "Preserved appearance",
|
||||
"material.editor.public_reference": "Public reference",
|
||||
"material.editor.reference": "Reference",
|
||||
"material.editor.remove": "Remove",
|
||||
"material.editor.save_or_discard": "Save or discard the material draft before assigning a material.",
|
||||
"material.editor.saved": "Material saved. Body assignments are unchanged.",
|
||||
"material.editor.scope": "Save stores this material without assigning it to bodies. Blank properties and conditions remain unknown. Card density does not change assigned density or mass.",
|
||||
|
||||
@@ -2204,6 +2204,7 @@
|
||||
"material.editor.preserved_appearance": "Apariencia conservada",
|
||||
"material.editor.public_reference": "Referencia pública",
|
||||
"material.editor.reference": "Referencia",
|
||||
"material.editor.remove": "Eliminar",
|
||||
"material.editor.save_or_discard": "Guarda o descarta el borrador antes de asignar un material.",
|
||||
"material.editor.saved": "Material guardado. Las asignaciones a cuerpos no han cambiado.",
|
||||
"material.editor.scope": "Guardar almacena este material sin asignarlo a cuerpos. Las propiedades y condiciones vacías siguen siendo desconocidas. La densidad de la ficha no cambia la densidad asignada ni la masa.",
|
||||
|
||||
+625
-6
File diff suppressed because it is too large
Load Diff
@@ -1899,6 +1899,7 @@
|
||||
"material.editor.preserved_appearance": "Apparence conservée",
|
||||
"material.editor.public_reference": "Référence publique",
|
||||
"material.editor.reference": "Référence",
|
||||
"material.editor.remove": "Retirer",
|
||||
"material.editor.save_or_discard": "Enregistrez ou abandonnez le brouillon de matériau avant d'affecter un matériau.",
|
||||
"material.editor.saved": "Matériau enregistré. Les affectations des corps sont inchangées.",
|
||||
"material.editor.scope": "Enregistrer stocke ce matériau sans l'affecter à des corps. Les propriétés et conditions vides restent inconnues. La masse volumique de la fiche ne modifie ni la masse volumique affectée ni la masse.",
|
||||
|
||||
@@ -1889,6 +1889,7 @@
|
||||
"material.editor.preserved_appearance": "Aparencia conservada",
|
||||
"material.editor.public_reference": "Referencia pública",
|
||||
"material.editor.reference": "Referencia",
|
||||
"material.editor.remove": "Quitar",
|
||||
"material.editor.save_or_discard": "Garda ou descarta o borrador do material antes de asignar un material.",
|
||||
"material.editor.saved": "Material gardado. As asignacións aos corpos non cambian.",
|
||||
"material.editor.scope": "Gardar almacena este material sen asignalo a corpos. As propiedades e condicións en branco seguen sendo descoñecidas. A densidade da ficha non cambia a densidade asignada nin a masa.",
|
||||
|
||||
@@ -2218,6 +2218,7 @@
|
||||
"material.editor.preserved_appearance": "מראה שנשמר",
|
||||
"material.editor.public_reference": "ייחוס ציבורי",
|
||||
"material.editor.reference": "ייחוס",
|
||||
"material.editor.remove": "הסרה",
|
||||
"material.editor.save_or_discard": "יש לשמור או לבטל את טיוטת החומר לפני הקצאת חומר.",
|
||||
"material.editor.saved": "החומר נשמר. הקצאות הגופים לא השתנו.",
|
||||
"material.editor.scope": "השמירה מאחסנת את החומר הזה בלי להקצות אותו לגופים. תכונות ותנאים ריקים נשארים לא ידועים. צפיפות הכרטיס אינה משנה את הצפיפות המוקצית או את המסה.",
|
||||
|
||||
@@ -1889,6 +1889,7 @@
|
||||
"material.editor.preserved_appearance": "Očuvani izgled",
|
||||
"material.editor.public_reference": "Javna referenca",
|
||||
"material.editor.reference": "Referenca",
|
||||
"material.editor.remove": "Ukloni",
|
||||
"material.editor.save_or_discard": "Spremite ili odbacite nacrt materijala prije dodjele materijala.",
|
||||
"material.editor.saved": "Materijal je spremljen. Dodjele tijelima ostaju nepromijenjene.",
|
||||
"material.editor.scope": "Spremi pohranjuje ovaj materijal bez dodjele tijelima. Prazna svojstva i uvjeti ostaju nepoznati. Gustoća s kartice ne mijenja dodijeljenu gustoću ni masu.",
|
||||
|
||||
@@ -1889,6 +1889,7 @@
|
||||
"material.editor.preserved_appearance": "Megőrzött megjelenés",
|
||||
"material.editor.public_reference": "Nyilvános hivatkozás",
|
||||
"material.editor.reference": "Hivatkozás",
|
||||
"material.editor.remove": "Eltávolítás",
|
||||
"material.editor.save_or_discard": "Anyag hozzárendelése előtt mentse vagy vesse el az anyagvázlatot.",
|
||||
"material.editor.saved": "Az anyag mentve. A testekhez rendelt anyagok nem változtak.",
|
||||
"material.editor.scope": "A Mentés az anyagot a testekhez való hozzárendelés nélkül tárolja. Az üres tulajdonságok és feltételek ismeretlenek maradnak. A kártyán szereplő sűrűség nem változtatja meg a hozzárendelt sűrűséget vagy a tömeget.",
|
||||
|
||||
@@ -1889,6 +1889,7 @@
|
||||
"material.editor.preserved_appearance": "Tampilan yang dipertahankan",
|
||||
"material.editor.public_reference": "Referensi publik",
|
||||
"material.editor.reference": "Referensi",
|
||||
"material.editor.remove": "Hapus",
|
||||
"material.editor.save_or_discard": "Simpan atau buang draf material sebelum menetapkan material.",
|
||||
"material.editor.saved": "Material disimpan. Penetapan ke bodi tidak berubah.",
|
||||
"material.editor.scope": "Simpan menyimpan material ini tanpa menetapkannya ke bodi. Properti dan kondisi yang dikosongkan tetap dianggap tidak diketahui. Massa jenis pada kartu tidak mengubah massa jenis atau massa yang sudah ditetapkan.",
|
||||
|
||||
@@ -1896,6 +1896,7 @@
|
||||
"material.editor.preserved_appearance": "Aspetto conservato",
|
||||
"material.editor.public_reference": "Riferimento pubblico",
|
||||
"material.editor.reference": "Riferimento",
|
||||
"material.editor.remove": "Rimuovi",
|
||||
"material.editor.save_or_discard": "Salva o scarta la bozza del materiale prima di assegnare un materiale.",
|
||||
"material.editor.saved": "Materiale salvato. Le assegnazioni ai corpi non sono cambiate.",
|
||||
"material.editor.scope": "Salva memorizza questo materiale senza assegnarlo ai corpi. Le proprietà e le condizioni lasciate vuote restano sconosciute. La densità della scheda non cambia la densità assegnata né la massa.",
|
||||
|
||||
@@ -2218,6 +2218,7 @@
|
||||
"material.editor.preserved_appearance": "保持される外観",
|
||||
"material.editor.public_reference": "公開資料",
|
||||
"material.editor.reference": "参照",
|
||||
"material.editor.remove": "削除",
|
||||
"material.editor.save_or_discard": "材料を割り当てる前に下書きを保存または破棄してください。",
|
||||
"material.editor.saved": "材料を保存しました。ボディへの割り当ては変わりません。",
|
||||
"material.editor.scope": "保存してもボディには割り当てません。空欄の特性と条件は不明のままです。カードの密度は割り当て密度や質量を変更しません。",
|
||||
|
||||
@@ -1939,6 +1939,7 @@
|
||||
"material.editor.preserved_appearance": "შენარჩუნებული იერსახე",
|
||||
"material.editor.public_reference": "საჯარო წყარო",
|
||||
"material.editor.reference": "საცნობარო",
|
||||
"material.editor.remove": "წაშლა",
|
||||
"material.editor.save_or_discard": "მასალის მინიჭებამდე შეინახეთ ან გააუქმეთ მასალის მონახაზი.",
|
||||
"material.editor.saved": "მასალა შენახულია. სხეულებზე მინიჭებები არ შეცვლილა.",
|
||||
"material.editor.scope": "შენახვა ამ მასალას ინახავს სხეულებზე მინიჭების გარეშე. ცარიელი თვისებები და პირობები უცნობად რჩება. ბარათის სიმკვრივე არ ცვლის მინიჭებულ სიმკვრივეს ან მასას.",
|
||||
|
||||
@@ -1889,6 +1889,7 @@
|
||||
"material.editor.preserved_appearance": "Penampilan dikekalkan",
|
||||
"material.editor.public_reference": "Rujukan awam",
|
||||
"material.editor.reference": "Rujukan",
|
||||
"material.editor.remove": "Buang",
|
||||
"material.editor.save_or_discard": "Simpan atau buang draf bahan sebelum menetapkan bahan.",
|
||||
"material.editor.saved": "Bahan disimpan. Penetapan jasad tidak berubah.",
|
||||
"material.editor.scope": "Simpan menyimpan bahan ini tanpa menetapkannya pada jasad. Sifat dan keadaan yang dibiarkan kosong kekal tidak diketahui. Ketumpatan kad tidak mengubah ketumpatan yang ditetapkan atau jisim.",
|
||||
|
||||
@@ -1889,6 +1889,7 @@
|
||||
"material.editor.preserved_appearance": "Bevart utseende",
|
||||
"material.editor.public_reference": "Offentlig referanse",
|
||||
"material.editor.reference": "Referanse",
|
||||
"material.editor.remove": "Fjern",
|
||||
"material.editor.save_or_discard": "Lagre eller forkast materialutkastet før du tildeler et materiale.",
|
||||
"material.editor.saved": "Materialet er lagret. Kroppstildelinger er uendret.",
|
||||
"material.editor.scope": "Ved lagring lagres materialet uten at det tildeles kropper. Tomme egenskaper og betingelser forblir ukjente. Tettheten på kortet endrer ikke tildelt tetthet eller masse.",
|
||||
|
||||
@@ -1893,6 +1893,7 @@
|
||||
"material.editor.preserved_appearance": "Behouden uiterlijk",
|
||||
"material.editor.public_reference": "Openbare referentie",
|
||||
"material.editor.reference": "Referentie",
|
||||
"material.editor.remove": "Verwijderen",
|
||||
"material.editor.save_or_discard": "Sla het materiaalconcept op of verwerp het voordat je een materiaal toewijst.",
|
||||
"material.editor.saved": "Materiaal opgeslagen. Toewijzingen aan lichamen zijn ongewijzigd.",
|
||||
"material.editor.scope": "Opslaan bewaart dit materiaal zonder het aan lichamen toe te wijzen. Lege eigenschappen en voorwaarden blijven onbekend. De dichtheid op de kaart wijzigt de toegewezen dichtheid of massa niet.",
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
{
|
||||
"ar": {
|
||||
"material.editor.remove": "إزالة"
|
||||
},
|
||||
"bg": {
|
||||
"material.editor.remove": "Remove"
|
||||
},
|
||||
"bs": {
|
||||
"material.editor.remove": "Ukloni"
|
||||
},
|
||||
"ca": {
|
||||
"material.editor.remove": "Eliminar"
|
||||
},
|
||||
"cs": {
|
||||
"material.editor.remove": "Odebrat"
|
||||
},
|
||||
"da": {
|
||||
"material.editor.remove": "Fjern"
|
||||
},
|
||||
"de": {
|
||||
"material.editor.remove": "Entfernen"
|
||||
},
|
||||
"en": {
|
||||
"material.editor.remove": "Remove"
|
||||
},
|
||||
"es": {
|
||||
"material.editor.remove": "Eliminar"
|
||||
},
|
||||
"fi": {
|
||||
"material.editor.remove": "Poista"
|
||||
},
|
||||
"fr": {
|
||||
"material.editor.remove": "Retirer"
|
||||
},
|
||||
"gl": {
|
||||
"material.editor.remove": "Quitar"
|
||||
},
|
||||
"he": {
|
||||
"material.editor.remove": "הסרה"
|
||||
},
|
||||
"hr": {
|
||||
"material.editor.remove": "Ukloni"
|
||||
},
|
||||
"hu": {
|
||||
"material.editor.remove": "Eltávolítás"
|
||||
},
|
||||
"id": {
|
||||
"material.editor.remove": "Hapus"
|
||||
},
|
||||
"it": {
|
||||
"material.editor.remove": "Rimuovi"
|
||||
},
|
||||
"ja": {
|
||||
"material.editor.remove": "削除"
|
||||
},
|
||||
"ka": {
|
||||
"material.editor.remove": "წაშლა"
|
||||
},
|
||||
"ms": {
|
||||
"material.editor.remove": "Buang"
|
||||
},
|
||||
"nb": {
|
||||
"material.editor.remove": "Fjern"
|
||||
},
|
||||
"nl": {
|
||||
"material.editor.remove": "Verwijderen"
|
||||
},
|
||||
"pl": {
|
||||
"material.editor.remove": "Usuń"
|
||||
},
|
||||
"pt-BR": {
|
||||
"material.editor.remove": "Remover"
|
||||
},
|
||||
"ro": {
|
||||
"material.editor.remove": "Elimină"
|
||||
},
|
||||
"ru": {
|
||||
"material.editor.remove": "Убрать"
|
||||
},
|
||||
"sr": {
|
||||
"material.editor.remove": "Уклони"
|
||||
},
|
||||
"sv": {
|
||||
"material.editor.remove": "Ta bort"
|
||||
},
|
||||
"tr": {
|
||||
"material.editor.remove": "Kaldır"
|
||||
},
|
||||
"uk": {
|
||||
"material.editor.remove": "Вилучити"
|
||||
},
|
||||
"vi": {
|
||||
"material.editor.remove": "Xóa"
|
||||
},
|
||||
"zh-TW": {
|
||||
"material.editor.remove": "刪除"
|
||||
},
|
||||
"zh": {
|
||||
"material.editor.remove": "删除"
|
||||
}
|
||||
}
|
||||
@@ -2204,6 +2204,7 @@
|
||||
"material.editor.preserved_appearance": "Zachowany wygląd",
|
||||
"material.editor.public_reference": "Publiczne źródło",
|
||||
"material.editor.reference": "Źródło",
|
||||
"material.editor.remove": "Usuń",
|
||||
"material.editor.save_or_discard": "Zapisz lub odrzuć szkic materiału przed przypisaniem materiału.",
|
||||
"material.editor.saved": "Zapisano materiał. Przypisania do brył pozostają bez zmian.",
|
||||
"material.editor.scope": "Zapisz przechowuje ten materiał bez przypisywania go do brył. Puste właściwości i warunki pozostają nieznane. Gęstość z karty nie zmienia przypisanej gęstości ani masy.",
|
||||
|
||||
@@ -2204,6 +2204,7 @@
|
||||
"material.editor.preserved_appearance": "Aparência preservada",
|
||||
"material.editor.public_reference": "Referência pública",
|
||||
"material.editor.reference": "Referência",
|
||||
"material.editor.remove": "Remover",
|
||||
"material.editor.save_or_discard": "Salve ou descarte o rascunho do material antes de atribuir um material.",
|
||||
"material.editor.saved": "Material salvo. As atribuições dos corpos não mudam.",
|
||||
"material.editor.scope": "Salvar armazena este material sem atribuí-lo a corpos. Propriedades e condições em branco permanecem desconhecidas. A densidade da ficha não altera a densidade atribuída nem a massa.",
|
||||
|
||||
@@ -1889,6 +1889,7 @@
|
||||
"material.editor.preserved_appearance": "Aspect păstrat",
|
||||
"material.editor.public_reference": "Referință publică",
|
||||
"material.editor.reference": "Referință",
|
||||
"material.editor.remove": "Elimină",
|
||||
"material.editor.save_or_discard": "Salvați sau renunțați la ciorna materialului înainte de a atribui un material.",
|
||||
"material.editor.saved": "Material salvat. Atribuirile pe corpuri rămân neschimbate.",
|
||||
"material.editor.scope": "Salvarea stochează acest material fără a-l atribui corpurilor. Proprietățile și condițiile lăsate necompletate rămân necunoscute. Densitatea din fișă nu modifică densitatea atribuită și nici masa.",
|
||||
|
||||
@@ -1882,6 +1882,7 @@
|
||||
"material.editor.preserved_appearance": "Сохранённый внешний вид",
|
||||
"material.editor.public_reference": "Общедоступный справочник",
|
||||
"material.editor.reference": "Ссылка",
|
||||
"material.editor.remove": "Убрать",
|
||||
"material.editor.save_or_discard": "Сохраните или отмените черновик материала, прежде чем назначать материал.",
|
||||
"material.editor.saved": "Материал сохранён. Назначения телам не изменились.",
|
||||
"material.editor.scope": "Сохранение записывает этот материал, не назначая его телам. Пустые свойства и условия остаются неизвестными. Плотность в карточке не меняет назначенную плотность и массу.",
|
||||
|
||||
@@ -1889,6 +1889,7 @@
|
||||
"material.editor.preserved_appearance": "Сачувани изглед",
|
||||
"material.editor.public_reference": "Јавна референца",
|
||||
"material.editor.reference": "Референца",
|
||||
"material.editor.remove": "Уклони",
|
||||
"material.editor.save_or_discard": "Сачувајте или одбаците нацрт материјала пре доделе материјала.",
|
||||
"material.editor.saved": "Материјал је сачуван. Доделе телима остају непромењене.",
|
||||
"material.editor.scope": "Сачувај чува овај материјал без доделе телима. Празна својства и услови остају непознати. Густина са картице не мења додељену густину ни масу.",
|
||||
|
||||
@@ -1890,6 +1890,7 @@
|
||||
"material.editor.preserved_appearance": "Bevarat utseende",
|
||||
"material.editor.public_reference": "Offentlig referens",
|
||||
"material.editor.reference": "Referens",
|
||||
"material.editor.remove": "Ta bort",
|
||||
"material.editor.save_or_discard": "Spara eller förkasta materialutkastet innan du tilldelar ett material.",
|
||||
"material.editor.saved": "Materialet sparades. Kropparnas tilldelningar är oförändrade.",
|
||||
"material.editor.scope": "Spara lagrar materialet utan att tilldela det till kroppar. Tomma egenskaper och förhållanden förblir okända. Kortets densitet ändrar inte tilldelad densitet eller massa.",
|
||||
|
||||
@@ -1889,6 +1889,7 @@
|
||||
"material.editor.preserved_appearance": "Korunan görünüm",
|
||||
"material.editor.public_reference": "Herkese açık referans",
|
||||
"material.editor.reference": "Referans",
|
||||
"material.editor.remove": "Kaldır",
|
||||
"material.editor.save_or_discard": "Malzeme atamadan önce malzeme taslağını kaydedin veya atın.",
|
||||
"material.editor.saved": "Malzeme kaydedildi. Gövde atamaları değişmedi.",
|
||||
"material.editor.scope": "Kaydetme, bu malzemeyi gövdelere atamadan saklar. Boş özellikler ve koşullar bilinmez kalır. Kart yoğunluğu, atanan yoğunluğu veya kütleyi değiştirmez.",
|
||||
|
||||
@@ -2199,6 +2199,7 @@
|
||||
"material.editor.preserved_appearance": "Збережений вигляд",
|
||||
"material.editor.public_reference": "Публічний довідник",
|
||||
"material.editor.reference": "Посилання",
|
||||
"material.editor.remove": "Вилучити",
|
||||
"material.editor.save_or_discard": "Збережіть або відхиліть чернетку матеріалу перед призначенням матеріалу.",
|
||||
"material.editor.saved": "Матеріал збережено. Призначення тілам не змінилися.",
|
||||
"material.editor.scope": "Збереження записує цей матеріал, не призначаючи його тілам. Порожні властивості й умови лишаються невідомими. Густина в картці не змінює призначену густину чи масу.",
|
||||
|
||||
@@ -2218,6 +2218,7 @@
|
||||
"material.editor.preserved_appearance": "Bề mặt hiển thị giữ lại",
|
||||
"material.editor.public_reference": "Tài liệu tham khảo công khai",
|
||||
"material.editor.reference": "Tham khảo",
|
||||
"material.editor.remove": "Xóa",
|
||||
"material.editor.save_or_discard": "Lưu hoặc hủy bản nháp vật liệu trước khi gán vật liệu.",
|
||||
"material.editor.saved": "Đã lưu vật liệu. Gán vật liệu cho khối không đổi.",
|
||||
"material.editor.scope": "Lưu sẽ lưu trữ vật liệu này mà không gán cho khối. Các thuộc tính và điều kiện để trống vẫn chưa xác định. Khối lượng riêng trên thẻ không làm thay đổi khối lượng riêng hay khối lượng đã gán.",
|
||||
|
||||
@@ -1896,6 +1896,7 @@
|
||||
"material.editor.preserved_appearance": "保留的外觀",
|
||||
"material.editor.public_reference": "公開參考資料",
|
||||
"material.editor.reference": "參考",
|
||||
"material.editor.remove": "刪除",
|
||||
"material.editor.save_or_discard": "指定材料前,請儲存或放棄材料草稿。",
|
||||
"material.editor.saved": "材料已儲存。實體的材料指定未變。",
|
||||
"material.editor.scope": "儲存材料不會將其指定給實體。空白屬性和條件仍為未知。屬性卡密度不會改變指定密度或質量。",
|
||||
|
||||
@@ -2218,6 +2218,7 @@
|
||||
"material.editor.preserved_appearance": "保留的外观",
|
||||
"material.editor.public_reference": "公开参考资料",
|
||||
"material.editor.reference": "参考",
|
||||
"material.editor.remove": "删除",
|
||||
"material.editor.save_or_discard": "指定材料前,请保存或放弃材料草稿。",
|
||||
"material.editor.saved": "材料已保存。实体的材料指定未变。",
|
||||
"material.editor.scope": "保存材料不会将其指定给实体。空白属性和条件仍为未知。属性卡密度不会改变指定密度或质量。",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1831,6 +1831,26 @@ pub(super) fn line_beyond_reach(mb: &ModelBody, at: Vec3, dir: Vec3, reach: f64)
|
||||
lo > hi
|
||||
}
|
||||
|
||||
/// Whether [`kernel::cylinder_face_at`] is known to find no face of `mb` at `p`. It takes a face
|
||||
/// only when its mesh of the body, made to a chord tolerance of `(size * 1e-3).max(1e-3)`, comes
|
||||
/// within a twentieth of the body's size of the point. That mesh lies within its tolerance of
|
||||
/// the exact faces, so of the body's exact box ([`exact_box`]): a box farther from the point
|
||||
/// than a twentieth of the size and twice that tolerance holds no face for it. Working that out
|
||||
/// from the exact box spares meshing every body in the design for each tap marking (#10227). A
|
||||
/// mesh body, and a body whose box is unknown, are searched as before.
|
||||
pub(super) fn beyond_cylinder_reach(mb: &ModelBody, p: Vec3) -> bool {
|
||||
if mb.body.is_mesh() {
|
||||
return false;
|
||||
}
|
||||
let Some(b) = exact_box(&mb.body) else { return false };
|
||||
let size = mb.body.size();
|
||||
let reach = size * 0.05 + 2.0 * (size * 1e-3).max(1e-3);
|
||||
let off = |at: f64, lo: f64, hi: f64| (lo - at).max(at - hi).max(0.0);
|
||||
let (dx, dy, dz) = (off(p.x, b.min.x, b.max.x), off(p.y, b.min.y, b.max.y), off(p.z, b.min.z, b.max.z));
|
||||
let dist = (dx * dx + dy * dy + dz * dz).sqrt();
|
||||
dist.is_finite() && reach.is_finite() && dist > reach
|
||||
}
|
||||
|
||||
/// Whether two boxes are known to be apart (an unknown box is never apart).
|
||||
pub(super) fn boxes_apart(a: &Option<Aabb3>, b: &Option<Aabb3>) -> bool {
|
||||
let (Some(a), Some(b)) = (a, b) else { return false };
|
||||
|
||||
@@ -192,6 +192,7 @@ pub(super) fn thread_on(st: &ModelState, feature: u64, p: Vec3, ask: &ThreadAsk)
|
||||
.bodies
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, b)| !super::more::beyond_cylinder_reach(b, p))
|
||||
.filter_map(|(i, b)| {
|
||||
#[cfg(test)]
|
||||
super::cost_tests::count(|c| c.cylinder_search += 1);
|
||||
|
||||
@@ -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,10 +12,11 @@ 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, CombineParams, CombineResult, ConeParams, CylinderParams, ExtrudeParams, FeatureNotice,
|
||||
FeatureResult, FilletParams, FilletResult, NoticeAction, OffsetPlaneParams, PipeParams, PlaneAtAngleParams, ScaleParams, SphereParams,
|
||||
TorusParams,
|
||||
AppliedRadius, BoundingSolidParams, BoxParams, ChamferParams, CombineParams, CombineResult, ConeParams, CylinderParams, ExtrudeParams,
|
||||
FeatureNotice, FeatureResult, FilletParams, FilletResult, NoticeAction, OffsetPlaneParams, PipeParams, PlaneAtAngleParams, ScaleParams,
|
||||
SphereParams, TorusParams,
|
||||
};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{EngineError, Result, Session};
|
||||
@@ -27,12 +28,14 @@ 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();
|
||||
const OFFSET_PLANE_CONTRACT: TypedContract<OffsetPlaneParams> = TypedContract::new();
|
||||
const PLANE_AT_ANGLE_CONTRACT: TypedContract<PlaneAtAngleParams> = TypedContract::new();
|
||||
const FILLET_CONTRACT: TypedContract<FilletParams> = TypedContract::new();
|
||||
const CHAMFER_CONTRACT: TypedContract<ChamferParams> = TypedContract::new();
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("solid.extrude", "Extrude", crate::checked!("solid.extrude", extrude))
|
||||
@@ -46,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))
|
||||
@@ -88,7 +93,12 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.contract(&FILLET_CONTRACT)
|
||||
.output(output_schema_for::<FilletResult>)
|
||||
.params("edges: [[x,y,z] point on edge | {body, index}] ; radius: expr; tangent_chain?: bool (default true: each picked edge's tangent chain is rounded with it; false: exactly the picked edges); body?: the body the edges are on (without it, each point is looked up on every placed body, shown or hidden, and a point on edges of 2 bodies is refused, naming them); type?: constant|curvature (alias g2: curvature-continuous quintic between planar faces along straight edges, one planar end face at each end (including sloped ends); radius sets the equivalent circular setback, not constant curvature; exact fit only)|chord (radius is the width across)|variable (radius at the end nearest start, radius2 at the other; one end may be 0, where the round closes to the corner)|spline (the radius along the edge is a clamped B-spline from the end nearest start: radius is its first control radius, radii the rest, knots its knot vector from 0 to 1; one end may be 0); radius2?; start?: [x,y,z]; radii?: [expr, …]; knots?: [number, …]; mid?: [[position 0…1 from start, radius expr], …] (variable radii between the ends); smooth?: bool (smooth instead of straight between the radii); corner?: rolling|g2 (rolling, the default: the corners where three blended edges meet are pieces of the rolling ball's sphere, tangent to each blend; a curvature round's corners are always g2 patches, between its own sections; g2: each corner where three or more rounded edges meet, and every edge there is rounded, is set back (1.5 × the radius along each edge unless `setbacks` names it) and filled with a patch meeting the rounds and the faces round it to the second order (curvature-continuous seams, clear of the points where a round's section ends on a face), constant-radius exact fillets only, refused where it cannot be built); setbacks?: [{vertex: [x,y,z], distance: expr}, …] (corner setbacks: the corner at the vertex nearest `vertex`, where every edge meeting it is rounded, starts its patch `distance` along each of those edges from the vertex, past where the rounds would meet (past the radius at a square corner), so the rounds blend through a larger patch; one distance for all of a corner's edges; constant-radius exact fillets only; with `corner: g2`, the G2 patch's corners); turn?: mitre|swept (where two rounds turn round a sharp, straight, convex side edge square to both on a plane, such as the rounds along a boss's base at its upright edges: mitre, the default, runs them on to meet in a mitre; swept ends them at the side edge and fills the corner with their section revolved about it, a torus; swept takes type: constant, corner: rolling, no setbacks and fit: exact or reduce, and is refused with the reason where the rounds are unequal or the side edge is concave, curved or tangent); fit?: exact|reduce|taper (exact, the default: a radius too large for the geometry around the edges is refused, naming the largest that fits; reduce: where it doesn't fit it is reduced to the largest that does, one radius for edges sharing a face; the smallest used is returned as `radius_applied`, each edge's as `radii_applied: [{edge: [x,y,z], radius}]`, and noted in the feature's warning; taper: along each straight edge between planar faces where it doesn't fit, the radius follows the room there as a smooth spline, down to 0 where a face closes to a point, noted in the feature's warning)"),
|
||||
CommandSpec::new("solid.chamfer", "Chamfer", chamfer).at("SOLID", "MODIFY").icon("chamfer").params("edges: [[x,y,z] | {body, index}] ; distance: expr; tangent_chain?: bool (default true: each picked edge's tangent chain is cut with it; false: exactly the picked edges); distance2?: expr (along the second face) | angle?: expr (from the first face); flip?: bool (which face is first: by default the one facing up most); body?: the body the edges are on (without it, each point is looked up on every placed body, shown or hidden, and a point on edges of 2 bodies is refused, naming them)"),
|
||||
CommandSpec::new("solid.chamfer", "Chamfer", crate::checked!("solid.chamfer", chamfer))
|
||||
.at("SOLID", "MODIFY")
|
||||
.icon("chamfer")
|
||||
.contract(&CHAMFER_CONTRACT)
|
||||
.output(output_schema_for::<FeatureResult>)
|
||||
.params("edges: [[x,y,z] | {body, index}] ; distance: expr; tangent_chain?: bool (default true: each picked edge's tangent chain is cut with it; false: exactly the picked edges); distance2?: expr (along the second face) | angle?: expr (from the first face); flip?: bool (which face is first: by default the one facing up most); body?: the body the edges are on (without it, each point is looked up on every placed body, shown or hidden, and a point on edges of 2 bodies is refused, naming them)"),
|
||||
CommandSpec::new("solid.combine", "Combine", crate::checked!("solid.combine", combine))
|
||||
.at("SOLID", "MODIFY")
|
||||
.icon("combine")
|
||||
@@ -112,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")
|
||||
@@ -2941,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]),
|
||||
@@ -2985,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());
|
||||
@@ -3208,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).
|
||||
@@ -3516,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,
|
||||
@@ -3554,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.
|
||||
@@ -3574,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)),
|
||||
@@ -3602,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);
|
||||
|
||||
@@ -8,14 +8,21 @@ use super::{
|
||||
CommandSpec,
|
||||
features::{add_feature, check_expr},
|
||||
};
|
||||
use crate::schema::features::FeatureResult;
|
||||
use crate::schema::mesh::MeshConvertParams;
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{
|
||||
Result, Sel, Session,
|
||||
params::{bad, expr, str_},
|
||||
};
|
||||
|
||||
const MESH_CONVERT_CONTRACT: TypedContract<MeshConvertParams> = TypedContract::new();
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("mesh.convert", "Convert Mesh", convert)
|
||||
CommandSpec::new("mesh.convert", "Convert Mesh", crate::checked!("mesh.convert", convert))
|
||||
.icon("import")
|
||||
.contract(&MESH_CONVERT_CONTRACT)
|
||||
.output(output_schema_for::<FeatureResult>)
|
||||
.params("body?: mesh body name (default: the selected mesh body); tolerance?: length expression (default 0.05 mm): reconstruct a closed constant-section mechanical mesh as editable planar and cylindrical faces, with a parametric timeline feature in the mesh body's owning component (the active component must be that component or an ancestor; otherwise activate the named component first); the active component is unchanged; name?"),
|
||||
];
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -159,12 +159,23 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
"sketch: id|name; joint? with from and to, or study?; index?; samples? (2…500); auto_update?: false to detach. \
|
||||
Edits a saved associative trace's driving conditions in one undo step",
|
||||
),
|
||||
CommandSpec::new("motion.plot", "Plot Motion Study", analysis::plot).icon("graph").noundo().params(analysis::PLOT_DOC),
|
||||
CommandSpec::new("motion.interference", "Motion Interference", analysis::interference)
|
||||
CommandSpec::new("motion.plot", "Plot Motion Study", crate::checked!("motion.plot", analysis::plot))
|
||||
.contract(&TypedContract::<crate::schema::motion_analysis::PlotParams>::new())
|
||||
.output(output_schema_for::<crate::schema::motion_analysis::MotionPlot>)
|
||||
.icon("graph")
|
||||
.noundo()
|
||||
.params(analysis::PLOT_DOC),
|
||||
CommandSpec::new("motion.interference", "Motion Interference", crate::checked!("motion.interference", analysis::interference))
|
||||
.contract(&TypedContract::<crate::schema::motion_analysis::InterferenceParams>::new())
|
||||
.output(output_schema_for::<crate::schema::motion_analysis::MotionInterference>)
|
||||
.icon("interference")
|
||||
.noundo()
|
||||
.params(analysis::INTERFERENCE_DOC),
|
||||
CommandSpec::new("joint.sweep", "Sweep Joint", sweep::sweep).icon("interference").params(sweep::SWEEP_DOC),
|
||||
CommandSpec::new("joint.sweep", "Sweep Joint", crate::checked!("joint.sweep", sweep::sweep))
|
||||
.icon("interference")
|
||||
.contract(&TypedContract::<crate::schema::joint_sweep::SweepParams>::new())
|
||||
.output(output_schema_for::<crate::schema::joint_sweep::SweepResult>)
|
||||
.params(sweep::SWEEP_DOC),
|
||||
CommandSpec::new("joint.find_limits", "Find Joint Limits", crate::checked!("joint.find_limits", sweep::find_limits))
|
||||
.icon("interference")
|
||||
.noundo()
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
use solidcraft_geom::prelude::*;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use serde_json::{Map, Value, json};
|
||||
use solidcore_record::Vec3;
|
||||
use solidcraft_doc::joints;
|
||||
use solidcraft_doc::motion::MotionStudy;
|
||||
@@ -16,6 +16,7 @@ use solidcraft_geom::Aabb3;
|
||||
use super::contact_sweep::{ContactGeometry, Member};
|
||||
use super::{drive_with_contact, joint_id, keyed, occurrences, path_world, study_index, traced_point, values_at};
|
||||
use crate::params::{bad, bool_, num};
|
||||
use crate::schema::motion_analysis::{CollidingPair, MotionInterference, MotionPlot, PlotSeries, PlottedPoint, StudyStop};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
pub const PLOT_DOC: &str = "study: id|name; joints?: [id|name] (default: every joint that moves); occurrence? and point? (a point \
|
||||
@@ -78,8 +79,8 @@ fn step_of(m: &MotionStudy, k: usize, n: usize) -> f64 {
|
||||
/// Plays study `m` on a copy of the design in `n` samples, each driven from the one before (so
|
||||
/// closed loops follow on their branch, as `motion.play` places them; contact sets stop it only
|
||||
/// when `contact`). `each` sees the copy at every sample and answers whether to go on. Returns
|
||||
/// where the motion stopped short (all null when it did not).
|
||||
fn play(s: &Session, m: &MotionStudy, n: usize, contact: bool, mut each: impl FnMut(&Session, usize) -> Result<bool>) -> Result<Value> {
|
||||
/// where the motion stopped short (all `None` when it did not).
|
||||
fn play(s: &Session, m: &MotionStudy, n: usize, contact: bool, mut each: impl FnMut(&Session, usize) -> Result<bool>) -> Result<Stopped> {
|
||||
let mut t = s.scratch();
|
||||
// Played from where the parts are placed: moves only shown are not where joints are solved.
|
||||
t.pending_moves.clear();
|
||||
@@ -91,14 +92,22 @@ fn play(s: &Session, m: &MotionStudy, n: usize, contact: bool, mut each: impl Fn
|
||||
let want = values_at(&t, m, step_of(m, k, n));
|
||||
let (r, _) = drive_with_contact(&mut t, &want, &given)?;
|
||||
let go = each(&t, k)?;
|
||||
if r["stopped_by_loop"].is_object() || r["stopped_by_contact"].is_object() {
|
||||
return Ok(json!({"stopped_at": k, "stopped_by_loop": r["stopped_by_loop"], "stopped_by_contact": r["stopped_by_contact"]}));
|
||||
let (by_loop, by_contact) = (r["stopped_by_loop"].as_object(), r["stopped_by_contact"].as_object());
|
||||
if by_loop.is_some() || by_contact.is_some() {
|
||||
return Ok(Stopped { stop: StudyStop { stopped_at: Some(k), stopped_by_loop: by_loop.cloned() }, by_contact: by_contact.cloned() });
|
||||
}
|
||||
if !go {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(json!({"stopped_at": null, "stopped_by_loop": null, "stopped_by_contact": null}))
|
||||
Ok(Stopped { stop: StudyStop { stopped_at: None, stopped_by_loop: None }, by_contact: None })
|
||||
}
|
||||
|
||||
/// Where [`play`] stopped short: the replies' `stopped_at` and `stopped_by_loop`, and the
|
||||
/// contact that stopped it (which only a plot reports: interference plays without contact).
|
||||
struct Stopped {
|
||||
stop: StudyStop,
|
||||
by_contact: Option<Map<String, Value>>,
|
||||
}
|
||||
|
||||
/// The derivative of samples `v` spaced `h` apart: central differences inside, second-order
|
||||
@@ -206,12 +215,9 @@ pub(super) fn plot(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let seconds_per_step = duration.map(|d| d / f64::from(m.steps));
|
||||
let h = f64::from(m.steps) / (n - 1) as f64 * seconds_per_step.unwrap_or(1.0);
|
||||
let per = if duration.is_some() { "s" } else { "step" };
|
||||
let steps: Vec<f64> = (0..taken).map(|k| step_of(&m, k, n)).collect();
|
||||
let mut out = json!({"study": m.name, "steps": m.steps, "samples": taken, "step": steps});
|
||||
if let Some(sps) = seconds_per_step {
|
||||
out["time"] = steps.iter().map(|st| st * sps).collect();
|
||||
}
|
||||
let joints_out: Vec<Value> = series
|
||||
let step: Vec<f64> = (0..taken).map(|k| step_of(&m, k, n)).collect();
|
||||
let time = seconds_per_step.map(|sps| step.iter().map(|st| st * sps).collect());
|
||||
let joints_out: Vec<PlotSeries> = series
|
||||
.iter()
|
||||
.zip(values.iter_mut())
|
||||
.map(|((id, i), v)| {
|
||||
@@ -227,13 +233,21 @@ pub(super) fn plot(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
unwrap_degrees(v);
|
||||
}
|
||||
}
|
||||
json!({"joint": id, "name": j.map(|j| j.name.clone()), "index": i, "dof": j.and_then(|j| j.kind.dofs().get(*i).copied()),
|
||||
"unit": unit, "velocity_unit": format!("{unit}/{per}"), "acceleration_unit": format!("{unit}/{per}²"),
|
||||
"value": v, "velocity": derivative(v, h), "acceleration": second_derivative(v, h)})
|
||||
PlotSeries {
|
||||
joint: *id,
|
||||
name: j.map(|j| j.name.clone()),
|
||||
index: *i,
|
||||
dof: j.and_then(|j| j.kind.dofs().get(*i).copied()),
|
||||
unit,
|
||||
velocity_unit: format!("{unit}/{per}"),
|
||||
acceleration_unit: format!("{unit}/{per}²"),
|
||||
velocity: derivative(v, h),
|
||||
acceleration: second_derivative(v, h),
|
||||
value: std::mem::take(v),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
out["joints"] = json!(joints_out);
|
||||
if let Some((occ, _)) = &point {
|
||||
let followed = point.as_ref().map(|(occ, _)| {
|
||||
let axis = |c: usize| -> Vec<f64> { path.iter().map(|q| [q.x, q.y, q.z].get(c).copied().unwrap_or(0.0)).collect() };
|
||||
let d: Vec<(Vec<f64>, Vec<f64>)> = (0..3).map(|c| (derivative(&axis(c), h), second_derivative(&axis(c), h))).collect();
|
||||
let vec_at = |k: usize, second: bool| -> Vec3 {
|
||||
@@ -241,16 +255,33 @@ pub(super) fn plot(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Vec3::new(c(0), c(1), c(2))
|
||||
};
|
||||
let (vel, acc): (Vec<Vec3>, Vec<Vec3>) = (0..path.len()).map(|k| (vec_at(k, false), vec_at(k, true))).unzip();
|
||||
let xyz = |list: &[Vec3]| -> Value { list.iter().map(|v| json!([v.x, v.y, v.z])).collect() };
|
||||
let xyz = |list: &[Vec3]| -> Vec<[f64; 3]> { list.iter().map(|v| [v.x, v.y, v.z]).collect() };
|
||||
let name = s.doc.occurrences.iter().find(|o| Some(&o.id) == occ.last()).map_or_else(|| "root".to_string(), |o| o.name.clone());
|
||||
out["point"] = json!({"occurrence": name, "unit": "mm", "velocity_unit": format!("mm/{per}"), "acceleration_unit": format!("mm/{per}²"),
|
||||
"position": xyz(&path), "velocity": xyz(&vel), "acceleration": xyz(&acc),
|
||||
"speed": vel.iter().map(|v| v.len()).collect::<Vec<_>>(), "acceleration_norm": acc.iter().map(|v| v.len()).collect::<Vec<_>>()});
|
||||
}
|
||||
for k in ["stopped_at", "stopped_by_loop", "stopped_by_contact"] {
|
||||
out[k] = stop[k].clone();
|
||||
}
|
||||
Ok(out)
|
||||
PlottedPoint {
|
||||
occurrence: name,
|
||||
unit: "mm",
|
||||
velocity_unit: format!("mm/{per}"),
|
||||
acceleration_unit: format!("mm/{per}²"),
|
||||
position: xyz(&path),
|
||||
velocity: xyz(&vel),
|
||||
acceleration: xyz(&acc),
|
||||
speed: vel.iter().map(|v| v.len()).collect(),
|
||||
acceleration_norm: acc.iter().map(|v| v.len()).collect(),
|
||||
}
|
||||
});
|
||||
let reply = MotionPlot {
|
||||
study: m.name,
|
||||
steps: m.steps,
|
||||
samples: taken,
|
||||
step,
|
||||
time,
|
||||
joints: joints_out,
|
||||
point: followed,
|
||||
stop: stop.stop,
|
||||
stopped_by_contact: stop.by_contact,
|
||||
param_warnings: Vec::new(),
|
||||
};
|
||||
serde_json::to_value(reply).map_err(|e| EngineError::Other(e.to_string()))
|
||||
}
|
||||
|
||||
/// One pair of parts that collides along a study.
|
||||
@@ -466,7 +497,7 @@ fn interference_within(s: &mut Session, p: &Value, budget: usize, seconds: Optio
|
||||
}
|
||||
let mut list: Vec<(&(u64, u64), &Collision)> = found.iter().collect();
|
||||
list.sort_by_key(|(_, c)| c.samples.first().copied().unwrap_or(usize::MAX));
|
||||
let collisions: Vec<Value> = list
|
||||
let collisions: Vec<CollidingPair> = list
|
||||
.iter()
|
||||
.map(|(_, c)| {
|
||||
let (volume, deepest, not_measured) = match &c.most_volume {
|
||||
@@ -475,23 +506,36 @@ fn interference_within(s: &mut Session, p: &Value, budget: usize, seconds: Optio
|
||||
};
|
||||
let first = c.samples.first().map(|k| step_of(&m, *k, n));
|
||||
let last = c.samples.last().map(|k| step_of(&m, *k, n));
|
||||
json!({"a": c.names.0, "b": c.names.1, "first_step": first, "last_step": last, "steps": ranges(&m, &c.samples, n),
|
||||
"depth_mm": c.depth, "deepest_step": deepest, "volume_mm3": volume, "not_measured": not_measured})
|
||||
CollidingPair {
|
||||
a: c.names.0.clone(),
|
||||
b: c.names.1.clone(),
|
||||
first_step: first,
|
||||
last_step: last,
|
||||
steps: ranges(&m, &c.samples, n),
|
||||
depth_mm: c.depth,
|
||||
deepest_step: deepest,
|
||||
volume_mm3: volume,
|
||||
not_measured,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
// A check cut short says nothing about the steps it did not reach, so it is not clear (#7855).
|
||||
let clear = collisions.is_empty() && !truncated;
|
||||
let mut out = json!({"study": m.name, "steps": m.steps, "clear": clear, "collisions": collisions,
|
||||
"pairs": geometry.pair_count(), "samples": checked, "truncated": truncated});
|
||||
if truncated {
|
||||
out["checked_to"] = json!(checked.checked_sub(1).map(|k| step_of(&m, k, n)));
|
||||
}
|
||||
if let Some(v) = seconds {
|
||||
out["budget_s"] = json!(v);
|
||||
}
|
||||
out["stopped_at"] = stop["stopped_at"].clone();
|
||||
out["stopped_by_loop"] = stop["stopped_by_loop"].clone();
|
||||
Ok(out)
|
||||
let reply = MotionInterference {
|
||||
study: m.name.clone(),
|
||||
steps: m.steps,
|
||||
clear,
|
||||
collisions,
|
||||
pairs: geometry.pair_count(),
|
||||
samples: checked,
|
||||
truncated,
|
||||
// At least one sample was checked (none is refused above), so this is a step.
|
||||
checked_to: if truncated { checked.checked_sub(1).map(|k| step_of(&m, k, n)) } else { None },
|
||||
budget_s: seconds,
|
||||
stop: stop.stop,
|
||||
param_warnings: Vec::new(),
|
||||
};
|
||||
serde_json::to_value(reply).map_err(|e| EngineError::Other(e.to_string()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use serde_json::Value;
|
||||
use solidcore_record::Vec3;
|
||||
use solidcraft_doc::Document;
|
||||
use solidcraft_doc::joints::JointKind;
|
||||
@@ -17,6 +17,7 @@ use super::analysis::checked_pairs;
|
||||
use super::contact_sweep::{ContactGeometry, Gap};
|
||||
use super::{drive_with_contact, joint_id, value};
|
||||
use crate::params::{bad, bool_};
|
||||
use crate::schema::joint_sweep::{NearestGap, SampleGap, SweepResult, SweepSample};
|
||||
use crate::schema::motion::{FindLimitsResult, FoundLimits, Overlap, Stop, Why};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
@@ -87,7 +88,7 @@ struct Swept {
|
||||
/// The last value clear of contact (or where a loop or limit stopped it), and what stopped
|
||||
/// it; `None`: it reached `to`.
|
||||
stop: Option<(f64, Stop)>,
|
||||
samples: Vec<Value>,
|
||||
samples: Vec<SweepSample>,
|
||||
nearest: BTreeMap<String, Nearest>,
|
||||
touching: Vec<[String; 2]>,
|
||||
/// Pairs of parts passing into each other at the start deeper than a touch: (a, b, mm).
|
||||
@@ -168,24 +169,28 @@ pub(super) fn sweep(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
doc.assembly.contact_enabled = s.doc.assembly.contact_enabled;
|
||||
*s.doc_mut() = doc;
|
||||
}
|
||||
let by = |stop: &Stop| serde_json::to_value(stop).map_err(|e| EngineError::Other(e.to_string()));
|
||||
let mut out = json!({
|
||||
"joint": id, "name": name, "index": index, "unit": if angle { "deg" } else { "mm" },
|
||||
"from": shown(from), "to": shown(to),
|
||||
"stopped_at": r.stop.as_ref().map(|(v, _)| shown(*v)),
|
||||
"stopped_by": r.stop.as_ref().map(|(_, stop)| by(stop)).transpose()?,
|
||||
"samples": r.samples,
|
||||
"refine_mm": refine,
|
||||
"gap_method": "mesh", "gap_tolerance_mm": r.chord,
|
||||
"pairs": r.pairs,
|
||||
"touching_at_start": r.touching,
|
||||
"applied": applied,
|
||||
});
|
||||
if report_gaps {
|
||||
out["min_gap"] =
|
||||
r.nearest.into_iter().map(|(k, g)| (k, json!({"mm": g.mm, "at_value": g.at, "to": g.to}))).collect::<serde_json::Map<_, _>>().into();
|
||||
}
|
||||
Ok(out)
|
||||
let (stopped_at, stopped_by) = r.stop.map(|(v, stop)| (shown(v), stop)).unzip();
|
||||
let min_gap = report_gaps.then(|| r.nearest.into_iter().map(|(part, g)| (part, NearestGap { mm: g.mm, at_value: g.at, to: g.to })).collect());
|
||||
let reply = SweepResult {
|
||||
joint: id,
|
||||
name,
|
||||
index,
|
||||
unit: if angle { "deg" } else { "mm" },
|
||||
from: shown(from),
|
||||
to: shown(to),
|
||||
stopped_at,
|
||||
stopped_by,
|
||||
samples: r.samples,
|
||||
refine_mm: refine,
|
||||
gap_method: "mesh",
|
||||
gap_tolerance_mm: r.chord,
|
||||
pairs: r.pairs,
|
||||
touching_at_start: r.touching,
|
||||
applied,
|
||||
min_gap,
|
||||
param_warnings: Vec::new(),
|
||||
};
|
||||
serde_json::to_value(reply).map_err(|e| EngineError::Other(e.to_string()))
|
||||
}
|
||||
|
||||
pub(super) fn find_limits(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -372,18 +377,19 @@ fn run(s: &Session, mut geometry: ContactGeometry, sw: Sweep, cmd: &str) -> Resu
|
||||
let targets: Vec<f64> =
|
||||
(1..=n).map(|k| from + (sample_to - from) * k as f64 / n as f64).chain(((sample_to - to).abs() > 1e-9).then_some(to)).collect();
|
||||
|
||||
let mut samples: Vec<Value> = Vec::new();
|
||||
let mut samples: Vec<SweepSample> = Vec::new();
|
||||
let mut nearest: BTreeMap<String, Nearest> = BTreeMap::new();
|
||||
let mut record = |t: &Session, v: f64, work: &mut usize, geometry: &ContactGeometry| -> Result<()> {
|
||||
if !report_gaps {
|
||||
samples.push(json!({"value": shown(v)}));
|
||||
samples.push(SweepSample { value: shown(v), gap: None });
|
||||
return Ok(());
|
||||
}
|
||||
let gaps = geometry.gaps(t, f64::INFINITY, work, budget)?;
|
||||
match gaps.first() {
|
||||
Some(g) => samples.push(json!({"value": shown(v), "min_gap_mm": g.mm, "pair": [g.a.1, g.b.1]})),
|
||||
None => samples.push(json!({"value": shown(v), "min_gap_mm": null, "pair": null})),
|
||||
}
|
||||
let gap = match gaps.first() {
|
||||
Some(g) => SampleGap { min_gap_mm: Some(g.mm), pair: Some([g.a.1.clone(), g.b.1.clone()]) },
|
||||
None => SampleGap { min_gap_mm: None, pair: None },
|
||||
};
|
||||
samples.push(SweepSample { value: shown(v), gap: Some(gap) });
|
||||
for g in &gaps {
|
||||
for (me, other) in [(&g.a.1, &g.b.1), (&g.b.1, &g.a.1)] {
|
||||
if nearest.get(me).is_none_or(|x| g.mm < x.mm) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,3 +1,7 @@
|
||||
use crate::schema::render_jobs::{
|
||||
ExportFormat, ImageCopied, ImageExported, Job, JobProgressReply, JobState, PreparationCancelled, QueueReply, RenderCancelled, RenderProgress,
|
||||
ResultPinned,
|
||||
};
|
||||
use crate::{Result, Session, params::bad, render_jobs::job_error};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
@@ -868,10 +872,19 @@ struct Regrade {
|
||||
presentation: solidcraft_render::job::presentation::Presentation,
|
||||
}
|
||||
pub(super) fn regrade(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
let input: Regrade = serde_json::from_value(params.clone()).map_err(|e| bad("render.result.regrade", e.to_string()))?;
|
||||
let cmd = "render.result.regrade";
|
||||
let input: Regrade = serde_json::from_value(params.clone()).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
input.presentation.validate().map_err(job_error)?;
|
||||
let job = result_source(s, input.job, "render.result.regrade")?;
|
||||
Ok(json!(s.render_jobs.regrade(job, input.presentation).map_err(job_error)?))
|
||||
let job = result_source(s, input.job, cmd)?;
|
||||
let status = s.render_jobs.regrade(job, input.presentation).map_err(job_error)?;
|
||||
super::typed_reply(cmd, retyped::<JobState>(cmd, &status)?)
|
||||
}
|
||||
|
||||
/// `value`, a render crate type without a schema, as the reply type `T` that describes the same
|
||||
/// JSON (`schema::render_jobs`). The JSON doesn't change; a value `T` can't read back is refused
|
||||
/// rather than sent out unlike its schema.
|
||||
fn retyped<T: serde::de::DeserializeOwned>(cmd: &str, value: &impl serde::Serialize) -> Result<T> {
|
||||
serde_json::to_value(value).and_then(serde_json::from_value).map_err(|e| bad(cmd, e.to_string()))
|
||||
}
|
||||
|
||||
fn result_source(s: &mut Session, job: Option<JobId>, command: &str) -> Result<JobId> {
|
||||
@@ -880,16 +893,15 @@ fn result_source(s: &mut Session, job: Option<JobId>, command: &str) -> Result<J
|
||||
}
|
||||
|
||||
pub(super) fn status(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
let input: ResultJob = serde_json::from_value(params.clone()).map_err(|e| bad("render.status", e.to_string()))?;
|
||||
let cmd = "render.status";
|
||||
let input: ResultJob = serde_json::from_value(params.clone()).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
s.advance_render_batch()?;
|
||||
let Some(job) = input.job else {
|
||||
return Ok(json!({"queue":s.render_queue_state()}));
|
||||
return super::typed_reply(cmd, RenderProgress::Queue(QueueReply { queue: s.render_queue_state(), param_warnings: Vec::new() }));
|
||||
};
|
||||
let mut reply = json!(s.render_jobs.status(job).map_err(job_error)?);
|
||||
if let Some(object) = reply.as_object_mut() {
|
||||
object.insert("queue".into(), json!(s.render_queue_state()));
|
||||
}
|
||||
Ok(reply)
|
||||
// The job's status first: reading it collects finished jobs, which the queue then counts.
|
||||
let state = retyped(cmd, &s.render_jobs.status(job).map_err(job_error)?)?;
|
||||
super::typed_reply(cmd, RenderProgress::Job(Box::new(JobProgressReply { state, queue: s.render_queue_state() })))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -898,9 +910,11 @@ struct Pin {
|
||||
job: Option<JobId>,
|
||||
}
|
||||
pub(super) fn pin(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
let input: Pin = serde_json::from_value(params.clone()).map_err(|e| bad("render.result.pin", e.to_string()))?;
|
||||
let job = if params.get("job").is_some_and(Value::is_null) { None } else { Some(result_source(s, input.job, "render.result.pin")?) };
|
||||
Ok(json!({"frame":s.render_jobs.pin(job).map_err(job_error)?}))
|
||||
let cmd = "render.result.pin";
|
||||
let input: Pin = serde_json::from_value(params.clone()).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
let job = if params.get("job").is_some_and(Value::is_null) { None } else { Some(result_source(s, input.job, cmd)?) };
|
||||
let frame = s.render_jobs.pin(job).map_err(job_error)?.map(|info| retyped(cmd, &info)).transpose()?;
|
||||
super::typed_reply(cmd, ResultPinned { frame, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -923,7 +937,7 @@ pub(super) fn copy(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
if solidcraft_render::RenderLimits::default().fit(size.0, size.1, None) != Some(size) {
|
||||
return Err(bad("render.result.copy", "clipboard image exceeds 4096 per side or 4194304 pixels; export the PNG instead"));
|
||||
}
|
||||
Ok(json!({"frame":frame.info}))
|
||||
super::typed_reply("render.result.copy", ImageCopied { frame: retyped("render.result.copy", &frame.info)?, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
pub(crate) fn cancel_params(params: &Value) -> Result<Option<JobId>> {
|
||||
@@ -933,34 +947,19 @@ pub(crate) fn cancel_params(params: &Value) -> Result<Option<JobId>> {
|
||||
Ok(input.job)
|
||||
}
|
||||
pub(super) fn cancel(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
let cmd = "render.cancel";
|
||||
let requested = cancel_params(params)?;
|
||||
if requested.is_none() && s.cancel_native_preparation() {
|
||||
return Ok(json!({"preparing":true,"cancel_requested":true,"queue":s.render_queue_state()}));
|
||||
let stopped = PreparationCancelled { preparing: true, cancel_requested: true, queue: s.render_queue_state(), param_warnings: Vec::new() };
|
||||
return super::typed_reply(cmd, RenderCancelled::Preparation(stopped));
|
||||
}
|
||||
let job = requested.or(s.render_queue_state().job).ok_or_else(|| bad("render.cancel", "job is required without an active shot queue"))?;
|
||||
let job = requested.or(s.render_queue_state().job).ok_or_else(|| bad(cmd, "job is required without an active shot queue"))?;
|
||||
let status = s.render_jobs.status(job).map_err(job_error)?;
|
||||
s.cancel_render_batch(job);
|
||||
if status.progress.phase.terminal() {
|
||||
return Ok(json!(status));
|
||||
}
|
||||
Ok(json!(s.render_jobs.handle(job).map_err(job_error)?.cancel().map_err(job_error)?))
|
||||
let status = if status.progress.phase.terminal() { status } else { s.render_jobs.handle(job).map_err(job_error)?.cancel().map_err(job_error)? };
|
||||
super::typed_reply(cmd, RenderCancelled::Job(Box::new(retyped(cmd, &status)?)))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum ExportFormat {
|
||||
#[default]
|
||||
Png,
|
||||
Exr,
|
||||
}
|
||||
impl ExportFormat {
|
||||
fn extension(self) -> &'static str {
|
||||
match self {
|
||||
Self::Png => "png",
|
||||
Self::Exr => "exr",
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Export {
|
||||
@@ -992,14 +991,29 @@ pub(super) fn export(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
.linear_exr()
|
||||
.ok_or_else(|| bad("render.export", "this completed job has no raw linear master; enable retention before rendering"))?,
|
||||
};
|
||||
// Typed before the write: a conversion that fails then leaves no file behind.
|
||||
let frame_info = retyped("render.export", &frame.info)?;
|
||||
let linear_master = frame.linear_master_info().map(|info| retyped("render.export", info)).transpose()?;
|
||||
let presentation = frame.presentation().map(|info| retyped("render.export", info)).transpose()?;
|
||||
// Replace atomically without reading an arbitrarily large previous target into memory.
|
||||
// Design saves retain their independent backup policy.
|
||||
let result =
|
||||
if input.overwrite { solidcraft_io::write_atomic(path, bytes, false) } else { solidcraft_io::safe_write::create_atomic(path, bytes) };
|
||||
result.map_err(|e| bad("render.export", format!("could not export completed {}: {e}", input.format.extension())))?;
|
||||
Ok(
|
||||
json!({"job":job,"path":input.path,"bytes":bytes.len(),"format":input.format.extension(),"encoding":if input.format==ExportFormat::Exr {"scene_linear_srgb_d65"} else {"srgb"},"alpha":if input.format==ExportFormat::Exr {"coverage_premultiplied"} else {"straight"},"frame":frame.info,"linear_master":frame.linear_master_info(),"presentation":frame.presentation()}),
|
||||
)
|
||||
let exr = input.format == ExportFormat::Exr;
|
||||
let reply = ImageExported {
|
||||
job: Job(job),
|
||||
bytes: bytes.len(),
|
||||
path: input.path,
|
||||
format: input.format,
|
||||
encoding: if exr { "scene_linear_srgb_d65" } else { "srgb" },
|
||||
alpha: if exr { "coverage_premultiplied" } else { "straight" },
|
||||
frame: frame_info,
|
||||
linear_master,
|
||||
presentation,
|
||||
param_warnings: Vec::new(),
|
||||
};
|
||||
super::typed_reply("render.export", reply)
|
||||
}
|
||||
|
||||
fn validate_studio_weights(weights: Option<[f64; 3]>, command: &str) -> Result<Option<[f64; 3]>> {
|
||||
|
||||
@@ -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,20 +14,36 @@ 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, SurfaceSweepParams};
|
||||
use crate::schema::surface::{FairParams, OffsetParams, ReverseNormalParams, StitchParams, ThickenParams, UnstitchParams, UntrimParams};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{Result, Session};
|
||||
|
||||
const STITCH_CONTRACT: TypedContract<StitchParams> = TypedContract::new();
|
||||
const UNSTITCH_CONTRACT: TypedContract<UnstitchParams> = TypedContract::new();
|
||||
const REVERSE_NORMAL_CONTRACT: TypedContract<ReverseNormalParams> = TypedContract::new();
|
||||
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)
|
||||
.at("SURFACE", "CREATE")
|
||||
.icon("surface_patch")
|
||||
.params("sketch?: id|name and profiles? (planar patches of sketch regions) | edges: [[x,y,z]] (points on a closed loop of a body's edges: one face on them, planar when the loop is, else a smooth B-spline surface) and body?; continuity?: g0|g1|g2|g3|g4 (edges only, default g0: how the patch meets the faces beside the loop — g1 their tangent planes, g2 also their curvature across the edges, g3 also its rate of change, g4 the change of that rate) or [{edge: [x,y,z], g: g0|g1|g2|g3|g4}] (those edges so, the rest g0) or {all, edges}; points?: [[x,y,z]] (edges only, up to 64: points the patch passes through); crown?: number −0.9…3 (default 0: only a fitted patch, whose middle becomes fuller by this many times the loop's size — the bump vanishes on the loop, so the face still meets the picked edges and the continuity across them; a loop that is planar and g0 is the exact plane whatever the crown; with points, crown needs a g1 or higher edge); name?, body_name?"),
|
||||
CommandSpec::new("surface.stitch", "Stitch", stitch)
|
||||
CommandSpec::new("surface.stitch", "Stitch", crate::checked!("surface.stitch", stitch))
|
||||
.at("SURFACE", "MODIFY")
|
||||
.icon("surface_stitch")
|
||||
.contract(&STITCH_CONTRACT)
|
||||
.output(output_schema_for::<FeatureResult>)
|
||||
.params("bodies: [names] (surfaces sewn into the first; a solid when they close); tolerance?: expr (default: tight)"),
|
||||
CommandSpec::new("surface.thicken", "Thicken", thicken)
|
||||
CommandSpec::new("surface.thicken", "Thicken", crate::checked!("surface.thicken", thicken))
|
||||
.at("SURFACE", "CREATE")
|
||||
.icon("surface_thicken")
|
||||
.contract(&THICKEN_CONTRACT)
|
||||
.output(output_schema_for::<FeatureResult>)
|
||||
.params("bodies: [surface names]; thickness: expr (negative: against the normal); symmetric?: bool; operation?: new|join|cut|intersect; targets?"),
|
||||
CommandSpec::new("surface.extend", "Extend", extend)
|
||||
.at("SURFACE", "MODIFY")
|
||||
@@ -45,17 +61,21 @@ 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")
|
||||
.icon("surface_loft")
|
||||
.params("sections: [{sketch, curves?: [curve ids], profiles?} | {body, edges: [[x,y,z]]}] (2…50 in order; each one chain: a profile without holes, or curves meeting end to end; all open or all closed; a body edge chain takes the face the chain bounds as the surface the end repeats); continuity?: [g0|g1|g2|g3, g0|g1|g2|g3] (each end, default g0: how much of the surface at the first and last section the loft repeats — the tangent plane, the curvature, or how that curvature changes); crown?: [number, number] (default 0: how full each end leaves, -0.9 flattest to 3 fullest); rails?, rail_fit? (as solid.loft; sketch sections only, both ends g0); name?, body_name? (a surface body: ruled between two sections, smooth through three or more; no caps)"),
|
||||
CommandSpec::new("surface.offset", "Offset Surface", offset)
|
||||
CommandSpec::new("surface.offset", "Offset Surface", crate::checked!("surface.offset", offset))
|
||||
.at("SURFACE", "CREATE")
|
||||
.icon("surface_offset")
|
||||
.contract(&OFFSET_CONTRACT)
|
||||
.output(output_schema_for::<FeatureResult>)
|
||||
.params("faces: [[x,y,z]] (points on the faces); body?; distance: expr (signed, along the faces' normals; 0 copies the faces); chain?: bool (each pick's whole smooth chain of faces); tolerance?: expr (how far a fitted face may stray, default 0.01 mm). Planes and cylinders move exactly; other faces are fitted. A new surface body (a solid where its faces close)"),
|
||||
CommandSpec::new("surface.ruled", "Ruled Surface", ruled)
|
||||
.at("SURFACE", "CREATE")
|
||||
@@ -65,21 +85,29 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.at("SURFACE", "CREATE")
|
||||
.icon("surface_blend")
|
||||
.params("from, to: {body, edges: [[x,y,z]]} (points on the edges of one chain of each body: two picks on the same edge is that one edge, more chain up); continuity?: [g0|g1|g2|g3, g0|g1|g2|g3] (each end, default g1: how much of the surface at the picked edges the blend repeats — the tangent plane, the curvature, or how that curvature changes); bulge?: [number, number] (default 0: how full each end leaves, -0.9 flattest to 3 fullest); align?: [[x,y,z], [x,y,z]] (a point each end leaves towards, when the faces at the picks do not say); seam?: [[x,y,z], [x,y,z]] (a point on each chain its stations start from: two closed chains otherwise pair the way that keeps the blend from twisting; an open chain starts at the end nearer its point; a closed chain blends only to a closed chain); stitch?: bool (sew the blend onto its two bodies where the boundaries meet, replacing them); name?, body_name?"),
|
||||
CommandSpec::new("surface.untrim", "Untrim", untrim)
|
||||
CommandSpec::new("surface.untrim", "Untrim", crate::checked!("surface.untrim", untrim))
|
||||
.at("SURFACE", "MODIFY")
|
||||
.icon("surface_untrim")
|
||||
.contract(&UNTRIM_CONTRACT)
|
||||
.output(output_schema_for::<FeatureResult>)
|
||||
.params("face: {body, point}; holes?: all|[[x,y,z]] (points on the inner loops to remove; default: all); outer?: bool (restore the outer boundary to the surface's natural bounds: a free-form surface's own, an analytic surface's span of the face); in_place?: bool (untrim a surface body's face where it is; a solid's face is always copied out). A surface body; the face keeps its name"),
|
||||
CommandSpec::new("surface.unstitch", "Unstitch", unstitch)
|
||||
CommandSpec::new("surface.unstitch", "Unstitch", crate::checked!("surface.unstitch", unstitch))
|
||||
.at("SURFACE", "MODIFY")
|
||||
.icon("surface_unstitch")
|
||||
.contract(&UNSTITCH_CONTRACT)
|
||||
.output(output_schema_for::<FeatureResult>)
|
||||
.params("body; faces?: [[x,y,z]] (points on the faces to take out, each its own surface body; the rest stay together under the body's name; default: every face its own surface body). A solid comes apart into surfaces"),
|
||||
CommandSpec::new("surface.reverse_normal", "Reverse Normal", reverse_normal)
|
||||
CommandSpec::new("surface.reverse_normal", "Reverse Normal", crate::checked!("surface.reverse_normal", reverse_normal))
|
||||
.at("SURFACE", "MODIFY")
|
||||
.icon("surface_reverse_normal")
|
||||
.contract(&REVERSE_NORMAL_CONTRACT)
|
||||
.output(output_schema_for::<FeatureResult>)
|
||||
.params("body (a surface); faces?: [[x,y,z]] (points on the faces to turn over; default: all of them)"),
|
||||
CommandSpec::new("surface.fair", "Fair Surface", fair)
|
||||
CommandSpec::new("surface.fair", "Fair Surface", crate::checked!("surface.fair", fair))
|
||||
.at("SURFACE", "MODIFY")
|
||||
.icon("surface_fair")
|
||||
.contract(&FAIR_CONTRACT)
|
||||
.output(output_schema_for::<FeatureResult>)
|
||||
.params("body: surface name; faces?: [[x,y,z]] points on the faces to fair (default: every face); strength?: number (0..1 of the full fairing step, default 1); iterations?: number (1..1000 steps, default 1); keep_edges?: bool (default true: hold the boundary row and the row next to it, so a face keeps meeting its neighbours in position and in tangent); tolerance?: expr mm (how far a control vertex may move, default 0.001 of the body's size). Moves the interior control vertices of each B-spline face to flatten its curvature variation while the boundary stays put: the face keeps its edges and the body its shape within the tolerance, and the warning in the reply carries the energy before and after; name?"),
|
||||
planned!("surface.fillet", "Surface Fillet", "769")
|
||||
.at("SURFACE", "MODIFY")
|
||||
|
||||
@@ -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}");
|
||||
|
||||
@@ -57,15 +57,24 @@ pub(crate) struct BatchShot {
|
||||
pub request: Arc<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize)]
|
||||
/// The shot queue (`render.batch`), as Render Progress and Cancel Render report it. Its schema
|
||||
/// is part of their replies (`schema::render_jobs`).
|
||||
#[derive(Clone, Debug, serde::Serialize, schemars::JsonSchema)]
|
||||
pub struct RenderQueueState {
|
||||
/// How many of this design's jobs are still running.
|
||||
pub running: usize,
|
||||
/// How many shots wait for their turn.
|
||||
pub waiting: usize,
|
||||
/// A real native preparation claim, separate from an invented running job.
|
||||
/// A real native preparation claim, separate from an invented running job. Present, and
|
||||
/// true, only while the queue prepares a shot that has no job yet.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub preparing: bool,
|
||||
/// The queue's current job, or `null`.
|
||||
#[schemars(with = "Option<crate::schema::render_jobs::Job>")]
|
||||
pub job: Option<JobId>,
|
||||
/// The name of the shot that job renders, or `null`.
|
||||
pub shot: Option<String>,
|
||||
/// Why the queue stopped, or `null`.
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
|
||||
@@ -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)]
|
||||
@@ -584,6 +688,38 @@ pub struct SetbackParam {
|
||||
pub distance: LengthExpr,
|
||||
}
|
||||
|
||||
/// Bevel edges of a body with a flat face: `distance` back from each edge along both faces, or
|
||||
/// along the first face with `distance2` along the second, or with the bevel at `angle` from the
|
||||
/// first. Giving both `distance2` and `angle` is the handler's to refuse.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct ChamferParams {
|
||||
/// The edges to bevel: a point on each, or `{body, index}`.
|
||||
pub edges: EdgeRefs,
|
||||
/// How far the bevel cuts back from the edge: along both faces, or along the first face when
|
||||
/// `distance2` or `angle` is given.
|
||||
pub distance: LengthExpr,
|
||||
/// How far the bevel cuts back along the second face. Not with `angle`.
|
||||
#[serde(default)]
|
||||
pub distance2: Option<LengthExpr>,
|
||||
/// The bevel's angle from the first face. Not with `distance2`.
|
||||
#[serde(default)]
|
||||
pub angle: Option<AngleExpr>,
|
||||
/// Which face is first: by default the one facing up most; true takes the other. Default:
|
||||
/// false.
|
||||
#[serde(default)]
|
||||
pub flip: Option<bool>,
|
||||
/// The body whose edges the points pick. Default: whichever body has an edge there.
|
||||
#[serde(default)]
|
||||
pub body: Option<String>,
|
||||
/// Cut each picked edge's tangent chain with it. Default: true; false cuts exactly the picked
|
||||
/// edges.
|
||||
#[serde(default)]
|
||||
pub tangent_chain: Option<bool>,
|
||||
#[serde(flatten, deserialize_with = "feature_common")]
|
||||
pub common: FeatureCommon,
|
||||
}
|
||||
|
||||
/// The radius `fit: reduce` gave one edge.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct AppliedRadius {
|
||||
|
||||
@@ -3,9 +3,10 @@ use std::collections::BTreeSet;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{
|
||||
BoundingSolidParams, BoxParams, CombineParams, CombineResult, ConeParams, CylinderParams, ExtrudeParams, FeatureResult, FilletParams,
|
||||
FilletResult, OffsetPlaneParams, PipeParams, PlaneAtAngleParams, ScaleParams, SphereParams, TorusParams,
|
||||
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};
|
||||
@@ -113,6 +114,10 @@ fn schemas_and_the_key_check_agree() {
|
||||
"type",
|
||||
],
|
||||
);
|
||||
assert_keys(
|
||||
input_schema_for::<ChamferParams>(),
|
||||
&["angle", "body", "body_name", "body_names", "distance", "distance2", "edges", "flip", "name", "participants", "tangent_chain"],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -222,6 +227,17 @@ fn strict_keeps_todays_errors() {
|
||||
("solid.combine", json!({"target": "A", "tools": ["B"], "operation": "new"})),
|
||||
("solid.combine", json!({"target": "A", "tools": ["B"], "operation": "bogus"})),
|
||||
("solid.combine", json!({"target": "A", "tools": ["B"]})),
|
||||
// #12884: Chamfer's edges and distances; giving both `distance2` and `angle`, and a
|
||||
// distance that doesn't evaluate as a length, stay the handler's to refuse.
|
||||
("solid.chamfer", json!({"edges": [], "distance": 1})),
|
||||
("solid.chamfer", json!({"edges": 5, "distance": 1})),
|
||||
("solid.chamfer", json!({"edges": [[1, 2]], "distance": 1})),
|
||||
("solid.chamfer", json!({"edges": [[0, 0, 0]], "distance": true})),
|
||||
("solid.chamfer", json!({"edges": [[0, 0, 0]], "distance": " "})),
|
||||
("solid.chamfer", json!({"edges": [[0, 0, 0]], "distance": 1e9})),
|
||||
("solid.chamfer", json!({"edges": [[0, 0, 0]], "distance": "no_such_parameter"})),
|
||||
("solid.chamfer", json!({"edges": [[0, 0, 0]], "distance": 1, "angle": "5 mm"})),
|
||||
("solid.chamfer", json!({"edges": [[0, 0, 0]], "distance": 1, "distance2": 1, "angle": 30})),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -294,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))] {
|
||||
@@ -388,6 +719,65 @@ fn fillet_replies_match_its_output_schema() {
|
||||
run(json!({"edges": [[20, 0, 10], [40, 15, 10], [40, 0, 5]], "radius": 2, "corner": "g2", "setbacks": [{"vertex": [40, 0, 10], "distance": 4}]}));
|
||||
}
|
||||
|
||||
/// #12884: Chamfer's contract takes what its input schema allows: each call's check finds no issue
|
||||
/// exactly when the call matches the schema. Both `distance2` and `angle` match it; the handler
|
||||
/// refuses them together.
|
||||
#[test]
|
||||
fn chamfer_check_takes_what_the_schema_allows() {
|
||||
let schema = input_schema_for::<ChamferParams>();
|
||||
let schema = schema.as_value();
|
||||
for (p, takes) in [
|
||||
(json!({"edges": [[0, 0, 0]], "distance": 1}), true),
|
||||
(json!({"edges": [{"body": "B", "index": 0}], "distance": "2 mm", "distance2": 3}), true),
|
||||
(json!({"edges": [[0, 0, 0]], "distance": 1, "angle": "30 deg", "flip": true, "tangent_chain": false, "body": "B", "name": "Bevel"}), true),
|
||||
(json!({"edges": [[0, 0, 0]], "distance": 1, "distance2": null, "angle": null, "flip": null, "body": null, "tangent_chain": null}), true),
|
||||
(json!({"edges": [[0, 0, 0]], "distance": 1, "distance2": 1, "angle": 30}), true),
|
||||
(json!({"edges": [[0, 0, 0]]}), false),
|
||||
(json!({"distance": 1}), false),
|
||||
(json!({"edges": [], "distance": 1}), false),
|
||||
(json!({"edges": [[0, 0, 0]], "distance": true}), false),
|
||||
(json!({"edges": [[0, 0, 0]], "distance": 1, "distance2": true}), false),
|
||||
(json!({"edges": [[0, 0, 0]], "distance": 1, "angle": [30]}), false),
|
||||
(json!({"edges": [[0, 0, 0]], "distance": 1, "flip": "yes"}), false),
|
||||
(json!({"edges": [[0, 0, 0]], "distance": 1, "tangent_chain": 1}), false),
|
||||
(json!({"edges": [[0, 0, 0]], "distance": 1, "body": 5}), false),
|
||||
(json!({"edges": [[0, 0, 0]], "distance": 1, "colour": "red"}), false),
|
||||
] {
|
||||
let issues = crate::schema::issues("solid.chamfer", &p);
|
||||
assert_eq!(issues.is_empty(), takes, "{p}: {issues:?}");
|
||||
assert_eq!(conforms(schema, schema, &p), takes, "{p}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #12884: Chamfer replies with `FeatureResult`, with the keys it gave before, in each of its
|
||||
/// forms: one distance, two, a distance and an angle, flipped, an edge by index, and every
|
||||
/// optional key `null`. In Warn, without warnings, so its contract takes each call.
|
||||
#[test]
|
||||
fn chamfer_replies_with_feature_result() {
|
||||
let schema = output_schema_for::<FeatureResult>();
|
||||
let schema = schema.as_value();
|
||||
let before = BTreeSet::from(["feature", "name", "bodies", "created_bodies", "changed_bodies", "removed_bodies", "warning", "ms"]);
|
||||
// A 40 x 30 x 10 plate with a 10 x 10 x 5 boss on it, whose top edges `boss` are.
|
||||
let run = |p: Value| {
|
||||
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
|
||||
s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 10, "body_name": "B"})).unwrap_or_else(|e| panic!("{e}"));
|
||||
s.execute("solid.box", &json!({"corner": [15, 10, 10], "length": 10, "width": 10, "height": 5, "operation": "join"}))
|
||||
.unwrap_or_else(|e| panic!("{e}"));
|
||||
let r = s.execute("solid.chamfer", &p).unwrap_or_else(|e| panic!("{p}: {e}"));
|
||||
assert!(conforms(schema, schema, &r), "{r} doesn't match {schema}");
|
||||
assert!(r.get("param_warnings").is_none(), "{p}: {r}");
|
||||
let keys: BTreeSet<&str> = r.as_object().into_iter().flatten().map(|(k, _)| k.as_str()).collect();
|
||||
assert_eq!(keys, before, "{p}: {r}");
|
||||
assert_eq!(r["changed_bodies"], json!(["B"]), "{p}: {r}");
|
||||
};
|
||||
let boss = json!([[20, 10, 15], [25, 15, 15], [20, 20, 15], [15, 15, 15]]);
|
||||
run(json!({"edges": [[20, 0, 10]], "distance": 1}));
|
||||
run(json!({"edges": boss, "distance": 1, "distance2": "0.5 mm", "flip": true, "tangent_chain": true, "body": "B", "name": "Bevel"}));
|
||||
run(json!({"edges": [[40, 15, 10]], "distance": "1 mm", "angle": "30 deg"}));
|
||||
run(json!({"edges": [{"body": "B", "index": 0}], "distance": 0.5, "tangent_chain": false}));
|
||||
run(json!({"edges": [[20, 30, 10]], "distance": 1, "distance2": null, "angle": null, "flip": null, "body": null, "tangent_chain": null}));
|
||||
}
|
||||
|
||||
/// The feature commands converted after box and extrude answer with `FeatureResult` too, and
|
||||
/// their contracts take every form these calls use: in Warn, a contract that refused one would
|
||||
/// still run the command, so the replies are checked for warnings too.
|
||||
|
||||
@@ -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:?}");
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
//! Contract and reply type for Sweep Joint (`joint.sweep`, #12844), whose handler is `sweep` in
|
||||
//! `cmd/motion_sweep.rs`. It drives one value of a joint from `from` toward `to` on a copy of the
|
||||
//! design and stops at the first contact between the parts it checks.
|
||||
//!
|
||||
//! It takes the keys of Find Joint Limits (`FindLimitsParams` in `schema/motion.rs`), whose
|
||||
//! `JointRef`, `PartRef` and `Stop` it reuses, plus `from`, `to`, `report_gaps` and `apply`. The
|
||||
//! handler reads its parameters from the raw JSON, so `from`, `to`, `refine` and `step` read here
|
||||
//! as it reads them and fail with its message. Whether the joint exists and moves, and whether
|
||||
//! there are two parts to check, stay the handler's to check.
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
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::{JointRef, PartRef, Stop, require};
|
||||
|
||||
/// The largest `refine` the handler takes, in mm (`positive(p, "refine", 1e3, …)`).
|
||||
const MAX_REFINE_MM: f64 = 1e3;
|
||||
/// The largest `step` the handler takes, in the value's unit (`positive(p, "step", 1e9, …)`).
|
||||
const MAX_STEP: f64 = 1e9;
|
||||
|
||||
/// Drive one value of a joint from `from` toward `to` on a copy of the design, and stop at the
|
||||
/// first contact between the parts checked (bisected to `refine`), where a closed loop can't
|
||||
/// close, or at a limit. Closed loops follow; contact sets don't stop it. The design is not
|
||||
/// changed unless `apply` is true.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = from_and_to_required)]
|
||||
pub struct SweepParams {
|
||||
pub joint: JointRef,
|
||||
/// Which value (0 for a revolute's angle). Default: 0.
|
||||
#[serde(default)]
|
||||
pub index: Option<u64>,
|
||||
// `from` and `to` are optional in the type and required in the schema
|
||||
// (`from_and_to_required`): left out, the handler refuses the call with its own message,
|
||||
// which names both, not the check's "is required".
|
||||
/// Where the sweep starts.
|
||||
#[serde(default, deserialize_with = "joint_value", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "JointValue")]
|
||||
pub from: Option<JointValue>,
|
||||
/// Where the sweep heads.
|
||||
#[serde(default, deserialize_with = "joint_value", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "JointValue")]
|
||||
pub to: Option<JointValue>,
|
||||
/// mm: the first contact is bisected until no part moves more than this between the last
|
||||
/// clear value and the touching one (above 0, at most 1000). Default: 0.01.
|
||||
#[serde(default, deserialize_with = "refine")]
|
||||
#[schemars(schema_with = "refine_schema")]
|
||||
pub refine: Option<f64>,
|
||||
/// Between samples, in the value's unit (degrees for angles, mm for distances; above 0, at
|
||||
/// most 1e9). Default: fine enough that no part passes through another between two.
|
||||
#[serde(default, deserialize_with = "step")]
|
||||
#[schemars(schema_with = "step_schema")]
|
||||
pub step: Option<f64>,
|
||||
/// The parts checked against each other. Default: every occurrence.
|
||||
#[serde(default, deserialize_with = "super::present", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "Vec<PartRef>")]
|
||||
pub occurrences: Option<Vec<PartRef>>,
|
||||
/// The parts that stay: each of `occurrences` is checked only against these.
|
||||
#[serde(default)]
|
||||
pub against: Option<Vec<PartRef>>,
|
||||
/// Check the pairs of the design's contact sets instead (not with `occurrences`).
|
||||
#[serde(default)]
|
||||
pub contact_sets: Option<bool>,
|
||||
/// Report the nearest gap at every sample, and the nearest each part came to another
|
||||
/// (`min_gap`). Default: false.
|
||||
#[serde(default)]
|
||||
pub report_gaps: Option<bool>,
|
||||
/// Leave the design where the sweep stopped, in one undo step. Default: false: the design
|
||||
/// is not changed.
|
||||
#[serde(default)]
|
||||
pub apply: Option<bool>,
|
||||
}
|
||||
|
||||
fn from_and_to_required(schema: &mut Schema) {
|
||||
require(schema, "from");
|
||||
require(schema, "to");
|
||||
}
|
||||
|
||||
/// A joint value: a number (degrees for an angle, mm for a distance) or an expression, as
|
||||
/// `cmd/joints.rs`'s `value` reads it. Whether an expression evaluates is the handler's to check.
|
||||
#[derive(Debug)]
|
||||
pub struct JointValue;
|
||||
|
||||
impl JsonSchema for JointValue {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"JointValue".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "A joint value: a number, in degrees for an angle and in mm for a distance, smaller than 1e9 in size, \
|
||||
or an expression (`\"90 deg\"`, `\"Opening\"`).",
|
||||
"type": ["number", "string"],
|
||||
"exclusiveMinimum": -1e9,
|
||||
"exclusiveMaximum": 1e9,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// `from` or `to`: a number smaller than 1e9 in size, or text, with the handler's message for
|
||||
/// anything else, `null` included.
|
||||
fn joint_value<'de, D: Deserializer<'de>>(d: D) -> Result<Option<JointValue>, D::Error> {
|
||||
let v = Value::deserialize(d)?;
|
||||
let taken = match &v {
|
||||
Value::Number(n) => n.as_f64().is_some_and(|x| x.is_finite() && x.abs() < 1e9),
|
||||
Value::String(_) => true,
|
||||
_ => false,
|
||||
};
|
||||
if taken { Ok(Some(JointValue)) } else { Err(D::Error::custom(whole(format!("a joint value {}", crate::params::expr_problem(Some(&v)))))) }
|
||||
}
|
||||
|
||||
/// A number above 0, at most `max`, or `null` (left out), as the handler's `positive` reads it,
|
||||
/// with its message (the check names the key).
|
||||
fn positive<E: serde::de::Error>(v: Value, max: f64) -> Result<Option<f64>, E> {
|
||||
match v {
|
||||
Value::Null => Ok(None),
|
||||
v => v
|
||||
.as_f64()
|
||||
.filter(|x| x.is_finite() && *x > 0.0 && *x <= max)
|
||||
.map(Some)
|
||||
.ok_or_else(|| E::custom(format!("must be a number above 0, at most {max}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn refine<'de, D: Deserializer<'de>>(d: D) -> Result<Option<f64>, D::Error> {
|
||||
positive(Value::deserialize(d)?, MAX_REFINE_MM)
|
||||
}
|
||||
|
||||
fn step<'de, D: Deserializer<'de>>(d: D) -> Result<Option<f64>, D::Error> {
|
||||
positive(Value::deserialize(d)?, MAX_STEP)
|
||||
}
|
||||
|
||||
/// A number above 0, at most `max`, or `null`.
|
||||
fn positive_schema(description: &str, max: f64) -> Schema {
|
||||
json_schema!({
|
||||
"description": description,
|
||||
"type": ["number", "null"],
|
||||
"exclusiveMinimum": 0,
|
||||
"maximum": max,
|
||||
})
|
||||
}
|
||||
|
||||
fn refine_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
positive_schema(
|
||||
"mm: the first contact is bisected until no part moves more than this between the last clear value and the touching one \
|
||||
(above 0, at most 1000). Default: 0.01.",
|
||||
MAX_REFINE_MM,
|
||||
)
|
||||
}
|
||||
|
||||
fn step_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
positive_schema(
|
||||
"Between samples, in the value's unit (degrees for angles, mm for distances; above 0, at most 1e9). Default: fine enough \
|
||||
that no part passes through another between two.",
|
||||
MAX_STEP,
|
||||
)
|
||||
}
|
||||
|
||||
/// The nearest gap at one sample, with `report_gaps`.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct SampleGap {
|
||||
/// The smallest gap between two parts checked, in mm, or `null` when no two come near.
|
||||
pub min_gap_mm: Option<f64>,
|
||||
/// The two parts with that gap, or `null`.
|
||||
pub pair: Option<[String; 2]>,
|
||||
}
|
||||
|
||||
/// One sample of the sweep.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct SweepSample {
|
||||
/// The joint value, in `unit`.
|
||||
pub value: f64,
|
||||
/// With `report_gaps` only.
|
||||
#[serde(flatten)]
|
||||
pub gap: Option<SampleGap>,
|
||||
}
|
||||
|
||||
/// The nearest one part came to another along the sweep.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct NearestGap {
|
||||
/// The gap, in mm.
|
||||
pub mm: f64,
|
||||
/// The joint value where it was, in `unit`.
|
||||
pub at_value: f64,
|
||||
/// The other part.
|
||||
pub to: String,
|
||||
}
|
||||
|
||||
/// What `joint.sweep` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct SweepResult {
|
||||
/// The joint's id.
|
||||
pub joint: u64,
|
||||
pub name: String,
|
||||
/// Which value was swept.
|
||||
pub index: usize,
|
||||
/// `deg` for an angle, `mm` for a distance: the unit of `from`, `to`, `stopped_at` and the
|
||||
/// samples.
|
||||
pub unit: &'static str,
|
||||
pub from: f64,
|
||||
pub to: f64,
|
||||
/// The last value clear of contact, or where a loop or limit stopped it; `null`: it reached
|
||||
/// `to`.
|
||||
pub stopped_at: Option<f64>,
|
||||
/// What stopped it (two parts touching, a loop or a limit), or `null`.
|
||||
pub stopped_by: Option<Stop>,
|
||||
/// The values tested, `from` excluded, before the bisection.
|
||||
pub samples: Vec<SweepSample>,
|
||||
pub refine_mm: f64,
|
||||
/// `mesh`: distances between the parts' tessellations.
|
||||
pub gap_method: &'static str,
|
||||
/// How far those can be off: 0 when every face is flat.
|
||||
pub gap_tolerance_mm: f64,
|
||||
/// How many pairs of parts were checked.
|
||||
pub pairs: usize,
|
||||
/// Pairs of parts touching at `from`, such as parts joined face to face: they stop it only
|
||||
/// once they pass into each other deeper than they start.
|
||||
pub touching_at_start: Vec<[String; 2]>,
|
||||
/// Whether the design was left where the sweep stopped (`apply`, and it moved).
|
||||
pub applied: bool,
|
||||
/// With `report_gaps` only: by part name, the nearest it came to another.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub min_gap: Option<BTreeMap<String, NearestGap>>,
|
||||
/// 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 = "joint_sweep_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,160 @@
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{SweepParams, SweepResult};
|
||||
use crate::Session;
|
||||
use crate::schema::tests::{assert_keys, conforms};
|
||||
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
|
||||
|
||||
/// A 100 × 4 × 50 door hinged about Z at the origin (joint `Hinge`), with a 20 × 20 block from
|
||||
/// (60, 30) in its way, as in `cmd/motion_sweep_tests.rs`.
|
||||
fn door() -> Session {
|
||||
let mut s = Session::default();
|
||||
for (name, corner, size) in [("Door", [0, 0, 0], [100, 4, 50]), ("Block", [60, 30, 0], [20, 20, 50])] {
|
||||
for (id, p) in [
|
||||
("component.activate", json!({"component": "root"})),
|
||||
("component.create", json!({"name": name, "grounded": false})),
|
||||
("solid.box", json!({"length": size[0], "width": size[1], "height": size[2], "corner": corner})),
|
||||
] {
|
||||
s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}"));
|
||||
}
|
||||
}
|
||||
let hinge = json!({"type": "revolute", "a": {"occurrence": 0, "point": [0, 0, 0]}, "b": {"occurrence": "Door", "point": [0, 0, 0]},
|
||||
"flip": true, "name": "Hinge"});
|
||||
s.execute("joint.create", &hinge).unwrap_or_else(|e| panic!("{e}"));
|
||||
s
|
||||
}
|
||||
|
||||
/// `p` with the door's sweep from 0 to 90 degrees added.
|
||||
fn swept(mut p: Value) -> Value {
|
||||
for (key, value) in [("joint", json!("Hinge")), ("from", json!(0)), ("to", json!(90))] {
|
||||
if p.get(key).is_none() {
|
||||
p[key] = value;
|
||||
}
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schemas_and_the_key_check_agree() {
|
||||
assert_keys(
|
||||
input_schema_for::<SweepParams>(),
|
||||
&["against", "apply", "contact_sets", "from", "index", "joint", "occurrences", "refine", "report_gaps", "step", "to"],
|
||||
);
|
||||
let schema = input_schema_for::<SweepParams>();
|
||||
let required: Vec<&str> = schema.as_value()["required"].as_array().into_iter().flatten().filter_map(Value::as_str).collect();
|
||||
assert_eq!(required, ["joint", "from", "to"]);
|
||||
}
|
||||
|
||||
/// #12844: calls that fail today get the same message in strict: the handler's for `from` or
|
||||
/// `to` left out (the schema requires them, and the type leaves them to the handler), for a joint
|
||||
/// value that isn't a number in range or an expression, and for `refine` or `step` out of range,
|
||||
/// and the handler's own for a joint, value or parts it can't use. It needs the door's hinge, so
|
||||
/// the shared helper (on a sketch) doesn't fit.
|
||||
#[test]
|
||||
fn strict_keeps_todays_errors() {
|
||||
for p in [
|
||||
json!({"joint": "Hinge"}),
|
||||
json!({"joint": "Hinge", "from": 0}),
|
||||
json!({"joint": true, "from": 0, "to": 90}),
|
||||
json!({"joint": null, "from": 0, "to": 90}),
|
||||
json!({"joint": "Nope", "from": 0, "to": 90}),
|
||||
swept(json!({"from": null})),
|
||||
swept(json!({"to": true})),
|
||||
swept(json!({"to": [90]})),
|
||||
swept(json!({"from": 2e9})),
|
||||
swept(json!({"to": "Opening"})),
|
||||
swept(json!({"refine": 0})),
|
||||
swept(json!({"refine": "fine"})),
|
||||
swept(json!({"refine": 2000})),
|
||||
swept(json!({"step": -1})),
|
||||
swept(json!({"step": 1e10})),
|
||||
swept(json!({"step": 1e-6})),
|
||||
swept(json!({"occurrences": ["Door"]})),
|
||||
swept(json!({"occurrences": ["Door", "Block"], "contact_sets": true})),
|
||||
swept(json!({"against": ["Block"]})),
|
||||
] {
|
||||
let error = |level: ParamCheck| {
|
||||
let mut s = door();
|
||||
s.param_check = level;
|
||||
s.execute("joint.sweep", &p).err().map(|e| e.to_string())
|
||||
};
|
||||
let off = error(ParamCheck::Off);
|
||||
assert!(off.is_some(), "{p} should fail today");
|
||||
assert_eq!(error(ParamCheck::Strict), off, "{p}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #12844: the contract takes what the input schema allows: each call's check finds no issue
|
||||
/// exactly when it matches the schema. (`from` and `to` left out are the exception, on purpose:
|
||||
/// see `strict_keeps_todays_errors`.)
|
||||
#[test]
|
||||
fn the_check_takes_what_the_schema_allows() {
|
||||
let schema = input_schema_for::<SweepParams>();
|
||||
let schema = schema.as_value();
|
||||
for (p, takes) in [
|
||||
(swept(json!({})), true),
|
||||
(json!({"joint": 1, "from": "0 deg", "to": "Opening"}), true),
|
||||
(swept(json!({"refine": null, "step": null, "index": null})), true),
|
||||
(swept(json!({"refine": 1000, "step": 1e9})), true),
|
||||
(swept(json!({"index": 0, "occurrences": ["Door", 2], "against": ["Block"], "report_gaps": true, "apply": false})), true),
|
||||
(swept(json!({"contact_sets": true})), true),
|
||||
(json!({"from": 0, "to": 90}), false),
|
||||
(swept(json!({"joint": [1]})), false),
|
||||
(swept(json!({"from": null})), false),
|
||||
(swept(json!({"to": 1e9})), false),
|
||||
(swept(json!({"to": -1e9})), false),
|
||||
(swept(json!({"refine": 0})), false),
|
||||
(swept(json!({"refine": 1001})), false),
|
||||
(swept(json!({"step": 0})), false),
|
||||
(swept(json!({"step": "1"})), false),
|
||||
(swept(json!({"index": -1})), false),
|
||||
(swept(json!({"occurrences": null})), false),
|
||||
(swept(json!({"apply": "yes"})), false),
|
||||
] {
|
||||
let issues = crate::schema::issues("joint.sweep", &p);
|
||||
assert_eq!(issues.is_empty(), takes, "{p}: {issues:?}");
|
||||
assert_eq!(conforms(schema, schema, &p), takes, "{p}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #12844: Sweep Joint replies as its output schema says, in Warn without warnings: a sweep
|
||||
/// stopped by the block, one with the gaps reported, one that reaches `to` (an expression), and
|
||||
/// one applied to the design. The samples carry gaps, and the reply `min_gap`, only with
|
||||
/// `report_gaps`.
|
||||
#[test]
|
||||
fn sweep_joint_replies_with_its_schema() {
|
||||
let schema = output_schema_for::<SweepResult>();
|
||||
let schema = schema.as_value();
|
||||
let mut s = door();
|
||||
s.param_check = ParamCheck::Warn;
|
||||
let sweep = |s: &mut Session, p: Value| {
|
||||
let r = s.execute("joint.sweep", &p).unwrap_or_else(|e| panic!("{p}: {e}"));
|
||||
assert!(conforms(schema, schema, &r), "{r} doesn't match {schema}");
|
||||
assert!(r.get("param_warnings").is_none(), "{r}");
|
||||
r
|
||||
};
|
||||
let stopped = sweep(&mut s, swept(json!({})));
|
||||
assert!(stopped["stopped_by"]["a"].is_string() && stopped["stopped_at"].is_number(), "{stopped}");
|
||||
assert_eq!((&stopped["unit"], &stopped["applied"]), (&json!("deg"), &json!(false)), "{stopped}");
|
||||
assert!(stopped.get("min_gap").is_none(), "{stopped}");
|
||||
let samples = stopped["samples"].as_array().cloned().unwrap_or_default();
|
||||
assert!(!samples.is_empty() && samples.iter().all(|x| x.as_object().is_some_and(|o| o.len() == 1 && o["value"].is_number())), "{stopped}");
|
||||
|
||||
let gaps = sweep(&mut s, swept(json!({"report_gaps": true, "refine": 0.1})));
|
||||
assert!(gaps["min_gap"]["Door:1"]["mm"].is_number(), "{gaps}");
|
||||
let keys = |x: &Value| {
|
||||
let mut keys = x.as_object().map(|o| o.keys().cloned().collect::<Vec<_>>()).unwrap_or_default();
|
||||
keys.sort();
|
||||
keys
|
||||
};
|
||||
assert!(gaps["samples"].as_array().is_some_and(|all| all.iter().all(|x| keys(x) == ["min_gap_mm", "pair", "value"])), "{gaps}");
|
||||
|
||||
let reached = sweep(&mut s, json!({"joint": "Hinge", "from": 0, "to": "10 deg", "step": 5}));
|
||||
assert_eq!((&reached["stopped_at"], &reached["stopped_by"]), (&Value::Null, &Value::Null), "{reached}");
|
||||
assert!(reached["to"].as_f64().is_some_and(|to| (to - 10.0).abs() < 1e-9), "{reached}");
|
||||
|
||||
let before = s.doc.clone();
|
||||
let applied = sweep(&mut s, json!({"joint": "Hinge", "from": 0, "to": 10, "step": 5, "apply": true}));
|
||||
assert_eq!(applied["applied"], true, "{applied}");
|
||||
assert_ne!(*s.doc, *before, "the hinge was left at 10 degrees");
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
//! Contracts for the commands in `cmd/mesh.rs`. Convert Mesh replies with the plain feature
|
||||
//! reply, [`super::features::FeatureResult`].
|
||||
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::types::{LengthExpr, StringList};
|
||||
|
||||
/// Rebuild a closed mesh body as an editable solid, with a parametric feature in the timeline.
|
||||
/// The solid keeps the mesh body's name and colour.
|
||||
///
|
||||
/// Whether the body exists and is a mesh, and whether the active component may edit it, stay the
|
||||
/// handler's to check. The kernel checks the tolerance's range when the feature evaluates, so an
|
||||
/// expression is taken here whatever its value.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct MeshConvertParams {
|
||||
/// The mesh body, by name. Default: the one selected body. A blank name also means the
|
||||
/// selected body, as the handler reads it.
|
||||
#[serde(default)]
|
||||
pub body: Option<String>,
|
||||
/// How far the rebuilt faces may stray from the mesh's triangles: a run of facets within it of
|
||||
/// an arc becomes a cylinder. From 0.000001 mm to 1 mm. Default: 0.05 mm.
|
||||
#[serde(default)]
|
||||
pub tolerance: Option<LengthExpr>,
|
||||
// Not `FeatureCommon`: the solid keeps the mesh body's name, so `body_name` and `body_names`
|
||||
// would do nothing. `participants` stays because `timeline.redefine` passes a feature's own
|
||||
// back.
|
||||
/// The feature's name in the timeline.
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
/// The bodies taking part, which every feature records; each an existing body or `*`. A
|
||||
/// conversion changes only `body`, whatever this lists.
|
||||
#[serde(default)]
|
||||
pub participants: Option<StringList>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "mesh_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,116 @@
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::MeshConvertParams;
|
||||
use crate::Sel;
|
||||
use crate::schema::features::FeatureResult;
|
||||
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms};
|
||||
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
|
||||
use crate::tests::mesh_convert::imported_box;
|
||||
|
||||
#[test]
|
||||
fn schemas_and_the_key_check_agree() {
|
||||
assert_keys(input_schema_for::<MeshConvertParams>(), &["body", "name", "participants", "tolerance"]);
|
||||
}
|
||||
|
||||
/// The handler finds the body before it reads the tolerance, so on a design with no mesh body
|
||||
/// each of these fails on the body, the same way in strict. A blank or `null` body means the
|
||||
/// selected one, and nothing is selected.
|
||||
#[test]
|
||||
fn strict_keeps_todays_errors() {
|
||||
assert_strict_keeps_todays_errors(&[
|
||||
("mesh.convert", json!({})),
|
||||
("mesh.convert", json!({"body": null})),
|
||||
("mesh.convert", json!({"body": " "})),
|
||||
("mesh.convert", json!({"body": "Nope"})),
|
||||
("mesh.convert", json!({"body": "Nope", "tolerance": "nonsense"})),
|
||||
("mesh.convert", json!({"body": "Nope", "tolerance": 0.01, "name": "Rebuilt", "participants": ["*"]})),
|
||||
]);
|
||||
}
|
||||
|
||||
/// The error a call gets on the imported box with a solid box `Solid` beside it, at `level`.
|
||||
fn conversion_error(level: ParamCheck, p: &Value) -> Option<String> {
|
||||
let mut s = imported_box();
|
||||
s.param_check = level;
|
||||
let solid = json!({"length": 5, "width": 5, "height": 5, "corner": [50, 0, 0], "body_name": "Solid"});
|
||||
s.execute("solid.box", &solid).unwrap_or_else(|e| panic!("{e}"));
|
||||
s.execute("mesh.convert", p).err().map(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// On a design with a mesh body, what the contract leaves to the handler and the kernel fails the
|
||||
/// same way in strict: a body that isn't a mesh, a tolerance that doesn't evaluate or isn't a
|
||||
/// length, and one the kernel refuses (outside 0.000001 mm to 1 mm).
|
||||
#[test]
|
||||
fn strict_keeps_the_conversions_own_errors() {
|
||||
for p in [
|
||||
json!({"body": "Solid"}),
|
||||
json!({"body": "Mesh", "tolerance": "nonsense"}),
|
||||
json!({"body": "Mesh", "tolerance": "30 deg"}),
|
||||
json!({"body": "Mesh", "tolerance": 5}),
|
||||
json!({"body": "Mesh", "tolerance": "0 mm"}),
|
||||
json!({"body": "Mesh", "tolerance": "-1"}),
|
||||
] {
|
||||
let off = conversion_error(ParamCheck::Off, &p);
|
||||
assert!(off.is_some(), "{p} should fail today");
|
||||
assert_eq!(conversion_error(ParamCheck::Strict, &p), off, "{p}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #13006: Convert Mesh's contract takes what its input schema allows: each call's check finds no
|
||||
/// issue exactly when the call matches the schema.
|
||||
#[test]
|
||||
fn the_check_takes_what_the_schema_allows() {
|
||||
let schema = input_schema_for::<MeshConvertParams>();
|
||||
let schema = schema.as_value();
|
||||
for (p, takes) in [
|
||||
(json!({}), true),
|
||||
(json!({"body": "Mesh"}), true),
|
||||
(json!({"body": "Mesh", "tolerance": "0.01 mm", "name": "Rebuilt", "participants": ["Mesh"]}), true),
|
||||
(json!({"body": "", "tolerance": 0.02, "participants": "*"}), true),
|
||||
(json!({"body": null, "tolerance": null, "name": null, "participants": null}), true),
|
||||
(json!({"body": 5}), false),
|
||||
(json!({"body": ["Mesh"]}), false),
|
||||
(json!({"tolerance": ""}), false),
|
||||
(json!({"tolerance": true}), false),
|
||||
(json!({"tolerance": 2e9}), false),
|
||||
(json!({"name": 5}), false),
|
||||
(json!({"participants": [1]}), false),
|
||||
(json!({"body_name": "Solid"}), false),
|
||||
(json!({"bodies": ["Mesh"]}), false),
|
||||
] {
|
||||
let issues = crate::schema::issues("mesh.convert", &p);
|
||||
assert_eq!(issues.is_empty(), takes, "{p}: {issues:?}");
|
||||
assert_eq!(conforms(schema, schema, &p), takes, "{p}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #13006: Convert Mesh replies with `FeatureResult`, with the keys it gave before, when it names
|
||||
/// the body and when it converts the selected one (no `body`, or a blank one). The solid takes the
|
||||
/// mesh body's place under its name, as a body the new feature makes, so the reply lists it as
|
||||
/// created (and not as changed or removed). In Warn, without warnings, so its contract takes each
|
||||
/// call.
|
||||
#[test]
|
||||
fn convert_mesh_replies_with_feature_result() {
|
||||
let schema = output_schema_for::<FeatureResult>();
|
||||
let schema = schema.as_value();
|
||||
let before = BTreeSet::from(["feature", "name", "bodies", "created_bodies", "changed_bodies", "removed_bodies", "warning", "ms"]);
|
||||
for p in [
|
||||
json!({"body": "Mesh"}),
|
||||
json!({}),
|
||||
json!({"body": "", "tolerance": "0.01 mm", "name": "Rebuilt"}),
|
||||
json!({"body": "Mesh", "tolerance": 0.02, "participants": ["Mesh"]}),
|
||||
] {
|
||||
let mut s = imported_box();
|
||||
s.param_check = ParamCheck::Warn;
|
||||
s.selection = vec![Sel::Body { name: "Mesh".into() }];
|
||||
let r = s.execute("mesh.convert", &p).unwrap_or_else(|e| panic!("{p}: {e}"));
|
||||
assert!(conforms(schema, schema, &r), "{r} doesn't match {schema}");
|
||||
assert!(r.get("param_warnings").is_none(), "{p}: {r}");
|
||||
let keys: BTreeSet<&str> = r.as_object().into_iter().flatten().map(|(k, _)| k.as_str()).collect();
|
||||
assert_eq!(keys, before, "{p}: {r}");
|
||||
let lists = (&r["created_bodies"], &r["changed_bodies"], &r["removed_bodies"]);
|
||||
assert_eq!(lists, (&json!(["Mesh"]), &json!([]), &json!([])), "{p}: {r}");
|
||||
assert!(s.model.state().body("Mesh").is_some_and(|b| !b.body.is_mesh()), "{p}: still a mesh");
|
||||
}
|
||||
}
|
||||
@@ -73,13 +73,16 @@ pub mod file;
|
||||
pub mod frame;
|
||||
pub mod inspect;
|
||||
pub mod interference;
|
||||
pub mod joint_sweep;
|
||||
pub mod joints;
|
||||
pub mod libraries;
|
||||
pub mod mass_properties;
|
||||
pub mod material_properties;
|
||||
pub mod materials;
|
||||
pub mod mesh;
|
||||
pub mod metadata;
|
||||
pub mod motion;
|
||||
pub mod motion_analysis;
|
||||
pub mod motion_play;
|
||||
pub mod panels;
|
||||
pub mod parameter_sweep;
|
||||
@@ -92,6 +95,7 @@ pub mod render;
|
||||
pub mod render_bindings;
|
||||
pub mod render_camera;
|
||||
pub mod render_hdr;
|
||||
pub mod render_jobs;
|
||||
pub mod render_library;
|
||||
pub mod render_lights;
|
||||
pub mod render_presets;
|
||||
@@ -100,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;
|
||||
@@ -109,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;
|
||||
@@ -117,6 +123,7 @@ pub mod sketch_modify;
|
||||
pub mod sketch_project;
|
||||
pub mod sketch_session;
|
||||
pub mod sketch_shapes;
|
||||
pub mod surface;
|
||||
pub mod surface_analysis;
|
||||
pub mod surface_boundary;
|
||||
pub mod surface_match;
|
||||
|
||||
@@ -236,12 +236,7 @@ pub struct OccurrenceKeys(pub Vec<Value>);
|
||||
|
||||
impl<'de> Deserialize<'de> for OccurrenceKeys {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::Array(list) if list.len() > MAX_OCCURRENCES => Err(D::Error::custom(whole("too many occurrences"))),
|
||||
Value::Array(list) if list.iter().all(|v| v.is_number() || v.is_string()) => Ok(OccurrenceKeys(list)),
|
||||
Value::Array(_) => Err(D::Error::custom(whole("an occurrence must be an id or a name"))),
|
||||
_ => Err(D::Error::custom(whole("`occurrences` must list occurrences"))),
|
||||
}
|
||||
occurrence_list(Value::deserialize(d)?).map(OccurrenceKeys)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,6 +255,45 @@ impl JsonSchema for OccurrenceKeys {
|
||||
}
|
||||
}
|
||||
|
||||
/// A list of occurrences as `cmd/motion.rs`'s `occurrences` reads it, with its messages: at most
|
||||
/// 10000, each an id or a name. Anything else, `null` included, isn't a list. Whether each
|
||||
/// occurrence exists, and that none is the root, stay the handler's to check.
|
||||
fn occurrence_list<E: serde::de::Error>(v: Value) -> Result<Vec<Value>, E> {
|
||||
match v {
|
||||
Value::Array(list) if list.len() > MAX_OCCURRENCES => Err(E::custom(whole("too many occurrences"))),
|
||||
Value::Array(list) if list.iter().all(|v| v.is_number() || v.is_string()) => Ok(list),
|
||||
Value::Array(_) => Err(E::custom(whole("an occurrence must be an id or a name"))),
|
||||
_ => Err(E::custom(whole("`occurrences` must list occurrences"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// The parts a collision check takes, `occurrences` or `against`, as `checked_pairs` in
|
||||
/// `cmd/motion_analysis.rs` reads each: through `cmd/motion.rs`'s `occurrences`, so `against`'s
|
||||
/// messages name `occurrences` too. Unlike [`OccurrenceKeys`], one part is enough: the handler
|
||||
/// counts the two lists together.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PartList(pub Vec<Value>);
|
||||
|
||||
impl<'de> Deserialize<'de> for PartList {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
occurrence_list(Value::deserialize(d)?).map(PartList)
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for PartList {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"PartList".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "Occurrences, each by id (a number) or name; not the root.",
|
||||
"type": "array",
|
||||
"items": {"type": ["number", "string"]},
|
||||
"maxItems": MAX_OCCURRENCES
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// `occurrences`: given, `null` included, it must be [`OccurrenceKeys`], as the handlers read it
|
||||
/// whenever the key is there.
|
||||
fn some_occurrences<'de, D: Deserializer<'de>>(d: D) -> Result<Option<OccurrenceKeys>, D::Error> {
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
//! Contracts and reply types for the motion analysis commands in `cmd/motion_analysis.rs`
|
||||
//! (#12852): Plot Motion Study (`motion.plot`) and Motion Interference (`motion.interference`).
|
||||
//! Both play a motion study sample by sample on a copy of the design; neither changes it.
|
||||
//!
|
||||
//! The handlers read their parameters from the raw JSON, so each key here reads as its handler
|
||||
//! reads it and fails with its message. `study` reuses the motion study commands' reader, and
|
||||
//! the parts Motion Interference checks, `occurrences` and `against`, read as its
|
||||
//! `checked_pairs` reads them ([`PartList`]). Whether the study, joints and occurrences exist,
|
||||
//! and whether there are parts to check, stay the handlers' to check.
|
||||
|
||||
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
|
||||
use serde::de::Error as _;
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use serde_json::{Map, Value, json};
|
||||
use solidcraft_geom::Vec3Geom;
|
||||
|
||||
use super::check::whole;
|
||||
use super::motion::{JointRef, PartList, require, some_study, study_ref_schema};
|
||||
|
||||
/// The most samples either command takes (`MAX_SAMPLES` in `cmd/motion_analysis.rs`).
|
||||
const MAX_SAMPLES: f64 = 1000.0;
|
||||
/// The fewest samples a plot takes: three, so it has an acceleration.
|
||||
const MIN_PLOT_SAMPLES: f64 = 3.0;
|
||||
/// The fewest samples an interference check takes.
|
||||
const MIN_INTERFERENCE_SAMPLES: f64 = 2.0;
|
||||
/// The most joints a plot lists (`MAX_SERIES`).
|
||||
const MAX_JOINTS: usize = 256;
|
||||
/// The longest and shortest `duration` a plot takes, in seconds.
|
||||
const MIN_DURATION_S: f64 = 1e-3;
|
||||
const MAX_DURATION_S: f64 = 1e9;
|
||||
/// The longest `budget_s` an interference check takes: a day (`MAX_BUDGET_S`).
|
||||
const MAX_BUDGET_S: f64 = 86_400.0;
|
||||
/// The most ids an occurrence path lists, and the largest size of a point's coordinates
|
||||
/// (`traced_point` in `cmd/motion.rs`).
|
||||
const MAX_PATH_IDS: usize = 64;
|
||||
const MAX_POINT_MM: f64 = 1e7;
|
||||
|
||||
/// Play a motion study on a copy of the design and plot each joint value, with its velocity
|
||||
/// and acceleration (finite differences between samples), and optionally the path of a point of
|
||||
/// a part. The study plays as `motion.play` plays it, contact sets included. The design is not
|
||||
/// changed.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = plot_keys)]
|
||||
pub struct PlotParams {
|
||||
// Required in the schema (`plot_keys`), optional in the type: left out, the handler refuses
|
||||
// it with its own message, not the check's "is required".
|
||||
#[serde(default, deserialize_with = "some_study", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(schema_with = "study_ref_schema")]
|
||||
pub study: Option<Value>,
|
||||
/// The joints to plot, each by id or name: every value of each. Default (or `null`): every
|
||||
/// joint that isn't suppressed.
|
||||
#[serde(default, deserialize_with = "plotted_joints")]
|
||||
#[schemars(schema_with = "joints_schema")]
|
||||
pub joints: Option<Vec<JointRef>>,
|
||||
/// The part whose point to follow: an occurrence by id or name, or a path of occurrence ids.
|
||||
/// Given with `point`.
|
||||
#[serde(default, deserialize_with = "followed_occurrence", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(schema_with = "occurrence_schema")]
|
||||
pub occurrence: Option<Value>,
|
||||
/// The point to follow, [x, y, z] in world mm where the part is now. Given with
|
||||
/// `occurrence`.
|
||||
#[serde(default, deserialize_with = "followed_point", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(schema_with = "point_schema")]
|
||||
pub point: Option<Value>,
|
||||
/// How many samples, from 3 to 1000 (rounded). Default: the study's steps + 1.
|
||||
#[serde(default, deserialize_with = "plot_samples", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(schema_with = "plot_samples_schema")]
|
||||
pub samples: Option<f64>,
|
||||
/// The seconds the whole study takes, 0.001 or more: velocities and accelerations are then
|
||||
/// per second, and the reply has `time`. Default (or `null`): per step.
|
||||
#[serde(default, deserialize_with = "duration")]
|
||||
#[schemars(schema_with = "duration_schema")]
|
||||
pub duration: Option<f64>,
|
||||
}
|
||||
|
||||
fn plot_keys(schema: &mut Schema) {
|
||||
require(schema, "study");
|
||||
// `occurrence` and `point` come together. `conforms` in the tests doesn't read this; the
|
||||
// handler refuses one without the other.
|
||||
schema.insert("dependentRequired".into(), json!({"occurrence": ["point"], "point": ["occurrence"]}));
|
||||
}
|
||||
|
||||
/// Play a motion study on a copy of the design and find the pairs of parts that pass into each
|
||||
/// other along it, with the steps where they overlap. Contact sets don't stop it. Parts are
|
||||
/// tested at the samples only: one that passes wholly through another between two samples is
|
||||
/// missed. The design is not changed.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = study_required)]
|
||||
pub struct InterferenceParams {
|
||||
#[serde(default, deserialize_with = "some_study", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(schema_with = "study_ref_schema")]
|
||||
pub study: Option<Value>,
|
||||
/// The parts checked against each other (two or more, or one with `against`). Default:
|
||||
/// every occurrence.
|
||||
#[serde(default, deserialize_with = "super::present", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "PartList")]
|
||||
pub occurrences: Option<PartList>,
|
||||
/// The parts that must be missed: each of `occurrences` is checked only against these.
|
||||
/// Default (or `null`): none.
|
||||
#[serde(default)]
|
||||
pub against: Option<PartList>,
|
||||
/// Check the pairs of the design's contact sets instead (not with `occurrences`).
|
||||
#[serde(default)]
|
||||
pub contact_sets: Option<bool>,
|
||||
/// How many samples, from 2 to 1000 (rounded). Default: the study's steps + 1.
|
||||
#[serde(default, deserialize_with = "interference_samples", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(schema_with = "interference_samples_schema")]
|
||||
pub samples: Option<f64>,
|
||||
/// The seconds the check may take (above 0, at most 86400), looked at between samples, for
|
||||
/// a mechanism that needs longer than the fixed work budget. Default (or `null`): that
|
||||
/// budget.
|
||||
#[serde(default, deserialize_with = "budget_s")]
|
||||
#[schemars(schema_with = "budget_s_schema")]
|
||||
pub budget_s: Option<f64>,
|
||||
}
|
||||
|
||||
fn study_required(schema: &mut Schema) {
|
||||
require(schema, "study");
|
||||
}
|
||||
|
||||
/// `joints`: a list of at most 256 joints, each an id or a name, or `null`, with the handler's
|
||||
/// messages (`plotted` and `joint_id`).
|
||||
fn plotted_joints<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Vec<JointRef>>, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::Null => Ok(None),
|
||||
Value::Array(list) if list.len() <= MAX_JOINTS => {
|
||||
if list.iter().all(|v| v.is_number() || v.is_string()) {
|
||||
Ok(Some(list.iter().map(|_| JointRef).collect()))
|
||||
} else {
|
||||
Err(D::Error::custom(whole("`joint` must be an id or a name")))
|
||||
}
|
||||
}
|
||||
_ => Err(D::Error::custom(format!("must list at most {MAX_JOINTS} joints"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn joints_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "The joints to plot, each by id (a number) or name: every value of each. Default (or `null`): every joint \
|
||||
that isn't suppressed. At most 256 joints, and 256 joint values in all.",
|
||||
"type": ["array", "null"],
|
||||
"items": {"type": ["number", "string"]},
|
||||
"maxItems": MAX_JOINTS,
|
||||
})
|
||||
}
|
||||
|
||||
/// `occurrence`: an id or a name, or a path of at most 64 occurrence ids, with the handler's
|
||||
/// messages (`traced_point` and `occurrence_id`), `null` included.
|
||||
fn followed_occurrence<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Value>, D::Error> {
|
||||
let v = Value::deserialize(d)?;
|
||||
let problem = match &v {
|
||||
Value::Number(_) | Value::String(_) => None,
|
||||
Value::Array(ids) if ids.len() > MAX_PATH_IDS => Some("an occurrence path has at most 64 ids"),
|
||||
Value::Array(ids) if !ids.iter().all(|id| id.as_u64().is_some()) => Some("an occurrence path lists occurrence ids"),
|
||||
Value::Array(_) => None,
|
||||
_ => Some("an occurrence must be an id or a name"),
|
||||
};
|
||||
match problem {
|
||||
Some(message) => Err(D::Error::custom(whole(message))),
|
||||
None => Ok(Some(v)),
|
||||
}
|
||||
}
|
||||
|
||||
fn occurrence_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "The part whose point to follow: an occurrence by id (a number) or name (`root` or 0 for the design's own \
|
||||
bodies), or a path of at most 64 occurrence ids from the top. Given with `point`.",
|
||||
"anyOf": [
|
||||
{"type": ["number", "string"]},
|
||||
{"type": "array", "items": {"type": "integer", "minimum": 0}, "maxItems": MAX_PATH_IDS},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
/// `point`: [x, y, z] or {x, y, z}, finite and nearer the origin than 1e7 mm, as `params::vec3`
|
||||
/// and `traced_point` read it, with its message (the check names the key).
|
||||
fn followed_point<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Value>, D::Error> {
|
||||
let v = Value::deserialize(d)?;
|
||||
match crate::params::vec3(&v) {
|
||||
Some(p) if p.len() < MAX_POINT_MM => Ok(Some(v)),
|
||||
_ => Err(D::Error::custom("must be [x, y, z], a world point")),
|
||||
}
|
||||
}
|
||||
|
||||
fn point_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
let coordinate = json!({"type": "number", "exclusiveMinimum": -1e9, "exclusiveMaximum": 1e9});
|
||||
json_schema!({
|
||||
"description": "The point to follow, in world mm where the part is now: [x, y, z] or {x, y, z}, nearer the origin than 1e7 mm. \
|
||||
Given with `occurrence`.",
|
||||
"anyOf": [
|
||||
{"type": "array", "items": coordinate, "minItems": 3, "maxItems": 3},
|
||||
{"type": "object", "properties": {"x": coordinate, "y": coordinate, "z": coordinate}, "required": ["x", "y", "z"]},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
/// `samples`: a number from `min` to 1000, with the handler's message (`samples`), `null`
|
||||
/// included.
|
||||
fn samples_from<E: serde::de::Error>(v: &Value, min: f64) -> Result<Option<f64>, E> {
|
||||
v.as_f64().filter(|n| n.is_finite() && *n >= min && *n <= MAX_SAMPLES).map(Some).ok_or_else(|| E::custom(format!("must be {min}…{MAX_SAMPLES}")))
|
||||
}
|
||||
|
||||
fn plot_samples<'de, D: Deserializer<'de>>(d: D) -> Result<Option<f64>, D::Error> {
|
||||
samples_from(&Value::deserialize(d)?, MIN_PLOT_SAMPLES)
|
||||
}
|
||||
|
||||
fn interference_samples<'de, D: Deserializer<'de>>(d: D) -> Result<Option<f64>, D::Error> {
|
||||
samples_from(&Value::deserialize(d)?, MIN_INTERFERENCE_SAMPLES)
|
||||
}
|
||||
|
||||
fn samples_schema(min: f64) -> Schema {
|
||||
json_schema!({
|
||||
"description": format!("How many samples, from {min} to 1000 (rounded to a whole number). Default: the study's steps + 1."),
|
||||
"type": "number",
|
||||
"minimum": min,
|
||||
"maximum": MAX_SAMPLES,
|
||||
})
|
||||
}
|
||||
|
||||
fn plot_samples_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
samples_schema(MIN_PLOT_SAMPLES)
|
||||
}
|
||||
|
||||
fn interference_samples_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
samples_schema(MIN_INTERFERENCE_SAMPLES)
|
||||
}
|
||||
|
||||
/// `duration`: seconds from 0.001 to 1e9, or `null`, with the handler's message.
|
||||
fn duration<'de, D: Deserializer<'de>>(d: D) -> Result<Option<f64>, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::Null => Ok(None),
|
||||
v => v
|
||||
.as_f64()
|
||||
.filter(|s| (MIN_DURATION_S..=MAX_DURATION_S).contains(s))
|
||||
.map(Some)
|
||||
.ok_or_else(|| D::Error::custom("must be seconds, 0.001 or more")),
|
||||
}
|
||||
}
|
||||
|
||||
fn duration_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "The seconds the whole study takes, 0.001 or more (at most 1e9): velocities and accelerations are then per \
|
||||
second, and the reply has `time`. Default (or `null`): per step.",
|
||||
"type": ["number", "null"],
|
||||
"minimum": MIN_DURATION_S,
|
||||
"maximum": MAX_DURATION_S,
|
||||
})
|
||||
}
|
||||
|
||||
/// `budget_s`: seconds above 0, at most a day, or `null`, with the handler's message (it reads
|
||||
/// the number with `params::num`, which takes it finite and smaller than 1e9 in size).
|
||||
fn budget_s<'de, D: Deserializer<'de>>(d: D) -> Result<Option<f64>, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::Null => Ok(None),
|
||||
v => v
|
||||
.as_f64()
|
||||
.filter(|s| s.is_finite() && *s > 0.0 && *s <= MAX_BUDGET_S)
|
||||
.map(Some)
|
||||
.ok_or_else(|| D::Error::custom(format!("must be a number of seconds above 0, at most {MAX_BUDGET_S}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn budget_s_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "The seconds the check may take (above 0, at most 86400), looked at between samples, so the last sample may \
|
||||
run past it. Default (or `null`): a fixed work budget.",
|
||||
"type": ["number", "null"],
|
||||
"exclusiveMinimum": 0,
|
||||
"maximum": MAX_BUDGET_S,
|
||||
})
|
||||
}
|
||||
|
||||
/// Where a study stopped short, and why: shared by both replies.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct StudyStop {
|
||||
/// The sample it stopped at, or `null` when it played to the end.
|
||||
pub stopped_at: Option<usize>,
|
||||
/// The joint whose closed loop could no longer close (`{joint}`), or `null`.
|
||||
pub stopped_by_loop: Option<Map<String, Value>>,
|
||||
}
|
||||
|
||||
/// One joint value plotted.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct PlotSeries {
|
||||
/// The joint's id.
|
||||
pub joint: u64,
|
||||
pub name: Option<String>,
|
||||
/// Which of its values.
|
||||
pub index: usize,
|
||||
/// The value's name (`angle`, `distance`, …).
|
||||
pub dof: Option<&'static str>,
|
||||
/// `deg` for an angle (unwrapped, so it runs on past a turn), `mm` for a distance.
|
||||
pub unit: &'static str,
|
||||
/// `unit` per second with a `duration`, else per step.
|
||||
pub velocity_unit: String,
|
||||
pub acceleration_unit: String,
|
||||
/// At each sample.
|
||||
pub value: Vec<f64>,
|
||||
pub velocity: Vec<f64>,
|
||||
pub acceleration: Vec<f64>,
|
||||
}
|
||||
|
||||
/// The path of the point a plot follows.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct PlottedPoint {
|
||||
/// The part's name (`root` for the design's own bodies).
|
||||
pub occurrence: String,
|
||||
/// `mm`.
|
||||
pub unit: &'static str,
|
||||
pub velocity_unit: String,
|
||||
pub acceleration_unit: String,
|
||||
/// Where it is at each sample, [x, y, z] in world mm.
|
||||
pub position: Vec<[f64; 3]>,
|
||||
pub velocity: Vec<[f64; 3]>,
|
||||
pub acceleration: Vec<[f64; 3]>,
|
||||
/// The length of `velocity` at each sample.
|
||||
pub speed: Vec<f64>,
|
||||
/// The length of `acceleration` at each sample.
|
||||
pub acceleration_norm: Vec<f64>,
|
||||
}
|
||||
|
||||
/// What `motion.plot` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct MotionPlot {
|
||||
/// The study's name.
|
||||
pub study: String,
|
||||
/// Its steps.
|
||||
pub steps: u32,
|
||||
/// How many samples were taken: fewer than asked when the study stopped short.
|
||||
pub samples: usize,
|
||||
/// The study step of each sample.
|
||||
pub step: Vec<f64>,
|
||||
/// With a `duration` only: the time of each sample, in seconds.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub time: Option<Vec<f64>>,
|
||||
pub joints: Vec<PlotSeries>,
|
||||
/// With `occurrence` and `point` only.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub point: Option<PlottedPoint>,
|
||||
#[serde(flatten)]
|
||||
pub stop: StudyStop,
|
||||
/// Where two parts in a contact set touched and stopped it, or `null`.
|
||||
pub stopped_by_contact: Option<Map<String, 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>,
|
||||
}
|
||||
|
||||
/// One pair of parts that pass into each other along the study.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct CollidingPair {
|
||||
/// The two parts, the older occurrence first.
|
||||
pub a: String,
|
||||
pub b: String,
|
||||
/// The first and last study steps where they overlap.
|
||||
pub first_step: Option<f64>,
|
||||
pub last_step: Option<f64>,
|
||||
/// The step ranges where they overlap, [from, to].
|
||||
pub steps: Vec<[f64; 2]>,
|
||||
/// How far into each other they go at most, in mm: an estimate from points of the bodies'
|
||||
/// contact meshes, not exact.
|
||||
pub depth_mm: f64,
|
||||
/// The first step where they share the most volume, or `null` with `not_measured`.
|
||||
pub deepest_step: Option<f64>,
|
||||
/// The most volume they share at a sample, in mm³, worked out exactly from the bodies, or
|
||||
/// `null` with `not_measured` (never an estimate).
|
||||
pub volume_mm3: Option<f64>,
|
||||
/// Why the volume wasn't worked out at every sample they overlap, or `null`.
|
||||
pub not_measured: Option<String>,
|
||||
}
|
||||
|
||||
/// What `motion.interference` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct MotionInterference {
|
||||
/// The study's name.
|
||||
pub study: String,
|
||||
/// Its steps.
|
||||
pub steps: u32,
|
||||
/// Every sample was checked and no two parts collide. Never true when `truncated`.
|
||||
pub clear: bool,
|
||||
/// In the order they first collide.
|
||||
pub collisions: Vec<CollidingPair>,
|
||||
/// How many pairs of parts were checked.
|
||||
pub pairs: usize,
|
||||
/// How many samples were checked.
|
||||
pub samples: usize,
|
||||
/// The work budget, or `budget_s`, ran out before the last sample.
|
||||
pub truncated: bool,
|
||||
/// When `truncated`: the last step checked.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub checked_to: Option<f64>,
|
||||
/// The `budget_s` given.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub budget_s: Option<f64>,
|
||||
#[serde(flatten)]
|
||||
pub stop: StudyStop,
|
||||
/// 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 = "motion_analysis_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,206 @@
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{InterferenceParams, MotionInterference, MotionPlot, PlotParams};
|
||||
use crate::Session;
|
||||
use crate::schema::tests::{assert_keys, conforms};
|
||||
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
|
||||
|
||||
/// Cube B (x 20…30) slides on joint `Rail` toward cube A (x 0…10), as in
|
||||
/// `cmd/motion_analysis.rs`'s tests: study `Push` takes it from 10 mm away until it touches A,
|
||||
/// and study `Through` 5 mm into A.
|
||||
fn rail() -> Session {
|
||||
let mut s = Session::default();
|
||||
let run = |s: &mut Session, id: &str, p: Value| {
|
||||
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
};
|
||||
for (name, x) in [("A", 0), ("B", 20)] {
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
run(&mut s, "component.create", json!({"name": name}));
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0]}));
|
||||
}
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
let a = s.doc.find_component("A").and_then(|c| s.doc.occurrence_of(c)).map(|o| o.id).unwrap_or(0);
|
||||
run(&mut s, "occurrence.ground", json!({"occurrence": a, "grounded": true}));
|
||||
run(&mut s, "joint.create", json!({"type": "slider", "a": {"face": [10, 5, 5]}, "b": {"face": [20, 5, 5]}, "name": "Rail"}));
|
||||
run(&mut s, "joint.drive", json!({"joint": "Rail", "value": 10}));
|
||||
run(&mut s, "motion.study", json!({"name": "Push", "steps": 15, "keys": [{"joint": "Rail", "points": [[0, 10], [15, 0]]}]}));
|
||||
run(&mut s, "motion.study", json!({"name": "Through", "steps": 15, "keys": [{"joint": "Rail", "points": [[0, 10], [15, -5]]}]}));
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schemas_and_the_key_check_agree() {
|
||||
assert_keys(input_schema_for::<PlotParams>(), &["duration", "joints", "occurrence", "point", "samples", "study"]);
|
||||
assert_keys(input_schema_for::<InterferenceParams>(), &["against", "budget_s", "contact_sets", "occurrences", "samples", "study"]);
|
||||
for schema in [input_schema_for::<PlotParams>(), input_schema_for::<InterferenceParams>()] {
|
||||
assert_eq!(schema.as_value()["required"], json!(["study"]));
|
||||
}
|
||||
let plot = input_schema_for::<PlotParams>();
|
||||
assert_eq!(plot.as_value()["dependentRequired"], json!({"occurrence": ["point"], "point": ["occurrence"]}));
|
||||
}
|
||||
|
||||
/// #12852: calls that fail today get the same message in strict: the handlers' for a study,
|
||||
/// `samples`, `duration`, `joints`, the followed occurrence and point, and `budget_s` of the wrong
|
||||
/// shape (`study`, `occurrence` and `point` left out included: the schema requires them, and
|
||||
/// the types leave them to the handlers), the part lists' (`occurrences` and `against`), and the
|
||||
/// handlers' own for what the design lacks. They need the rail's studies, so the shared helper
|
||||
/// (on a sketch) doesn't fit.
|
||||
#[test]
|
||||
fn strict_keeps_todays_errors() {
|
||||
let many_joints = vec![json!("Rail"); 257];
|
||||
let long_path = vec![json!(1); 65];
|
||||
let too_many_parts = vec![json!("A"); 10_001];
|
||||
for (id, p) in [
|
||||
("motion.plot", json!({})),
|
||||
("motion.plot", json!({"study": null})),
|
||||
("motion.plot", json!({"study": [1]})),
|
||||
("motion.plot", json!({"study": "Nope"})),
|
||||
("motion.plot", json!({"study": "Push", "samples": 2})),
|
||||
("motion.plot", json!({"study": "Push", "samples": "10"})),
|
||||
("motion.plot", json!({"study": "Push", "samples": null})),
|
||||
("motion.plot", json!({"study": "Push", "duration": 0})),
|
||||
("motion.plot", json!({"study": "Push", "duration": "1"})),
|
||||
("motion.plot", json!({"study": "Push", "joints": "Rail"})),
|
||||
("motion.plot", json!({"study": "Push", "joints": many_joints})),
|
||||
("motion.plot", json!({"study": "Push", "joints": [true]})),
|
||||
("motion.plot", json!({"study": "Push", "joints": ["Nope"]})),
|
||||
("motion.plot", json!({"study": "Push", "occurrence": "B"})),
|
||||
("motion.plot", json!({"study": "Push", "point": [25, 5, 5]})),
|
||||
("motion.plot", json!({"study": "Push", "occurrence": "B", "point": null})),
|
||||
("motion.plot", json!({"study": "Push", "occurrence": "B", "point": [25, 5]})),
|
||||
("motion.plot", json!({"study": "Push", "occurrence": "B", "point": [2e7, 0, 0]})),
|
||||
("motion.plot", json!({"study": "Push", "occurrence": true, "point": [25, 5, 5]})),
|
||||
("motion.plot", json!({"study": "Push", "occurrence": [1.5], "point": [25, 5, 5]})),
|
||||
("motion.plot", json!({"study": "Push", "occurrence": long_path, "point": [25, 5, 5]})),
|
||||
("motion.plot", json!({"study": "Push", "occurrence": "Nope", "point": [25, 5, 5]})),
|
||||
("motion.interference", json!({})),
|
||||
("motion.interference", json!({"study": true})),
|
||||
("motion.interference", json!({"study": "Push", "samples": 1})),
|
||||
("motion.interference", json!({"study": "Push", "samples": 1001})),
|
||||
("motion.interference", json!({"study": "Push", "budget_s": 0})),
|
||||
("motion.interference", json!({"study": "Push", "budget_s": 86401})),
|
||||
("motion.interference", json!({"study": "Push", "budget_s": "60"})),
|
||||
("motion.interference", json!({"study": "Push", "occurrences": ["A"]})),
|
||||
("motion.interference", json!({"study": "Push", "occurrences": ["A", "B"], "contact_sets": true})),
|
||||
("motion.interference", json!({"study": "Push", "against": ["A"]})),
|
||||
("motion.interference", json!({"study": "Push", "contact_sets": true})),
|
||||
// The part lists: not a list, `null`, an entry that is neither an id nor a name, more
|
||||
// than 10000; `against` is read the same way, with the same messages.
|
||||
("motion.interference", json!({"study": "Push", "occurrences": 5})),
|
||||
("motion.interference", json!({"study": "Push", "occurrences": null})),
|
||||
("motion.interference", json!({"study": "Push", "occurrences": ["A", true]})),
|
||||
("motion.interference", json!({"study": "Push", "occurrences": too_many_parts.clone()})),
|
||||
("motion.interference", json!({"study": "Push", "occurrences": ["A"], "against": 5})),
|
||||
("motion.interference", json!({"study": "Push", "occurrences": ["A"], "against": [true]})),
|
||||
("motion.interference", json!({"study": "Push", "occurrences": ["A"], "against": too_many_parts})),
|
||||
] {
|
||||
let error = |level: ParamCheck| {
|
||||
let mut s = rail();
|
||||
s.param_check = level;
|
||||
s.execute(id, &p).err().map(|e| e.to_string())
|
||||
};
|
||||
let off = error(ParamCheck::Off);
|
||||
assert!(off.is_some(), "{id} {p} should fail today");
|
||||
assert_eq!(error(ParamCheck::Strict), off, "{id} {p}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #12852: the contracts take what the input schemas allow: each call's check finds no issue
|
||||
/// exactly when it matches the schema. Left out of this on purpose: `study` left out, and
|
||||
/// `occurrence` or `point` without the other (the schema requires them, and the handlers refuse
|
||||
/// them with their own messages; see `strict_keeps_todays_errors`), and a point 1e7 mm or more
|
||||
/// from the origin (refused by the reader; JSON Schema can't measure a length).
|
||||
#[test]
|
||||
fn the_checks_take_what_the_schemas_allow() {
|
||||
let schemas = [("motion.plot", input_schema_for::<PlotParams>()), ("motion.interference", input_schema_for::<InterferenceParams>())];
|
||||
let schema = |id: &str| schemas.iter().find(|(name, _)| *name == id).map(|(_, s)| s.as_value().clone()).unwrap_or_default();
|
||||
let too_many_parts = vec![json!("A"); 10_001];
|
||||
for (id, p, takes) in [
|
||||
("motion.plot", json!({"study": "Push"}), true),
|
||||
("motion.plot", json!({"study": 1}), true),
|
||||
("motion.plot", json!({"study": "Push", "joints": ["Rail", 2], "samples": 10.4, "duration": 1.5}), true),
|
||||
("motion.plot", json!({"study": "Push", "joints": null, "duration": null}), true),
|
||||
("motion.plot", json!({"study": "Push", "occurrence": "B", "point": [25, 5, 5]}), true),
|
||||
("motion.plot", json!({"study": "Push", "occurrence": [1, 2], "point": {"x": 1, "y": 2, "z": 3}}), true),
|
||||
("motion.plot", json!({"study": true}), false),
|
||||
("motion.plot", json!({"study": "Push", "samples": 2}), false),
|
||||
("motion.plot", json!({"study": "Push", "samples": 1001}), false),
|
||||
("motion.plot", json!({"study": "Push", "samples": null}), false),
|
||||
("motion.plot", json!({"study": "Push", "duration": 0}), false),
|
||||
("motion.plot", json!({"study": "Push", "duration": 2e9}), false),
|
||||
("motion.plot", json!({"study": "Push", "joints": "Rail"}), false),
|
||||
("motion.plot", json!({"study": "Push", "joints": [true]}), false),
|
||||
("motion.plot", json!({"study": "Push", "occurrence": true, "point": [0, 0, 0]}), false),
|
||||
("motion.plot", json!({"study": "Push", "occurrence": [-1], "point": [0, 0, 0]}), false),
|
||||
("motion.plot", json!({"study": "Push", "occurrence": "B", "point": [1, 2]}), false),
|
||||
("motion.plot", json!({"study": "Push", "occurrence": "B", "point": null}), false),
|
||||
("motion.interference", json!({"study": "Push"}), true),
|
||||
("motion.interference", json!({"study": "Push", "occurrences": ["A", "B"], "samples": 2, "budget_s": 86400}), true),
|
||||
("motion.interference", json!({"study": "Push", "occurrences": ["A"], "against": ["B"]}), true),
|
||||
("motion.interference", json!({"study": "Push", "contact_sets": true, "budget_s": null}), true),
|
||||
("motion.interference", json!({"study": "Push", "samples": 1}), false),
|
||||
("motion.interference", json!({"study": "Push", "budget_s": 0}), false),
|
||||
("motion.interference", json!({"study": "Push", "budget_s": 86401}), false),
|
||||
("motion.interference", json!({"study": "Push", "occurrences": null}), false),
|
||||
("motion.interference", json!({"study": "Push", "contact_sets": "yes"}), false),
|
||||
("motion.interference", json!({"study": "Push", "occurrences": [1], "against": null}), true),
|
||||
("motion.interference", json!({"study": "Push", "occurrences": 5}), false),
|
||||
("motion.interference", json!({"study": "Push", "occurrences": ["A", true]}), false),
|
||||
("motion.interference", json!({"study": "Push", "occurrences": too_many_parts.clone()}), false),
|
||||
("motion.interference", json!({"study": "Push", "occurrences": ["A"], "against": "B"}), false),
|
||||
("motion.interference", json!({"study": "Push", "occurrences": ["A"], "against": too_many_parts}), false),
|
||||
] {
|
||||
let issues = crate::schema::issues(id, &p);
|
||||
assert_eq!(issues.is_empty(), takes, "{id} {p}: {issues:?}");
|
||||
let schema = schema(id);
|
||||
assert_eq!(conforms(&schema, &schema, &p), takes, "{id} {p}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The keys of a reply.
|
||||
fn keys(r: &Value) -> BTreeSet<&str> {
|
||||
r.as_object().into_iter().flatten().map(|(k, _)| k.as_str()).collect()
|
||||
}
|
||||
|
||||
/// #12852: both commands reply as their output schemas say, in Warn without warnings, with the
|
||||
/// keys they gave before: a plot per step, a plot in seconds following a point of B, a clear
|
||||
/// interference check, and one where B passes into A.
|
||||
#[test]
|
||||
fn motion_analysis_replies_with_its_schemas() {
|
||||
let mut s = rail();
|
||||
s.param_check = ParamCheck::Warn;
|
||||
let before = s.doc.clone();
|
||||
let mut reply = |id: &str, p: Value, schema: &Value| {
|
||||
let r = s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
assert!(conforms(schema, schema, &r), "{id}: {r} doesn't match {schema}");
|
||||
assert!(r.get("param_warnings").is_none(), "{id}: {r}");
|
||||
r
|
||||
};
|
||||
let plot = output_schema_for::<MotionPlot>();
|
||||
let plot = plot.as_value();
|
||||
let stopped = ["stopped_at", "stopped_by_loop", "stopped_by_contact"];
|
||||
let per_step = reply("motion.plot", json!({"study": "Push"}), plot);
|
||||
let plain: BTreeSet<&str> = ["study", "steps", "samples", "step", "joints"].into_iter().chain(stopped).collect();
|
||||
assert_eq!(keys(&per_step), plain, "{per_step}");
|
||||
let rail = per_step["joints"].as_array().and_then(|all| all.iter().find(|j| j["name"] == "Rail")).cloned().unwrap_or_default();
|
||||
assert_eq!((&rail["unit"], &rail["velocity_unit"]), (&json!("mm"), &json!("mm/step")), "{per_step}");
|
||||
let timed = reply("motion.plot", json!({"study": "Push", "duration": 1.5, "occurrence": "B", "point": [25, 5, 5]}), plot);
|
||||
assert_eq!(keys(&timed), plain.iter().copied().chain(["time", "point"]).collect::<BTreeSet<_>>(), "{timed}");
|
||||
assert!(timed["point"]["position"].as_array().is_some_and(|p| p.len() == 16), "{timed}");
|
||||
|
||||
let interference = output_schema_for::<MotionInterference>();
|
||||
let interference = interference.as_value();
|
||||
let clear = reply("motion.interference", json!({"study": "Push"}), interference);
|
||||
let checked: BTreeSet<&str> = ["study", "steps", "clear", "collisions", "pairs", "samples", "truncated", "stopped_at", "stopped_by_loop"].into();
|
||||
assert_eq!(keys(&clear), checked, "{clear}");
|
||||
assert_eq!((&clear["clear"], &clear["collisions"]), (&json!(true), &json!([])), "{clear}");
|
||||
let through = reply("motion.interference", json!({"study": "Through", "budget_s": 600}), interference);
|
||||
assert_eq!(keys(&through), checked.iter().copied().chain(["budget_s"]).collect::<BTreeSet<_>>(), "{through}");
|
||||
let pair = &through["collisions"][0];
|
||||
assert!(through["clear"] == false && pair["volume_mm3"].as_f64().is_some_and(|v| v > 0.0), "{through}");
|
||||
let pair_keys = BTreeSet::from(["a", "b", "deepest_step", "depth_mm", "first_step", "last_step", "not_measured", "steps", "volume_mm3"]);
|
||||
assert_eq!(keys(pair), pair_keys, "{pair}");
|
||||
assert_eq!(*s.doc, *before, "the analyses leave the design as it was");
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
//! Contracts and reply types for the render job commands in `cmd/render/jobs.rs` (#12836):
|
||||
//! Render Progress, Cancel Render, Retain Displayed Render Result, Regrade Linear Master, Copy
|
||||
//! Render Image and Export Render Image. They follow a job that `render.start` queued, and keep,
|
||||
//! regrade, copy and export its finished image. None of them changes the design.
|
||||
//!
|
||||
//! The handlers parse their parameters with serde and give serde's message after the command's
|
||||
//! prefix. Each key here reads its value as the same Rust type and passes serde's message on
|
||||
//! whole ([`as_handler`]), so strict refuses those calls with today's message. Only an unknown
|
||||
//! key and a missing required key are worded by the check. What the handlers check after
|
||||
//! parsing (a presentation's ranges, the export path's extension, whether a job exists) stays
|
||||
//! theirs, with their own messages.
|
||||
//!
|
||||
//! The render crate has no `JsonSchema`. A job's id, progress, error and frame are typed here;
|
||||
//! its other sections (the request, what it resolved to, its options, the linear master) are
|
||||
//! published as JSON objects, for reading.
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
|
||||
use serde::de::{DeserializeOwned, Error as _};
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
use solidcraft_render::job::JobId;
|
||||
use solidcraft_render::job::presentation::Presentation;
|
||||
|
||||
use super::check::whole;
|
||||
use crate::render_jobs::RenderQueueState;
|
||||
|
||||
/// The value as a `T`, with serde's message passed on whole: the handler parses the same type
|
||||
/// from the same JSON and gives that message after its prefix, so strict gives it too.
|
||||
fn as_handler<'de, D: Deserializer<'de>, T: DeserializeOwned>(d: D) -> Result<T, D::Error> {
|
||||
serde_json::from_value(Value::deserialize(d)?).map_err(|e| D::Error::custom(whole(e)))
|
||||
}
|
||||
|
||||
/// A render job's identity, as `render.start` returns it and the render crate's `JobId` reads
|
||||
/// it: `{session, serial, generation}`, each a whole number, and no other key.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct Job(pub JobId);
|
||||
|
||||
impl JsonSchema for Job {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"RenderJob".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
let whole_number = |description: &str| serde_json::json!({"description": description, "type": "integer", "minimum": 0});
|
||||
json_schema!({
|
||||
"description": "A render job, exactly as `render.start` returned it. It belongs to the open design: \
|
||||
a job of another design, or of this one before it was opened again, is refused.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"session": whole_number("The open design's render identity."),
|
||||
"serial": whole_number("The job's number among that design's jobs."),
|
||||
"generation": whole_number("The job's render generation."),
|
||||
},
|
||||
"required": ["session", "serial", "generation"],
|
||||
"additionalProperties": false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Render Progress (`render.status`).
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct StatusParams {
|
||||
/// The job to report. Without it, or with `null`, the reply has the shot queue only. Asking
|
||||
/// also moves a started shot queue (`render.batch`) on to its next shot when a job has
|
||||
/// finished; it never renders.
|
||||
#[serde(default, deserialize_with = "as_handler", skip_serializing_if = "Option::is_none")]
|
||||
pub job: Option<Job>,
|
||||
}
|
||||
|
||||
/// Cancel Render (`render.cancel`).
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct CancelParams {
|
||||
/// The job to cancel. Without it, or with `null`: the shot queue's job, and its waiting
|
||||
/// shots are dropped. A job that has already finished is left as it is.
|
||||
#[serde(default, deserialize_with = "as_handler", skip_serializing_if = "Option::is_none")]
|
||||
pub job: Option<Job>,
|
||||
}
|
||||
|
||||
/// Retain Displayed Render Result (`render.result.pin`).
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct PinParams {
|
||||
/// The finished job to select among the eight results kept (the oldest is dropped first).
|
||||
/// Without it: the result already selected. `null` clears the selection.
|
||||
#[serde(default, deserialize_with = "as_handler", skip_serializing_if = "Option::is_none")]
|
||||
pub job: Option<Job>,
|
||||
}
|
||||
|
||||
/// Copy Render Image (`render.result.copy`).
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct CopyParams {
|
||||
/// The finished job whose image to copy. Without it, or with `null`: the selected result.
|
||||
/// The image may be at most 4096 pixels a side and 4194304 pixels in all.
|
||||
#[serde(default, deserialize_with = "as_handler", skip_serializing_if = "Option::is_none")]
|
||||
pub job: Option<Job>,
|
||||
}
|
||||
|
||||
/// Regrade Linear Master (`render.result.regrade`).
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct RegradeParams {
|
||||
/// The finished job whose linear master to regrade: it must have kept one (a CPU or GPU job
|
||||
/// with `linear_exr`). Without it, or with `null`: the selected result.
|
||||
#[serde(default, deserialize_with = "as_handler", skip_serializing_if = "Option::is_none")]
|
||||
pub job: Option<Job>,
|
||||
#[serde(deserialize_with = "as_handler")]
|
||||
pub presentation: PresentationParam,
|
||||
}
|
||||
|
||||
/// How a regraded image is presented: the render crate's `Presentation`, read as it reads it.
|
||||
#[derive(Clone, Copy, Debug, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct PresentationParam(pub Presentation);
|
||||
|
||||
impl JsonSchema for PresentationParam {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"RenderPresentation".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
let colour = |description: &str| {
|
||||
serde_json::json!({
|
||||
"description": description,
|
||||
"type": "array",
|
||||
"items": {"type": "integer", "minimum": 0, "maximum": 255},
|
||||
"minItems": 3,
|
||||
"maxItems": 3,
|
||||
})
|
||||
};
|
||||
json_schema!({
|
||||
"description": "How to present the image again from its linear master. Every key is optional, \
|
||||
and a key left out takes its default, not the first image's value. Transparent output can't \
|
||||
have a backdrop, bloom or photographic effects.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"exposure_ev": {"description": "Exposure, in stops, from -32 to 32. Default: 0.", "type": "number", "default": 0.0},
|
||||
"tone_map": {
|
||||
"description": "The tone response. Default: `reinhard`.",
|
||||
"type": "string",
|
||||
"enum": ["reinhard", "aces_fitted"],
|
||||
"default": "reinhard",
|
||||
},
|
||||
"finishing": {
|
||||
"description": "Creative finishing: `looks` (at most 8 layers), `photographic`, `bloom`, \
|
||||
`rgb_gain` [r, g, b], `contrast`, `saturation` and `vignette`. `render.effects.describe` \
|
||||
lists the looks and the photographic defaults. Default: none.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"looks": {"type": "array", "items": {"type": "object"}, "maxItems": 8},
|
||||
"photographic": {"type": "object"},
|
||||
"bloom": {"type": ["object", "null"]},
|
||||
"rgb_gain": {"type": "array", "items": {"type": "number"}, "minItems": 3, "maxItems": 3},
|
||||
"contrast": {"type": "number"},
|
||||
"saturation": {"type": "number"},
|
||||
"vignette": {"type": "number"},
|
||||
},
|
||||
"additionalProperties": false,
|
||||
},
|
||||
"backdrop": {
|
||||
"description": "A gradient behind the product, or `null` for none (the default): `mode` \
|
||||
(`vertical`, `horizontal` or `radial`), `first_srgb` and `second_srgb` colours, and for \
|
||||
`radial`, its `center` [x, y] as fractions of the image and its `radius`.",
|
||||
"type": ["object", "null"],
|
||||
"properties": {
|
||||
"mode": {"type": "string", "enum": ["vertical", "horizontal", "radial"]},
|
||||
"first_srgb": colour("The first colour, [r, g, b] from 0 to 255."),
|
||||
"second_srgb": colour("The second colour, [r, g, b] from 0 to 255."),
|
||||
"center": {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 2},
|
||||
"radius": {"type": "number"},
|
||||
},
|
||||
"additionalProperties": false,
|
||||
},
|
||||
"background": colour("The background colour, [r, g, b] from 0 to 255. Default: [240, 243, 248]."),
|
||||
"transparent_background": {
|
||||
"description": "Leave the background transparent. Default: false.",
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
},
|
||||
},
|
||||
"additionalProperties": false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The file format Export Render Image writes: `png`, the finished image as shown, or `exr`,
|
||||
/// the raw linear FLOAT32 master, which the job must have kept (`linear_exr`). The handler
|
||||
/// reads it as this type.
|
||||
// The variants have no doc comments on purpose: with them, schemars publishes `oneOf` and
|
||||
// `const` instead of a plain `enum`, which the schema checks in the tests don't read.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ExportFormat {
|
||||
#[default]
|
||||
Png,
|
||||
Exr,
|
||||
}
|
||||
|
||||
impl ExportFormat {
|
||||
/// The file extension the path must end with.
|
||||
pub fn extension(self) -> &'static str {
|
||||
match self {
|
||||
Self::Png => "png",
|
||||
Self::Exr => "exr",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Export Render Image (`render.export`).
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct ExportParams {
|
||||
/// The finished job whose image to write. Without it, or with `null`: the selected result.
|
||||
#[serde(default, deserialize_with = "as_handler", skip_serializing_if = "Option::is_none")]
|
||||
pub job: Option<Job>,
|
||||
/// The file to write. At most 4096 bytes, with no control characters, ending in the
|
||||
/// format's extension (`.png` or `.exr`, in any case), and within the paths this session
|
||||
/// may write.
|
||||
#[serde(deserialize_with = "as_handler")]
|
||||
pub path: String,
|
||||
/// The file format. Default: `png`.
|
||||
#[serde(default, deserialize_with = "as_handler")]
|
||||
pub format: ExportFormat,
|
||||
/// Replace a file already at `path`. Default: false, which refuses one. Either way the
|
||||
/// file is written whole or not at all.
|
||||
#[serde(default, deserialize_with = "as_handler")]
|
||||
pub overwrite: bool,
|
||||
}
|
||||
|
||||
/// Where a job is. It has finished once `cancelled`, `completed` (its image is done) or
|
||||
/// `failed` (see `error`).
|
||||
// No doc comments on the variants, so the schema is a plain `enum` (see `ExportFormat`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum JobPhase {
|
||||
Queued,
|
||||
Preparing,
|
||||
Running,
|
||||
Encoding,
|
||||
Cancelling,
|
||||
Cancelled,
|
||||
Completed,
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// A job's progress.
|
||||
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct JobProgress {
|
||||
pub phase: JobPhase,
|
||||
/// The work done, of `total`, in the job's own units (passes or tiles).
|
||||
pub done: u64,
|
||||
pub total: u64,
|
||||
}
|
||||
|
||||
/// Why a job failed.
|
||||
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct JobFailure {
|
||||
/// The kind of failure, in snake_case: `invalid_request`, `limit_exceeded`, `cancelled`,
|
||||
/// `not_completed` and others.
|
||||
pub code: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// A finished image.
|
||||
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct Frame {
|
||||
/// The job that made it.
|
||||
pub job: Job,
|
||||
/// The design revision its scene was captured from.
|
||||
pub scene_generation: u64,
|
||||
/// The render backend and executor that made it (`builtin`, `cpu-raytrace`, …).
|
||||
pub backend: String,
|
||||
pub executor: String,
|
||||
/// Its size, in pixels.
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// Its PNG's colour encoding: `srgb`.
|
||||
pub encoding: String,
|
||||
/// Its PNG's alpha: `straight`.
|
||||
pub alpha: String,
|
||||
/// The length of its PNG, in bytes.
|
||||
pub png_bytes: u64,
|
||||
/// The SHA-256 of its PNG, in lowercase hex.
|
||||
pub png_sha256: String,
|
||||
}
|
||||
|
||||
/// A render job's status: what Render Progress gives for a job, and what Cancel Render and
|
||||
/// Regrade Linear Master return.
|
||||
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct JobState {
|
||||
pub job: Job,
|
||||
/// The design revision its scene was captured from.
|
||||
pub scene_generation: u64,
|
||||
pub progress: JobProgress,
|
||||
/// Why it failed, or `null`.
|
||||
pub error: Option<JobFailure>,
|
||||
/// Its finished image, or `null` until it completes.
|
||||
pub frame: Option<Frame>,
|
||||
/// The image request it runs, as the render crate gives it.
|
||||
pub request: Map<String, Value>,
|
||||
/// The backend and executor the request resolved to, and their limits.
|
||||
pub resolved: Map<String, Value>,
|
||||
/// Its options, as the render crate gives them.
|
||||
pub options: Map<String, Value>,
|
||||
/// The textures it uploaded, or `null`.
|
||||
pub textures: Option<Map<String, Value>>,
|
||||
/// Preview passes left out because both preview images were still in use. The final image
|
||||
/// is complete all the same.
|
||||
pub preview_retention_skips: u32,
|
||||
/// The sections that apply to this job, each an object as the render crate gives it:
|
||||
/// `preview` and `preview_unavailable_reason`, `presentation` (for a regraded image),
|
||||
/// `linear_master`, `sampling`, `environment` and `punctual_diagnostics`.
|
||||
#[serde(flatten)]
|
||||
pub sections: Map<String, 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>,
|
||||
}
|
||||
|
||||
/// Render Progress for a job: its status and the shot queue.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct JobProgressReply {
|
||||
#[serde(flatten)]
|
||||
pub state: JobState,
|
||||
pub queue: RenderQueueState,
|
||||
}
|
||||
|
||||
/// Render Progress without a job: the shot queue only.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct QueueReply {
|
||||
pub queue: RenderQueueState,
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
/// What `render.status` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
#[serde(untagged)]
|
||||
pub enum RenderProgress {
|
||||
// Boxed: a job's status is far larger than the queue alone (clippy::large_enum_variant).
|
||||
Job(Box<JobProgressReply>),
|
||||
Queue(QueueReply),
|
||||
}
|
||||
|
||||
/// Cancel Render while the shot queue is still preparing its first job, before it has one.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct PreparationCancelled {
|
||||
/// Always true.
|
||||
pub preparing: bool,
|
||||
/// Always true: the preparation was asked to stop.
|
||||
pub cancel_requested: bool,
|
||||
pub queue: RenderQueueState,
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
/// What `render.cancel` returns: the job's status once asked to stop (a job that had finished
|
||||
/// is unchanged), or, while the shot queue prepares its first job, that preparation stopping.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
#[serde(untagged)]
|
||||
pub enum RenderCancelled {
|
||||
// Boxed, as in `RenderProgress`.
|
||||
Job(Box<JobState>),
|
||||
Preparation(PreparationCancelled),
|
||||
}
|
||||
|
||||
/// What `render.result.pin` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ResultPinned {
|
||||
/// The selected result's image, or `null` when the selection was cleared.
|
||||
pub frame: Option<Frame>,
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
/// What `render.result.copy` returns. The app copies the image; a script gets its frame only.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ImageCopied {
|
||||
pub frame: Frame,
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
/// What `render.export` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ImageExported {
|
||||
pub job: Job,
|
||||
/// The file written, as given.
|
||||
pub path: String,
|
||||
/// How many bytes were written.
|
||||
pub bytes: usize,
|
||||
pub format: ExportFormat,
|
||||
/// The file's colour encoding: `srgb` for a PNG, `scene_linear_srgb_d65` for an EXR.
|
||||
pub encoding: &'static str,
|
||||
/// The file's alpha: `straight` for a PNG, `coverage_premultiplied` for an EXR.
|
||||
pub alpha: &'static str,
|
||||
/// The finished image.
|
||||
pub frame: Frame,
|
||||
/// The linear master the job kept, as the render crate describes it, or `null`.
|
||||
pub linear_master: Option<Map<String, Value>>,
|
||||
/// How a regraded image was presented, as the render crate describes it, or `null` for an
|
||||
/// image rendered directly.
|
||||
pub presentation: Option<Map<String, 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 = "render_jobs_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,269 @@
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_render::job::{CpuHost, JobId, Phase};
|
||||
|
||||
use super::{
|
||||
CancelParams, CopyParams, ExportParams, ImageCopied, ImageExported, JobPhase, PinParams, RegradeParams, RenderCancelled, RenderProgress,
|
||||
ResultPinned, StatusParams,
|
||||
};
|
||||
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};
|
||||
|
||||
/// A job no session has: the render identities count up from 1.
|
||||
fn foreign_job() -> Value {
|
||||
json!({"session": u64::MAX, "serial": 1, "generation": 1})
|
||||
}
|
||||
|
||||
/// Pump a cooperative host until its jobs are done, as `cmd/render/job_tests.rs` does.
|
||||
fn finish(host: &CpuHost) {
|
||||
for _ in 0..1000 {
|
||||
if !host.pump(4096).unwrap_or_else(|e| panic!("{e}")) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
panic!("cooperative job did not finish");
|
||||
}
|
||||
|
||||
/// Render Image on `s`, 16 × 16, returning the job.
|
||||
fn start(s: &mut Session) -> JobId {
|
||||
let started = s.execute("render.start", &json!({"settings": {"width": 16, "height": 16}})).unwrap_or_else(|e| panic!("{e}"));
|
||||
serde_json::from_value(started["job"].clone()).unwrap_or_else(|e| panic!("{e}"))
|
||||
}
|
||||
|
||||
/// `params` run as `id` in Warn: the reply, which must match `schema` and have no warnings.
|
||||
fn reply(s: &mut Session, id: &str, params: Value, schema: &schemars::Schema) -> Value {
|
||||
let r = s.execute(id, ¶ms).unwrap_or_else(|e| panic!("{id} {params}: {e}"));
|
||||
assert!(conforms(schema.as_value(), schema.as_value(), &r), "{id}: {r} doesn't match {}", schema.as_value());
|
||||
assert!(r.get("param_warnings").is_none(), "{id}: {r}");
|
||||
r
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schemas_and_the_key_check_agree() {
|
||||
for schema in
|
||||
[input_schema_for::<StatusParams>(), input_schema_for::<CancelParams>(), input_schema_for::<PinParams>(), input_schema_for::<CopyParams>()]
|
||||
{
|
||||
assert_keys(schema, &["job"]);
|
||||
}
|
||||
assert_keys(input_schema_for::<RegradeParams>(), &["job", "presentation"]);
|
||||
assert_keys(input_schema_for::<ExportParams>(), &["format", "job", "overwrite", "path"]);
|
||||
}
|
||||
|
||||
/// #12836: `JobPhase` names every phase the render crate has, as it spells them. The match has
|
||||
/// no catch-all, so a new phase stops this test from compiling until `JobPhase` has it too.
|
||||
#[test]
|
||||
fn job_phase_names_every_render_phase() {
|
||||
let phases =
|
||||
[Phase::Queued, Phase::Preparing, Phase::Running, Phase::Encoding, Phase::Cancelling, Phase::Cancelled, Phase::Completed, Phase::Failed];
|
||||
for phase in phases {
|
||||
let ours = match phase {
|
||||
Phase::Queued => JobPhase::Queued,
|
||||
Phase::Preparing => JobPhase::Preparing,
|
||||
Phase::Running => JobPhase::Running,
|
||||
Phase::Encoding => JobPhase::Encoding,
|
||||
Phase::Cancelling => JobPhase::Cancelling,
|
||||
Phase::Cancelled => JobPhase::Cancelled,
|
||||
Phase::Completed => JobPhase::Completed,
|
||||
Phase::Failed => JobPhase::Failed,
|
||||
};
|
||||
assert_eq!(json!(phase), json!(ours));
|
||||
}
|
||||
}
|
||||
|
||||
/// #12836: calls that fail today get the same message in strict: serde's for a job, a
|
||||
/// presentation, a path, a format or `overwrite` of the wrong shape (the check passes it on
|
||||
/// whole), and the handlers' own for a job of another design, a presentation out of range, a
|
||||
/// path without the format's extension and no result to use. (A key left out is in
|
||||
/// `a_key_left_out_is_refused_by_the_check`, an unknown key in
|
||||
/// `an_unknown_key_is_refused_in_every_mode`.)
|
||||
#[test]
|
||||
fn strict_keeps_todays_errors() {
|
||||
assert_strict_keeps_todays_errors(&[
|
||||
("render.status", json!({"job": 5})),
|
||||
("render.status", json!({"job": {"session": 1}})),
|
||||
("render.status", json!({"job": {"session": 1, "serial": 2, "generation": 3, "shot": 4}})),
|
||||
("render.status", json!({"job": {"session": -1, "serial": 2, "generation": 3}})),
|
||||
("render.status", json!({"job": {"session": 1.5, "serial": 2, "generation": 3}})),
|
||||
("render.status", json!({"job": foreign_job()})),
|
||||
("render.cancel", json!({"job": "Shot 1"})),
|
||||
("render.cancel", json!({})),
|
||||
("render.result.pin", json!({"job": [1, 2, 3]})),
|
||||
("render.result.pin", json!({})),
|
||||
("render.result.copy", json!({"job": true})),
|
||||
("render.result.copy", json!({})),
|
||||
("render.result.regrade", json!({"presentation": 5})),
|
||||
("render.result.regrade", json!({"presentation": {"exposure": 1}})),
|
||||
("render.result.regrade", json!({"presentation": {"tone_map": "filmic"}})),
|
||||
("render.result.regrade", json!({"presentation": {"background": [256, 0, 0]}})),
|
||||
("render.result.regrade", json!({"presentation": {"backdrop": {"mode": "diagonal"}}})),
|
||||
("render.result.regrade", json!({"presentation": {"exposure_ev": 40}})),
|
||||
("render.result.regrade", json!({"presentation": {}})),
|
||||
("render.export", json!({"path": 5})),
|
||||
("render.export", json!({"path": "shot.png", "format": "jpg"})),
|
||||
("render.export", json!({"path": "shot.png", "format": null})),
|
||||
("render.export", json!({"path": "shot.png", "overwrite": "yes"})),
|
||||
("render.export", json!({"path": "shot.txt"})),
|
||||
("render.export", json!({"path": "shot.png", "job": foreign_job()})),
|
||||
]);
|
||||
}
|
||||
|
||||
/// #12836: Regrade Linear Master without `presentation` and Export Render Image without `path`
|
||||
/// are refused by the check itself: in strict with the check's "is required", where the
|
||||
/// handler says serde's "missing field".
|
||||
#[test]
|
||||
fn a_key_left_out_is_refused_by_the_check() {
|
||||
for (id, p, key) in [("render.result.regrade", json!({}), "presentation"), ("render.export", json!({"format": "png"}), "path")] {
|
||||
let messages: Vec<String> = crate::schema::issues(id, &p).into_iter().map(|i| i.message).collect();
|
||||
assert_eq!(messages, [format!("`{key}` is required")], "{id} {p}");
|
||||
let error = |level: ParamCheck| Session { param_check: level, ..Session::default() }.execute(id, &p).err().map(|e| e.to_string());
|
||||
assert!(error(ParamCheck::Off).is_some_and(|e| e.ends_with(&format!("missing field `{key}`"))), "{id}: {:?}", error(ParamCheck::Off));
|
||||
assert!(error(ParamCheck::Strict).is_some_and(|e| e.ends_with(&format!("`{key}` is required"))), "{id}: {:?}", error(ParamCheck::Strict));
|
||||
}
|
||||
}
|
||||
|
||||
/// #12836: an unknown key fails in every mode, as the handlers' serde structs refuse it: with
|
||||
/// serde's message with the check off, and in strict with the check's, which suggests `job`.
|
||||
#[test]
|
||||
fn an_unknown_key_is_refused_in_every_mode() {
|
||||
for id in ["render.status", "render.cancel", "render.result.pin", "render.result.copy", "render.result.regrade", "render.export"] {
|
||||
let p = json!({"jobs": null});
|
||||
let error = |level: ParamCheck| Session { param_check: level, ..Session::default() }.execute(id, &p).err().map(|e| e.to_string());
|
||||
assert!(error(ParamCheck::Off).is_some_and(|e| e.contains("unknown field `jobs`")), "{id}: {:?}", error(ParamCheck::Off));
|
||||
assert!(error(ParamCheck::Strict).is_some_and(|e| e.contains("did you mean `job`?")), "{id}: {:?}", error(ParamCheck::Strict));
|
||||
}
|
||||
}
|
||||
|
||||
/// #12836: the contracts take what the input schemas allow: each call's check finds no issue
|
||||
/// exactly when it matches the schema. (What the handlers refuse after parsing, such as an
|
||||
/// exposure of 40 stops or a `.txt` path, both allow.)
|
||||
#[test]
|
||||
fn the_checks_take_what_the_schemas_allow() {
|
||||
let job = json!({"session": 1, "serial": 2, "generation": 3});
|
||||
let cases = [
|
||||
("render.status", json!({}), true),
|
||||
("render.status", json!({"job": null}), true),
|
||||
("render.status", json!({"job": job}), true),
|
||||
("render.status", json!({"job": 5}), false),
|
||||
("render.status", json!({"job": {"session": 1}}), false),
|
||||
("render.status", json!({"job": {"session": 1, "serial": 2, "generation": 3, "shot": 4}}), false),
|
||||
("render.status", json!({"job": {"session": -1, "serial": 2, "generation": 3}}), false),
|
||||
("render.status", json!({"job": {"session": 1.5, "serial": 2, "generation": 3}}), false),
|
||||
("render.result.pin", json!({"job": null}), true),
|
||||
("render.result.regrade", json!({"presentation": {}}), true),
|
||||
(
|
||||
"render.result.regrade",
|
||||
json!({"job": job, "presentation": {"exposure_ev": 1.5, "tone_map": "aces_fitted", "background": [1, 2, 3], "transparent_background": true}}),
|
||||
true,
|
||||
),
|
||||
("render.result.regrade", json!({"presentation": {"exposure_ev": 40}}), true),
|
||||
("render.result.regrade", json!({"presentation": {"backdrop": null}}), true),
|
||||
("render.result.regrade", json!({"presentation": {"backdrop": {"mode": "radial", "center": [0.5, 0.5], "radius": 0.5}}}), true),
|
||||
("render.result.regrade", json!({"presentation": {"finishing": {"contrast": 1.1, "looks": [], "bloom": null}}}), true),
|
||||
("render.result.regrade", json!({"presentation": {"background": [256, 0, 0]}}), false),
|
||||
("render.result.regrade", json!({"presentation": {"tone_map": "filmic"}}), false),
|
||||
("render.result.regrade", json!({"presentation": {"exposure": 1}}), false),
|
||||
("render.result.regrade", json!({"presentation": {"finishing": {"glow": 1}}}), false),
|
||||
("render.result.regrade", json!({"presentation": {"backdrop": {"mode": "diagonal"}}}), false),
|
||||
("render.result.regrade", json!({"presentation": null}), false),
|
||||
("render.export", json!({"path": "shot.png"}), true),
|
||||
("render.export", json!({"path": "shot.txt"}), true),
|
||||
("render.export", json!({"path": "shot.exr", "format": "exr", "overwrite": true, "job": null}), true),
|
||||
("render.export", json!({"path": 5}), false),
|
||||
("render.export", json!({"path": "shot.png", "format": "PNG"}), false),
|
||||
("render.export", json!({"path": "shot.png", "format": null}), false),
|
||||
("render.export", json!({"path": "shot.png", "overwrite": null}), false),
|
||||
];
|
||||
let schemas = crate::cmd::command_specs().into_iter().filter_map(|c| c.contract.map(|contract| (c.id, contract.input_schema())));
|
||||
let schemas: std::collections::HashMap<_, _> = schemas.collect();
|
||||
for (id, p, takes) in cases {
|
||||
let schema = schemas.get(id).unwrap_or_else(|| panic!("{id} has no contract")).as_value();
|
||||
assert_eq!(crate::schema::issues(id, &p).is_empty(), takes, "{id} {p}: {:?}", crate::schema::issues(id, &p));
|
||||
assert_eq!(conforms(schema, schema, &p), takes, "{id} {p}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #12836: each command replies as its output schema says, in Warn without warnings, and the
|
||||
/// replies are the JSON the handlers gave before: a finished job's status and the queue, the
|
||||
/// result selected and cleared, the image copied and exported, a finished job left as it is
|
||||
/// and a running job cancelled.
|
||||
#[test]
|
||||
fn render_job_commands_reply_with_their_schemas() {
|
||||
let host = CpuHost::cooperative();
|
||||
let mut s = Session::default();
|
||||
s.set_render_job_host(Some(host.clone())).unwrap_or_else(|e| panic!("{e}"));
|
||||
let job = start(&mut s);
|
||||
finish(&host);
|
||||
s.param_check = ParamCheck::Warn;
|
||||
|
||||
let progress = output_schema_for::<RenderProgress>();
|
||||
let state = reply(&mut s, "render.status", json!({"job": job}), &progress);
|
||||
let mut before = json!(s.render_jobs.status(job).unwrap_or_else(|e| panic!("{e}")));
|
||||
before["queue"] = json!(s.render_queue_state());
|
||||
assert_eq!(state, before);
|
||||
assert_eq!((&state["progress"]["phase"], &state["frame"]["width"]), (&json!("completed"), &json!(16)), "{state}");
|
||||
let queue = reply(&mut s, "render.status", json!({}), &progress);
|
||||
assert_eq!(queue, json!({"queue": {"running": 0, "waiting": 0, "job": null, "shot": null, "error": null}}));
|
||||
|
||||
let pinned = output_schema_for::<ResultPinned>();
|
||||
assert_eq!(reply(&mut s, "render.result.pin", json!({"job": job}), &pinned), json!({"frame": state["frame"]}));
|
||||
let copied = reply(&mut s, "render.result.copy", json!({}), &output_schema_for::<ImageCopied>());
|
||||
assert_eq!(copied, json!({"frame": state["frame"]}));
|
||||
let path = std::env::temp_dir().join(format!("solidcraft-render-jobs-schema-{}.png", std::process::id()));
|
||||
let exported = reply(&mut s, "render.export", json!({"job": job, "path": path, "overwrite": true}), &output_schema_for::<ImageExported>());
|
||||
let written = std::fs::metadata(&path).map(|m| m.len()).unwrap_or_default();
|
||||
let _ = std::fs::remove_file(&path);
|
||||
assert_eq!(
|
||||
exported,
|
||||
json!({"job": job, "path": path, "bytes": written, "format": "png", "encoding": "srgb", "alpha": "straight",
|
||||
"frame": state["frame"], "linear_master": null, "presentation": null})
|
||||
);
|
||||
assert_eq!(reply(&mut s, "render.result.pin", json!({"job": null}), &pinned), json!({"frame": null}));
|
||||
|
||||
let cancelled = output_schema_for::<RenderCancelled>();
|
||||
let finished = reply(&mut s, "render.cancel", json!({"job": job}), &cancelled);
|
||||
assert_eq!(finished, json!(s.render_jobs.status(job).unwrap_or_else(|e| panic!("{e}"))));
|
||||
assert_eq!(finished["progress"]["phase"], "completed", "a finished job is left as it is");
|
||||
let running = start(&mut s);
|
||||
let stopping = reply(&mut s, "render.cancel", json!({"job": running}), &cancelled);
|
||||
assert!(["cancelling", "cancelled"].contains(&stopping["progress"]["phase"].as_str().unwrap_or_default()), "{stopping}");
|
||||
finish(&host);
|
||||
}
|
||||
|
||||
/// #12836: Regrade Linear Master replies as its output schema says, and so does Export Render
|
||||
/// Image for the linear master (EXR) and for the regraded image, whose replies describe the
|
||||
/// master and the presentation.
|
||||
#[cfg(all(feature = "raytrace-cpu", not(target_arch = "wasm32")))]
|
||||
#[test]
|
||||
fn regrade_and_export_of_a_linear_master_reply_with_their_schemas() {
|
||||
let mut s = Session::default();
|
||||
s.execute(
|
||||
"render.settings.set",
|
||||
&json!({"name": "Master", "set": {"width": 8, "height": 8, "job": {"method": "cpu_raytrace",
|
||||
"options": {"progressive": {"samples_per_pixel": 1, "seed": 7}, "lights": [], "output": {"linear_exr": true}}}}}),
|
||||
)
|
||||
.unwrap_or_else(|e| panic!("{e}"));
|
||||
s.set_render_job_host(Some(CpuHost::for_platform().unwrap_or_else(|e| panic!("{e}")))).unwrap_or_else(|e| panic!("{e}"));
|
||||
let wait = |s: &Session, job: JobId| {
|
||||
let status = s.render_job_handle(job).and_then(|h| h.wait_timeout(std::time::Duration::from_secs(30)).map_err(crate::render_jobs::job_error));
|
||||
assert_eq!(status.map(|status| status.progress.phase).ok(), Some(Phase::Completed));
|
||||
};
|
||||
let started = s.execute("render.start", &json!({"setup": "Master"})).unwrap_or_else(|e| panic!("{e}"));
|
||||
let master: JobId = serde_json::from_value(started["job"].clone()).unwrap_or_else(|e| panic!("{e}"));
|
||||
wait(&s, master);
|
||||
s.param_check = ParamCheck::Warn;
|
||||
|
||||
let regraded =
|
||||
reply(&mut s, "render.result.regrade", json!({"job": master, "presentation": {"exposure_ev": -1}}), &output_schema_for::<super::JobState>());
|
||||
let regraded: JobId = serde_json::from_value(regraded["job"].clone()).unwrap_or_else(|e| panic!("{e}"));
|
||||
wait(&s, regraded);
|
||||
let exported = output_schema_for::<ImageExported>();
|
||||
for (job, format) in [(master, "exr"), (regraded, "png")] {
|
||||
let path = std::env::temp_dir().join(format!("solidcraft-render-jobs-schema-{}-{}.{format}", std::process::id(), job.serial));
|
||||
let r = reply(&mut s, "render.export", json!({"job": job, "path": path, "format": format, "overwrite": true}), &exported);
|
||||
let _ = std::fs::remove_file(&path);
|
||||
assert!(r["linear_master"].is_object(), "{r}");
|
||||
if job == regraded {
|
||||
assert!(r["presentation"].is_object(), "{r}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
//! Contracts for the surface edit commands in `cmd/surface.rs` (#12627): Stitch, Unstitch, Reverse
|
||||
//! Normal, Thicken, Offset Surface, Untrim and Fair Surface. Each adds a feature and returns
|
||||
//! `add_feature`'s reply, a [`super::features::FeatureResult`].
|
||||
//!
|
||||
//! The handlers read their parameters from the raw JSON, so each key here reads as its handler
|
||||
//! reads it and fails with the handler's message. Whether a body or a face exists, and whether a
|
||||
//! value evaluates and fits the surface, stay the handlers' to check. Where a handler ignores a
|
||||
//! key of the wrong type (a `chain` that isn't true or false, an item of `faces` that isn't a
|
||||
//! point, which the handlers skip), the contract refuses it.
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
|
||||
use serde::de::Error as _;
|
||||
use serde::{Deserialize, Deserializer};
|
||||
use serde_json::Value;
|
||||
|
||||
use super::check::whole;
|
||||
use super::config::NameList;
|
||||
use super::motion::require;
|
||||
use super::types::{FeatureCommon, LengthExpr, OperationParam, Point3, TouchingParam, feature_common, present};
|
||||
use crate::params::{self, MAX_STRING_LIST};
|
||||
|
||||
/// The most faces, or holes, one call may pick, as `cmd/surface.rs` reads them.
|
||||
const MAX_PICKS: usize = 10_000;
|
||||
|
||||
/// A point's schema, as [`Point3`] publishes it: [x, y, z] or {x, y, z}, in mm.
|
||||
fn point_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
generator.subschema_for::<Point3>()
|
||||
}
|
||||
|
||||
/// `body` for Unstitch, Reverse Normal and Fair Surface: text, with the handler's message for
|
||||
/// anything else (`body_faces` reads a body that isn't text as left out).
|
||||
fn body_name<'de, D: Deserializer<'de>>(d: D) -> Result<String, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::String(name) => Ok(name),
|
||||
_ => Err(D::Error::custom(whole("`body` is required"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Points on faces of one body, at most 10 000: the faces Unstitch takes out, Reverse Normal turns
|
||||
/// over or Fair Surface fairs. The handlers skip an item that isn't a point, and read `faces`
|
||||
/// that isn't a list as none; the contract refuses both.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct FacePoints(pub Vec<Value>);
|
||||
|
||||
impl<'de> Deserialize<'de> for FacePoints {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::Array(points) if points.len() > MAX_PICKS => Err(D::Error::custom(whole("too many faces"))),
|
||||
Value::Array(points) if points.iter().all(|p| params::vec3(p).is_some()) => Ok(FacePoints(points)),
|
||||
_ => Err(D::Error::custom("must list points [x, y, z] on the body's faces")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for FacePoints {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"FacePoints".into()
|
||||
}
|
||||
fn inline_schema() -> bool {
|
||||
true
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"type": "array", "items": point_schema(generator), "maxItems": MAX_PICKS})
|
||||
}
|
||||
}
|
||||
|
||||
/// The handlers' message for `bodies` with no body in it.
|
||||
const BODIES_MESSAGE: &str = "`bodies` must list bodies";
|
||||
|
||||
/// `bodies` for Stitch and Thicken: a name or a list of 1 to 10 000 names, read as
|
||||
/// `params::string_list` reads it (through [`NameList`]), with its messages. `string_list` reads
|
||||
/// `null` as no names, which the handlers then refuse; the contract refuses `null` and an empty
|
||||
/// list with that message.
|
||||
fn surface_bodies<'de, D: Deserializer<'de>>(d: D) -> Result<Option<NameList>, D::Error> {
|
||||
let v = Value::deserialize(d)?;
|
||||
let names = if v.is_null() { NameList::default() } else { NameList::deserialize(v).map_err(D::Error::custom)? };
|
||||
if names.0.is_empty() { Err(D::Error::custom(whole(BODIES_MESSAGE))) } else { Ok(Some(names)) }
|
||||
}
|
||||
|
||||
/// The schema of [`surface_bodies`]. Not `NameList`'s own, which allows the empty list.
|
||||
fn bodies_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "The surface bodies, by name: one, or a list of 1 to 10 000.",
|
||||
"anyOf": [
|
||||
{"type": "string"},
|
||||
{"type": "array", "items": {"type": "string"}, "minItems": 1, "maxItems": MAX_STRING_LIST}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
fn bodies_required(schema: &mut Schema) {
|
||||
require(schema, "bodies");
|
||||
}
|
||||
|
||||
/// Sew surface bodies into the first one along the edges they share; a solid when they close. One
|
||||
/// undo step.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = bodies_required)]
|
||||
pub struct StitchParams {
|
||||
// Optional in the type, required in the schema: left out, the handler refuses the call with
|
||||
// its own message ("`bodies` must list bodies"), which the contract gives `null` and `[]`.
|
||||
#[serde(default, deserialize_with = "surface_bodies")]
|
||||
#[schemars(schema_with = "bodies_schema")]
|
||||
pub bodies: Option<NameList>,
|
||||
/// How far apart two edges may be and still be sewn together. Default: 0, edges that meet.
|
||||
#[serde(default)]
|
||||
pub tolerance: Option<LengthExpr>,
|
||||
#[serde(flatten, deserialize_with = "feature_common")]
|
||||
pub common: FeatureCommon,
|
||||
}
|
||||
|
||||
/// Take faces out of a body, each a surface body of its own; the rest stay together under the
|
||||
/// body's name. A solid comes apart into surfaces. One undo step.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct UnstitchParams {
|
||||
/// The body, by name.
|
||||
#[serde(deserialize_with = "body_name")]
|
||||
pub body: String,
|
||||
/// Points on the faces to take out. Default: every face, each its own surface body.
|
||||
#[serde(default)]
|
||||
pub faces: Option<FacePoints>,
|
||||
#[serde(flatten, deserialize_with = "feature_common")]
|
||||
pub common: FeatureCommon,
|
||||
}
|
||||
|
||||
/// Turn faces of a surface body over, so their normals point the other way. One undo step.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct ReverseNormalParams {
|
||||
/// The surface body, by name.
|
||||
#[serde(deserialize_with = "body_name")]
|
||||
pub body: String,
|
||||
/// Points on the faces to turn over. Default: every face.
|
||||
#[serde(default)]
|
||||
pub faces: Option<FacePoints>,
|
||||
#[serde(flatten, deserialize_with = "feature_common")]
|
||||
pub common: FeatureCommon,
|
||||
}
|
||||
|
||||
/// Give surface bodies a thickness, making solids, and combine them with other bodies. One undo
|
||||
/// step.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = bodies_required)]
|
||||
pub struct ThickenParams {
|
||||
// Required in the schema (`bodies_required`), optional in the type, as Stitch's.
|
||||
#[serde(default, deserialize_with = "surface_bodies")]
|
||||
#[schemars(schema_with = "bodies_schema")]
|
||||
pub bodies: Option<NameList>,
|
||||
/// How thick, along each surface's normal; negative: against it.
|
||||
pub thickness: LengthExpr,
|
||||
/// True: half the thickness each side of the surface. Default: false.
|
||||
#[serde(default)]
|
||||
pub symmetric: Option<bool>,
|
||||
/// How the thickened bodies combine with existing ones. Default: new.
|
||||
#[serde(default)]
|
||||
pub operation: Option<OperationParam>,
|
||||
/// The bodies a join, cut or intersect works on, by name. Default: every body in the active
|
||||
/// component.
|
||||
#[serde(default)]
|
||||
pub targets: Option<NameList>,
|
||||
/// 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,
|
||||
}
|
||||
|
||||
/// Points on the faces to offset, 1 to 10 000, with the handler's message for anything else.
|
||||
/// The handler skips an item that isn't a point and refuses the call only when none is left; the
|
||||
/// contract refuses any such item.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct OffsetFaces(pub Vec<Value>);
|
||||
|
||||
/// The handler's message for `faces` it can't use.
|
||||
const OFFSET_FACES_MESSAGE: &str = "`faces` must list points on the faces to offset";
|
||||
|
||||
impl<'de> Deserialize<'de> for OffsetFaces {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::Array(points) if (1..=MAX_PICKS).contains(&points.len()) && points.iter().all(|p| params::vec3(p).is_some()) => {
|
||||
Ok(OffsetFaces(points))
|
||||
}
|
||||
_ => Err(D::Error::custom(whole(OFFSET_FACES_MESSAGE))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for OffsetFaces {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"OffsetFaces".into()
|
||||
}
|
||||
fn inline_schema() -> bool {
|
||||
true
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "Points on the faces to offset: 1 to 10 000.",
|
||||
"type": "array",
|
||||
"items": point_schema(generator),
|
||||
"minItems": 1,
|
||||
"maxItems": MAX_PICKS
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn faces_required(schema: &mut Schema) {
|
||||
require(schema, "faces");
|
||||
}
|
||||
|
||||
/// Copy faces at a distance along their normals, as a new surface body (a solid where its faces
|
||||
/// close). Planes and cylinders move exactly; other faces are fitted. One undo step.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = faces_required)]
|
||||
pub struct OffsetParams {
|
||||
// Optional in the type, required in the schema: left out, the handler refuses the call with
|
||||
// its own message, which the contract gives `null` and an empty list (`present`: a `null`
|
||||
// goes to `OffsetFaces`, which refuses it).
|
||||
#[serde(default, deserialize_with = "present")]
|
||||
#[schemars(with = "OffsetFaces")]
|
||||
pub faces: Option<OffsetFaces>,
|
||||
/// The body the faces are on, by name. Default: the body at the points.
|
||||
#[serde(default)]
|
||||
pub body: Option<String>,
|
||||
/// How far, along the faces' normals; negative: against them; 0 copies the faces.
|
||||
pub distance: LengthExpr,
|
||||
/// True: each point picks its face's whole chain of smoothly joined faces. Default: false.
|
||||
#[serde(default)]
|
||||
pub chain: Option<bool>,
|
||||
/// How far a fitted face may stray from the true offset. Default: 0.01 mm.
|
||||
#[serde(default)]
|
||||
pub tolerance: Option<LengthExpr>,
|
||||
#[serde(flatten, deserialize_with = "feature_common")]
|
||||
pub common: FeatureCommon,
|
||||
}
|
||||
|
||||
/// Untrim's `face`: `{body, point}`, a point on a face of a body, read as the handler reads it,
|
||||
/// with its messages. Other keys in it are read past.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct UntrimFace(pub Value);
|
||||
|
||||
impl<'de> Deserialize<'de> for UntrimFace {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let face = Value::deserialize(d)?;
|
||||
if !face.get("body").is_some_and(Value::is_string) {
|
||||
return Err(D::Error::custom(whole("`face.body` must name a body")));
|
||||
}
|
||||
if face.get("point").and_then(params::vec3).is_none() {
|
||||
return Err(D::Error::custom(whole("`face.point` must be [x, y, z] on the face")));
|
||||
}
|
||||
Ok(UntrimFace(face))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for UntrimFace {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"UntrimFace".into()
|
||||
}
|
||||
fn inline_schema() -> bool {
|
||||
true
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "The face to untrim: `body`, by name, and `point`, a point on the face.",
|
||||
"type": "object",
|
||||
"required": ["body", "point"],
|
||||
"properties": {"body": {"type": "string"}, "point": point_schema(generator)}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Untrim's `holes`: `"all"`, or points on the inner loops to remove, read as the handler reads
|
||||
/// them, with its messages. `null` is left out (`Option`), as the handler reads it: every hole.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct UntrimHoles(pub Value);
|
||||
|
||||
impl<'de> Deserialize<'de> for UntrimHoles {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
v @ Value::String(_) if v == "all" => Ok(UntrimHoles(v)),
|
||||
Value::Array(points) if points.len() > MAX_PICKS => Err(D::Error::custom(whole("too many holes"))),
|
||||
Value::Array(points) if points.iter().all(|p| params::vec3(p).is_some()) => Ok(UntrimHoles(Value::Array(points))),
|
||||
Value::Array(_) => Err(D::Error::custom(whole("`holes` must list points [x, y, z] on the inner loops"))),
|
||||
_ => Err(D::Error::custom(whole("`holes` must be \"all\" or a list of points [x, y, z] on the inner loops"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for UntrimHoles {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"UntrimHoles".into()
|
||||
}
|
||||
fn inline_schema() -> bool {
|
||||
true
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "The holes to remove: \"all\" (the default), or points on their inner loops (at most 10 000). An empty list removes none, for `outer` alone.",
|
||||
"anyOf": [
|
||||
{"type": "string", "enum": ["all"]},
|
||||
{"type": "array", "items": point_schema(generator), "maxItems": MAX_PICKS},
|
||||
{"type": "null"}
|
||||
]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn face_required(schema: &mut Schema) {
|
||||
require(schema, "face");
|
||||
}
|
||||
|
||||
/// Remove a face's holes, or restore its outer boundary, as a surface body; the face keeps its
|
||||
/// name. A solid's face is copied out. One undo step.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = face_required)]
|
||||
pub struct UntrimParams {
|
||||
// Optional in the type, required in the schema: left out, the handler refuses the call with
|
||||
// its own message ("`face` is required: {body, point}"). A `null` goes to `UntrimFace`
|
||||
// (`present`), which refuses it as the handler does.
|
||||
#[serde(default, deserialize_with = "present")]
|
||||
#[schemars(with = "UntrimFace")]
|
||||
pub face: Option<UntrimFace>,
|
||||
#[serde(default)]
|
||||
pub holes: Option<UntrimHoles>,
|
||||
/// True: restore the outer boundary to the surface's natural bounds (a free-form surface's
|
||||
/// own, an analytic surface's span of the face). Default: false.
|
||||
#[serde(default)]
|
||||
pub outer: Option<bool>,
|
||||
/// True: untrim a surface body's face where it is, not as a copy. A solid's face is always
|
||||
/// copied out. Default: false.
|
||||
#[serde(default)]
|
||||
pub in_place: Option<bool>,
|
||||
#[serde(flatten, deserialize_with = "feature_common")]
|
||||
pub common: FeatureCommon,
|
||||
}
|
||||
|
||||
/// Fair Surface's `strength`: above 0 and at most 1, with the handler's message for a number out
|
||||
/// of range. The handler ignores one that isn't a number; the contract refuses it.
|
||||
fn fair_strength<'de, D: Deserializer<'de>>(d: D) -> Result<Option<f64>, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::Null => Ok(None),
|
||||
Value::Number(n) => n
|
||||
.as_f64()
|
||||
.filter(|x| *x > 0.0 && *x <= 1.0)
|
||||
.map(Some)
|
||||
.ok_or_else(|| D::Error::custom(whole("`strength` must be greater than 0 and at most 1 (the whole fairing step)"))),
|
||||
_ => Err(D::Error::custom("must be a number")),
|
||||
}
|
||||
}
|
||||
|
||||
fn fair_strength_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "How much of the full fairing step each iteration takes: above 0, at most 1. Default: 1.",
|
||||
"type": ["number", "null"],
|
||||
"exclusiveMinimum": 0,
|
||||
"maximum": 1
|
||||
})
|
||||
}
|
||||
|
||||
/// Fair Surface's `iterations`: 1 to 1000 (a fraction is cut to a whole number), with the
|
||||
/// handler's message for a number out of range. The handler ignores one that isn't a number; the
|
||||
/// contract refuses it.
|
||||
fn fair_iterations<'de, D: Deserializer<'de>>(d: D) -> Result<Option<f64>, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::Null => Ok(None),
|
||||
Value::Number(n) => {
|
||||
n.as_f64().filter(|x| (1.0..=1000.0).contains(x)).map(Some).ok_or_else(|| D::Error::custom(whole("`iterations` must be 1 to 1000")))
|
||||
}
|
||||
_ => Err(D::Error::custom("must be a number")),
|
||||
}
|
||||
}
|
||||
|
||||
fn fair_iterations_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "How many fairing steps: 1 to 1000. Default: 1.",
|
||||
"type": ["number", "null"],
|
||||
"minimum": 1,
|
||||
"maximum": 1000
|
||||
})
|
||||
}
|
||||
|
||||
/// Fair a surface body's free-form faces: smooth their control points so curvature changes
|
||||
/// evenly, staying within `tolerance` of where they are. One undo step.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct FairParams {
|
||||
/// The surface body, by name.
|
||||
#[serde(deserialize_with = "body_name")]
|
||||
pub body: String,
|
||||
/// Points on the faces to fair. Default: every face.
|
||||
#[serde(default)]
|
||||
pub faces: Option<FacePoints>,
|
||||
#[serde(default, deserialize_with = "fair_strength")]
|
||||
#[schemars(schema_with = "fair_strength_schema")]
|
||||
pub strength: Option<f64>,
|
||||
#[serde(default, deserialize_with = "fair_iterations")]
|
||||
#[schemars(schema_with = "fair_iterations_schema")]
|
||||
pub iterations: Option<f64>,
|
||||
/// True: hold each face's boundary row of control points and the row next to it, so the face
|
||||
/// keeps meeting its neighbours in position and tangent. Default: true.
|
||||
#[serde(default)]
|
||||
pub keep_edges: Option<bool>,
|
||||
/// How far a control point may move. Default: a thousandth of the body's size.
|
||||
#[serde(default)]
|
||||
pub tolerance: Option<LengthExpr>,
|
||||
#[serde(flatten, deserialize_with = "feature_common")]
|
||||
pub common: FeatureCommon,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "surface_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,324 @@
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{FairParams, OffsetParams, ReverseNormalParams, StitchParams, ThickenParams, UnstitchParams, UntrimParams};
|
||||
use crate::Session;
|
||||
use crate::schema::features::FeatureResult;
|
||||
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms};
|
||||
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
|
||||
|
||||
fn session(level: ParamCheck, calls: &[(&str, Value)]) -> Session {
|
||||
let mut s = Session { param_check: level, ..Session::default() };
|
||||
for (id, p) in calls {
|
||||
s.execute(id, p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// A 10 mm cube, `Box`.
|
||||
fn cube(level: ParamCheck) -> Session {
|
||||
session(level, &[("solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Box"}))])
|
||||
}
|
||||
|
||||
/// A flat 10 × 10 mm surface on XY, `Sheet`.
|
||||
fn sheet(level: ParamCheck) -> Session {
|
||||
session(
|
||||
level,
|
||||
&[
|
||||
("sketch.create", json!({"plane": "XY", "name": "S"})),
|
||||
("sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 10]})),
|
||||
("sketch.finish", json!({})),
|
||||
("surface.patch", json!({"sketch": "S", "body_name": "Sheet"})),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
/// A 40 × 20 × 2 mm plate, `Plate`, with three round holes through it at x = 10, 20 and 30.
|
||||
fn plate(level: ParamCheck) -> Session {
|
||||
let mut calls = vec![("solid.box", json!({"length": 40, "width": 20, "height": 2, "body_name": "Plate"}))];
|
||||
for x in [10, 20, 30] {
|
||||
calls.push(("solid.cylinder", json!({"radius": 2, "height": 4, "base": [x, 10, -1], "operation": "cut", "targets": ["Plate"]})));
|
||||
}
|
||||
session(level, &calls)
|
||||
}
|
||||
|
||||
/// A fitted B-spline surface, `Shell`: the patch on the rim of a box whose top a sideways
|
||||
/// cylinder cut away, as Fair Surface's own test makes it.
|
||||
fn shell(level: ParamCheck) -> Session {
|
||||
let z = |x: f64| 50.0 - (1600.0 - x * x).sqrt();
|
||||
let rim = json!([[14, 0, z(14.0)], [0, 14, z(0.0)], [-14, 0, z(-14.0)], [0, -14, z(0.0)]]);
|
||||
session(
|
||||
level,
|
||||
&[
|
||||
("solid.box", json!({"length": 28, "width": 28, "height": 20, "corner": [-14, -14, 0], "body_name": "Saddle"})),
|
||||
("solid.cylinder", json!({"radius": 40, "height": 120, "base": [0, -60, 50], "axis": "Y", "operation": "cut", "targets": ["Saddle"]})),
|
||||
("surface.patch", json!({"edges": rim, "body": "Saddle", "body_name": "Shell"})),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
/// The holed plate's top face, for Untrim.
|
||||
fn plate_top() -> Value {
|
||||
json!({"body": "Plate", "point": [5, 5, 2]})
|
||||
}
|
||||
|
||||
/// `n` copies of a point.
|
||||
fn points(n: usize) -> Value {
|
||||
json!(vec![[5, 5, 10]; n])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schemas_and_the_key_check_agree() {
|
||||
let common = ["body_name", "body_names", "name", "participants"];
|
||||
let with = |own: &[&'static str]| {
|
||||
let mut keys: Vec<&str> = own.iter().chain(common.iter()).copied().collect();
|
||||
keys.sort_unstable();
|
||||
keys
|
||||
};
|
||||
assert_keys(input_schema_for::<StitchParams>(), &with(&["bodies", "tolerance"]));
|
||||
assert_keys(input_schema_for::<UnstitchParams>(), &with(&["body", "faces"]));
|
||||
assert_keys(input_schema_for::<ReverseNormalParams>(), &with(&["body", "faces"]));
|
||||
assert_keys(input_schema_for::<ThickenParams>(), &with(&["bodies", "operation", "symmetric", "targets", "thickness", "touching"]));
|
||||
assert_keys(input_schema_for::<OffsetParams>(), &with(&["body", "chain", "distance", "faces", "tolerance"]));
|
||||
assert_keys(input_schema_for::<UntrimParams>(), &with(&["face", "holes", "in_place", "outer"]));
|
||||
assert_keys(input_schema_for::<FairParams>(), &with(&["body", "faces", "iterations", "keep_edges", "strength", "tolerance"]));
|
||||
}
|
||||
|
||||
/// As `assert_strict_keeps_todays_errors`, on the design `fixture` makes: for the calls that need
|
||||
/// a body to get past the handler's first checks.
|
||||
fn assert_strict_keeps_errors_on(fixture: fn(ParamCheck) -> Session, cases: &[(&str, Value)]) {
|
||||
for (id, p) in cases {
|
||||
let off = fixture(ParamCheck::Off).execute(id, p).err().map(|e| e.to_string());
|
||||
assert!(off.is_some(), "{id} {p} should fail today");
|
||||
assert_eq!(fixture(ParamCheck::Strict).execute(id, p).err().map(|e| e.to_string()), off, "{id} {p}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #12627: a `null` or empty `bodies` (Stitch, Thicken), and a `null` `faces` (Offset Surface)
|
||||
/// or `face` (Untrim), which the handlers refuse, are refused by the contracts too, with the
|
||||
/// handlers' messages. So the check (warn, strict, MCP validation, batch preflight) catches them
|
||||
/// before the handler runs. Only a key left out goes on to the handler, for its own message.
|
||||
#[test]
|
||||
fn the_contracts_refuse_null_and_empty_where_the_handlers_do() {
|
||||
for (id, p, message) in [
|
||||
("surface.stitch", json!({"bodies": null}), "`bodies` must list bodies"),
|
||||
("surface.stitch", json!({"bodies": []}), "`bodies` must list bodies"),
|
||||
("surface.thicken", json!({"bodies": null, "thickness": 1}), "`bodies` must list bodies"),
|
||||
("surface.thicken", json!({"bodies": [], "thickness": 1}), "`bodies` must list bodies"),
|
||||
("surface.offset", json!({"faces": null, "distance": 1}), "`faces` must list points on the faces to offset"),
|
||||
("surface.untrim", json!({"face": null}), "`face.body` must name a body"),
|
||||
] {
|
||||
let messages: Vec<String> = crate::schema::issues(id, &p).into_iter().map(|i| i.message).collect();
|
||||
assert_eq!(messages, [message], "{id} {p}");
|
||||
}
|
||||
for (id, p) in [("surface.stitch", json!({})), ("surface.offset", json!({"distance": 1})), ("surface.untrim", json!({}))] {
|
||||
assert!(crate::schema::issues(id, &p).is_empty(), "{id} {p}: left out, the handler refuses it");
|
||||
}
|
||||
}
|
||||
|
||||
/// #12627: calls that fail today get the same message in strict. The first ones fail on the
|
||||
/// shared helper's sketch, which has no body; the rest need the cube, the sheet, the plate or the
|
||||
/// shell to get past the handler's check of the body.
|
||||
#[test]
|
||||
fn strict_keeps_todays_errors() {
|
||||
assert_strict_keeps_todays_errors(&[
|
||||
("surface.stitch", json!({})),
|
||||
("surface.stitch", json!({"bodies": null})),
|
||||
("surface.stitch", json!({"bodies": []})),
|
||||
("surface.stitch", json!({"bodies": 5})),
|
||||
("surface.stitch", json!({"bodies": ["Sheet", 5]})),
|
||||
("surface.stitch", json!({"bodies": ["Nope"]})),
|
||||
("surface.unstitch", json!({})),
|
||||
("surface.unstitch", json!({"body": null})),
|
||||
("surface.unstitch", json!({"body": 5})),
|
||||
("surface.unstitch", json!({"body": "Nope"})),
|
||||
("surface.reverse_normal", json!({"body": ["Sheet"]})),
|
||||
("surface.thicken", json!({"bodies": null, "thickness": 1})),
|
||||
("surface.thicken", json!({"bodies": ["Nope"], "thickness": 1})),
|
||||
("surface.offset", json!({"distance": 1})),
|
||||
("surface.offset", json!({"faces": null, "distance": 1})),
|
||||
("surface.offset", json!({"faces": [], "distance": 1})),
|
||||
("surface.offset", json!({"faces": 5, "distance": 1})),
|
||||
("surface.offset", json!({"faces": ["top"], "distance": 1})),
|
||||
("surface.offset", json!({"faces": points(10_001), "distance": 1})),
|
||||
("surface.offset", json!({"faces": [[5, 5, 10]]})),
|
||||
("surface.offset", json!({"faces": [[5, 5, 10]], "distance": "nope + 1"})),
|
||||
("surface.offset", json!({"faces": [[5, 5, 10]], "distance": 1, "tolerance": "2 deg"})),
|
||||
("surface.untrim", json!({})),
|
||||
("surface.untrim", json!({"face": null})),
|
||||
("surface.untrim", json!({"face": 5})),
|
||||
("surface.untrim", json!({"face": {"point": [5, 5, 2]}})),
|
||||
("surface.untrim", json!({"face": {"body": "Nope", "point": [5, 5, 2]}})),
|
||||
("surface.fair", json!({"body": 5})),
|
||||
("surface.fair", json!({"body": "Nope"})),
|
||||
]);
|
||||
assert_strict_keeps_errors_on(cube, &[("surface.unstitch", json!({"body": "Box", "faces": points(10_001)}))]);
|
||||
assert_strict_keeps_errors_on(
|
||||
sheet,
|
||||
&[
|
||||
("surface.stitch", json!({"bodies": ["Sheet"], "tolerance": "2 deg"})),
|
||||
("surface.thicken", json!({"bodies": ["Sheet"], "thickness": "nope"})),
|
||||
("surface.thicken", json!({"bodies": ["Sheet"], "thickness": 1, "operation": "glue"})),
|
||||
("surface.thicken", json!({"bodies": ["Sheet"], "thickness": 1, "targets": 5})),
|
||||
],
|
||||
);
|
||||
let many = vec![[22, 10, 2]; 10_001];
|
||||
assert_strict_keeps_errors_on(
|
||||
plate,
|
||||
&[
|
||||
("surface.untrim", json!({"face": {"body": "Plate"}})),
|
||||
("surface.untrim", json!({"face": {"body": "Plate", "point": [5, 5]}})),
|
||||
("surface.untrim", json!({"face": plate_top(), "holes": "some"})),
|
||||
("surface.untrim", json!({"face": plate_top(), "holes": 5})),
|
||||
("surface.untrim", json!({"face": plate_top(), "holes": [[22, 10]]})),
|
||||
("surface.untrim", json!({"face": plate_top(), "holes": many})),
|
||||
// Nothing to untrim: no holes, and the outer boundary kept.
|
||||
("surface.untrim", json!({"face": plate_top(), "holes": []})),
|
||||
],
|
||||
);
|
||||
assert_strict_keeps_errors_on(
|
||||
shell,
|
||||
&[
|
||||
("surface.fair", json!({"body": "Shell", "strength": 2})),
|
||||
("surface.fair", json!({"body": "Shell", "strength": 0})),
|
||||
("surface.fair", json!({"body": "Shell", "iterations": 0})),
|
||||
("surface.fair", json!({"body": "Shell", "iterations": 1001})),
|
||||
("surface.fair", json!({"body": "Shell", "tolerance": "nope"})),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// #12627: keys of the wrong type that the handlers ignore today (an item of `faces` that isn't
|
||||
/// a point is skipped; a `chain`, `outer` or `strength` that isn't the right type is read as left
|
||||
/// out) are refused by the contracts: in Warn the call still works, with a warning.
|
||||
#[test]
|
||||
fn the_contracts_refuse_what_the_handlers_ignore() {
|
||||
let fair = |extra: Value| {
|
||||
let mut p = json!({"body": "Shell", "tolerance": 1.0});
|
||||
if let (Some(p), Value::Object(extra)) = (p.as_object_mut(), extra) {
|
||||
p.extend(extra);
|
||||
}
|
||||
p
|
||||
};
|
||||
let cases: Vec<(fn(ParamCheck) -> Session, &str, Value, &str)> = vec![
|
||||
(cube, "surface.unstitch", json!({"body": "Box", "faces": [[5, 5, 10], "top"]}), "faces"),
|
||||
(cube, "surface.unstitch", json!({"body": "Box", "faces": 5}), "faces"),
|
||||
(sheet, "surface.reverse_normal", json!({"body": "Sheet", "faces": "all"}), "faces"),
|
||||
(sheet, "surface.stitch", json!({"bodies": ["Sheet"], "tolerance": true}), "tolerance"),
|
||||
(sheet, "surface.thicken", json!({"bodies": ["Sheet"], "thickness": 1, "symmetric": "yes"}), "symmetric"),
|
||||
(sheet, "surface.thicken", json!({"bodies": ["Sheet"], "thickness": 1, "operation": 5}), "operation"),
|
||||
(cube, "surface.offset", json!({"faces": [[5, 5, 10], "top"], "distance": 1}), "faces"),
|
||||
(cube, "surface.offset", json!({"faces": [[5, 5, 10]], "distance": 1, "body": 5}), "body"),
|
||||
(cube, "surface.offset", json!({"faces": [[5, 5, 10]], "distance": 1, "chain": "yes"}), "chain"),
|
||||
(cube, "surface.offset", json!({"faces": [[5, 5, 10]], "distance": 1, "tolerance": true}), "tolerance"),
|
||||
(plate, "surface.untrim", json!({"face": plate_top(), "outer": "yes"}), "outer"),
|
||||
(plate, "surface.untrim", json!({"face": plate_top(), "in_place": 1}), "in_place"),
|
||||
(shell, "surface.fair", fair(json!({"strength": "0.5"})), "strength"),
|
||||
(shell, "surface.fair", fair(json!({"iterations": "3"})), "iterations"),
|
||||
(shell, "surface.fair", fair(json!({"keep_edges": "no"})), "keep_edges"),
|
||||
];
|
||||
for (fixture, id, p, key) in cases {
|
||||
assert!(fixture(ParamCheck::Off).execute(id, &p).is_ok(), "{id} {p} works today");
|
||||
let warned = fixture(ParamCheck::Warn).execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
assert!(warned["param_warnings"].to_string().contains(key), "{warned}");
|
||||
let error = fixture(ParamCheck::Strict).execute(id, &p).err().map(|e| e.to_string()).unwrap_or_default();
|
||||
assert!(error.contains(&format!("`{key}`")), "{id} {p}: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #12627: the schemas take what the handlers take. Each call is checked against both the schema
|
||||
/// and the handler, with the check off, so the two can't drift apart: Offset Surface on the cube,
|
||||
/// Untrim on the plate and Fair Surface on the shell.
|
||||
#[test]
|
||||
fn the_schemas_take_what_the_handlers_take() {
|
||||
let agree = |fixture: fn(ParamCheck) -> Session, id: &str, schema: schemars::Schema, calls: &[Value]| {
|
||||
let schema = schema.as_value();
|
||||
for p in calls {
|
||||
let takes = fixture(ParamCheck::Off).execute(id, p).is_ok();
|
||||
assert_eq!(conforms(schema, schema, p), takes, "{id} {p}");
|
||||
}
|
||||
};
|
||||
agree(
|
||||
cube,
|
||||
"surface.offset",
|
||||
input_schema_for::<OffsetParams>(),
|
||||
&[
|
||||
json!({"faces": [[5, 5, 10]], "distance": 2}),
|
||||
json!({"faces": [{"x": 5, "y": 5, "z": 10}], "distance": "2 mm", "chain": true, "tolerance": "0.05 mm", "body": "Box"}),
|
||||
json!({"faces": [[5, 5, 10]], "distance": 0, "body_name": "Copy"}),
|
||||
json!({"faces": [], "distance": 2}),
|
||||
json!({"faces": [[5, 5]], "distance": 2}),
|
||||
json!({"faces": [[5, 5, 10]], "distance": ""}),
|
||||
json!({"faces": [[5, 5, 10]]}),
|
||||
],
|
||||
);
|
||||
agree(
|
||||
plate,
|
||||
"surface.untrim",
|
||||
input_schema_for::<UntrimParams>(),
|
||||
&[
|
||||
json!({"face": plate_top()}),
|
||||
json!({"face": plate_top(), "holes": "all"}),
|
||||
json!({"face": plate_top(), "holes": null}),
|
||||
json!({"face": plate_top(), "holes": [[22, 10, 2]], "body_name": "Two"}),
|
||||
json!({"face": {"body": "Plate", "point": [5, 5, 2], "note": "top"}}),
|
||||
json!({"face": plate_top(), "holes": "All"}),
|
||||
json!({"face": plate_top(), "holes": [[22, 10]]}),
|
||||
json!({"face": {"body": 5, "point": [5, 5, 2]}}),
|
||||
json!({"holes": "all"}),
|
||||
],
|
||||
);
|
||||
agree(
|
||||
shell,
|
||||
"surface.fair",
|
||||
input_schema_for::<FairParams>(),
|
||||
&[
|
||||
json!({"body": "Shell", "tolerance": 1.0}),
|
||||
json!({"body": "Shell", "tolerance": 1.0, "strength": 0.5, "iterations": 2.5, "keep_edges": false}),
|
||||
json!({"body": "Shell", "tolerance": 1.0, "strength": 1, "iterations": 1}),
|
||||
json!({"body": "Shell", "tolerance": 1.0, "strength": 1.5}),
|
||||
json!({"body": "Shell", "tolerance": 1.0, "iterations": 0.5}),
|
||||
json!({"body": "Shell", "tolerance": 1.0, "faces": [[0, 0, 10]]}),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// #12627: each command replies as a feature command does, in Warn without warnings: a cube taken
|
||||
/// apart and stitched back, a sheet turned over and thickened, a face offset, the plate's top
|
||||
/// untrimmed and the shell faired. A misspelt key is ignored today: Warn says so, and strict
|
||||
/// refuses it.
|
||||
#[test]
|
||||
fn surface_edits_reply_with_their_schemas() {
|
||||
let schema = output_schema_for::<FeatureResult>();
|
||||
let schema = schema.as_value();
|
||||
let check = |s: &mut Session, id: &str, p: Value| {
|
||||
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(), "{r}");
|
||||
r
|
||||
};
|
||||
let mut s = cube(ParamCheck::Warn);
|
||||
check(&mut s, "surface.offset", json!({"faces": [[5, 5, 10]], "distance": 2, "body_name": "Lid"}));
|
||||
let apart = check(&mut s, "surface.unstitch", json!({"body": "Box"}));
|
||||
let pieces = apart["created_bodies"].clone();
|
||||
assert_eq!(pieces.as_array().map(Vec::len), Some(6), "six faces, six surfaces: {apart}");
|
||||
let back = check(&mut s, "surface.stitch", json!({"bodies": pieces}));
|
||||
assert_eq!(
|
||||
back["created_bodies"].as_array().map(Vec::len).unwrap_or(0) + back["changed_bodies"].as_array().map(Vec::len).unwrap_or(0),
|
||||
1,
|
||||
"{back}"
|
||||
);
|
||||
let mut s = sheet(ParamCheck::Warn);
|
||||
check(&mut s, "surface.reverse_normal", json!({"body": "Sheet"}));
|
||||
check(&mut s, "surface.thicken", json!({"bodies": "Sheet", "thickness": 2, "body_name": "Slab"}));
|
||||
let mut s = plate(ParamCheck::Warn);
|
||||
check(&mut s, "surface.untrim", json!({"face": plate_top(), "body_name": "Top"}));
|
||||
let mut s = shell(ParamCheck::Warn);
|
||||
let faired = check(&mut s, "surface.fair", json!({"body": "Shell", "tolerance": 1.0, "iterations": 3}));
|
||||
assert!(faired["warning"].is_string(), "the energies before and after: {faired}");
|
||||
// A misspelt key.
|
||||
let p = json!({"faces": [[5, 5, 10]], "distance": 2, "chian": true});
|
||||
let r = cube(ParamCheck::Warn).execute("surface.offset", &p).unwrap_or_else(|e| panic!("{p}: {e}"));
|
||||
assert!(r["param_warnings"].to_string().contains("chian"), "{r}");
|
||||
let error = cube(ParamCheck::Strict).execute("surface.offset", &p).err().map(|e| e.to_string()).unwrap_or_default();
|
||||
assert!(error.contains("`chian`"), "{error}");
|
||||
}
|
||||
@@ -366,8 +366,11 @@ fn issues_are_read_from_the_commands_own_contract() {
|
||||
let issues = super::issues("solid.box", &json!({"length": 1, "width": 1, "height": 1, "operaton": "cut"}));
|
||||
assert_eq!(messages(&issues), ["unknown key `operaton`; did you mean `operation`?"]);
|
||||
assert_eq!(issues, box_contract().check(&json!({"length": 1, "width": 1, "height": 1, "operaton": "cut"})));
|
||||
// No contract, or no such command: nothing to say.
|
||||
assert!(super::issues("solid.chamfer", &json!({"nonsense": 1})).is_empty());
|
||||
// No contract, or no such command: nothing to say. The first command without a contract
|
||||
// stands for them all, so converting a command doesn't break this.
|
||||
if let Some(c) = crate::cmd::command_specs().into_iter().find(|c| c.contract.is_none()) {
|
||||
assert!(super::issues(c.id, &json!({"nonsense": 1})).is_empty(), "{}", c.id);
|
||||
}
|
||||
assert!(super::issues("no.such", &json!({"nonsense": 1})).is_empty());
|
||||
}
|
||||
|
||||
|
||||
@@ -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,36 +11,21 @@ file.export
|
||||
file.insert_mesh
|
||||
file.insert_step
|
||||
file.new
|
||||
file.save
|
||||
file.save_as
|
||||
file.save_mesh
|
||||
file.slicers
|
||||
joint.sweep
|
||||
mesh.convert
|
||||
motion.export_animation
|
||||
motion.interference
|
||||
motion.plot
|
||||
motion.trace
|
||||
motion.trace.edit
|
||||
motion.trace.keep
|
||||
render.aging.describe
|
||||
render.backends
|
||||
render.cancel
|
||||
render.effects.describe
|
||||
render.export
|
||||
render.preflight
|
||||
render.result.pin
|
||||
render.result.regrade
|
||||
render.scene
|
||||
render.start
|
||||
render.status
|
||||
sheet.export_dxf
|
||||
sketch.dimension
|
||||
sketch.draw.dimensioned
|
||||
sketch.slot.center_to_center
|
||||
sketch.slot.overall
|
||||
solid.align
|
||||
solid.chamfer
|
||||
solid.coil
|
||||
solid.draft
|
||||
solid.emboss
|
||||
@@ -58,21 +43,12 @@ solid.revolve
|
||||
solid.shell
|
||||
solid.split_body
|
||||
solid.split_face
|
||||
solid.sweep
|
||||
solid.thread
|
||||
surface.blend
|
||||
surface.extend
|
||||
surface.extrude
|
||||
surface.fair
|
||||
surface.loft
|
||||
surface.offset
|
||||
surface.patch
|
||||
surface.reverse_normal
|
||||
surface.revolve
|
||||
surface.ruled
|
||||
surface.stitch
|
||||
surface.sweep
|
||||
surface.thicken
|
||||
surface.trim
|
||||
surface.unstitch
|
||||
surface.untrim
|
||||
|
||||
@@ -4,7 +4,7 @@ use serde_json::{Value, json};
|
||||
|
||||
use crate::*;
|
||||
|
||||
mod mesh_convert;
|
||||
pub(crate) mod mesh_convert;
|
||||
|
||||
/// Counts the copies `Session::doc_mut` makes of a shared document.
|
||||
pub(crate) mod doc_copies {
|
||||
@@ -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]
|
||||
|
||||
@@ -2,7 +2,9 @@ use super::*;
|
||||
use solidcore_record::Vec3;
|
||||
use solidcraft_doc::{Document, FeatureKind, MeshData};
|
||||
|
||||
fn imported_box() -> Session {
|
||||
/// A design whose only feature imports a 10 x 20 x 30 mm box as the mesh body `Mesh`. The
|
||||
/// schema tests (`schema/mesh_tests.rs`) convert it too.
|
||||
pub(crate) fn imported_box() -> Session {
|
||||
let body = solidcraft_kernel::box_solid(Vec3::ZERO, Vec3::new(10., 20., 30.)).unwrap();
|
||||
let mesh = body.mesh(&solidcraft_kernel::MeshTolerance::chord(0.01)).unwrap();
|
||||
let mut doc = Document::default();
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ use std::process::Command;
|
||||
|
||||
/// The workspace crates a design's model is computed from: `doc` and every workspace crate it
|
||||
/// depends on (a test checks the list against their manifests).
|
||||
const EVALUATION_CRATES: [&str; 9] = ["geom", "profile", "smlib", "step", "sketch", "kernel", "render", "derive", "doc"];
|
||||
const EVALUATION_CRATES: [&str; 10] = ["color", "geom", "profile", "smlib", "step", "sketch", "kernel", "render", "derive", "doc"];
|
||||
/// The other files the saved model depends on, relative to the workspace root.
|
||||
const EVALUATION_FILES: [&str; 3] = ["crates/io/build.rs", "crates/io/src/design/native_cache.rs", ".cargo/config.toml"];
|
||||
/// What under `vendor/` does not compute models: notes, and the interface's renderer.
|
||||
|
||||
@@ -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]
|
||||
|
||||
+50
-3
@@ -3586,9 +3586,14 @@ fn a_bridged_server_checks_calls_at_its_param_check_level() {
|
||||
assert_eq!(r["result"]["isError"], json!(true), "{r}");
|
||||
assert!(text(&r).contains(&format!("invalid parameters for `solid.box`: {warning}")), "{r}");
|
||||
assert_eq!(sent("engine.execute solid.box"), 0, "refused unsent");
|
||||
let r = raw_tool(&mut s, "execute", json!({"command": "solid.chamfer", "params": {"nonsense": 1}}));
|
||||
assert_ne!(r["result"]["isError"], json!(true), "{r}");
|
||||
assert_eq!(sent("engine.execute solid.chamfer"), 1, "no contract here: the app decides");
|
||||
// A body command with no contract yet: the first stands for them all, so converting a command
|
||||
// doesn't break this.
|
||||
let unchecked = solidcraft_engine::cmd::command_specs().into_iter().find(|c| c.contract.is_none() && c.id.starts_with("solid."));
|
||||
if let Some(id) = unchecked.map(|c| c.id) {
|
||||
let r = raw_tool(&mut s, "execute", json!({"command": id, "params": {"nonsense": 1}}));
|
||||
assert_ne!(r["result"]["isError"], json!(true), "{id}: {r}");
|
||||
assert_eq!(sent(&format!("engine.execute {id}")), 1, "{id}: no contract here, so the app decides");
|
||||
}
|
||||
let r = raw_tool(&mut s, "batch", json!({"commands": [good, misspelt]}));
|
||||
assert_eq!(r["result"]["isError"], json!(true), "{r}");
|
||||
let reply: Value = serde_json::from_str(&text(&r)).unwrap();
|
||||
@@ -3898,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}");
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user