Compare commits
86 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e27d17c421 | |||
| 52bc27aaf6 | |||
| c357b1565e | |||
| de9a2bc846 | |||
| 6b6bd26648 | |||
| 8239b914ba | |||
| e95946ac82 | |||
| 46ce12b7f0 | |||
| aba00427fa | |||
| cccf8c1ece | |||
| fb80454368 | |||
| f92e16fa25 | |||
| dcbedbd805 | |||
| 9bb216a357 | |||
| fd9b3fd9f2 | |||
| 6488a540c9 | |||
| 3e70ad9876 | |||
| 30842efb22 | |||
| 549aef17a5 | |||
| 4d738cd210 | |||
| 1cfe77bb85 | |||
| e3f99de8af | |||
| 40b1b70dec | |||
| 0862f1679a | |||
| 2cd54b6af9 | |||
| c9c063620a | |||
| 9060abc83e | |||
| bec762fb2b | |||
| 826fce68eb | |||
| 73f50d7bb4 | |||
| d1927aac25 | |||
| b224ad6dae | |||
| ca10e02573 | |||
| 5500681d6a | |||
| fabeaa329a | |||
| 8f67c7fb95 | |||
| 6bca7d2854 | |||
| eeb686f164 | |||
| 9e3e403b3f | |||
| 0231eec4c6 | |||
| 69f202f1c8 | |||
| 1cbfb889d5 | |||
| ee3cc32525 | |||
| 954232cef1 | |||
| 9092df9b97 | |||
| c1a23f8f97 | |||
| 0fc4cd2747 | |||
| 6d83f95fba | |||
| bf625dff05 | |||
| ed3510a91f | |||
| 8f0130a61b | |||
| 72df682af7 | |||
| 41a450be7c | |||
| b227924be9 | |||
| 28b47059bb | |||
| a19a32ddd7 | |||
| 606e264f6d | |||
| d25f8198fa | |||
| 464f4ac748 | |||
| 03b5248dc1 | |||
| f9dd77e383 | |||
| 4afd24d29f | |||
| 8a74336713 | |||
| 5e4c52a7cb | |||
| 77e0e11775 | |||
| 7bd66b7c06 | |||
| 86db05e538 | |||
| d912f3533b | |||
| 9d7bbb7fa9 | |||
| 6312c2760a | |||
| 3a23d21795 | |||
| e199f9aeff | |||
| e06d6e36d8 | |||
| 9d1ad9f0b9 | |||
| d39a2d28db | |||
| 1e649653ef | |||
| 6988eb0043 | |||
| 23c2244307 | |||
| a52ee850aa | |||
| 65d23a78a5 | |||
| 481687fff3 | |||
| 46a9cf80b8 | |||
| f74c6605c4 | |||
| 215cc17c52 | |||
| c5bc849913 | |||
| 6178d1a45e |
@@ -0,0 +1,20 @@
|
||||
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
|
||||
reviews:
|
||||
auto_review:
|
||||
# Dependabot bumps are checked by CI; skip CodeRabbit reviews on them.
|
||||
ignore_usernames:
|
||||
- "dependabot[bot]"
|
||||
path_instructions:
|
||||
- path: "**"
|
||||
instructions: |
|
||||
Project rule: don't change anyone's existing defaults unless the change is purely additive and belongs in the application.
|
||||
Flag any change to something a user already relies on: a default setting or preference value, what a command or tool does when used the usual way, the UI's default layout or behaviour, a keyboard or mouse shortcut, or the result an existing saved design rebuilds to.
|
||||
Don't flag purely additive changes: a new command, a new setting whose default keeps today's behaviour, or a new option that appears only when someone chooses it. Don't flag bug fixes that only turn a crash or an error into the result the feature always promised.
|
||||
When you flag one, start the comment with "Changes a default:", say what a user sees before and after, whether a setting keeps the old behaviour, and whether the PR description's `Changes a default:` line says so.
|
||||
pre_merge_checks:
|
||||
custom_checks:
|
||||
- name: "Existing defaults unchanged"
|
||||
mode: "warning"
|
||||
instructions: |
|
||||
Pass if the pull request changes no default a user already relies on (default setting or preference values, what a command or tool does when used the usual way, the UI's default layout or behaviour, shortcuts, or the result an existing saved design rebuilds to), or if its changes are purely additive.
|
||||
Fail otherwise, naming each changed default and what a user sees before and after. Also fail if the description says `Changes a default: no` while the code changes one.
|
||||
@@ -784,6 +784,20 @@ Original SolidCraft drawings, Open for owner review; no runtime assignments.
|
||||
| `assets/locales/pending/sketch-offset-instances.json` | SolidCraft contributors | Original offset instance-count label and translations | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/settings_profile.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original analytic head and shoulder geometry for settings profiles | MIT OR Apache-2.0 |
|
||||
| tests/kernel-cases/import/4561-convert-box-mesh-with-a-zero-area-triangle/box-degenerate.stl | SolidCraft contributors | synthetic ASCII STL written by hand: a box with one zero-area triangle, for the kernel regression case of tangency-labs/solidcraft#4561 | MIT OR Apache-2.0 |
|
||||
| assets/locales/pending/theme-blur-ui.json | SolidCraft contributors | Original blur controls, notices and translations | MIT OR Apache-2.0 |
|
||||
| crates/ui-egui/tests/fixtures/theme-blur.json | SolidCraft contributors | Original theme for blur option scenarios and manager review | MIT OR Apache-2.0 |
|
||||
| assets/locales/ka.json | David Machakhelidze (devius) | Original Georgian translation of SolidCraft's English catalog, written with Claude Code from the meaning of each English string; terminology and doubtful strings decided by a native Georgian speaker; no commercial CAD text used | MIT OR Apache-2.0 |
|
||||
| assets/branding/logo/solidcraft-mark.svg | SolidCraft contributors | static SolidCraft mark: the five faces and gradients of assets/icons/app/macos/source/SolidCraft.icon/Assets/03-solid-blue-cutout.svg (the final frame of assets/styles/web/loading-logo.svg), cropped to the mark's bounds; original artwork | MIT OR Apache-2.0 |
|
||||
| assets/branding/logo/solidcraft-mark-mono-black.svg | SolidCraft contributors | one-colour (black) version of assets/branding/logo/solidcraft-mark.svg, same paths; original artwork | MIT OR Apache-2.0 |
|
||||
| assets/branding/logo/solidcraft-mark-mono-white.svg | SolidCraft contributors | one-colour (white) version of assets/branding/logo/solidcraft-mark.svg, same paths; original artwork | MIT OR Apache-2.0 |
|
||||
| assets/branding/logo/solidcraft-mark-512.png | SolidCraft contributors | transparent 512 px render of assets/branding/logo/solidcraft-mark.svg (cairosvg); original artwork | MIT OR Apache-2.0 |
|
||||
| assets/branding/logo/solidcraft-mark-1024.png | SolidCraft contributors | transparent 1024 px render of assets/branding/logo/solidcraft-mark.svg (cairosvg); original artwork | MIT OR Apache-2.0 |
|
||||
| assets/branding/logo/solidcraft-mark-2048.png | SolidCraft contributors | transparent 2048 px render of assets/branding/logo/solidcraft-mark.svg (cairosvg); original artwork | MIT OR Apache-2.0 |
|
||||
| assets/branding/logo/solidcraft-wordmark.svg | SolidCraft contributors | the name SolidCraft set like the loading logo's wordmark (bold, +0.02 em tracking, #15499e) in IBM Plex Sans Bold from assets/fonts/ibm-plex-sans (SIL OFL 1.1, IBM Corp.), outlined to paths; no font file is embedded | MIT OR Apache-2.0 |
|
||||
| assets/branding/logo/solidcraft-lockup-horizontal.svg | SolidCraft contributors | assets/branding/logo/solidcraft-mark.svg beside assets/branding/logo/solidcraft-wordmark.svg, in the proportions of assets/branding/web/source/og.png; original artwork | MIT OR Apache-2.0 |
|
||||
| assets/styles/ui/themes/onyx.json | SolidCraft contributors | original Onyx stone-grey palette, option overlays and semantic icon-role mapping; existing SolidCraft artwork is unchanged | MIT OR Apache-2.0 |
|
||||
| docs/book/src/media/ui-tour/window.png | SolidCraft contributors | screenshot of SolidCraft 0.29.0 itself (`solidcraft --sample`, default dark theme, software renderer, 1280x800) with original numbered callouts (digits set in DejaVu Sans Bold), captured with examples/demo/doc_shots.py | MIT OR Apache-2.0 |
|
||||
| docs/book/src/media/ui-tour/navigate.mp4 | SolidCraft contributors | original screen recording of the actual SolidCraft 0.29.0 English desktop UI (`--sample`, default dark theme, software renderer) orbiting, panning and zooming, with the original drawn pointer of examples/demo/overlay.py and labels set in DejaVu Sans Bold, captured with examples/demo/doc_shots.py | MIT OR Apache-2.0 |
|
||||
|
||||
## Code adapted from sibling projects
|
||||
|
||||
|
||||
@@ -472,6 +472,16 @@ the same area), first resolve the command registry and source conflicts, then ru
|
||||
The generator tests cover stable ordering and additions that leave existing lines
|
||||
unchanged. The initial switch to sorted rows is a one-time larger diff.
|
||||
|
||||
## Pictures in the user guide
|
||||
|
||||
The user guide's screenshots and short clips (`docs/book/src/media/`) are captured from the real
|
||||
app by `examples/demo/doc_shots.py`, one function per figure. Each run starts a fresh app with its
|
||||
own settings, so your preferences never show. When a change makes a figure out of date, run
|
||||
`python3 examples/demo/doc_shots.py <page or shot>` (it needs Xvfb, ffmpeg, Pillow and a release
|
||||
build) and commit the new file. A new figure is a new function there, an ATTRIBUTION row in
|
||||
`attribution/pending/`, and a `<video>` (clips) or image link on the page. Keep a still under
|
||||
about 250 KB and a clip under about 1 MB.
|
||||
|
||||
## Licence
|
||||
|
||||
By contributing you agree that your contribution is licensed as MIT OR Apache-2.0, like the rest
|
||||
|
||||
@@ -4102,6 +4102,7 @@ dependencies = [
|
||||
"ureq",
|
||||
"windows-sys 0.61.2",
|
||||
"winit",
|
||||
"winreg",
|
||||
"x11rb",
|
||||
"zbus 5.19.0",
|
||||
]
|
||||
|
||||
@@ -171,6 +171,7 @@ mod web {
|
||||
|
||||
use solidcraft_engine::Session;
|
||||
use solidcraft_engine::io::vfs;
|
||||
use solidcraft_ui_egui::ui_kit::BackingTooltipExt as _;
|
||||
use solidcraft_ui_egui::{Services, SolveApp, ui_kit};
|
||||
use wasm_bindgen::JsCast as _;
|
||||
|
||||
@@ -500,7 +501,7 @@ mod web {
|
||||
files::pick_from_disk(&self.files, Intent::Open);
|
||||
}
|
||||
if ui_kit::button(ui, "Insert from computer…", ui_kit::Button::Normal)
|
||||
.on_hover_text(solidcraft_ui_egui::i18n::tr("web.files.insert_drawings_hint"))
|
||||
.backing_on_hover_text(solidcraft_ui_egui::i18n::tr("web.files.insert_drawings_hint"))
|
||||
.clicked()
|
||||
{
|
||||
files::pick_from_disk(&self.files, Intent::Insert);
|
||||
@@ -554,7 +555,7 @@ mod web {
|
||||
let design = solidcraft_engine::io::is_design_path(&e.name);
|
||||
if !design
|
||||
&& ui_kit::button(ui, "Insert", ui_kit::Button::Small)
|
||||
.on_hover_text(solidcraft_ui_egui::i18n::tr("web.files.insert_stored_drawings_hint"))
|
||||
.backing_on_hover_text(solidcraft_ui_egui::i18n::tr("web.files.insert_stored_drawings_hint"))
|
||||
.clicked()
|
||||
{
|
||||
act = Some(("insert", e.name.clone()));
|
||||
@@ -564,7 +565,7 @@ mod web {
|
||||
self.rename_focus = true;
|
||||
}
|
||||
if ui_kit::button(ui, "Save to computer…", ui_kit::Button::Small)
|
||||
.on_hover_text("A copy in a file you choose; the stored design stays here")
|
||||
.backing_on_hover_text("A copy in a file you choose; the stored design stays here")
|
||||
.clicked()
|
||||
{
|
||||
act = Some(("save_copy", e.name.clone()));
|
||||
|
||||
@@ -81,6 +81,7 @@ windows-sys = { version = "0.61", features = [
|
||||
"Win32_UI_HiDpi",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
] }
|
||||
winreg = "0.56"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
muda = { version = "0.21.2", default-features = false }
|
||||
@@ -92,7 +93,7 @@ hidapi = { version = "2.6.7", default-features = false, features = ["macos-share
|
||||
# Files opened from Finder and the Dock (src/open_documents.rs), and the fullscreen title-bar
|
||||
# inset (src/main.rs); all already come with eframe and rfd.
|
||||
objc2 = "0.6"
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSResponder", "NSWindow"] }
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSAccessibility", "NSApplication", "NSResponder", "NSWindow", "NSWorkspace"] }
|
||||
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSArray", "NSNotification", "NSOperation", "NSProcessInfo", "NSString", "NSURL"] }
|
||||
block2 = "0.6"
|
||||
# Native crash capture (src/native_crash_macos.rs): the signal handler, the faulting module
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
//! Cached accessibility signal, observed only while a theme offers blur. No UI-thread IO.
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, AtomicU8, Ordering},
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
struct Reading {
|
||||
needed: AtomicBool,
|
||||
started: AtomicBool,
|
||||
value: AtomicU8,
|
||||
}
|
||||
|
||||
pub fn cached(ctx: &egui::Context) -> Box<dyn Fn(bool) -> Option<bool>> {
|
||||
let state = Arc::new(Reading::default());
|
||||
let ctx = ctx.clone();
|
||||
Box::new(move |needed| {
|
||||
state.needed.store(needed, Ordering::Relaxed);
|
||||
if needed && !state.started.swap(true, Ordering::Relaxed) {
|
||||
let weak = Arc::downgrade(&state);
|
||||
let ctx = ctx.clone();
|
||||
if let Err(error) = std::thread::Builder::new().name("desktop-transparency".into()).spawn(move || {
|
||||
while let Some(state) = weak.upgrade() {
|
||||
if state.needed.load(Ordering::Relaxed) {
|
||||
let code = match read() {
|
||||
Some(true) => 1,
|
||||
Some(false) => 2,
|
||||
None => 0,
|
||||
};
|
||||
if state.value.swap(code, Ordering::Relaxed) != code {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
drop(state);
|
||||
std::thread::sleep(std::time::Duration::from_secs(5));
|
||||
}
|
||||
}) {
|
||||
log::warn!("desktop transparency: could not observe accessibility setting: {error}");
|
||||
}
|
||||
}
|
||||
match state.value.load(Ordering::Relaxed) {
|
||||
1 => Some(true),
|
||||
2 => Some(false),
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn read() -> Option<bool> {
|
||||
let key =
|
||||
winreg::RegKey::predef(winreg::enums::HKEY_CURRENT_USER).open_subkey(r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize").ok()?;
|
||||
match key.get_value::<u32, _>("EnableTransparency").ok()? {
|
||||
0 => Some(true),
|
||||
1 => Some(false),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn read() -> Option<bool> {
|
||||
objc2::rc::autoreleasepool(|_| Some(objc2_app_kit::NSWorkspace::sharedWorkspace().accessibilityDisplayShouldReduceTransparency()))
|
||||
}
|
||||
@@ -24,6 +24,8 @@ mod come_forward;
|
||||
mod control_server;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod desktop_theme;
|
||||
#[cfg(any(target_os = "windows", target_os = "macos"))]
|
||||
mod desktop_transparency;
|
||||
mod device_lost;
|
||||
#[cfg(target_os = "windows")]
|
||||
mod native_crash;
|
||||
@@ -539,6 +541,10 @@ fn services(cc: &eframe::CreationContext<'_>, folder: &DialogFolder) -> Services
|
||||
desktop_dark: Some(desktop_theme::start(&cc.egui_ctx)),
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
desktop_dark: None,
|
||||
#[cfg(any(target_os = "windows", target_os = "macos"))]
|
||||
reduce_transparency: Some(desktop_transparency::cached(&cc.egui_ctx)),
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||
reduce_transparency: None,
|
||||
http_get: Some(std::sync::Arc::new(http_get)),
|
||||
http_download: Some(std::sync::Arc::new(http_download)),
|
||||
// Already an installed app: only the browser build offers to install.
|
||||
@@ -779,6 +785,28 @@ fn graphics() -> Option<serde_json::Value> {
|
||||
graphics_path().and_then(|p| std::fs::read_to_string(p).ok()).and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
|
||||
}
|
||||
|
||||
fn restore_preferences(app: &mut SolveApp, stored_ui: Option<&str>, saved_graphics: Option<&serde_json::Value>, safe_mode: bool) {
|
||||
if let Some(p) = stored_ui.filter(|_| !safe_mode) {
|
||||
app.load_prefs(p);
|
||||
}
|
||||
// Even safe mode edits the real graphics.json. Keep its choices in Preferences so an
|
||||
// anti-aliasing change cannot erase them; safe mode's renderer still uses its own defaults.
|
||||
restore_graphics_preferences(app, saved_graphics);
|
||||
}
|
||||
|
||||
/// Restore startup choices after loading the separately saved UI preferences.
|
||||
fn restore_graphics_preferences(app: &mut SolveApp, saved: Option<&serde_json::Value>) {
|
||||
app.preferences.graphics_adapter = saved.and_then(|v| v.get("adapter").and_then(serde_json::Value::as_str)).unwrap_or_default().to_string();
|
||||
// graphics.json chooses the window before app.ron is loaded. Reflect that saved choice,
|
||||
// not the current session's window system (which an environment override may change).
|
||||
app.preferences.window_system = saved
|
||||
.and_then(|v| v.get("window_system").and_then(serde_json::Value::as_str))
|
||||
.map(str::trim)
|
||||
.filter(|choice| matches!(*choice, "wayland" | "x11"))
|
||||
.unwrap_or("auto")
|
||||
.to_string();
|
||||
}
|
||||
|
||||
/// The graphics backend preference (`graphics.json`'s `backend`: `auto`, `dx12`, `vulkan`, `gl`).
|
||||
fn backend_preference() -> Option<String> {
|
||||
graphics().and_then(|v| v.get("backend").and_then(serde_json::Value::as_str).map(str::to_string))
|
||||
@@ -997,6 +1025,8 @@ fn main() -> eframe::Result {
|
||||
solidcraft_engine::crash::install(dir, build)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
// The recovery folder also holds the reopen guard marker (#5064); read once, reused below.
|
||||
let recovery_dir = solidcraft_engine::recovery::default_dir();
|
||||
// A crash in native code (a graphics driver) says where it happened: the exception or signal
|
||||
// and the module on Windows and macOS, and on every system the startup step the app was in.
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -1099,9 +1129,8 @@ fn main() -> eframe::Result {
|
||||
app.integrated_titlebar = cfg!(target_os = "macos");
|
||||
app.cpu_meshing = safe_mode;
|
||||
app.graphics_fallback = fallback_note.clone();
|
||||
if let Some(p) = cc.storage.and_then(|s| solidcraft_ui_egui::prefs::stored(|k| s.get_string(k))).filter(|_| !safe_mode) {
|
||||
app.load_prefs(&p);
|
||||
}
|
||||
let stored_ui = cc.storage.and_then(|s| solidcraft_ui_egui::prefs::stored(|k| s.get_string(k)));
|
||||
restore_preferences(&mut app, stored_ui.as_deref(), graphics().as_ref(), safe_mode);
|
||||
if let Some(path) = &theme_file
|
||||
&& let Err(e) = solidcraft_ui_egui::prefs::follow_theme_file_for_run(&mut app, path)
|
||||
{
|
||||
@@ -1114,10 +1143,6 @@ fn main() -> eframe::Result {
|
||||
lost_at: None,
|
||||
reason: String::new(),
|
||||
});
|
||||
if !safe_mode {
|
||||
app.preferences.graphics_adapter =
|
||||
graphics().and_then(|v| v.get("adapter").and_then(serde_json::Value::as_str).map(str::to_string)).unwrap_or_default();
|
||||
}
|
||||
if let Some(rs) = &cc.wgpu_render_state {
|
||||
app.services.graphics_adapters = rs
|
||||
.available_adapters
|
||||
@@ -1190,8 +1215,25 @@ fn main() -> eframe::Result {
|
||||
};
|
||||
// No design given: the designs open at the last quit, when the preference asks (#5542).
|
||||
// Not when driven over the control channel, whose clients expect a fresh design.
|
||||
let files =
|
||||
if files.is_empty() && !sample && control_port.is_none() { solidcraft_ui_egui::documents::designs_to_reopen(&app) } else { files };
|
||||
// A design that stopped the last session while it was being reopened is skipped this
|
||||
// time, so one bad file cannot brick every launch (#5064).
|
||||
app.reopen_guard_dir = recovery_dir.clone();
|
||||
let files = if files.is_empty() && !sample && control_port.is_none() {
|
||||
let (reopen, skipped) = solidcraft_ui_egui::documents::reopen_plan(&app, recovery_dir.as_deref());
|
||||
if !skipped.is_empty() {
|
||||
let names: Vec<String> = skipped
|
||||
.iter()
|
||||
.map(|p| std::path::Path::new(p).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| p.clone()))
|
||||
.collect();
|
||||
notes.push(format!(
|
||||
"The last session stopped while reopening {}. It was not reopened this time; open it yourself if you need it.",
|
||||
names.join(", ")
|
||||
));
|
||||
}
|
||||
reopen
|
||||
} else {
|
||||
files
|
||||
};
|
||||
for f in &files {
|
||||
app.open_path_async(f);
|
||||
}
|
||||
@@ -1207,9 +1249,9 @@ fn main() -> eframe::Result {
|
||||
app.home.show(rev);
|
||||
}
|
||||
// Autosave, and offer what crashed sessions left behind.
|
||||
if let Some(dir) = solidcraft_engine::recovery::default_dir() {
|
||||
if let Some(dir) = recovery_dir.as_ref() {
|
||||
let every = if app.autosave_minutes > 0.0 { app.autosave_minutes * 60.0 } else { f64::MAX / 4.0 };
|
||||
solidcraft_ui_egui::recovery_ui::start(&mut app, &dir, std::time::Duration::from_secs_f64(every.min(1e9)));
|
||||
solidcraft_ui_egui::recovery_ui::start(&mut app, dir, std::time::Duration::from_secs_f64(every.min(1e9)));
|
||||
// Safe mode keeps autosaving but offers nothing: the entries wait for a normal start.
|
||||
if safe_mode {
|
||||
app.recoverable.clear();
|
||||
@@ -1709,6 +1751,88 @@ mod viewport_tests {
|
||||
|
||||
#[cfg(test)]
|
||||
mod msaa_tests {
|
||||
/// Safe mode uses default UI settings but edits the real graphics.json for the next start.
|
||||
#[test]
|
||||
fn safe_mode_preserves_graphics_choices_when_changing_antialiasing() {
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_ui_egui::scenario::Harness;
|
||||
|
||||
for choice in ["wayland", "x11"] {
|
||||
let mut h = Harness::new();
|
||||
let path = h.dir.join("graphics.json");
|
||||
let saved = json!({"window_system": choice, "adapter": "Selected GPU", "backend": "vulkan", "msaa": 8});
|
||||
std::fs::write(&path, saved.to_string()).unwrap();
|
||||
h.app.services.graphics_path = Some(path.to_string_lossy().into_owned());
|
||||
let defaults = h.app.preferences.clone();
|
||||
let stored_ui = json!({"preferences": {"window_system": "auto", "graphics_adapter": "Stale GPU", "msaa": 2,
|
||||
"display_quality": "fine"}})
|
||||
.to_string();
|
||||
super::restore_preferences(&mut h.app, Some(&stored_ui), Some(&saved), true);
|
||||
|
||||
assert_eq!(h.app.preferences.window_system, choice);
|
||||
assert_eq!(h.app.preferences.graphics_adapter, "Selected GPU");
|
||||
assert_eq!(h.app.preferences.msaa, defaults.msaa, "safe mode ignores saved anti-aliasing");
|
||||
assert_eq!(h.app.preferences.display_quality, defaults.display_quality, "safe mode ignores saved UI settings");
|
||||
assert_eq!(serde_json::from_str::<Value>(&std::fs::read_to_string(&path).unwrap()).unwrap(), saved, "startup does not write settings");
|
||||
|
||||
assert_eq!(h.call("ui.prefs", json!({"open": "Graphics"}))["ok"], true);
|
||||
assert_eq!(h.call("ui.prefs", json!({"msaa": 1}))["ok"], true);
|
||||
assert_eq!(h.call("ui.prefs", json!({"button": "ok"}))["ok"], true);
|
||||
let after: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
|
||||
assert_eq!(after, json!({"window_system": choice, "adapter": "Selected GPU", "backend": "vulkan", "msaa": 1}));
|
||||
|
||||
let mut normal = Harness::new();
|
||||
super::restore_preferences(&mut normal.app, Some(&stored_ui), Some(&after), false);
|
||||
assert_eq!(normal.app.preferences.window_system, choice);
|
||||
assert_eq!(normal.app.preferences.graphics_adapter, "Selected GPU");
|
||||
assert_eq!(normal.app.preferences.display_quality, "fine", "normal startup still restores UI settings");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_or_invalid_startup_window_system_uses_automatic() {
|
||||
use serde_json::json;
|
||||
|
||||
for saved in [None, Some(json!(null)), Some(json!({})), Some(json!({"window_system": 7})), Some(json!({"window_system": "unknown"}))] {
|
||||
let mut app = super::SolveApp::new(super::Session::default(), super::Services::default());
|
||||
app.preferences.window_system = "wayland".into();
|
||||
super::restore_graphics_preferences(&mut app, saved.as_ref());
|
||||
assert_eq!(app.preferences.window_system, "auto", "{saved:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A manual graphics.json choice wins over stale app.ron preferences and survives an
|
||||
/// unrelated graphics edit (#10116), including on a fresh launch with no saved UI state.
|
||||
#[test]
|
||||
fn startup_window_system_survives_changing_antialiasing() {
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_ui_egui::scenario::Harness;
|
||||
|
||||
for choice in ["wayland", "x11", "auto", " wayland ", " x11 "] {
|
||||
for stale in [None, Some("auto"), Some("wayland"), Some("x11")] {
|
||||
let mut h = Harness::new();
|
||||
if let Some(stale) = stale {
|
||||
h.app.load_prefs(&json!({"preferences": {"window_system": stale}}).to_string());
|
||||
}
|
||||
let path = h.dir.join("graphics.json");
|
||||
std::fs::write(&path, json!({"window_system": choice, "backend": "vulkan", "adapter": "Selected GPU", "msaa": 4}).to_string())
|
||||
.unwrap();
|
||||
h.app.services.graphics_path = Some(path.to_string_lossy().into_owned());
|
||||
let saved: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
|
||||
super::restore_graphics_preferences(&mut h.app, Some(&saved));
|
||||
|
||||
let result = h.call("ui.prefs", json!({"msaa": 2}));
|
||||
assert_eq!(result["ok"], true, "{result}");
|
||||
let after: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
|
||||
assert_eq!(after["window_system"], choice.trim(), "saved choice {choice:?}, stale UI choice {stale:?}");
|
||||
assert_eq!(result["result"]["window_system"], choice.trim());
|
||||
assert_eq!(after["msaa"], 2);
|
||||
assert_eq!(after["backend"], "vulkan");
|
||||
assert_eq!(after["adapter"], "Selected GPU");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_launch_adopts_the_previous_release_settings() {
|
||||
let d = std::env::temp_dir().join(format!("solidcraft-adopt-settings-{}", std::process::id()));
|
||||
|
||||
@@ -17,6 +17,7 @@ assets/
|
||||
ui/design/tools/ # authoring bases, checks and included review app
|
||||
ui/source/ # Rust viewport glyphs, markers and navigation
|
||||
branding/web/source/og.png # shared social preview card
|
||||
branding/logo/ # print/web logo files; guide in branding/README.md
|
||||
styles/ui/palettes.rs # light/dark defaults
|
||||
styles/web/loading.css # web loading/canvas styles
|
||||
appearances/library.rs # built-in visual presets
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
# SolidCraft brand kit
|
||||
|
||||
Logo files for stickers, shirts, the web and social posts. Everything in `logo/` is drawn
|
||||
from the app icon (`assets/icons/app/macos/source/SolidCraft.icon/Assets/03-solid-blue-cutout.svg`),
|
||||
which is also the last frame of the loading animation (`assets/styles/web/loading-logo.svg`).
|
||||
The SVGs are plain paths: no scripts, fonts, animation or external links, so any printer can use them.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Use it for |
|
||||
|---|---|
|
||||
| `logo/solidcraft-mark.svg` | The mark in full colour. First choice for stickers, print and the web. |
|
||||
| `logo/solidcraft-mark-mono-black.svg` | One-colour print on light material: screen-printed shirts, stamps, engraving, vinyl. |
|
||||
| `logo/solidcraft-mark-mono-white.svg` | One-colour print on dark material, such as a white print on a dark shirt. |
|
||||
| `logo/solidcraft-mark-512.png`, `-1024.png`, `-2048.png` | Transparent square PNGs for places that don't take SVG: avatars, social posts, slides, print shops that ask for a bitmap (send the 2048). |
|
||||
| `logo/solidcraft-wordmark.svg` | The name on its own, outlined to paths. |
|
||||
| `logo/solidcraft-lockup-horizontal.svg` | Mark and name side by side: headers, banners, shirt fronts, wide stickers. |
|
||||
| `web/source/og.png` | The existing link preview card for the docs site. |
|
||||
|
||||
The gaps between the faces are part of the logo. In the colour and one-colour files they are
|
||||
transparent, so the material shows through. For a die-cut sticker, cut around the outer shape
|
||||
and let the backing colour (white is best) fill the gaps.
|
||||
|
||||
## Colours
|
||||
|
||||
The mark's five faces each use a two-stop gradient from the icon source:
|
||||
|
||||
| Face | From | To |
|
||||
|---|---|---|
|
||||
| Top | `#51adff` | `#2b83f1` |
|
||||
| Left | `#258df5` | `#1461da` |
|
||||
| Right | `#1b66cf` | `#124596` |
|
||||
| Lower step (top) | `#459fff` | `#2379e7` |
|
||||
| Upper step (inner side) | `#1d63c6` | `#15499e` |
|
||||
|
||||
Other colours, all already used in the project:
|
||||
|
||||
| Colour | Hex | Where it comes from |
|
||||
|---|---|---|
|
||||
| Wordmark blue | `#15499e` | loading logo `--word` |
|
||||
| Interface accent | `#2678da` | light theme `accent` token (`assets/styles/ui/palettes.rs`) |
|
||||
| Light background | `#e8ebf0` | web loading screen (`assets/styles/web/loading.css`) |
|
||||
| Dark background | `#1f232b` | dark theme `file_background` token |
|
||||
| Black / white | `#000000` / `#ffffff` | one-colour versions only |
|
||||
|
||||
If a printer can't do gradients, use the one-colour files, or fill every face with `#2678da`.
|
||||
|
||||
## Clear space and size
|
||||
|
||||
- Keep empty space around the mark of at least one fifth of its height on every side. Treat
|
||||
the lockup the same way, measured from the mark.
|
||||
- Smallest mark: 16 px tall on screen, 12 mm tall in print. Below that the gaps fill in.
|
||||
- Smallest lockup: 120 px wide on screen, 40 mm wide in print.
|
||||
|
||||
## Don'ts
|
||||
|
||||
- Don't recolour it outside the colours above.
|
||||
- Don't stretch, squash, rotate, skew or crop it. Scale it evenly.
|
||||
- Don't add shadows, outlines, glows, bevels or other effects.
|
||||
- Don't fill or close the gaps between the faces, or move the faces apart.
|
||||
- Don't rearrange the lockup, retype the name in another font, or put the mark on a busy
|
||||
background where it loses contrast.
|
||||
|
||||
## Name and logo
|
||||
|
||||
The SolidCraft code and these files are licensed MIT OR Apache-2.0 (see the [README](../../README.md#licence)),
|
||||
so you may copy and change them. The name SolidCraft and this logo identify the SolidCraft
|
||||
project. Use them to refer to the project, for example in an article, a talk, or a sticker for a
|
||||
meetup. If you publish a fork or a modified build, give it its own name and logo so people
|
||||
don't mistake it for SolidCraft.
|
||||
|
||||
The wordmark is set in IBM Plex Sans Bold (SIL Open Font License 1.1, the interface font in
|
||||
`assets/fonts/ibm-plex-sans/`) and outlined, so you don't need the font to print it.
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="116.406 53.364 2748.71 904.436" width="2749" height="904" role="img"><title>SolidCraft</title><defs><linearGradient id="sc1" x1="619.14" y1="179.734" x2="694.687" y2="396.761" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#1d63c6"/><stop offset="1" stop-color="#15499e"/></linearGradient><linearGradient id="sc2" x1="619.14" y1="413.244" x2="790.839" y2="605.547" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#459fff"/><stop offset="1" stop-color="#2379e7"/></linearGradient><linearGradient id="sc3" x1="632.876" y1="399.508" x2="783.971" y2="968.175" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#1b66cf"/><stop offset="1" stop-color="#124596"/></linearGradient><linearGradient id="sc4" x1="207.063" y1="303.357" x2="426.837" y2="968.175" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#258df5"/><stop offset="1" stop-color="#1461da"/></linearGradient><linearGradient id="sc5" x1="385.63" y1="53.364" x2="522.989" y2="510.769" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#51adff"/><stop offset="1" stop-color="#2b83f1"/></linearGradient></defs><g><path d="M547.883 282.66 L709.797 188.211 L709.797 375.985 Z" fill="url(#sc1)"/><path d="M727.797 593.626 L727.797 407.136 L889.571 500.381 Z" fill="url(#sc2)"/><path d="M530 521.17 L691.797 427.912 L691.797 655.928 L907.594 531.545 L907.594 740.159 L530 957.8 Z" fill="url(#sc3)"/><path d="M116.406 510.769 L116.406 303.529 L494 521.17 L494 957.8 L314.203 854.167 L314.203 624.777 Z" fill="url(#sc4)"/><path d="M134.374 272.334 L512 53.364 L691.828 157.015 L476.117 282.846 L673.752 396.761 L512 489.993 Z" fill="url(#sc5)"/></g><path fill="#15499e" transform="translate(1092.78 632.20) scale(0.36281)" d="M303 12Q212 12 147.5 -20Q83 -52 38 -101L138 -202Q173 -162 217.5 -142Q262 -122 313 -122Q369 -122 396 -145Q423 -168 423 -206Q423 -225 416.5 -240.5Q410 -256 392.5 -266.5Q375 -277 342 -281L273 -290Q200 -300 152.5 -326.5Q105 -353 82 -396.5Q59 -440 59 -498Q59 -561 90.5 -608.5Q122 -656 180.5 -683Q239 -710 321 -710Q400 -710 460 -684.5Q520 -659 563 -612L462 -510Q437 -539 401.5 -557.5Q366 -576 312 -576Q261 -576 236 -558Q211 -540 211 -508Q211 -484 219.5 -469Q228 -454 246.5 -446Q265 -438 294 -433L363 -422Q435 -411 482 -385Q529 -359 552 -317Q575 -275 575 -215Q575 -148 543.5 -97Q512 -46 451 -17Q390 12 303 12ZM926 12Q849 12 794 -21.5Q739 -55 709.5 -117Q680 -179 680 -263Q680 -348 709.5 -409Q739 -470 794 -503.5Q849 -537 926 -537Q1003 -537 1058 -503.5Q1113 -470 1142.5 -409Q1172 -348 1172 -263Q1172 -179 1142.5 -117Q1113 -55 1058 -21.5Q1003 12 926 12ZM926 -105Q970 -105 994 -132.5Q1018 -160 1018 -209V-316Q1018 -366 994 -393Q970 -420 926 -420Q883 -420 858.5 -393Q834 -366 834 -316V-209Q834 -160 858.5 -132.5Q883 -105 926 -105ZM1511 0H1444Q1368 0 1332.5 -37Q1297 -74 1297 -144V-740H1445V-115H1511ZM1694 -581Q1649 -581 1628.5 -602Q1608 -623 1608 -654V-676Q1608 -708 1628.5 -728.5Q1649 -749 1694 -749Q1739 -749 1759.5 -728.5Q1780 -708 1780 -676V-654Q1780 -623 1759.5 -602Q1739 -581 1694 -581ZM1620 0V-525H1768V0ZM2248 0V-88H2243Q2232 -45 2192.5 -16.5Q2153 12 2102 12Q2036 12 1991 -19.5Q1946 -51 1923 -112.5Q1900 -174 1900 -263Q1900 -352 1923 -413Q1946 -474 1991 -505.5Q2036 -537 2102 -537Q2153 -537 2192.5 -509Q2232 -481 2243 -437H2248V-740H2396V0ZM2151 -108Q2179 -108 2201 -117Q2223 -126 2235.5 -143.5Q2248 -161 2248 -185V-340Q2248 -364 2235.5 -381.5Q2223 -399 2201 -408Q2179 -417 2151 -417Q2109 -417 2081.5 -387.5Q2054 -358 2054 -308V-217Q2054 -168 2081.5 -138Q2109 -108 2151 -108ZM2844 12Q2750 12 2680.5 -28Q2611 -68 2573 -147Q2535 -226 2535 -344Q2535 -462 2573 -543.5Q2611 -625 2680.5 -667.5Q2750 -710 2844 -710Q2940 -710 3003.5 -670Q3067 -630 3105 -546L2974 -478Q2961 -522 2930.5 -548.5Q2900 -575 2844 -575Q2777 -575 2736.5 -531.5Q2696 -488 2696 -405V-293Q2696 -211 2736.5 -167Q2777 -123 2844 -123Q2900 -123 2933.5 -153.5Q2967 -184 2983 -227L3107 -155Q3068 -76 3004 -32Q2940 12 2844 12ZM3373 0H3225V-525H3373V-411H3378Q3384 -440 3400 -466Q3416 -492 3443.5 -508.5Q3471 -525 3512 -525H3538V-387H3501Q3458 -387 3429.5 -379.5Q3401 -372 3387 -355Q3373 -338 3373 -307ZM4105 0H4023Q3996 0 3972.5 -13.5Q3949 -27 3935 -53.5Q3921 -80 3921 -117V-130L3953 -92H3917Q3905 -41 3864 -14.5Q3823 12 3763 12Q3684 12 3642 -30.5Q3600 -73 3600 -141Q3600 -197 3627.5 -233Q3655 -269 3705.5 -287Q3756 -305 3825 -305H3908V-338Q3908 -376 3888 -398.5Q3868 -421 3821 -421Q3777 -421 3751 -402Q3725 -383 3708 -359L3620 -437Q3652 -484 3701 -510.5Q3750 -537 3832 -537Q3943 -537 3999.5 -487.5Q4056 -438 4056 -345V-115H4105ZM3908 -221H3836Q3793 -221 3771 -206Q3749 -191 3749 -162V-147Q3749 -119 3767 -105Q3785 -91 3818 -91Q3843 -91 3863 -98Q3883 -105 3895.5 -120Q3908 -135 3908 -159ZM4250 0V-410H4176V-525H4250V-587Q4250 -663 4290.5 -701.5Q4331 -740 4407 -740H4503V-625H4398V-525H4503V-410H4398V0ZM4877 0H4789Q4713 0 4673 -39Q4633 -78 4633 -153V-410H4559V-525H4596Q4626 -525 4637 -539.5Q4648 -554 4648 -581V-667H4781V-525H4885V-410H4781V-115H4877Z"/></svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 193 KiB |
|
After Width: | Height: | Size: 32 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="116.406 53.364 791.188 904.436" width="791" height="904" role="img"><title>SolidCraft</title><g fill="#000000"><path d="M547.883 282.66 L709.797 188.211 L709.797 375.985 Z"/><path d="M727.797 593.626 L727.797 407.136 L889.571 500.381 Z"/><path d="M530 521.17 L691.797 427.912 L691.797 655.928 L907.594 531.545 L907.594 740.159 L530 957.8 Z"/><path d="M116.406 510.769 L116.406 303.529 L494 521.17 L494 957.8 L314.203 854.167 L314.203 624.777 Z"/><path d="M134.374 272.334 L512 53.364 L691.828 157.015 L476.117 282.846 L673.752 396.761 L512 489.993 Z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 612 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="116.406 53.364 791.188 904.436" width="791" height="904" role="img"><title>SolidCraft</title><g fill="#ffffff"><path d="M547.883 282.66 L709.797 188.211 L709.797 375.985 Z"/><path d="M727.797 593.626 L727.797 407.136 L889.571 500.381 Z"/><path d="M530 521.17 L691.797 427.912 L691.797 655.928 L907.594 531.545 L907.594 740.159 L530 957.8 Z"/><path d="M116.406 510.769 L116.406 303.529 L494 521.17 L494 957.8 L314.203 854.167 L314.203 624.777 Z"/><path d="M134.374 272.334 L512 53.364 L691.828 157.015 L476.117 282.846 L673.752 396.761 L512 489.993 Z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 612 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="116.406 53.364 791.188 904.436" width="791" height="904" role="img"><title>SolidCraft</title><defs><linearGradient id="sc1" x1="619.14" y1="179.734" x2="694.687" y2="396.761" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#1d63c6"/><stop offset="1" stop-color="#15499e"/></linearGradient><linearGradient id="sc2" x1="619.14" y1="413.244" x2="790.839" y2="605.547" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#459fff"/><stop offset="1" stop-color="#2379e7"/></linearGradient><linearGradient id="sc3" x1="632.876" y1="399.508" x2="783.971" y2="968.175" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#1b66cf"/><stop offset="1" stop-color="#124596"/></linearGradient><linearGradient id="sc4" x1="207.063" y1="303.357" x2="426.837" y2="968.175" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#258df5"/><stop offset="1" stop-color="#1461da"/></linearGradient><linearGradient id="sc5" x1="385.63" y1="53.364" x2="522.989" y2="510.769" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#51adff"/><stop offset="1" stop-color="#2b83f1"/></linearGradient></defs><g><path d="M547.883 282.66 L709.797 188.211 L709.797 375.985 Z" fill="url(#sc1)"/><path d="M727.797 593.626 L727.797 407.136 L889.571 500.381 Z" fill="url(#sc2)"/><path d="M530 521.17 L691.797 427.912 L691.797 655.928 L907.594 531.545 L907.594 740.159 L530 957.8 Z" fill="url(#sc3)"/><path d="M116.406 510.769 L116.406 303.529 L494 521.17 L494 957.8 L314.203 854.167 L314.203 624.777 Z" fill="url(#sc4)"/><path d="M134.374 272.334 L512 53.364 L691.828 157.015 L476.117 282.846 L673.752 396.761 L512 489.993 Z" fill="url(#sc5)"/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="38.0 -749.0 4847.0 761.0" width="4847" height="761" role="img"><title>SolidCraft</title><path fill="#15499e" d="M303 12Q212 12 147.5 -20Q83 -52 38 -101L138 -202Q173 -162 217.5 -142Q262 -122 313 -122Q369 -122 396 -145Q423 -168 423 -206Q423 -225 416.5 -240.5Q410 -256 392.5 -266.5Q375 -277 342 -281L273 -290Q200 -300 152.5 -326.5Q105 -353 82 -396.5Q59 -440 59 -498Q59 -561 90.5 -608.5Q122 -656 180.5 -683Q239 -710 321 -710Q400 -710 460 -684.5Q520 -659 563 -612L462 -510Q437 -539 401.5 -557.5Q366 -576 312 -576Q261 -576 236 -558Q211 -540 211 -508Q211 -484 219.5 -469Q228 -454 246.5 -446Q265 -438 294 -433L363 -422Q435 -411 482 -385Q529 -359 552 -317Q575 -275 575 -215Q575 -148 543.5 -97Q512 -46 451 -17Q390 12 303 12ZM926 12Q849 12 794 -21.5Q739 -55 709.5 -117Q680 -179 680 -263Q680 -348 709.5 -409Q739 -470 794 -503.5Q849 -537 926 -537Q1003 -537 1058 -503.5Q1113 -470 1142.5 -409Q1172 -348 1172 -263Q1172 -179 1142.5 -117Q1113 -55 1058 -21.5Q1003 12 926 12ZM926 -105Q970 -105 994 -132.5Q1018 -160 1018 -209V-316Q1018 -366 994 -393Q970 -420 926 -420Q883 -420 858.5 -393Q834 -366 834 -316V-209Q834 -160 858.5 -132.5Q883 -105 926 -105ZM1511 0H1444Q1368 0 1332.5 -37Q1297 -74 1297 -144V-740H1445V-115H1511ZM1694 -581Q1649 -581 1628.5 -602Q1608 -623 1608 -654V-676Q1608 -708 1628.5 -728.5Q1649 -749 1694 -749Q1739 -749 1759.5 -728.5Q1780 -708 1780 -676V-654Q1780 -623 1759.5 -602Q1739 -581 1694 -581ZM1620 0V-525H1768V0ZM2248 0V-88H2243Q2232 -45 2192.5 -16.5Q2153 12 2102 12Q2036 12 1991 -19.5Q1946 -51 1923 -112.5Q1900 -174 1900 -263Q1900 -352 1923 -413Q1946 -474 1991 -505.5Q2036 -537 2102 -537Q2153 -537 2192.5 -509Q2232 -481 2243 -437H2248V-740H2396V0ZM2151 -108Q2179 -108 2201 -117Q2223 -126 2235.5 -143.5Q2248 -161 2248 -185V-340Q2248 -364 2235.5 -381.5Q2223 -399 2201 -408Q2179 -417 2151 -417Q2109 -417 2081.5 -387.5Q2054 -358 2054 -308V-217Q2054 -168 2081.5 -138Q2109 -108 2151 -108ZM2844 12Q2750 12 2680.5 -28Q2611 -68 2573 -147Q2535 -226 2535 -344Q2535 -462 2573 -543.5Q2611 -625 2680.5 -667.5Q2750 -710 2844 -710Q2940 -710 3003.5 -670Q3067 -630 3105 -546L2974 -478Q2961 -522 2930.5 -548.5Q2900 -575 2844 -575Q2777 -575 2736.5 -531.5Q2696 -488 2696 -405V-293Q2696 -211 2736.5 -167Q2777 -123 2844 -123Q2900 -123 2933.5 -153.5Q2967 -184 2983 -227L3107 -155Q3068 -76 3004 -32Q2940 12 2844 12ZM3373 0H3225V-525H3373V-411H3378Q3384 -440 3400 -466Q3416 -492 3443.5 -508.5Q3471 -525 3512 -525H3538V-387H3501Q3458 -387 3429.5 -379.5Q3401 -372 3387 -355Q3373 -338 3373 -307ZM4105 0H4023Q3996 0 3972.5 -13.5Q3949 -27 3935 -53.5Q3921 -80 3921 -117V-130L3953 -92H3917Q3905 -41 3864 -14.5Q3823 12 3763 12Q3684 12 3642 -30.5Q3600 -73 3600 -141Q3600 -197 3627.5 -233Q3655 -269 3705.5 -287Q3756 -305 3825 -305H3908V-338Q3908 -376 3888 -398.5Q3868 -421 3821 -421Q3777 -421 3751 -402Q3725 -383 3708 -359L3620 -437Q3652 -484 3701 -510.5Q3750 -537 3832 -537Q3943 -537 3999.5 -487.5Q4056 -438 4056 -345V-115H4105ZM3908 -221H3836Q3793 -221 3771 -206Q3749 -191 3749 -162V-147Q3749 -119 3767 -105Q3785 -91 3818 -91Q3843 -91 3863 -98Q3883 -105 3895.5 -120Q3908 -135 3908 -159ZM4250 0V-410H4176V-525H4250V-587Q4250 -663 4290.5 -701.5Q4331 -740 4407 -740H4503V-625H4398V-525H4503V-410H4398V0ZM4877 0H4789Q4713 0 4673 -39Q4633 -78 4633 -153V-410H4559V-525H4596Q4626 -525 4637 -539.5Q4648 -554 4648 -581V-667H4781V-525H4885V-410H4781V-115H4877Z"/></svg>
|
||||
|
After Width: | Height: | Size: 3.3 KiB |
@@ -161,6 +161,7 @@ min_radius false
|
||||
mirror false
|
||||
motion_link false
|
||||
move false
|
||||
nav_settings false
|
||||
new false
|
||||
normal false
|
||||
offset false
|
||||
|
||||
@@ -85,6 +85,7 @@
|
||||
"Bottom Right": "أسفل اليمين",
|
||||
"Boundary": "الحد",
|
||||
"Bounding box": "الصندوق المحيط",
|
||||
"Bridge": "جسر",
|
||||
"CHECK": "تحقق",
|
||||
"COMPONENTS": "المكوّنات",
|
||||
"CONFIGURATIONS": "التكوينات",
|
||||
@@ -744,22 +745,22 @@
|
||||
"ai.prefs.availability": "التوفر",
|
||||
"ai.prefs.background_commands": "تشغيل الأوامر البطيئة في الخلفية",
|
||||
"ai.prefs.connect_apps": "إضافة SolidCraft إلى تطبيقات الذكاء الاصطناعي",
|
||||
"ai.prefs.connect_apps_note": "إيقافه يزيل الإدخالات مجدداً.\n\nعند بدء تشغيل SolidCraft، يُضاف كخادم MCP إلى تطبيقات الذكاء الاصطناعي الموجودة على هذا الحاسوب (OpenCode وClaude Desktop وClaude Code وCursor وCodex وGemini CLI وCopilot CLI وWindsurf وZed وVS Code)، ليتمكن الوكيل من استخدامه دون أي إعداد. لا يُضاف إلا مُدخل solidcraft أو يُحدَّث مساره؛ ويُترك المُدخل الذي غيّرته كما هو، ويُنسخ الملف احتياطيًا أولًا. عند الإيقاف تُزال المُدخلات التي أضافها. الأمر «المساعدة > ربط مساعدي الذكاء الاصطناعي…» يفعل ذلك يدويًا.",
|
||||
"ai.prefs.connect_apps_note": "إيقافه يزيل الإدخالات مجددًا.\n\nعند بدء تشغيل SolidCraft، يُضاف كخادم MCP إلى تطبيقات الذكاء الاصطناعي الموجودة على هذا الحاسوب (OpenCode وClaude Desktop وClaude Code وCursor وCodex وGemini CLI وCopilot CLI وWindsurf وZed وVS Code)، ليتمكن الوكيل من استخدامه دون أي إعداد. لا يُضاف إلا مُدخل solidcraft أو يُحدَّث مساره؛ ويُترك المُدخل الذي غيّرته كما هو، ويُنسخ الملف احتياطيًا أولًا. عند الإيقاف تُزال المُدخلات التي أضافها. الأمر «المساعدة > ربط مساعدي الذكاء الاصطناعي…» يفعل ذلك يدويًا.",
|
||||
"ai.prefs.connect_at_start": "توصيل آخر وكيل عند البدء",
|
||||
"ai.prefs.connect_at_start_note": "يبدأ في الخلفية الوكيل الذي اتصلت به آخر مرة (أو الوكيل الافتراضي) في كل مرة يبدأ فيها SolidCraft، ليكون جاهزًا للمحادثة. لا يحدث ذلك عند بدء SolidCraft مع --control. إذا تعذّر الاتصال، يعرض المساعد «فشل الاتصال» وزر «إعادة المحاولة».",
|
||||
"ai.prefs.follow_camera": "اتباع الكاميرا",
|
||||
"ai.prefs.follow_camera": "تتبّع الكاميرا",
|
||||
"ai.prefs.follow_camera_off": "تتبّع الكاميرا التلقائي متوقف.",
|
||||
"ai.prefs.follow_camera_on": "تتجه الكاميرا نحو الرسومات التي ينشئها الوكيل.",
|
||||
"ai.prefs.intro": "وكلاء الذكاء الاصطناعي في SolidCraft.",
|
||||
"ai.prefs.make_default": "تعيين كافتراضي",
|
||||
"ai.prefs.make_default": "تعيين افتراضيًا",
|
||||
"ai.prefs.no_agents": "لا يوجد وكلاء بعد. أضف وكيلًا أو حدّث القائمة للعثور على الوكلاء المثبّتين.",
|
||||
"ai.prefs.no_saved_approvals": "لا توجد موافقات محفوظة.",
|
||||
"ai.prefs.open_at_start": "فتح المحادثة عند البدء",
|
||||
"ai.prefs.open_at_start_note": "يعرض لوحة المساعد (أو نافذتها المستقلة) في كل مرة يبدأ فيها SolidCraft. لا يحدث ذلك عند بدء SolidCraft مع --control.",
|
||||
"ai.prefs.override_all_warning": "قد يحفظ هذا الوكيل التصميم أو يكتب فوقه أو يصدّره أو يستبدله دون أن يسأل.",
|
||||
"ai.prefs.permission_all": "فعل كل شيء",
|
||||
"ai.prefs.permission_all_summary": "لا يسأل أبداً",
|
||||
"ai.prefs.permission_ask": "السؤال أولاً",
|
||||
"ai.prefs.permission_all": "تنفيذ كل شيء",
|
||||
"ai.prefs.permission_all_summary": "لا يسأل أبدًا",
|
||||
"ai.prefs.permission_ask": "السؤال أولًا",
|
||||
"ai.prefs.permission_ask_summary": "يسأل عن كل تغيير",
|
||||
"ai.prefs.permission_edits": "إجراء تعديلات",
|
||||
"ai.prefs.permission_edits_summary": "يعدّل بحرية ويسأل عن الباقي",
|
||||
@@ -1296,6 +1297,7 @@
|
||||
"cmd.timeline_stop": "إيقاف السجل",
|
||||
"cmd.timeline_suppress": "تعطيل الميزة",
|
||||
"cmd.ui.assistant": "إظهار المساعد أو إخفاؤه",
|
||||
"cmd.view.navigator": "متصفح العرض المخصص",
|
||||
"cmd.view.render_browser": "متصفح التصيير",
|
||||
"cmd.view.render_dock": "نقل لوحة التصيير",
|
||||
"cmd.view.render_focus": "عرض تركيز التصيير",
|
||||
@@ -1396,6 +1398,9 @@
|
||||
"dialog.custom_axis": "↖ محور مخصص",
|
||||
"dialog.dimension_insert_hint": "انقر على بُعد قائد آخر لإدراج اسمه",
|
||||
"dialog.dimension_name": "البُعد: {name}",
|
||||
"dialog.dimension_step_down": "إنقاص بمقدار {step}",
|
||||
"dialog.dimension_step_expression": "يتغير الرقم فقط بالخطوات؛ يبقى التعبير كما كُتب",
|
||||
"dialog.dimension_step_up": "زيادة بمقدار {step}",
|
||||
"dialog.edit_title": "تعديل {title}",
|
||||
"dialog.no_shown_body": "لا يوجد جسم ظاهر لتغييره: أظهر جسمًا، أو اختر «جسم جديد»",
|
||||
"dialog.pattern_sketch_not_object": "اختر أجسامًا أو ميزات: الرسم ليس أيًّا منهما. كرّر منحنياته بأداة النمط أو المرآة داخل الرسم، أو ابثقه أولًا.",
|
||||
@@ -1403,6 +1408,7 @@
|
||||
"dialog.selected_count": "↖ {count} محدد",
|
||||
"dialog.selection_constraint": "قيد {id}",
|
||||
"dialog.selection_external_curve": "{sketch} · منحنى {id}",
|
||||
"dialog.selection_external_point": "{point} · {sketch}",
|
||||
"dialog.selection_guidance": "حدد مُدخلًا لتغييره. انقر على العناصر المحددة لإزالتها؛ Ctrl/Cmd مع النقر يبدّل تحديدها أيضًا. راجع المعاينة ثم اضغط موافق.",
|
||||
"dialog.selection_midpoint": "نقطة المنتصف · {id}",
|
||||
"dialog.selection_profile": "{sketch} · مقطع {number}",
|
||||
@@ -1420,6 +1426,7 @@
|
||||
"document.read_only_save": "فُتح هذا التصميم للقراءة فقط: احفظه كملف جديد",
|
||||
"document.save_changes": "حفظ التغييرات على {name}؟",
|
||||
"document.save_copy": "حفظ نسخة…",
|
||||
"document.tab_unsaved_changes": "تغييرات غير محفوظة",
|
||||
"document.unsaved_changes": "في {name} تغييرات غير محفوظة. إن لم تحفظها فستضيع.",
|
||||
"drive.rigid": "{joint} مفصل صلب: يثبّت أجزاءه في مكانها، لذا لا يوجد ما يمكن تحريكه. لتتحرك الأجزاء، غيّر نوعه في «تعديل المفصل»: «دوراني» للدوران، «منزلق» للانزلاق، «أسطواني» لكليهما.",
|
||||
"drive.stopped_by_contact": "توقف عند {value}: سيخترق {a} الجسم {b}",
|
||||
@@ -1600,7 +1607,7 @@
|
||||
"help.parts.resize": "يغيّر مقاس قطعة قياسية مدرجة أو طولها ويعيد بناءها، وتبقيها مفاصلها مثبتة في مكانها. استخدمه عندما يتبين أن برغيًا أطول من اللازم أو أن ثقبًا يحتاج إلى مقاس آخر.",
|
||||
"help.plastic.assign_rule": "يعيّن قاعدة بلاستيك لأجسام، فتأخذ ميزاتها البلاستيكية ميلها وخلوصها منها كقيم افتراضية. استخدمه لإعداد قطعة مقولبة مرة واحدة لكل ميزاتها.",
|
||||
"help.plastic.boss": "يضيف نتوءًا لمسمار على وجه: عمودًا مع ثقب اختياري وميل وتقويس عند الجذر وأضلاع تقوية. استخدمه لنقاط البراغي في الهياكل المقولبة أو المطبوعة.",
|
||||
"help.plastic.lip": "يضيف شفة أو أخدودًا مطابقًا على طول حافة جسم مفرَّغ. استخدمه ليتمركز نصفا الهيكل أحدهما على الآخر ويُغلقا بإحكام.",
|
||||
"help.plastic.lip": "يضيف شفة أو أخدودًا مطابقًا على طول إطار جسم مفرَّغ (فرّغ الجسم بفتح أحد وجوهه أولًا، ثم اختر الإطار المحيط بتلك الفتحة). استخدمه ليتمركز نصفا الهيكل أحدهما على الآخر ويُغلقا بإحكام.",
|
||||
"help.plastic.manage_rules": "ينشئ قواعد البلاستيك ويعدّلها: المادة وسماكة الجدار وأنصاف الأقطار والخلوص والميل. استخدمها للحفاظ على اتساق ميزات القطعة المقولبة.",
|
||||
"help.plastic.rest": "يضيف مسندًا على وجه: وسادة مستديرة أو مستطيلة، مصمتة أو مجوفة، مع ميل. استخدمه كدعامة أو مُباعِد في هيكل مقولب.",
|
||||
"help.plastic.snap_fit": "يضيف خطاف وصلة كبس على وجه، بطوله وسماكته ونتوء التثبيت. استخدمه لتثبيت القطع معًا بلا براغي.",
|
||||
@@ -1918,7 +1925,7 @@
|
||||
"joint.limit_loop": "{side} {value}: لا يمكن لحلقة مغلقة أن تنغلق بعده",
|
||||
"joint.limit_range": "{side}: لا تلامس ضمن {range}، تُرك كما كان",
|
||||
"joint.limit_turn": "{side} {value}: لا تلامس ضمن دورة واحدة",
|
||||
"joint.limits_overlapping": "متداخلة بالفعل هنا: {pairs}. لا توقف البحث إلا إذا تداخلت أعمق.",
|
||||
"joint.limits_overlapping": "متداخلة بالفعل هنا: {pairs}. لا توقف البحث إلا إذا ازداد تداخلها.",
|
||||
"joint.one_body": "كلا الاختيارين على {body}. اختر الثاني على قطعة أخرى.",
|
||||
"joint.one_component": "هذه القطع أجسام لمكوّن واحد: {bodies}. المفصل يحرّك مكوّنًا مقابل آخر، لذا تحتاج كل قطعة إلى مكوّن خاص بها.",
|
||||
"joint.rigid_group_one_component": "هذه الأجسام وعددها {count} كلها في مكوّن واحد (أو في التصميم نفسه): استخدم {command} أولًا.",
|
||||
@@ -1933,7 +1940,7 @@
|
||||
"joint_move.fine": "الخطوة بعيدًا عن المحور",
|
||||
"joint_move.fixed": "لا يملك هذا الجزء محور مفصل قابلًا للحركة.",
|
||||
"joint_move.help": "تحريك — اسحب سهمًا أو قوسًا. يلغى السحب الحالي بمفتاح Esc.",
|
||||
"joint_move.legacy_hint": "إيقاف: لوحة التحريك السابقة.",
|
||||
"joint_move.legacy_hint": "عند الإيقاف: لوحة التحريك السابقة.",
|
||||
"joint_move.precision": "اسحب خارج الدليل المتقطع للخطوة الدقيقة.",
|
||||
"joint_move.rotation": "الدوران من نقطة البداية",
|
||||
"joint_move.select": "اختر وجهًا أو جزءًا واحدًا من التجميع، ثم اختر تحريك.",
|
||||
@@ -1999,6 +2006,7 @@
|
||||
"material.selection.mixed": "مواد مختلفة",
|
||||
"measure.at": "عند",
|
||||
"measure.item": "العنصر {number}",
|
||||
"measure.line_to_centre": "من الخط إلى المركز",
|
||||
"measure.quick_copied": "تم نسخ القياس",
|
||||
"measure.quick_copy": "انقر لنسخ القياس",
|
||||
"measure.quick_edges": "{count} حواف",
|
||||
@@ -2240,7 +2248,7 @@
|
||||
"prefs.anti_aliasing_amd_dx12": "يستخدم تنعيم الحواف 1× لأن فحص بدء التشغيل لم يرسم شيئًا مع أخذ العينات المتعددة ونجح عند 1×. يمكنك اختيار إعداد آخر أعلاه؛ أعد التشغيل لتطبيقه إن نجح الفحص.",
|
||||
"prefs.anti_aliasing_note": "ينعّم الحواف المسننة.",
|
||||
"prefs.appearance.colour_vision_help": "يغيّر الألوان ذات الدلالة: حالات الرسم والمحاور والثنيات والتحليلات والأيقونات.",
|
||||
"prefs.appearance.desktop_only": "لسطح المكتب فقط",
|
||||
"prefs.appearance.desktop_only": "لتطبيق سطح المكتب فقط",
|
||||
"prefs.appearance.follow_help": "يعيد التحميل عندما تعيد أداة أخرى كتابة الملف.",
|
||||
"prefs.appearance.interface_scale": "مقياس الواجهة",
|
||||
"prefs.appearance.next_themes": "السمات التالية",
|
||||
@@ -2250,11 +2258,11 @@
|
||||
"prefs.appearance.theme_count": "{count} سمات",
|
||||
"prefs.appearance.theme_own": "ألوان السمة",
|
||||
"prefs.appearance.viewport_colours": "ألوان العرض ثلاثي الأبعاد",
|
||||
"prefs.appearance.viewport_logo": "منفذ العرض والشعار",
|
||||
"prefs.appearance.viewport_logo": "منطقة العرض والشعار",
|
||||
"prefs.applies_on_restart": "يُطبَّق عند إعادة التشغيل",
|
||||
"prefs.assistant": "المساعد",
|
||||
"prefs.assistant_connecting": "الاتصال",
|
||||
"prefs.assistant_follow_help": "تحرّك عروض Look At للرسم المنظور مع المؤشر.",
|
||||
"prefs.assistant_follow_help": "النظر إلى الرسومات يحرّك العرض مع المؤشر.",
|
||||
"prefs.assistant_limit": "الحد",
|
||||
"prefs.assistant_limited": "محدود",
|
||||
"prefs.assistant_pointer_speed": "سرعة المؤشر",
|
||||
@@ -2262,13 +2270,21 @@
|
||||
"prefs.assistant_seconds_aria": "الثواني",
|
||||
"prefs.assistant_use_care": "استخدم بحذر",
|
||||
"prefs.auto_operation": "اختيار العملية تلقائيًا",
|
||||
"prefs.auto_operation_note": "ضم أو جسم جديد أو قطع، حسب ما تلامسه المادة.",
|
||||
"prefs.auto_operation_note": "ضم أو جسم جديد أو قص، حسب ما تلامسه المادة.",
|
||||
"prefs.axes": "المحاور",
|
||||
"prefs.axis": "المحور",
|
||||
"prefs.blur_cost": "تكلفة العرض النسبية. زيادة الأشرطة تعني عادةً مزيدًا من العمل.",
|
||||
"prefs.blur_device": "غير متاح على جهاز الرسوميات أو مسار العرض هذا",
|
||||
"prefs.blur_guard": "خُفّضت جودة التمويه للحفاظ على استجابة الإطارات. لم يتغير نصف القطر.",
|
||||
"prefs.blur_off": "إيقاف",
|
||||
"prefs.blur_reduce_transparency": "معطّل بسبب إعداد تقليل الشفافية في النظام",
|
||||
"prefs.blur_software": "غير متاح مع الرسوميات البرمجية (gpu-software)",
|
||||
"prefs.blur_source": "غير متاح في عرض مساحة العمل هذا",
|
||||
"prefs.blur_web": "غير متاح في مسار عرض الويب هذا",
|
||||
"prefs.bodies_folders": "مجلدات الأجسام",
|
||||
"prefs.bodies_folders_always": "دائمًا",
|
||||
"prefs.bodies_folders_never": "أبدًا",
|
||||
"prefs.bodies_folders_several": "عدة",
|
||||
"prefs.bodies_folders_several": "عند التعدد",
|
||||
"prefs.bottom": "أسفل",
|
||||
"prefs.bottom_centre": "أسفل الوسط",
|
||||
"prefs.bottom_right": "أسفل اليمين",
|
||||
@@ -2323,6 +2339,13 @@
|
||||
"prefs.dialogs_edge": "عند حافة العرض",
|
||||
"prefs.dialogs_left_column": "في العمود الأيسر",
|
||||
"prefs.dialogs_note": "مكان فتح حوارات الأوامر: مثبتة عند حافة العرض، أو في العمود الأيسر مكان المستعرض والسجل، اللذين يعودان كما كانا عند إغلاق الحوار.",
|
||||
"prefs.dimension_step_angle": "ما تضيفه أسهم النافذة والسهمان لأعلى ولأسفل والعجلة إلى الزاوية",
|
||||
"prefs.dimension_step_length": "ما تضيفه أسهم النافذة والسهمان لأعلى ولأسفل والعجلة إلى الطول",
|
||||
"prefs.dimension_steps": "خطوات النافذة",
|
||||
"prefs.dimension_values": "قيم الأبعاد",
|
||||
"prefs.dimension_values_note": "مكان كتابة قيمة البعد. للنافذة أسهم تغيّرها بخطوات؛ تضع أداة البعد النص أولًا.",
|
||||
"prefs.dimension_values_view": "في العرض",
|
||||
"prefs.dimension_values_window": "في نافذة",
|
||||
"prefs.display": "العرض",
|
||||
"prefs.edges": "الحواف والتظليل",
|
||||
"prefs.extend": "توسيع",
|
||||
@@ -2360,7 +2383,7 @@
|
||||
"prefs.graphics_note": "وازن بين السرعة والنعومة. كل خيار يعرض أثره على عيّنة.",
|
||||
"prefs.graphics_page.current_view": "العرض الحالي",
|
||||
"prefs.graphics_page.fastest": "الأسرع",
|
||||
"prefs.graphics_page.fold_items": "طي العناصر",
|
||||
"prefs.graphics_page.fold_items": "تلخيص العناصر",
|
||||
"prefs.graphics_page.in_view": "داخل العرض",
|
||||
"prefs.graphics_page.live_details": "تفاصيل مباشرة",
|
||||
"prefs.graphics_page.own_mix": "مزيجك الخاص",
|
||||
@@ -2369,7 +2392,7 @@
|
||||
"prefs.graphics_page.restart": "تلزم إعادة التشغيل",
|
||||
"prefs.graphics_page.sharpest": "الأكثر وضوحًا",
|
||||
"prefs.graphics_page.single_surface_analysis": "تحليل سطح واحد",
|
||||
"prefs.graphics_page.status_chips": "شرائح الحالة",
|
||||
"prefs.graphics_page.status_chips": "شارات الحالة",
|
||||
"prefs.graphics_page.subtitle": "وازن بين السرعة والنعومة.",
|
||||
"prefs.graphics_page.supersampling": "التنعيم الفائق",
|
||||
"prefs.graphics_page.supersampling_adaptive": "فقط أثناء سكون العرض",
|
||||
@@ -2416,7 +2439,7 @@
|
||||
"prefs.layout_page.icons_labels": "الأيقونات + التسميات",
|
||||
"prefs.layout_page.ok_cancel": "موافق وإلغاء",
|
||||
"prefs.layout_page.on_hover": "عند مرور المؤشر",
|
||||
"prefs.layout_page.open": "فتح",
|
||||
"prefs.layout_page.open": "تُفتح",
|
||||
"prefs.layout_page.origin_bodies": "الأصل والأجسام في الأعلى",
|
||||
"prefs.layout_page.panels": "اللوحات",
|
||||
"prefs.layout_page.pinned_menus": "استبعاد الأوامر المثبتة من القوائم",
|
||||
@@ -2456,8 +2479,8 @@
|
||||
"prefs.navigation.cube_compact": "المكعب",
|
||||
"prefs.navigation.device": "الجهاز",
|
||||
"prefs.navigation.free": "حر",
|
||||
"prefs.navigation.orbit_style": "نمط الدوران",
|
||||
"prefs.navigation.radial_help": "اضغط مطولاً أو انقر سريعاً بالزر الأيمن لعرض حلقة من الأدوات.",
|
||||
"prefs.navigation.orbit_style": "نمط التدوير",
|
||||
"prefs.navigation.radial_help": "اضغط مطولًا أو انقر سريعًا بالزر الأيمن لعرض حلقة من الأدوات.",
|
||||
"prefs.navigation.turntable": "طاولة دوارة",
|
||||
"prefs.navigation_note": "كيف تحرك الفأرة ولوحة اللمس والفأرة ثلاثية الأبعاد العرض.",
|
||||
"prefs.navigator_fit": "ملاءمة النموذج بعد تغيير العرض من متصفح العرض",
|
||||
@@ -2475,7 +2498,7 @@
|
||||
"prefs.origin_plane_names_note": "الأسماء المعروضة في المستعرض والحوارات والقوائم. {top} هو XY و{front} هو XZ و{right} هو YZ، والمحور Z للأعلى. تحتفظ الأوامر والسكربتات والتصاميم المحفوظة بـ XY وXZ وYZ.",
|
||||
"prefs.overall_speed": "السرعة العامة",
|
||||
"prefs.parameter_list_rows": "صفوف قائمة المعاملات",
|
||||
"prefs.parameter_list_rows_tip": "الحد الأقصى للمعلمات التي يسردها السهم لأسفل في مربع تعبير فارغ.",
|
||||
"prefs.parameter_list_rows_tip": "الحد الأقصى للمعاملات التي يسردها السهم لأسفل في مربع تعبير فارغ.",
|
||||
"prefs.persistent_ribbon_pressed": "تمييز أوامر الشريط النشطة",
|
||||
"prefs.persistent_ribbon_pressed_help": "تمييز أوامر الشريط أثناء تفعيل تأثيراتها. أوقف هذا الخيار للاحتفاظ بالمظهر المحايد السابق ومساعدة الأوامر.",
|
||||
"prefs.perspective_fov": "مجال الرؤية",
|
||||
@@ -2533,6 +2556,8 @@
|
||||
"prefs.section_hatch_by_part": "حسب الجزء",
|
||||
"prefs.section_hatch_one": "نمط واحد",
|
||||
"prefs.select": "تحديد",
|
||||
"prefs.select_hidden_edges": "تحديد الحواف المخفية في العرض المظلل مع الحواف المخفية",
|
||||
"prefs.select_hidden_edges_help": "يتيح تحديد الحواف عبر الأسطح عند تفعيل العرض المظلل مع الحواف المخفية، بما في ذلك استخدام أداة Fillet. يحافظ إيقافه على سلوك التحديد في v0.30.1. تظل الأجسام المخفية غير قابلة للتحديد.",
|
||||
"prefs.select_one_item": "تحديد عنصر واحد",
|
||||
"prefs.select_other_hidden": "تحديد العناصر الهندسية المخفية باستخدام «تحديد عنصر آخر»",
|
||||
"prefs.select_other_hidden_help": "عند التفعيل: تضمين الأجسام والمخططات المخفية والحواف المحجوبة قرب المؤشر. عند التعطيل: استخدام القائمة الأصلية للعناصر الهندسية المرئية أسفل المؤشر.",
|
||||
@@ -2544,19 +2569,21 @@
|
||||
"prefs.shortcuts_import": "استيراد",
|
||||
"prefs.shortcuts_intro": "مفاتيح كل أمر.",
|
||||
"prefs.shortcuts_not_assigned": "غير معيّن",
|
||||
"prefs.shortcuts_press": "اضغط مفتاحاً...",
|
||||
"prefs.shortcuts_press": "اضغط مفتاحًا...",
|
||||
"prefs.single_surface_analysis": "استخدام تحليل سطح واحد",
|
||||
"prefs.single_surface_analysis_help": "عند إيقاف هذا الخيار، يمكن إبقاء تظليل السطح وتحليل الاستمرارية مفعّلين معًا. عند تشغيله، يبقى آخر تحليل تم بدؤه أو تغييره فقط.",
|
||||
"prefs.sketch.align_points": "المحاذاة مع نقاط الرسم الأخرى",
|
||||
"prefs.sketch.align_points_note": "تتحاذى الخطوط أثناء الرسم مع النقاط على المحورين معًا.",
|
||||
"prefs.sketch.auto_dimensions": "إضافة كل الأبعاد للأشكال الجديدة",
|
||||
"prefs.sketch.auto_dimensions_note": "القيم المدخلة وكل بُعد يثبّت الشكل.",
|
||||
"prefs.sketch.auto_locate": "التحديد انطلاقًا من أصل الرسم",
|
||||
"prefs.sketch.auto_locate": "تحديد الموضع من أصل الرسم",
|
||||
"prefs.sketch.auto_locate_note": "مع إضافة كل الأبعاد.",
|
||||
"prefs.sketch.center_arc": "قوس المركز يتبع المؤشر",
|
||||
"prefs.sketch.center_arc": "القوس بنقطة مركز يتبع المؤشر",
|
||||
"prefs.sketch.click_selects": "النقر يحدد",
|
||||
"prefs.sketch.conflict_colour": "لون التعارض",
|
||||
"prefs.sketch.drawing": "الرسم",
|
||||
"prefs.sketch.ellipse_axes": "رسم القطوع الناقصة مع محاورها",
|
||||
"prefs.sketch.ellipse_axes_note": "المحوران الأكبر والأصغر كخطوط مساعدة، لتحديد أبعاد أنصاف المحاور والمحاور. عند الإيقاف: القطع الناقص وحده، ويعطي D عليه نصف قطره الأصغر أينما سُحبت القيمة، مرسومًا ومُعلَّمًا بـ R كما في السابق.",
|
||||
"prefs.sketch.exit_keep": "إبقاء",
|
||||
"prefs.sketch.exit_previous": "العودة إلى العرض السابق",
|
||||
"prefs.sketch.exit_unless_rotated": "إلا إذا تم التدوير",
|
||||
@@ -2568,7 +2595,7 @@
|
||||
"prefs.sketch.prefer_free_points": "تفضيل النقاط القابلة للتحريك عند تداخلها",
|
||||
"prefs.sketch.project_options": "السؤال عن خيارات الإسقاط أولًا",
|
||||
"prefs.sketch.recent_snap": "الالتقاط عند تقاطع دليلين",
|
||||
"prefs.sketch.select_other_hidden": "اختيار آخر يشمل الهندسة المخفية",
|
||||
"prefs.sketch.select_other_hidden": "تحديد آخر يشمل الهندسة المخفية",
|
||||
"prefs.sketch.shortcut_cycles_modes": "الاختصار مجددًا يبدّل الوضع",
|
||||
"prefs.sketch.shortcut_cycles_modes_tip": "عند الإيقاف: تبدأ الأداة مجددًا.",
|
||||
"prefs.sketch.snapping_guides": "الالتقاط والأدلة",
|
||||
@@ -2650,6 +2677,10 @@
|
||||
"prefs.theme_follow_off": "إيقاف",
|
||||
"prefs.theme_follow_stop": "إيقاف الاتباع",
|
||||
"prefs.theme_import_hint": "انقر لاختيار ملف سمة، أو أفلت ملفًا هنا، أو اضغط Ctrl+V (Cmd+V على Mac) بعد نسخ سمة",
|
||||
"prefs.theme_notice_dismiss": "إخفاء",
|
||||
"prefs.theme_option_blur": "تمويه خلف اللوحات",
|
||||
"prefs.theme_option_blur_help": "تمويه خلف اللوحات شبه الشفافة، بالبكسل الفعلي. الصفر يوقف التمويه.",
|
||||
"prefs.theme_option_invalid": "القيمة المحفوظة للخيار {option} غير صالحة؛ تُستخدم القيمة الافتراضية للسمة. تبقى القيمة المحفوظة كما هي.",
|
||||
"prefs.theme_option_opacity": "العتامة",
|
||||
"prefs.theme_option_opacity_help": "اضبط عتامة اللوحات العائمة. يُطبّق حد أدنى للعتامة للحفاظ على وضوح النص فوق ألوان منطقة العرض.",
|
||||
"prefs.theme_option_tab_colours": "ألوان علامات التبويب",
|
||||
@@ -2658,6 +2689,7 @@
|
||||
"prefs.theme_option_tab_fade_ms_help": "تتغيّر ألوان اللوحات ومنطقة العرض تدريجيًا عند تبديل علامات التبويب. القيمة صفر تبدّلها فورًا.",
|
||||
"prefs.theme_options_badge": "الخيارات",
|
||||
"prefs.theme_options_more": "المزيد · {count}",
|
||||
"prefs.theme_options_newer": "تتطلب هذه السمة إصدارًا أحدث من SolidCraft. بعد الترقية، أعد استيراد السمة الأصلية لاستعادة تعريفات خياراتها.",
|
||||
"prefs.theme_options_reset": "إعادة تعيين",
|
||||
"prefs.theme_options_unavailable": "غير متاح على هذا الجهاز",
|
||||
"prefs.theme_paste": "لصق السمة",
|
||||
@@ -2686,7 +2718,7 @@
|
||||
"prefs.validation.each_step": "بعد كل خطوة",
|
||||
"prefs.validation.label": "فحص النواة الكامل",
|
||||
"prefs.validation.off": "متوقف (تجريبي)",
|
||||
"prefs.validation_note": "بعد كل خطوة: يفحص كل عملية سحب ودمج وتقويس.\nمرة لكل ميزة: يفحص ناتج كل ميزة مرة واحدة.\nمتوقف (تجريبي): لا فحص.",
|
||||
"prefs.validation_note": "بعد كل خطوة: يفحص كل عملية كنس وعملية منطقية وتقويس.\nمرة لكل ميزة: يفحص ناتج كل ميزة مرة واحدة.\nمتوقف (تجريبي): لا فحص.",
|
||||
"prefs.value_boxes_in_view": "إظهار القيم بجانب مقابضها",
|
||||
"prefs.value_boxes_in_view_tip": "مسافة البثق ونصف قطر التدوير... في مربع داخل العرض.",
|
||||
"prefs.view": "سلوك العرض",
|
||||
@@ -4091,7 +4123,7 @@
|
||||
"settings.display_quality_note": "المستويات الأدق ترسم الأوجه والحواف المنحنية بنعومة أكبر وتستغرق وقتًا أطول لإنشاء الشبكة. يتغير العرض فقط، لا التصميم ولا الملفات المصدّرة.",
|
||||
"settings.double_click_components": "النقر المزدوج على مكوّن لتنشيطه",
|
||||
"settings.frame_rate_details_note": "يضيف رسمًا لأزمنة الإطارات الأخيرة مع المثلثات المرسومة فوقه، ومدرّجًا تكراريًا لأزمنة الإطارات، وما رسمته وحدة GPU. انقر معدل الإطارات في العرض لإظهارها أو إخفائها.",
|
||||
"settings.frame_rate_note": "يعرض الإطارات في الثانية في الزاوية اليمنى السفلية من العرض. أثناء تفعيله يُعاد رسم العرض باستمرار.",
|
||||
"settings.frame_rate_note": "يعرض الإطارات في الثانية في الزاوية اليمنى السفلية من العرض.",
|
||||
"settings.insert_lists_open_designs": "إدراج تصميم يسرد التصاميم المفتوحة أولًا",
|
||||
"settings.measure_axis_distances": "مسافات المحاور في القياس",
|
||||
"settings.measure_axis_distances_help": "إظهار مركّبات الأبعاد على محاور العالم بجانب المسافة الكلية أثناء فتح أداة القياس.",
|
||||
@@ -4201,13 +4233,13 @@
|
||||
"thread.automatic_size": "حجم تلقائي",
|
||||
"thread.chamfer_depth_needed": "مطلوب لهذا الشكل",
|
||||
"thread.designation": "التسمية",
|
||||
"thread.drill_range": "مثقاب صانع اللقمة {drill} مم. القطر الأصغر القياسي للسن الداخلي {minor} مم. يُقبل مثقاب من {low} إلى {high} مم.",
|
||||
"thread.drill_range": "مثقاب التسنين الذي يوصي به صانعو ذكور التسنين {drill} مم. القطر الأصغر القياسي للسن الداخلي {minor} مم. يُقبل مثقاب من {low} إلى {high} مم.",
|
||||
"thread.drill_range_custom": "المثقاب هو القطر الأكبر ناقص الخطوة، {drill} مم. القطر الأصغر القياسي للسن الداخلي {minor} مم. يُقبل مثقاب من {low} إلى {high} مم.",
|
||||
"thread.drill_range_taper": "مثقاب صانع اللقمة {drill} مم. القطر الأصغر القياسي عند نهاية السن {minor} مم. يُقبل مثقاب من {low} إلى {high} مم.",
|
||||
"thread.drill_range_taper": "مثقاب التسنين الذي يوصي به صانعو ذكور التسنين {drill} مم. القطر الأصغر القياسي عند نهاية السن {minor} مم. يُقبل مثقاب من {low} إلى {high} مم.",
|
||||
"thread.end_square": "قائمة",
|
||||
"thread.fade_short": "1.25 P (ISO 3508)",
|
||||
"thread.fade_standard": "2.5 P (ISO 3508)",
|
||||
"thread.fits_the_face": "يناسب الوجه، مثلاً {example}",
|
||||
"thread.fits_the_face": "يناسب الوجه، مثلًا {example}",
|
||||
"thread.form_bspp": "سن أنابيب G (ISO 228-1)",
|
||||
"thread.form_bspt": "سن أنابيب مخروطي R، Rc (ISO 7-1)",
|
||||
"thread.form_npt": "سن أنابيب مخروطي NPT (ASME B1.20.1)",
|
||||
@@ -4464,7 +4496,7 @@
|
||||
"ui.agent_chat_no_images": "هذا الوكيل لا يقبل الصور",
|
||||
"ui.agent_chat_no_picture": "لا توجد صورة في الحافظة",
|
||||
"ui.agent_chat_no_tool_call": "لم يستخدم النموذج أدوات SolidCraft، لذا لم يتغيّر التصميم. اختر نموذجًا يدعم استدعاء الأدوات؛ راجع «النماذج المحلية» في دليل محادثة الوكيل.",
|
||||
"ui.agent_chat_node_too_old": "إصدار Node.js {version} قديم جدًا لهذا الوكيل. ثبّت Node.js 20.10 أو أحدث، ثم اضغط على تحديث.",
|
||||
"ui.agent_chat_node_too_old": "إصدار Node.js {version} قديم جدًا لهذا الوكيل. ثبّت Node.js 20.10 أو أحدث، ثم اضغط تحديث.",
|
||||
"ui.agent_chat_none_found": "لم يُعثر على وكيل برمجي. ثبّت أحد هذه ثم اضغط تحديث:",
|
||||
"ui.agent_chat_none_found_short": "لم يُعثر على وكيل",
|
||||
"ui.agent_chat_not_installed": "غير مثبت",
|
||||
@@ -4505,7 +4537,7 @@
|
||||
"ui.agent_chat_send_now_tip": "إيقاف خطوة الوكيل الحالية وإرسال هذه بعدها (Ctrl+Enter)",
|
||||
"ui.agent_chat_setup_body": "يتواصل SolidCraft مع وكيل برمجة مثبّت على هذا الحاسوب. اختر واحدًا من التفضيلات › الوكيل.",
|
||||
"ui.agent_chat_setup_title": "إعداد مساعد",
|
||||
"ui.agent_chat_side": "إرساء المحادثة",
|
||||
"ui.agent_chat_side": "تثبيت المحادثة",
|
||||
"ui.agent_chat_signed_in": "سجّل الدخول",
|
||||
"ui.agent_chat_state_auth": "يلزم تسجيل الدخول",
|
||||
"ui.agent_chat_state_busy": "جارٍ العمل…",
|
||||
@@ -4570,10 +4602,10 @@
|
||||
"ui.ai_key_note": "يُخزّن في سلسلة مفاتيح النظام، وليس في إعدادات SolidCraft أبدًا. اتركه فارغًا لخادم محلي.",
|
||||
"ui.ai_keychain_error": "رفضت سلسلة مفاتيح النظام: {error}",
|
||||
"ui.ai_kind": "النوع",
|
||||
"ui.ai_mode_all": "فعل كل شيء",
|
||||
"ui.ai_mode_all": "تنفيذ كل شيء",
|
||||
"ui.ai_mode_all_note": "لا شيء يسأل أولًا. تبقى تقارير الأعطال محظورة وتظل حدود المجلدات سارية.",
|
||||
"ui.ai_mode_all_warning": "يمكن للوكيل حفظ تصميمك والكتابة فوقه وتصديره واستبداله دون سؤال. استخدم هذا فقط مع وكيل تثق به، وعلى تصاميم حفظتها.",
|
||||
"ui.ai_mode_ask": "السؤال أولاً",
|
||||
"ui.ai_mode_ask": "السؤال أولًا",
|
||||
"ui.ai_mode_ask_note": "كل تغيير في التصميم وكل إجراء على الملفات ينتظر سماحك.",
|
||||
"ui.ai_mode_edits": "إجراء تعديلات",
|
||||
"ui.ai_mode_edits_note": "أوامر النمذجة تعمل دون سؤال؛ أما الحفظ والفتح والتصدير وإجراءات الملفات أو الإجراءات الهدّامة الأخرى فتسأل.",
|
||||
@@ -4744,21 +4776,21 @@
|
||||
"ui.construction.ribbon_label": "خطوط مساعدة",
|
||||
"ui.construction.side_hint": "خط إنشائي: انقر الجهة التي يمتد نحوها الخط؛ انقر بزر الفأرة الأيمن للبدء من جديد",
|
||||
"ui.context_menu": "القائمة السياقية",
|
||||
"ui.continuity_crease": "تجعّد",
|
||||
"ui.continuity_crease": "ثنية",
|
||||
"ui.continuity_curvature": "الانحناء",
|
||||
"ui.continuity_curvature_change": "تغيّر الانحناء",
|
||||
"ui.continuity_curvature_change_tip": "تغيّر الانحناء (G3): يتغير الانحناء أيضًا بسلاسة عبر خط الالتقاء.",
|
||||
"ui.continuity_curvature_tip": "متصل الانحناء (G2): تمر الانعكاسات دون انكسار.",
|
||||
"ui.continuity_g0": "G0 تجعّد",
|
||||
"ui.continuity_g0": "G0 ثنية",
|
||||
"ui.continuity_g1": "G1 مماس",
|
||||
"ui.continuity_g2": "G2 انحناء",
|
||||
"ui.continuity_gap": "فجوة",
|
||||
"ui.continuity_position": "الموضع",
|
||||
"ui.continuity_position_tip": "الموضع (G0): تلتقي الأسطح، مع تجعّد عند التقائها.",
|
||||
"ui.continuity_position_tip": "الموضع (G0): تلتقي الأسطح، مع ثنية عند التقائها.",
|
||||
"ui.continuity_smooth_curvature_change": "تغيّر انحناء سلس",
|
||||
"ui.continuity_smooth_curvature_change_tip": "تغيّر انحناء سلس (G4): حتى طريقة تغيّر الانحناء تستمر بسلاسة عبر خط الالتقاء.",
|
||||
"ui.continuity_tangent": "مماس",
|
||||
"ui.continuity_tangent_tip": "مماس (G1): لا تجعّد، لكن الانحناء يقفز عبر خط الالتقاء.",
|
||||
"ui.continuity_tangent_tip": "مماس (G1): لا ثنية، لكن الانحناء يقفز عبر خط الالتقاء.",
|
||||
"ui.copy": "نسخ",
|
||||
"ui.copy_details": "نسخ التفاصيل",
|
||||
"ui.copy_details_tip": "انسخ الرسالة كاملة للبحث عنها أو للإبلاغ عنها.",
|
||||
@@ -4784,6 +4816,7 @@
|
||||
"ui.default_modelling_orientation": "اتجاه النمذجة الافتراضي",
|
||||
"ui.delete": "حذف",
|
||||
"ui.delete_select_first": "حدّد أولًا ما تريد حذفه: الميزات أو الأجسام أو المكوّنات أو هندسة الرسم",
|
||||
"ui.dependents_select_first": "حدّد أولًا ما تريد سرد معتمداته",
|
||||
"ui.depth": "العمق",
|
||||
"ui.design": "التصميم",
|
||||
"ui.design_properties.author": "المؤلف",
|
||||
@@ -4909,7 +4942,7 @@
|
||||
"ui.extract_done": "أصبح المكوّن تصميمًا مستقلًا، مرتبطًا هنا.",
|
||||
"ui.extrude": "بثق",
|
||||
"ui.extrude_auto_to_object": "البثق إلى هدف مختار",
|
||||
"ui.extrude_auto_to_object_tip": "وجه أو رأس أو مستوى موازٍ يغيّر المدى إلى «إلى كائن».",
|
||||
"ui.extrude_auto_to_object_tip": "وجه أو رأس أو مستوى موازٍ يغيّر الامتداد إلى «إلى كائن».",
|
||||
"ui.factor": "المعامل",
|
||||
"ui.fade": "تلاشٍ",
|
||||
"ui.fade_length": "طول التلاشي",
|
||||
@@ -4939,9 +4972,13 @@
|
||||
"ui.fillet_setback": "مسافة الإرجاع",
|
||||
"ui.fillet_setback_hint": "{factor} × نصف القطر",
|
||||
"ui.fillet_setback_least": "أكثر من {least}",
|
||||
"ui.fillet_setback_tip": "حيث تبدأ رقعة الزاوية الناعمة: المسافة على طول كل حافة من الزاوية. الافتراضي {factor} ضعف نصف القطر. يجب أن تكون أكبر من أقل مسافة إرجاع، حيث تلتقي الاستدارات بدونها: نصف القطر حيث تكون الأوجه متعامدة.",
|
||||
"ui.fillet_setback_tip": "حيث تبدأ رقعة الزاوية الناعمة: المسافة على طول كل حافة من الزاوية. الافتراضي {factor} ضعف نصف القطر. يجب أن تكون أكبر من أقل مسافة إرجاع، وهي التي تلتقي عندها التقويسات دون إرجاع: نصف القطر حين تكون الأوجه متعامدة.",
|
||||
"ui.fillet_smooth_radius": "تغيّر ناعم لنصف القطر",
|
||||
"ui.fillet_type_choices_tip": "نصف قطر ثابت: نصف قطر واحد على طول الحافة.\nطول الوتر: يبقى عرض التقويس نفسه مهما كانت الزاوية بين الوجوه.\nنصف قطر متغيّر: نصف قطر عند كل طرف، ومزيد على طول الحافة إن أضفته.\nمتصل الانحناء: مقطع بلا انكسار في الانعكاسات حيث يلتقي بالوجوه المستوية (G2).\nمنحنى: يتبع نصف القطر على طول الحافة أنصاف أقطار تحكّم تحدّدها.",
|
||||
"ui.fillet_turn": "الالتفاف",
|
||||
"ui.fillet_turn_mitre": "وصلة مائلة",
|
||||
"ui.fillet_turn_swept": "ملتف",
|
||||
"ui.fillet_turn_tip": "حيث يلتف تقويسان حول حافة ركن حادة بينهما، مثل التقويسات على قاعدة نتوء عند حوافه القائمة.\nوصلة مائلة: يمتد التقويسان حتى يلتقيا في وصلة مائلة.\nملتف: يتوقف التقويسان عند حافة الركن ويلتفان حولها، والركن طارة مماسة لكليهما. فقط لتقويسين بنصف قطر واحد حول حافة ركن مستقيمة ومحدبة.",
|
||||
"ui.fillet_type_choices_tip": "نصف قطر ثابت: نصف قطر واحد على طول الحافة.\nطول الوتر: يبقى عرض التقويس نفسه مهما كانت الزاوية بين الوجوه.\nنصف قطر متغيّر: نصف قطر عند كل طرف، ومزيد على طول الحافة إن أضفته.\nمتصل الانحناء: مقطع بلا انكسار في الانعكاسات حيث يلتقي بالوجوه المستوية (G2).\nمنحنى شريحي: يتبع نصف القطر على طول الحافة أنصاف أقطار تحكّم تحدّدها.",
|
||||
"ui.fillet_type_curvature": "متصل الانحناء",
|
||||
"ui.fillet_type_tip": "المقطع عبر الحافة. متصل الانحناء: مقطع متصل الانحناء (G2) بين وجوه مستوية، فتمر الانعكاسات من الوجوه إلى التقويس دون انكسار.",
|
||||
"ui.filter_commands_or_keys": "تصفية الأوامر أو المفاتيح",
|
||||
@@ -4986,6 +5023,7 @@
|
||||
"ui.heading_construct": "إنشاء مرجعي",
|
||||
"ui.heading_create": "إنشاء",
|
||||
"ui.heading_create_sketch": "إنشاء رسم",
|
||||
"ui.heading_custom_view_navigator": "متصفح العرض المخصص",
|
||||
"ui.heading_cylinder": "أسطوانة",
|
||||
"ui.heading_design": "التصميم",
|
||||
"ui.heading_draft": "ميل السحب",
|
||||
@@ -5053,7 +5091,7 @@
|
||||
"ui.hidePlanes": "إخفاء المستويات",
|
||||
"ui.hideProfile": "إخفاء المقطع",
|
||||
"ui.hide_component_history": "إخفاء سجله",
|
||||
"ui.hide_origin_by_default": "إخفاء مجلد الأصل في التصاميم الجديدة",
|
||||
"ui.hide_origin_by_default": "إخفاء مجلد نقطة الأصل في التصاميم الجديدة",
|
||||
"ui.hide_origin_note": "تبدأ التصاميم الجديدة ومجلد نقطة الأصل مخفي. يحتفظ كل تصميم بإعداد نقطة الأصل الخاص به بمجرد إظهارها أو إخفائها.",
|
||||
"ui.history_back": "الرجوع خطوة",
|
||||
"ui.history_below_browser": "أسفل المستعرض",
|
||||
@@ -5581,6 +5619,14 @@
|
||||
"values must be numbers or expressions": "يجب أن تكون القيم أعدادًا أو تعابير",
|
||||
"view.auto_ortho_faces": "منظوري أثناء التدوير، ومتعامد على الأوجه",
|
||||
"view.render_inspector": "مفتش التصيير",
|
||||
"view_gizmo.arrows": "أسهم التدوير",
|
||||
"view_gizmo.home": "زر العرض الرئيسي",
|
||||
"view_gizmo.no_such_body": "لا يوجد في هذا التصميم جسم بهذا الاسم.",
|
||||
"view_gizmo.note": "اختر جسمًا في العرض. يُحفظ مع تفضيلاتك وملفات تعريف الإعدادات، ويظهر في كل تصميم. انقر أحد أوجهه للنظر إليه مباشرة.",
|
||||
"view_gizmo.texture": "الخامة",
|
||||
"view_gizmo.themed": "حسب السمة",
|
||||
"view_gizmo.too_detailed": "مفصّل أكثر مما يناسب متصفح العرض (أكثر من 10,000 مثلث)",
|
||||
"view_gizmo.too_thin": "طويل ورفيع أكثر مما يناسب متصفح العرض: يجب ألا يقل أرفع جانب عن خُمس أطول جانب",
|
||||
"web.files.insert_drawings_hint": "ملفات CAD أو شبكات أو DXF أو SVG إلى التصميم المفتوح",
|
||||
"web.files.insert_stored_drawings_hint": "إلى التصميم المفتوح (ملفات CAD، شبكات، DXF، SVG)",
|
||||
"web.open_full": "فتح في SolidCraft",
|
||||
|
||||
@@ -1291,6 +1291,7 @@
|
||||
"cmd.timeline_stop": "Verlauf anhalten",
|
||||
"cmd.timeline_suppress": "Merkmal unterdrücken",
|
||||
"cmd.ui.assistant": "Assistenten ein-/ausblenden",
|
||||
"cmd.view.navigator": "Eigener Ansichtsnavigator",
|
||||
"cmd.view.render_browser": "Render-Browser",
|
||||
"cmd.view.render_dock": "Render-Panel verschieben",
|
||||
"cmd.view.render_focus": "Render-Fokusansicht",
|
||||
@@ -1391,6 +1392,9 @@
|
||||
"dialog.custom_axis": "↖ Eigene Achse",
|
||||
"dialog.dimension_insert_hint": "Doppelklicken Sie auf eine andere steuernde Bemaßung, um ihren Namen einzufügen",
|
||||
"dialog.dimension_name": "Bemaßung: {name}",
|
||||
"dialog.dimension_step_down": "Um {step} verringern",
|
||||
"dialog.dimension_step_expression": "Nur eine Zahl lässt sich schrittweise ändern; ein Ausdruck bleibt, wie er eingegeben ist",
|
||||
"dialog.dimension_step_up": "Um {step} erhöhen",
|
||||
"dialog.edit_title": "{title} bearbeiten",
|
||||
"dialog.no_shown_body": "Kein sichtbarer Körper zum Ändern vorhanden. Blenden Sie einen Körper ein oder wählen Sie „Neuer Körper“.",
|
||||
"dialog.pattern_sketch_not_object": "Körper oder Funktionen wählen: Eine Skizze ist keines von beiden. Ihre Kurven mit Muster oder Spiegeln in der Skizze wiederholen oder sie zuerst extrudieren.",
|
||||
@@ -1415,6 +1419,7 @@
|
||||
"document.read_only_save": "Dieses Design wurde schreibgeschützt geöffnet: Speichern Sie es als neue Datei",
|
||||
"document.save_changes": "Änderungen in {name} speichern?",
|
||||
"document.save_copy": "Kopie speichern…",
|
||||
"document.tab_unsaved_changes": "Ungespeicherte Änderungen",
|
||||
"document.unsaved_changes": "{name} enthält ungespeicherte Änderungen. Wenn Sie nicht speichern, gehen sie verloren.",
|
||||
"drive.rigid": "{joint} ist ein starres Gelenk. Es hält die verbundenen Teile fest zusammen und ermöglicht daher keine Bewegung. Um die Teile beweglich zu machen, ändern Sie den Gelenktyp unter „Gelenk bearbeiten“: Drehgelenk für Drehbewegungen, Schubgelenk für lineare Bewegungen oder Zylindergelenk für beides.",
|
||||
"drive.stopped_by_contact": "Angehalten bei {value}: {a} würde {b} durchdringen",
|
||||
@@ -1982,6 +1987,7 @@
|
||||
"material.selection.mixed": "Unterschiedliche Materialien",
|
||||
"measure.at": "Bei",
|
||||
"measure.item": "Element {number}",
|
||||
"measure.line_to_centre": "Linie zum Mittelpunkt",
|
||||
"measure.quick_copied": "Messwert kopiert",
|
||||
"measure.quick_copy": "Klicken, um den Messwert zu kopieren",
|
||||
"measure.quick_edges": "{count} Kanten",
|
||||
@@ -2248,6 +2254,14 @@
|
||||
"prefs.auto_operation_note": "Vereinigung, Neuer Körper oder Schnitt, je nachdem, was das Material berührt.",
|
||||
"prefs.axes": "Achsen",
|
||||
"prefs.axis": "Achse",
|
||||
"prefs.blur_cost": "Relativer Renderaufwand. Mehr Balken bedeuten meist mehr Arbeit.",
|
||||
"prefs.blur_device": "Auf diesem Grafikgerät oder Renderpfad nicht verfügbar",
|
||||
"prefs.blur_guard": "Die Unschärfequalität wurde für flüssigere Bilder verringert. Der Radius bleibt unverändert.",
|
||||
"prefs.blur_off": "Aus",
|
||||
"prefs.blur_reduce_transparency": "Durch die Systemeinstellung zur Transparenzreduzierung deaktiviert",
|
||||
"prefs.blur_software": "Mit Softwaregrafik nicht verfügbar (gpu-software)",
|
||||
"prefs.blur_source": "In dieser Arbeitsbereichsansicht nicht verfügbar",
|
||||
"prefs.blur_web": "Auf diesem Web-Renderpfad nicht verfügbar",
|
||||
"prefs.bodies_folders": "Körperordner",
|
||||
"prefs.bodies_folders_always": "Immer",
|
||||
"prefs.bodies_folders_never": "Nie",
|
||||
@@ -2306,6 +2320,13 @@
|
||||
"prefs.dialogs_edge": "Am Rand der Ansicht",
|
||||
"prefs.dialogs_left_column": "In der linken Spalte",
|
||||
"prefs.dialogs_note": "Wo Befehlsdialoge öffnen: am Rand der Ansicht angedockt oder in der linken Spalte anstelle von Browser und Verlauf, die beim Schließen des Dialogs wie zuvor zurückkehren.",
|
||||
"prefs.dimension_step_angle": "Was die Pfeile des Fensters, Auf/Ab und das Mausrad zu einem Winkel addieren",
|
||||
"prefs.dimension_step_length": "Was die Pfeile des Fensters, Auf/Ab und das Mausrad zu einer Länge addieren",
|
||||
"prefs.dimension_steps": "Schritte im Fenster",
|
||||
"prefs.dimension_values": "Bemaßungswerte",
|
||||
"prefs.dimension_values_note": "Wo der Wert einer Bemaßung eingegeben wird. Ein Fenster hat Pfeile, die ihn schrittweise ändern; das Bemaßungswerkzeug platziert zuerst den Text.",
|
||||
"prefs.dimension_values_view": "In der Ansicht",
|
||||
"prefs.dimension_values_window": "In einem Fenster",
|
||||
"prefs.display": "Anzeige",
|
||||
"prefs.edges": "Kanten und Schattierung",
|
||||
"prefs.extend": "Erweitern",
|
||||
@@ -2511,6 +2532,8 @@
|
||||
"prefs.search": "Einstellungen suchen",
|
||||
"prefs.search_empty": "Keine passenden Einstellungen",
|
||||
"prefs.select": "Auswählen",
|
||||
"prefs.select_hidden_edges": "Verdeckte Kanten in der schattierten Ansicht mit verdeckten Kanten auswählen",
|
||||
"prefs.select_hidden_edges_help": "Kanten durch Flächen auswählen, wenn die schattierte Ansicht mit verdeckten Kanten aktiv ist, auch zum Verrunden. Aus behält das Auswahlverhalten von v0.30.1 bei. Ausgeblendete Körper bleiben ausgeschlossen.",
|
||||
"prefs.select_one_item": "Ein Element auswählen",
|
||||
"prefs.select_other_hidden": "Verborgene Geometrie mit „Andere auswählen“ auswählen",
|
||||
"prefs.select_other_hidden_help": "Ein: Verborgene Körper, Skizzen und verdeckte Kanten nahe dem Zeiger einbeziehen. Aus: Die ursprüngliche Liste sichtbarer Geometrie direkt unter dem Zeiger verwenden.",
|
||||
@@ -2535,6 +2558,8 @@
|
||||
"prefs.sketch.click_selects": "Klick wählt",
|
||||
"prefs.sketch.conflict_colour": "Konfliktfarbe",
|
||||
"prefs.sketch.drawing": "Zeichnen",
|
||||
"prefs.sketch.ellipse_axes": "Ellipsen mit ihren Achsen zeichnen",
|
||||
"prefs.sketch.ellipse_axes_note": "Haupt- und Nebenachse als Konstruktionslinien, damit Halbachsen und Achsen bemaßt werden können. Aus: nur die Ellipse, und D darauf ergibt ihren Nebenradius, wohin der Wert auch gezogen wird, gezeichnet und mit R beschriftet wie bisher.",
|
||||
"prefs.sketch.exit_keep": "Beibehalten",
|
||||
"prefs.sketch.exit_previous": "Zur Ansicht davor zurückkehren",
|
||||
"prefs.sketch.exit_unless_rotated": "Außer bei Drehung",
|
||||
@@ -2628,6 +2653,10 @@
|
||||
"prefs.theme_follow_off": "Aus",
|
||||
"prefs.theme_follow_stop": "Nicht mehr folgen",
|
||||
"prefs.theme_import_hint": "Klicken, um eine Designdatei zu wählen, eine hierher ziehen oder mit kopiertem Design Strg+V (Cmd+V auf dem Mac) drücken",
|
||||
"prefs.theme_notice_dismiss": "Ausblenden",
|
||||
"prefs.theme_option_blur": "Unschärfe hinter Panels",
|
||||
"prefs.theme_option_blur_help": "Unschärfe hinter durchscheinenden Panels in physischen Pixeln. Null bedeutet aus.",
|
||||
"prefs.theme_option_invalid": "Der gespeicherte Wert für {option} ist ungültig; der Designstandard wird verwendet. Der gespeicherte Wert bleibt erhalten.",
|
||||
"prefs.theme_option_opacity": "Deckkraft",
|
||||
"prefs.theme_option_opacity_help": "Deckkraft schwebender Bedienfelder anpassen. Eine Mindestdeckkraft schützt die Lesbarkeit über den Ansichtsfarben.",
|
||||
"prefs.theme_option_tab_colours": "Tabfarben",
|
||||
@@ -2636,6 +2665,7 @@
|
||||
"prefs.theme_option_tab_fade_ms_help": "Panel- und Ansichtsfarben beim Tabwechsel überblenden. Null wechselt sofort.",
|
||||
"prefs.theme_options_badge": "Optionen",
|
||||
"prefs.theme_options_more": "Mehr · {count}",
|
||||
"prefs.theme_options_newer": "Dieses Design benötigt eine neuere Version von SolidCraft. Nach dem Upgrade das Originaldesign erneut importieren, um seine Optionsdefinitionen wiederherzustellen.",
|
||||
"prefs.theme_options_reset": "Zurücksetzen",
|
||||
"prefs.theme_options_unavailable": "Auf diesem Gerät nicht verfügbar",
|
||||
"prefs.theme_paste": "Design einfügen",
|
||||
@@ -4759,6 +4789,7 @@
|
||||
"ui.default_modelling_orientation": "Standard-Modellierungsorientierung",
|
||||
"ui.delete": "Löschen",
|
||||
"ui.delete_select_first": "Wählen Sie zuerst aus, was gelöscht werden soll: Features, Körper, Komponenten oder Skizzengeometrie",
|
||||
"ui.dependents_select_first": "Wählen Sie zuerst aus, wessen Abhängige aufgelistet werden sollen",
|
||||
"ui.depth": "Tiefe",
|
||||
"ui.design": "Design",
|
||||
"ui.design_properties.author": "Autor",
|
||||
@@ -4914,6 +4945,10 @@
|
||||
"ui.fillet_setback_least": "Mehr als {least}",
|
||||
"ui.fillet_setback_tip": "Wo das glatte Eckflächenstück beginnt: wie weit von der Ecke entlang jeder Kante. Standard ist {factor} × der Radius. Er muss größer sein als der kleinste Rücksprung, wo sich die Rundungen ohne ihn treffen: der Radius, wo die Flächen rechtwinklig zueinander stehen.",
|
||||
"ui.fillet_smooth_radius": "Radius glatt ändern",
|
||||
"ui.fillet_turn": "Wendungen",
|
||||
"ui.fillet_turn_mitre": "Gehrung",
|
||||
"ui.fillet_turn_swept": "Geschwenkt",
|
||||
"ui.fillet_turn_tip": "Wo zwei Rundungen um eine scharfe Eckkante zwischen ihnen herumlaufen, etwa die Rundungen am Fuß eines Zapfens an seinen senkrechten Kanten.\nGehrung: Die Rundungen laufen weiter, bis sie sich in einer Gehrung treffen.\nGeschwenkt: Die Rundungen enden an der Eckkante und schwenken um sie herum; die Ecke ist ein Torus, tangential zu beiden. Nur für Rundungen mit einem Radius um eine gerade, konvexe Eckkante.",
|
||||
"ui.fillet_type_choices_tip": "Konstanter Radius: ein Radius entlang der ganzen Kante.\nSehnenlänge: die Breite der Rundung bleibt gleich, in welchem Winkel die Flächen auch aufeinandertreffen.\nVariabler Radius: ein Radius an jedem Ende und weitere entlang der Kante, wenn Sie sie hinzufügen.\nKrümmungsstetig: ein Querschnitt ohne Knick in den Spiegelungen, wo er auf ebene Flächen trifft (G2).\nSpline: der Radius entlang der Kante folgt Steuerradien, die Sie festlegen.",
|
||||
"ui.fillet_type_curvature": "Krümmungsstetig",
|
||||
"ui.fillet_type_tip": "Der Querschnitt über die Kante. Krümmungsstetig: ein krümmungsstetiger Querschnitt (G2) zwischen ebenen Flächen, sodass Spiegelungen ohne Knick von den Flächen in die Rundung laufen.",
|
||||
@@ -4959,6 +4994,7 @@
|
||||
"ui.heading_construct": "KONSTRUKTION",
|
||||
"ui.heading_create": "ERSTELLEN",
|
||||
"ui.heading_create_sketch": "SKIZZE ERSTELLEN",
|
||||
"ui.heading_custom_view_navigator": "EIGENER ANSICHTSNAVIGATOR",
|
||||
"ui.heading_cylinder": "ZYLINDER",
|
||||
"ui.heading_design": "DESIGN",
|
||||
"ui.heading_draft": "ABZIEHEN",
|
||||
@@ -5547,6 +5583,14 @@
|
||||
"values must be numbers or expressions": "Werte müssen Zahlen oder Ausdrücke sein",
|
||||
"view.auto_ortho_faces": "Perspektivisch beim Drehen, orthografisch auf Flächen",
|
||||
"view.render_inspector": "Render-Inspektor",
|
||||
"view_gizmo.arrows": "Drehpfeile",
|
||||
"view_gizmo.home": "Schaltfläche „Ausgangsansicht“",
|
||||
"view_gizmo.no_such_body": "Diese Konstruktion hat keinen Körper mit diesem Namen.",
|
||||
"view_gizmo.note": "Wählen Sie einen Körper in der Ansicht. Er wird mit Ihren Einstellungen und Einstellungsprofilen gespeichert und in jeder Konstruktion angezeigt. Klicken Sie auf eine seiner Flächen, um direkt darauf zu blicken.",
|
||||
"view_gizmo.texture": "Textur",
|
||||
"view_gizmo.themed": "Design",
|
||||
"view_gizmo.too_detailed": "Zu detailliert für den Ansichtsnavigator (über 10.000 Dreiecke)",
|
||||
"view_gizmo.too_thin": "Zu lang und dünn für den Ansichtsnavigator: Die dünnste Seite muss mindestens ein Fünftel der längsten sein",
|
||||
"web.files.insert_drawings_hint": "CAD-Dateien, Netze, DXF oder SVG in das geöffnete Design",
|
||||
"web.files.insert_stored_drawings_hint": "In das geöffnete Design (CAD-Dateien, Netze, DXF, SVG)",
|
||||
"web.open_full": "In SolidCraft öffnen",
|
||||
|
||||
@@ -69,6 +69,7 @@
|
||||
"help.solid.split_face": "Divides the selected faces along a plane, the face of another body or sketch curves, without cutting the body. Use it to give part of a face its own draft, colour or offset.",
|
||||
"help.third_party": "Third-party licences",
|
||||
"home.new_mm_hint": "Millimetres · opens in Design",
|
||||
"measure.line_to_centre": "Line to centre",
|
||||
"navbar.custom.anchor.bottom_centre": "Bottom centre",
|
||||
"navbar.custom.anchor.top_centre": "Top centre",
|
||||
"navbar.custom.customise": "Customise navigation bar…",
|
||||
@@ -86,6 +87,7 @@
|
||||
"prefs.max_file_size_web": "The web app opens STEP and import files up to 512 MB and designs up to 256 MB: a browser tab can't hold larger files. The desktop app can raise these limits.",
|
||||
"prefs.megabytes": "MB",
|
||||
"prefs.network_locations_note": "For organisations that keep designs on file servers. Off: only servers you allow.",
|
||||
"prefs.select_hidden_edges_help": "Select edges through faces when Shaded with hidden edges is active, including for Fillet. Off keeps v0.30.1 selection behaviour. Hidden bodies stay unselectable.",
|
||||
"prefs.sketch.center_arc": "Centre-point arc follows the cursor",
|
||||
"prefs.sketch.conflict_colour": "Conflict colour",
|
||||
"prefs.sketch.tracking_guides": "Centre and midpoint guides",
|
||||
|
||||
@@ -85,6 +85,7 @@
|
||||
"Bottom Right": "Bottom Right",
|
||||
"Boundary": "Boundary",
|
||||
"Bounding box": "Bounding box",
|
||||
"Bridge": "Bridge",
|
||||
"CHECK": "CHECK",
|
||||
"COMPONENTS": "COMPONENTS",
|
||||
"CONFIGURATIONS": "CONFIGURATIONS",
|
||||
@@ -1296,6 +1297,7 @@
|
||||
"cmd.timeline_stop": "Stop History",
|
||||
"cmd.timeline_suppress": "Suppress Feature",
|
||||
"cmd.ui.assistant": "Toggle Assistant",
|
||||
"cmd.view.navigator": "Custom View Navigator",
|
||||
"cmd.view.render_browser": "Render Browser",
|
||||
"cmd.view.render_dock": "Move Render Dock",
|
||||
"cmd.view.render_focus": "Render Focus View",
|
||||
@@ -1396,6 +1398,9 @@
|
||||
"dialog.custom_axis": "↖ Custom Axis",
|
||||
"dialog.dimension_insert_hint": "Click another driving dimension to insert its name",
|
||||
"dialog.dimension_name": "Dimension: {name}",
|
||||
"dialog.dimension_step_down": "Down by {step}",
|
||||
"dialog.dimension_step_expression": "Only a number steps; an expression stays as typed",
|
||||
"dialog.dimension_step_up": "Up by {step}",
|
||||
"dialog.edit_title": "EDIT {title}",
|
||||
"dialog.no_shown_body": "No shown body to change: show one, or choose New Body",
|
||||
"dialog.pattern_sketch_not_object": "Pick bodies or features: a sketch is neither. Repeat its curves with the sketch's own Pattern or Mirror, or extrude it first.",
|
||||
@@ -1403,6 +1408,7 @@
|
||||
"dialog.selected_count": "↖ {count} selected",
|
||||
"dialog.selection_constraint": "Constraint {id}",
|
||||
"dialog.selection_external_curve": "{sketch} · curve {id}",
|
||||
"dialog.selection_external_point": "{point} · {sketch}",
|
||||
"dialog.selection_guidance": "Select an input to change it. Click selected objects to remove them; Ctrl/Cmd-click also toggles them. Review the preview, then press OK.",
|
||||
"dialog.selection_midpoint": "Midpoint · {id}",
|
||||
"dialog.selection_profile": "{sketch} · profile {number}",
|
||||
@@ -1420,6 +1426,7 @@
|
||||
"document.read_only_save": "This design was opened read-only: save it as a new file",
|
||||
"document.save_changes": "Save changes to {name}?",
|
||||
"document.save_copy": "Save a Copy…",
|
||||
"document.tab_unsaved_changes": "Unsaved changes",
|
||||
"document.unsaved_changes": "{name} has unsaved changes. If you don't save, they are lost.",
|
||||
"drive.rigid": "{joint} is a rigid joint: it holds its parts still, so it has nothing to drive. To let them move, change its type in Edit Joint: Revolute to rotate, Slider to slide, Cylindrical for both.",
|
||||
"drive.stopped_by_contact": "Stopped at {value}: {a} would pass through {b}",
|
||||
@@ -1600,7 +1607,7 @@
|
||||
"help.parts.resize": "Changes the size or length of an inserted standard part and rebuilds it; its joints keep it seated. Use it when a screw turns out too long or a hole needs another size.",
|
||||
"help.plastic.assign_rule": "Assigns a plastic rule to bodies, so their plastic features take its draft and clearance as defaults. Use it to set up a moulded part once for all its features.",
|
||||
"help.plastic.boss": "Adds a screw boss on a face: a post with an optional hole, draft, root fillet and supporting ribs. Use it for screw points in moulded or printed housings.",
|
||||
"help.plastic.lip": "Adds a lip or a matching groove along the rim of a shelled body. Use it so that two housing halves locate on each other and close tightly.",
|
||||
"help.plastic.lip": "Adds a lip or a matching groove along the rim of a shelled body (Shell a face open first, then pick the rim around that opening). Use it so that two housing halves locate on each other and close tightly.",
|
||||
"help.plastic.manage_rules": "Creates and edits plastic rules: material, wall thickness, radii, clearance and draft. Use them to keep the features of a moulded part consistent.",
|
||||
"help.plastic.rest": "Adds a rest on a face: a round or rectangular pad, solid or hollow, with draft. Use it as a support or standoff in a moulded housing.",
|
||||
"help.plastic.snap_fit": "Adds a snap-fit hook on a face, with its length, thickness and catch. Use it to clip parts together without screws.",
|
||||
@@ -1999,6 +2006,7 @@
|
||||
"material.selection.mixed": "Different materials",
|
||||
"measure.at": "At",
|
||||
"measure.item": "Item {number}",
|
||||
"measure.line_to_centre": "Line to center",
|
||||
"measure.quick_copied": "Measurement copied",
|
||||
"measure.quick_copy": "Click to copy the measurement",
|
||||
"measure.quick_edges": "{count} edges",
|
||||
@@ -2265,6 +2273,14 @@
|
||||
"prefs.auto_operation_note": "Join, New Body or Cut, from what the material touches.",
|
||||
"prefs.axes": "Axes",
|
||||
"prefs.axis": "Axis",
|
||||
"prefs.blur_cost": "Relative rendering cost. More bars usually mean more work.",
|
||||
"prefs.blur_device": "Unavailable on this graphics device or rendering path",
|
||||
"prefs.blur_guard": "Blur quality was reduced to keep frames responsive. The radius is unchanged.",
|
||||
"prefs.blur_off": "Off",
|
||||
"prefs.blur_reduce_transparency": "Disabled by the system's Reduce transparency setting",
|
||||
"prefs.blur_software": "Unavailable with software graphics (gpu-software)",
|
||||
"prefs.blur_source": "Unavailable in this workspace view",
|
||||
"prefs.blur_web": "Unavailable on this web rendering path",
|
||||
"prefs.bodies_folders": "Bodies folders",
|
||||
"prefs.bodies_folders_always": "Always",
|
||||
"prefs.bodies_folders_never": "Never",
|
||||
@@ -2323,6 +2339,13 @@
|
||||
"prefs.dialogs_edge": "At the view's edge",
|
||||
"prefs.dialogs_left_column": "In the left column",
|
||||
"prefs.dialogs_note": "Where command dialogs open: docked at the view's edge, or in the left column in place of the browser and the History, which come back as they were when the dialog closes.",
|
||||
"prefs.dimension_step_angle": "What the window's arrows, Up/Down and the wheel add to an angle",
|
||||
"prefs.dimension_step_length": "What the window's arrows, Up/Down and the wheel add to a length",
|
||||
"prefs.dimension_steps": "Window steps",
|
||||
"prefs.dimension_values": "Dimension values",
|
||||
"prefs.dimension_values_note": "Where you type a dimension's value. A window has arrows that step it; the Dimension tool places the text first.",
|
||||
"prefs.dimension_values_view": "In the view",
|
||||
"prefs.dimension_values_window": "In a window",
|
||||
"prefs.display": "Display",
|
||||
"prefs.edges": "Edges and shading",
|
||||
"prefs.extend": "Extend",
|
||||
@@ -2533,6 +2556,8 @@
|
||||
"prefs.section_hatch_by_part": "By part",
|
||||
"prefs.section_hatch_one": "One style",
|
||||
"prefs.select": "Select",
|
||||
"prefs.select_hidden_edges": "Select hidden edges in Shaded with hidden edges",
|
||||
"prefs.select_hidden_edges_help": "Select edges through faces when Shaded with hidden edges is active, including for Fillet. Off keeps v0.30.1 selection behavior. Hidden bodies stay unselectable.",
|
||||
"prefs.select_one_item": "Select one item",
|
||||
"prefs.select_other_hidden": "Select hidden geometry with Select Other",
|
||||
"prefs.select_other_hidden_help": "On: include hidden bodies, sketches and occluded edges near the pointer. Off: use the original list of visible geometry under the pointer.",
|
||||
@@ -2557,6 +2582,8 @@
|
||||
"prefs.sketch.click_selects": "Click selects",
|
||||
"prefs.sketch.conflict_colour": "Conflict color",
|
||||
"prefs.sketch.drawing": "Drawing",
|
||||
"prefs.sketch.ellipse_axes": "Draw ellipses with their axes",
|
||||
"prefs.sketch.ellipse_axes_note": "The major and minor axes as construction lines, so the half-axes and axes can be dimensioned. Off: the ellipse alone, and D on it gives its minor radius wherever the value is pulled, drawn and labelled R as before.",
|
||||
"prefs.sketch.exit_keep": "Keep",
|
||||
"prefs.sketch.exit_previous": "Return to the view before",
|
||||
"prefs.sketch.exit_unless_rotated": "Unless turned",
|
||||
@@ -2650,6 +2677,10 @@
|
||||
"prefs.theme_follow_off": "Off",
|
||||
"prefs.theme_follow_stop": "Stop following",
|
||||
"prefs.theme_import_hint": "Click to choose a theme file, drop one here, or press Ctrl+V (Cmd+V on a Mac) with a theme copied",
|
||||
"prefs.theme_notice_dismiss": "Dismiss",
|
||||
"prefs.theme_option_blur": "Blur behind panels",
|
||||
"prefs.theme_option_blur_help": "Blur behind translucent panels, in physical pixels. Zero is off.",
|
||||
"prefs.theme_option_invalid": "Saved value for {option} is invalid; using the theme default. The saved value is kept.",
|
||||
"prefs.theme_option_opacity": "Opacity",
|
||||
"prefs.theme_option_opacity_help": "Adjust floating panel opacity. A readability floor protects text over viewport colors.",
|
||||
"prefs.theme_option_tab_colours": "Tab colors",
|
||||
@@ -2658,6 +2689,7 @@
|
||||
"prefs.theme_option_tab_fade_ms_help": "Fade panel and viewport colors when switching tabs. Zero switches instantly.",
|
||||
"prefs.theme_options_badge": "Options",
|
||||
"prefs.theme_options_more": "More · {count}",
|
||||
"prefs.theme_options_newer": "This theme requires a newer SolidCraft. Upgrade, then reimport the original theme to recover its option definitions.",
|
||||
"prefs.theme_options_reset": "Reset",
|
||||
"prefs.theme_options_unavailable": "Unavailable on this device",
|
||||
"prefs.theme_paste": "Paste theme",
|
||||
@@ -4784,6 +4816,7 @@
|
||||
"ui.default_modelling_orientation": "Default modeling orientation",
|
||||
"ui.delete": "Delete",
|
||||
"ui.delete_select_first": "Select what to delete first: features, bodies, components or sketch geometry",
|
||||
"ui.dependents_select_first": "Select what to list the dependents of first",
|
||||
"ui.depth": "Depth",
|
||||
"ui.design": "Design",
|
||||
"ui.design_properties.author": "Author",
|
||||
@@ -4941,6 +4974,10 @@
|
||||
"ui.fillet_setback_least": "More than {least}",
|
||||
"ui.fillet_setback_tip": "Where the smooth corner patch starts: how far along each edge from the corner. The default is {factor} × the radius. It must be more than the least setback, where the rounds meet without one: the radius where the faces are square to each other.",
|
||||
"ui.fillet_smooth_radius": "Smooth radius change",
|
||||
"ui.fillet_turn": "Turns",
|
||||
"ui.fillet_turn_mitre": "Mitre",
|
||||
"ui.fillet_turn_swept": "Swept",
|
||||
"ui.fillet_turn_tip": "Where two rounds turn round a sharp corner edge between them, such as the rounds along a boss's base at its upright edges.\nMitre: the rounds run on until they meet in a mitre.\nSwept: the rounds stop at the corner edge and turn round it, the corner a torus tangent to both. Only for rounds of one radius round a straight, convex corner edge.",
|
||||
"ui.fillet_type_choices_tip": "Constant radius: one radius all along the edge.\nChord length: the width across the round stays the same, whatever angle the faces meet at.\nVariable radius: a radius at each end, and more along the edge if you add them.\nCurvature continuous: a section without a kink in reflections where it meets flat faces (G2).\nSpline: the radius along the edge follows control radii you set.",
|
||||
"ui.fillet_type_curvature": "Curvature continuous",
|
||||
"ui.fillet_type_tip": "The section across the edge. Curvature continuous: a curvature-continuous (G2) section between planar faces, so reflections run from the faces into the round without a kink.",
|
||||
@@ -4986,6 +5023,7 @@
|
||||
"ui.heading_construct": "CONSTRUCT",
|
||||
"ui.heading_create": "CREATE",
|
||||
"ui.heading_create_sketch": "CREATE SKETCH",
|
||||
"ui.heading_custom_view_navigator": "CUSTOM VIEW NAVIGATOR",
|
||||
"ui.heading_cylinder": "CYLINDER",
|
||||
"ui.heading_design": "DESIGN",
|
||||
"ui.heading_draft": "DRAFT",
|
||||
@@ -5581,6 +5619,14 @@
|
||||
"values must be numbers or expressions": "values must be numbers or expressions",
|
||||
"view.auto_ortho_faces": "Perspective while orbiting, ortho on faces",
|
||||
"view.render_inspector": "Render Inspector",
|
||||
"view_gizmo.arrows": "Rotation arrows",
|
||||
"view_gizmo.home": "Home button",
|
||||
"view_gizmo.no_such_body": "This design has no body of that name.",
|
||||
"view_gizmo.note": "Pick a body in the view. It is kept with your preferences and settings profiles, and shown in every design. Click one of its faces to look straight at it.",
|
||||
"view_gizmo.texture": "Texture",
|
||||
"view_gizmo.themed": "Themed",
|
||||
"view_gizmo.too_detailed": "Too detailed for the view navigator (over 10,000 triangles)",
|
||||
"view_gizmo.too_thin": "Too long and thin for the view navigator: its thinnest side must be at least a fifth of its longest",
|
||||
"web.files.insert_drawings_hint": "CAD files, meshes, DXF or SVG into the open design",
|
||||
"web.files.insert_stored_drawings_hint": "Into the open design (CAD files, meshes, DXF, SVG)",
|
||||
"web.open_full": "Open in SolidCraft",
|
||||
|
||||
@@ -1358,6 +1358,7 @@
|
||||
"document.read_only_save": "Este diseño se abrió en solo lectura: guárdalo como un archivo nuevo",
|
||||
"document.save_changes": "¿Guardar los cambios de {name}?",
|
||||
"document.save_copy": "Guardar una copia…",
|
||||
"document.tab_unsaved_changes": "Cambios sin guardar",
|
||||
"document.unsaved_changes": "{name} tiene cambios sin guardar. Si no los guardas, se perderán.",
|
||||
"drive.rigid": "{joint} es una junta rígida: mantiene sus partes quietas, así que no tiene nada que impulsar.",
|
||||
"drive.stopped_by_contact": "Detenido en {value}: {a} atravesaría {b}",
|
||||
|
||||
@@ -1391,6 +1391,7 @@
|
||||
"document.read_only_save": "Cette conception a été ouverte en lecture seule : enregistrez-la dans un nouveau fichier",
|
||||
"document.save_changes": "Enregistrer les modifications de {name} ?",
|
||||
"document.save_copy": "Enregistrer une copie…",
|
||||
"document.tab_unsaved_changes": "Modifications non enregistrées",
|
||||
"document.unsaved_changes": "{name} contient des modifications non enregistrées. Si vous n'enregistrez pas, elles seront perdues.",
|
||||
"drive.rigid": "{joint} est une liaison rigide : elle maintient ses pièces immobiles, il n'y a donc rien à piloter. Pour les laisser bouger, changer son type dans Modifier la liaison : Revolute pour tourner, Slider pour coulisser, Cylindrical pour les deux.",
|
||||
"drive.stopped_by_contact": "Arrêté à {value} : {a} traverserait {b}",
|
||||
|
||||
@@ -85,6 +85,7 @@
|
||||
"Bottom Right": "למטה מימין",
|
||||
"Boundary": "גבול",
|
||||
"Bounding box": "תיבה תוחמת",
|
||||
"Bridge": "גשר",
|
||||
"CHECK": "בדיקה",
|
||||
"COMPONENTS": "רכיבים",
|
||||
"CONFIGURATIONS": "תצורות",
|
||||
@@ -1296,6 +1297,7 @@
|
||||
"cmd.timeline_stop": "עצירת ציר הזמן",
|
||||
"cmd.timeline_suppress": "השבתת פיצ'ר",
|
||||
"cmd.ui.assistant": "הצגה או הסתרה של העוזר",
|
||||
"cmd.view.navigator": "ניווט תצוגה מותאם אישית",
|
||||
"cmd.view.render_browser": "דפדפן רינדור",
|
||||
"cmd.view.render_dock": "הזז את עגינת הרינדור",
|
||||
"cmd.view.render_focus": "תצוגת מיקוד רינדור",
|
||||
@@ -1396,6 +1398,9 @@
|
||||
"dialog.custom_axis": "↖ ציר מותאם",
|
||||
"dialog.dimension_insert_hint": "יש ללחוץ על מידה מניעה אחרת כדי להוסיף את שמה",
|
||||
"dialog.dimension_name": "מידה: {name}",
|
||||
"dialog.dimension_step_down": "הפחתה ב־{step}",
|
||||
"dialog.dimension_step_expression": "רק מספר משתנה בצעדים; ביטוי נשאר כפי שנכתב",
|
||||
"dialog.dimension_step_up": "הוספה של {step}",
|
||||
"dialog.edit_title": "עריכת {title}",
|
||||
"dialog.no_shown_body": "אין גוף מוצג לשינוי: הציגו גוף, או בחרו „גוף חדש”",
|
||||
"dialog.pattern_sketch_not_object": "בחרו גופים או פיצ'רים: סקיצה אינה אף אחד מהם. חזרו על העקומות שלה בעזרת המערך או השיקוף של הסקיצה עצמה, או בצעו לה שליפה קודם.",
|
||||
@@ -1403,6 +1408,7 @@
|
||||
"dialog.selected_count": "↖ {count} נבחרו",
|
||||
"dialog.selection_constraint": "אילוץ {id}",
|
||||
"dialog.selection_external_curve": "{sketch} · עקומה {id}",
|
||||
"dialog.selection_external_point": "{point} · {sketch}",
|
||||
"dialog.selection_guidance": "יש לבחור קלט כדי לשנות אותו. לחיצה על אובייקט שנבחר מסירה אותו; גם Ctrl/Cmd+לחיצה מחליפה את מצבו. לאחר בדיקת התצוגה המקדימה יש ללחוץ על אישור.",
|
||||
"dialog.selection_midpoint": "נקודת אמצע · {id}",
|
||||
"dialog.selection_profile": "{sketch} · פרופיל {number}",
|
||||
@@ -1420,6 +1426,7 @@
|
||||
"document.read_only_save": "התכן הזה נפתח לקריאה בלבד: יש לשמור אותו כקובץ חדש",
|
||||
"document.save_changes": "לשמור את השינויים ב־{name}?",
|
||||
"document.save_copy": "שמירת עותק…",
|
||||
"document.tab_unsaved_changes": "שינויים שלא נשמרו",
|
||||
"document.unsaved_changes": "ב־{name} יש שינויים שלא נשמרו. ללא שמירה, הם יאבדו.",
|
||||
"drive.rigid": "{joint} הוא מפרק קשיח: הוא מחזיק את החלקים שלו במקומם, ולכן אין מה להניע. כדי שהחלקים יזוזו, שנו את הסוג שלו ב״עריכת מפרק״: Revolute לסיבוב, Slider להחלקה, Cylindrical לשניהם.",
|
||||
"drive.stopped_by_contact": "נעצר ב־{value}: {a} היה עובר דרך {b}",
|
||||
@@ -1999,6 +2006,7 @@
|
||||
"material.selection.mixed": "חומרים שונים",
|
||||
"measure.at": "מיקום",
|
||||
"measure.item": "פריט {number}",
|
||||
"measure.line_to_centre": "מהקו למרכז",
|
||||
"measure.quick_copied": "המדידה הועתקה",
|
||||
"measure.quick_copy": "לחיצה מעתיקה את המדידה",
|
||||
"measure.quick_edges": "{count} מקצועות",
|
||||
@@ -2265,6 +2273,14 @@
|
||||
"prefs.auto_operation_note": "איחוד, גוף חדש או חיתוך, לפי מה שהחומר נוגע בו.",
|
||||
"prefs.axes": "צירים",
|
||||
"prefs.axis": "ציר",
|
||||
"prefs.blur_cost": "עלות עיבוד יחסית. יותר פסים מציינים בדרך כלל יותר עבודה.",
|
||||
"prefs.blur_device": "לא זמין בהתקן הגרפי או בנתיב העיבוד הזה",
|
||||
"prefs.blur_guard": "איכות הטשטוש הופחתה כדי לשמור על תגובתיות הפריימים. הרדיוס לא השתנה.",
|
||||
"prefs.blur_off": "כבוי",
|
||||
"prefs.blur_reduce_transparency": "מושבת עקב הגדרת הפחתת השקיפות של המערכת",
|
||||
"prefs.blur_software": "לא זמין עם עיבוד גרפי בתוכנה (gpu-software)",
|
||||
"prefs.blur_source": "לא זמין בתצוגת סביבת העבודה הזו",
|
||||
"prefs.blur_web": "לא זמין בנתיב העיבוד הזה בדפדפן",
|
||||
"prefs.bodies_folders": "תיקיות גופים",
|
||||
"prefs.bodies_folders_always": "תמיד",
|
||||
"prefs.bodies_folders_never": "אף פעם",
|
||||
@@ -2323,6 +2339,13 @@
|
||||
"prefs.dialogs_edge": "בקצה התצוגה",
|
||||
"prefs.dialogs_left_column": "בעמודה השמאלית",
|
||||
"prefs.dialogs_note": "היכן נפתחות תיבות הדו־שיח של הפקודות: מעוגנות בקצה התצוגה, או בעמודה השמאלית במקום הדפדפן וההיסטוריה, שחוזרים כפי שהיו כשהתיבה נסגרת.",
|
||||
"prefs.dimension_step_angle": "מה שהחצים בחלון, למעלה/למטה והגלגלת מוסיפים לזווית",
|
||||
"prefs.dimension_step_length": "מה שהחצים בחלון, למעלה/למטה והגלגלת מוסיפים לאורך",
|
||||
"prefs.dimension_steps": "צעדי החלון",
|
||||
"prefs.dimension_values": "ערכי מידות",
|
||||
"prefs.dimension_values_note": "היכן מקלידים את ערך המידה. לחלון יש חצים שמשנים אותו בצעדים; כלי המידה מציב קודם את הטקסט.",
|
||||
"prefs.dimension_values_view": "בתצוגה",
|
||||
"prefs.dimension_values_window": "בחלון",
|
||||
"prefs.display": "תצוגה",
|
||||
"prefs.edges": "מקצועות והצללה",
|
||||
"prefs.extend": "הרחבה",
|
||||
@@ -2533,6 +2556,8 @@
|
||||
"prefs.section_hatch_by_part": "לפי חלק",
|
||||
"prefs.section_hatch_one": "סגנון אחד",
|
||||
"prefs.select": "בחירה",
|
||||
"prefs.select_hidden_edges": "בחירת קצוות מוסתרים בתצוגה מוצללת עם קצוות מוסתרים",
|
||||
"prefs.select_hidden_edges_help": "מאפשר לבחור קצוות דרך פאות בתצוגה מוצללת עם קצוות מוסתרים, גם עבור Fillet. כיבוי שומר על אופן הבחירה של v0.30.1. גופים שהוסתרו עדיין אינם ניתנים לבחירה.",
|
||||
"prefs.select_one_item": "בחירת פריט אחד",
|
||||
"prefs.select_other_hidden": "בחירת גאומטריה מוסתרת באמצעות בחירה אחרת",
|
||||
"prefs.select_other_hidden_help": "מופעל: כולל גופים, סקיצות וקצוות מוסתרים בקרבת הסמן. כבוי: שימוש ברשימה המקורית של גאומטריה גלויה מתחת לסמן.",
|
||||
@@ -2557,6 +2582,8 @@
|
||||
"prefs.sketch.click_selects": "לחיצה בוחרת",
|
||||
"prefs.sketch.conflict_colour": "צבע התנגשות",
|
||||
"prefs.sketch.drawing": "שרטוט",
|
||||
"prefs.sketch.ellipse_axes": "שרטוט אליפסות עם הצירים שלהן",
|
||||
"prefs.sketch.ellipse_axes_note": "הציר הראשי והציר המשני כקווי עזר, כדי שאפשר יהיה לתת מידות לחצאי הצירים ולצירים. כבוי: האליפסה בלבד, ו-D עליה נותן את הרדיוס המשני שלה בכל מקום שאליו נגרר הערך, משורטט ומסומן R כמו קודם.",
|
||||
"prefs.sketch.exit_keep": "שמירה",
|
||||
"prefs.sketch.exit_previous": "חזרה לתצוגה הקודמת",
|
||||
"prefs.sketch.exit_unless_rotated": "אלא אם סובבה",
|
||||
@@ -2650,6 +2677,10 @@
|
||||
"prefs.theme_follow_off": "כבוי",
|
||||
"prefs.theme_follow_stop": "הפסקת המעקב",
|
||||
"prefs.theme_import_hint": "לחצו כדי לבחור קובץ ערכת נושא, שחררו קובץ כאן, או הקישו Ctrl+V (Cmd+V ב-Mac) אחרי שהעתקתם ערכת נושא",
|
||||
"prefs.theme_notice_dismiss": "הסתרה",
|
||||
"prefs.theme_option_blur": "טשטוש מאחורי לוחות",
|
||||
"prefs.theme_option_blur_help": "טשטוש מאחורי לוחות שקופים למחצה, בפיקסלים פיזיים. אפס מכבה את הטשטוש.",
|
||||
"prefs.theme_option_invalid": "הערך השמור עבור {option} אינו תקין; נעשה שימוש בברירת המחדל של ערכת הנושא. הערך השמור נשמר ללא שינוי.",
|
||||
"prefs.theme_option_opacity": "אטימות",
|
||||
"prefs.theme_option_opacity_help": "כוונון אטימות הלוחות הצפים. רמת אטימות מינימלית שומרת על קריאות הטקסט על רקע צבעי חלון התצוגה.",
|
||||
"prefs.theme_option_tab_colours": "צבעי כרטיסיות",
|
||||
@@ -2658,6 +2689,7 @@
|
||||
"prefs.theme_option_tab_fade_ms_help": "שנה בהדרגה את צבעי הלוח ואזור התצוגה בעת מעבר בין כרטיסיות. ערך אפס מבצע את המעבר מיד.",
|
||||
"prefs.theme_options_badge": "אפשרויות",
|
||||
"prefs.theme_options_more": "עוד · {count}",
|
||||
"prefs.theme_options_newer": "ערכת נושא זו דורשת גרסה חדשה יותר של SolidCraft. לאחר השדרוג יש לייבא שוב את ערכת הנושא המקורית כדי לשחזר את הגדרות האפשרויות שלה.",
|
||||
"prefs.theme_options_reset": "איפוס",
|
||||
"prefs.theme_options_unavailable": "לא זמין במכשיר זה",
|
||||
"prefs.theme_paste": "הדבקת ערכת נושא",
|
||||
@@ -4784,6 +4816,7 @@
|
||||
"ui.default_modelling_orientation": "כיוון מידול ברירת מחדל",
|
||||
"ui.delete": "מחיקה",
|
||||
"ui.delete_select_first": "בחרו קודם מה למחוק: תכונות, גופים, רכיבים או גאומטריית סקיצה",
|
||||
"ui.dependents_select_first": "בחרו קודם מה להציג את התלויים שלו",
|
||||
"ui.depth": "עומק",
|
||||
"ui.design": "תכן",
|
||||
"ui.design_properties.author": "מחבר",
|
||||
@@ -4941,6 +4974,10 @@
|
||||
"ui.fillet_setback_least": "יותר מ-{least}",
|
||||
"ui.fillet_setback_tip": "היכן מתחיל טלאי הפינה החלק: המרחק לאורך כל קצה מהפינה. ברירת המחדל היא פי {factor} מהרדיוס. עליה להיות גדולה מהנסיגה המזערית, שבה העיגולים נפגשים בלעדיה: הרדיוס כשהפאות ניצבות זו לזו.",
|
||||
"ui.fillet_smooth_radius": "שינוי רדיוס חלק",
|
||||
"ui.fillet_turn": "פניות",
|
||||
"ui.fillet_turn_mitre": "מיטר",
|
||||
"ui.fillet_turn_swept": "מסובב",
|
||||
"ui.fillet_turn_tip": "היכן ששני עיגולים מסתובבים סביב קצה פינה חד ביניהם, כמו העיגולים בבסיס בליטה ליד הקצוות הזקופים שלה.\nמיטר: העיגולים נמשכים עד שהם נפגשים במיטר.\nמסובב: העיגולים נעצרים בקצה הפינה ומסתובבים סביבו, והפינה היא טורוס המשיק לשניהם. רק לעיגולים ברדיוס אחד סביב קצה פינה ישר וקמור.",
|
||||
"ui.fillet_type_choices_tip": "רדיוס קבוע: רדיוס אחד לכל אורך הקצה.\nאורך מיתר: רוחב העיגול נשאר זהה, בכל זווית שבה הפאות נפגשות.\nרדיוס משתנה: רדיוס בכל קצה, ועוד לאורך הקצה אם מוסיפים.\nרציף בעקמומיות: חתך בלי שבר בהשתקפויות היכן שהוא פוגש פאות מישוריות (G2).\nספליין: הרדיוס לאורך הקצה עוקב אחרי רדיוסי בקרה שאתם קובעים.",
|
||||
"ui.fillet_type_curvature": "רציף בעקמומיות",
|
||||
"ui.fillet_type_tip": "החתך לרוחב הקצה. רציף בעקמומיות: חתך רציף בעקמומיות (G2) בין פאות מישוריות, כך שההשתקפויות עוברות מהפאות אל העיגול בלי שבר.",
|
||||
@@ -4986,6 +5023,7 @@
|
||||
"ui.heading_construct": "עזר",
|
||||
"ui.heading_create": "יצירה",
|
||||
"ui.heading_create_sketch": "יצירת סקיצה",
|
||||
"ui.heading_custom_view_navigator": "ניווט תצוגה מותאם אישית",
|
||||
"ui.heading_cylinder": "גליל",
|
||||
"ui.heading_design": "תכן",
|
||||
"ui.heading_draft": "שיפוע יציקה",
|
||||
@@ -5581,6 +5619,14 @@
|
||||
"values must be numbers or expressions": "הערכים חייבים להיות מספרים או ביטויים",
|
||||
"view.auto_ortho_faces": "פרספקטיבה בזמן סיבוב, אורתוגרפי על פאות",
|
||||
"view.render_inspector": "מפקח רינדור",
|
||||
"view_gizmo.arrows": "חיצי סיבוב",
|
||||
"view_gizmo.home": "לחצן תצוגת בית",
|
||||
"view_gizmo.no_such_body": "אין בעיצוב הזה גוף בשם הזה.",
|
||||
"view_gizmo.note": "בחרו גוף בתצוגה. הוא נשמר עם ההעדפות ופרופילי ההגדרות שלכם ומוצג בכל עיצוב. לחצו על אחת מפאותיו כדי להביט בה ישירות.",
|
||||
"view_gizmo.texture": "מרקם",
|
||||
"view_gizmo.themed": "לפי ערכת הנושא",
|
||||
"view_gizmo.too_detailed": "מפורט מדי לניווט התצוגה (יותר מ-10,000 משולשים)",
|
||||
"view_gizmo.too_thin": "ארוך ודק מדי לניווט התצוגה: הצד הדק ביותר חייב להיות לפחות חמישית מהצד הארוך ביותר",
|
||||
"web.files.insert_drawings_hint": "קובצי CAD, רשתות משולשים, DXF או SVG אל התכן הפתוח",
|
||||
"web.files.insert_stored_drawings_hint": "אל התכן הפתוח (קובצי CAD, רשתות משולשים, DXF, SVG)",
|
||||
"web.open_full": "פתיחה ב־SolidCraft",
|
||||
|
||||
@@ -1392,6 +1392,7 @@
|
||||
"document.read_only_save": "Questa progettazione è stata aperta in sola lettura: salvala come nuovo file",
|
||||
"document.save_changes": "Salvare le modifiche a {name}?",
|
||||
"document.save_copy": "Salva una copia…",
|
||||
"document.tab_unsaved_changes": "Modifiche non salvate",
|
||||
"document.unsaved_changes": "{name} contiene modifiche non salvate. Se non salvi, andranno perse.",
|
||||
"drive.rigid": "{joint} è un giunto rigido: tiene ferme le sue parti, quindi non c'è nulla da azionare. Per farle muovere, cambia il suo tipo in Modifica giunto: Rotante per ruotare, Scorrevole per scorrere, Cilindrico per entrambi.",
|
||||
"drive.stopped_by_contact": "Arrestato a {value}: {a} attraverserebbe {b}",
|
||||
|
||||
@@ -85,6 +85,7 @@
|
||||
"Bottom Right": "右下",
|
||||
"Boundary": "境界",
|
||||
"Bounding box": "境界ボックス",
|
||||
"Bridge": "ブリッジ",
|
||||
"CHECK": "チェック",
|
||||
"COMPONENTS": "コンポーネント",
|
||||
"CONFIGURATIONS": "構成",
|
||||
@@ -1296,6 +1297,7 @@
|
||||
"cmd.timeline_stop": "履歴を停止",
|
||||
"cmd.timeline_suppress": "フィーチャーを抑制",
|
||||
"cmd.ui.assistant": "アシスタントの表示切り替え",
|
||||
"cmd.view.navigator": "カスタム ビューナビゲーター",
|
||||
"cmd.view.render_browser": "Renderブラウザー",
|
||||
"cmd.view.render_dock": "レンダードックを移動",
|
||||
"cmd.view.render_focus": "Renderフォーカス表示",
|
||||
@@ -1396,6 +1398,9 @@
|
||||
"dialog.custom_axis": "↖ カスタム軸",
|
||||
"dialog.dimension_insert_hint": "別の駆動寸法をクリックして名前を挿入",
|
||||
"dialog.dimension_name": "寸法: {name}",
|
||||
"dialog.dimension_step_down": "{step} 減らす",
|
||||
"dialog.dimension_step_expression": "増減できるのは数値だけです。式はそのまま残ります",
|
||||
"dialog.dimension_step_up": "{step} 増やす",
|
||||
"dialog.edit_title": "{title}を編集",
|
||||
"dialog.no_shown_body": "変更できる表示中のボディがありません。ボディを表示するか、新規ボディを選択してください",
|
||||
"dialog.pattern_sketch_not_object": "ボディかフィーチャーを選んでください。スケッチはどちらでもありません。曲線はスケッチ内のパターンかミラーで繰り返すか、先に押し出してください。",
|
||||
@@ -1403,6 +1408,7 @@
|
||||
"dialog.selected_count": "↖ {count} 個選択中",
|
||||
"dialog.selection_constraint": "拘束 {id}",
|
||||
"dialog.selection_external_curve": "{sketch} · 曲線 {id}",
|
||||
"dialog.selection_external_point": "{point} · {sketch}",
|
||||
"dialog.selection_guidance": "入力欄を選んで変更します。選択済みのオブジェクトをクリックすると解除できます。Ctrl/Cmd を押しながらクリックしても切り替えられます。プレビューを確認してから OK を押してください。",
|
||||
"dialog.selection_midpoint": "中点 · {id}",
|
||||
"dialog.selection_profile": "{sketch} · プロファイル {number}",
|
||||
@@ -1420,6 +1426,7 @@
|
||||
"document.read_only_save": "このデザインは読み取り専用で開かれています:新しいファイルとして保存してください",
|
||||
"document.save_changes": "{name} への変更を保存しますか?",
|
||||
"document.save_copy": "コピーを保存…",
|
||||
"document.tab_unsaved_changes": "未保存の変更",
|
||||
"document.unsaved_changes": "{name} には保存されていない変更があります。保存しない場合、変更内容は失われます。",
|
||||
"drive.rigid": "{joint} は剛体ジョイントです。部品をその場に固定するため、駆動するものがありません。動かすには「ジョイントを編集」で種類を変更してください: 回転させるなら Revolute、スライドさせるなら Slider、両方なら Cylindrical。",
|
||||
"drive.stopped_by_contact": "{value} で停止: {a} が {b} を通り抜けてしまいます",
|
||||
@@ -1999,6 +2006,7 @@
|
||||
"material.selection.mixed": "異なる材料",
|
||||
"measure.at": "位置",
|
||||
"measure.item": "項目 {number}",
|
||||
"measure.line_to_centre": "線から中心まで",
|
||||
"measure.quick_copied": "測定値をコピーしました",
|
||||
"measure.quick_copy": "クリックで測定値をコピー",
|
||||
"measure.quick_edges": "エッジ {count} 本",
|
||||
@@ -2265,6 +2273,14 @@
|
||||
"prefs.auto_operation_note": "材料が接触する対象に応じて結合、新規ボディ、切り取りを選びます。",
|
||||
"prefs.axes": "軸",
|
||||
"prefs.axis": "軸",
|
||||
"prefs.blur_cost": "相対的な描画負荷です。バーが多いほど、通常は処理量が増えます。",
|
||||
"prefs.blur_device": "このグラフィックスデバイスまたは描画経路では利用できません",
|
||||
"prefs.blur_guard": "画面の応答性を保つため、ぼかしの品質を下げました。半径は変更されていません。",
|
||||
"prefs.blur_off": "オフ",
|
||||
"prefs.blur_reduce_transparency": "システムの透明度を下げる設定により無効です",
|
||||
"prefs.blur_software": "ソフトウェア描画では利用できません (gpu-software)",
|
||||
"prefs.blur_source": "このワークスペース表示では利用できません",
|
||||
"prefs.blur_web": "このウェブ描画経路では利用できません",
|
||||
"prefs.bodies_folders": "ボディフォルダー",
|
||||
"prefs.bodies_folders_always": "常に表示",
|
||||
"prefs.bodies_folders_never": "表示しない",
|
||||
@@ -2323,6 +2339,13 @@
|
||||
"prefs.dialogs_edge": "ビューの端",
|
||||
"prefs.dialogs_left_column": "左の列",
|
||||
"prefs.dialogs_note": "コマンドダイアログを開く場所:ビューの端にドッキングするか、左の列でブラウザーと履歴の代わりに表示します。ダイアログを閉じるとブラウザーと履歴は元どおりに戻ります。",
|
||||
"prefs.dimension_step_angle": "ウィンドウの矢印、上下キー、ホイールで角度に加える量",
|
||||
"prefs.dimension_step_length": "ウィンドウの矢印、上下キー、ホイールで長さに加える量",
|
||||
"prefs.dimension_steps": "ウィンドウの増減量",
|
||||
"prefs.dimension_values": "寸法値の入力",
|
||||
"prefs.dimension_values_note": "寸法値を入力する場所です。ウィンドウでは矢印で値を増減でき、寸法ツールは先に文字を配置します。",
|
||||
"prefs.dimension_values_view": "ビュー内",
|
||||
"prefs.dimension_values_window": "ウィンドウ",
|
||||
"prefs.display": "表示",
|
||||
"prefs.edges": "エッジと陰影",
|
||||
"prefs.extend": "拡張",
|
||||
@@ -2533,6 +2556,8 @@
|
||||
"prefs.section_hatch_by_part": "部品ごと",
|
||||
"prefs.section_hatch_one": "単一スタイル",
|
||||
"prefs.select": "選択",
|
||||
"prefs.select_hidden_edges": "隠線表示で隠れた辺を選択する",
|
||||
"prefs.select_hidden_edges_help": "「Shaded with hidden edges」で面の向こうの辺を選択し、Filletにも使用できます。オフではv0.30.1相当の選択動作になります。非表示のボディは選択対象になりません。",
|
||||
"prefs.select_one_item": "項目を 1 つ選択",
|
||||
"prefs.select_other_hidden": "「他を選択」で非表示のジオメトリを選択",
|
||||
"prefs.select_other_hidden_help": "オン: ポインター付近の非表示のボディやスケッチ、遮られたエッジを含めます。オフ: ポインター直下の表示中のジオメトリだけを元のリストに表示します。",
|
||||
@@ -2557,6 +2582,8 @@
|
||||
"prefs.sketch.click_selects": "クリックで選択",
|
||||
"prefs.sketch.conflict_colour": "競合色",
|
||||
"prefs.sketch.drawing": "作図",
|
||||
"prefs.sketch.ellipse_axes": "楕円を軸と一緒に描く",
|
||||
"prefs.sketch.ellipse_axes_note": "長軸と短軸をコンストラクション線として描き、半軸と軸に寸法を付けられるようにします。オフ: 楕円のみを描き、D で短半径を寸法にします。値をどこへ引いても、これまでどおり R 付きで描かれます。",
|
||||
"prefs.sketch.exit_keep": "維持",
|
||||
"prefs.sketch.exit_previous": "開始前のビューに戻る",
|
||||
"prefs.sketch.exit_unless_rotated": "回転した場合を除く",
|
||||
@@ -2650,6 +2677,10 @@
|
||||
"prefs.theme_follow_off": "オフ",
|
||||
"prefs.theme_follow_stop": "追従を停止",
|
||||
"prefs.theme_import_hint": "クリックしてテーマファイルを選ぶか、ここにドロップするか、テーマをコピーした状態で Ctrl+V(Mac では Cmd+V)を押します",
|
||||
"prefs.theme_notice_dismiss": "非表示",
|
||||
"prefs.theme_option_blur": "パネル背面のぼかし",
|
||||
"prefs.theme_option_blur_help": "半透明パネルの背面のぼかしを物理ピクセル単位で指定します。0でオフになります。",
|
||||
"prefs.theme_option_invalid": "{option} の保存値が無効なため、テーマの既定値を使用します。保存値は保持されます。",
|
||||
"prefs.theme_option_opacity": "不透明度",
|
||||
"prefs.theme_option_opacity_help": "フローティングパネルの不透明度を調整します。ビューポートの色に重なっても文字が読めるように下限が適用されます。",
|
||||
"prefs.theme_option_tab_colours": "タブの色",
|
||||
@@ -2658,6 +2689,7 @@
|
||||
"prefs.theme_option_tab_fade_ms_help": "タブの切り替え時にパネルとビューポートの色を徐々に変えます。0で即座に切り替えます。",
|
||||
"prefs.theme_options_badge": "オプション",
|
||||
"prefs.theme_options_more": "その他 · {count}",
|
||||
"prefs.theme_options_newer": "このテーマには新しいバージョンの SolidCraft が必要です。更新後、元のテーマを再インポートしてオプション定義を復元してください。",
|
||||
"prefs.theme_options_reset": "リセット",
|
||||
"prefs.theme_options_unavailable": "このデバイスでは利用できません",
|
||||
"prefs.theme_paste": "テーマを貼り付け",
|
||||
@@ -4784,6 +4816,7 @@
|
||||
"ui.default_modelling_orientation": "モデリングのデフォルト方向",
|
||||
"ui.delete": "削除",
|
||||
"ui.delete_select_first": "先に削除するものを選択してください: フィーチャ、ボディ、コンポーネント、スケッチ ジオメトリ",
|
||||
"ui.dependents_select_first": "先に、依存先を表示する対象を選択してください",
|
||||
"ui.depth": "深さ",
|
||||
"ui.design": "デザイン",
|
||||
"ui.design_properties.author": "作成者",
|
||||
@@ -4941,6 +4974,10 @@
|
||||
"ui.fillet_setback_least": "{least} より大きく",
|
||||
"ui.fillet_setback_tip": "なめらかなコーナーパッチが始まる位置: コーナーから各エッジに沿った距離。既定値は半径の {factor} 倍。最小セットバック (セットバックなしでラウンド同士が交わる位置。面どうしが直角なら半径) より大きくする必要があります。",
|
||||
"ui.fillet_smooth_radius": "半径をなめらかに変化",
|
||||
"ui.fillet_turn": "曲がり角",
|
||||
"ui.fillet_turn_mitre": "留め継ぎ",
|
||||
"ui.fillet_turn_swept": "回転",
|
||||
"ui.fillet_turn_tip": "2 つのフィレットが間にある鋭いコーナーエッジを回り込む所(ボスの根元のフィレットが立ち上がりエッジを回る所など)。\n留め継ぎ: フィレットは延びて留め継ぎで出会います。\n回転: フィレットはコーナーエッジで止まり、その周りを回ります。コーナーは両方に接するトーラスです。同じ半径のフィレットが、まっすぐな凸のコーナーエッジを回る場合のみ。",
|
||||
"ui.fillet_type_choices_tip": "一定の半径: エッジ全体で一つの半径。\n弦長: 面がどんな角度で交わっても、フィレットの幅が同じ。\n可変半径: 両端の半径と、追加すればエッジ沿いの半径。\n曲率連続: 平面と接する所で映り込みが折れない断面 (G2)。\nスプライン: エッジ沿いの半径が、設定した制御半径に従います。",
|
||||
"ui.fillet_type_curvature": "曲率連続",
|
||||
"ui.fillet_type_tip": "エッジを横切る断面。曲率連続: 平面の間の曲率連続 (G2) 断面で、映り込みが面からフィレットへ折れずにつながります。",
|
||||
@@ -4986,6 +5023,7 @@
|
||||
"ui.heading_construct": "構築",
|
||||
"ui.heading_create": "作成",
|
||||
"ui.heading_create_sketch": "スケッチを作成",
|
||||
"ui.heading_custom_view_navigator": "カスタム ビューナビゲーター",
|
||||
"ui.heading_cylinder": "円柱",
|
||||
"ui.heading_design": "デザイン",
|
||||
"ui.heading_draft": "勾配",
|
||||
@@ -5581,6 +5619,14 @@
|
||||
"values must be numbers or expressions": "値は数値または式にしてください",
|
||||
"view.auto_ortho_faces": "回転中は透視投影、面を正面に見ると平行投影",
|
||||
"view.render_inspector": "レンダーインスペクター",
|
||||
"view_gizmo.arrows": "回転矢印",
|
||||
"view_gizmo.home": "ホーム ボタン",
|
||||
"view_gizmo.no_such_body": "このデザインにその名前のボディはありません。",
|
||||
"view_gizmo.note": "ビューでボディを選択してください。ボディは環境設定と設定プロファイルに保存され、すべてのデザインで表示されます。面をクリックすると、その面を正面から見ます。",
|
||||
"view_gizmo.texture": "テクスチャ",
|
||||
"view_gizmo.themed": "テーマ",
|
||||
"view_gizmo.too_detailed": "ビューナビゲーターには細かすぎます (10,000 三角形を超えています)",
|
||||
"view_gizmo.too_thin": "ビューナビゲーターには細長すぎます: 最も薄い辺が最も長い辺の 5 分の 1 以上である必要があります",
|
||||
"web.files.insert_drawings_hint": "CADファイル、メッシュ、DXF、SVGを開いているデザインに挿入",
|
||||
"web.files.insert_stored_drawings_hint": "開いているデザインに挿入(CADファイル、メッシュ、DXF、SVG)",
|
||||
"web.open_full": "SolidCraft で開く",
|
||||
|
||||
@@ -1133,6 +1133,7 @@
|
||||
"cmd.timeline_stop": "기록 재생 중지",
|
||||
"cmd.timeline_suppress": "특성 비활성화",
|
||||
"cmd.ui.assistant": "도우미 표시 전환",
|
||||
"cmd.view.navigator": "사용자 지정 뷰 탐색기",
|
||||
"cmd.view.render_browser": "렌더 브라우저",
|
||||
"cmd.view.render_dock": "렌더 도크 이동",
|
||||
"cmd.view.render_focus": "렌더 집중 보기",
|
||||
@@ -1207,6 +1208,9 @@
|
||||
"dialog.custom_axis": "↖ 사용자 정의 축",
|
||||
"dialog.dimension_insert_hint": "다른 구동 치수를 두 번 클릭하여 이름 삽입",
|
||||
"dialog.dimension_name": "치수: {name}",
|
||||
"dialog.dimension_step_down": "{step} 줄이기",
|
||||
"dialog.dimension_step_expression": "숫자만 증감할 수 있으며 수식은 입력한 그대로 유지됩니다",
|
||||
"dialog.dimension_step_up": "{step} 늘리기",
|
||||
"dialog.edit_title": "{title} 편집",
|
||||
"dialog.pattern_sketch_not_object": "바디나 피처를 선택하세요. 스케치는 둘 다 아닙니다. 곡선은 스케치 안의 패턴이나 대칭 복사로 반복하거나, 먼저 돌출하세요.",
|
||||
"dialog.select": "↖ 선택",
|
||||
@@ -1431,6 +1435,7 @@
|
||||
"material.selection.mixed": "서로 다른 재질",
|
||||
"measure.at": "위치",
|
||||
"measure.item": "항목 {number}",
|
||||
"measure.line_to_centre": "선에서 중심까지",
|
||||
"messages.ago_h": "{n}시간 전",
|
||||
"messages.ago_min": "{n}분 전",
|
||||
"messages.ago_s": "{n}초 전",
|
||||
@@ -1657,6 +1662,14 @@
|
||||
"prefs.auto_operation_note": "재료가 닿는 대상에 따라 결합, 새 바디 또는 자르기를 선택합니다.",
|
||||
"prefs.axes": "축",
|
||||
"prefs.axis": "축",
|
||||
"prefs.blur_cost": "상대적인 렌더링 비용입니다. 막대가 많을수록 일반적으로 더 많은 작업이 필요합니다.",
|
||||
"prefs.blur_device": "이 그래픽 장치 또는 렌더링 경로에서는 사용할 수 없습니다",
|
||||
"prefs.blur_guard": "화면의 원활한 응답을 위해 흐림 품질을 낮췄습니다. 반경은 변경되지 않습니다.",
|
||||
"prefs.blur_off": "끄기",
|
||||
"prefs.blur_reduce_transparency": "시스템의 투명도 줄이기 설정에 의해 비활성화됨",
|
||||
"prefs.blur_software": "소프트웨어 그래픽에서는 사용할 수 없습니다 (gpu-software)",
|
||||
"prefs.blur_source": "이 작업 공간 보기에서는 사용할 수 없습니다",
|
||||
"prefs.blur_web": "이 웹 렌더링 경로에서는 사용할 수 없습니다",
|
||||
"prefs.bodies_folders": "바디 폴더",
|
||||
"prefs.bodies_folders_always": "항상 표시",
|
||||
"prefs.bodies_folders_never": "표시 안 함",
|
||||
@@ -1712,6 +1725,13 @@
|
||||
"prefs.dialogs_edge": "뷰 가장자리",
|
||||
"prefs.dialogs_left_column": "왼쪽 열",
|
||||
"prefs.dialogs_note": "명령 대화 상자가 열리는 위치: 뷰 가장자리에 고정하거나, 왼쪽 열에서 브라우저와 이력 대신 표시합니다. 대화 상자를 닫으면 브라우저와 이력이 그대로 돌아옵니다.",
|
||||
"prefs.dimension_step_angle": "창의 화살표, 위/아래 키와 휠이 각도에 더하는 값",
|
||||
"prefs.dimension_step_length": "창의 화살표, 위/아래 키와 휠이 길이에 더하는 값",
|
||||
"prefs.dimension_steps": "창의 증감 단위",
|
||||
"prefs.dimension_values": "치수 값",
|
||||
"prefs.dimension_values_note": "치수 값을 입력하는 위치입니다. 창에는 값을 증감하는 화살표가 있으며, 치수 도구는 텍스트를 먼저 배치합니다.",
|
||||
"prefs.dimension_values_view": "뷰에서",
|
||||
"prefs.dimension_values_window": "창에서",
|
||||
"prefs.display": "표시",
|
||||
"prefs.edges": "모서리 및 음영",
|
||||
"prefs.extend": "확장",
|
||||
@@ -1870,6 +1890,8 @@
|
||||
"prefs.search": "설정 검색",
|
||||
"prefs.search_empty": "일치하는 설정이 없습니다",
|
||||
"prefs.select": "선택",
|
||||
"prefs.select_hidden_edges": "숨은 모서리가 표시된 음영 뷰에서 숨은 모서리 선택",
|
||||
"prefs.select_hidden_edges_help": "숨은 모서리가 표시된 음영 뷰에서 면 뒤의 모서리를 선택하여 필렛에 사용할 수 있습니다. 끄면 v0.30.1의 선택 동작을 유지합니다. 숨긴 바디는 선택되지 않습니다.",
|
||||
"prefs.select_one_item": "항목 하나 선택",
|
||||
"prefs.select_other_hidden": "다른 항목 선택으로 숨겨진 형상 선택",
|
||||
"prefs.select_other_hidden_help": "켜짐: 포인터 근처의 숨겨진 바디, 스케치 및 가려진 모서리를 포함합니다. 꺼짐: 포인터 바로 아래에 보이는 형상만 포함하는 기존 목록을 사용합니다.",
|
||||
@@ -1894,6 +1916,8 @@
|
||||
"prefs.sketch.click_selects": "클릭으로 선택",
|
||||
"prefs.sketch.conflict_colour": "충돌 색상",
|
||||
"prefs.sketch.drawing": "그리기",
|
||||
"prefs.sketch.ellipse_axes": "타원을 축과 함께 그리기",
|
||||
"prefs.sketch.ellipse_axes_note": "장축과 단축을 구성선으로 그려 반축과 축에 치수를 지정할 수 있습니다. 끄면: 타원만 그리며, D는 값을 어디로 끌든 단반경을 주며, 이전처럼 R로 표시되어 그려집니다.",
|
||||
"prefs.sketch.exit_keep": "유지",
|
||||
"prefs.sketch.exit_previous": "이전 뷰로 돌아가기",
|
||||
"prefs.sketch.exit_unless_rotated": "회전하지 않았다면",
|
||||
@@ -1975,6 +1999,10 @@
|
||||
"prefs.theme_follow_note": "다른 도구가 파일을 다시 쓸 때마다 다시 불러옵니다. 테마를 선택하면 따르기를 중지합니다.",
|
||||
"prefs.theme_follow_off": "끄기",
|
||||
"prefs.theme_follow_stop": "따르기 중지",
|
||||
"prefs.theme_notice_dismiss": "닫기",
|
||||
"prefs.theme_option_blur": "패널 뒤 흐림",
|
||||
"prefs.theme_option_blur_help": "반투명 패널 뒤의 흐림을 물리적 픽셀 단위로 설정합니다. 0이면 꺼집니다.",
|
||||
"prefs.theme_option_invalid": "{option}의 저장된 값이 유효하지 않아 테마 기본값을 사용합니다. 저장된 값은 유지됩니다.",
|
||||
"prefs.theme_option_opacity": "불투명도",
|
||||
"prefs.theme_option_opacity_help": "플로팅 패널의 불투명도를 조정합니다. 뷰포트 색상 위에서도 텍스트를 읽을 수 있도록 최솟값이 적용됩니다.",
|
||||
"prefs.theme_option_tab_colours": "탭 색상",
|
||||
@@ -1983,6 +2011,7 @@
|
||||
"prefs.theme_option_tab_fade_ms_help": "탭을 전환할 때 패널 및 뷰포트 색상을 서서히 변경합니다. 0이면 즉시 전환합니다.",
|
||||
"prefs.theme_options_badge": "옵션",
|
||||
"prefs.theme_options_more": "더 보기 · {count}",
|
||||
"prefs.theme_options_newer": "이 테마에는 더 최신 버전의 SolidCraft가 필요합니다. 업그레이드 후 원본 테마를 다시 가져와 옵션 정의를 복구하세요.",
|
||||
"prefs.theme_options_reset": "초기화",
|
||||
"prefs.theme_options_unavailable": "이 기기에서는 사용할 수 없음",
|
||||
"prefs.timeline_half_hour": "−30분",
|
||||
@@ -3562,6 +3591,7 @@
|
||||
"ui.default_modelling_orientation": "기본 모델링 방향",
|
||||
"ui.delete": "삭제",
|
||||
"ui.delete_select_first": "먼저 삭제할 항목을 선택하세요: 피처, 바디, 컴포넌트 또는 스케치 형상",
|
||||
"ui.dependents_select_first": "먼저 종속 항목을 표시할 대상을 선택하세요",
|
||||
"ui.depth": "깊이",
|
||||
"ui.design": "디자인",
|
||||
"ui.design_properties.author": "작성자",
|
||||
@@ -3712,6 +3742,10 @@
|
||||
"ui.fillet_setback_least": "{least}보다 크게",
|
||||
"ui.fillet_setback_tip": "부드러운 코너 패치가 시작되는 위치: 코너에서 각 모서리를 따른 거리. 기본값은 반지름의 {factor}배. 최소 셋백(셋백 없이 라운드가 만나는 곳, 면이 서로 직각이면 반지름)보다 커야 합니다.",
|
||||
"ui.fillet_smooth_radius": "반지름 부드럽게 변화",
|
||||
"ui.fillet_turn": "꺾임",
|
||||
"ui.fillet_turn_mitre": "마이터",
|
||||
"ui.fillet_turn_swept": "회전",
|
||||
"ui.fillet_turn_tip": "두 라운드가 사이의 날카로운 코너 모서리를 돌아가는 곳(보스 밑동의 라운드가 세로 모서리를 도는 곳 등).\n마이터: 라운드가 계속 이어져 마이터로 만납니다.\n회전: 라운드가 코너 모서리에서 멈추고 그 둘레를 돕니다. 코너는 둘 모두에 접하는 토러스입니다. 반지름이 같은 라운드가 곧고 볼록한 코너 모서리를 돌 때만.",
|
||||
"ui.fillet_type_choices_tip": "일정한 반지름: 모서리 전체에 하나의 반지름.\n현 길이: 면이 어떤 각도로 만나든 라운드의 폭이 같습니다.\n가변 반지름: 양 끝의 반지름과, 추가하면 모서리를 따라 더.\n곡률 연속: 평면과 만나는 곳에서 반사가 꺾이지 않는 단면(G2).\n스플라인: 모서리를 따라 반지름이 설정한 제어 반지름을 따릅니다.",
|
||||
"ui.fillet_type_curvature": "곡률 연속",
|
||||
"ui.fillet_type_tip": "모서리를 가로지르는 단면. 곡률 연속: 평면 사이의 곡률 연속(G2) 단면으로, 반사가 면에서 라운드로 꺾임 없이 이어집니다.",
|
||||
@@ -3755,6 +3789,7 @@
|
||||
"ui.heading_construct": "구성",
|
||||
"ui.heading_create": "만들기",
|
||||
"ui.heading_create_sketch": "스케치 만들기",
|
||||
"ui.heading_custom_view_navigator": "사용자 지정 뷰 탐색기",
|
||||
"ui.heading_cylinder": "원통",
|
||||
"ui.heading_design": "디자인",
|
||||
"ui.heading_draft": "구배",
|
||||
@@ -4282,5 +4317,13 @@
|
||||
"values must be finite numbers": "값은 유한한 숫자여야 합니다",
|
||||
"values must be numbers or expressions": "값은 숫자 또는 식이어야 합니다",
|
||||
"view.render_inspector": "렌더 검사기",
|
||||
"view_gizmo.arrows": "회전 화살표",
|
||||
"view_gizmo.home": "홈 버튼",
|
||||
"view_gizmo.no_such_body": "이 디자인에 그런 이름의 바디가 없습니다.",
|
||||
"view_gizmo.note": "뷰에서 바디를 선택하십시오. 바디는 환경설정과 설정 프로필에 저장되어 모든 디자인에 표시됩니다. 면을 클릭하면 그 면을 정면으로 봅니다.",
|
||||
"view_gizmo.texture": "텍스처",
|
||||
"view_gizmo.themed": "테마",
|
||||
"view_gizmo.too_detailed": "뷰 탐색기에 너무 세밀합니다 (삼각형 10,000개 초과)",
|
||||
"view_gizmo.too_thin": "뷰 탐색기에 너무 가늘고 깁니다: 가장 얇은 변이 가장 긴 변의 5분의 1 이상이어야 합니다",
|
||||
"↑ ↓ choose · Enter / Tab complete · Esc dismiss": "↑ ↓ 선택 · Enter / Tab 완성 · Esc 닫기"
|
||||
}
|
||||
|
||||
@@ -1368,6 +1368,9 @@
|
||||
"dialog.custom_axis": "↖ Aangepaste as",
|
||||
"dialog.dimension_insert_hint": "Klik op een andere aandrijvende maat om de naam ervan in te voegen",
|
||||
"dialog.dimension_name": "Maat: {name}",
|
||||
"dialog.dimension_step_down": "{step} omlaag",
|
||||
"dialog.dimension_step_expression": "Alleen een getal verspringt; een uitdrukking blijft zoals ingevoerd",
|
||||
"dialog.dimension_step_up": "{step} omhoog",
|
||||
"dialog.edit_title": "{title} BEWERKEN",
|
||||
"dialog.no_shown_body": "Geen zichtbaar lichaam om te wijzigen: toon er een of kies Nieuw lichaam",
|
||||
"dialog.pattern_sketch_not_object": "Kies lichamen of kenmerken: een schets is geen van beide. Herhaal de curven ervan met het eigen Patroon of Spiegelen van de schets, of extrudeer de schets eerst.",
|
||||
@@ -1392,6 +1395,7 @@
|
||||
"document.read_only_save": "Dit ontwerp is alleen-lezen geopend: sla het op als nieuw bestand",
|
||||
"document.save_changes": "Wijzigingen in {name} opslaan?",
|
||||
"document.save_copy": "Kopie opslaan…",
|
||||
"document.tab_unsaved_changes": "Niet-opgeslagen wijzigingen",
|
||||
"document.unsaved_changes": "{name} heeft niet-opgeslagen wijzigingen. Als u niet opslaat, gaan ze verloren.",
|
||||
"drive.rigid": "{joint} is een starre verbinding: die houdt zijn onderdelen vast en heeft dus niets om aan te sturen. Wijzig het type in Verbinding bewerken om ze te laten bewegen: Draaiend om te draaien, Schuivend om te schuiven, Cilindrisch voor beide.",
|
||||
"drive.stopped_by_contact": "Gestopt bij {value}: {a} zou door {b} heen gaan",
|
||||
@@ -2194,6 +2198,13 @@
|
||||
"prefs.dialogs_edge": "Aan de rand van de weergave",
|
||||
"prefs.dialogs_left_column": "In de linkerkolom",
|
||||
"prefs.dialogs_note": "Waar opdrachtdialogen openen: vastgezet aan de rand van de weergave, of in de linkerkolom in plaats van de browser en de geschiedenis, die terugkomen zoals ze waren wanneer de dialoog sluit.",
|
||||
"prefs.dimension_step_angle": "Wat de pijlen van het venster, Omhoog/Omlaag en het wieltje bij een hoek optellen",
|
||||
"prefs.dimension_step_length": "Wat de pijlen van het venster, Omhoog/Omlaag en het wieltje bij een lengte optellen",
|
||||
"prefs.dimension_steps": "Stappen in het venster",
|
||||
"prefs.dimension_values": "Maatwaarden",
|
||||
"prefs.dimension_values_note": "Waar de waarde van een maat wordt ingevoerd. Een venster heeft pijlen die de waarde laten verspringen; het maatgereedschap plaatst eerst de tekst.",
|
||||
"prefs.dimension_values_view": "In de weergave",
|
||||
"prefs.dimension_values_window": "In een venster",
|
||||
"prefs.display": "Weergave",
|
||||
"prefs.edges": "Randen en schaduwing",
|
||||
"prefs.extend": "Verlengen",
|
||||
|
||||
@@ -1347,6 +1347,7 @@
|
||||
"document.read_only_save": "Ten projekt otwarto tylko do odczytu: zapisz go jako nowy plik",
|
||||
"document.save_changes": "Zapisać zmiany w {name}?",
|
||||
"document.save_copy": "Zapisz kopię…",
|
||||
"document.tab_unsaved_changes": "Niezapisane zmiany",
|
||||
"document.unsaved_changes": "{name} ma niezapisane zmiany. Jeśli ich nie zapiszesz, zostaną utracone.",
|
||||
"drive.rigid": "{joint} to złącze sztywne: utrzymuje swoje części nieruchomo, więc nie ma czym sterować. Aby mogły się poruszać, zmień jego typ w Edytuj złącze: Obrotowe do obrotu, Przesuwne do przesuwu, Walcowe do obu.",
|
||||
"drive.stopped_by_contact": "Zatrzymano przy {value}: {a} przeszedłby przez {b}",
|
||||
@@ -1985,6 +1986,8 @@
|
||||
"prefs.shortcuts": "Skróty",
|
||||
"prefs.single_surface_analysis": "Używaj jednej analizy powierzchni",
|
||||
"prefs.single_surface_analysis_help": "Gdy wyłączone, cieniowanie powierzchni i Ciągłość mogą być włączone jednocześnie. Włączenie zachowuje ostatnio uruchomioną lub zmienioną analizę.",
|
||||
"prefs.sketch.ellipse_axes": "Rysuj elipsy z osiami",
|
||||
"prefs.sketch.ellipse_axes_note": "Oś wielka i mała jako linie konstrukcyjne, aby można było wymiarować półosie i osie. Wyłączone: sama elipsa, a D na niej daje promień małej półosi, gdziekolwiek przeciągnąć wartość, rysowany i oznaczony R jak dotąd.",
|
||||
"prefs.sketch_align_points": "Wyrównuj do innych punktów szkicu podczas rysowania",
|
||||
"prefs.sketch_align_points_note": "Podczas rysowania punkt wyrównuje się jednocześnie poziomo z jednym punktem szkicu i pionowo z innym, z kropkowaną linią pomocniczą, a kliknięcie zachowuje oba wyrównania. Wyłączone: tylko z poprzednim punktem, po jednej osi.",
|
||||
"prefs.sketch_conflict_colour": "Geometria w konflikcie w kolorze konfliktu",
|
||||
|
||||
@@ -182,6 +182,8 @@
|
||||
"Designs you open or save appear here.": "Здесь появляются открытые и сохранённые дизайны.",
|
||||
"Determined by To Object. Choose Angle to edit the angular extent.": "Определяется параметром «До объекта». Выберите «Угол», чтобы задать угловой предел.",
|
||||
"Diameter": "Диаметр",
|
||||
"Did this assembly open the wrong way up?": "Эта сборка открылась не той стороной вверх?",
|
||||
"Did this part open the wrong way up?": "Эта деталь открылась не той стороной вверх?",
|
||||
"Dimension new shapes": "Проставлять размеры новых фигур",
|
||||
"Direction": "Направление",
|
||||
"Discard": "Отменить",
|
||||
@@ -606,6 +608,7 @@
|
||||
"Square": "Квадрат",
|
||||
"Stand it up": "Поставить вертикально",
|
||||
"Stand it up: a quarter turn about X, as one Move feature you can undo.": "Поставить вертикально: четверть оборота вокруг X одной операцией «Переместить», которую можно отменить.",
|
||||
"Stand this assembly up?": "Поставить эту сборку вертикально?",
|
||||
"Stand this part up?": "Поставить эту деталь вертикально?",
|
||||
"Start angle": "Начальный угол",
|
||||
"Start bulge": "Выпуклость в начале",
|
||||
@@ -1356,6 +1359,7 @@
|
||||
"dialog.dimension_insert_hint": "Дважды щёлкните другой управляющий размер, чтобы вставить его имя",
|
||||
"dialog.dimension_name": "Размер: {name}",
|
||||
"dialog.edit_title": "ИЗМЕНИТЬ: {title}",
|
||||
"dialog.no_shown_body": "Нет отображаемого тела для изменения: покажите тело или выберите «Новое тело»",
|
||||
"dialog.pattern_sketch_not_object": "Выберите тела или операции: эскиз — ни то, ни другое. Повторите его кривые собственным инструментом «Массив» или «Зеркало» в эскизе либо сначала выдавите его.",
|
||||
"dialog.select": "↖ Выбрать",
|
||||
"dialog.selected_count": "↖ Выбрано: {count}",
|
||||
@@ -1717,6 +1721,8 @@
|
||||
"help.view.spin": "Непрерывно вращает вид вокруг модели до следующего щелчка или нажатия клавиши. Используйте, чтобы показать модель со всех сторон.",
|
||||
"help.view.tool": "Закрепляет инструмент камеры (орбиту, панорамирование или масштаб), так что перетаскивание левой кнопкой мыши двигает вид, пока вы его не отключите. Используйте на трекпаде или без средней кнопки мыши.",
|
||||
"help.view.visual_style": "Переключает способ отрисовки модели: тонированная с рёбрами или без них, каркас или со скрытыми рёбрами. Используйте каркас, чтобы заглянуть внутрь, а тонированный вид — для чистого вида.",
|
||||
"history.sketch_under_constrained": "Недоопределён (степеней свободы: {count})",
|
||||
"history.sketch_under_constrained_one": "Недоопределён ({count} степень свободы)",
|
||||
"hole_presets.confirm_delete": "Подтвердить удаление",
|
||||
"hole_presets.delete": "Удалить",
|
||||
"hole_presets.delete_prompt": "Удалить этот личный пресет? Существующие отверстия не изменятся.",
|
||||
@@ -2128,6 +2134,8 @@
|
||||
"prefs.anti_aliasing_note": "Сглаживает неровные края.",
|
||||
"prefs.applies_on_restart": "Применяется после перезапуска",
|
||||
"prefs.assistant": "Ассистент",
|
||||
"prefs.auto_operation": "Выбирать операцию автоматически",
|
||||
"prefs.auto_operation_note": "Выбор «Объединить», «Новое тело» или «Вырезать» по тому, чего касается материал.",
|
||||
"prefs.axes": "Оси",
|
||||
"prefs.axis": "Ось",
|
||||
"prefs.bodies_folders": "Папки тел",
|
||||
@@ -2349,6 +2357,8 @@
|
||||
"prefs.sketch_selection": "Эскиз и выделение",
|
||||
"prefs.sketch_selection_heading": "Эскиз и выделение",
|
||||
"prefs.sketch_selection_note": "Как работает привязка в эскизах и как щелчки выделяют объекты.",
|
||||
"prefs.sketch_state_icons": "Значки эскизов в цвете их состояния",
|
||||
"prefs.sketch_state_icons_note": "В истории и браузере значок эскиза принимает цвет его состояния (недоопределён, полностью определён или с конфликтом), а подсказка называет это состояние. Выкл.: у всех эскизов одинаковый значок.",
|
||||
"prefs.sketch_tab_always": "Всегда показывать вкладку «Эскиз»",
|
||||
"prefs.sketch_tab_always_note": "Вкладка «Эскиз» остаётся на ленте сразу после «Модель», пока не открыт ни один эскиз. Её инструменты рисования создают эскиз на выбранной плоскости или грани либо на плоскости, которую вы выберете следом, и сразу включают нужный инструмент.",
|
||||
"prefs.sketch_tracking_guides": "Направляющие выравнивания по центру и середине",
|
||||
@@ -2360,6 +2370,11 @@
|
||||
"prefs.source": "Источник",
|
||||
"prefs.speed": "Скорость",
|
||||
"prefs.stand_up_note": "После открытия файла STEP или IGES с осью Z вверх предлагать повернуть его на четверть оборота вокруг X.",
|
||||
"prefs.stand_up_unknown": "Для файлов из неизвестных программ",
|
||||
"prefs.stand_up_unknown.ask": "Спрашивать в диалоговом окне",
|
||||
"prefs.stand_up_unknown.note": "Показывать уведомление",
|
||||
"prefs.stand_up_unknown.off": "Не предлагать",
|
||||
"prefs.stand_up_unknown_note": "Как делается предложение, когда в файле не указана известная программа.",
|
||||
"prefs.status_bar": "Строка состояния",
|
||||
"prefs.status_bar_note": "Тонкая полоса вдоль нижнего края окна показывает, что изменяется, состояние открытого эскиза его цветом и единицы дизайна; щелчок по единицам изменяет их. Состояние эскиза при этом больше не показывается в углу вида.",
|
||||
"prefs.status_done": "Готово",
|
||||
@@ -3863,7 +3878,7 @@
|
||||
"status.navigation": "Назначение кнопок мыши: {mapping}",
|
||||
"status.navigation_tip": "Как мышь двигает вид. Щёлкните, чтобы выбрать другое назначение",
|
||||
"status.under_constrained": "Недоопределён (степеней свободы: {count})",
|
||||
"status.under_constrained_one": "Недоопределён (степеней свободы: {count})",
|
||||
"status.under_constrained_one": "Недоопределён ({count} степень свободы)",
|
||||
"status.units_tip": "Единицы дизайна. Щёлкните, чтобы изменить",
|
||||
"the angle must be a number": "угол должен быть числом",
|
||||
"the curves must be in one sketch": "кривые должны быть в одном эскизе",
|
||||
@@ -3902,8 +3917,10 @@
|
||||
"timeline.move_to_group": "Переместить операции в папку",
|
||||
"timeline.rename_group": "Переименовать папку операций",
|
||||
"timeline.reorder_many": "Изменить порядок операций",
|
||||
"timeline.suppress_features": "Подавить операции",
|
||||
"timeline.suppress_folders_tip": "Подавить или снять подавление со всех операций в папках и вложенных папках",
|
||||
"timeline.ungroup": "Убрать папки операций",
|
||||
"timeline.unsuppress_features": "Снять подавление с операций",
|
||||
"to the face": "до грани",
|
||||
"to the next face": "до следующей грани",
|
||||
"toolbar.customize_note": "Вкладка {tab}. Отметьте команду, чтобы добавить её кнопку на панель инструментов. Можно также перетащить строку из выпадающего меню панели на панель инструментов, перетащить кнопку за пределы панели для удаления или использовать значок закрепления в строке выпадающего меню.",
|
||||
@@ -4349,6 +4366,8 @@
|
||||
"ui.cancel": "Отмена",
|
||||
"ui.canvases": "Подложки",
|
||||
"ui.center": "Центр",
|
||||
"ui.center_arc_follows_cursor": "Дуга из центра следует направлению курсора",
|
||||
"ui.center_arc_follows_cursor_tip": "Дуга из центра идёт по часовой стрелке или против неё — в ту сторону, в которую курсор обходит её центр. Выкл.: всегда против часовой стрелки от начальной точки.",
|
||||
"ui.centerline": "Осевая линия",
|
||||
"ui.chain": "Цепочка",
|
||||
"ui.chamfer": "Фаска",
|
||||
@@ -5160,11 +5179,13 @@
|
||||
"update.installed": "SolidCraft {version} установлен. Перезапустите, чтобы использовать его.",
|
||||
"update.installer_started": "Установщик запустится после закрытия SolidCraft.",
|
||||
"update.later": "Напомнить позже",
|
||||
"update.managed": "Доступно обновление: обновите SolidCraft в {manager}",
|
||||
"update.no_notes": "У этого выпуска нет примечаний.",
|
||||
"update.package_hint": "Эта копия установлена из {kind}. Скачайте новый пакет, затем установите его командой:",
|
||||
"update.prefs_dev": "Это сборка для разработки ({version}): она не проверяет обновления.",
|
||||
"update.prefs_flatpak": "Эту копию ({version}) обновляет Flatpak: выполните {command} или воспользуйтесь центром приложений. SolidCraft не проверяет обновления.",
|
||||
"update.prefs_label": "Обновления",
|
||||
"update.prefs_managed": "Это SolidCraft {version}, которым управляет {manager}. Проверка запрашивает у GitHub последний релиз и больше ничего не отправляет; обновляйте SolidCraft в {manager}.",
|
||||
"update.prefs_note": "Это SolidCraft {version} ({kind}). При проверке у GitHub запрашивается последний выпуск, больше ничего не отправляется.",
|
||||
"update.prefs_web": "Перезагрузите страницу, чтобы получить последнюю версию веб-приложения. Если сеть недоступна или обновление не успевает загрузиться, открывается сохранённая версия; незавершённая загрузка продолжится до следующей перезагрузки.",
|
||||
"update.refresh_checking": "Проверка наличия новейшего выпуска…",
|
||||
|
||||
@@ -1392,6 +1392,7 @@
|
||||
"document.read_only_save": "Den här konstruktionen öppnades skrivskyddad: spara den som en ny fil",
|
||||
"document.save_changes": "Spara ändringar i {name}?",
|
||||
"document.save_copy": "Spara en kopia…",
|
||||
"document.tab_unsaved_changes": "Osparade ändringar",
|
||||
"document.unsaved_changes": "{name} har osparade ändringar. Om du inte sparar går de förlorade.",
|
||||
"drive.rigid": "{joint} är ett stelt led: det håller sina delar stilla och har därför inget att driva. Om delarna ska kunna röra sig, ändra ledets typ i Redigera led: Roterande för att rotera, Glidande för att glida, Cylindrisk för båda.",
|
||||
"drive.stopped_by_contact": "Stoppade vid {value}: {a} skulle gå igenom {b}",
|
||||
|
||||
@@ -85,6 +85,7 @@
|
||||
"Bottom Right": "Dưới cùng bên phải",
|
||||
"Boundary": "Biên dạng bao",
|
||||
"Bounding box": "Hộp bao ngoài",
|
||||
"Bridge": "Cầu nối",
|
||||
"CHECK": "KIỂM TRA",
|
||||
"COMPONENTS": "CHI TIẾT",
|
||||
"CONFIGURATIONS": "CẤU HÌNH",
|
||||
@@ -1296,6 +1297,7 @@
|
||||
"cmd.timeline_stop": "Dừng lịch sử",
|
||||
"cmd.timeline_suppress": "Tạm nén tính năng",
|
||||
"cmd.ui.assistant": "Bật/tắt trợ lý",
|
||||
"cmd.view.navigator": "Khối điều hướng góc nhìn tùy chỉnh",
|
||||
"cmd.view.render_browser": "Trình duyệt kết xuất",
|
||||
"cmd.view.render_dock": "Di chuyển neo kết xuất",
|
||||
"cmd.view.render_focus": "Chế độ xem tập trung kết xuất",
|
||||
@@ -1396,6 +1398,9 @@
|
||||
"dialog.custom_axis": "↖ Trục tùy chỉnh",
|
||||
"dialog.dimension_insert_hint": "Nhấp vào một kích thước điều khiển khác để chèn tên của nó",
|
||||
"dialog.dimension_name": "Kích thước: {name}",
|
||||
"dialog.dimension_step_down": "Giảm {step}",
|
||||
"dialog.dimension_step_expression": "Chỉ số mới thay đổi theo bước; biểu thức giữ nguyên như đã nhập",
|
||||
"dialog.dimension_step_up": "Tăng {step}",
|
||||
"dialog.edit_title": "CHỈNH SỬA {title}",
|
||||
"dialog.no_shown_body": "Không có thân khối đang hiển thị để thay đổi: hãy hiển thị một thân khối, hoặc chọn “Tạo mới Thân khối”",
|
||||
"dialog.pattern_sketch_not_object": "Hãy chọn thân khối hoặc tính năng: bản phác thảo không phải là cả hai. Lặp lại các đường cong của nó bằng Mảng hoặc Lấy đối xứng trong chính bản phác thảo, hoặc đùn khối nó trước.",
|
||||
@@ -1403,6 +1408,7 @@
|
||||
"dialog.selected_count": "↖ {count} đã chọn",
|
||||
"dialog.selection_constraint": "Ràng buộc {id}",
|
||||
"dialog.selection_external_curve": "{sketch} · đường cong {id}",
|
||||
"dialog.selection_external_point": "{point} · {sketch}",
|
||||
"dialog.selection_guidance": "Chọn một mục đầu vào để thay đổi. Nhấp vào đối tượng đã chọn để bỏ chọn; Ctrl/Cmd-nhấp để bật/tắt chọn. Xem trước kết quả, sau đó nhấn OK.",
|
||||
"dialog.selection_midpoint": "Trung điểm · {id}",
|
||||
"dialog.selection_profile": "{sketch} · biên dạng {number}",
|
||||
@@ -1420,6 +1426,7 @@
|
||||
"document.read_only_save": "Thiết kế này được mở ở chế độ chỉ đọc: hãy lưu thành tệp mới",
|
||||
"document.save_changes": "Lưu các thay đổi đối với {name}?",
|
||||
"document.save_copy": "Lưu bản sao…",
|
||||
"document.tab_unsaved_changes": "Thay đổi chưa lưu",
|
||||
"document.unsaved_changes": "{name} có các thay đổi chưa lưu. Nếu không lưu, các thay đổi sẽ bị mất.",
|
||||
"drive.rigid": "{joint} là khớp nối cứng: nó giữ cố định các chi tiết, nên không có gì để dẫn động. Để các chi tiết chuyển động, hãy đổi loại khớp nối trong “Chỉnh sửa khớp nối”: Revolute để xoay, Slider để trượt, Cylindrical cho cả hai.",
|
||||
"drive.stopped_by_contact": "Đã dừng tại {value}: {a} sẽ xuyên qua {b}",
|
||||
@@ -1999,6 +2006,7 @@
|
||||
"material.selection.mixed": "Các vật liệu khác nhau",
|
||||
"measure.at": "Tại",
|
||||
"measure.item": "Mục {number}",
|
||||
"measure.line_to_centre": "Từ đường đến tâm",
|
||||
"measure.quick_copied": "Đã sao chép số đo",
|
||||
"measure.quick_copy": "Nhấp để sao chép số đo",
|
||||
"measure.quick_edges": "{count} cạnh",
|
||||
@@ -2265,6 +2273,14 @@
|
||||
"prefs.auto_operation_note": "Nối, Vật thể mới hoặc Cắt, tùy vào phần vật liệu tiếp xúc.",
|
||||
"prefs.axes": "Các trục tọa độ",
|
||||
"prefs.axis": "Trục tọa độ",
|
||||
"prefs.blur_cost": "Chi phí kết xuất tương đối. Nhiều thanh hơn thường có nghĩa là cần xử lý nhiều hơn.",
|
||||
"prefs.blur_device": "Không khả dụng trên thiết bị đồ họa hoặc đường kết xuất này",
|
||||
"prefs.blur_guard": "Chất lượng làm mờ đã giảm để duy trì độ phản hồi của khung hình. Bán kính không thay đổi.",
|
||||
"prefs.blur_off": "Tắt",
|
||||
"prefs.blur_reduce_transparency": "Bị tắt bởi cài đặt Giảm độ trong suốt của hệ thống",
|
||||
"prefs.blur_software": "Không khả dụng với đồ họa phần mềm (gpu-software)",
|
||||
"prefs.blur_source": "Không khả dụng trong chế độ xem không gian làm việc này",
|
||||
"prefs.blur_web": "Không khả dụng trên đường kết xuất web này",
|
||||
"prefs.bodies_folders": "Thư mục thân khối",
|
||||
"prefs.bodies_folders_always": "Luôn luôn",
|
||||
"prefs.bodies_folders_never": "Không bao giờ",
|
||||
@@ -2323,6 +2339,13 @@
|
||||
"prefs.dialogs_edge": "Ở cạnh khung nhìn",
|
||||
"prefs.dialogs_left_column": "Trong cột bên trái",
|
||||
"prefs.dialogs_note": "Nơi hộp thoại lệnh mở ra: gắn ở cạnh khung nhìn, hoặc trong cột bên trái thay cho trình duyệt và Lịch sử, hai phần này trở lại như cũ khi hộp thoại đóng.",
|
||||
"prefs.dimension_step_angle": "Lượng mà mũi tên của cửa sổ, Lên/Xuống và con lăn cộng vào một góc",
|
||||
"prefs.dimension_step_length": "Lượng mà mũi tên của cửa sổ, Lên/Xuống và con lăn cộng vào một độ dài",
|
||||
"prefs.dimension_steps": "Bước của cửa sổ",
|
||||
"prefs.dimension_values": "Giá trị kích thước",
|
||||
"prefs.dimension_values_note": "Nơi bạn nhập giá trị kích thước. Cửa sổ có mũi tên để thay đổi theo bước; công cụ Kích thước đặt văn bản trước.",
|
||||
"prefs.dimension_values_view": "Trong khung nhìn",
|
||||
"prefs.dimension_values_window": "Trong một cửa sổ",
|
||||
"prefs.display": "Hiển thị",
|
||||
"prefs.edges": "Cạnh và đổ bóng",
|
||||
"prefs.extend": "Kéo dài",
|
||||
@@ -2533,6 +2556,8 @@
|
||||
"prefs.section_hatch_by_part": "Theo chi tiết",
|
||||
"prefs.section_hatch_one": "Một kiểu",
|
||||
"prefs.select": "Chọn",
|
||||
"prefs.select_hidden_edges": "Chọn cạnh bị che trong chế độ tô bóng có cạnh ẩn",
|
||||
"prefs.select_hidden_edges_help": "Cho phép chọn cạnh xuyên qua bề mặt khi dùng chế độ tô bóng có cạnh ẩn, kể cả cho lệnh Fillet. Tắt để giữ cách chọn của v0.30.1. Các khối đã ẩn vẫn không thể chọn.",
|
||||
"prefs.select_one_item": "Chọn một mục",
|
||||
"prefs.select_other_hidden": "Chọn hình học ẩn bằng Chọn đối tượng khác",
|
||||
"prefs.select_other_hidden_help": "Bật: bao gồm các khối, bản phác thảo ẩn và cạnh bị che gần con trỏ. Tắt: dùng danh sách ban đầu chỉ gồm hình học đang hiển thị ngay dưới con trỏ.",
|
||||
@@ -2557,6 +2582,8 @@
|
||||
"prefs.sketch.click_selects": "Nhấp để chọn",
|
||||
"prefs.sketch.conflict_colour": "Màu xung đột",
|
||||
"prefs.sketch.drawing": "Vẽ",
|
||||
"prefs.sketch.ellipse_axes": "Vẽ hình elip kèm các trục",
|
||||
"prefs.sketch.ellipse_axes_note": "Trục lớn và trục nhỏ dưới dạng đường gióng dựng hình, để có thể ghi kích thước bán trục và trục. Tắt: chỉ vẽ hình elip, và D trên nó cho bán kính nhỏ dù kéo giá trị đến đâu, được vẽ và ghi nhãn R như trước.",
|
||||
"prefs.sketch.exit_keep": "Giữ nguyên",
|
||||
"prefs.sketch.exit_previous": "Trở về góc nhìn trước đó",
|
||||
"prefs.sketch.exit_unless_rotated": "Trừ khi đã xoay",
|
||||
@@ -2650,6 +2677,10 @@
|
||||
"prefs.theme_follow_off": "Tắt",
|
||||
"prefs.theme_follow_stop": "Dừng theo dõi",
|
||||
"prefs.theme_import_hint": "Nhấp để chọn tệp giao diện, thả tệp vào đây, hoặc nhấn Ctrl+V (Cmd+V trên Mac) khi đã sao chép một giao diện",
|
||||
"prefs.theme_notice_dismiss": "Ẩn",
|
||||
"prefs.theme_option_blur": "Làm mờ phía sau bảng",
|
||||
"prefs.theme_option_blur_help": "Làm mờ phía sau các bảng bán trong suốt, tính bằng pixel vật lý. Giá trị 0 là tắt.",
|
||||
"prefs.theme_option_invalid": "Giá trị đã lưu cho {option} không hợp lệ; đang dùng giá trị mặc định của giao diện. Giá trị đã lưu vẫn được giữ lại.",
|
||||
"prefs.theme_option_opacity": "Độ đục",
|
||||
"prefs.theme_option_opacity_help": "Điều chỉnh độ đục của các bảng nổi. Giới hạn độ đục tối thiểu giúp văn bản luôn dễ đọc trên nền màu của khung nhìn.",
|
||||
"prefs.theme_option_tab_colours": "Màu thẻ",
|
||||
@@ -2658,6 +2689,7 @@
|
||||
"prefs.theme_option_tab_fade_ms_help": "Chuyển dần màu của bảng điều khiển và khung nhìn khi đổi thẻ. Đặt thành 0 để chuyển ngay lập tức.",
|
||||
"prefs.theme_options_badge": "Tùy chọn",
|
||||
"prefs.theme_options_more": "Thêm · {count}",
|
||||
"prefs.theme_options_newer": "Giao diện này yêu cầu phiên bản SolidCraft mới hơn. Sau khi nâng cấp, hãy nhập lại giao diện gốc để khôi phục các định nghĩa tùy chọn.",
|
||||
"prefs.theme_options_reset": "Đặt lại",
|
||||
"prefs.theme_options_unavailable": "Không khả dụng trên thiết bị này",
|
||||
"prefs.theme_paste": "Dán giao diện",
|
||||
@@ -4784,6 +4816,7 @@
|
||||
"ui.default_modelling_orientation": "Hướng mô hình hóa mặc định",
|
||||
"ui.delete": "Xóa",
|
||||
"ui.delete_select_first": "Hãy chọn trước những gì cần xóa: tính năng, khối, thành phần hoặc hình học phác thảo",
|
||||
"ui.dependents_select_first": "Hãy chọn trước đối tượng để liệt kê các phần phụ thuộc của nó",
|
||||
"ui.depth": "Độ sâu",
|
||||
"ui.design": "Thiết kế",
|
||||
"ui.design_properties.author": "Tác giả",
|
||||
@@ -4941,6 +4974,10 @@
|
||||
"ui.fillet_setback_least": "Lớn hơn {least}",
|
||||
"ui.fillet_setback_tip": "Nơi mảng góc trơn bắt đầu: khoảng cách dọc theo mỗi cạnh tính từ góc. Mặc định là {factor} × bán kính. Giá trị phải lớn hơn khoảng lùi nhỏ nhất, tại đó các đường bo gặp nhau mà không cần lùi: bằng bán kính khi các mặt vuông góc với nhau.",
|
||||
"ui.fillet_smooth_radius": "Thay đổi bán kính trơn",
|
||||
"ui.fillet_turn": "Chỗ rẽ",
|
||||
"ui.fillet_turn_mitre": "Vát góc",
|
||||
"ui.fillet_turn_swept": "Quét vòng",
|
||||
"ui.fillet_turn_tip": "Nơi hai góc lượn rẽ quanh một cạnh góc sắc nằm giữa chúng, ví dụ các góc lượn ở chân một khối lồi tại các cạnh đứng của nó.\nVát góc: các góc lượn kéo dài cho đến khi gặp nhau theo đường vát.\nQuét vòng: các góc lượn dừng ở cạnh góc và quay quanh nó, góc là một mặt xuyến tiếp xúc với cả hai. Chỉ cho các góc lượn cùng bán kính quanh một cạnh góc thẳng và lồi.",
|
||||
"ui.fillet_type_choices_tip": "Bán kính không đổi: một bán kính dọc suốt cạnh.\nĐộ dài dây cung: độ rộng ngang đường bo giữ nguyên, bất kể góc gặp nhau của các mặt.\nBán kính thay đổi: một bán kính ở mỗi đầu, và thêm dọc cạnh nếu bạn thêm vào.\nLiên tục độ cong: mặt cắt không làm gãy phản chiếu tại nơi gặp các mặt phẳng (G2).\nSpline: bán kính dọc cạnh theo các bán kính điều khiển bạn đặt.",
|
||||
"ui.fillet_type_curvature": "Liên tục độ cong",
|
||||
"ui.fillet_type_tip": "Mặt cắt ngang qua cạnh. Liên tục độ cong: mặt cắt liên tục độ cong (G2) giữa các mặt phẳng, nên phản chiếu đi từ các mặt vào đường bo mà không bị gãy.",
|
||||
@@ -4986,6 +5023,7 @@
|
||||
"ui.heading_construct": "DỰNG HÌNH",
|
||||
"ui.heading_create": "TẠO MỚI",
|
||||
"ui.heading_create_sketch": "TẠO PHÁC THẢO",
|
||||
"ui.heading_custom_view_navigator": "KHỐI ĐIỀU HƯỚNG GÓC NHÌN TÙY CHỈNH",
|
||||
"ui.heading_cylinder": "HÌNH TRỤ",
|
||||
"ui.heading_design": "THIẾT KẾ",
|
||||
"ui.heading_draft": "ĐỘ DỐC THOÁT KHUÔN",
|
||||
@@ -5581,6 +5619,14 @@
|
||||
"values must be numbers or expressions": "các giá trị phải là số hoặc biểu thức",
|
||||
"view.auto_ortho_faces": "Phối cảnh khi xoay quỹ đạo, trực giao khi nhìn trực diện mặt",
|
||||
"view.render_inspector": "Bảng thuộc tính kết xuất",
|
||||
"view_gizmo.arrows": "Mũi tên xoay",
|
||||
"view_gizmo.home": "Nút góc nhìn mặc định (Home)",
|
||||
"view_gizmo.no_such_body": "Thiết kế này không có thân khối nào mang tên đó.",
|
||||
"view_gizmo.note": "Chọn một thân khối trong khung nhìn. Thân khối được lưu cùng tùy chọn và hồ sơ cài đặt của bạn, và hiển thị trong mọi thiết kế. Nhấp vào một mặt của nó để nhìn thẳng vào mặt đó.",
|
||||
"view_gizmo.texture": "Kết cấu",
|
||||
"view_gizmo.themed": "Theo chủ đề",
|
||||
"view_gizmo.too_detailed": "Quá chi tiết cho khối điều hướng góc nhìn (hơn 10.000 tam giác)",
|
||||
"view_gizmo.too_thin": "Quá dài và mảnh cho khối điều hướng góc nhìn: cạnh mỏng nhất phải ít nhất bằng một phần năm cạnh dài nhất",
|
||||
"web.files.insert_drawings_hint": "Tệp CAD, lưới đa giác, DXF hoặc SVG vào thiết kế đang mở",
|
||||
"web.files.insert_stored_drawings_hint": "Vào thiết kế đang mở (tệp CAD, lưới đa giác, DXF, SVG)",
|
||||
"web.open_full": "Mở trong SolidCraft",
|
||||
|
||||
@@ -85,6 +85,7 @@
|
||||
"Bottom Right": "右下",
|
||||
"Boundary": "边界",
|
||||
"Bounding box": "边界框",
|
||||
"Bridge": "桥接",
|
||||
"CHECK": "检查",
|
||||
"COMPONENTS": "组件",
|
||||
"CONFIGURATIONS": "配置",
|
||||
@@ -1296,6 +1297,7 @@
|
||||
"cmd.timeline_stop": "停止历史播放",
|
||||
"cmd.timeline_suppress": "抑制特征",
|
||||
"cmd.ui.assistant": "切换助手显示",
|
||||
"cmd.view.navigator": "自定义视图导航器",
|
||||
"cmd.view.render_browser": "渲染浏览器",
|
||||
"cmd.view.render_dock": "移动渲染停靠面板",
|
||||
"cmd.view.render_focus": "渲染专注视图",
|
||||
@@ -1396,6 +1398,9 @@
|
||||
"dialog.custom_axis": "↖ 自定义轴",
|
||||
"dialog.dimension_insert_hint": "单击其他驱动尺寸以插入其名称",
|
||||
"dialog.dimension_name": "尺寸:{name}",
|
||||
"dialog.dimension_step_down": "减少 {step}",
|
||||
"dialog.dimension_step_expression": "只有数字可以增减;表达式保持不变",
|
||||
"dialog.dimension_step_up": "增加 {step}",
|
||||
"dialog.edit_title": "编辑 {title}",
|
||||
"dialog.no_shown_body": "没有可更改的显示实体:请显示一个实体,或选择新建实体",
|
||||
"dialog.pattern_sketch_not_object": "请选择实体或特征:草图两者都不是。请用草图自身的阵列或镜像重复其曲线,或先将其拉伸。",
|
||||
@@ -1403,6 +1408,7 @@
|
||||
"dialog.selected_count": "↖ 已选择 {count} 个",
|
||||
"dialog.selection_constraint": "约束 {id}",
|
||||
"dialog.selection_external_curve": "{sketch} · 曲线 {id}",
|
||||
"dialog.selection_external_point": "{point} · {sketch}",
|
||||
"dialog.selection_guidance": "选择输入项进行修改。点击已选对象可取消选择;按住 Ctrl/Cmd 点击也可切换选择。检查预览后,再按“确定”。",
|
||||
"dialog.selection_midpoint": "中点 · {id}",
|
||||
"dialog.selection_profile": "{sketch} · 轮廓 {number}",
|
||||
@@ -1420,6 +1426,7 @@
|
||||
"document.read_only_save": "此设计以只读方式打开:请另存为新文件",
|
||||
"document.save_changes": "保存对 {name} 的更改?",
|
||||
"document.save_copy": "保存副本…",
|
||||
"document.tab_unsaved_changes": "未保存的更改",
|
||||
"document.unsaved_changes": "{name} 有未保存的更改。如果不保存,将丢失这些更改。",
|
||||
"drive.rigid": "{joint} 是刚性接头:它把零件固定在原位,因此没有可驱动的运动。要让零件运动,请在“编辑接头”中更改其类型:旋转用 Revolute,滑动用 Slider,两者兼有用 Cylindrical。",
|
||||
"drive.stopped_by_contact": "停在 {value}:{a} 会穿过 {b}",
|
||||
@@ -1999,6 +2006,7 @@
|
||||
"material.selection.mixed": "不同材料",
|
||||
"measure.at": "位置",
|
||||
"measure.item": "项目 {number}",
|
||||
"measure.line_to_centre": "线到圆心",
|
||||
"measure.quick_copied": "已复制测量值",
|
||||
"measure.quick_copy": "点击复制测量值",
|
||||
"measure.quick_edges": "{count} 条边",
|
||||
@@ -2265,6 +2273,14 @@
|
||||
"prefs.auto_operation_note": "根据材料接触的对象选择合并、新建实体或切割。",
|
||||
"prefs.axes": "坐标轴",
|
||||
"prefs.axis": "轴",
|
||||
"prefs.blur_cost": "相对渲染开销。条形越多,通常意味着处理工作越多。",
|
||||
"prefs.blur_device": "此图形设备或渲染路径不支持此功能",
|
||||
"prefs.blur_guard": "已降低模糊质量,以保持画面响应。模糊半径未改变。",
|
||||
"prefs.blur_off": "关闭",
|
||||
"prefs.blur_reduce_transparency": "已被系统的降低透明度设置禁用",
|
||||
"prefs.blur_software": "软件图形渲染不支持此功能 (gpu-software)",
|
||||
"prefs.blur_source": "此工作区视图不支持此功能",
|
||||
"prefs.blur_web": "此网页渲染路径不支持此功能",
|
||||
"prefs.bodies_folders": "实体文件夹",
|
||||
"prefs.bodies_folders_always": "始终显示",
|
||||
"prefs.bodies_folders_never": "不显示",
|
||||
@@ -2323,6 +2339,13 @@
|
||||
"prefs.dialogs_edge": "视图边缘",
|
||||
"prefs.dialogs_left_column": "左侧栏",
|
||||
"prefs.dialogs_note": "命令对话框的打开位置:停靠在视图边缘,或在左侧栏中替代浏览器和历史;关闭对话框后,浏览器和历史恢复原样。",
|
||||
"prefs.dimension_step_angle": "窗口的箭头、上下方向键和滚轮给角度增减的量",
|
||||
"prefs.dimension_step_length": "窗口的箭头、上下方向键和滚轮给长度增减的量",
|
||||
"prefs.dimension_steps": "窗口步长",
|
||||
"prefs.dimension_values": "尺寸值",
|
||||
"prefs.dimension_values_note": "输入尺寸值的位置。窗口带有可增减数值的箭头;尺寸工具会先放置文字。",
|
||||
"prefs.dimension_values_view": "在视图中",
|
||||
"prefs.dimension_values_window": "在窗口中",
|
||||
"prefs.display": "显示",
|
||||
"prefs.edges": "边缘与着色",
|
||||
"prefs.extend": "扩展",
|
||||
@@ -2533,6 +2556,8 @@
|
||||
"prefs.section_hatch_by_part": "按零件",
|
||||
"prefs.section_hatch_one": "单一样式",
|
||||
"prefs.select": "选择",
|
||||
"prefs.select_hidden_edges": "在带隐藏边线的着色视图中选择隐藏边",
|
||||
"prefs.select_hidden_edges_help": "在带隐藏边线的着色视图中,可穿过面选择边并用于圆角。关闭时保留v0.30.1的选择行为。隐藏的实体仍不可选择。",
|
||||
"prefs.select_one_item": "选择一个项目",
|
||||
"prefs.select_other_hidden": "使用“选择其他”选择隐藏几何体",
|
||||
"prefs.select_other_hidden_help": "开启:包括指针附近的隐藏实体、草图和被遮挡的边。关闭:恢复原来的列表,仅包含指针正下方的可见几何体。",
|
||||
@@ -2557,6 +2582,8 @@
|
||||
"prefs.sketch.click_selects": "单击选择",
|
||||
"prefs.sketch.conflict_colour": "冲突颜色",
|
||||
"prefs.sketch.drawing": "绘制",
|
||||
"prefs.sketch.ellipse_axes": "绘制椭圆时带上轴线",
|
||||
"prefs.sketch.ellipse_axes_note": "将长轴和短轴绘制为构造线,以便标注半轴和轴。关闭:仅绘制椭圆,对其按 D 得到短半径,无论将数值拖到何处,都像以前一样绘制并标注 R。",
|
||||
"prefs.sketch.exit_keep": "保持",
|
||||
"prefs.sketch.exit_previous": "返回之前的视图",
|
||||
"prefs.sketch.exit_unless_rotated": "除非已旋转",
|
||||
@@ -2650,6 +2677,10 @@
|
||||
"prefs.theme_follow_off": "关闭",
|
||||
"prefs.theme_follow_stop": "停止跟随",
|
||||
"prefs.theme_import_hint": "单击选择主题文件,拖放到此处,或复制主题后按 Ctrl+V(Mac 上为 Cmd+V)",
|
||||
"prefs.theme_notice_dismiss": "忽略",
|
||||
"prefs.theme_option_blur": "面板背后模糊",
|
||||
"prefs.theme_option_blur_help": "半透明面板背后的模糊程度,以物理像素为单位。0 表示关闭。",
|
||||
"prefs.theme_option_invalid": "{option} 的已保存值无效;正在使用主题默认值。已保存值将予以保留。",
|
||||
"prefs.theme_option_opacity": "不透明度",
|
||||
"prefs.theme_option_opacity_help": "调整浮动面板的不透明度。最低不透明度限制可确保文字在视口颜色上清晰可读。",
|
||||
"prefs.theme_option_tab_colours": "选项卡颜色",
|
||||
@@ -2658,6 +2689,7 @@
|
||||
"prefs.theme_option_tab_fade_ms_help": "切换选项卡时渐变面板和视口颜色。设为零可立即切换。",
|
||||
"prefs.theme_options_badge": "选项",
|
||||
"prefs.theme_options_more": "更多 · {count}",
|
||||
"prefs.theme_options_newer": "此主题需要更新版本的 SolidCraft。升级后,请重新导入原始主题以恢复其选项定义。",
|
||||
"prefs.theme_options_reset": "重置",
|
||||
"prefs.theme_options_unavailable": "此设备不支持",
|
||||
"prefs.theme_paste": "粘贴主题",
|
||||
@@ -4784,6 +4816,7 @@
|
||||
"ui.default_modelling_orientation": "默认建模方向",
|
||||
"ui.delete": "删除",
|
||||
"ui.delete_select_first": "请先选择要删除的内容:特征、实体、组件或草图几何",
|
||||
"ui.dependents_select_first": "请先选择要列出其依赖项的对象",
|
||||
"ui.depth": "深度",
|
||||
"ui.design": "设计",
|
||||
"ui.design_properties.author": "作者",
|
||||
@@ -4941,6 +4974,10 @@
|
||||
"ui.fillet_setback_least": "须大于 {least}",
|
||||
"ui.fillet_setback_tip": "平滑角点曲面片开始的位置:从角点沿每条边的距离。默认值为半径的 {factor} 倍。必须大于最小后退距离(不后退时圆角相交之处;面相互垂直时即半径)。",
|
||||
"ui.fillet_smooth_radius": "半径平滑变化",
|
||||
"ui.fillet_turn": "转角",
|
||||
"ui.fillet_turn_mitre": "斜接",
|
||||
"ui.fillet_turn_swept": "旋转",
|
||||
"ui.fillet_turn_tip": "两个圆角绕过它们之间尖锐的角边处,例如凸台根部的圆角绕过其竖直边。\n斜接:圆角继续延伸,直到以斜接相遇。\n旋转:圆角止于角边并绕它旋转,转角是与两者相切的圆环面。仅用于同一半径的圆角绕过笔直的凸角边时。",
|
||||
"ui.fillet_type_choices_tip": "恒定半径:整条边一个半径。\n弦长:无论面以什么角度相交,圆角宽度都相同。\n可变半径:两端各一个半径,添加后沿边还有更多。\n曲率连续:与平面相接处反射没有折痕的截面 (G2)。\n样条:沿边的半径跟随您设置的控制半径。",
|
||||
"ui.fillet_type_curvature": "曲率连续",
|
||||
"ui.fillet_type_tip": "横跨边的截面。曲率连续:平面之间的曲率连续 (G2) 截面,反射从面平滑过渡到圆角,没有折痕。",
|
||||
@@ -4986,6 +5023,7 @@
|
||||
"ui.heading_construct": "构造",
|
||||
"ui.heading_create": "创建",
|
||||
"ui.heading_create_sketch": "创建草图",
|
||||
"ui.heading_custom_view_navigator": "自定义视图导航器",
|
||||
"ui.heading_cylinder": "圆柱体",
|
||||
"ui.heading_design": "设计",
|
||||
"ui.heading_draft": "拔模",
|
||||
@@ -5581,6 +5619,14 @@
|
||||
"values must be numbers or expressions": "值必须是数字或表达式",
|
||||
"view.auto_ortho_faces": "旋转时透视,正对表面时正交",
|
||||
"view.render_inspector": "渲染检查器",
|
||||
"view_gizmo.arrows": "旋转箭头",
|
||||
"view_gizmo.home": "主页按钮",
|
||||
"view_gizmo.no_such_body": "此设计中没有该名称的实体。",
|
||||
"view_gizmo.note": "在视图中选择一个实体。它随首选项和设置配置文件保存,并在每个设计中显示。单击它的一个面即可正视该面。",
|
||||
"view_gizmo.texture": "纹理",
|
||||
"view_gizmo.themed": "主题",
|
||||
"view_gizmo.too_detailed": "对视图导航器而言过于精细(超过 10,000 个三角形)",
|
||||
"view_gizmo.too_thin": "对视图导航器而言过于细长:最薄的一边至少要有最长一边的五分之一",
|
||||
"web.files.insert_drawings_hint": "将 CAD 文件、网格、DXF 或 SVG 插入当前设计",
|
||||
"web.files.insert_stored_drawings_hint": "插入当前设计(CAD 文件、网格、DXF、SVG)",
|
||||
"web.open_full": "在 SolidCraft 中打开",
|
||||
|
||||
@@ -802,7 +802,9 @@ fn vs_line(@builtin(vertex_index) vi: u32, @location(0) a: vec3<f32>, @location(
|
||||
let sb = cb.xy / max(cb.w, 1e-6) * half;
|
||||
var dir = sb - sa;
|
||||
let len = length(dir);
|
||||
if (len < 1e-6) { dir = vec2<f32>(1.0, 0.0); } else { dir = dir / len; }
|
||||
// Intel OpenGL can miscompile vector/scalar division here (#8053). Scalar
|
||||
// quotients keep the same direction and the fallback for a point.
|
||||
if (len < 1e-6) { dir = vec2<f32>(1.0, 0.0); } else { dir = vec2<f32>(dir.x / len, dir.y / len); }
|
||||
let nrm = vec2<f32>(-dir.y, dir.x);
|
||||
// Two triangles: corners (t, side).
|
||||
var ts = array<f32, 6>(0.0, 1.0, 1.0, 0.0, 1.0, 0.0);
|
||||
|
||||
@@ -0,0 +1,667 @@
|
||||
{
|
||||
"order": 260,
|
||||
"provenance": "Original SolidCraft stone-grey palette and semantic icon-role mapping",
|
||||
"format": "solidcraft-theme/1",
|
||||
"name": "Onyx",
|
||||
"base": "dark",
|
||||
"colors": {
|
||||
"accent": "#9eafbd",
|
||||
"accent_soft": "#3b4852",
|
||||
"agent_busy": "#9eafbd",
|
||||
"agent_ok": "#a5b9a5",
|
||||
"app_bar": "#1c1e20",
|
||||
"app_bar_hover": "#dededc16",
|
||||
"app_bar_hover_button": "#dededc1c",
|
||||
"app_bar_hover_strong": "#dededc28",
|
||||
"app_bar_hover_subtle": "#dededc0c",
|
||||
"app_bar_separator": "#383c3f",
|
||||
"app_bar_tab_active": "#303235",
|
||||
"app_bar_text": "#dededc",
|
||||
"axis_x": "#d7938f",
|
||||
"axis_y": "#a5b9a5",
|
||||
"axis_z": "#98b2c8",
|
||||
"bend_down": "#b8a7c3",
|
||||
"bend_up": "#a5b9a5",
|
||||
"body": "#93989c",
|
||||
"body_edge": "#60666b",
|
||||
"body_selected": "#3b4852",
|
||||
"border": "#3d4145",
|
||||
"box_window": "#9eafbd",
|
||||
"chip": "#758b9b",
|
||||
"construction_geom": "#b9a78e",
|
||||
"construction_plane": "#b9a78e24",
|
||||
"continuity": [
|
||||
"#a5b9a5",
|
||||
"#98b2c8",
|
||||
"#c7b28b",
|
||||
"#b8a7c3"
|
||||
],
|
||||
"control_border": "#4a4f54",
|
||||
"control_fill": "#303235",
|
||||
"cube_border": "#50565b",
|
||||
"cube_face": "#34373a",
|
||||
"cube_hover": "#53616c",
|
||||
"cube_text": "#dededc",
|
||||
"dialog_bg": "#2b2d2f",
|
||||
"dialog_body": "#2b2d2f",
|
||||
"dialog_border": "#3d4145",
|
||||
"dialog_footer": "#27292b",
|
||||
"dialog_label": "#dededc",
|
||||
"dialog_rule": "#383c3f",
|
||||
"dimension": "#c7b28b",
|
||||
"dimension_driven": "#a59474",
|
||||
"error": "#d7938f",
|
||||
"field": "#1c1e20",
|
||||
"form_accent": "#9d8faa",
|
||||
"form_dim": "#c6c8c9",
|
||||
"form_error": "#d7938f",
|
||||
"form_error_fill": "#493334",
|
||||
"form_fill": "#b9abc5",
|
||||
"form_ink": "#dededc",
|
||||
"form_primary": "#9eafbd",
|
||||
"form_primary_fill": "#9eafbd",
|
||||
"form_soft": "#343039",
|
||||
"form_success": "#a5b9a5",
|
||||
"form_success_fill": "#344139",
|
||||
"form_tab_ink": "#b9abc5",
|
||||
"glyph_bg": "#34373a",
|
||||
"glyph_edge": "#d7938f",
|
||||
"glyph_text": "#d7938f",
|
||||
"grid": "#252729",
|
||||
"grid_major": "#2e3235",
|
||||
"hover": "#3b4852",
|
||||
"hover_edge": "#b1c1ce",
|
||||
"hover_edge_halo": "#181a1c",
|
||||
"hover_profile": "#9eafbd30",
|
||||
"hover_profile_edge": "#b1c1ce",
|
||||
"icon": "#dededc",
|
||||
"icons.agent": "#b8a7c3",
|
||||
"icons.highlight": "#dededc",
|
||||
"icons.marker_halo": "#1c1e20cc",
|
||||
"interference": "#d7938f",
|
||||
"joint_axis": "#98b2c8",
|
||||
"joint_manipulator": "#9eafbd",
|
||||
"logo": [
|
||||
"#c9d4dc",
|
||||
"#9eafbd",
|
||||
"#647986"
|
||||
],
|
||||
"manipulator": "#9eafbd",
|
||||
"mesh_accent": "#a29772",
|
||||
"mesh_fill": "#beb48d",
|
||||
"mesh_ink": "#beb48d",
|
||||
"mesh_soft": "#34332b",
|
||||
"nav_dim": "#b5b7b8",
|
||||
"nav_field": "#1c1e20",
|
||||
"nav_hover": "#43494f",
|
||||
"nav_press": "#59636c",
|
||||
"nav_tip": "#2b2d2f",
|
||||
"nav_tip_ink": "#dededc",
|
||||
"nav_tip_key_border": "#3b3e41",
|
||||
"nav_viewport": "#27292b",
|
||||
"on_accent": "#17191b",
|
||||
"origin_plane": "#b9a78e2c",
|
||||
"origin_plane_edge": "#b9a78e94",
|
||||
"origin_plane_hover": "#9eafbd60",
|
||||
"origin_point": "#b5b7b8",
|
||||
"overlay": "#2b2d2f",
|
||||
"panel": "#2b2d2f",
|
||||
"panel_header": "#303235",
|
||||
"preview_add": "#9eafbd",
|
||||
"preview_cut": "#d7938f68",
|
||||
"preview_new": "#c7b28b",
|
||||
"preview_sketch": "#a5b9a5",
|
||||
"profile_fill": "#9eafbd28",
|
||||
"render_accent": "#a88e88",
|
||||
"render_fill": "#c1a49e",
|
||||
"render_ink": "#c1a49e",
|
||||
"render_soft": "#38302f",
|
||||
"rubber_band": "#9eafbd",
|
||||
"section_cap": "#c7b28b",
|
||||
"sel_edge": "#b1c1ce",
|
||||
"sel_edge_rim": "#d0d9df",
|
||||
"sel_face": "#3b4852",
|
||||
"sel_profile": "#3b4852",
|
||||
"sel_profile_edge": "#b1c1ce",
|
||||
"sel_vertex": "#9eafbd",
|
||||
"selected_bg": "#3b4852",
|
||||
"selected_ink": "#dededc",
|
||||
"sheet_fill": "#9eb9bd",
|
||||
"sheet_ink": "#9eb9bd",
|
||||
"sheet_soft": "#29363a",
|
||||
"sketch": "#9eafbd",
|
||||
"sketch_accent": "#b2b995",
|
||||
"sketch_centerline": "#b5b7b8",
|
||||
"sketch_conflict": "#d7938f",
|
||||
"sketch_construction": "#b5b7b8",
|
||||
"sketch_fixed": "#dededc",
|
||||
"sketch_halo": "#181a1c",
|
||||
"sketch_ink": "#b2b995",
|
||||
"sketch_locked": "#a5b9a5",
|
||||
"sketch_lost": "#d7938f",
|
||||
"sketch_point": "#dededc",
|
||||
"sketch_point_free": "#9eafbd",
|
||||
"sketch_projected": "#b8a7c3",
|
||||
"sketch_soft": "#32352c",
|
||||
"surface_accent": "#a6947d",
|
||||
"surface_fill": "#c0ae96",
|
||||
"surface_ink": "#c0ae96",
|
||||
"surface_soft": "#35322e",
|
||||
"tab_on_fill_dark": "#17191b",
|
||||
"text": "#dededc",
|
||||
"text_dim": "#c6c8c9",
|
||||
"timeline": "#2b2d2f",
|
||||
"timeline_item": "#303235",
|
||||
"timeline_item_error": "#493334",
|
||||
"timeline_marker": "#b5b7b8",
|
||||
"toolbar": "#2b2d2f",
|
||||
"viewport_bottom": "#1b1d1f",
|
||||
"viewport_top": "#222426",
|
||||
"warning": "#c7b28b"
|
||||
},
|
||||
"radii": {
|
||||
"radius_sm": 3,
|
||||
"radius_md": 6,
|
||||
"radius_lg": 8,
|
||||
"radius_xl": 10,
|
||||
"control_radius": 4,
|
||||
"sketch_mode_radius": 6
|
||||
},
|
||||
"icon_palette": {
|
||||
"ink": "#1b1d1f",
|
||||
"line": "#d7d9d8",
|
||||
"passive-1": "#b9bdc0",
|
||||
"passive-2": "#8d9398",
|
||||
"passive-3": "#646c72",
|
||||
"guide": "#a2a8ac",
|
||||
"solid": [
|
||||
"#c9d4dc",
|
||||
"#b2c1cc",
|
||||
"#9eafbd"
|
||||
],
|
||||
"surface": [
|
||||
"#d6cab8",
|
||||
"#c0ae96",
|
||||
"#a6947d"
|
||||
],
|
||||
"mesh": [
|
||||
"#d4ccad",
|
||||
"#beb48d",
|
||||
"#a29772"
|
||||
],
|
||||
"form": [
|
||||
"#d0c5d9",
|
||||
"#b9abc5",
|
||||
"#9d8faa"
|
||||
],
|
||||
"sketch": [
|
||||
"#c9cfb3",
|
||||
"#b2b995",
|
||||
"#979f78"
|
||||
],
|
||||
"constraint": [
|
||||
"#e1b7b2",
|
||||
"#d7938f",
|
||||
"#b77875"
|
||||
],
|
||||
"construction": [
|
||||
"#d2c6b4",
|
||||
"#b9a78e",
|
||||
"#9f8c73"
|
||||
]
|
||||
},
|
||||
"options": [
|
||||
{
|
||||
"id": "opacity",
|
||||
"type": "range",
|
||||
"default": 80,
|
||||
"min": 60,
|
||||
"max": 100
|
||||
},
|
||||
{
|
||||
"id": "blur",
|
||||
"type": "range",
|
||||
"default": 60,
|
||||
"max": 60
|
||||
},
|
||||
{
|
||||
"id": "tab_colours",
|
||||
"type": "bool",
|
||||
"default": false
|
||||
},
|
||||
{
|
||||
"id": "tab_fade_ms",
|
||||
"type": "range",
|
||||
"default": 240
|
||||
},
|
||||
{
|
||||
"id": "accent",
|
||||
"type": "choice",
|
||||
"default": "slate",
|
||||
"label": {
|
||||
"en": "Accent",
|
||||
"de": "Akzent",
|
||||
"ja": "アクセント",
|
||||
"ko": "강조색",
|
||||
"zh": "强调色"
|
||||
},
|
||||
"help": {
|
||||
"en": "Selection, highlights and Model controls; workspace and status colours keep their meaning.",
|
||||
"de": "Auswahl, Hervorhebungen und Modell-Steuerung; Arbeitsbereichs- und Statusfarben behalten ihre Bedeutung.",
|
||||
"ja": "選択・強調・モデルの操作色。ワークスペースや状態の色の意味は変えません。",
|
||||
"ko": "선택, 강조 및 모델 조작의 색상입니다. 작업 공간과 상태 색상의 의미는 유지됩니다.",
|
||||
"zh": "更改选择、高亮与模型控件的色调;工作空间和状态色保持原有含义。"
|
||||
},
|
||||
"values": [
|
||||
{
|
||||
"id": "slate",
|
||||
"label": {
|
||||
"en": "Slate blue",
|
||||
"de": "Schieferblau",
|
||||
"ja": "石板の青",
|
||||
"ko": "슬레이트 블루",
|
||||
"zh": "岩蓝"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "silver",
|
||||
"label": {
|
||||
"en": "Silver",
|
||||
"de": "Silber",
|
||||
"ja": "シルバー",
|
||||
"ko": "은색",
|
||||
"zh": "银灰"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "sage",
|
||||
"label": {
|
||||
"en": "Sage",
|
||||
"de": "Salbei",
|
||||
"ja": "セージ",
|
||||
"ko": "세이지",
|
||||
"zh": "鼠尾草绿"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "contrast",
|
||||
"type": "choice",
|
||||
"default": "soft",
|
||||
"label": {
|
||||
"en": "Contrast",
|
||||
"de": "Kontrast",
|
||||
"ja": "コントラスト",
|
||||
"ko": "대비",
|
||||
"zh": "对比度"
|
||||
},
|
||||
"help": {
|
||||
"en": "Soft stone layers or stronger separation between panels and fields.",
|
||||
"de": "Weiche Steinschichten oder stärkere Trennung von Flächen und Feldern.",
|
||||
"ja": "柔らかな石の階調か、パネルと入力欄を明確に分ける階調を選びます。",
|
||||
"ko": "부드러운 석재 톤 또는 패널과 입력란을 더 뚜렷하게 구분합니다.",
|
||||
"zh": "选择柔和的石色层次,或增强面板与输入框的区分。"
|
||||
},
|
||||
"values": [
|
||||
{
|
||||
"id": "soft",
|
||||
"label": {
|
||||
"en": "Soft",
|
||||
"de": "Weich",
|
||||
"ja": "ソフト",
|
||||
"ko": "부드럽게",
|
||||
"zh": "柔和"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "defined",
|
||||
"label": {
|
||||
"en": "Defined",
|
||||
"de": "Deutlich",
|
||||
"ja": "くっきり",
|
||||
"ko": "뚜렷하게",
|
||||
"zh": "分明"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "brightness",
|
||||
"type": "range",
|
||||
"effect": "surface_brightness",
|
||||
"default": 100,
|
||||
"min": 60,
|
||||
"max": 120,
|
||||
"step": 1,
|
||||
"unit": "%",
|
||||
"label": {
|
||||
"en": "Brightness",
|
||||
"de": "Helligkeit",
|
||||
"ja": "明るさ",
|
||||
"ko": "밝기",
|
||||
"zh": "亮度"
|
||||
},
|
||||
"help": {
|
||||
"en": "Adjust stone surfaces, viewport backgrounds and grid brightness. 100% keeps the authored tones; text and semantic colours stay unchanged.",
|
||||
"de": "Helligkeit von Steinflächen, Ansichtsflächen und Raster. 100% erhält die ursprünglichen Töne; Text und Bedeutungsfarben bleiben unverändert.",
|
||||
"ja": "石調の面・ビューポート背景・グリッドの明るさを調整します。100%は元の階調です。文字と意味のある色は変えません。",
|
||||
"ko": "석재 표면·뷰포트 배경·격자의 밝기를 조절합니다. 100%는 원래 톤을 유지하며 글자와 의미 색상은 바꾸지 않습니다.",
|
||||
"zh": "调整石色表面、视口背景和网格的亮度。100%保留原有色调;文字和语义颜色保持不变。"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "foreground_colours",
|
||||
"type": "choice",
|
||||
"default": "vibrant",
|
||||
"label": {
|
||||
"en": "Foreground colours",
|
||||
"de": "Vordergrundfarben",
|
||||
"ja": "前景の色",
|
||||
"ko": "전경 색상",
|
||||
"zh": "前景颜色"
|
||||
},
|
||||
"help": {
|
||||
"en": "Vibrant strengthens axes, icons and guides. Muted keeps the original quiet palette. Accent controls selection.",
|
||||
"de": "Leuchtend verstärkt Achsen, Symbole und Hilfen. Gedämpft behält die ruhige ursprüngliche Palette. Akzent steuert die Auswahl.",
|
||||
"ja": "鮮やかは軸・アイコン・作図ガイドを強調します。落ち着いた配色は元の控えめな色を保ちます。選択の色はアクセントで設定します。",
|
||||
"ko": "선명함은 축·아이콘·가이드를 강조합니다. 차분함은 기존의 은은한 팔레트를 유지합니다. 선택 색상은 강조색 설정을 따릅니다.",
|
||||
"zh": "鲜艳增强轴、图标和辅助线的色彩。柔和保留原有的沉静配色。选择颜色仍由强调色控制。"
|
||||
},
|
||||
"values": [
|
||||
{
|
||||
"id": "muted",
|
||||
"label": {
|
||||
"en": "Muted",
|
||||
"de": "Gedämpft",
|
||||
"ja": "落ち着いた",
|
||||
"ko": "차분함",
|
||||
"zh": "柔和"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "vibrant",
|
||||
"label": {
|
||||
"en": "Vibrant",
|
||||
"de": "Leuchtend",
|
||||
"ja": "鮮やか",
|
||||
"ko": "선명함",
|
||||
"zh": "鲜艳"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"overlays": [
|
||||
{
|
||||
"when": {
|
||||
"opt": "contrast",
|
||||
"eq": "defined"
|
||||
},
|
||||
"colors": {
|
||||
"app_bar": "#151719",
|
||||
"panel": "#25282a",
|
||||
"dialog_bg": "#25282a",
|
||||
"dialog_body": "#25282a",
|
||||
"dialog_footer": "#202325",
|
||||
"panel_header": "#373b3e",
|
||||
"field": "#171a1c",
|
||||
"control_fill": "#383d41",
|
||||
"border": "#4e555b",
|
||||
"dialog_border": "#4e555b",
|
||||
"dialog_rule": "#444b51",
|
||||
"control_border": "#626c73",
|
||||
"text": "#eeeeec",
|
||||
"text_dim": "#ced1d2",
|
||||
"form_ink": "#eeeeec",
|
||||
"form_dim": "#ced1d2",
|
||||
"dialog_label": "#eeeeec",
|
||||
"toolbar": "#25282a",
|
||||
"timeline": "#25282a"
|
||||
}
|
||||
},
|
||||
{
|
||||
"when": {
|
||||
"opt": "accent",
|
||||
"eq": "silver"
|
||||
},
|
||||
"colors": {
|
||||
"accent": "#b7bdc2",
|
||||
"manipulator": "#b7bdc2",
|
||||
"joint_manipulator": "#b7bdc2",
|
||||
"preview_add": "#b7bdc2",
|
||||
"rubber_band": "#b7bdc2",
|
||||
"box_window": "#b7bdc2",
|
||||
"form_primary": "#b7bdc2",
|
||||
"form_primary_fill": "#b7bdc2",
|
||||
"sketch": "#b7bdc2",
|
||||
"sketch_point_free": "#b7bdc2",
|
||||
"sel_vertex": "#b7bdc2",
|
||||
"accent_soft": "#454b50",
|
||||
"hover": "#454b50",
|
||||
"selected_bg": "#454b50",
|
||||
"sel_face": "#454b50",
|
||||
"sel_profile": "#454b50",
|
||||
"body_selected": "#454b50",
|
||||
"sel_edge": "#d5dadf",
|
||||
"sel_profile_edge": "#d5dadf",
|
||||
"hover_edge": "#d5dadf",
|
||||
"hover_profile_edge": "#d5dadf",
|
||||
"sel_edge_rim": "#dde0e3",
|
||||
"hover_profile": "#b7bdc230",
|
||||
"origin_plane_hover": "#b7bdc260",
|
||||
"profile_fill": "#b7bdc228",
|
||||
"cube_hover": "#454b50",
|
||||
"chip": "#b7bdc2",
|
||||
"logo": [
|
||||
"#dde0e3",
|
||||
"#c9cfd4",
|
||||
"#b7bdc2"
|
||||
]
|
||||
},
|
||||
"icon_palette": {
|
||||
"solid": [
|
||||
"#dde0e3",
|
||||
"#c9cfd4",
|
||||
"#b7bdc2"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"when": {
|
||||
"opt": "accent",
|
||||
"eq": "sage"
|
||||
},
|
||||
"colors": {
|
||||
"accent": "#aabcae",
|
||||
"manipulator": "#aabcae",
|
||||
"joint_manipulator": "#aabcae",
|
||||
"preview_add": "#aabcae",
|
||||
"rubber_band": "#aabcae",
|
||||
"box_window": "#aabcae",
|
||||
"form_primary": "#aabcae",
|
||||
"form_primary_fill": "#aabcae",
|
||||
"sketch": "#aabcae",
|
||||
"sketch_point_free": "#aabcae",
|
||||
"sel_vertex": "#aabcae",
|
||||
"accent_soft": "#414d45",
|
||||
"hover": "#414d45",
|
||||
"selected_bg": "#414d45",
|
||||
"sel_face": "#414d45",
|
||||
"sel_profile": "#414d45",
|
||||
"body_selected": "#414d45",
|
||||
"sel_edge": "#c2d2c5",
|
||||
"sel_profile_edge": "#c2d2c5",
|
||||
"hover_edge": "#c2d2c5",
|
||||
"hover_profile_edge": "#c2d2c5",
|
||||
"sel_edge_rim": "#d1ddd4",
|
||||
"hover_profile": "#aabcae30",
|
||||
"origin_plane_hover": "#aabcae60",
|
||||
"profile_fill": "#aabcae28",
|
||||
"cube_hover": "#414d45",
|
||||
"chip": "#aabcae",
|
||||
"logo": [
|
||||
"#d1ddd4",
|
||||
"#becec2",
|
||||
"#aabcae"
|
||||
]
|
||||
},
|
||||
"icon_palette": {
|
||||
"solid": [
|
||||
"#d1ddd4",
|
||||
"#becec2",
|
||||
"#aabcae"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"when": {
|
||||
"opt": "foreground_colours",
|
||||
"eq": "vibrant"
|
||||
},
|
||||
"colors": {
|
||||
"axis_x": "#ff6d79",
|
||||
"axis_y": "#4fd681",
|
||||
"axis_z": "#4da7ff",
|
||||
"agent_busy": "#4da7ff",
|
||||
"agent_ok": "#4fd681",
|
||||
"warning": "#ffd166",
|
||||
"error": "#ff6d79",
|
||||
"form_error": "#ff6d79",
|
||||
"form_success": "#4fd681",
|
||||
"glyph_edge": "#ff6d79",
|
||||
"glyph_text": "#ff6d79",
|
||||
"interference": "#ff6d79",
|
||||
"sketch_conflict": "#ff6d79",
|
||||
"sketch_lost": "#ff6d79",
|
||||
"sketch_locked": "#4fd681",
|
||||
"sketch_projected": "#c78bff",
|
||||
"dimension": "#ffd166",
|
||||
"dimension_driven": "#ffb166",
|
||||
"construction_geom": "#ffb166",
|
||||
"sketch_centerline": "#b5b7b8",
|
||||
"sketch_construction": "#b5b7b8",
|
||||
"joint_axis": "#42d9dd",
|
||||
"bend_down": "#c78bff",
|
||||
"bend_up": "#4fd681",
|
||||
"preview_cut": "#ff6d7968",
|
||||
"preview_new": "#ffd166",
|
||||
"preview_sketch": "#4fd681",
|
||||
"section_cap": "#ffd166",
|
||||
"sketch_accent": "#4fd681",
|
||||
"sketch_ink": "#4fd681",
|
||||
"surface_accent": "#ffb166",
|
||||
"surface_fill": "#ffb166",
|
||||
"surface_ink": "#ffb166",
|
||||
"mesh_accent": "#ffd166",
|
||||
"mesh_fill": "#ffd166",
|
||||
"mesh_ink": "#ffd166",
|
||||
"form_accent": "#c78bff",
|
||||
"form_fill": "#c78bff",
|
||||
"form_tab_ink": "#c78bff",
|
||||
"sheet_fill": "#63cbd5",
|
||||
"sheet_ink": "#63cbd5",
|
||||
"render_accent": "#ff6d79",
|
||||
"render_fill": "#ff6d79",
|
||||
"render_ink": "#ff6d79",
|
||||
"icons.agent": "#c78bff",
|
||||
"continuity": [
|
||||
"#4fd681",
|
||||
"#4da7ff",
|
||||
"#ffd166",
|
||||
"#c78bff"
|
||||
],
|
||||
"origin_plane_edge": "#ffb16694",
|
||||
"sheet_soft": "#283a3e"
|
||||
},
|
||||
"icon_palette": {
|
||||
"ink": "#1b1d1f",
|
||||
"line": "#d7d9d8",
|
||||
"passive-1": "#d3d8dc",
|
||||
"passive-2": "#b2bbc2",
|
||||
"passive-3": "#929fa9",
|
||||
"guide": "#b4c8d6",
|
||||
"solid": [
|
||||
"#82c1ff",
|
||||
"#62b2ff",
|
||||
"#4da7ff"
|
||||
],
|
||||
"surface": [
|
||||
"#ffc894",
|
||||
"#ffba78",
|
||||
"#ffb166"
|
||||
],
|
||||
"mesh": [
|
||||
"#ffdf94",
|
||||
"#ffd778",
|
||||
"#ffd166"
|
||||
],
|
||||
"form": [
|
||||
"#d8aeff",
|
||||
"#ce99ff",
|
||||
"#c78bff"
|
||||
],
|
||||
"sketch": [
|
||||
"#84e2a7",
|
||||
"#64db90",
|
||||
"#4fd681"
|
||||
],
|
||||
"constraint": [
|
||||
"#ff99a1",
|
||||
"#ff7f89",
|
||||
"#ff6d79"
|
||||
],
|
||||
"construction": [
|
||||
"#ffc894",
|
||||
"#ffba78",
|
||||
"#ffb166"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"tabs": {
|
||||
"MODEL": {
|
||||
"viewport_top": "#24292d",
|
||||
"viewport_bottom": "#1b2023",
|
||||
"panel": "#303438"
|
||||
},
|
||||
"SURFACE": {
|
||||
"viewport_top": "#292622",
|
||||
"viewport_bottom": "#211e1b",
|
||||
"panel": "#36332f"
|
||||
},
|
||||
"MESH": {
|
||||
"viewport_top": "#282820",
|
||||
"viewport_bottom": "#202019",
|
||||
"panel": "#35352e"
|
||||
},
|
||||
"FORM": {
|
||||
"viewport_top": "#26232b",
|
||||
"viewport_bottom": "#1e1b22",
|
||||
"panel": "#343137"
|
||||
},
|
||||
"SHEET METAL": {
|
||||
"viewport_top": "#22282a",
|
||||
"viewport_bottom": "#1b2022",
|
||||
"panel": "#303638"
|
||||
},
|
||||
"RENDER": {
|
||||
"viewport_top": "#2b2423",
|
||||
"viewport_bottom": "#221c1b",
|
||||
"panel": "#373130"
|
||||
},
|
||||
"SKETCH": {
|
||||
"viewport_top": "#252921",
|
||||
"viewport_bottom": "#1d211a",
|
||||
"panel": "#33362f"
|
||||
}
|
||||
},
|
||||
"borders": {
|
||||
"primary_inset": {
|
||||
"width": 0.5,
|
||||
"gap": 1,
|
||||
"color": "#dce2e70e"
|
||||
}
|
||||
},
|
||||
"requires": 2
|
||||
}
|
||||
@@ -734,6 +734,10 @@ pub enum FeatureKind {
|
||||
/// from the vertex instead of where the rounds meet (a constant-radius fillet only).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
setbacks: Vec<CornerSetback>,
|
||||
/// How two rounds turn where they meet at a sharp side edge: in a mitre (the default) or
|
||||
/// swept round it (a constant-radius, exact, rolling-corner fillet without setbacks only).
|
||||
#[serde(default, skip_serializing_if = "FilletTurn::is_mitre")]
|
||||
turn: FilletTurn,
|
||||
/// Round each picked edge's tangent chain with it (the default: edges continuing it
|
||||
/// smoothly at its ends, as far as they go), or only the picked edges.
|
||||
#[serde(default = "yes", skip_serializing_if = "is_yes")]
|
||||
@@ -1971,6 +1975,38 @@ impl FilletCorner {
|
||||
}
|
||||
}
|
||||
|
||||
/// How two constant rounds of one radius 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.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum FilletTurn {
|
||||
/// The rounds run on to where they cross and meet in a mitre.
|
||||
#[default]
|
||||
Mitre,
|
||||
/// The rounds end at the side edge and the corner is their section revolved about it, a
|
||||
/// torus tangent to both (`solidcraft_kernel::FilletTurn::Swept`).
|
||||
Swept,
|
||||
}
|
||||
|
||||
/// Why a swept turn is refused with a fillet's other options: the kernel sweeps the turn of two
|
||||
/// constant rounds of one radius, at its own corners only.
|
||||
pub const SWEPT_TAKES: &str = "a swept corner turns two constant rounds of one radius: `turn: swept` takes `type: constant`, \
|
||||
`corner: rolling`, no `setbacks` and `fit: exact` or `reduce`";
|
||||
|
||||
impl FilletTurn {
|
||||
pub fn is_mitre(&self) -> bool {
|
||||
*self == FilletTurn::Mitre
|
||||
}
|
||||
|
||||
/// The kernel's turn.
|
||||
pub fn kernel(self) -> solidcraft_kernel::FilletTurn {
|
||||
match self {
|
||||
FilletTurn::Mitre => solidcraft_kernel::FilletTurn::Mitre,
|
||||
FilletTurn::Swept => solidcraft_kernel::FilletTurn::Swept,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How Extend continues a surface past its edges.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
@@ -2374,6 +2410,12 @@ pub enum HoleKind {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[walk(angle = "Tip angle")]
|
||||
tip_angle: Option<Expr>,
|
||||
/// Printing aid: two bridge layers this thick below the counterbore's floor (a slot along
|
||||
/// X, then a square), so a printer bridges the step instead of sagging into it. Absent:
|
||||
/// a plain counterbore.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[walk(length = "Bridge layer")]
|
||||
bridge: Option<Expr>,
|
||||
},
|
||||
Countersink {
|
||||
#[walk(length = "Countersink diameter")]
|
||||
|
||||
@@ -409,13 +409,14 @@ fn fillet_fitted(
|
||||
edges: &[Vec3],
|
||||
r: f64,
|
||||
fit: crate::FilletFit,
|
||||
turn: kernel::FilletTurn,
|
||||
) -> std::result::Result<(kernel::Body, Option<Vec<f64>>, Vec<Vec3>), kernel::KernelError> {
|
||||
match kernel::fillet_removing(body, edges, r) {
|
||||
match kernel::fillet_removing(body, edges, r, turn) {
|
||||
Ok((b, gone)) => Ok((b, None, gone)),
|
||||
Err(kernel::KernelError::TooLarge(why)) if fit == crate::FilletFit::Exact => {
|
||||
Err(with_largest_fit(why, "radius", || kernel::fillet_max(body, edges, r)))
|
||||
Err(with_largest_fit(why, "radius", || kernel::fillet_max(body, edges, r, turn)))
|
||||
}
|
||||
Err(kernel::KernelError::TooLarge(why)) if fit == crate::FilletFit::Reduce => match kernel::fillet_max_each(body, edges, r)? {
|
||||
Err(kernel::KernelError::TooLarge(why)) if fit == crate::FilletFit::Reduce => match kernel::fillet_max_each(body, edges, r, turn)? {
|
||||
// Built at the radius asked for after all (the groups apart): nothing reduced.
|
||||
Some((used, b)) => Ok((b, used.iter().any(|u| *u < r).then_some(used), Vec::new())),
|
||||
None => Err(kernel::KernelError::TooLarge(why)),
|
||||
@@ -4014,6 +4015,11 @@ fn blend_body(
|
||||
) -> Result<(Traced, Option<Vec<f64>>, Tapered)> {
|
||||
let input = mb.traced();
|
||||
let (mut used, mut tapered) = (None, Vec::new());
|
||||
if let FeatureKind::Fillet { turn: crate::FilletTurn::Swept, style, fit, corner, setbacks, .. } = &f.kind
|
||||
&& (!style.is_constant() || *fit == crate::FilletFit::Taper || !corner.is_rolling() || !setbacks.is_empty())
|
||||
{
|
||||
return Err(DocError::Invalid(crate::SWEPT_TAKES.into()));
|
||||
}
|
||||
// The blended body, and the lineage of what the last blend started from.
|
||||
let (nb, from) = match &f.kind {
|
||||
// A curvature-continuous fillet's corners are G2 patches, whatever `corner` says: SMLib's
|
||||
@@ -4061,8 +4067,8 @@ fn blend_body(
|
||||
tapered = edges.iter().copied().zip(tapers).filter_map(|(p, t)| Some((p, t?))).collect();
|
||||
(b, input)
|
||||
}
|
||||
FeatureKind::Fillet { fit, .. } => {
|
||||
let (b, u, gone) = fillet_fitted(&mb.body, edges, r, *fit)?;
|
||||
FeatureKind::Fillet { fit, turn, .. } => {
|
||||
let (b, u, gone) = fillet_fitted(&mb.body, edges, r, *fit, turn.kernel())?;
|
||||
if let Some(u) = &u {
|
||||
append_warning(warning, reduced_note(u, r));
|
||||
}
|
||||
@@ -4629,13 +4635,26 @@ fn hole_tools_and_prisms(
|
||||
match hole {
|
||||
HoleKind::Simple => tools.extend(bore(None)?),
|
||||
HoleKind::Drilled { tip_angle } => tools.extend(bore(Some(tip_angle))?),
|
||||
HoleKind::Counterbore { cb_diameter, cb_depth, tip_angle } => {
|
||||
HoleKind::Counterbore { cb_diameter, cb_depth, tip_angle, bridge } => {
|
||||
let (rc, dc) = (val(vals, cb_diameter, Kind::Length)? / 2.0, val(vals, cb_depth, Kind::Length)?);
|
||||
if !(rc > r && dc > 0.0 && -dc > bottom) {
|
||||
return Err(DocError::Invalid("the counterbore must be wider than the hole and shallower than it".into()));
|
||||
}
|
||||
// Wide and shallow first: the narrow hole then crosses the pocket floor.
|
||||
tools.push(cyl(rc, -dc, top, "cbore")?);
|
||||
if let Some(layer) = bridge {
|
||||
let layer = val(vals, layer, Kind::Length)?;
|
||||
tools.extend(bridge_tools(&plane, r, rc, dc, bottom, layer)?);
|
||||
// Through all, `bottom` is the tool's reach, not the material's: the second layer
|
||||
// must end inside a body (exact classification), short of the exit face.
|
||||
if depth.is_none() {
|
||||
let past = *position + dir * (dc + 2.0 * layer + size * 1e-6);
|
||||
let classes = st.bodies.iter().map(|b| b.body.classify_point(past)).collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
if !classes.contains(&kernel::PointClass::Inside) {
|
||||
return Err(bridge_too_deep(layer, "make the counterbore shallower or the bridge thinner"));
|
||||
}
|
||||
}
|
||||
}
|
||||
tools.extend(bore(tip_angle.as_ref())?);
|
||||
}
|
||||
HoleKind::Countersink { cs_diameter, cs_angle, tip_angle } => {
|
||||
@@ -4664,6 +4683,59 @@ fn hole_tools_and_prisms(
|
||||
Ok(tools)
|
||||
}
|
||||
|
||||
/// How much wider than the hole a bridged counterbore's slot and square are, in mm.
|
||||
const BRIDGE_CLEARANCE: f64 = 0.02;
|
||||
|
||||
/// Why a bridged counterbore's two layers of `layer` were refused: they reach past the hole, and
|
||||
/// `fix` says what to change.
|
||||
fn bridge_too_deep(layer: f64, fix: &str) -> DocError {
|
||||
DocError::Invalid(format!("the two bridge layers ({:.3} mm) must fit in the hole below the counterbore: {fix}", 2.0 * layer))
|
||||
}
|
||||
|
||||
/// The bridge layers of a counterbore printed opening down: below its floor, a slot across the
|
||||
/// whole counterbore, along X (one `layer` thick), then a square (one more), both
|
||||
/// [`BRIDGE_CLEARANCE`] wider than the hole, so each layer spans between supported edges.
|
||||
/// `plane` is the hole's, its normal out of the material; the bore crosses both layers, clear of
|
||||
/// their sides.
|
||||
fn bridge_tools(plane: &Plane, r: f64, rc: f64, dc: f64, bottom: f64, layer: f64) -> Result<Vec<(Traced, Option<more::PrimitivePrism>)>> {
|
||||
if !(layer > 0.0 && layer.is_finite()) {
|
||||
return Err(DocError::Invalid("the bridge layer must be a positive length, such as 0.2 mm".into()));
|
||||
}
|
||||
let (t1, t2) = (-dc - layer, -dc - 2.0 * layer);
|
||||
if t2 <= bottom {
|
||||
return Err(bridge_too_deep(layer, "make the hole deeper or the bridge thinner"));
|
||||
}
|
||||
// Both a hair wider than the hole, so the bore's rim doesn't touch their sides.
|
||||
let q = r + BRIDGE_CLEARANCE / 2.0;
|
||||
// The slot's ends need room on the counterbore's wall past that.
|
||||
if !(rc >= q + BRIDGE_CLEARANCE / 2.0) {
|
||||
return Err(DocError::Invalid(format!("a bridged counterbore must be at least {} mm wider than the hole", 2.0 * BRIDGE_CLEARANCE)));
|
||||
}
|
||||
// The slot along world X where the axis allows it, else along the hole plane's own x.
|
||||
let n = plane.normal();
|
||||
let x = (Vec3::X - n * Vec3::X.dot(n)).normalized().filter(|_| Vec3::X.dot(n).abs() < 0.99).unwrap_or(plane.x);
|
||||
let bp = Plane::new(plane.origin, x, n.cross(x)).ok_or_else(|| DocError::Invalid("hole axis".into()))?;
|
||||
// The slot ends on the counterbore's wall: two lines and two arcs of its circle.
|
||||
let a = (rc * rc - q * q).sqrt();
|
||||
let phi = q.atan2(a);
|
||||
let slot = solidcore_record::Loop2 {
|
||||
segs: vec![
|
||||
solidcore_record::Seg2::Line { a: Vec2::new(-a, -q), b: Vec2::new(a, -q) },
|
||||
solidcore_record::Seg2::Arc { center: Vec2::ZERO, radius: rc, start: -phi, sweep: 2.0 * phi },
|
||||
solidcore_record::Seg2::Line { a: Vec2::new(a, q), b: Vec2::new(-a, q) },
|
||||
solidcore_record::Seg2::Arc { center: Vec2::ZERO, radius: rc, start: std::f64::consts::PI - phi, sweep: 2.0 * phi },
|
||||
],
|
||||
};
|
||||
let square = solidcore_record::Loop2::polygon(&[Vec2::new(-q, -q), Vec2::new(q, -q), Vec2::new(q, q), Vec2::new(-q, q)]);
|
||||
let mut tools = Vec::new();
|
||||
for (outline, lo, hi) in [(slot, t1, -dc), (square, t2, t1)] {
|
||||
let region = Region2 { outer: outline, holes: vec![] };
|
||||
let tool = kernel::extrude(&bp, &[region], lo, hi)?.pop().ok_or_else(|| DocError::Invalid("hole tool".into()))?;
|
||||
tools.push((crate::lineage::part_tool(tool, "bridge"), None));
|
||||
}
|
||||
Ok(tools)
|
||||
}
|
||||
|
||||
/// The angular interval used by both the solid and its interactive extent handles.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct RevolveExtent {
|
||||
@@ -6323,12 +6395,12 @@ fn replay(
|
||||
let nb = match &src.kind {
|
||||
FeatureKind::Chamfer { distance2, angle, flip, .. } => chamfer_with(vals, &mb.body, &moved, r, distance2, angle, *flip)?,
|
||||
// A copy is congruent to the original: the radius it was reduced to first.
|
||||
FeatureKind::Fillet { fit, .. } => match st.fitted.get(&src.id) {
|
||||
FeatureKind::Fillet { fit, turn, .. } => match st.fitted.get(&src.id) {
|
||||
Some(used) => {
|
||||
let used: Vec<f64> = idx.iter().map(|i| used.get(*i).copied().unwrap_or(r)).collect();
|
||||
kernel::fillet_each(&mb.body, &moved, &used)?
|
||||
kernel::fillet_each(&mb.body, &moved, &used, turn.kernel())?
|
||||
}
|
||||
None => fillet_fitted(&mb.body, &moved, r, *fit)?.0,
|
||||
None => fillet_fitted(&mb.body, &moved, r, *fit, turn.kernel())?.0,
|
||||
},
|
||||
_ => kernel::fillet(&mb.body, &moved, r)?,
|
||||
};
|
||||
@@ -8550,6 +8622,7 @@ mod chain_tests {
|
||||
fit: Default::default(),
|
||||
corner: Default::default(),
|
||||
setbacks: Vec::new(),
|
||||
turn: Default::default(),
|
||||
tangent_chain: true,
|
||||
},
|
||||
None,
|
||||
@@ -8578,6 +8651,7 @@ mod chain_tests {
|
||||
fit: Default::default(),
|
||||
corner: Default::default(),
|
||||
setbacks: Vec::new(),
|
||||
turn: Default::default(),
|
||||
tangent_chain,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2318,11 +2318,16 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
|
||||
_ => false,
|
||||
};
|
||||
let (Some(_), Some(_), false) = (outer, openings.first(), alike) else {
|
||||
// A solid box top has no opening: Shell that face first, then pick the rim
|
||||
// around the cavity (#10437).
|
||||
let what = match (loops.len(), openings.len()) {
|
||||
(0 | 1, _) => "pick the rim face of a shelled body (a face with an inner and an outer edge)".to_string(),
|
||||
(_, 0) => "pick the rim face of a shelled body: this face's inner edges are where other features stand on it, not an opening"
|
||||
(0 | 1, _) => "Lip needs the rim of a shelled body: Shell a face open first, then pick the rim around that opening (a face with an outer edge and an inner hole)"
|
||||
.to_string(),
|
||||
(_, k) => format!("pick the rim face of a shelled body (round one opening): this face has {k} openings alike in size"),
|
||||
(_, 0) => "Lip needs a rim around an opening: this face's inner edges are where other features stand on it, not a shell cavity. Shell a face open, then pick that rim"
|
||||
.to_string(),
|
||||
(_, k) => format!(
|
||||
"Lip needs one clear opening as the rim: this face has {k} openings alike in size (for example a plate of holes). Shell one cavity, or pick a rim that rings a single opening"
|
||||
),
|
||||
};
|
||||
return Err(DocError::Invalid(what));
|
||||
};
|
||||
|
||||
@@ -103,6 +103,11 @@ enum End {
|
||||
/// At (or within half a pitch of) an end of the face where material goes on past it, a
|
||||
/// shoulder, a head, a blind hole's bottom: the groove stops short, clear of it.
|
||||
Closed,
|
||||
/// At an end of an external thread's face where a shoulder starts and stays beyond the face's
|
||||
/// end: the groove runs on through the plane of that end and is trimmed there, so the root
|
||||
/// reaches the shoulder at every angle, a nut bottoms out on it, and nothing past the plane
|
||||
/// is cut.
|
||||
Shoulder,
|
||||
/// Inside the face: the groove stops square there.
|
||||
Inside,
|
||||
}
|
||||
@@ -121,6 +126,9 @@ pub(super) struct ThreadOn {
|
||||
/// A modelled internal thread in a hole wider than it needs: the radius the bore is narrowed
|
||||
/// to (the thread's minor radius) before the groove is cut, with the hole's own radius.
|
||||
narrow: Option<f64>,
|
||||
/// A shoulder keeps the square stop because the kernel couldn't check whether it stays beyond
|
||||
/// the face's end.
|
||||
shoulder_unchecked: bool,
|
||||
}
|
||||
|
||||
/// The free-end test of a face's two ends (`side` 0 at its start, 1 at its end), each made
|
||||
@@ -160,6 +168,22 @@ impl FaceEnds<'_> {
|
||||
}
|
||||
Ok(free)
|
||||
}
|
||||
|
||||
/// Does the shoulder at this closed end stay beyond the plane of the face's end? Not where it
|
||||
/// rises back along the face (a slanted plate, a boss on a round body), which a groove
|
||||
/// running to that plane would cut into: there is material outside the face just inside it.
|
||||
/// The caller keeps the square stop, and says so, when the kernel can't make the check.
|
||||
fn clear_of_end_plane(&self, side: usize) -> Result<bool> {
|
||||
#[cfg(test)]
|
||||
if tests::FAIL_CLEARANCE.get() {
|
||||
return Err(kernel::KernelError::Failed("clearance refused for the test".into()).into());
|
||||
}
|
||||
let (p, e) = (self.pitch, self.pitch * 1e-3);
|
||||
let inside = if side == 0 { (self.cyl.start + e, self.cyl.start + p) } else { (self.cyl.end - p, self.cyl.end - e) };
|
||||
// All the radii the groove cuts beyond the face; only touching their rims is not in its
|
||||
// way, as in `free`.
|
||||
kernel::material_in_ring(self.body, self.cyl, self.beyond.0 + e, self.beyond.1 - e, inside).map(|hit| !hit).map_err(DocError::from)
|
||||
}
|
||||
}
|
||||
|
||||
/// A thread on the cylindrical face at `p`: its size, and the part of the face it runs over.
|
||||
@@ -333,11 +357,23 @@ pub(super) fn thread_on(st: &ModelState, feature: u64, p: Vec3, ask: &ThreadAsk)
|
||||
// How each end of the thread meets the face: at a face end, or within half a pitch (and its
|
||||
// runout) of one, where the groove would reach past it, that end's free-end test decides.
|
||||
let near = |at: f64, side: usize| if side == 0 { at - lo } else { hi - at } < pitch / 2.0 + ease.get(side).copied().unwrap_or(0.0);
|
||||
// A shoulder whose check the kernel couldn't make keeps the square stop, and the feature says so.
|
||||
let mut unchecked = false;
|
||||
let mut kind = |at: f64, side: usize| -> Result<End> {
|
||||
Ok(if !ask.modeled || !near(at, side) {
|
||||
End::Inside
|
||||
} else if !ends.free(side)? {
|
||||
End::Closed
|
||||
let at_end = (if side == 0 { at - lo } else { hi - at }) < pitch * 1e-3;
|
||||
let square = ease.get(side).copied().unwrap_or(0.0) == 0.0;
|
||||
let clear = at_end && square && !cyl.internal && !form.is_taper() && {
|
||||
let check = ends.clear_of_end_plane(side);
|
||||
unchecked |= matches!(check, Err(DocError::Kernel(_)));
|
||||
match check {
|
||||
Err(DocError::Kernel(_)) => false,
|
||||
other => other?,
|
||||
}
|
||||
};
|
||||
if clear { End::Shoulder } else { End::Closed }
|
||||
} else if (if side == 0 { at - lo } else { hi - at }) < pitch * 1e-3 {
|
||||
End::Free
|
||||
} else {
|
||||
@@ -373,7 +409,7 @@ pub(super) fn thread_on(st: &ModelState, feature: u64, p: Vec3, ask: &ThreadAsk)
|
||||
.then(|| form.groove(major, pitch, true))
|
||||
.filter(|g| !(g.root_r - cyl.radius >= g.basic_depth / 4.0))
|
||||
.map(|g| g.crest_r);
|
||||
Ok(ThreadOn { info, cyl, ends, steep: ask.steep, ease, from: side, narrow })
|
||||
Ok(ThreadOn { info, cyl, ends, steep: ask.steep, ease, from: side, narrow, shoulder_unchecked: unchecked })
|
||||
}
|
||||
|
||||
/// The end of the face (0: its start, 1: its end) a thread of limited length starts at: the end
|
||||
@@ -536,7 +572,9 @@ fn thread_grooves(
|
||||
// Past a free end of the face the groove runs out into the air, a pitch beyond it. Before
|
||||
// a closed one it stops with its section clear of the face's end (its centre as far inside
|
||||
// as the section reaches that way), so it never cuts into the shoulder there; inside the
|
||||
// face it stops square at the threaded length's end.
|
||||
// face it stops square at the threaded length's end. At a shoulder it can be trimmed against
|
||||
// ([`End::Shoulder`]) its whole section runs past the plane of the face's end, so every angle
|
||||
// of the last turn is cut down to that plane ([`trim_at_shoulders`] takes off the rest).
|
||||
//
|
||||
// A runout `e` at an end (ISO 3508) fades the groove out over `e`: its section moves out of
|
||||
// the material (an external thread's away from the axis, past the surface; an internal
|
||||
@@ -548,12 +586,14 @@ fn thread_grooves(
|
||||
(End::Free, false) => t.start - p,
|
||||
(End::Free, true) => t.start,
|
||||
(End::Closed, _) => (t.start - ease[0]).max(face.0 + w_lo),
|
||||
(End::Shoulder, _) => face.0 - w_hi - 0.1 * p,
|
||||
(End::Inside, _) => t.start - ease[0],
|
||||
};
|
||||
let s1 = match (ends[1], ease[1] > 0.0) {
|
||||
(End::Free, false) => t.end + p,
|
||||
(End::Free, true) => t.end,
|
||||
(End::Closed, _) => (t.end + ease[1]).min(face.1 - w_hi),
|
||||
(End::Shoulder, _) => face.1 + w_lo + 0.1 * p,
|
||||
(End::Inside, _) => t.end + ease[1],
|
||||
};
|
||||
if !(s1 - s0 > p * 1e-3) {
|
||||
@@ -698,6 +738,24 @@ fn taper_envelope(t: &ThreadInfo, radius: f64, ends: [End; 2], (d0, from): (f64,
|
||||
Ok(ring.pop())
|
||||
}
|
||||
|
||||
/// The groove with what runs past a shoulder's plane taken off ([`End::Shoulder`]), so cutting it
|
||||
/// leaves the shoulder whole.
|
||||
fn trim_at_shoulders(mut tool: Body, axis: Vec3, ends: [End; 2], face: (f64, f64), k: UnitVec3) -> Result<Body> {
|
||||
for (end, at, keep_positive) in [(ends[0], face.0, true), (ends[1], face.1, false)] {
|
||||
if end != End::Shoulder {
|
||||
continue;
|
||||
}
|
||||
let plane = Plane::from_normal(axis + k * at, *k).ok_or_else(|| DocError::Invalid("thread axis".into()))?;
|
||||
// An error of the cut, like the kernel's own: `cut` then falls back to the square stop.
|
||||
let missed = || kernel::KernelError::Failed("the thread's groove misses its face".into());
|
||||
let kept = kernel::half_space(&tool, &plane, keep_positive)?.0;
|
||||
#[cfg(test)]
|
||||
let kept = kept.filter(|_| !tests::EMPTY_TRIM.get());
|
||||
tool = kept.ok_or_else(missed)?;
|
||||
}
|
||||
Ok(tool)
|
||||
}
|
||||
|
||||
/// Evaluate a thread feature: a cosmetic thread is listed in the model's threads, a modelled
|
||||
/// one cut into its body.
|
||||
pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &mut ModelState, warning: &mut Option<String>) -> Result<()> {
|
||||
@@ -768,7 +826,13 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
|
||||
None => note,
|
||||
});
|
||||
}
|
||||
cut(st, on, (*tip, angle, depth))
|
||||
if let Some(note) = cut(st, on, (*tip, angle, depth))? {
|
||||
*warning = Some(match warning.take() {
|
||||
Some(w) => format!("{w}; {note}"),
|
||||
None => note,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A runout as (mm, pitches): ISO 3508's standard 2.5 P and short 1.25 P, or a length.
|
||||
@@ -783,9 +847,10 @@ pub(super) fn runout(vals: &BTreeMap<String, Value>, e: &Option<crate::ThreadRun
|
||||
|
||||
/// Cut a modelled thread resolved on its face into its body: the grooves (SMLib's exact screw
|
||||
/// sweeps, a taper's crests by its envelope) by one B-rep boolean, then the chamfered tip
|
||||
/// (`tip`, with its angle and depth when given).
|
||||
pub(super) fn cut(st: &mut ModelState, on: ThreadOn, (tip, angle, depth): (ThreadTip, Option<f64>, Option<f64>)) -> Result<()> {
|
||||
let ThreadOn { info: t, cyl, ends, steep, ease, from: from_side, narrow } = on;
|
||||
/// (`tip`, with its angle and depth when given). What the user should be told about the result, if
|
||||
/// anything, is returned.
|
||||
pub(super) fn cut(st: &mut ModelState, on: ThreadOn, (tip, angle, depth): (ThreadTip, Option<f64>, Option<f64>)) -> Result<Option<String>> {
|
||||
let ThreadOn { info: t, cyl, ends, steep, ease, from: from_side, narrow, shoulder_unchecked } = on;
|
||||
let i = st.bodies.iter().position(|b| b.name == t.body).ok_or_else(|| DocError::Invalid("threaded body".into()))?;
|
||||
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
|
||||
let (mut input, radius) = (mb.traced(), cyl.radius);
|
||||
@@ -822,21 +887,46 @@ pub(super) fn cut(st: &mut ModelState, on: ThreadOn, (tip, angle, depth): (Threa
|
||||
}
|
||||
false => None,
|
||||
};
|
||||
let mut grooves = Vec::new();
|
||||
for g in thread_grooves(&t, groove_radius, (cyl.start, cyl.end), ends, steep, ease, taper)? {
|
||||
let section = Region2 { outer: solidcore_record::Loop2::polygon(&g.section), holes: Vec::new() };
|
||||
grooves.push(kernel::sweep_helix_radial(&g.plane, §ion, g.base, g.axis, g.pitch, g.turns, &g.law)?);
|
||||
}
|
||||
if let Some(taper) = taper
|
||||
&& let Some(envelope) = taper_envelope(&t, radius, ends, taper)?
|
||||
{
|
||||
grooves.push(envelope);
|
||||
}
|
||||
let tool = match grooves.as_slice() {
|
||||
[one] => one.clone(),
|
||||
many => kernel::union_all(&many.iter().collect::<Vec<_>>())?,
|
||||
let cut_grooves = |ends: [End; 2]| -> Result<(Option<Body>, usize)> {
|
||||
let mut grooves = Vec::new();
|
||||
for g in thread_grooves(&t, groove_radius, (cyl.start, cyl.end), ends, steep, ease, taper)? {
|
||||
let section = Region2 { outer: solidcore_record::Loop2::polygon(&g.section), holes: Vec::new() };
|
||||
grooves.push(kernel::sweep_helix_radial(&g.plane, §ion, g.base, g.axis, g.pitch, g.turns, &g.law)?);
|
||||
}
|
||||
if let Some(taper) = taper
|
||||
&& let Some(envelope) = taper_envelope(&t, radius, ends, taper)?
|
||||
{
|
||||
grooves.push(envelope);
|
||||
}
|
||||
let tool = match grooves.as_slice() {
|
||||
[one] => one.clone(),
|
||||
many => kernel::union_all(&many.iter().collect::<Vec<_>>())?,
|
||||
};
|
||||
let tool = trim_at_shoulders(tool, t.axis_point, ends, (cyl.start, cyl.end), t.axis)?;
|
||||
let cut = kernel::boolean(&input.body, &tool, BoolOp::Cut)?;
|
||||
// Now rather than at the feature's end, where it could only fail the feature.
|
||||
if ends.contains(&End::Shoulder)
|
||||
&& let Some(body) = &cut
|
||||
{
|
||||
kernel::check_kept(body)?;
|
||||
}
|
||||
Ok((cut, tool.face_count()))
|
||||
};
|
||||
let (nb, groove_faces) = (kernel::boolean(&input.body, &tool, BoolOp::Cut)?, tool.face_count());
|
||||
// A design that built with the square stop keeps building: where the kernel can't make the
|
||||
// trimmed cut, the groove stops clear of the shoulder as it did, and the feature says so.
|
||||
let mut made = cut_grooves(ends);
|
||||
let mut square_stop = shoulder_unchecked;
|
||||
if matches!(made, Err(DocError::Kernel(_))) && ends.contains(&End::Shoulder) {
|
||||
made = cut_grooves(ends.map(|e| if e == End::Shoulder { End::Closed } else { e }));
|
||||
square_stop = true;
|
||||
}
|
||||
let note = (square_stop && made.is_ok()).then(|| {
|
||||
format!(
|
||||
"the {} thread's groove could not be run down to the shoulder here, so it stops clear of it and its last turn is left uncut",
|
||||
t.designation
|
||||
)
|
||||
});
|
||||
let (nb, groove_faces) = made?;
|
||||
let nb = nb.ok_or_else(|| DocError::Invalid("the thread removed everything".into()))?;
|
||||
// The groove's faces have no role to name them by.
|
||||
let groove = crate::lineage::Lineage::unnamed_faces(groove_faces);
|
||||
@@ -854,7 +944,7 @@ pub(super) fn cut(st: &mut ModelState, on: ThreadOn, (tip, angle, depth): (Threa
|
||||
if let Some(slot) = st.bodies.get_mut(i) {
|
||||
*slot = ModelBody::traced_new(mb.name, nb, mb.feature);
|
||||
}
|
||||
Ok(())
|
||||
Ok(note)
|
||||
}
|
||||
|
||||
/// The chamfered tip of an external thread (ISO 4753, chamfered end CH) at the free ends `tip`
|
||||
@@ -937,3 +1027,99 @@ fn tip_cones(
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::cell::Cell;
|
||||
|
||||
thread_local! {
|
||||
/// Make [`trim_at_shoulders`] find nothing of the groove left.
|
||||
pub(super) static EMPTY_TRIM: Cell<bool> = const { Cell::new(false) };
|
||||
/// Make the rising-shoulder check fail as the kernel would.
|
||||
pub(super) static FAIL_CLEARANCE: Cell<bool> = const { Cell::new(false) };
|
||||
}
|
||||
|
||||
fn ask() -> ThreadAsk<'static> {
|
||||
ThreadAsk {
|
||||
form: None,
|
||||
designation: Some("M10"),
|
||||
hand: ThreadHand::Right,
|
||||
starts: 1,
|
||||
steep: SteepFlank::default(),
|
||||
extent: Extent::Full,
|
||||
from: None,
|
||||
modeled: true,
|
||||
ease_out: (0.0, 0.0),
|
||||
tapped: false,
|
||||
old_auto_size: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// How a thread's ends meet the face at (5, 0, z) of `body`.
|
||||
fn ends_of(body: Body, z: f64) -> [End; 2] {
|
||||
let mut st = ModelState::default();
|
||||
st.bodies.push(ModelBody::new("Part".into(), body, 1));
|
||||
thread_on(&st, 1, Vec3::new(5.0, 0.0, z), &ask()).unwrap().ends
|
||||
}
|
||||
|
||||
/// A shoulder that stays beyond the face's end takes the trim. One rising back along the face
|
||||
/// (a slanted plate, a boss on a round body) keeps the square stop.
|
||||
#[test]
|
||||
fn only_a_shoulder_that_stays_beyond_the_faces_end_takes_the_trim() {
|
||||
let z = Vec3::new(0.0, 0.0, 1.0);
|
||||
let join = |a: &Body, b: &Body| kernel::boolean(a, b, BoolOp::Union).unwrap().unwrap();
|
||||
let boss = kernel::cylinder(Vec3::new(0.0, 0.0, 2.0), z, 5.0, 26.0).unwrap();
|
||||
let plate = kernel::box_solid(Vec3::new(-20.0, -20.0, -6.0), Vec3::new(20.0, 20.0, 2.0)).unwrap();
|
||||
let flat = ends_of(join(&plate, &boss), 20.0);
|
||||
assert!(flat.contains(&End::Shoulder), "{flat:?}");
|
||||
let tilted = kernel::cylinder(Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.2, 0.0, 1.0), 20.0, 6.0).unwrap();
|
||||
let slanted = ends_of(join(&tilted, &boss), 20.0);
|
||||
assert!(slanted.contains(&End::Closed) && !slanted.contains(&End::Shoulder), "{slanted:?}");
|
||||
let round = kernel::cylinder(Vec3::new(0.0, -20.0, 0.0), Vec3::new(0.0, 1.0, 0.0), 10.0, 40.0).unwrap();
|
||||
let on_round = ends_of(join(&round, &boss), 20.0);
|
||||
assert!(on_round.contains(&End::Closed) && !on_round.contains(&End::Shoulder), "{on_round:?}");
|
||||
}
|
||||
|
||||
/// Where the kernel can't check the shoulder, the thread still builds with the square stop, and
|
||||
/// the feature says its last turn is left uncut.
|
||||
#[test]
|
||||
fn a_shoulder_the_kernel_cannot_check_keeps_the_square_stop_and_says_so() {
|
||||
let plate = kernel::box_solid(Vec3::new(-20.0, -20.0, 0.0), Vec3::new(20.0, 20.0, 6.0)).unwrap();
|
||||
let boss = kernel::cylinder(Vec3::new(0.0, 0.0, 6.0), Vec3::new(0.0, 0.0, 1.0), 5.0, 16.0).unwrap();
|
||||
let body = kernel::boolean(&plate, &boss, BoolOp::Union).unwrap().unwrap();
|
||||
let mut st = ModelState::default();
|
||||
st.bodies.push(ModelBody::new("Part".into(), body, 1));
|
||||
FAIL_CLEARANCE.set(true);
|
||||
let on = thread_on(&st, 1, Vec3::new(5.0, 0.0, 14.0), &ask());
|
||||
FAIL_CLEARANCE.set(false);
|
||||
let on = on.unwrap();
|
||||
assert!(on.ends.contains(&End::Closed) && !on.ends.contains(&End::Shoulder), "{:?}", on.ends);
|
||||
let note = cut(&mut st, on, (ThreadTip::None, None, None)).unwrap();
|
||||
assert!(note.is_some_and(|n| n.contains("M10") && n.contains("left uncut")));
|
||||
}
|
||||
|
||||
/// Where the trim leaves nothing of the groove, a thread at a shoulder that built with the
|
||||
/// square stop still builds with it, and the feature says its last turn is left uncut.
|
||||
#[test]
|
||||
fn a_shoulder_cut_the_kernel_refuses_falls_back_to_the_square_stop_and_says_so() {
|
||||
let plate = kernel::box_solid(Vec3::new(-20.0, -20.0, 0.0), Vec3::new(20.0, 20.0, 6.0)).unwrap();
|
||||
let boss = kernel::cylinder(Vec3::new(0.0, 0.0, 6.0), Vec3::new(0.0, 0.0, 1.0), 5.0, 16.0).unwrap();
|
||||
let body = kernel::boolean(&plate, &boss, BoolOp::Union).unwrap().unwrap();
|
||||
let build = |fail: bool| {
|
||||
let mut st = ModelState::default();
|
||||
st.bodies.push(ModelBody::new("Part".into(), body.clone(), 1));
|
||||
let on = thread_on(&st, 1, Vec3::new(5.0, 0.0, 14.0), &ask()).unwrap();
|
||||
EMPTY_TRIM.set(fail);
|
||||
let note = cut(&mut st, on, (ThreadTip::None, None, None));
|
||||
EMPTY_TRIM.set(false);
|
||||
(note.unwrap(), kernel::measure(&st.bodies[0].body).unwrap().volume)
|
||||
};
|
||||
let (none, trimmed) = build(false);
|
||||
assert_eq!(none, None);
|
||||
let (note, square) = build(true);
|
||||
assert!(note.is_some_and(|n| n.contains("M10") && n.contains("left uncut")));
|
||||
// The square stop leaves about half a pitch's worth of groove uncut: well over a mm³.
|
||||
assert!(square > trimmed + 1.0, "{square} vs {trimmed}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
//! A counterbore hole may save a `bridge` length: two printing layers that thick below the
|
||||
//! counterbore's floor (a slot as wide as the hole along X, then a square) so a 3D printer bridges
|
||||
//! the step. An older design has none and keeps a plain counterbore. The last release refuses a
|
||||
//! design that has it (it asks for an update) instead of opening it without the bridge layers.
|
||||
@@ -0,0 +1,5 @@
|
||||
//! A fillet saves `turn: "swept"` when two of its rounds that turn round a sharp side edge (the
|
||||
//! rounds along a boss's base, at its upright edges) are swept round that edge, their corner an
|
||||
//! exact torus; absent, the default `mitre`, they meet in a mitre, as designs before this change were
|
||||
//! built. An older design has none and keeps its mitres. The last release refuses a design with
|
||||
//! this change (the newer format number).
|
||||
@@ -757,10 +757,19 @@ pub(crate) fn order_lumps(bodies: &mut [crate::ModelBody], origin: &Arc<crate::n
|
||||
// cut body's own feature's and the rest are the cut's, and `body.rename` stores a name in
|
||||
// the `body_names` of the feature a body says made it (#10438).
|
||||
let names: Vec<(String, u64)> = members.iter().filter_map(|i| bodies.get(*i).map(|b| (b.name.clone(), b.feature))).collect();
|
||||
for ((name, feature), (_, i)) in names.into_iter().zip(keys) {
|
||||
if let Some(b) = bodies.get_mut(i) {
|
||||
b.name = name;
|
||||
b.feature = feature;
|
||||
// The pieces take the family's places in that order too, so the state lists them as
|
||||
// their names run. The session pins a feature's body names in state order, and the next
|
||||
// evaluation hands them out in this order: listed in the kernel's order, the pieces
|
||||
// swapped names once the design was evaluated again from its pinned names (opened).
|
||||
let moved: Vec<crate::ModelBody> = keys.iter().filter_map(|(_, i)| bodies.get(*i).cloned()).collect();
|
||||
if moved.len() != members.len() || names.len() != members.len() {
|
||||
continue;
|
||||
}
|
||||
for ((mut b, (name, feature)), place) in moved.into_iter().zip(names).zip(&members) {
|
||||
b.name = name;
|
||||
b.feature = feature;
|
||||
if let Some(slot) = bodies.get_mut(*place) {
|
||||
*slot = b;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,6 +295,7 @@ fn box_fillet_cut_timeline() {
|
||||
fit: Default::default(),
|
||||
corner: Default::default(),
|
||||
setbacks: Vec::new(),
|
||||
turn: Default::default(),
|
||||
tangent_chain: true,
|
||||
},
|
||||
None,
|
||||
@@ -370,6 +371,7 @@ fn errors_are_reported_not_fatal() {
|
||||
fit: Default::default(),
|
||||
corner: Default::default(),
|
||||
setbacks: Vec::new(),
|
||||
turn: Default::default(),
|
||||
tangent_chain: true,
|
||||
},
|
||||
None,
|
||||
@@ -867,12 +869,39 @@ fn fillet_setbacks_default_to_none_and_round_trip() {
|
||||
fit: Default::default(),
|
||||
corner: Default::default(),
|
||||
setbacks: vec![CornerSetback { vertex: Position(Vec3::new(60.0, 40.0, 20.0)), distance: "8 mm".into() }],
|
||||
turn: Default::default(),
|
||||
tangent_chain: true,
|
||||
};
|
||||
let back: FeatureKind = serde_json::from_value(serde_json::to_value(&set).unwrap()).unwrap();
|
||||
assert_eq!(back, set);
|
||||
}
|
||||
|
||||
/// A fillet saved before turns reads as mitred and writes no `turn`; a swept one saves
|
||||
/// `turn: "swept"` and keeps it through a save and an open; a turn of another name is refused.
|
||||
#[test]
|
||||
fn fillet_turn_defaults_to_mitre_round_trips_and_refuses_unknown_turns() {
|
||||
let old = serde_json::json!({"type": "fillet", "edges": [[15.0, 0.0, 10.0]], "radius": "2"});
|
||||
let kind: FeatureKind = serde_json::from_value(old.clone()).unwrap();
|
||||
let FeatureKind::Fillet { turn, .. } = &kind else { panic!("{kind:?}") };
|
||||
assert_eq!(*turn, crate::FilletTurn::Mitre);
|
||||
assert!(serde_json::to_value(&kind).unwrap().get("turn").is_none());
|
||||
let mut swept = old.clone();
|
||||
swept["turn"] = serde_json::json!("swept");
|
||||
let kind: FeatureKind = serde_json::from_value(swept).unwrap();
|
||||
assert!(matches!(kind, FeatureKind::Fillet { turn: crate::FilletTurn::Swept, .. }), "{kind:?}");
|
||||
assert_eq!(serde_json::to_value(&kind).unwrap()["turn"], "swept");
|
||||
let mut doc = Document::new("turn");
|
||||
doc.add_feature(kind.clone(), None).unwrap();
|
||||
let saved = doc.to_json();
|
||||
let opened = Document::from_json(&saved).unwrap();
|
||||
assert_eq!(opened.features.last().map(|f| &f.kind), Some(&kind));
|
||||
assert_eq!(opened.to_json(), saved);
|
||||
let mut unknown = old;
|
||||
unknown["turn"] = serde_json::json!("round");
|
||||
let e = serde_json::from_value::<FeatureKind>(unknown).unwrap_err().to_string();
|
||||
assert!(e.contains("unknown variant `round`") && e.contains("mitre") && e.contains("swept"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeline_group_legacy_json_omits_empty_joint_membership() {
|
||||
let old = serde_json::json!({"id":100, "name":"Folder", "parent":null, "features":[]});
|
||||
|
||||
@@ -54,6 +54,7 @@ impl Walk for solidcraft_kernel::DraftCorner {}
|
||||
impl Walk for crate::document::Direction {}
|
||||
impl Walk for crate::document::FilletFit {}
|
||||
impl Walk for crate::document::FilletCorner {}
|
||||
impl Walk for crate::document::FilletTurn {}
|
||||
impl Walk for crate::document::MeshData {}
|
||||
impl Walk for crate::document::RevolveConvention {}
|
||||
impl Walk for crate::document::RailFit {}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
//! `solidcraft-cli mcp setup` and Help > Connect AI assistants: add a `solidcraft` MCP server entry
|
||||
//! to the AI clients installed on this machine, using the `solidcraft-cli` path given.
|
||||
//!
|
||||
//! The clients' own files are edited in place, not rewritten: only the `solidcraft` entries change,
|
||||
//! every other byte (other servers, other settings, comments, key order) stays as it was. The file
|
||||
//! is copied to `<name>.bak` first. A file that cannot be read as JSON (with comments) or TOML is
|
||||
//! Only the `solidcraft` entries change; every other byte (other servers, other settings, comments,
|
||||
//! key order) stays as it was. Updates replace the file atomically, keeping the previous version
|
||||
//! as `<name>.bak`. A file that cannot be read as JSON (with comments) or TOML is
|
||||
//! left alone, and so is an entry the user changed (its command is not a SolidCraft CLI, or its
|
||||
//! arguments differ); an entry that only has an old path to the CLI is updated.
|
||||
//! A client counts as installed when its configuration folder exists; no client program is run.
|
||||
@@ -606,11 +606,7 @@ fn toml_to_json(t: &toml_edit::Table) -> Value {
|
||||
/// One client's outcome, a line of the report.
|
||||
fn configure_file(t: &Target, exe: &str, live: bool, dry_run: bool, mode: Mode) -> Result<(String, Vec<(&'static str, Change)>), String> {
|
||||
let fail = |e: String| format!("{}: {e}; left unchanged", t.file.display());
|
||||
let existing = match std::fs::read_to_string(&t.file) {
|
||||
Ok(s) => Some(s),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
|
||||
Err(e) => return Err(format!("{}: {e}", t.file.display())),
|
||||
};
|
||||
let existing = read_config(&t.file).map_err(|e| format!("{}: {e}", t.file.display()))?;
|
||||
let mut text = existing.clone().unwrap_or_default();
|
||||
let changes =
|
||||
if t.shape == Shape::Codex { edit_toml(&mut text, exe, live, mode) } else { edit_json(&mut text, t.shape, exe, live, mode) }.map_err(fail)?;
|
||||
@@ -633,20 +629,30 @@ fn configure_file(t: &Target, exe: &str, live: bool, dry_run: bool, mode: Mode)
|
||||
if !changed {
|
||||
return done(said);
|
||||
}
|
||||
if existing.is_some() {
|
||||
let mut bak = t.file.clone().into_os_string();
|
||||
bak.push(".bak");
|
||||
// The first backup is the user's original: later runs keep it.
|
||||
if !Path::new(&bak).exists() {
|
||||
std::fs::copy(&t.file, &bak).map_err(|e| format!("{}: cannot back up: {e}", t.file.display()))?;
|
||||
}
|
||||
} else if let Some(dir) = t.file.parent() {
|
||||
std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
}
|
||||
std::fs::write(&t.file, text).map_err(|e| format!("{}: {e}", t.file.display()))?;
|
||||
write_config(&t.file, existing.as_deref(), &text).map_err(|e| format!("{}: {e}", t.file.display()))?;
|
||||
done(format!("{said} ({})", t.file.display()))
|
||||
}
|
||||
|
||||
fn read_config(path: &Path) -> std::io::Result<Option<String>> {
|
||||
match std::fs::read_to_string(path) {
|
||||
Ok(text) => Ok(Some(text)),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Refuse a stale edit before writing. Other clients do not share a lock with SolidCraft, so
|
||||
/// this narrows the race with their writes; it cannot exclude a write after the comparison.
|
||||
fn write_config(path: &Path, expected: Option<&str>, text: &str) -> std::io::Result<()> {
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
if read_config(path)?.as_deref() != expected {
|
||||
return Err(std::io::Error::other("configuration changed during setup; left unchanged, try again"));
|
||||
}
|
||||
solidcraft_io::safe_write::write_atomic(path, text.as_bytes(), true)
|
||||
}
|
||||
|
||||
/// Set up every detected client (or `--client NAME`; flags `--dry-run`, `--live`) for the
|
||||
/// `solidcraft-cli` at `exe`. Returns the result lines, one per client file, and whether every
|
||||
/// client succeeded.
|
||||
@@ -842,6 +848,96 @@ mod tests {
|
||||
assert!(toml.contains("[mcp_servers.other]") && !toml.contains("solidcraft"), "{toml}");
|
||||
}
|
||||
|
||||
fn config_dir(label: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-agent-config-{label}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_backup_tracks_the_file_before_each_change() {
|
||||
let dir = config_dir("backup");
|
||||
let target = Target { client: "claude-code", file: dir.join("config.json"), shape: Shape::ClaudeCode };
|
||||
let backup = dir.join("config.json.bak");
|
||||
let original = "{\n \"personal_setting\": true\n}\n";
|
||||
std::fs::write(&target.file, original).unwrap();
|
||||
configure_file(&target, EXE, false, false, Mode::Add).unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&backup).unwrap(), original);
|
||||
let changed_by_client = std::fs::read_to_string(&target.file).unwrap().replace("true", "false");
|
||||
std::fs::write(&target.file, &changed_by_client).unwrap();
|
||||
configure_file(&target, "/new/solidcraft-cli", false, false, Mode::Add).unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&backup).unwrap(), changed_by_client);
|
||||
let current = std::fs::read_to_string(&target.file).unwrap();
|
||||
assert!(current.contains("false") && current.contains("/new/solidcraft-cli"));
|
||||
// A no-op or dry run must leave both the file and its backup alone.
|
||||
configure_file(&target, "/new/solidcraft-cli", false, false, Mode::Add).unwrap();
|
||||
configure_file(&target, "/future/solidcraft-cli", false, true, Mode::Add).unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&target.file).unwrap(), current);
|
||||
assert_eq!(std::fs::read_to_string(&backup).unwrap(), changed_by_client);
|
||||
configure_file(&target, "/new/solidcraft-cli", false, false, Mode::Remove).unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&backup).unwrap(), current);
|
||||
assert!(!std::fs::read_to_string(&target.file).unwrap().contains("solidcraft"));
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_is_unchanged_when_its_backup_cannot_be_replaced() {
|
||||
let dir = config_dir("backup-failure");
|
||||
let target = Target { client: "codex", file: dir.join("config.toml"), shape: Shape::Codex };
|
||||
let original = "# personal setting\nmodel = \"custom\"\n";
|
||||
std::fs::write(&target.file, original).unwrap();
|
||||
std::fs::create_dir(dir.join("config.toml.bak")).unwrap();
|
||||
assert!(configure_file(&target, EXE, false, false, Mode::Add).is_err());
|
||||
assert_eq!(std::fs::read_to_string(&target.file).unwrap(), original);
|
||||
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 2, "no temporary files remain");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_refuses_an_edit_based_on_an_outdated_read() {
|
||||
let dir = config_dir("concurrent");
|
||||
let path = dir.join("config.json");
|
||||
let backup = dir.join("config.json.bak");
|
||||
std::fs::write(&backup, "previous backup").unwrap();
|
||||
for (expected, current) in [(Some("old"), Some("new")), (Some("old"), None), (None, Some("new"))] {
|
||||
if let Some(text) = current {
|
||||
std::fs::write(&path, text).unwrap();
|
||||
} else {
|
||||
std::fs::remove_file(&path).unwrap();
|
||||
}
|
||||
let error = write_config(&path, expected, "stale edit").unwrap_err();
|
||||
assert!(error.to_string().contains("configuration changed"), "{error}");
|
||||
assert_eq!(read_config(&path).unwrap().as_deref(), current);
|
||||
assert_eq!(std::fs::read_to_string(&backup).unwrap(), "previous backup");
|
||||
}
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn configuration_replaces_a_complete_file_and_keeps_links_and_permissions() {
|
||||
use std::io::Read;
|
||||
use std::os::unix::fs::{PermissionsExt, symlink};
|
||||
let dir = config_dir("atomic");
|
||||
let real = dir.join("real.json");
|
||||
let target = Target { client: "claude-code", file: dir.join("config.json"), shape: Shape::ClaudeCode };
|
||||
let original = "{\"personal_setting\": true}\n";
|
||||
std::fs::write(&real, original).unwrap();
|
||||
std::fs::set_permissions(&real, std::fs::Permissions::from_mode(0o600)).unwrap();
|
||||
symlink("real.json", &target.file).unwrap();
|
||||
let mut reader = std::fs::File::open(&target.file).unwrap();
|
||||
configure_file(&target, EXE, false, false, Mode::Add).unwrap();
|
||||
let mut old = String::new();
|
||||
reader.read_to_string(&mut old).unwrap();
|
||||
assert_eq!(old, original, "an existing reader sees the complete old file");
|
||||
assert!(std::fs::read_to_string(&target.file).unwrap().contains(EXE));
|
||||
assert!(std::fs::symlink_metadata(&target.file).unwrap().file_type().is_symlink());
|
||||
assert_eq!(std::fs::metadata(&real).unwrap().permissions().mode() & 0o777, 0o600);
|
||||
assert_eq!(std::fs::read_to_string(dir.join("real.json.bak")).unwrap(), original);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quiet_adds_only_the_plain_entry_and_names_new_clients_once() {
|
||||
let h = std::env::temp_dir().join(format!("solidcraft-quiet-{}", std::process::id()));
|
||||
|
||||
@@ -64,7 +64,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
or features?: [feature id or name] (the bodies they made; path?: [occurrence ids] for only that placement's); \
|
||||
or folder?: bodies|sketches|construction|origin|joints|all (all of it; showing also shows its browser folders and groups, and `all` takes in the occurrence hides too); \
|
||||
or tree_folders?: [browser folder or group key: c0/bodies, o:4/sketches, c0/g7, c0/joints, c0/canvases, c0/point_clouds] (the folder or group itself: its items keep their own state); \
|
||||
or paths?: [[occurrence ids]] (exact placements and descendants), isolate?: bool (requires paths and visible:true). Saved with the design and undone like any other change (the camera is not)",
|
||||
or paths?: [[occurrence ids]] (exact placements and descendants), isolate?: bool (with paths or items of bodies, and visible:true: everything else is hidden, other placements as placements). Saved with the design and undone like any other change (the camera is not)",
|
||||
),
|
||||
CommandSpec::new("browser.expand_all", "Expand All", expand_all).icon("folder").noundo().params(
|
||||
"path?: [occurrence ids] (that placement and its descendants); component?: id (that component's own folders); \
|
||||
@@ -436,6 +436,89 @@ fn tree_folder_key(k: &str) -> bool {
|
||||
k.len() <= 2048 && scope_ok && last_ok
|
||||
}
|
||||
|
||||
/// Isolate bodies (#7896): the picked bodies stay, and so do the placements that hold them, down
|
||||
/// to their own; those placements' other bodies and the root's other bodies are hidden one by
|
||||
/// one, and every other placement is hidden as a placement, so its Browser row shows it is off and
|
||||
/// its eye brings it back.
|
||||
fn isolate_bodies(s: &mut Session, p: &Value, visible: bool) -> Result<Value> {
|
||||
let cmd = "browser.visibility";
|
||||
if !visible {
|
||||
return Err(bad(cmd, "isolate requires visible:true"));
|
||||
}
|
||||
if ["folder", "features", "joints"].iter().any(|k| p.get(*k).is_some()) {
|
||||
return Err(bad(cmd, "isolate takes `paths` or `items` (bodies)"));
|
||||
}
|
||||
let keys = keys(p, "items", cmd)?;
|
||||
if keys.is_empty() {
|
||||
return Err(bad(cmd, "isolate needs `paths` or `items` (bodies)"));
|
||||
}
|
||||
let world = s.world_state()?;
|
||||
let keep: Vec<String> = keys
|
||||
.iter()
|
||||
.map(|k| match shown_item(s, &world, k) {
|
||||
Some(Shown::Body(name)) => Ok(name),
|
||||
_ => Err(bad(cmd, format!("no body `{k}`"))),
|
||||
})
|
||||
.collect::<Result<_>>()?;
|
||||
let mut hidden_bodies: Vec<String> =
|
||||
s.model.state().bodies.iter().filter(|b| s.doc.model_body_component(b) == 0 && !keep.contains(&b.name)).map(|b| b.name.clone()).collect();
|
||||
let mut hidden = Vec::new();
|
||||
let mut stack = vec![Vec::<u64>::new()];
|
||||
let mut count = 0;
|
||||
while let Some(parent) = stack.pop() {
|
||||
for child in s.occurrence_children(&parent)? {
|
||||
count += 1;
|
||||
if count > 100_000 {
|
||||
return Err(bad(cmd, "isolate exceeds 100000 placements"));
|
||||
}
|
||||
if s.occurrence_bodies_shared(&child.path, true)?.iter().any(|b| keep.contains(b)) {
|
||||
hidden_bodies.extend(child.bodies.into_iter().map(|b| b.world).filter(|b| !keep.contains(b)));
|
||||
stack.push(child.path);
|
||||
} else {
|
||||
hidden.push(child.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
// A hidden Bodies folder or group a picked body is in would keep it hidden: those show
|
||||
// again. Their other bodies are hidden one by one above, as the root's are.
|
||||
let shown_folders = folders_holding(s, &keep);
|
||||
s.visibility.hidden_folders.retain(|k| !shown_folders.contains(k));
|
||||
s.visibility.hidden_occurrences = hidden;
|
||||
s.visibility.hidden_bodies = hidden_bodies;
|
||||
s.refresh_display();
|
||||
Ok(json!({"visible":true,"isolated":keep,"visibility":s.visibility}))
|
||||
}
|
||||
|
||||
/// The tree keys of the Bodies folders and groups (with the groups they are in) that hold the
|
||||
/// bodies named `world` in some placement.
|
||||
fn folders_holding(s: &Session, world: &[String]) -> std::collections::BTreeSet<String> {
|
||||
let mut keys = std::collections::BTreeSet::new();
|
||||
if s.visibility.hidden_folders.is_empty() {
|
||||
return keys;
|
||||
}
|
||||
let st = s.model.state();
|
||||
let names = solidcraft_doc::WorldNames::new(&s.doc, &st);
|
||||
// Each body's component, found once rather than once per placement.
|
||||
let owned: Vec<(u64, &str)> = s.doc.model_body_components(&st).into_iter().zip(st.bodies.iter().map(|b| b.name.as_str())).collect();
|
||||
for (component, path, _) in s.doc.placements() {
|
||||
let scope = Session::folder_scope(&path);
|
||||
for b in owned.iter().filter(|(c, _)| *c == component).map(|(_, name)| *name) {
|
||||
if !world.contains(&names.name(b, component, &path)) {
|
||||
continue;
|
||||
}
|
||||
keys.insert(format!("{scope}/bodies"));
|
||||
for g in s.doc.browser_groups.iter().filter(|g| g.component == component && g.folder == "bodies" && g.has_item(b)) {
|
||||
for id in std::iter::successors(Some(g.id), |id| s.doc.browser_group(*id).and_then(|g| g.parent))
|
||||
.take(solidcraft_doc::MAX_GROUP_NESTING + 1)
|
||||
{
|
||||
keys.insert(format!("{scope}/g{id}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
keys
|
||||
}
|
||||
|
||||
fn visibility(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "browser.visibility";
|
||||
let visible = bool_(p, "visible").ok_or_else(|| bad(cmd, "`visible` must be true or false"))?;
|
||||
@@ -525,6 +608,9 @@ fn visibility(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.refresh_display();
|
||||
return Ok(json!({"visible":visible,"changed":paths.len(),"visibility":s.visibility}));
|
||||
}
|
||||
if bool_(p, "isolate").unwrap_or(false) {
|
||||
return isolate_bodies(s, p, visible);
|
||||
}
|
||||
let items: Vec<Shown> = match (str_(p, "folder"), p.get("items")) {
|
||||
_ if p.get("features").is_some() => {
|
||||
if ["items", "folder", "joints"].iter().any(|k| p.get(*k).is_some()) {
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use serde_json::Value;
|
||||
use solidcraft_geom::mesh_distance::box_gap;
|
||||
use solidcraft_geom::prelude::*;
|
||||
use solidcraft_geom::{Aabb3, Mesh};
|
||||
@@ -17,9 +17,20 @@ use solidcraft_kernel::{BodyDistance, DistanceShape};
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, num, str_, string_list};
|
||||
use crate::schema::clearance::{
|
||||
Clearance as ClearanceReply, ClearanceParams, MAX_CLEARANCE, MAX_NEAREST, NearPair, Neighbour, Overlap, OverlapBounds, Unchecked,
|
||||
};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("inspect.clearance", "Clearance", clearance).icon("interference").noundo().params(
|
||||
const CONTRACT: TypedContract<ClearanceParams> = TypedContract::new();
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("inspect.clearance", "Clearance", crate::checked!("inspect.clearance", clearance))
|
||||
.icon("interference")
|
||||
.noundo()
|
||||
.contract(&CONTRACT)
|
||||
.output(output_schema_for::<ClearanceReply>)
|
||||
.params(
|
||||
"bodies?: [names] (default: every solid body, in world placement, but those in `against`; mesh bodies are not measured); against?: [names] (only pairs with one body in `bodies` and the other here); \
|
||||
min_volume?: mm³ (default 0.01: smaller overlaps count as contact); clearance?: mm (default 0: report pairs closer than this); \
|
||||
nearest?: n (each body of `bodies` gets its n nearest others); skip_within?: \"occurrence\" (pairs of bodies of one occurrence) | \
|
||||
@@ -34,10 +45,6 @@ pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("inspect.clearance", "C
|
||||
const MAX_BODIES: usize = 20_000;
|
||||
/// Most pairs whose grown boxes meet that one call measures.
|
||||
const MAX_PAIRS: usize = 200_000;
|
||||
/// Most neighbours a body's `nearest` list holds.
|
||||
const MAX_NEAREST: usize = 1000;
|
||||
/// Largest clearance (mm).
|
||||
const MAX_CLEARANCE: f64 = 1e6;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum SkipWithin {
|
||||
@@ -261,48 +268,56 @@ fn clearance(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for k in ["bodies", "against"] {
|
||||
asked.extend(string_list(cmd, p, k)?);
|
||||
}
|
||||
let mut unchecked: Vec<Value> = mesh_bodies
|
||||
let mut unchecked: Vec<Unchecked> = mesh_bodies
|
||||
.iter()
|
||||
.filter(|b| p.get("bodies").is_none_or(Value::is_null) || asked.contains(&b.name))
|
||||
.map(|b| json!({"a": b.name, "b": null, "reason": super::interference::MESH_NOT_CHECKED}))
|
||||
.map(|b| Unchecked { a: b.name.clone(), b: None, reason: super::interference::MESH_NOT_CHECKED.to_string() })
|
||||
.collect();
|
||||
let name = |i: usize| solid.get(i).map_or("", |b| b.name.as_str());
|
||||
let pair = |i: usize, j: usize, reason: String| Unchecked { a: name(i).to_string(), b: Some(name(j).to_string()), reason };
|
||||
for (&(i, j), r) in close.iter().zip(results) {
|
||||
let r = match r {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
unchecked.push(json!({"a": name(i), "b": name(j), "reason": e.to_string()}));
|
||||
unchecked.push(pair(i, j, e.to_string()));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if let Some(reason) = r.unchecked {
|
||||
unchecked.push(json!({"a": name(i), "b": name(j), "reason": reason}));
|
||||
unchecked.push(pair(i, j, reason));
|
||||
continue;
|
||||
}
|
||||
if let Some((volume, area, bbox, center)) = r.overlap
|
||||
&& volume >= min_volume
|
||||
{
|
||||
overlaps.push(json!({"a": name(i), "b": name(j), "volume_mm3": volume, "area_mm2": area,
|
||||
"bbox": {"min": bbox.min, "max": bbox.max}, "center": center}));
|
||||
overlaps.push(Overlap {
|
||||
a: name(i).to_string(),
|
||||
b: name(j).to_string(),
|
||||
volume_mm3: volume,
|
||||
area_mm2: area,
|
||||
bbox: OverlapBounds { min: bbox.min.into(), max: bbox.max.into() },
|
||||
center: center.into(),
|
||||
});
|
||||
known.insert((i, j), 0.0);
|
||||
continue;
|
||||
}
|
||||
let Some(d) = r.distance else { continue };
|
||||
known.insert((i, j), d.distance);
|
||||
if d.distance < gap {
|
||||
let mut e = json!({"a": name(i), "b": name(j), "distance_mm": d.distance, "from": d.from, "to": d.to});
|
||||
if let Some((v, ..)) = r.overlap.filter(|o| o.0 > 0.0) {
|
||||
e["overlap_mm3"] = json!(v);
|
||||
}
|
||||
if !d.exact {
|
||||
e["exact"] = json!(false);
|
||||
}
|
||||
near.push(e);
|
||||
near.push(NearPair {
|
||||
a: name(i).to_string(),
|
||||
b: name(j).to_string(),
|
||||
distance_mm: d.distance,
|
||||
from: d.from.into(),
|
||||
to: d.to.into(),
|
||||
overlap_mm3: r.overlap.filter(|o| o.0 > 0.0).map(|o| o.0),
|
||||
exact: (!d.exact).then_some(false),
|
||||
});
|
||||
}
|
||||
}
|
||||
near.sort_by(|x, y| x["distance_mm"].as_f64().unwrap_or(0.0).total_cmp(&y["distance_mm"].as_f64().unwrap_or(0.0)));
|
||||
near.sort_by(|x, y| x.distance_mm.total_cmp(&y.distance_mm));
|
||||
|
||||
let mut nearest_out = serde_json::Map::new();
|
||||
let mut nearest_out = BTreeMap::new();
|
||||
if nearest > 0 {
|
||||
let lists = nearest_lists(&subjects, n, nearest, &boxes, &allowed, &mut known, |pairs| {
|
||||
solidcraft_kernel::map_with_local_pools(pairs, usize::MAX, |&(i, j)| {
|
||||
@@ -311,23 +326,25 @@ fn clearance(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
});
|
||||
for (i, list) in lists {
|
||||
let rows: Vec<Value> = list.iter().map(|&(j, d)| json!({"body": name(j), "distance_mm": d})).collect();
|
||||
nearest_out.insert(name(i).to_string(), Value::Array(rows));
|
||||
let rows = list.iter().map(|&(j, d)| Neighbour { body: name(j).to_string(), distance_mm: d }).collect();
|
||||
nearest_out.insert(name(i).to_string(), rows);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(json!({
|
||||
"overlaps": overlaps,
|
||||
"near": near,
|
||||
"nearest": nearest_out,
|
||||
"unchecked": unchecked,
|
||||
"bodies": subjects.len(),
|
||||
"pairs_checked": close.len(),
|
||||
"pairs_reused": reused,
|
||||
"pairs_pruned": total.saturating_sub(close.len()),
|
||||
"truncated": truncated > 0,
|
||||
"ms": started.elapsed().as_secs_f64() * 1000.0,
|
||||
}))
|
||||
let reply = ClearanceReply {
|
||||
overlaps,
|
||||
near,
|
||||
nearest: nearest_out,
|
||||
unchecked,
|
||||
bodies: subjects.len(),
|
||||
pairs_checked: close.len(),
|
||||
pairs_reused: reused,
|
||||
pairs_pruned: total.saturating_sub(close.len()),
|
||||
truncated: truncated > 0,
|
||||
ms: started.elapsed().as_secs_f64() * 1000.0,
|
||||
param_warnings: Vec::new(),
|
||||
};
|
||||
serde_json::to_value(reply).map_err(|e| bad(cmd, e.to_string()))
|
||||
}
|
||||
|
||||
/// One pair: its overlap when the meshes meet or one holds the other, otherwise its distance
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! well as any; each place's volume, centre and box are then measured exactly, on the pieces of
|
||||
//! the two cuts a − b and b − a that lie there, as `inspect.measure` measures a body.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use serde_json::Value;
|
||||
use solidcore_record::Vec3;
|
||||
use solidcraft_geom::Aabb3;
|
||||
use solidcraft_geom::compare::{self, CompareOptions, Side};
|
||||
@@ -13,9 +13,19 @@ use solidcraft_geom::prelude::*;
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::bad;
|
||||
use crate::schema::compare::{
|
||||
Bounds, CompareParams, CompareSide, Comparison, Deviation, Deviations, Difference, MeasuredBy, Region, Resolution, Sampling, SideTotal,
|
||||
};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{Result, Sel, Session};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("inspect.compare", "Compare Bodies", compare_bodies).icon("compare").params(
|
||||
const CONTRACT: TypedContract<CompareParams> = TypedContract::new();
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("inspect.compare", "Compare Bodies", crate::checked!("inspect.compare", compare_bodies))
|
||||
.icon("compare")
|
||||
.contract(&CONTRACT)
|
||||
.output(output_schema_for::<Comparison>)
|
||||
.params(
|
||||
"a, b: body names (default: the two selected bodies, or the design's only two), in world placement; tolerance?: mm (0.001 to 10, default 0.01): \
|
||||
surfaces closer than this agree; spacing?: mm (default 0.5): the largest piece of a face sampled as one point, coarser for a body that would need \
|
||||
more than max_points? (default 8 million, up to 50 million) samples; max_regions? (default 50); color?: true | {only_in_a?, only_in_b?: \"#rrggbb\"} \
|
||||
@@ -32,10 +42,10 @@ pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("inspect.compare", "Com
|
||||
const ONLY_IN_A: [u8; 3] = [0xe0, 0x31, 0x31];
|
||||
const ONLY_IN_B: [u8; 3] = [0x2f, 0x9e, 0x44];
|
||||
|
||||
fn side_name(s: Side) -> &'static str {
|
||||
fn side_of(s: Side) -> CompareSide {
|
||||
match s {
|
||||
Side::OnlyInA => "only_in_a",
|
||||
Side::OnlyInB => "only_in_b",
|
||||
Side::OnlyInA => CompareSide::OnlyInA,
|
||||
Side::OnlyInB => CompareSide::OnlyInB,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,21 +115,21 @@ fn v3(v: &Value) -> Option<Vec3> {
|
||||
}
|
||||
|
||||
/// b − a of the two bodies' measures.
|
||||
fn difference(ma: &Value, mb: &Value) -> Value {
|
||||
fn difference(ma: &Value, mb: &Value) -> Difference {
|
||||
let f = |m: &Value, k: &str| m.get(k).and_then(Value::as_f64);
|
||||
let sub = |x: Option<f64>, y: Option<f64>| x.zip(y).map(|(x, y)| y - x);
|
||||
let (va, vb) = (f(ma, "volume_mm3"), f(mb, "volume_mm3"));
|
||||
let com = v3(&ma["center_of_mass"]).zip(v3(&mb["center_of_mass"])).map(|(a, b)| b - a);
|
||||
let corner = |k: &str| v3(&ma["bbox"][k]).zip(v3(&mb["bbox"][k])).map(|(a, b)| b - a);
|
||||
json!({
|
||||
"volume_mm3": sub(va, vb),
|
||||
"volume_percent": va.zip(vb).filter(|(a, _)| a.abs() > 0.0).map(|(a, b)| 100.0 * (b - a) / a),
|
||||
"area_mm2": sub(f(ma, "area_mm2"), f(mb, "area_mm2")),
|
||||
"center_of_mass": com,
|
||||
"center_of_mass_distance_mm": com.map(Vec3::len),
|
||||
"bbox_min": corner("min"),
|
||||
"bbox_max": corner("max"),
|
||||
})
|
||||
let corner = |k: &str| v3(&ma["bbox"][k]).zip(v3(&mb["bbox"][k])).map(|(a, b)| (b - a).into());
|
||||
Difference {
|
||||
volume_mm3: sub(va, vb),
|
||||
volume_percent: va.zip(vb).filter(|(a, _)| a.abs() > 0.0).map(|(a, b)| 100.0 * (b - a) / a),
|
||||
area_mm2: sub(f(ma, "area_mm2"), f(mb, "area_mm2")),
|
||||
center_of_mass: com.map(Into::into),
|
||||
center_of_mass_distance_mm: com.map(Vec3::len),
|
||||
bbox_min: corner("min"),
|
||||
bbox_max: corner("max"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The volume a closed mesh encloses (divergence theorem, about its box's centre).
|
||||
@@ -245,8 +255,8 @@ fn exact_regions(
|
||||
(measured, notes)
|
||||
}
|
||||
|
||||
fn deviation_json(d: &compare::Deviation) -> Value {
|
||||
json!({"max_mm": d.max, "mean_mm": d.mean, "at": d.at, "area_beyond_mm2": d.beyond_area, "area_mm2": d.area, "points": d.points})
|
||||
fn deviation(d: &compare::Deviation) -> Deviation {
|
||||
Deviation { max_mm: d.max, mean_mm: d.mean, at: d.at.into(), area_beyond_mm2: d.beyond_area, area_mm2: d.area, points: d.points }
|
||||
}
|
||||
|
||||
fn compare_bodies(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -308,30 +318,36 @@ fn compare_bodies(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
exact_notes.iter().find(mine).or_else(|| exact_notes.last()).cloned().unwrap_or_else(|| "the exact cut gave no piece here".to_string())
|
||||
};
|
||||
let axis = |k: usize| ["X", "Y", "Z"].get(k).copied().unwrap_or("?");
|
||||
let regions: Vec<Value> = order
|
||||
let regions: Vec<Region> = order
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, &k)| c.regions.get(k).map(|r| (i, k, r)))
|
||||
.map(|(i, k, r)| {
|
||||
let m = measured(k);
|
||||
json!({
|
||||
"index": i + 1,
|
||||
"side": side_name(r.side),
|
||||
"material_of": if r.side == Side::OnlyInA { &na } else { &nb },
|
||||
"volume_mm3": m.map(|m| m.volume),
|
||||
"measured_by": if m.is_some() { "boolean" } else { "not measured" },
|
||||
"not_measured": if m.is_some() { Value::Null } else { json!(reason(k)) },
|
||||
"area_mm2": r.area_a + r.area_b,
|
||||
"area_a_mm2": r.area_a,
|
||||
"area_b_mm2": r.area_b,
|
||||
"max_deviation_mm": r.max_deviation,
|
||||
"at": r.at,
|
||||
"center": m.map(|m| m.center),
|
||||
"bbox": m.map(|m| json!({"min": m.bbox.min, "max": m.bbox.max})).unwrap_or_else(|| json!({"min": r.bbox.min, "max": r.bbox.max})),
|
||||
"faces_a": r.faces_a.iter().map(|x| x.0).collect::<Vec<u32>>(),
|
||||
"faces_b": r.faces_b.iter().map(|x| x.0).collect::<Vec<u32>>(),
|
||||
"resolution": {"along": axis(r.lines.axis), "line_spacing_mm": r.lines.spacing, "lines": r.lines.count, "skipped_lines": r.lines.skipped},
|
||||
})
|
||||
let bbox = m.map_or(r.bbox, |m| m.bbox);
|
||||
Region {
|
||||
index: i + 1,
|
||||
side: side_of(r.side),
|
||||
material_of: if r.side == Side::OnlyInA { na.clone() } else { nb.clone() },
|
||||
volume_mm3: m.map(|m| m.volume),
|
||||
measured_by: if m.is_some() { MeasuredBy::Boolean } else { MeasuredBy::NotMeasured },
|
||||
not_measured: if m.is_some() { None } else { Some(reason(k)) },
|
||||
area_mm2: r.area_a + r.area_b,
|
||||
area_a_mm2: r.area_a,
|
||||
area_b_mm2: r.area_b,
|
||||
max_deviation_mm: r.max_deviation,
|
||||
at: r.at.into(),
|
||||
center: m.map(|m| m.center.into()),
|
||||
bbox: Bounds { min: bbox.min.into(), max: bbox.max.into() },
|
||||
faces_a: r.faces_a.iter().map(|x| x.0).collect(),
|
||||
faces_b: r.faces_b.iter().map(|x| x.0).collect(),
|
||||
resolution: Resolution {
|
||||
along: axis(r.lines.axis).to_string(),
|
||||
line_spacing_mm: r.lines.spacing,
|
||||
lines: r.lines.count,
|
||||
skipped_lines: r.lines.skipped,
|
||||
},
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
// A side's total, or none when one of its regions is not measured.
|
||||
@@ -361,25 +377,28 @@ fn compare_bodies(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !warnings.is_empty() {
|
||||
verdict += &format!(" ({} warning{})", warnings.len(), if warnings.len() == 1 { "" } else { "s" });
|
||||
}
|
||||
let mut out = json!({
|
||||
"a": measure_a,
|
||||
"b": measure_b,
|
||||
"difference": difference(&measure_a, &measure_b),
|
||||
"tolerance_mm": tolerance,
|
||||
"deviation": {"a_to_b": deviation_json(&c.a_to_b), "b_to_a": deviation_json(&c.b_to_a), "max_mm": worst},
|
||||
"identical": identical,
|
||||
"verdict": verdict,
|
||||
"regions": regions,
|
||||
"only_in_a": {"regions": ca, "volume_mm3": va},
|
||||
"only_in_b": {"regions": cb, "volume_mm3": vb},
|
||||
"unlisted_regions": c.unlisted,
|
||||
"warnings": warnings,
|
||||
"sampling": {"spacing_mm": c.spacing, "chord_mm": chord, "triangles_a": mesh_a.triangles.len(), "triangles_b": mesh_b.triangles.len()},
|
||||
});
|
||||
if let Some(colors) = colors {
|
||||
out["colored_faces"] = json!(color_regions(s, &c, [&na, &nb], colors)?);
|
||||
}
|
||||
Ok(out)
|
||||
let colored_faces = match colors {
|
||||
Some(colors) => Some(color_regions(s, &c, [&na, &nb], colors)?),
|
||||
None => None,
|
||||
};
|
||||
let out = Comparison {
|
||||
difference: difference(&measure_a, &measure_b),
|
||||
a: measure_a.as_object().cloned().unwrap_or_default(),
|
||||
b: measure_b.as_object().cloned().unwrap_or_default(),
|
||||
tolerance_mm: tolerance,
|
||||
deviation: Deviations { a_to_b: deviation(&c.a_to_b), b_to_a: deviation(&c.b_to_a), max_mm: worst },
|
||||
identical,
|
||||
verdict,
|
||||
regions,
|
||||
only_in_a: SideTotal { regions: ca, volume_mm3: va },
|
||||
only_in_b: SideTotal { regions: cb, volume_mm3: vb },
|
||||
unlisted_regions: c.unlisted,
|
||||
warnings,
|
||||
sampling: Sampling { spacing_mm: c.spacing, chord_mm: chord, triangles_a: mesh_a.triangles.len(), triangles_b: mesh_b.triangles.len() },
|
||||
colored_faces,
|
||||
param_warnings: Vec::new(),
|
||||
};
|
||||
serde_json::to_value(out).map_err(|e| bad(cmd, e.to_string()))
|
||||
}
|
||||
|
||||
/// A face is coloured for a region when at least this share of it deviates there.
|
||||
|
||||
@@ -175,7 +175,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.params("component (or occurrence): what to place again; path?: validates selected occurrence; parent?: component (default: the same parent); translate?: [x,y,z]; in_place?: bool (default false: the selected transform within the parent; true: where the selected placement is in the world, translate in world axes; a parent placed more than once is placed by parent_path?, else the active placement, and every instance gets the copy). Copies the selected effective transform, including pending moves"),
|
||||
CommandSpec::new("component.paste_new", "Paste New", paste_new).icon("paste").params("component: independently copy its nested definition graph and history, materials and styles; occurrence?, path?: selected source placement; parent?: component the copy goes in (default: the source placement's; not the component or one inside it); in_place?, parent_path?: as occurrence.copy; translate?: [x,y,z]. Reassigned external bodies become base geometry; retained external links and omitted boundary joints are reported"),
|
||||
CommandSpec::new("occurrence.move", "Move Occurrence", move_occurrence).icon("move")
|
||||
.params("occurrence; path?: exact placement; space?: local|world (default local); translate?: [x,y,z]; axis?: [x,y,z], angle?: radians or \"30 deg\", origin?: [x,y,z]; matrix?: [12 or 16 numbers] (instead of translate/axis/angle/origin: a rigid move as a row-major 3x4 rotation-and-translation or 4x4 matrix, flat or as rows; one that is a rotation only up to rounding is orthonormalised, a scale, shear or mirror is refused); tolerance?: largest accepted |RᵀR − I| (default 1e-9, at most 1e-3); capture?: bool (default true; false keeps it pending until Capture Position); move_grounded?: bool (default false: a grounded occurrence refuses; true moves it and it stays grounded). Nested occurrence definitions remain shared"),
|
||||
.params("occurrence; path?: exact placement; space?: local|world (default local); translate?: [x,y,z]; axis?: [x,y,z], angle?: radians or \"30 deg\", origin?: [x,y,z]; matrix?: [12 or 16 numbers] (instead of translate/axis/angle/origin: a rigid move as a row-major 3x4 rotation-and-translation or 4x4 matrix, flat or as rows; one that is a rotation only up to rounding is orthonormalised, a scale, shear or mirror is refused); tolerance?: largest accepted |RᵀR − I| (default 1e-9, at most 1e-3); capture?: bool (default true; false keeps it pending until Capture Position); move_grounded?: bool (default false: a grounded occurrence refuses; true moves it and it stays grounded). Nested occurrence definitions remain shared. Replies {occurrence, transform, pending}; when joints hold it to something fixed and put it back, transform is where it is and the reply adds reverted: true and a warning naming the joint"),
|
||||
CommandSpec::new("occurrence.drag", "Drag Occurrence", drag::drag).icon("move").params(
|
||||
"occurrence or path: exact placement; at: [x,y,z] world point on it; point: [x,y,z] where it goes. Moves the placement by \
|
||||
point - at (with any chain of joints it is in that is joined to nothing grounded) and captures its position; other pending \
|
||||
@@ -730,6 +730,9 @@ fn move_occurrence(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(x) = s.doc_mut().occurrences.iter_mut().find(|x| x.id == id) {
|
||||
x.transform = next;
|
||||
}
|
||||
if !s.doc.assembly.joints.is_empty() {
|
||||
return Ok(after_joints(s, p, id, next, cmd));
|
||||
}
|
||||
} else {
|
||||
s.pending_moves.insert(id, next);
|
||||
s.revision += 1;
|
||||
@@ -737,6 +740,46 @@ fn move_occurrence(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"occurrence": id, "transform": next, "pending": !bool_(p, "capture").unwrap_or(true)}))
|
||||
}
|
||||
|
||||
/// The reply to a captured move once the joints are solved (#11723): a part its joints hold to
|
||||
/// something fixed is put back by that solve, so the reply gives where it is now, says
|
||||
/// `reverted`, and names the joint and what to change instead.
|
||||
fn after_joints(s: &mut Session, p: &Value, id: u64, moved: Mat, cmd: &str) -> Value {
|
||||
s.refresh();
|
||||
let now = s.doc.occurrences.iter().find(|o| o.id == id).map_or(moved, |o| o.transform);
|
||||
let same = now.iter().flatten().zip(moved.iter().flatten()).all(|(a, b)| (a - b).abs() <= 1e-6);
|
||||
if same {
|
||||
return json!({"occurrence": id, "transform": now, "pending": false});
|
||||
}
|
||||
let name = s.doc.occurrences.iter().find(|o| o.id == id).map(|o| o.name.clone()).unwrap_or_default();
|
||||
// The placement the caller named, else the first one: a part in a component placed more
|
||||
// than once has no path of its own, and its joints hold it alike in every placement.
|
||||
let path = if p.get("path").is_some() {
|
||||
occurrence_path(s, p, id, cmd).ok()
|
||||
} else {
|
||||
let parent = s.doc.occurrences.iter().find(|o| o.id == id).map_or(0, |o| o.parent);
|
||||
let mut path = s.doc.first_path(parent);
|
||||
path.push(id);
|
||||
Some(path)
|
||||
};
|
||||
let why = path.and_then(|path| s.drag_group(&path).err()).map(|e| format!("{e}. ")).unwrap_or_else(|| {
|
||||
// No one joint to blame (a hinge's stored value, say): name the joints on the part.
|
||||
let on: Vec<&str> = s
|
||||
.doc
|
||||
.assembly
|
||||
.joints
|
||||
.iter()
|
||||
.filter(|j| !j.suppressed && (j.a.occurrence == id || j.b.occurrence == id))
|
||||
.map(|j| j.name.as_str())
|
||||
.collect();
|
||||
if on.is_empty() { String::new() } else { format!("Joints on {name}: {}. ", on.join(", ")) }
|
||||
});
|
||||
let warning = format!(
|
||||
"{why}The joints put {name} back where they hold it. To move it, change the joint (joint.edit for its offset or angle, \
|
||||
joint.drive for a hinge or slider value), move what it is held to, or use joint.reach"
|
||||
);
|
||||
json!({"occurrence": id, "transform": now, "pending": false, "reverted": true, "warning": warning})
|
||||
}
|
||||
|
||||
fn ground(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "occurrence.ground";
|
||||
let id = occurrence(s, p, cmd)?;
|
||||
|
||||
@@ -287,3 +287,66 @@ fn a_unique_part_can_drag_a_chain_through_a_shared_peer() {
|
||||
run(&mut s, "joint.solve", json!({}));
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
}
|
||||
|
||||
/// #11723: Move Occurrence on a part that rigid joints hold to something grounded is put back by
|
||||
/// the joint solve after it. The move still succeeds, but its reply says it was put back, gives
|
||||
/// where the part is, and names the joint. A free chain follows the move, as before, and says
|
||||
/// nothing.
|
||||
#[test]
|
||||
fn a_move_its_joints_undo_says_so() {
|
||||
let setups: [(&str, fn(&mut Session, u64, u64, u64)); 4] = [
|
||||
("free chain", |_, _, _, _| {}),
|
||||
("plate held to base", |s, base, plate, _| {
|
||||
run(s, "joint.as_built", json!({"type": "rigid", "a": base, "b": plate, "name": "Mount"}));
|
||||
}),
|
||||
("motor held to base", |s, base, _, motor| {
|
||||
run(s, "joint.as_built", json!({"type": "rigid", "a": base, "b": motor, "name": "Mount"}));
|
||||
}),
|
||||
("motor grounded", |s, _, _, motor| {
|
||||
run(s, "occurrence.ground", json!({"occurrence": motor, "grounded": true}));
|
||||
}),
|
||||
];
|
||||
for (case, setup) in setups {
|
||||
let mut s = Session::default();
|
||||
let (_, base) = part(&mut s, "Base", [0.0, 0.0, 0.0]);
|
||||
let (_, plate) = part(&mut s, "Plate", [20.0, 0.0, 0.0]);
|
||||
let (_, motor) = part(&mut s, "Motor", [20.0, 0.0, 10.0]);
|
||||
run(&mut s, "joint.as_built", json!({"type": "rigid", "a": plate, "b": motor, "name": "Bolted"}));
|
||||
setup(&mut s, base, plate, motor);
|
||||
let (plate_at, motor_at) = (origin(&s, &[plate]), origin(&s, &[motor]));
|
||||
let r = run(&mut s, "occurrence.move", json!({"occurrence": plate, "space": "world", "translate": [0, 0, 30]}));
|
||||
let reply_at = [r["transform"][3][0].as_f64().unwrap(), r["transform"][3][1].as_f64().unwrap(), r["transform"][3][2].as_f64().unwrap()];
|
||||
near(reply_at, origin(&s, &[plate]));
|
||||
if case == "free chain" {
|
||||
near(origin(&s, &[plate]), [plate_at[0], plate_at[1], plate_at[2] + 30.0]);
|
||||
near(origin(&s, &[motor]), [motor_at[0], motor_at[1], motor_at[2] + 30.0]);
|
||||
assert!(r.get("reverted").is_none() && r.get("warning").is_none(), "{case}: {r}");
|
||||
continue;
|
||||
}
|
||||
near(origin(&s, &[plate]), plate_at);
|
||||
assert_eq!(r["reverted"], json!(true), "{case}: {r}");
|
||||
let warning = r["warning"].as_str().unwrap_or_default();
|
||||
let holder = if case == "motor grounded" { "Bolted" } else { "Mount" };
|
||||
assert!(warning.contains(holder) && warning.contains("joint.reach") && warning.contains("joint.edit"), "{case}: {warning}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #11723: a part inside an assembly placed twice has no path of its own. When its joints put it
|
||||
/// back, the warning still names the joint that holds it.
|
||||
#[test]
|
||||
fn a_move_its_joints_undo_names_the_joint_in_a_repeated_assembly() {
|
||||
let mut s = Session::default();
|
||||
let (_, base) = part(&mut s, "Base", [0.0, 0.0, 0.0]);
|
||||
let arm = run(&mut s, "component.create", json!({"name": "Arm"}));
|
||||
let (arm_component, arm_at) = (arm["component"].as_u64().unwrap(), arm["occurrence"].as_u64().unwrap());
|
||||
run(&mut s, "component.activate", json!({"component": arm_component}));
|
||||
let plate = run(&mut s, "component.create", json!({"name": "Plate"}))["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0]}));
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
run(&mut s, "joint.as_built", json!({"type": "rigid", "a": base, "b": plate, "name": "Mount"}));
|
||||
run(&mut s, "occurrence.copy", json!({"occurrence": arm_at, "translate": [0, 50, 0]}));
|
||||
let r = run(&mut s, "occurrence.move", json!({"occurrence": plate, "translate": [0, 0, 30]}));
|
||||
assert_eq!(r["reverted"], json!(true), "{r}");
|
||||
let warning = r["warning"].as_str().unwrap_or_default();
|
||||
assert!(warning.contains("Mount"), "the warning names the joint: {warning}");
|
||||
}
|
||||
|
||||
@@ -331,6 +331,11 @@ fn row(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if rename.as_ref().is_some_and(|n| n != &name && s.doc.configs.rows.iter().any(|r| &r.name == n)) {
|
||||
return Err(bad(cmd, "configuration name already exists"));
|
||||
}
|
||||
for (column, value) in s.doc.configs.columns.iter().zip(&cells) {
|
||||
if matches!(column, Column::Suppress(_)) {
|
||||
solidcraft_doc::config::suppression_cell(value).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
}
|
||||
}
|
||||
let changes: Vec<_> = s
|
||||
.doc
|
||||
.configs
|
||||
@@ -743,6 +748,78 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_rows_reject_invalid_suppression_without_changes() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane":"XY", "name":"TestSketch"}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "config.configure", json!({"features":["TestSketch"]}));
|
||||
let before = s.doc.clone();
|
||||
let undo_len = s.undo.len();
|
||||
for value in [json!("banana"), json!(""), json!(2), json!(-1)] {
|
||||
for params in [
|
||||
json!({"name":"Bad", "values":{"suppress:TestSketch":value}}),
|
||||
json!({"name":"Copy", "from":"Default", "values":{"TestSketch":value}}),
|
||||
json!({"name":"Default", "rename":"Renamed", "values":{"TestSketch":value}}),
|
||||
] {
|
||||
let error = s.execute("config.row", ¶ms).expect_err("invalid suppression must be rejected when saving a row");
|
||||
assert!(error.to_string().contains("suppression must be true or false"), "{params}: {error}");
|
||||
assert_eq!(s.doc, before, "{params}");
|
||||
assert_eq!(s.undo.len(), undo_len, "{params}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_rows_validate_inherited_suppression_and_allow_repair() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane":"XY", "name":"TestSketch"}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "config.configure", json!({"features":["TestSketch"]}));
|
||||
// A row saved by an older reader may already contain invalid suppression text.
|
||||
s.doc_mut().configs.rows[0].cells[0] = "banana".into();
|
||||
let before = s.doc.clone();
|
||||
let undo_len = s.undo.len();
|
||||
for params in [json!({"name":"Copy", "from":"Default"}), json!({"name":"Default", "rename":"Renamed"})] {
|
||||
let error = s.execute("config.row", ¶ms).expect_err("inherited invalid suppression must also be checked");
|
||||
assert!(error.to_string().contains("suppression must be true or false"), "{params}: {error}");
|
||||
assert_eq!(s.doc, before, "{params}");
|
||||
assert_eq!(s.undo.len(), undo_len, "{params}");
|
||||
}
|
||||
run(&mut s, "config.row", json!({"name":"Default", "values":{"TestSketch":false}}));
|
||||
assert_eq!(s.doc.configs.rows[0].cells[0], "false");
|
||||
run(&mut s, "config.activate", json!({"row":"Default"}));
|
||||
assert!(!s.doc.features[0].suppressed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_rows_keep_supported_suppression_spellings() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane":"XY", "name":"TestSketch"}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "config.configure", json!({"features":["TestSketch"]}));
|
||||
for (value, suppressed) in [
|
||||
(json!(true), true),
|
||||
(json!(false), false),
|
||||
(json!(1), true),
|
||||
(json!(0), false),
|
||||
(json!("true"), true),
|
||||
(json!("false"), false),
|
||||
(json!(" YES "), true),
|
||||
(json!(" No "), false),
|
||||
(json!("1"), true),
|
||||
(json!("0"), false),
|
||||
] {
|
||||
let before = s.doc.features.clone();
|
||||
run(&mut s, "config.row", json!({"name":"Accepted", "values":{"suppress:TestSketch":value}}));
|
||||
assert_eq!(s.doc.features, before, "saving a row does not activate it");
|
||||
let text = value.as_str().map(str::to_owned).unwrap_or_else(|| value.to_string());
|
||||
assert_eq!(s.doc.configs.rows[1].cells[0], text, "accepted spellings are preserved");
|
||||
run(&mut s, "config.activate", json!({"row":"Accepted"}));
|
||||
assert_eq!(s.doc.features[0].suppressed, suppressed, "{value}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_rows_validate_the_final_parameter_graph_without_activating_it() {
|
||||
let mut s = Session::default();
|
||||
|
||||
@@ -13,8 +13,8 @@ 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::{
|
||||
BoundingSolidParams, BoxParams, ConeParams, CylinderParams, ExtrudeParams, FeatureNotice, FeatureResult, NoticeAction, OffsetPlaneParams,
|
||||
PipeParams, PlaneAtAngleParams, ScaleParams, SphereParams, TorusParams,
|
||||
AppliedRadius, BoundingSolidParams, BoxParams, 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};
|
||||
@@ -30,6 +30,7 @@ const BOUNDING_SOLID_CONTRACT: TypedContract<BoundingSolidParams> = TypedContrac
|
||||
const SCALE_CONTRACT: TypedContract<ScaleParams> = 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();
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("solid.extrude", "Extrude", crate::checked!("solid.extrude", extrude))
|
||||
@@ -78,11 +79,13 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.contract(&CONE_CONTRACT)
|
||||
.output(output_schema_for::<FeatureResult>)
|
||||
.params("radius | diameter, height: expr (the base circle, and the top's distance along the axis; a negative height goes the other way); top_radius? | top_diameter?: expr (the top circle; default 0: a full cone ending in an apex); base?: [x,y,z] (centre of the base circle: numbers in mm or length expressions; default: the origin); axis?: X|Y|Z|[x,y,z] (default Z); operation?, touching?, targets?: [body], name?, body_name?"),
|
||||
CommandSpec::new("solid.fillet", "Fillet", fillet)
|
||||
CommandSpec::new("solid.fillet", "Fillet", crate::checked!("solid.fillet", fillet))
|
||||
.at("SOLID", "MODIFY")
|
||||
.icon("fillet")
|
||||
.key("F")
|
||||
.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?; 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); 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)"),
|
||||
.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?; 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?"),
|
||||
CommandSpec::new("solid.combine", "Combine", combine).at("SOLID", "MODIFY").icon("combine").params("target: body; tools: [body]; operation?: join|cut|intersect; touching?: separate|one_body (what a join makes of pieces that share no face: separate, the default, makes each piece that touches another only along an edge or at a point a body of its own and keeps pieces that touch nothing in one body; one_body keeps every piece in one body); keep_tools?: bool; new_component?: bool (the result goes into a new component; component_name?)"),
|
||||
CommandSpec::new("solid.pattern.rectangular", "Rectangular Pattern", pattern_rect)
|
||||
@@ -102,7 +105,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; 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?; 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; 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)"),
|
||||
CommandSpec::new("solid.pipe", "Pipe", crate::checked!("solid.pipe", pipe))
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("pipe")
|
||||
@@ -1788,6 +1791,15 @@ fn fillet(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(cmd, "`setbacks` are built for a constant-radius fillet (`type: constant`, `fit: exact`) only"));
|
||||
}
|
||||
}
|
||||
// How two rounds turn at a sharp side edge.
|
||||
let turn = match str_(p, "turn").map(str::to_ascii_lowercase).as_deref() {
|
||||
None | Some("mitre") => solidcraft_doc::FilletTurn::Mitre,
|
||||
Some("swept") => solidcraft_doc::FilletTurn::Swept,
|
||||
Some(o) => return Err(bad(cmd, format!("unknown turn `{o}` (mitre or swept)"))),
|
||||
};
|
||||
if !turn.is_mitre() && (!style.is_constant() || fit == solidcraft_doc::FilletFit::Taper || !corner.is_rolling() || !setbacks.is_empty()) {
|
||||
return Err(bad(cmd, solidcraft_doc::SWEPT_TAKES));
|
||||
}
|
||||
let mut out = add_feature(
|
||||
s,
|
||||
p,
|
||||
@@ -1799,6 +1811,7 @@ fn fillet(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fit,
|
||||
corner,
|
||||
setbacks,
|
||||
turn,
|
||||
tangent_chain: bool_(p, "tangent_chain").unwrap_or(true),
|
||||
},
|
||||
)?;
|
||||
@@ -1808,9 +1821,9 @@ fn fillet(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
&& let Some(r) = state.fitted_radius(id)
|
||||
{
|
||||
out["radius_applied"] = serde_json::json!(r);
|
||||
let each: Vec<Value> =
|
||||
state.fitted_edges(id).unwrap_or_default().into_iter().map(|(p, r)| serde_json::json!({"edge": [p.x, p.y, p.z], "radius": r})).collect();
|
||||
out["radii_applied"] = Value::Array(each);
|
||||
let each: Vec<AppliedRadius> =
|
||||
state.fitted_edges(id).unwrap_or_default().into_iter().map(|(p, radius)| AppliedRadius { edge: [p.x, p.y, p.z], radius }).collect();
|
||||
out["radii_applied"] = serde_json::to_value(each).map_err(|e| EngineError::Internal(cmd.into(), e.to_string()))?;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
@@ -2825,15 +2838,24 @@ fn hole(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let e = req_expr(cmd, p, k)?;
|
||||
Ok(e)
|
||||
};
|
||||
// Given but unreadable (`true`, a list, an empty text): refused, never a plain counterbore.
|
||||
let bridge = match (expr(p, "bridge"), p.get("bridge")) {
|
||||
(Some(b), _) => Some(b),
|
||||
(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") {
|
||||
"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") }
|
||||
}
|
||||
"counterbore" => solidcraft_doc::HoleKind::Counterbore {
|
||||
cb_diameter: get("cb_diameter")?,
|
||||
cb_depth: get("cb_depth")?,
|
||||
tip_angle: expr(p, "tip_angle"),
|
||||
bridge: bridge.clone(),
|
||||
},
|
||||
"countersink" => solidcraft_doc::HoleKind::Countersink {
|
||||
cs_diameter: get("cs_diameter")?,
|
||||
cs_angle: expr(p, "cs_angle").unwrap_or_else(|| "90 deg".into()),
|
||||
@@ -2841,6 +2863,11 @@ fn hole(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
},
|
||||
other => return Err(bad(cmd, format!("unknown hole type `{other}`"))),
|
||||
};
|
||||
match (&kind, &bridge) {
|
||||
(solidcraft_doc::HoleKind::Counterbore { .. }, Some(b)) => check_expr(s, b, Kind::Length, cmd, "bridge")?,
|
||||
(_, Some(_)) => return Err(bad(cmd, "`bridge` needs `type: counterbore`")),
|
||||
_ => {}
|
||||
}
|
||||
let to = match p.get("to") {
|
||||
Some(v) => Some(vec3(v).ok_or_else(|| bad(cmd, "`to` must be a point [x, y, z] on the face to drill to"))?),
|
||||
None => None,
|
||||
@@ -3567,6 +3594,87 @@ mod corner_tests {
|
||||
eprintln!("mixed corner g2: {err}");
|
||||
assert!(err.contains("not supported yet") && err.contains("(40.000, 20.000, 20.000)") && err.contains("all convex or all concave"), "{err}");
|
||||
}
|
||||
|
||||
/// `solid.fillet turn?: mitre|swept` on the four base edges of a 10 x 10 x 5 boss on a
|
||||
/// 40 x 30 x 10 plate, r 1: at each upright boss edge the two rounds turn round it. Each base
|
||||
/// edge gains the spandrel A = (1 - π/4) r² along its 10, and the corners the rounds' part
|
||||
/// beyond both walls, whose section's first moment about the upright edge is
|
||||
/// M = (5/6 - π/4) r³: mitred (the default), 8 M; swept, that section revolved a quarter turn at
|
||||
/// each corner, 2π M, the corner's face the horn torus of radii r, r about the upright edge,
|
||||
/// centred r above the plate. The turn is saved; an unknown turn and a swept chord round are
|
||||
/// refused as parameters, and swept rounds along a pocket's floor (its upright edges concave)
|
||||
/// with the kernel's reason.
|
||||
#[test]
|
||||
fn rounds_turning_round_a_boss_edge_are_mitred_or_swept() {
|
||||
use std::f64::consts::PI;
|
||||
const BASE: [[f64; 3]; 4] = [[20.0, 10.0, 10.0], [25.0, 15.0, 10.0], [20.0, 20.0, 10.0], [15.0, 15.0, 10.0]];
|
||||
let boss = || {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", &json!({"length": 40, "width": 30, "height": 10, "body_name": "B"}));
|
||||
run(&mut s, "solid.box", &json!({"corner": [15, 10, 10], "length": 10, "width": 10, "height": 5, "operation": "join"}));
|
||||
s
|
||||
};
|
||||
let turned = |turn: Option<&str>| {
|
||||
let mut s = boss();
|
||||
let mut p = json!({"edges": BASE, "radius": 1});
|
||||
if let Some(t) = turn {
|
||||
p["turn"] = json!(t);
|
||||
}
|
||||
let reply = run(&mut s, "solid.fillet", &p);
|
||||
assert_eq!(reply["warning"], json!(null), "{reply}");
|
||||
let body = s.world_state().unwrap().body("B").unwrap().body.clone();
|
||||
assert!(body.validity().is_empty(), "{:?}", body.validity());
|
||||
(s, solidcraft_kernel::measure(&body).unwrap(), body)
|
||||
};
|
||||
let (mitred, mitre, _) = turned(None);
|
||||
let (_, named, _) = turned(Some("mitre"));
|
||||
let (swept_session, swept, body) = turned(Some("swept"));
|
||||
eprintln!("mitre: {} faces, {:.9}; swept: {} faces, {:.9}", mitre.faces, mitre.volume, swept.faces, swept.volume);
|
||||
let r: f64 = 1.0;
|
||||
let (a, m) = ((1.0 - PI / 4.0) * r * r, (5.0 / 6.0 - PI / 4.0) * r * r * r);
|
||||
let near = |v: f64, want: f64| (v - want).abs() < 1e-9 * want;
|
||||
assert!(near(mitre.volume, 12500.0 + 40.0 * a + 8.0 * m), "{}", mitre.volume);
|
||||
assert!(near(swept.volume, 12500.0 + 40.0 * a + 2.0 * PI * m), "{}", swept.volume);
|
||||
assert_eq!(named.volume.to_bits(), mitre.volume.to_bits(), "mitre is the default");
|
||||
assert_eq!((mitre.faces, swept.faces), (15, 19));
|
||||
// The four corner faces (the record's torus is a ring torus, so a horn torus is its exact
|
||||
// rational form): every point on the horn torus about its upright edge.
|
||||
let mut corners = 0;
|
||||
for f in &body.record().faces {
|
||||
if !matches!(f.surface, solidcore_record::brep::Surface::RationalSpline { .. }) {
|
||||
continue;
|
||||
}
|
||||
corners += 1;
|
||||
let eval = solidcraft_geom::brep::eval::SurfaceEval::new(&f.surface).unwrap();
|
||||
let [[u0, u1], [v0, v1]] = f.domain;
|
||||
for i in 0..=4 {
|
||||
for j in 0..=4 {
|
||||
let p = eval.point([u0 + (u1 - u0) * f64::from(i) / 4.0, v0 + (v1 - v0) * f64::from(j) / 4.0]).unwrap();
|
||||
let (cx, cy) = (if p.x < 20.0 { 15.0 } else { 25.0 }, if p.y < 15.0 { 10.0 } else { 20.0 });
|
||||
let rho = (p.x - cx).hypot(p.y - cy);
|
||||
let off = ((rho - r).powi(2) + (p.z - 10.0 - r).powi(2)).sqrt() - r;
|
||||
assert!(off.abs() < 1e-9, "{p:?} is {off} off the horn torus");
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(corners, 4);
|
||||
assert!(!mitred.doc.to_json().contains("\"turn\""));
|
||||
assert!(swept_session.doc.to_json().contains("\"turn\": \"swept\""));
|
||||
let mut s = boss();
|
||||
let e = s.execute("solid.fillet", &json!({"edges": BASE, "radius": 1, "turn": "round"})).unwrap_err().to_string();
|
||||
assert!(e.contains("unknown turn `round` (mitre or swept)"), "{e}");
|
||||
let e = s.execute("solid.fillet", &json!({"edges": BASE, "radius": 1, "turn": "swept", "type": "chord"})).unwrap_err().to_string();
|
||||
assert!(e.contains(solidcraft_doc::SWEPT_TAKES), "{e}");
|
||||
// A pocket 5 deep in the plate, its floor's edges rounded: the rounds meet at its concave
|
||||
// upright edges.
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", &json!({"length": 40, "width": 30, "height": 10, "body_name": "P"}));
|
||||
run(&mut s, "solid.box", &json!({"corner": [10, 10, 5], "length": 20, "width": 10, "height": 6, "operation": "cut"}));
|
||||
let rim = json!([[20, 10, 5], [30, 15, 5], [20, 20, 5], [10, 15, 5]]);
|
||||
let e = s.execute("solid.fillet", &json!({"edges": rim, "radius": 1, "turn": "swept"})).unwrap_err().to_string();
|
||||
eprintln!("swept along a pocket's rim: {e}");
|
||||
assert!(e.contains("swept corner at (") && e.contains("round that edge at a radius of at least 1 first"), "{e}");
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
#[path = "fillet_connected_rounds_tests.rs"]
|
||||
|
||||
@@ -408,3 +408,24 @@ fn a_motion_link_takes_its_driven_joints_freedom_with_nothing_grounded() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rolling the marker back works a gear's ratio out from the faces as they were then, so the
|
||||
/// editor's pinned model and its source differ in the link's ratio, and the edit still goes
|
||||
/// through.
|
||||
#[test]
|
||||
fn an_editor_accepts_the_ratios_rolling_the_marker_works_out() {
|
||||
let mut s = gear_train();
|
||||
let before = s.doc.features.len();
|
||||
// G1's wall moves out to a radius of 12, so the link's ratio goes from -0.5 to -0.6.
|
||||
run(&mut s, "solid.offset_face", json!({"faces": [[10, 0, 2.5]], "distance": 2}));
|
||||
let ratio = |s: &Session| s.doc.assembly.links.first().map(|l| l.ratio).unwrap_or(f64::NAN);
|
||||
assert!(close_to(ratio(&s), -0.6), "{}", ratio(&s));
|
||||
let original = s.scratch();
|
||||
run(&mut s, "timeline.roll_to", json!({"position": before}));
|
||||
let source = s.scratch();
|
||||
assert!(close_to(ratio(&source), -0.5), "before the offset, the pitch radius is 10: {}", ratio(&source));
|
||||
let prepared = s.scratch().compute_editor_commands(&[], &source, original).unwrap();
|
||||
s.accept_commands(prepared).unwrap();
|
||||
assert_eq!(s.doc.marker, None);
|
||||
assert!(close_to(ratio(&s), -0.6), "{}", ratio(&s));
|
||||
}
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
//! Read-only validation of candidate engineering data; no assignment or simulation.
|
||||
use super::CommandSpec;
|
||||
use crate::params::bad;
|
||||
use crate::schema::material_properties::{CardModulus, CardValidated, DirectionalModulusParams, ValidateCardParams};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{Result, Session};
|
||||
use serde_json::{Value, json};
|
||||
use serde_json::Value;
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("material.properties.validate", "Validate Physical Material Data", validate)
|
||||
const VALIDATE_CONTRACT: TypedContract<ValidateCardParams> = TypedContract::new();
|
||||
const DIRECTIONAL_CONTRACT: TypedContract<DirectionalModulusParams> = TypedContract::new();
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("material.properties.validate", "Validate Physical Material Data", crate::checked!("material.properties.validate", validate))
|
||||
.icon("material")
|
||||
.noundo()
|
||||
.doc_only()
|
||||
.contract(&VALIDATE_CONTRACT)
|
||||
.output(output_schema_for::<CardValidated>)
|
||||
.params("card: schema_version 1 engineering material card in SI units. Returns numerical consistency, derived isotropic moduli, missing conditions and sources needing review. Read-only; does not assign/save properties or provide an analysis solver. See docs/physical-material-cards.md"),
|
||||
CommandSpec::new("material.properties.directional", "Directional Elastic Modulus", directional).icon("material").noundo().doc_only()
|
||||
CommandSpec::new("material.properties.directional", "Directional Elastic Modulus", crate::checked!("material.properties.directional", directional)).icon("material").noundo().doc_only()
|
||||
.contract(&DIRECTIONAL_CONTRACT)
|
||||
.output(output_schema_for::<CardModulus>)
|
||||
.params("card: schema_version 1 engineering material card; direction: three finite nonzero-vector components in the card's material-local axes. Returns normalized direction, Young modulus in Pa and source/condition review. Read-only constitutive query; no structural solver, persistence or body-axis assignment. See docs/physical-material-cards.md")];
|
||||
|
||||
fn directional(_: &mut Session, params: &Value) -> Result<Value> {
|
||||
@@ -28,7 +37,7 @@ fn directional(_: &mut Session, params: &Value) -> Result<Value> {
|
||||
let direction = [component(0)?, component(1)?, component(2)?];
|
||||
let card = solidcraft_doc::physical_material::admit_card(value).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
let response = card.directional_modulus(direction).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
serde_json::to_value(response).map_err(|e| bad(cmd, e.to_string()))
|
||||
serde_json::to_value(CardModulus::from(response)).map_err(|e| bad(cmd, e.to_string()))
|
||||
}
|
||||
|
||||
fn validate(_: &mut Session, params: &Value) -> Result<Value> {
|
||||
@@ -38,13 +47,15 @@ fn validate(_: &mut Session, params: &Value) -> Result<Value> {
|
||||
.ok_or_else(|| bad("material.properties.validate", "expected {card: ...}"))?;
|
||||
let card = object.get("card").ok_or_else(|| bad("material.properties.validate", "missing card"))?;
|
||||
let result = solidcraft_doc::physical_material::validate_card(card).map_err(|e| bad("material.properties.validate", e.to_string()))?;
|
||||
Ok(json!({"valid":true,"validation":result}))
|
||||
let reply = CardValidated { valid: true, validation: result.into(), param_warnings: Vec::new() };
|
||||
serde_json::to_value(reply).map_err(|e| bad("material.properties.validate", e.to_string()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::doc::Document;
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn card() -> Value {
|
||||
|
||||
@@ -10,28 +10,52 @@ use solidcraft_doc::metadata::{self, BOM_TYPE, MAX_KEYS, Owner};
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, str_};
|
||||
use crate::schema::metadata::{MetadataParams, MetadataRemoveParams, MetadataRemoveResult, MetadataResult, MetadataSetParams, MetadataSetResult};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{Result, Session};
|
||||
|
||||
const READ_CONTRACT: TypedContract<MetadataParams> = TypedContract::new();
|
||||
const SET_CONTRACT: TypedContract<MetadataSetParams> = TypedContract::new();
|
||||
const REMOVE_CONTRACT: TypedContract<MetadataRemoveParams> = TypedContract::new();
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("document.metadata", "Design Properties", read).icon("settings").noundo().doc_only().params(
|
||||
"→ metadata: the design's metadata object as saved (other tools' keys too); properties: each of SolidCraft's design \
|
||||
properties (project, part number, revision…), its text or null (absent, or not text)",
|
||||
),
|
||||
CommandSpec::new("document.metadata.set", "Change Design Properties", set).icon("settings").doc_only().params(
|
||||
"key and value, or values: {key: value, …} (all or nothing, one undo step). SolidCraft's keys, the ones document.metadata's \
|
||||
properties lists, take text (\"\" or null removes); another tool's data goes under its reverse-DNS key such as \
|
||||
com.example.tool (an object; null removes) → metadata, changed: [keys]",
|
||||
),
|
||||
CommandSpec::new("document.metadata.remove", "Remove Design Property", remove)
|
||||
CommandSpec::new("document.metadata", "Design Properties", crate::checked!("document.metadata", read))
|
||||
.icon("settings")
|
||||
.noundo()
|
||||
.doc_only()
|
||||
.contract(&READ_CONTRACT)
|
||||
.output(output_schema_for::<MetadataResult>)
|
||||
.params(
|
||||
"→ metadata: the design's metadata object as saved (other tools' keys too); properties: each of SolidCraft's design \
|
||||
properties (project, part number, revision…), its text or null (absent, or not text)",
|
||||
),
|
||||
CommandSpec::new("document.metadata.set", "Change Design Properties", crate::checked!("document.metadata.set", set))
|
||||
.icon("settings")
|
||||
.doc_only()
|
||||
.contract(&SET_CONTRACT)
|
||||
.output(output_schema_for::<MetadataSetResult>)
|
||||
.params(
|
||||
"key and value, or values: {key: value, …} (all or nothing, one undo step). SolidCraft's keys, the ones document.metadata's \
|
||||
properties lists, take text (\"\" or null removes); another tool's data goes under its reverse-DNS key such as \
|
||||
com.example.tool (an object; null removes) → metadata, changed: [keys]",
|
||||
),
|
||||
CommandSpec::new("document.metadata.remove", "Remove Design Property", crate::checked!("document.metadata.remove", remove))
|
||||
.icon("delete")
|
||||
.doc_only()
|
||||
.contract(&REMOVE_CONTRACT)
|
||||
.output(output_schema_for::<MetadataRemoveResult>)
|
||||
.params("key, or keys: [keys] (any key, also one another tool wrote) → metadata, removed: [keys that were there]"),
|
||||
];
|
||||
|
||||
/// A reply built from its schema's type, so the two can't drift apart.
|
||||
fn reply(cmd: &str, r: impl serde::Serialize) -> Result<Value> {
|
||||
serde_json::to_value(r).map_err(|e| bad(cmd, e.to_string()))
|
||||
}
|
||||
|
||||
fn read(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let m = &s.doc.metadata;
|
||||
let properties: Map<String, Value> = metadata::keys(Owner::Design).map(|k| (k.name.to_string(), json!(m.text(k.name)))).collect();
|
||||
Ok(json!({"metadata": m, "properties": properties}))
|
||||
reply("document.metadata", MetadataResult { metadata: m.0.clone(), properties })
|
||||
}
|
||||
|
||||
fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -60,7 +84,7 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if next != s.doc.metadata {
|
||||
s.doc_mut().metadata = next;
|
||||
}
|
||||
Ok(json!({"metadata": s.doc.metadata, "changed": changed}))
|
||||
reply(cmd, MetadataSetResult { metadata: s.doc.metadata.0.clone(), changed, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn remove(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -84,7 +108,8 @@ fn remove(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
m.remove(k);
|
||||
}
|
||||
}
|
||||
Ok(json!({"metadata": s.doc.metadata, "removed": removed}))
|
||||
let removed = removed.into_iter().map(str::to_string).collect();
|
||||
reply(cmd, MetadataRemoveResult { metadata: s.doc.metadata.0.clone(), removed, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -46,6 +46,7 @@ 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 use file::open_steps::OpenSteps;
|
||||
pub(crate) use inspect::color_param;
|
||||
pub use join_pieces::{Addition, addition};
|
||||
pub use parts::part_length_label;
|
||||
pub use sketch::dimension_roles;
|
||||
|
||||
@@ -541,6 +541,84 @@ fn isolate_nested_child_hides_ancestor_material_and_sibling() {
|
||||
}
|
||||
}
|
||||
|
||||
/// #7896: isolating a body hides its own component's other bodies one by one and every other
|
||||
/// placement as a placement, so the Browser row of a component shows it is off and its eye
|
||||
/// brings it back. A second placement of the body's own component is another placement.
|
||||
/// #7896: isolating a body inside a hidden Bodies folder or group shows that folder and group
|
||||
/// again, so the body is visible; the folder's other bodies stay hidden.
|
||||
#[test]
|
||||
fn isolate_shows_the_hidden_folder_and_group_a_picked_body_is_in() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Kept"}));
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"corner":[20,0,0],"body_name":"Other","operation":"new"}));
|
||||
let group = run(&mut s, "browser.group", json!({"folder": "bodies", "items": ["Kept"], "name": "Held"}));
|
||||
let group = group["group"].as_u64().or_else(|| group["id"].as_u64()).expect("the group's id");
|
||||
run(&mut s, "browser.visibility", json!({"tree_folders": ["c0/bodies", format!("c0/g{group}")], "visible": false}));
|
||||
assert!(!s.body_visible("Kept") && !s.body_visible("Other"));
|
||||
run(&mut s, "browser.visibility", json!({"items": ["Kept"], "visible": true, "isolate": true}));
|
||||
assert!(s.body_visible("Kept"), "the picked body shows: {:?}", s.visibility.hidden_folders);
|
||||
assert!(!s.body_visible("Other"), "the folder's other body stays hidden");
|
||||
assert!(s.visibility.hidden_folders.is_empty(), "{:?}", s.visibility.hidden_folders);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn isolate_bodies_hides_other_components_as_components() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"corner":[-20,0,0],"body_name":"Root"}));
|
||||
let comp = |s: &mut Session, name: &str| {
|
||||
let c = run(s, "component.create", json!({"name": name, "parent": "root", "grounded": false}));
|
||||
c["occurrence"].as_u64().unwrap()
|
||||
};
|
||||
let a = comp(&mut s, "CompA");
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"BoxA"}));
|
||||
run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"corner":[0,0,20],"body_name":"BoxA2"}));
|
||||
let b = comp(&mut s, "CompB");
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"corner":[20,0,0],"body_name":"BoxB1"}));
|
||||
run(&mut s, "solid.box", json!({"length":5,"width":5,"height":5,"corner":[40,0,0],"body_name":"BoxB2"}));
|
||||
let a2 = run(&mut s, "occurrence.copy", json!({"occurrence":a,"translate":[0,40,0]}))["occurrence"].as_u64().unwrap();
|
||||
let in_a = s.occurrence_bodies(&[a], false).unwrap();
|
||||
let (box_a, box_a2) = (in_a.iter().find(|n| !n.contains("BoxA2")).unwrap().clone(), in_a.iter().find(|n| n.contains("BoxA2")).unwrap().clone());
|
||||
|
||||
let out = run(&mut s, "browser.visibility", json!({"visible":true,"items":[box_a],"isolate":true}));
|
||||
assert_eq!(out["isolated"], json!([box_a]));
|
||||
let mut occurrences = s.visibility.hidden_occurrences.clone();
|
||||
occurrences.sort();
|
||||
let mut others = vec![vec![b], vec![a2]];
|
||||
others.sort();
|
||||
assert_eq!(occurrences, others, "CompB and CompA's other placement go as placements");
|
||||
let mut bodies = s.visibility.hidden_bodies.clone();
|
||||
bodies.sort();
|
||||
assert_eq!(bodies, vec![box_a2.clone(), "Root".to_string()], "only bodies beside BoxA go one by one");
|
||||
assert!(s.body_visible(&box_a) && !s.body_visible(&box_a2) && !s.body_visible("Root"));
|
||||
for name in s.occurrence_bodies(&[b], false).unwrap().iter().chain(&s.occurrence_bodies(&[a2], false).unwrap()) {
|
||||
assert!(!s.body_visible(name), "{name}");
|
||||
}
|
||||
// CompB's eye brings it back whole.
|
||||
run(&mut s, "browser.visibility", json!({"visible":true,"paths":[[b]]}));
|
||||
assert!(s.occurrence_bodies(&[b], false).unwrap().iter().all(|n| s.body_visible(n)));
|
||||
|
||||
// Isolating a root body hides every placement as a placement and no body one by one.
|
||||
run(&mut s, "browser.visibility", json!({"visible":true,"items":["Root"],"isolate":true}));
|
||||
let mut occurrences = s.visibility.hidden_occurrences.clone();
|
||||
occurrences.sort();
|
||||
let mut all = vec![vec![a], vec![b], vec![a2]];
|
||||
all.sort();
|
||||
assert_eq!(occurrences, all);
|
||||
assert!(s.visibility.hidden_bodies.is_empty() && s.body_visible("Root"), "{:?}", s.visibility.hidden_bodies);
|
||||
|
||||
// Only bodies isolate, and only shown; a refusal changes nothing.
|
||||
let before = s.visibility.clone();
|
||||
for p in [
|
||||
json!({"visible":false,"items":["Root"],"isolate":true}),
|
||||
json!({"visible":true,"items":["XY"],"isolate":true}),
|
||||
json!({"visible":true,"items":["Nope"],"isolate":true}),
|
||||
json!({"visible":true,"folder":"bodies","isolate":true}),
|
||||
] {
|
||||
assert!(s.execute("browser.visibility", &p).is_err(), "{p}");
|
||||
}
|
||||
assert_eq!(s.visibility, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_new_child_uses_inactive_parent_placement() {
|
||||
let mut s = Session::default();
|
||||
|
||||
@@ -15,27 +15,43 @@ use solidcraft_geom::prelude::*;
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, num, str_, vec3};
|
||||
use crate::schema::parts::{InsertedPart, PartFamily, PartsInsertParams, PartsLibrary, PartsLibraryParams, PartsResizeParams, ResizedPart};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
const LIBRARY_CONTRACT: TypedContract<PartsLibraryParams> = TypedContract::new();
|
||||
const INSERT_CONTRACT: TypedContract<PartsInsertParams> = TypedContract::new();
|
||||
const RESIZE_CONTRACT: TypedContract<PartsResizeParams> = TypedContract::new();
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("parts.library", "Standard Parts", library)
|
||||
CommandSpec::new("parts.library", "Standard Parts", crate::checked!("parts.library", library))
|
||||
.icon("library")
|
||||
.noundo()
|
||||
.contract(&LIBRARY_CONTRACT)
|
||||
.output(output_schema_for::<PartsLibrary>)
|
||||
.params("family? → families (name, standard, system: metric|inch, sizes, lengths) and, with family, each size's dimensions (mm)"),
|
||||
CommandSpec::new("parts.fastener", "Insert Fastener", insert_atomic)
|
||||
CommandSpec::new("parts.fastener", "Insert Fastener", crate::checked!("parts.fastener", insert_atomic))
|
||||
.at("SOLID", "INSERT")
|
||||
.icon("fastener")
|
||||
.contract(&INSERT_CONTRACT)
|
||||
.output(output_schema_for::<InsertedPart>)
|
||||
.params("the Insert Part dialog's command (same as parts.insert): family, size, length?, at?|point?+direction?, name?"),
|
||||
CommandSpec::new("parts.insert", "Insert Part", insert_atomic).icon("fastener").params(
|
||||
"family (socket_head_cap_screw | button_head_cap_screw | hex_bolt | hex_nut | washer | set_screw | dowel_pin | bearing | \
|
||||
CommandSpec::new("parts.insert", "Insert Part", crate::checked!("parts.insert", insert_atomic))
|
||||
.icon("fastener")
|
||||
.contract(&INSERT_CONTRACT)
|
||||
.output(output_schema_for::<InsertedPart>)
|
||||
.params(
|
||||
"family (socket_head_cap_screw | button_head_cap_screw | hex_bolt | hex_nut | washer | set_screw | dowel_pin | bearing | \
|
||||
hex_cap_screw_inch | hex_nut_inch | washer_sae | washer_uss); size (\"M6\", \"608\" for bearings, \"1/2-13 UNC\" for inch screws and nuts, \
|
||||
\"1/2\" for inch washers); length? (mm, inch parts too; screws, bolts, set screws, pins; default a standard length near 2.5 d); \
|
||||
at?: [x,y,z] on a hole's rim or wall (the part seats on the hole's face with a rigid joint), or a list of them (one component, \
|
||||
placed and seated once per hole) | point?: [x,y,z] with direction?: [x,y,z] (no joint); name? → component, occurrence, joint?, \
|
||||
and with at: occurrences, joints (one per hole, in pick order)",
|
||||
),
|
||||
CommandSpec::new("parts.resize", "Change Part Size", resize_atomic)
|
||||
),
|
||||
CommandSpec::new("parts.resize", "Change Part Size", crate::checked!("parts.resize", resize_atomic))
|
||||
.icon("scale")
|
||||
.contract(&RESIZE_CONTRACT)
|
||||
.output(output_schema_for::<ResizedPart>)
|
||||
.params("component: id|name of an inserted part; size?; length? — rebuilds it (joints keep it seated)"),
|
||||
];
|
||||
|
||||
@@ -572,7 +588,7 @@ fn build(s: &mut Session, sh: &Shape) -> Result<()> {
|
||||
}
|
||||
|
||||
fn library(_s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let fam = |id: &str| -> Value {
|
||||
let fam = |id: &str| -> PartFamily {
|
||||
let mut sizes: Vec<Value> = match id {
|
||||
"socket_head_cap_screw" => SHCS.iter().map(|r| json!({"size": r.0, "d": metric(r.0).map(|m| m.0), "pitch": metric(r.0).map(|m| m.1), "head_diameter": r.1, "head_height": r.2, "socket": r.3, "socket_depth": r.4})).collect(),
|
||||
"button_head_cap_screw" => BHCS.iter().map(|r| json!({"size": r.0, "d": metric(r.0).map(|m| m.0), "pitch": metric(r.0).map(|m| m.1), "head_diameter": r.1, "head_height": r.2, "socket": r.3, "socket_depth": r.4})).collect(),
|
||||
@@ -612,15 +628,24 @@ fn library(_s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
let system = if is_inch(id) { "inch" } else { "metric" };
|
||||
json!({"family": id, "name": name, "standard": standard, "system": system, "sizes": sizes, "lengths": lengths})
|
||||
PartFamily { family: id.into(), name: name.into(), standard: standard.into(), system: system.into(), sizes, lengths }
|
||||
};
|
||||
match str_(p, "family") {
|
||||
Some(f) if FAMILIES.iter().any(|x| x.0 == f) => Ok(fam(f)),
|
||||
Some(f) if FAMILIES.iter().any(|x| x.0 == f) => {
|
||||
reply("parts.library", PartsLibrary { families: None, family: Some(fam(f)), param_warnings: Vec::new() })
|
||||
}
|
||||
Some(f) => Err(bad("parts.library", format!("unknown part family `{f}`"))),
|
||||
None => Ok(json!({"families": FAMILIES.iter().map(|f| fam(f.0)).collect::<Vec<_>>()})),
|
||||
None => reply(
|
||||
"parts.library",
|
||||
PartsLibrary { families: Some(FAMILIES.iter().map(|f| fam(f.0)).collect()), family: None, param_warnings: Vec::new() },
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn reply(cmd: &str, reply: impl serde::Serialize) -> Result<Value> {
|
||||
serde_json::to_value(reply).map_err(|e| bad(cmd, e.to_string()))
|
||||
}
|
||||
|
||||
fn part_name(family: &str, size: &str, sh: &Shape) -> String {
|
||||
match sh {
|
||||
Shape::Screw { len, .. } | Shape::Rod { len, .. } => format!("{} {}", label(family), size_with_length(family, size, *len)),
|
||||
@@ -747,14 +772,26 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.doc_mut().parts.insert(comp, solidcraft_doc::StandardPart { family: family.clone(), size: size.clone(), length: shape_len(&sh) });
|
||||
set_identity(s, comp, identity(&family, &size, &sh), None);
|
||||
run(s, "component.activate", json!({"component": active}))?;
|
||||
let mut out = json!({"component": comp, "occurrence": occ, "name": name});
|
||||
let mut out = InsertedPart {
|
||||
component: comp,
|
||||
occurrence: occ,
|
||||
name: name.clone(),
|
||||
joint: None,
|
||||
occurrences: None,
|
||||
joints: None,
|
||||
param_warnings: Vec::new(),
|
||||
};
|
||||
if !holes.is_empty() {
|
||||
// The first hole has the placement the component came with; each further hole places the
|
||||
// same part again. All are made before any is seated, while each is still at the part's
|
||||
// own origin, where the joint below takes the part's seating point to be.
|
||||
let mut occurrences = vec![json!(occ)];
|
||||
let mut occurrences = vec![occ];
|
||||
for _ in 1..holes.len() {
|
||||
occurrences.push(run(s, "occurrence.copy", json!({"component": comp}))?["occurrence"].clone());
|
||||
occurrences.push(
|
||||
run(s, "occurrence.copy", json!({"component": comp}))?["occurrence"]
|
||||
.as_u64()
|
||||
.ok_or_else(|| bad(cmd, "the copy has no occurrence"))?,
|
||||
);
|
||||
}
|
||||
let mut joints = Vec::new();
|
||||
for (i, ((a, h), o)) in holes.into_iter().zip(&occurrences).enumerate() {
|
||||
@@ -766,11 +803,11 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"joint.create",
|
||||
json!({"type": "rigid", "a": {"occurrence": h, "circle": a}, "b": {"occurrence": o, "point": [0, 0, 0], "z": [0, 0, -1]}, "name": seat}),
|
||||
)?;
|
||||
joints.push(j["joint"].clone());
|
||||
joints.push(j["joint"].as_u64().ok_or_else(|| bad(cmd, "the joint has no id"))?);
|
||||
}
|
||||
out["joint"] = joints.first().cloned().unwrap_or(Value::Null);
|
||||
out["occurrences"] = Value::Array(occurrences);
|
||||
out["joints"] = Value::Array(joints);
|
||||
out.joint = joints.first().copied();
|
||||
out.occurrences = Some(occurrences);
|
||||
out.joints = Some(joints);
|
||||
} else if let Some(pt) = p.get("point").and_then(vec3) {
|
||||
// Placed at a point, its +z along `direction`.
|
||||
let dir = p.get("direction").and_then(vec3).and_then(|d| d.normalized()).unwrap_or(Vec3::Z);
|
||||
@@ -789,7 +826,7 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
o.transform = m;
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
reply(cmd, out)
|
||||
}
|
||||
|
||||
/// The most holes one insert seats a part in.
|
||||
@@ -887,7 +924,7 @@ fn resize(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(e) = s.model.results.iter().find_map(|r| r.error.clone()) {
|
||||
return Err(EngineError::Other(e));
|
||||
}
|
||||
Ok(json!({"component": comp, "name": name, "size": size, "length": shape_len(&sh)}))
|
||||
reply(cmd, ResizedPart { component: comp, name, size, length: shape_len(&sh), param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -404,7 +404,7 @@ mod tests {
|
||||
s.run_script(&crate::sample::script()).unwrap();
|
||||
let lip = json!({"body": "Plate", "face": [51.89, 45.2, 8.0], "width": "1.5 mm", "height": "3.5 mm", "side": "inside", "type": "lip"});
|
||||
let e = s.execute("plastic.lip", &lip).unwrap_err().to_string();
|
||||
assert!(e.contains("rim face") && e.contains("2 openings alike"), "{e}");
|
||||
assert!(e.contains("one clear opening") && e.contains("2 openings alike"), "{e}");
|
||||
// The boss alone, no holes: its foot is not an opening either.
|
||||
let mut b = Session::default();
|
||||
run(&mut b, "solid.box", json!({"length": 80, "width": 50, "height": 8, "body_name": "Plate"}));
|
||||
|
||||
@@ -81,21 +81,35 @@ CommandSpec::new("render.preset.apply", "Apply Native Studio", studio_presets::a
|
||||
.params("Same reviewed input as render.preset.resolve, expected_revision required, replace_lighting_and_environment:true and replace_stage:true when an existing stage would change. Re-resolves the complete candidate and publishes one undo or no mutation; camera, optical bindings, geometry and engineering materials are preserved."),
|
||||
CommandSpec::new("render.quality.prepare", "Review Fixed Sampling Budget", studio_presets::quality).icon("settings").noundo().doc_only()
|
||||
.params("id: Q-*; variant:fixed_samples_png; acknowledge_omissions:true. Read-only bounded GPU still-budget projection, not full adaptive/convergence/linear-master quality acceptance. Returns dimensions/samples/depth/seed/denoise and explicit omissions. Never switches executor or mutates a shot."),
|
||||
CommandSpec::new("render.shot.duplicate", "Duplicate Render Shot", shots::duplicate).icon("copy").doc_only()
|
||||
CommandSpec::new("render.shot.duplicate", "Duplicate Render Shot", crate::checked!("render.shot.duplicate", shots::duplicate)).icon("copy").doc_only()
|
||||
.contract(&shots::CONTRACT)
|
||||
.output(crate::schema::output_schema_for::<crate::schema::render_shots::ShotChange>)
|
||||
.params("name, new_name, expected_revision:u64. Copies exact saved setup and resource references in one undo. Existing destinations refuse; captured jobs and camera navigation are unchanged."),
|
||||
CommandSpec::new("render.shot.rename", "Rename Render Shot", shots::rename).icon("settings").doc_only()
|
||||
CommandSpec::new("render.shot.rename", "Rename Render Shot", crate::checked!("render.shot.rename", shots::rename)).icon("settings").doc_only()
|
||||
.contract(&shots::CONTRACT)
|
||||
.output(crate::schema::output_schema_for::<crate::schema::render_shots::ShotChange>)
|
||||
.params("name, new_name, expected_revision:u64. Renames exact saved setup in one undo; an existing destination or stale review refuses."),
|
||||
CommandSpec::new("render.shot.remove", "Remove Render Shot", shots::remove).icon("delete").doc_only()
|
||||
CommandSpec::new("render.shot.remove", "Remove Render Shot", crate::checked!("render.shot.remove", shots::remove)).icon("delete").doc_only()
|
||||
.contract(&shots::CONTRACT)
|
||||
.output(crate::schema::output_schema_for::<crate::schema::render_shots::ShotChange>)
|
||||
.params("name, expected_revision:u64. Removes only that saved setup in one undo; retains resources, geometry and captured results."),
|
||||
CommandSpec::new("render.library.preview", "Inspect Original Surface Map", library::preview).icon("appearance").noundo().doc_only()
|
||||
CommandSpec::new("render.library.preview", "Inspect Original Surface Map", crate::checked!("render.library.preview", library::preview)).icon("appearance").noundo().doc_only()
|
||||
.contract(&library::PREVIEW_CONTRACT)
|
||||
.output(crate::schema::output_schema_for::<crate::schema::render_library::LibraryPreview>)
|
||||
.params("id: SURF-*. Returns bounded hash-verified original base-color PNG bytes and source-map fidelity label, not a rendered material thumbnail. No external paths, rendering or document edits."),
|
||||
CommandSpec::new("render.library.physical.prepare", "Prepare Physical Reference", physical_library::prepare).icon("inspect").noundo().doc_only()
|
||||
.params("id: named built-in physical profile, name: proposed new material label, acknowledge_reference:true. Read-only loss-aware creation plan using native ValidatedCard admission and one point density; returns source receipts, extended fields and omissions. Does not execute material.create, assign bodies or infer effective-part physics."),
|
||||
CommandSpec::new("render.stage.ground", "Save Render Ground", staging::ground).icon("render_scene")
|
||||
CommandSpec::new("render.stage.ground", "Save Render Ground", crate::checked!("render.stage.ground", staging::ground)).icon("render_scene")
|
||||
.contract(&staging::GROUND_CONTRACT)
|
||||
.output(crate::schema::output_schema_for::<crate::schema::render_stage::StageSaved>)
|
||||
.params("name: typed raytrace setup, set?: setup patch, margin_fraction?:0.25, gap_mm?:0.1, reflectance_linear?:[0.18,0.18,0.18], replace_stage?:false. Fits one finite passive ground below the visible product once and saves its physical transforms in one undo. Product AABB separation is rechecked on every render."),
|
||||
CommandSpec::new("render.stage.backdrop", "Save Physical Render Background", staging::backdrop).icon("render_scene").doc_only()
|
||||
CommandSpec::new("render.stage.backdrop", "Save Physical Render Background", crate::checked!("render.stage.backdrop", staging::backdrop)).icon("render_scene").doc_only()
|
||||
.contract(&staging::BACKDROP_CONTRACT)
|
||||
.output(crate::schema::output_schema_for::<crate::schema::render_stage::StageSaved>)
|
||||
.params("name: typed raytrace setup, set?: setup patch, kind: floor_wall or cyclorama, reflectance_linear?: [0.18,0.18,0.18], expected_revision: reviewed revision, replace_stage?:false. Fits a finite floor/wall or a 24-strip polygonal cyclorama behind the visible subject using the saved camera azimuth; world +Z up and a minimum one-mm stage envelope. Preserves subject geometry, lighting, output, pose and engineering materials; one undo and rechecked product separation. Not an infinite/analytically smooth/terrain background."),
|
||||
CommandSpec::new("render.stage.clear", "Remove Render Stage", staging::clear).icon("render_scene").doc_only()
|
||||
CommandSpec::new("render.stage.clear", "Remove Render Stage", crate::checked!("render.stage.clear", staging::clear)).icon("render_scene").doc_only()
|
||||
.contract(&staging::CLEAR_CONTRACT)
|
||||
.output(crate::schema::output_schema_for::<crate::schema::render_stage::StageSaved>)
|
||||
.params("name: setup, set?: setup patch. Removes saved render-only panels atomically; CAD geometry and lighting are unchanged."),
|
||||
CommandSpec::new("render.library.surface.bind", "Save Surface Binding", surface_bindings::bind).icon("appearance")
|
||||
.params("name: GPU setup; set?: setup patch; surface: same input as render.library.surface.prepare; replace?: false. Pins exact source hashes and definition/feature/occurrence identity with local-mm mapping in one undo. No paths, mesh ordinals or physical material changes."),
|
||||
@@ -103,11 +117,17 @@ CommandSpec::new("render.library.surface.unbind", "Remove Surface Binding", surf
|
||||
.params("name: setup, target: saved definition/feature/component/occurrence_path, set?: setup patch. Removes only that explicit binding in one undo; no device or current geometry required."),
|
||||
CommandSpec::new("render.result.pin", "Retain Displayed Render Result", jobs::pin).icon("inspect").noundo().doc_only()
|
||||
.params("job?: completed JobId (default selected result), or null to clear selection. Selects within the eight retained completed results; oldest is evicted first. Preserves exact export bytes and original host memory accounting. No design/undo change; preview or foreign jobs refuse."),
|
||||
CommandSpec::new("render.library.targets", "Find Render Library Targets", library::targets).icon("list").noundo()
|
||||
CommandSpec::new("render.library.targets", "Find Render Library Targets", crate::checked!("render.library.targets", library::targets)).icon("list").noundo()
|
||||
.contract(&library::TARGETS_CONTRACT)
|
||||
.output(crate::schema::output_schema_for::<crate::schema::render_library::LibraryTargets>)
|
||||
.params("query?: exact-name substring; offset?:0; limit?:128 (1..128). Returns a bounded page of captured body ordinals/names, revision and scene bounds. Settles the model through ordinary command handling; never returns a large mesh payload."),
|
||||
CommandSpec::new("render.library.list", "Browse Render Library", library::list).icon("list").noundo().doc_only()
|
||||
CommandSpec::new("render.library.list", "Browse Render Library", crate::checked!("render.library.list", library::list)).icon("list").noundo().doc_only()
|
||||
.contract(&library::LIST_CONTRACT)
|
||||
.output(crate::schema::output_schema_for::<crate::schema::render_library::LibraryPage>)
|
||||
.params("kind?: surfaces, selene_appearances, hdr, rigs, scenes, materials, lighting, environments, quality, native_scenes, selene_shots or capabilities; query?: bounded search; availability?: all/usable/specification/blocked; offset?:0; limit?:48 (1..128). Returns source status and explicit implemented projections separately. No device, file IO or design change."),
|
||||
CommandSpec::new("render.library.describe", "Inspect Render Library Resource", library::describe).icon("inspect").noundo().doc_only()
|
||||
CommandSpec::new("render.library.describe", "Inspect Render Library Resource", crate::checked!("render.library.describe", library::describe)).icon("inspect").noundo().doc_only()
|
||||
.contract(&library::DESCRIBE_CONTRACT)
|
||||
.output(crate::schema::output_schema_for::<crate::schema::render_library::LibraryDescription>)
|
||||
.params("kind, id: exact authored resource. Returns original specification, runtime projection variants and explicit omissions. A concept is not an executable preset."),
|
||||
CommandSpec::new("render.library.appearance.apply", "Apply Library Opaque Appearance", library::apply_appearance).icon("appearance")
|
||||
.params("id: SURF-* or SELENE-AP-*; variant: uniform_opaque (required); bodies: 1..256 exact body names. Applies the original scalar baseline through ordinary appearance assignment in one undo, preserving physical properties. Spatial maps and advanced target effects are explicitly excluded."),
|
||||
|
||||
@@ -16,8 +16,22 @@ use solidcraft_render::{
|
||||
texture_input::MapRole,
|
||||
};
|
||||
|
||||
use crate::schema::TypedContract;
|
||||
use crate::schema::render_library::{
|
||||
LibraryDescribeParams, LibraryListParams, LibraryPage, LibraryPreview, LibraryPreviewParams, LibraryResource, LibraryTargets,
|
||||
LibraryTargetsParams, Pbr, RenderTarget, ResourceSummary, SceneBounds,
|
||||
};
|
||||
use crate::{Result, Session, params::bad};
|
||||
|
||||
pub(super) const LIST_CONTRACT: TypedContract<LibraryListParams> = TypedContract::new();
|
||||
pub(super) const DESCRIBE_CONTRACT: TypedContract<LibraryDescribeParams> = TypedContract::new();
|
||||
pub(super) const TARGETS_CONTRACT: TypedContract<LibraryTargetsParams> = TypedContract::new();
|
||||
pub(super) const PREVIEW_CONTRACT: TypedContract<LibraryPreviewParams> = TypedContract::new();
|
||||
|
||||
fn reply(command: &str, reply: impl serde::Serialize) -> Result<Value> {
|
||||
serde_json::to_value(reply).map_err(|e| bad(command, e.to_string()))
|
||||
}
|
||||
|
||||
const KINDS: &[&str] = &[
|
||||
"materials",
|
||||
"lighting",
|
||||
@@ -162,28 +176,47 @@ pub(super) fn list(_s: &mut Session, params: &Value) -> Result<Value> {
|
||||
};
|
||||
let values = catalog(&input.kind, command)?;
|
||||
let count = values.iter().filter(matches).count();
|
||||
let items: Vec<_> = values.iter().filter(matches).skip(input.offset).take(input.limit).map(|v| {
|
||||
json!({"id":v["id"],"name":v["name"],"domains":v["domains"],"family":v["family"],"source_status":v.get("status").or_else(|| v.get("state")),"projection":projection(&input.kind,v)})
|
||||
}).collect();
|
||||
let items = values
|
||||
.iter()
|
||||
.filter(matches)
|
||||
.skip(input.offset)
|
||||
.take(input.limit)
|
||||
.map(|v| {
|
||||
let listed = Listed::deserialize(v).map_err(|e| bad(command, format!("built-in catalog {}: {e}", input.kind)))?;
|
||||
Ok(ResourceSummary {
|
||||
id: listed.id,
|
||||
name: listed.name,
|
||||
domains: listed.domains,
|
||||
family: listed.family,
|
||||
source_status: listed.status.or(listed.state),
|
||||
projection: projection(&input.kind, v),
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
let end = input.offset.saturating_add(items.len());
|
||||
Ok(json!({"kind":input.kind,"total":count,"offset":input.offset,"next_offset":(end < count).then_some(end),"items":items}))
|
||||
let next_offset = (end < count).then_some(end);
|
||||
reply(command, LibraryPage { kind: input.kind, total: count, offset: input.offset, next_offset, items, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// The fields of a catalog's resource a page lists. Every catalog gives an id and a name; the
|
||||
/// source's status is `status`, or a rig's `state`.
|
||||
#[derive(Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
struct TargetQuery {
|
||||
query: String,
|
||||
offset: usize,
|
||||
limit: usize,
|
||||
}
|
||||
impl Default for TargetQuery {
|
||||
fn default() -> Self {
|
||||
Self { query: String::new(), offset: 0, limit: 128 }
|
||||
}
|
||||
struct Listed {
|
||||
id: String,
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
domains: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
family: Option<String>,
|
||||
#[serde(default)]
|
||||
status: Option<String>,
|
||||
#[serde(default)]
|
||||
state: Option<String>,
|
||||
}
|
||||
|
||||
pub(super) fn targets(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
let command = "render.library.targets";
|
||||
let input: TargetQuery = serde_json::from_value(params.clone()).map_err(|e| bad(command, e.to_string()))?;
|
||||
let input: LibraryTargetsParams = serde_json::from_value(params.clone()).map_err(|e| bad(command, e.to_string()))?;
|
||||
if input.query.len() > 256
|
||||
|| input.query.chars().any(char::is_control)
|
||||
|| input.limit == 0
|
||||
@@ -202,49 +235,70 @@ pub(super) fn targets(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
.filter(|(_, body)| body.name.to_lowercase().contains(&query))
|
||||
.skip(input.offset)
|
||||
.take(input.limit)
|
||||
.map(|(ordinal, body)| {
|
||||
json!({"ordinal":ordinal,"name":body.name,"color":body.color,"opacity":body.opacity,
|
||||
"pbr":body.surface.map(|v|json!({"metallic":v.metallic(),"roughness":v.roughness(),"dielectric_ior":v.dielectric_ior()}))})
|
||||
.map(|(ordinal, body)| RenderTarget {
|
||||
ordinal,
|
||||
name: body.name.clone(),
|
||||
color: body.color,
|
||||
opacity: body.opacity,
|
||||
pbr: body.surface.map(|v| Pbr { metallic: v.metallic(), roughness: v.roughness(), dielectric_ior: v.dielectric_ior() }),
|
||||
})
|
||||
.collect();
|
||||
let end = input.offset.saturating_add(items.len());
|
||||
Ok(json!({"items":items,"total":count,"offset":input.offset,"next_offset":(end<count).then_some(end),"revision":s.revision,
|
||||
"bounds":if scene.bounds.is_empty(){Value::Null}else{json!({"min":scene.bounds.min,"max":scene.bounds.max})}}))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Identify {
|
||||
kind: String,
|
||||
id: String,
|
||||
let bounds = (!scene.bounds.is_empty()).then(|| SceneBounds { min: scene.bounds.min.into(), max: scene.bounds.max.into() });
|
||||
reply(
|
||||
command,
|
||||
LibraryTargets {
|
||||
items,
|
||||
total: count,
|
||||
offset: input.offset,
|
||||
next_offset: (end < count).then_some(end),
|
||||
revision: s.revision,
|
||||
bounds,
|
||||
param_warnings: Vec::new(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn describe(_s: &mut Session, params: &Value) -> Result<Value> {
|
||||
let command = "render.library.describe";
|
||||
let input: Identify = serde_json::from_value(params.clone()).map_err(|e| bad(command, e.to_string()))?;
|
||||
let input: LibraryDescribeParams = serde_json::from_value(params.clone()).map_err(|e| bad(command, e.to_string()))?;
|
||||
if input.kind == "physical_profiles" {
|
||||
return super::physical_library::describe(&input.id);
|
||||
}
|
||||
let value = item(&input.kind, &input.id, command)?;
|
||||
let mut reply = json!({"kind":input.kind,"resource":value,"projection":projection(&input.kind,value),"source_manifest_unchanged":true});
|
||||
if matches!(input.kind.as_str(), "materials" | "surfaces") {
|
||||
reply["physical_references"] = super::physical_library::associations(&input.id)?;
|
||||
}
|
||||
Ok(reply)
|
||||
let physical_references =
|
||||
if matches!(input.kind.as_str(), "materials" | "surfaces") { Some(super::physical_library::associations(&input.id)?) } else { None };
|
||||
let projection = projection(&input.kind, value);
|
||||
reply(
|
||||
command,
|
||||
LibraryResource {
|
||||
kind: input.kind,
|
||||
resource: value.as_object().cloned().unwrap_or_default(),
|
||||
projection,
|
||||
source_manifest_unchanged: true,
|
||||
physical_references,
|
||||
param_warnings: Vec::new(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Preview {
|
||||
id: String,
|
||||
}
|
||||
pub(super) fn preview(_s: &mut Session, params: &Value) -> Result<Value> {
|
||||
let command = "render.library.preview";
|
||||
let input: Preview = serde_json::from_value(params.clone()).map_err(|e| bad(command, e.to_string()))?;
|
||||
let input: LibraryPreviewParams = serde_json::from_value(params.clone()).map_err(|e| bad(command, e.to_string()))?;
|
||||
item("surfaces", &input.id, command)?;
|
||||
let bytes = asset_bytes(&format!("{}/base_color", input.id), 2 * 1024 * 1024, command)?;
|
||||
Ok(json!({"id":input.id,"mime":"image/png","sha256":HdrId::of(&bytes),"bytes":bytes,
|
||||
"fidelity":"authored_base_color_source_map_not_a_render","document_changed":false}))
|
||||
reply(
|
||||
command,
|
||||
LibraryPreview {
|
||||
id: input.id,
|
||||
mime: "image/png".into(),
|
||||
sha256: HdrId::of(&bytes).to_string(),
|
||||
bytes,
|
||||
fidelity: "authored_base_color_source_map_not_a_render".into(),
|
||||
document_changed: false,
|
||||
param_warnings: Vec::new(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// The key is matched against generated constants, never joined to a filesystem path.
|
||||
|
||||
@@ -1,61 +1,65 @@
|
||||
//! Named saved-shot management through the existing RenderSettings authority.
|
||||
//! No captured jobs, camera navigation, external files or engineering data are edited.
|
||||
use crate::schema::TypedContract;
|
||||
use crate::schema::render_shots::{ShotChange, ShotOperation, ShotParams};
|
||||
use crate::{Result, Session, params::bad};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use serde_json::Value;
|
||||
use solidcraft_doc::render_settings::RenderSettings;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Named {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
new_name: Option<String>,
|
||||
expected_revision: u64,
|
||||
}
|
||||
fn change(s: &mut Session, params: &Value, operation: &str) -> Result<Value> {
|
||||
pub(super) const CONTRACT: TypedContract<ShotParams> = TypedContract::new();
|
||||
|
||||
fn change(s: &mut Session, params: &Value, operation: ShotOperation) -> Result<Value> {
|
||||
let command = match operation {
|
||||
"duplicate" => "render.shot.duplicate",
|
||||
"rename" => "render.shot.rename",
|
||||
_ => "render.shot.remove",
|
||||
ShotOperation::Duplicate => "render.shot.duplicate",
|
||||
ShotOperation::Rename => "render.shot.rename",
|
||||
ShotOperation::Remove => "render.shot.remove",
|
||||
};
|
||||
let input: Named = serde_json::from_value(params.clone()).map_err(|e| bad(command, e.to_string()))?;
|
||||
let input: ShotParams = serde_json::from_value(params.clone()).map_err(|e| bad(command, e.to_string()))?;
|
||||
if input.expected_revision != s.revision {
|
||||
return Err(bad(command, "saved shot changed after review; refresh before editing"));
|
||||
}
|
||||
RenderSettings::validate_name(&input.name).map_err(|e| bad(command, e.to_string()))?;
|
||||
let source = s.doc.render.setups.get(&input.name).cloned().ok_or_else(|| bad(command, "no saved shot with that name"))?;
|
||||
let mut settings = (*s.doc.render).clone();
|
||||
let output = if operation == "remove" {
|
||||
let output = if operation == ShotOperation::Remove {
|
||||
if input.new_name.is_some() {
|
||||
return Err(bad(command, "remove does not accept a destination name"));
|
||||
}
|
||||
settings.setups.remove(&input.name);
|
||||
Value::Null
|
||||
None
|
||||
} else {
|
||||
let destination = input.new_name.as_ref().ok_or_else(|| bad(command, "new_name is required"))?;
|
||||
RenderSettings::validate_name(destination).map_err(|e| bad(command, e.to_string()))?;
|
||||
if settings.setups.contains_key(destination) {
|
||||
let destination = input.new_name.ok_or_else(|| bad(command, "new_name is required"))?;
|
||||
RenderSettings::validate_name(&destination).map_err(|e| bad(command, e.to_string()))?;
|
||||
if settings.setups.contains_key(&destination) {
|
||||
return Err(bad(command, "destination already exists; saved shots are never overwritten"));
|
||||
}
|
||||
settings.setups.insert(destination.clone(), source);
|
||||
if operation == "rename" {
|
||||
if operation == ShotOperation::Rename {
|
||||
settings.setups.remove(&input.name);
|
||||
}
|
||||
json!(destination)
|
||||
Some(destination)
|
||||
};
|
||||
settings.validate().map_err(|e| bad(command, e.to_string()))?;
|
||||
s.doc_mut().render = Arc::new(settings);
|
||||
Ok(json!({"operation":operation,"source":input.name,"name":output,"resources_preserved":true,
|
||||
"camera_navigation_unchanged":true,"engineering_properties_unchanged":true,"captured_jobs_unchanged":true}))
|
||||
let reply = ShotChange {
|
||||
operation,
|
||||
source: input.name,
|
||||
name: output,
|
||||
resources_preserved: true,
|
||||
camera_navigation_unchanged: true,
|
||||
engineering_properties_unchanged: true,
|
||||
captured_jobs_unchanged: true,
|
||||
param_warnings: Vec::new(),
|
||||
};
|
||||
serde_json::to_value(reply).map_err(|e| bad(command, e.to_string()))
|
||||
}
|
||||
pub(super) fn duplicate(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
change(s, params, "duplicate")
|
||||
change(s, params, ShotOperation::Duplicate)
|
||||
}
|
||||
pub(super) fn rename(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
change(s, params, "rename")
|
||||
change(s, params, ShotOperation::Rename)
|
||||
}
|
||||
pub(super) fn remove(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
change(s, params, "remove")
|
||||
change(s, params, ShotOperation::Remove)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! Explicit saved render-only ground. Does not add engineering bodies or material cards.
|
||||
use crate::schema::TypedContract;
|
||||
use crate::schema::render_stage::{BackdropKind, BackdropParams, ClearParams, GroundParams, StageSaved};
|
||||
use crate::{Result, Session, params::bad};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use serde_json::Value;
|
||||
use solidcraft_doc::render_settings::{RenderJob, RenderSetup};
|
||||
use solidcraft_render::{
|
||||
ProductScene,
|
||||
@@ -9,49 +10,41 @@ use solidcraft_render::{
|
||||
stage::{Stage, StagePanel},
|
||||
};
|
||||
use std::sync::Arc;
|
||||
fn patch() -> Value {
|
||||
json!({})
|
||||
}
|
||||
fn margin() -> f64 {
|
||||
0.25
|
||||
}
|
||||
fn gap() -> f64 {
|
||||
0.1
|
||||
}
|
||||
fn reflectance() -> [f64; 3] {
|
||||
[0.18; 3]
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Ground {
|
||||
name: String,
|
||||
#[serde(default = "patch")]
|
||||
set: Value,
|
||||
#[serde(default = "margin")]
|
||||
margin_fraction: f64,
|
||||
#[serde(default = "gap")]
|
||||
gap_mm: f64,
|
||||
#[serde(default = "reflectance")]
|
||||
reflectance_linear: [f64; 3],
|
||||
#[serde(default)]
|
||||
replace_stage: bool,
|
||||
}
|
||||
|
||||
pub(super) const GROUND_CONTRACT: TypedContract<GroundParams> = TypedContract::new();
|
||||
pub(super) const BACKDROP_CONTRACT: TypedContract<BackdropParams> = TypedContract::new();
|
||||
pub(super) const CLEAR_CONTRACT: TypedContract<ClearParams> = TypedContract::new();
|
||||
|
||||
pub(super) fn apply(scene: &mut ProductScene, setup: &RenderSetup, command: &str) -> Result<()> {
|
||||
setup.stage.append_to(scene, &CancelToken::default()).map_err(|e| bad(command, e.to_string()))
|
||||
}
|
||||
fn save(s: &mut Session, name: String, setup: RenderSetup, command: &str) -> Result<Value> {
|
||||
fn save(s: &mut Session, name: String, setup: RenderSetup, command: &str) -> Result<StageSaved> {
|
||||
let mut settings = (*s.doc.render).clone();
|
||||
settings.setups.insert(name.clone(), setup.clone());
|
||||
settings.validate().map_err(|e| bad(command, e.to_string()))?;
|
||||
let setup = serde_json::to_value(&setup).map_err(|e| bad(command, e.to_string()))?;
|
||||
if settings != *s.doc.render {
|
||||
s.doc_mut().render = Arc::new(settings);
|
||||
}
|
||||
Ok(json!({"name":name,"setup":setup,"engineering_geometry_unchanged":true,"stage_only":true,
|
||||
"clearance":"conservative product AABB separation; not a B-rep collision proof"}))
|
||||
Ok(StageSaved {
|
||||
name,
|
||||
setup,
|
||||
engineering_geometry_unchanged: true,
|
||||
stage_only: true,
|
||||
clearance: "conservative product AABB separation; not a B-rep collision proof".into(),
|
||||
geometry_kind: None,
|
||||
light_transport: None,
|
||||
camera_preserved: None,
|
||||
limitations: None,
|
||||
param_warnings: Vec::new(),
|
||||
})
|
||||
}
|
||||
fn reply(command: &str, saved: StageSaved) -> Result<Value> {
|
||||
serde_json::to_value(saved).map_err(|e| bad(command, e.to_string()))
|
||||
}
|
||||
pub(super) fn ground(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
let command = "render.stage.ground";
|
||||
let input: Ground = serde_json::from_value(params.clone()).map_err(|e| bad(command, e.to_string()))?;
|
||||
let input: GroundParams = serde_json::from_value(params.clone()).map_err(|e| bad(command, e.to_string()))?;
|
||||
if !input.margin_fraction.is_finite()
|
||||
|| !(0.0..=10.0).contains(&input.margin_fraction)
|
||||
|| !input.gap_mm.is_finite()
|
||||
@@ -88,48 +81,22 @@ pub(super) fn ground(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
}])
|
||||
.map_err(|e| bad(command, e.to_string()))?;
|
||||
apply(&mut scene, &setup, command)?;
|
||||
save(s, input.name, setup, command)
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Clear {
|
||||
name: String,
|
||||
#[serde(default = "patch")]
|
||||
set: Value,
|
||||
reply(command, save(s, input.name, setup, command)?)
|
||||
}
|
||||
pub(super) fn clear(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
let command = "render.stage.clear";
|
||||
let input: Clear = serde_json::from_value(params.clone()).map_err(|e| bad(command, e.to_string()))?;
|
||||
let input: ClearParams = serde_json::from_value(params.clone()).map_err(|e| bad(command, e.to_string()))?;
|
||||
let mut setup = super::patched_settings(s, &input.name, &input.set, command)?;
|
||||
setup.stage = Stage::default();
|
||||
save(s, input.name, setup, command)
|
||||
reply(command, save(s, input.name, setup, command)?)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum BackdropKind {
|
||||
FloorWall,
|
||||
Cyclorama,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct BackdropInput {
|
||||
name: String,
|
||||
#[serde(default = "patch")]
|
||||
set: Value,
|
||||
kind: BackdropKind,
|
||||
#[serde(default = "reflectance")]
|
||||
reflectance_linear: [f64; 3],
|
||||
#[serde(default)]
|
||||
replace_stage: bool,
|
||||
expected_revision: u64,
|
||||
}
|
||||
/// Original world-space stage derived from captured subject bounds and the chosen
|
||||
/// camera azimuth. Twenty-four explicit planar strips approximate the round cove;
|
||||
/// it is not an analytic smooth surface, terrain or engineering model operation.
|
||||
pub(super) fn backdrop(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
let command = "render.stage.backdrop";
|
||||
let input: BackdropInput = serde_json::from_value(params.clone()).map_err(|e| bad(command, e.to_string()))?;
|
||||
let input: BackdropParams = serde_json::from_value(params.clone()).map_err(|e| bad(command, e.to_string()))?;
|
||||
if input.expected_revision != s.revision {
|
||||
return Err(bad(command, "the design changed; review the stage again"));
|
||||
}
|
||||
@@ -204,17 +171,20 @@ pub(super) fn backdrop(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
setup.stage = Stage::new(panels).map_err(|e| bad(command, e.to_string()))?;
|
||||
apply(&mut scene, &setup, command)?;
|
||||
let mut result = save(s, input.name, setup, command)?;
|
||||
result["geometry_kind"] = json!(match input.kind {
|
||||
BackdropKind::FloorWall => "finite_floor_and_wall",
|
||||
BackdropKind::Cyclorama => "24_segment_polygonal_cyclorama",
|
||||
});
|
||||
result["light_transport"] =
|
||||
json!("nonemissive world-space stage; receives native lighting and casts reflections/shadows supported by the selected executor");
|
||||
result["camera_preserved"] = json!(true);
|
||||
result["limitations"] = json!([
|
||||
"Finite background, not an infinite floor or a terrain mesh.",
|
||||
"Cyclorama is an explicit 24-strip polygonal approximation, not a smooth CAD face.",
|
||||
"No change to HDR, light energy, subject optics, pose or engineering properties."
|
||||
result.geometry_kind = Some(
|
||||
match input.kind {
|
||||
BackdropKind::FloorWall => "finite_floor_and_wall",
|
||||
BackdropKind::Cyclorama => "24_segment_polygonal_cyclorama",
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
result.light_transport =
|
||||
Some("nonemissive world-space stage; receives native lighting and casts reflections/shadows supported by the selected executor".into());
|
||||
result.camera_preserved = Some(true);
|
||||
result.limitations = Some(vec![
|
||||
"Finite background, not an infinite floor or a terrain mesh.".into(),
|
||||
"Cyclorama is an explicit 24-strip polygonal approximation, not a smooth CAD face.".into(),
|
||||
"No change to HDR, light energy, subject optics, pose or engineering properties.".into(),
|
||||
]);
|
||||
Ok(result)
|
||||
reply(command, result)
|
||||
}
|
||||
|
||||
@@ -141,7 +141,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.params(concat!("p0, p1: overall ends, width", connect_doc!(), created_doc!())),
|
||||
CommandSpec::new("sketch.point", "Point", draw_point).at("SKETCH", "CREATE").icon("point").enabled(in_sketch).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(
|
||||
"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|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, two midpoints or a line off the axes: with auto, text_at inside the box they span (as opposite corners) or in its outer corners is the distance between them, above or below the box horizontal, beside it vertical. 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 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 true): 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",
|
||||
"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, two midpoints or a line off the axes: with auto, text_at inside the box they span (as opposite corners) or in its outer corners is the distance between them, above or below the box horizontal, beside it vertical. 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 true): 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", c_horizontal_vertical)
|
||||
.at("SKETCH", "CONSTRAINTS")
|
||||
@@ -481,7 +481,7 @@ pub(super) mod solve_counts {
|
||||
}
|
||||
|
||||
/// How close (mm) a new point must land to an existing one to be connected to it.
|
||||
const CONNECT_TOL: f64 = 1e-6;
|
||||
pub(super) const CONNECT_TOL: f64 = 1e-6;
|
||||
|
||||
/// New points (index `first` on) that land on an existing point, the origin included, get a
|
||||
/// coincident constraint to it, as when a click snaps there; `connect: false` leaves them free.
|
||||
@@ -1171,7 +1171,8 @@ fn draw_line(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for i in 0..segs {
|
||||
let j = (i + 1) % n;
|
||||
let (Some(a), Some(b)) = (args.get(i), args.get(j)) else { continue };
|
||||
if a.pos().dist(b.pos()) < 1e-9 {
|
||||
// Ends closer than this would both be connected to one point (#11001).
|
||||
if a.pos().dist(b.pos()) < CONNECT_TOL {
|
||||
return Err(bad(cmd, "zero-length line"));
|
||||
}
|
||||
let id = ids.get(i).map(String::as_str);
|
||||
@@ -2928,6 +2929,13 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
None => (None, value),
|
||||
};
|
||||
let scale_sketch = bool_(p, "scale_sketch").unwrap_or(false);
|
||||
let previous_axis = str_(p, "previous_axis").map(str::to_ascii_lowercase);
|
||||
let half_axes = match p.get("half_axes") {
|
||||
None | Some(Value::Null) => true,
|
||||
Some(Value::Bool(b)) => *b,
|
||||
Some(_) => return Err(bad(cmd, "`half_axes` must be true or false")),
|
||||
};
|
||||
let mut axis = None;
|
||||
let mut scaled = None;
|
||||
let mut before = None;
|
||||
let (mut residual, mut new_id) = (None, String::new());
|
||||
@@ -2968,7 +2976,10 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
((None, Some(c)), None) if is_line(c) => {
|
||||
let (a, b) = line_pts(sk, c).unwrap_or_default();
|
||||
match auto_orientation(&ty, a, b, text_at) {
|
||||
// An ellipse's axis is a construction axis, not a free line: its length, the
|
||||
// whole axis, wherever the text is.
|
||||
let ty = if ty == "auto" && sk.is_ellipse_axis(c) { "length" } else { ty.as_str() };
|
||||
match auto_orientation(ty, a, b, text_at) {
|
||||
"angle" => {
|
||||
let angle = (b - a).angle();
|
||||
(ConstraintKind::LineAngle { l: c, value: angle }, angle)
|
||||
@@ -2991,9 +3002,27 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let Some(CurveKind::Ellipse { c: cp, m, r }) = sk.curves.get(c).map(|x| x.kind.clone()) else {
|
||||
return Err(bad(cmd, "ellipse"));
|
||||
};
|
||||
if ty == "major" {
|
||||
let d = sk.point(cp).zip(sk.point(m)).map(|(a, b)| a.dist(b)).unwrap_or(0.0);
|
||||
(ConstraintKind::Distance { p: cp, q: m, value: d }, d)
|
||||
// A half-axis, chosen by where the text is: the distance from the centre to an
|
||||
// axis end (the ellipse's own major end when its major axis is gone), or the
|
||||
// minor radius when its minor axis is gone or it never had one.
|
||||
if matches!(ty.as_str(), "major" | "minor") || (ty == "auto" && half_axes) {
|
||||
let (major, minor) = sk.ellipse_axis_lines(c).unwrap_or((None, None));
|
||||
let ends = (m, major.map(|(_, m2)| m2), minor.map(|(_, n1, n2)| [n1, n2]));
|
||||
// With one half-axis already held, the other is the only one left to guess.
|
||||
let ty = match (ty.as_str(), half_axes_held(sk, c, cp, m, major, minor)) {
|
||||
("auto", (true, false)) => "minor".to_string(),
|
||||
("auto", (false, true)) => "major".to_string(),
|
||||
_ => ty.clone(),
|
||||
};
|
||||
let (which, end) = ellipse_half_axis(sk, cp, ends, r, &ty, text_at, previous_axis.as_deref());
|
||||
axis = Some(which);
|
||||
match end {
|
||||
Some(end) => {
|
||||
let d = sk.point(cp).zip(sk.point(end)).map(|(a, b)| a.dist(b)).unwrap_or(0.0);
|
||||
(ConstraintKind::Distance { p: cp, q: end, value: d }, d)
|
||||
}
|
||||
None => (ConstraintKind::Radius { c, value: r }, r),
|
||||
}
|
||||
} else {
|
||||
(ConstraintKind::Radius { c, value: r }, r)
|
||||
}
|
||||
@@ -3192,7 +3221,11 @@ 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 {
|
||||
return Ok(json!({"param": Value::Null, "driven": true, "type": kind_name, "measured": current.0, "unit": current.1, "sketch": info}));
|
||||
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);
|
||||
}
|
||||
// A dimension the sketch already determines cannot drive it: it is refused, or with
|
||||
// `if_redundant: "driven"` placed as a driven (reference) dimension instead.
|
||||
@@ -3222,6 +3255,9 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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);
|
||||
}
|
||||
@@ -3262,6 +3298,107 @@ fn auto_orientation(ty: &str, a: Vec2, b: Vec2, text_at: Option<Vec2>) -> &str {
|
||||
}
|
||||
}
|
||||
|
||||
/// Inside this band around the boundary between the two half-axes (the ratio of how far the
|
||||
/// text is pulled across the major axis to how far past its end, both scaled by the half-axes),
|
||||
/// a dimension being placed keeps the half-axis it had, so the preview does not flip back and
|
||||
/// forth while the pointer crosses slowly.
|
||||
const HALF_AXIS_DEAD_ZONE: f64 = 1.25;
|
||||
|
||||
/// Whether a driving dimension already holds ellipse `e`'s major and its minor half-axis: a
|
||||
/// half-axis to an axis end (or the minor radius), or the whole axis, along its line or between
|
||||
/// its ends.
|
||||
fn half_axes_held(sk: &Sketch, e: usize, c: usize, m: usize, major: Option<(usize, usize)>, minor: Option<(usize, usize, usize)>) -> (bool, bool) {
|
||||
let pair = |p: usize, q: usize, a: usize, b: usize| (p == a && q == b) || (p == b && q == a);
|
||||
let mut held = (false, false);
|
||||
for k in sk.constraints.iter().filter(|k| !k.driven) {
|
||||
match k.kind {
|
||||
ConstraintKind::Distance { p, q, .. } => {
|
||||
let minor_end = |x: usize| minor.is_some_and(|(_, n1, n2)| x == n1 || x == n2);
|
||||
if pair(p, q, c, m) || major.is_some_and(|(_, m2)| pair(p, q, c, m2) || pair(p, q, m, m2)) {
|
||||
held.0 = true;
|
||||
} else if minor.is_some_and(|(_, n1, n2)| pair(p, q, n1, n2)) || (p == c && minor_end(q)) || (q == c && minor_end(p)) {
|
||||
held.1 = true;
|
||||
}
|
||||
}
|
||||
ConstraintKind::Length { l, .. } => {
|
||||
held.0 |= major.is_some_and(|(line, _)| line == l);
|
||||
held.1 |= minor.is_some_and(|(line, ..)| line == l);
|
||||
}
|
||||
ConstraintKind::Radius { c: on, .. } => held.1 |= on == e,
|
||||
// A horizontal (vertical) distance across an axis holds its length when the axis
|
||||
// lies that way, the distance then being the length; across a slanted axis it is only
|
||||
// a projection.
|
||||
ConstraintKind::DistanceX { p, q, value } | ConstraintKind::DistanceY { p, q, value } => {
|
||||
let length = sk.point(p).zip(sk.point(q)).map(|(a, b)| a.dist(b));
|
||||
if !length.is_some_and(|l| (value.abs() - l).abs() <= 1e-9 * l.max(1.0)) {
|
||||
continue;
|
||||
}
|
||||
if pair(p, q, c, m) || major.is_some_and(|(_, m2)| pair(p, q, c, m2) || pair(p, q, m, m2)) {
|
||||
held.0 = true;
|
||||
} else if minor.is_some_and(|(_, n1, n2)| pair(p, q, n1, n2) || pair(p, q, c, n1) || pair(p, q, c, n2)) {
|
||||
held.1 = true;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
held
|
||||
}
|
||||
|
||||
/// Which half-axis of an ellipse a dimension measures, and the axis end it goes to. `ends` is
|
||||
/// what it has of its axes: the major end (always its own), the point opposite it, the minor
|
||||
/// axis's ends. The choice does not depend on them; the major half-axis goes to the major end
|
||||
/// when the point opposite is missing, and the minor one has no end (the minor radius) when its
|
||||
/// axis line is.
|
||||
/// With `auto` and `text_at` it follows the rule for a line, in the ellipse's own frame: text
|
||||
/// pulled across the major axis measures along it (the major half-axis), text pulled out past
|
||||
/// an end of the major axis measures across it (the minor one). The end is the one on the
|
||||
/// text's side. Without `text_at` it is the minor half-axis, as it always was.
|
||||
fn ellipse_half_axis(
|
||||
sk: &Sketch,
|
||||
c: usize,
|
||||
ends: (usize, Option<usize>, Option<[usize; 2]>),
|
||||
minor: f64,
|
||||
ty: &str,
|
||||
text_at: Option<Vec2>,
|
||||
previous: Option<&str>,
|
||||
) -> (&'static str, Option<usize>) {
|
||||
let (m, m2, minor_ends) = ends;
|
||||
let (pc, pm) = (sk.point(c).unwrap_or_default(), sk.point(m).unwrap_or_default());
|
||||
let d = pm - pc;
|
||||
let major = d.len();
|
||||
let rel = text_at.map(|t| t - pc);
|
||||
let along = rel.map(|v| v.dot(d) / (major * major)).filter(|v| v.is_finite()).unwrap_or(0.0);
|
||||
let across = rel.map(|v| d.cross(v) / (major * minor)).filter(|v| v.is_finite()).unwrap_or(0.0);
|
||||
let which = match ty {
|
||||
"major" => "major",
|
||||
"minor" => "minor",
|
||||
_ => {
|
||||
let (x, y) = (along.abs(), across.abs());
|
||||
match previous {
|
||||
Some(keep @ ("major" | "minor")) if y < x * HALF_AXIS_DEAD_ZONE && x < y * HALF_AXIS_DEAD_ZONE => {
|
||||
if keep == "major" {
|
||||
"major"
|
||||
} else {
|
||||
"minor"
|
||||
}
|
||||
}
|
||||
_ if y > x => "major",
|
||||
_ => "minor",
|
||||
}
|
||||
}
|
||||
};
|
||||
if which == "major" {
|
||||
(which, Some(if along < 0.0 { m2.unwrap_or(m) } else { m }))
|
||||
} else {
|
||||
let end = minor_ends.map(|[n1, n2]| {
|
||||
let side = sk.point(n1).map(|q| d.cross(q - pc)).unwrap_or(1.0);
|
||||
if across * side < 0.0 { n2 } else { n1 }
|
||||
});
|
||||
(which, end)
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep a dimension's text where it was placed (`at`, sketch coordinates), relative to the
|
||||
/// dimension so it follows the geometry.
|
||||
fn place_text(sk: &mut Sketch, id: &str, at: Option<Vec2>) {
|
||||
|
||||
@@ -6,7 +6,7 @@ use solidcore_record::{Seg2, Vec2};
|
||||
use solidcraft_geom::prelude::*;
|
||||
use solidcraft_sketch::{ConstraintKind, CurveKind, Sketch};
|
||||
|
||||
use super::sketch::{add_c, curve_ref, edit, ids_of, line_or_axis_ref, line_pts, req_parg, result};
|
||||
use super::sketch::{CONNECT_TOL, add_c, curve_ref, edit, ids_of, line_or_axis_ref, line_pts, req_parg, result};
|
||||
use super::{CommandSpec, in_sketch};
|
||||
use crate::params::{bad, expr, num, str_, vec2};
|
||||
use crate::schema::sketch_create::{ConstructionLineParams, ConstructionLineResult};
|
||||
@@ -261,6 +261,10 @@ fn run(s: &mut Session, p: &Value, default_mode: &str) -> Result<Value> {
|
||||
let first_new = sk.points.len();
|
||||
let at = req_parg(sk, doc, &p, "at", CMD)?;
|
||||
let through = if mode == "free" { Some(req_parg(sk, doc, &p, "through", CMD)?) } else { None };
|
||||
// A through-point next to the start leaves a line whose direction no constraint holds (#11001).
|
||||
if through.as_ref().is_some_and(|q| q.pos().dist(at.pos()) < CONNECT_TOL) {
|
||||
return Err(bad(CMD, "the through point must lie away from the start point"));
|
||||
}
|
||||
let mut params = p.clone();
|
||||
params["at"] = json!([at.pos().x, at.pos().y]);
|
||||
let angle_expression = if mode == "angle" {
|
||||
|
||||
@@ -204,6 +204,26 @@ fn a_fillet_where_a_line_runs_on_smoothly_from_an_arc_is_refused() {
|
||||
assert_eq!(sk(&s).curves.len(), n);
|
||||
}
|
||||
|
||||
/// An ellipse's construction axes take no part in a corner: a line drawn from the major end
|
||||
/// meets the major axis there, and Fillet or Chamfer at that point, or on that pair, is refused
|
||||
/// instead of rounding the line into the axis.
|
||||
#[test]
|
||||
fn an_ellipse_s_axis_is_not_a_side_of_a_corner() {
|
||||
let mut s = new_sketch();
|
||||
let r = run(&mut s, "sketch.ellipse", json!({"center": [0, 0], "major": [20, 0], "minor_radius": 8}));
|
||||
let major = r["created"]["curves"][1].as_str().unwrap().to_string();
|
||||
let l = curves(&run(&mut s, "sketch.line", json!({"points": [format!("{major}.end"), [20, 15]], "infer": false, "connect": false})))[0].clone();
|
||||
let before = sk(&s);
|
||||
for (cmd, size) in [("sketch.fillet", "radius"), ("sketch.chamfer.equal_distance", "distance")] {
|
||||
for corner in [json!({"point": format!("{l}.start")}), json!({"a": l, "b": major})] {
|
||||
let mut p = corner;
|
||||
p[size] = json!(2);
|
||||
assert!(s.execute(cmd, &p).is_err(), "{cmd} {p}: {:?}", sk(&s).curves);
|
||||
assert_eq!(sk(&s), before, "{cmd} {p}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The ids of the constraints driven by the model parameter `name`.
|
||||
fn driven_by(s: &Session, name: &str) -> Vec<String> {
|
||||
sk(s).constraints.iter().filter(|c| c.param.as_deref() == Some(name)).map(|c| c.id.clone()).collect()
|
||||
|
||||
@@ -68,7 +68,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("center, start: centre arc start, end: direction of the centre arc end (counter-clockwise) or sweep: degrees (0 < sweep < 360), width; returns centre_arc", created_doc!())),
|
||||
CommandSpec::new("sketch.ellipse", "Ellipse", ellipse).at("SKETCH", "CREATE").icon("ellipse").enabled(in_sketch).params(concat!(
|
||||
"center, major: end of the major axis [x,y], minor: a point the ellipse passes through on the minor side [x,y] | minor_radius",
|
||||
"center, major: end of the major axis [x,y], minor: a point the ellipse passes through on the minor side [x,y] | minor_radius. Also adds its major and minor axes as construction lines through the centre (their ends: the major end and the point opposite it, and the two ends of the minor axis on the ellipse), held by midpoint, perpendicular and point-on-curve constraints, so a half-axis is the distance from the centre to an axis end and an axis line's length is a full axis; axes?: bool (default true; false: the ellipse alone, as the Ellipse tool draws it with Draw ellipses with their axes off)",
|
||||
created_doc!()
|
||||
)),
|
||||
CommandSpec::new("sketch.spline.fit_point", "Fit Point Spline", spline_fit)
|
||||
@@ -626,12 +626,38 @@ fn ellipse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !(r > 1e-9 && r < 1e8) {
|
||||
return Err(bad(cmd, "the minor radius must be positive"));
|
||||
}
|
||||
let axes = match p.get("axes") {
|
||||
None | Some(Value::Null) => true,
|
||||
Some(Value::Bool(b)) => *b,
|
||||
Some(_) => return Err(bad(cmd, "`axes` must be true or false")),
|
||||
};
|
||||
let (out, info) = edit(s, p, cmd, false, |sk, _| {
|
||||
let ci = sk.add_point(c, None)?;
|
||||
let mi = sk.add_point(m, None)?;
|
||||
let e = sk.add_curve(CurveKind::Ellipse { c: ci, m: mi, r }, None)?;
|
||||
mark_construction(sk, &[e], p);
|
||||
Ok((ids_of(sk, &[e]), Vec::new()))
|
||||
if !axes {
|
||||
return Ok((ids_of(sk, &[e]), Vec::new()));
|
||||
}
|
||||
// Its major and minor axes, construction lines through the centre that bisects both:
|
||||
// the major one ends at the major end, the minor one is perpendicular to it and ends
|
||||
// on the ellipse. Six new coordinates, six equations: the ellipse keeps its 5 degrees
|
||||
// of freedom, and each half-axis is a plain distance from the centre to an end.
|
||||
let n = u.perp() * r;
|
||||
let (m2, n1, n2) = (sk.add_point(c * 2.0 - m, None)?, sk.add_point(c + n, None)?, sk.add_point(c - n, None)?);
|
||||
let (major, minor) = (sk.add_line_pts(m2, mi, None)?, sk.add_line_pts(n2, n1, None)?);
|
||||
for l in [major, minor] {
|
||||
if let Some(cu) = sk.curves.get_mut(l) {
|
||||
cu.construction = true;
|
||||
}
|
||||
}
|
||||
let cons = vec![
|
||||
add_c(sk, ConstraintKind::Midpoint { p: ci, l: major })?,
|
||||
add_c(sk, ConstraintKind::Midpoint { p: ci, l: minor })?,
|
||||
add_c(sk, ConstraintKind::Perpendicular { a: minor, b: major })?,
|
||||
add_c(sk, ConstraintKind::PointOnCurve { p: n1, c: e })?,
|
||||
];
|
||||
Ok((ids_of(sk, &[e]), cons))
|
||||
})?;
|
||||
Ok(result(out, info))
|
||||
}
|
||||
|
||||
@@ -787,8 +787,21 @@ fn plan(sk: &Sketch, e: Ent, from: Vec2, to: Vec2) -> Option<(Path, Hold)> {
|
||||
Path::ConicRho { c, from: rho, to: to_rho }
|
||||
}
|
||||
k => {
|
||||
// Lines and splines slide with the cursor.
|
||||
// Lines and splines slide with the cursor. An ellipse's construction axis
|
||||
// takes the ellipse and the other axis along, keeping their sizes, as a
|
||||
// rectangle's diagonal does through the corners it shares with the edges.
|
||||
let mut ids = k.point_ids();
|
||||
// Whichever axes are left: one may have been deleted.
|
||||
if let Some((e, major, minor)) = (0..sk.curves.len()).find_map(|e| {
|
||||
let (major, minor) = sk.ellipse_axis_lines(e)?;
|
||||
(major.is_some_and(|(l, _)| l == c) || minor.is_some_and(|(l, ..)| l == c)).then_some((e, major, minor))
|
||||
}) && let Some(kind) = sk.curves.get(e).map(|x| &x.kind)
|
||||
{
|
||||
ids.extend(kind.point_ids());
|
||||
ids.extend(major.map(|(_, q)| q));
|
||||
ids.extend(minor.into_iter().flat_map(|(_, n1, n2)| [n1, n2]));
|
||||
hold.radii.push(e);
|
||||
}
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
let pts = ids.iter().filter(|q| free(q)).filter_map(|&q| Some((q, sk.point(q)?))).collect();
|
||||
@@ -802,8 +815,9 @@ fn plan(sk: &Sketch, e: Ent, from: Vec2, to: Vec2) -> Option<(Path, Hold)> {
|
||||
}
|
||||
|
||||
/// A selection dragged by one of its entities (`e`, the rest `with`): every free point of all of
|
||||
/// them slides by `d` and the solves hold them all (circles and ellipses keep their radii), so
|
||||
/// the selection moves as one instead of its free ends trailing behind the grabbed entity.
|
||||
/// them slides by `d` and the solves hold them all (circles and ellipses keep their radii, an
|
||||
/// ellipse's axis brings its ellipse), so the selection moves as one instead of its free ends
|
||||
/// trailing behind the grabbed entity.
|
||||
fn plan_group(sk: &Sketch, e: Ent, with: &[Ent], d: Vec2) -> Option<(Path, Hold)> {
|
||||
let mut hold = Hold::default();
|
||||
for e in std::iter::once(&e).chain(with) {
|
||||
@@ -815,6 +829,16 @@ fn plan_group(sk: &Sketch, e: Ent, with: &[Ent], d: Vec2) -> Option<(Path, Hold)
|
||||
if matches!(kind, CurveKind::Circle { .. } | CurveKind::Ellipse { .. }) {
|
||||
hold.radii.push(c);
|
||||
}
|
||||
// An ellipse's axis takes the ellipse and the other axis along, as it does dragged
|
||||
// alone (see `plan`).
|
||||
if let Some(e) = sk.ellipse_of_axis(c)
|
||||
&& let (Some(kind), Some((major, minor))) = (sk.curves.get(e).map(|x| &x.kind), sk.ellipse_axis_lines(e))
|
||||
{
|
||||
hold.points.extend(kind.point_ids());
|
||||
hold.points.extend(major.map(|(_, q)| q));
|
||||
hold.points.extend(minor.into_iter().flat_map(|(_, n1, n2)| [n1, n2]));
|
||||
hold.radii.push(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -386,6 +386,30 @@ fn a_drag_with_the_rest_of_a_selection_moves_it_as_one() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A selection dragged by an ellipse's axis takes the ellipse and its other axis along, as the
|
||||
/// axis dragged alone does: the whole ellipse slides by the cursor's move, keeping its size.
|
||||
#[test]
|
||||
fn a_selection_dragged_by_an_ellipse_axis_takes_the_ellipse_along() {
|
||||
for live in [false, true] {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
let r = run(&mut s, "sketch.ellipse", json!({"center": [0, 0], "major": [20, 0], "minor_radius": 8}));
|
||||
let e = r["curves"][0].as_str().expect("the ellipse").to_string();
|
||||
run(&mut s, "sketch.line", json!({"points": [[40, -10], [40, 10]], "connect": false}));
|
||||
let sk = s.doc.sketch(s.active_sketch.expect("sketch mode")).expect("the sketch");
|
||||
let Some((Some((major, _)), _)) = sk.ellipse_axis_lines(sk.curve_index(&e).expect("the ellipse")) else { panic!("its axes") };
|
||||
let axis = sk.curves[major].id.clone();
|
||||
let line = sk.curves.last().expect("the line").id.clone();
|
||||
run(&mut s, "sketch.drag", json!({"entity": axis, "with": [line], "from": [10, 0], "to": [10, 15], "live": live}));
|
||||
let sk = s.doc.sketch(s.active_sketch.expect("sketch mode")).expect("the sketch");
|
||||
let ei = sk.curve_index(&e).expect("the ellipse");
|
||||
let solidcraft_sketch::CurveKind::Ellipse { c, m, .. } = sk.curves[ei].kind else { panic!("an ellipse") };
|
||||
let v = solidcore_record::Vec2::new;
|
||||
assert!(sk.point(c).expect("its centre").dist(v(0.0, 15.0)) < 1e-6, "live {live}: the centre follows: {:?}", sk.point(c));
|
||||
assert!(sk.point(m).expect("its major end").dist(v(20.0, 15.0)) < 1e-6, "live {live}: the major end follows: {:?}", sk.point(m));
|
||||
}
|
||||
}
|
||||
|
||||
/// A 5 x 5 square drawn on the top face of a 30 x 20 x 10 box, the face's edges projected into
|
||||
/// its sketch; returns the session and a corner of the square (its id and where it is).
|
||||
fn on_a_face() -> (Session, String, solidcore_record::Vec2) {
|
||||
|
||||
@@ -58,8 +58,12 @@ fn cuts(sk: &Sketch, ci: usize, segs: &[Seg2], only_freeform: bool) -> Vec<(f64,
|
||||
let (t, u) = ((c - a).cross(s) / den, (c - a).cross(r) / den);
|
||||
((-1e-9..=1.0 + 1e-9).contains(&t) && (-1e-9..=1.0 + 1e-9).contains(&u)).then(|| a + r * t)
|
||||
};
|
||||
// An ellipse's own axes end on it and cut it nowhere, as a Center Rectangle's diagonals end
|
||||
// at its corners.
|
||||
let (major, minor) = sk.ellipse_axis_lines(ci).unwrap_or((None, None));
|
||||
let own_axes = [major.map(|(l, _)| l), minor.map(|(l, ..)| l)];
|
||||
for (oi, curve) in sk.curves.iter().enumerate() {
|
||||
if oi == ci || (only_freeform && !curve.kind.is_freeform()) {
|
||||
if oi == ci || (only_freeform && !curve.kind.is_freeform()) || own_axes.contains(&Some(oi)) {
|
||||
continue;
|
||||
}
|
||||
let other = sk.polyline(oi);
|
||||
|
||||
@@ -326,6 +326,25 @@ fn construction_tangents_respect_arc_sweeps_and_invalid_inputs_are_atomic() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_second_point_next_to_the_first_is_refused() {
|
||||
// #11001: a through-point 0.0000001 mm from the start made an infinite line whose direction
|
||||
// Horizontal no longer held, and a line that short collapsed onto the origin.
|
||||
let mut s = new_sketch();
|
||||
for (id, p) in [
|
||||
("sketch.construction_line", json!({"at":[0,0],"through":[0.0000001,0]})),
|
||||
("sketch.construction_line", json!({"at":[5,5],"through":[5,5.0000005]})),
|
||||
("sketch.line", json!({"points":[[0,0],[0.0000001,0]]})),
|
||||
("sketch.line", json!({"points":[[5,5],[10,5],[10,5.0000005]]})),
|
||||
] {
|
||||
let before = s.doc.to_json();
|
||||
assert!(s.execute(id, &p).is_err(), "{id} {p}");
|
||||
assert_eq!(s.doc.to_json(), before, "{id} {p}");
|
||||
}
|
||||
run(&mut s, "sketch.construction_line", json!({"at":[0,0],"through":[0.00001,0]}));
|
||||
run(&mut s, "sketch.line", json!({"points":[[5,5],[5.00001,5]]}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn construction_relations_follow_source_edits_without_an_auxiliary_dimension() {
|
||||
let mut s = new_sketch();
|
||||
|
||||
@@ -25,7 +25,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.at("SKETCH", "MODIFY")
|
||||
.icon("sketch_fillet")
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("point: corner shared by two curves (\"l1.end\") | points: [corners] (all rounded alike: one radius, the rest equal; a corner is a point reference or {a, b}, the two curves meeting there) | a, b: two lines or arcs meeting at a corner (two lines that share no end are first brought to where they meet: the nearer end of a line that stops short moves there, and a line that runs past is cut there, losing its shorter piece); radius: expr. A corner that dimensions or constraints measure to stays as a virtual sharp corner on both curves' extensions (`virtual_corners`; the moved ones in `moved_to_virtual_corner`)", created_doc!())),
|
||||
.params(concat!("point: corner shared by two curves (\"l1.end\") | points: [corners] (all rounded alike: one radius, the rest equal; a corner is a point reference or {a, b}, the two curves meeting there) | a, b: two lines or arcs meeting at a corner (two lines that share no end are first brought to where they meet: the nearer end of a line that stops short moves there, and a line that runs past is cut there, losing its shorter piece); radius: expr. A corner that dimensions or constraints measure to stays as a virtual sharp corner on both curves' extensions (`virtual_corners`; the moved ones in `moved_to_virtual_corner`). An ellipse's axis line is never a side of a corner (also for the chamfers)", created_doc!())),
|
||||
CommandSpec::new("sketch.chamfer.equal_distance", "Equal Distance Chamfer", chamfer_equal)
|
||||
.at("SKETCH", "MODIFY")
|
||||
.icon("sketch_chamfer")
|
||||
@@ -1780,7 +1780,10 @@ fn corner(sk: &Sketch, p: &Value, cmd: &str) -> Result<(usize, usize, usize, usi
|
||||
};
|
||||
let (la, lb) = if let Some(r) = str_(p, "point") {
|
||||
let q = sk.resolve_point(r).ok_or_else(|| bad(cmd, format!("unknown point `{r}`")))?;
|
||||
let lines: Vec<usize> = (0..sk.curves.len()).filter(|i| ends(*i).is_some_and(|(a, b)| a == q || b == q) && !sk.is_linked_curve(*i)).collect();
|
||||
// An ellipse's construction axis is never a side: it would round a line into the axis.
|
||||
let lines: Vec<usize> = (0..sk.curves.len())
|
||||
.filter(|i| ends(*i).is_some_and(|(a, b)| a == q || b == q) && !sk.is_linked_curve(*i) && !sk.is_ellipse_axis(*i))
|
||||
.collect();
|
||||
// A construction line ending there too (a diagonal that centres something) is not one of
|
||||
// the corner's sides: it keeps its end on the virtual sharp corner (#1026).
|
||||
let sides: Vec<usize> =
|
||||
@@ -1792,6 +1795,9 @@ fn corner(sk: &Sketch, p: &Value, cmd: &str) -> Result<(usize, usize, usize, usi
|
||||
} else {
|
||||
let a = str_(p, "a").and_then(|i| sk.curve_index(i)).ok_or_else(|| bad(cmd, "`a` must be a line"))?;
|
||||
let b = str_(p, "b").and_then(|i| sk.curve_index(i)).ok_or_else(|| bad(cmd, "`b` must be a line"))?;
|
||||
if sk.is_ellipse_axis(a) || sk.is_ellipse_axis(b) {
|
||||
return Err(bad(cmd, "an ellipse's axis is not a side of a corner"));
|
||||
}
|
||||
(a, b)
|
||||
};
|
||||
let (a0, a1) = ends(la).ok_or_else(|| bad(cmd, "fillets and chamfers work on two lines"))?;
|
||||
@@ -2087,7 +2093,9 @@ fn round_corner(sk: &mut Sketch, p: &Value, r: f64, cmd: &str) -> Result<Option<
|
||||
};
|
||||
let (ca, cb, q) = if let Some(rf) = str_(p, "point") {
|
||||
let Some(q) = sk.resolve_point(rf) else { return Ok(None) };
|
||||
let cs: Vec<usize> = (0..sk.curves.len()).filter(|i| ends(*i).is_some_and(|(a, b)| a == q || b == q) && !sk.is_linked_curve(*i)).collect();
|
||||
let cs: Vec<usize> = (0..sk.curves.len())
|
||||
.filter(|i| ends(*i).is_some_and(|(a, b)| a == q || b == q) && !sk.is_linked_curve(*i) && !sk.is_ellipse_axis(*i))
|
||||
.collect();
|
||||
match cs[..] {
|
||||
[a, b] => (a, b, q),
|
||||
_ => return Ok(None),
|
||||
@@ -2097,6 +2105,9 @@ fn round_corner(sk: &mut Sketch, p: &Value, r: f64, cmd: &str) -> Result<Option<
|
||||
return Ok(None);
|
||||
};
|
||||
let (Some((a0, a1)), Some((b0, b1))) = (ends(a), ends(b)) else { return Ok(None) };
|
||||
if sk.is_ellipse_axis(a) || sk.is_ellipse_axis(b) {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(q) = [a0, a1].into_iter().find(|x| *x == b0 || *x == b1) else { return Ok(None) };
|
||||
(a, b, q)
|
||||
};
|
||||
@@ -3186,6 +3197,28 @@ fn entities(sk: &Sketch, p: &Value, cmd: &str) -> Result<(Vec<usize>, Vec<usize>
|
||||
Ok((cs, ps))
|
||||
}
|
||||
|
||||
/// `cs` with the ellipse of every axis line in it and the axis lines of every ellipse in it: an
|
||||
/// ellipse, or one of its axes, moved, turned or scaled takes the rest along, as a drag does,
|
||||
/// instead of leaving the axes behind its centre or pulling an axis off the ellipse.
|
||||
fn with_ellipse_axes(sk: &Sketch, cs: &mut Vec<usize>) {
|
||||
let ellipses: Vec<usize> = cs.iter().filter_map(|&l| sk.ellipse_of_axis(l)).collect();
|
||||
for e in ellipses {
|
||||
if !cs.contains(&e) {
|
||||
cs.push(e);
|
||||
}
|
||||
}
|
||||
let axes: Vec<usize> = cs
|
||||
.iter()
|
||||
.filter_map(|&e| sk.ellipse_axis_lines(e))
|
||||
.flat_map(|(major, minor)| major.map(|(l, _)| l).into_iter().chain(minor.map(|(l, ..)| l)))
|
||||
.collect();
|
||||
for l in axes {
|
||||
if !cs.contains(&l) {
|
||||
cs.push(l);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Points used by curves plus lone points, in a stable order.
|
||||
fn involved_points(sk: &Sketch, cs: &[usize], ps: &[usize]) -> Vec<usize> {
|
||||
let mut v: Vec<usize> = Vec::new();
|
||||
@@ -3513,6 +3546,7 @@ fn move_copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
return Ok(ids);
|
||||
}
|
||||
with_ellipse_axes(sk, &mut cs);
|
||||
transform_in_place(sk, &cs, &ps, &f, 1.0, cmd)?;
|
||||
if rotate_constraints {
|
||||
let moved = involved_points(sk, &cs, &ps);
|
||||
@@ -3538,22 +3572,83 @@ fn transform(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
None => 0.0,
|
||||
};
|
||||
let t = if p.get("translate").is_some() { req_vec2(cmd, p, "translate")? } else { Vec2::ZERO };
|
||||
let (out, info) = edit(s, p, cmd, false, |sk, _| {
|
||||
let (cs, ps) = entities(sk, p, cmd)?;
|
||||
let (out, info) = edit(s, p, cmd, false, |sk, doc| {
|
||||
let (mut cs, ps) = entities(sk, p, cmd)?;
|
||||
with_ellipse_axes(sk, &mut cs);
|
||||
let c = if p.get("center").is_some() { point_or_ref(sk, p, "center", cmd)? } else { Vec2::ZERO };
|
||||
let rot = rotate(c, ang);
|
||||
let f = move |q: Vec2| rot(c + (q - c) * k) + t;
|
||||
transform_in_place(sk, &cs, &ps, &f, k, cmd)?;
|
||||
let moved = involved_points(sk, &cs, &ps);
|
||||
rotate_axis_constraints(sk, doc, &moved, ang)?;
|
||||
scale_dimensions(sk, doc, &cs, &moved, k)?;
|
||||
Ok(ids_of(sk, &cs))
|
||||
})?;
|
||||
s.doc_mut().prune_model_params();
|
||||
Ok(json!({"curves": out, "sketch": info}))
|
||||
}
|
||||
|
||||
/// The driving lengths wholly inside a transformed selection scale with it. Keep formulas
|
||||
/// tied to their user parameters, and compensate references between scaled dimensions so a
|
||||
/// dimension such as `d1 / 2` does not get the factor twice.
|
||||
fn scale_dimensions(sk: &mut Sketch, doc: &mut solidcraft_doc::Document, curves: &[usize], points: &[usize], factor: f64) -> Result<()> {
|
||||
if factor == 1.0 {
|
||||
return Ok(());
|
||||
}
|
||||
let selected: Vec<usize> = sk
|
||||
.constraints
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, c)| {
|
||||
let pts = sk.constraint_points(&c.kind);
|
||||
!c.driven
|
||||
&& c.kind.is_dimension()
|
||||
&& !c.kind.is_angle()
|
||||
&& !pts.is_empty()
|
||||
&& pts.iter().all(|p| points.contains(p))
|
||||
// A circle's centre may move with another curve while its radius stays put.
|
||||
&& c.kind.indices().1.iter().all(|i| {
|
||||
curves.contains(i)
|
||||
|| !matches!(sk.curves.get(*i).map(|c| &c.kind), Some(CurveKind::Circle { .. } | CurveKind::Ellipse { .. }))
|
||||
})
|
||||
})
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
let mut expressions = std::collections::BTreeMap::new();
|
||||
for i in selected {
|
||||
let Some(c) = sk.constraints.get_mut(i) else { continue };
|
||||
if let Some(value) = c.kind.value() {
|
||||
c.kind.set_value(value * factor);
|
||||
}
|
||||
if let Some(name) = &c.param
|
||||
&& let Some(param) = doc.param(name)
|
||||
{
|
||||
let expression = if solidcraft_doc::expr::references(¶m.expr).is_empty() {
|
||||
format!("{} mm", doc.eval(name, solidcraft_doc::expr::Kind::Length)? * factor)
|
||||
} else {
|
||||
let formula = param.expr.trim().strip_prefix('=').unwrap_or(¶m.expr);
|
||||
format!("({formula}) * {factor}")
|
||||
};
|
||||
expressions.insert(name.clone(), expression);
|
||||
}
|
||||
}
|
||||
for (name, mut expression) in expressions.clone() {
|
||||
for reference in solidcraft_doc::expr::references(&expression) {
|
||||
if expressions.contains_key(&reference) {
|
||||
expression = solidcraft_doc::expr::rename_reference(&expression, &reference, &format!("({reference} / {factor})"));
|
||||
}
|
||||
}
|
||||
doc.set_param(&name, &expression, None, None)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn scale(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.scale";
|
||||
let k = num(p, "factor").filter(|k| *k > 1e-9 && *k < 1e6).ok_or_else(|| bad(cmd, "`factor` must be a positive number"))?;
|
||||
let (out, info) = edit(s, p, cmd, false, |sk, _| {
|
||||
let (cs, ps) = entities(sk, p, cmd)?;
|
||||
let (mut cs, ps) = entities(sk, p, cmd)?;
|
||||
with_ellipse_axes(sk, &mut cs);
|
||||
let b = if p.get("base").is_some() { point_or_ref(sk, p, "base", cmd)? } else { Vec2::ZERO };
|
||||
let f = move |q: Vec2| b + (q - b) * k;
|
||||
transform_in_place(sk, &cs, &ps, &f, k, cmd)?;
|
||||
|
||||
@@ -142,7 +142,7 @@ fn explicit_ellipse_break_keeps_closed_profile() {
|
||||
let out = run(&mut s, "sketch.break", json!({"curve": id, "points": [[10,0], [0,4], [10,0]]}));
|
||||
assert_eq!(ids(&out["curves"]).len(), 4, "{out}");
|
||||
let after = sketch(&s);
|
||||
assert!(after.curves.iter().all(|c| matches!(c.kind, CurveKind::Conic { .. })));
|
||||
assert!(after.curves.iter().filter(|c| !c.construction).all(|c| matches!(c.kind, CurveKind::Conic { .. })));
|
||||
assert_eq!(profiles(&s).len(), 1);
|
||||
assert!((profiles(&s)[0] - area).abs() < 1e-6);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
@@ -174,6 +174,83 @@ fn explicit_break_rejects_bad_points_atomically_and_merges_duplicates() {
|
||||
}
|
||||
}
|
||||
|
||||
/// An axis line moved, turned or scaled on its own takes its ellipse and the other axis along,
|
||||
/// as a drag of the same line does, instead of pulling away from the ellipse.
|
||||
#[test]
|
||||
fn an_ellipse_axis_moved_turned_or_scaled_alone_takes_its_ellipse_along() {
|
||||
for (cmd, params, centre, major) in [
|
||||
("sketch.move", json!({"translate": [0, 30]}), Vec2::new(0.0, 30.0), Vec2::new(20.0, 30.0)),
|
||||
("sketch.move", json!({"angle": 90, "center": [0, 0]}), Vec2::ZERO, Vec2::new(0.0, 20.0)),
|
||||
("sketch.scale", json!({"base": [0, 0], "factor": 2}), Vec2::ZERO, Vec2::new(40.0, 0.0)),
|
||||
("sketch.transform", json!({"translate": [5, 0]}), Vec2::new(5.0, 0.0), Vec2::new(25.0, 0.0)),
|
||||
] {
|
||||
for which in ["major", "minor"] {
|
||||
let mut s = new_sketch();
|
||||
let e = ids(&run(&mut s, "sketch.ellipse", json!({"center": [0, 0], "major": [20, 0], "minor_radius": 8}))["curves"])[0].clone();
|
||||
let sk = sketch(&s);
|
||||
let Some((Some((major_line, _)), Some((minor_line, ..)))) = sk.ellipse_axis_lines(sk.curve_index(&e).unwrap()) else {
|
||||
panic!("its axes")
|
||||
};
|
||||
let axis = sk.curves[if which == "major" { major_line } else { minor_line }].id.clone();
|
||||
let mut p = params.clone();
|
||||
p["entities"] = json!([axis]);
|
||||
let r = run(&mut s, cmd, p);
|
||||
assert_eq!(r["sketch"]["solved"], json!(true), "{cmd} {params} {which}: {r}");
|
||||
let sk = sketch(&s);
|
||||
let ei = sk.curve_index(&e).unwrap();
|
||||
let CurveKind::Ellipse { c, m, .. } = sk.curves[ei].kind else { panic!("an ellipse") };
|
||||
assert!(sk.point(c).unwrap().dist(centre) < 1e-6, "{cmd} {params} {which}: the ellipse's centre follows: {:?}", sk.point(c));
|
||||
assert!(sk.point(m).unwrap().dist(major) < 1e-6, "{cmd} {params} {which}: its major end follows: {:?}", sk.point(m));
|
||||
assert!(sk.ellipse_axis_lines(ei).is_some_and(|(a, b)| a.is_some() && b.is_some()), "{cmd} {params} {which}: still its axes");
|
||||
assert!(ids(&r["curves"]).contains(&e), "{cmd} {params} {which}: the reply lists the ellipse: {r}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An ellipse moved, turned or scaled on its own takes its axes along, as a drag does: they
|
||||
/// stay its axes and nothing is left failing.
|
||||
#[test]
|
||||
fn an_ellipse_moved_turned_or_scaled_alone_takes_its_axes_along() {
|
||||
// With the minor axis deleted, the major axis that is left still goes along.
|
||||
let mut s = new_sketch();
|
||||
let e = ids(&run(&mut s, "sketch.ellipse", json!({"center": [0, 0], "major": [20, 0], "minor_radius": 8}))["curves"])[0].clone();
|
||||
let minor = sketch(&s).curves.iter().rev().find(|c| matches!(c.kind, CurveKind::Line { .. })).unwrap().id.clone();
|
||||
run(&mut s, "sketch.delete", json!({"entities": [minor]}));
|
||||
let r = run(&mut s, "sketch.move", json!({"entities": [e], "translate": [0, 30]}));
|
||||
assert_eq!(r["sketch"]["solved"], json!(true), "{r}");
|
||||
let sk = sketch(&s);
|
||||
let (major, _) = sk.ellipse_axis_lines(sk.curve_index(&e).unwrap()).and_then(|(major, _)| major).expect("its major axis");
|
||||
let CurveKind::Line { a, b } = sk.curves[major].kind else { panic!("a line") };
|
||||
assert!(sk.point(a).unwrap().dist(Vec2::new(-20.0, 30.0)) < 1e-6 && sk.point(b).unwrap().dist(Vec2::new(20.0, 30.0)) < 1e-6);
|
||||
assert!(ids(&r["curves"]).contains(&sk.curves[major].id), "the reply lists the axis moved: {r}");
|
||||
for (cmd, params, centre, major) in [
|
||||
("sketch.move", json!({"translate": [0, 30]}), Vec2::new(0.0, 30.0), Vec2::new(20.0, 30.0)),
|
||||
("sketch.move", json!({"angle": 90, "center": [0, 0]}), Vec2::ZERO, Vec2::new(0.0, 20.0)),
|
||||
("sketch.scale", json!({"base": [0, 0], "factor": 2}), Vec2::ZERO, Vec2::new(40.0, 0.0)),
|
||||
("sketch.transform", json!({"translate": [5, 0]}), Vec2::new(5.0, 0.0), Vec2::new(25.0, 0.0)),
|
||||
] {
|
||||
let mut s = new_sketch();
|
||||
let e = ids(&run(&mut s, "sketch.ellipse", json!({"center": [0, 0], "major": [20, 0], "minor_radius": 8}))["curves"])[0].clone();
|
||||
let mut p = params.clone();
|
||||
p["entities"] = json!([e]);
|
||||
let r = run(&mut s, cmd, p);
|
||||
assert_eq!(r["sketch"]["solved"], json!(true), "{cmd} {params}: {r}");
|
||||
let sk = sketch(&s);
|
||||
let ei = sk.curve_index(&e).unwrap();
|
||||
let Some((Some((major_line, opposite)), Some((minor_line, n1, n2)))) = sk.ellipse_axis_lines(ei) else {
|
||||
panic!("{cmd} {params}: still its axes")
|
||||
};
|
||||
let CurveKind::Ellipse { m, .. } = sk.curves[ei].kind else { panic!("an ellipse") };
|
||||
let ends: Vec<Vec2> = [m, opposite, n1, n2].iter().map(|q| sk.point(*q).unwrap()).collect();
|
||||
assert!(ends[0].dist(major) < 1e-6 && ends[1].dist(centre * 2.0 - major) < 1e-6, "{cmd} {params}: the major axis follows: {ends:?}");
|
||||
assert!(((ends[2] + ends[3]) * 0.5).dist(centre) < 1e-6, "{cmd} {params}: the minor axis follows: {ends:?}");
|
||||
let listed = ids(&r["curves"]);
|
||||
for l in [major_line, minor_line] {
|
||||
assert!(listed.contains(&sk.curves[l].id), "{cmd} {params}: the reply lists the axis lines: {r}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analytic_trim_and_break_use_freeform_cutters() {
|
||||
let mut s = new_sketch();
|
||||
@@ -190,11 +267,95 @@ fn analytic_trim_and_break_use_freeform_cutters() {
|
||||
let mut s = new_sketch();
|
||||
let circle = ids(&run(&mut s, "sketch.circle.center", json!({"center": [0,0], "radius": 5}))["curves"])[0].clone();
|
||||
run(&mut s, "sketch.ellipse", json!({"center": [0,0], "major": [8,0], "minor": [0,3]}));
|
||||
run(&mut s, "sketch.trim", json!({"curve": circle, "at": [5,0]}));
|
||||
// Picked 15 degrees round from where the major axis crosses the circle: the piece from the
|
||||
// axis to the ellipse's crossing goes, the rest stays. Its construction axes cut too (see
|
||||
// the next test).
|
||||
run(&mut s, "sketch.trim", json!({"curve": circle, "at": [4.8296,1.2941]}));
|
||||
let sk = sketch(&s);
|
||||
let ci = sk.curve_index(&circle).unwrap();
|
||||
assert!(matches!(sk.curves[ci].kind, CurveKind::Arc { .. }));
|
||||
assert!(sk.shape(ci).unwrap().dist(Vec2::new(-5.0, 0.0)) < 1e-6);
|
||||
let CurveKind::Arc { a, b, .. } = sk.curves[ci].kind else { panic!("{:?}", sk.curves[ci].kind) };
|
||||
let crossing = Vec2::new((25.0f64 - 351.0 / 55.0).sqrt(), (351.0f64 / 55.0).sqrt());
|
||||
let mut ends = [sk.point(a).unwrap(), sk.point(b).unwrap()];
|
||||
ends.sort_by(|p, q| p.y.total_cmp(&q.y));
|
||||
assert!(ends[0].dist(Vec2::new(5.0, 0.0)) < 1e-6 && ends[1].dist(crossing) < 1e-6, "{ends:?}");
|
||||
let shape = sk.shape(ci).unwrap();
|
||||
assert!(shape.dist(Vec2::new(-5.0, 0.0)) < 1e-6 && shape.dist(Vec2::new(4.8296, -1.2941)) < 1e-3);
|
||||
assert!(shape.dist(Vec2::new(4.8296, 1.2941)) > 0.5);
|
||||
}
|
||||
|
||||
/// An ellipse's own axes do not cut it (they end on it, where a Center Rectangle's diagonals end
|
||||
/// at its corners): a lone ellipse trims away whole, its axes staying as when it is deleted, and
|
||||
/// a trim of its outline stops where a line crosses, not at an axis end.
|
||||
#[test]
|
||||
fn an_ellipse_s_own_axes_do_not_cut_it() {
|
||||
let mut s = new_sketch();
|
||||
let e = ids(&run(&mut s, "sketch.ellipse", json!({"center": [0, 0], "major": [20, 0], "minor_radius": 8}))["curves"])[0].clone();
|
||||
run(&mut s, "sketch.trim", json!({"curve": e, "at": [0, 8]}));
|
||||
let sk = sketch(&s);
|
||||
assert!(sk.curves.iter().all(|c| c.construction), "the whole ellipse goes: {:?}", sk.curves);
|
||||
assert_eq!(sk.curves.len(), 2, "its two axes stay");
|
||||
|
||||
let mut s = new_sketch();
|
||||
let e = ids(&run(&mut s, "sketch.ellipse", json!({"center": [0, 0], "major": [20, 0], "minor_radius": 8}))["curves"])[0].clone();
|
||||
run(&mut s, "sketch.line", json!({"points": [[10, -10], [10, 10]], "connect": false}));
|
||||
// Clicked 10 degrees round from the major end: the cap right of the line goes, from one
|
||||
// crossing to the other, past the major end.
|
||||
let at = Vec2::new(20.0 * 10f64.to_radians().cos(), 8.0 * 10f64.to_radians().sin());
|
||||
run(&mut s, "sketch.trim", json!({"curve": e, "at": [at.x, at.y]}));
|
||||
let sk = sketch(&s);
|
||||
let left: Vec<usize> =
|
||||
(0..sk.curves.len()).filter(|i| !sk.curves[*i].construction && !matches!(sk.curves[*i].kind, CurveKind::Line { .. })).collect();
|
||||
// What is left of the ellipse comes back as conic pieces; the trim must leave some.
|
||||
assert!(!left.is_empty(), "part of the ellipse is left: {:?}", sk.curves);
|
||||
for i in left {
|
||||
let right = sk.polyline(i).iter().map(|q| q.x).fold(f64::MIN, f64::max);
|
||||
assert!(right < 10.0 + 1e-6, "{}: what is left stays left of the line, reaching x = {right}", sk.curves[i].id);
|
||||
}
|
||||
}
|
||||
|
||||
/// An ellipse's construction axes cut a trim, as a Center Rectangle's construction diagonals
|
||||
/// do: a line inside the ellipse trims back to the minor axis, one inside the rectangle to the
|
||||
/// diagonals.
|
||||
#[test]
|
||||
fn an_ellipse_s_axes_cut_as_a_center_rectangle_s_diagonals_do() {
|
||||
// What is left of the line at y = 1, as x ranges.
|
||||
let lines = |s: &Session| {
|
||||
let sk = sketch(s);
|
||||
let mut ends: Vec<[f64; 2]> = sk
|
||||
.curves
|
||||
.iter()
|
||||
.filter_map(|c| match c.kind {
|
||||
CurveKind::Line { a, b } => {
|
||||
let (a, b) = (sk.point(a).unwrap(), sk.point(b).unwrap());
|
||||
(close(a.y, 1.0) && close(b.y, 1.0)).then(|| [a.x.min(b.x), a.x.max(b.x)])
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
ends.sort_by(|x, y| x[0].total_cmp(&y[0]));
|
||||
ends
|
||||
};
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.ellipse", json!({"center": [0, 0], "major": [20, 0], "minor_radius": 8}));
|
||||
let line = ids(&run(&mut s, "sketch.line", json!({"points": [[-3, 1], [3, 1]], "connect": false}))["curves"])[0].clone();
|
||||
run(&mut s, "sketch.trim", json!({"curve": line, "at": [1, 1]}));
|
||||
let ends = lines(&s);
|
||||
assert!(ends.len() == 1 && close(ends[0][0], -3.0) && close(ends[0][1], 0.0), "{ends:?}");
|
||||
|
||||
// Extend stops at them too: a line inside the ellipse reaches the minor axis, not the outline.
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.ellipse", json!({"center": [0, 0], "major": [20, 0], "minor_radius": 8}));
|
||||
let line = ids(&run(&mut s, "sketch.line", json!({"points": [[-6, 1], [-3, 1]], "connect": false}))["curves"])[0].clone();
|
||||
run(&mut s, "sketch.extend", json!({"curve": line, "at": [-3, 1]}));
|
||||
let ends = lines(&s);
|
||||
assert!(ends.len() == 1 && close(ends[0][0], -6.0) && close(ends[0][1], 0.0), "{ends:?}");
|
||||
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.rectangle.center", json!({"center": [0, 0], "corner": [20, 8]}));
|
||||
let line = ids(&run(&mut s, "sketch.line", json!({"points": [[-3, 1], [3, 1]], "connect": false}))["curves"])[0].clone();
|
||||
run(&mut s, "sketch.trim", json!({"curve": line, "at": [1, 1]}));
|
||||
let ends = lines(&s);
|
||||
assert!(ends.len() == 2 && close(ends[0][1], -2.5) && close(ends[1][0], 2.5), "{ends:?}");
|
||||
}
|
||||
|
||||
/// #10851: an endpoint's tangent must stay with the arc piece that owns that endpoint,
|
||||
@@ -1194,6 +1355,65 @@ fn transform_scales_turns_and_moves_in_one_step() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Transform must leave a dimensioned rectangle where its preview put it, rather than letting
|
||||
/// the old horizontal/vertical relations and size dimensions pull it back (#11617).
|
||||
#[test]
|
||||
fn transform_turns_and_scales_a_dimensioned_rectangle() {
|
||||
for angle in [30.0_f64, 90.0, 180.0] {
|
||||
let mut s = new_sketch();
|
||||
let drawn = run(&mut s, "sketch.rectangle.center", json!({"center": [0, 0], "corner": [25, 12.5]}));
|
||||
let curves = ids(&drawn["curves"]);
|
||||
let width = run(&mut s, "sketch.dimension", json!({"entities": [curves[0]], "type": "length", "value": "50 mm"}));
|
||||
let height = run(&mut s, "sketch.dimension", json!({"entities": [curves[1]], "type": "length", "value": "25 mm"}));
|
||||
let before = sketch(&s);
|
||||
let result = run(&mut s, "sketch.transform", json!({"entities": curves, "factor": 2, "angle": angle, "center": [0, 0]}));
|
||||
assert_eq!(result["sketch"]["solved"], true);
|
||||
let after = sketch(&s);
|
||||
let (sin, cos) = angle.to_radians().sin_cos();
|
||||
for id in ["p1", "p2", "p3", "p4"] {
|
||||
let p = before.point(before.resolve_point(id).unwrap()).unwrap();
|
||||
let expected = Vec2::new(p.x * cos - p.y * sin, p.x * sin + p.y * cos) * 2.0;
|
||||
let actual = after.point(after.resolve_point(id).unwrap()).unwrap();
|
||||
assert!(actual.dist(expected) < 1e-6, "{angle} degrees, {id}: {actual:?}, expected {expected:?}");
|
||||
}
|
||||
for (dimension, expected) in [(&width, 100.0), (&height, 50.0)] {
|
||||
let name = dimension["param"].as_str().unwrap();
|
||||
assert!(close(s.doc.eval(name, solidcraft_doc::expr::Kind::Length).unwrap(), expected));
|
||||
}
|
||||
assert!(close(profiles(&s)[0], 5000.0));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(sketch(&s).constraints, before.constraints);
|
||||
assert!(close(profiles(&s)[0], 1250.0));
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert!(close(profiles(&s)[0], 5000.0));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transform_scales_dimension_formulas_once_and_keeps_unselected_dimensions() {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "parameters.add", json!({"name": "width", "expression": "50 mm"}));
|
||||
let rectangle = run(&mut s, "sketch.rectangle.two_point", json!({"p0": [10, 10], "p1": [60, 35]}));
|
||||
let curves = ids(&rectangle["curves"]);
|
||||
let width = run(&mut s, "sketch.dimension", json!({"entities": [curves[0]], "type": "length", "value": "=width"}));
|
||||
let width_param = width["param"].as_str().unwrap();
|
||||
let height = run(&mut s, "sketch.dimension", json!({"entities": [curves[1]], "type": "length", "value": format!("{width_param} / 2")}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": [curves[2]], "type": "length", "driven": true}));
|
||||
let circle = run(&mut s, "sketch.circle.center", json!({"center": [100, 100], "radius": 5}));
|
||||
let radius = run(&mut s, "sketch.dimension", json!({"entities": [circle["curves"][0]], "type": "radius", "value": "5 mm"}));
|
||||
let outside = s.doc.param(radius["param"].as_str().unwrap()).unwrap().clone();
|
||||
run(&mut s, "sketch.transform", json!({"entities": curves, "factor": 2, "angle": 90, "center": [35, 22.5]}));
|
||||
let length = solidcraft_doc::expr::Kind::Length;
|
||||
assert!(close(s.doc.eval("width", length).unwrap(), 50.0), "the user parameter stays unchanged");
|
||||
assert!(close(s.doc.eval(width_param, length).unwrap(), 100.0));
|
||||
assert!(close(s.doc.eval(height["param"].as_str().unwrap(), length).unwrap(), 50.0));
|
||||
assert_eq!(s.doc.param(&outside.name).unwrap(), &outside);
|
||||
assert!(close(sketch(&s).radius(sketch(&s).curve_index(circle["curves"][0].as_str().unwrap()).unwrap()).unwrap(), 5.0));
|
||||
run(&mut s, "parameters.change", json!({"name": "width", "expression": "60 mm"}));
|
||||
assert!(close(s.doc.eval(width_param, length).unwrap(), 120.0), "the formula still follows its user parameter");
|
||||
assert!(close(s.doc.eval(height["param"].as_str().unwrap(), length).unwrap(), 60.0), "dependent dimensions scale only once");
|
||||
}
|
||||
|
||||
/// #1485: after a circle is trimmed between two construction lines, clicking the trimmed end
|
||||
/// picks the construction line's end lying there, not the arc's own; the tangent arc still starts
|
||||
/// from the trimmed arc, tangent to it.
|
||||
@@ -2005,6 +2225,164 @@ fn ellipse_radii_dimensions() {
|
||||
assert!((a - std::f64::consts::PI * 60.0).abs() < 1e-6, "{a}");
|
||||
}
|
||||
|
||||
/// An ellipse comes with its major and minor axes as construction lines through its centre.
|
||||
/// They add no freedom (the ellipse keeps 5 degrees), follow its size, and stay when the
|
||||
/// ellipse is deleted, as a Center Rectangle's construction diagonals stay with its edges gone.
|
||||
#[test]
|
||||
fn an_ellipse_comes_with_its_axes_as_construction_lines() {
|
||||
let axes = |s: &Session| {
|
||||
let sk = sketch(s);
|
||||
let mut lengths: Vec<f64> = sk
|
||||
.curves
|
||||
.iter()
|
||||
.filter_map(|c| match c.kind {
|
||||
CurveKind::Line { a, b } if c.construction => Some(sk.point(a).unwrap().dist(sk.point(b).unwrap())),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
lengths.sort_by(f64::total_cmp);
|
||||
lengths
|
||||
};
|
||||
let mut s = new_sketch();
|
||||
let out = run(&mut s, "sketch.ellipse", json!({"center": [5, 3], "major": [25, 3], "minor_radius": 8}));
|
||||
let e = ids(&out["curves"])[0].clone();
|
||||
assert_eq!(out["created"]["curves"].as_array().map(Vec::len), Some(3), "{out}");
|
||||
assert_eq!(run(&mut s, "sketch.inspect", json!({}))["dof"], json!(5));
|
||||
let lengths = axes(&s);
|
||||
assert!(lengths.len() == 2 && close(lengths[0], 16.0) && close(lengths[1], 40.0), "{lengths:?}");
|
||||
assert!(profiles(&s).len() == 1 && close(profiles(&s)[0], std::f64::consts::PI * 160.0), "{:?}", profiles(&s));
|
||||
// Dragging either axis moves the ellipse and its axes as one, sizes kept, as dragging a
|
||||
// Center Rectangle's diagonal moves the rectangle.
|
||||
let half_axes = |s: &Session| {
|
||||
let sk = sketch(s);
|
||||
let Some(CurveKind::Ellipse { c, m, r }) = sk.curves.iter().map(|x| x.kind.clone()).find(|k| matches!(k, CurveKind::Ellipse { .. })) else {
|
||||
panic!("the ellipse")
|
||||
};
|
||||
(sk.point(c).unwrap(), sk.point(c).unwrap().dist(sk.point(m).unwrap()), r)
|
||||
};
|
||||
let (centre, ..) = half_axes(&s);
|
||||
let (major, minor) = {
|
||||
let sk = sketch(&s);
|
||||
let mut lines: Vec<(f64, String)> = sk
|
||||
.curves
|
||||
.iter()
|
||||
.filter_map(|c| match c.kind {
|
||||
CurveKind::Line { a, b } => Some((sk.point(a).unwrap().dist(sk.point(b).unwrap()), c.id.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
lines.sort_by(|x, y| y.0.total_cmp(&x.0));
|
||||
(lines[0].1.clone(), lines[1].1.clone())
|
||||
};
|
||||
let mut moved = centre;
|
||||
for (line, by) in [(&major, Vec2::new(4.0, 6.0)), (&minor, Vec2::new(-3.0, 2.0))] {
|
||||
let from = moved + if *line == major { Vec2::new(-12.0, 0.0) } else { Vec2::new(0.0, 4.0) };
|
||||
run(&mut s, "sketch.drag", json!({"entity": line, "from": [from.x, from.y], "to": [from.x + by.x, from.y + by.y]}));
|
||||
run(&mut s, "sketch.drag_end", json!({}));
|
||||
moved += by;
|
||||
let (at, a, b) = half_axes(&s);
|
||||
assert!(at.dist(moved) < 1e-6 && close(a, 20.0) && close(b, 8.0), "dragging {line}: centre {at:?}, half-axes {a} {b}");
|
||||
let lengths = axes(&s);
|
||||
assert!(close(lengths[0], 16.0) && close(lengths[1], 40.0), "dragging {line}: {lengths:?}");
|
||||
}
|
||||
// Dragging the outline still sets the minor half-axis, the axes following it.
|
||||
run(&mut s, "sketch.drag", json!({"entity": &e, "from": [moved.x, moved.y + 8.0], "to": [moved.x, moved.y + 10.0]}));
|
||||
run(&mut s, "sketch.drag_end", json!({}));
|
||||
let (at, a, b) = half_axes(&s);
|
||||
assert!(at.dist(moved) < 1e-6 && close(a, 20.0) && close(b, 10.0), "dragging the outline: centre {at:?}, half-axes {a} {b}");
|
||||
let lengths = axes(&s);
|
||||
assert!(close(lengths[0], 20.0) && close(lengths[1], 40.0), "dragging the outline: {lengths:?}");
|
||||
// Sizing the ellipse resizes its axes, and they stay through its centre.
|
||||
run(&mut s, "sketch.dimension", json!({"entities": [&e], "type": "major", "value": 30}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": [&e], "type": "minor", "value": 10}));
|
||||
let lengths = axes(&s);
|
||||
assert!(close(lengths[0], 20.0) && close(lengths[1], 60.0), "{lengths:?}");
|
||||
assert!(close(profiles(&s)[0], std::f64::consts::PI * 300.0), "{:?}", profiles(&s));
|
||||
// A double-click on the ellipse or on either axis selects all three, as one on a Center
|
||||
// Rectangle's edge or diagonal selects its edges and both diagonals.
|
||||
let curves: Vec<String> = sketch(&s).curves.iter().map(|c| c.id.clone()).collect();
|
||||
for id in &curves {
|
||||
let mut chain = ids(&run(&mut s, "sketch.chain", json!({"curve": id}))["curves"]);
|
||||
chain.sort();
|
||||
assert_eq!(chain, curves, "double-click on {id}");
|
||||
}
|
||||
// Deleting it leaves the two axes, still construction and still perpendicular, and still
|
||||
// bisected by the centre, which stays as a Center Rectangle's centre does.
|
||||
run(&mut s, "sketch.delete", json!({"entities": [&e]}));
|
||||
let sk = sketch(&s);
|
||||
assert_eq!(sk.curves.len(), 2);
|
||||
assert!(sk.curves.iter().all(|c| c.construction && matches!(c.kind, CurveKind::Line { .. })));
|
||||
assert!(sk.constraints.iter().any(|c| matches!(c.kind, ConstraintKind::Perpendicular { .. })));
|
||||
let centres: Vec<usize> =
|
||||
sk.constraints.iter().filter_map(|c| if let ConstraintKind::Midpoint { p, .. } = c.kind { Some(p) } else { None }).collect();
|
||||
assert!(centres.len() == 2 && centres[0] == centres[1], "both axes keep their midpoint on the centre: {:?}", sk.constraints);
|
||||
// The same as a Center Rectangle whose edges are deleted: its diagonals stay.
|
||||
let mut s = new_sketch();
|
||||
let edges = ids(&run(&mut s, "sketch.rectangle.center", json!({"center": [0, 0], "corner": [20, 10]}))["curves"]);
|
||||
assert_eq!(ids(&run(&mut s, "sketch.chain", json!({"curve": &edges[0]}))["curves"]).len(), 6);
|
||||
run(&mut s, "sketch.delete", json!({"entities": edges}));
|
||||
let sk = sketch(&s);
|
||||
assert!(sk.curves.len() == 2 && sk.curves.iter().all(|c| c.construction), "{:?}", sk.curves);
|
||||
// With axes: false (the Sketch preference off), the ellipse alone, as before its axes.
|
||||
let mut s = new_sketch();
|
||||
let r = run(&mut s, "sketch.ellipse", json!({"center": [0, 0], "major": [20, 0], "minor_radius": 8, "axes": false}));
|
||||
let sk = sketch(&s);
|
||||
assert!(sk.curves.len() == 1 && sk.constraints.is_empty(), "{r}");
|
||||
assert_eq!(r["sketch"]["dof"], json!(5));
|
||||
let refused = s.execute("sketch.ellipse", &json!({"center": [0, 0], "major": [20, 0], "minor_radius": 8, "axes": "false"}));
|
||||
assert!(refused.is_err_and(|e| e.to_string().contains("true or false")));
|
||||
}
|
||||
|
||||
/// An ellipse with its axes, the minor one (or the major one) then deleted: the ids of the
|
||||
/// ellipse and of the axis left.
|
||||
fn ellipse_with_one_axis(s: &mut Session, keep_major: bool) -> (String, String) {
|
||||
let e = ids(&run(s, "sketch.ellipse", json!({"center": [0, 0], "major": [20, 0], "minor_radius": 8}))["curves"])[0].clone();
|
||||
let sk = sketch(s);
|
||||
let mut lines: Vec<(f64, String)> = sk
|
||||
.curves
|
||||
.iter()
|
||||
.filter_map(|c| match c.kind {
|
||||
CurveKind::Line { a, b } => Some((sk.point(a).unwrap().dist(sk.point(b).unwrap()), c.id.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
lines.sort_by(|x, y| y.0.total_cmp(&x.0));
|
||||
let (kept, gone) = if keep_major { (&lines[0].1, &lines[1].1) } else { (&lines[1].1, &lines[0].1) };
|
||||
run(s, "sketch.delete", json!({"entities": [gone]}));
|
||||
(e, kept.clone())
|
||||
}
|
||||
|
||||
/// With one of its axes deleted, dragging the other still moves the ellipse with it, sizes kept.
|
||||
#[test]
|
||||
fn an_ellipse_s_remaining_axis_drags_it_along() {
|
||||
for (keep_major, from) in [(true, Vec2::new(-12.0, 0.0)), (false, Vec2::new(0.0, 4.0))] {
|
||||
let mut s = new_sketch();
|
||||
let (_, axis) = ellipse_with_one_axis(&mut s, keep_major);
|
||||
run(&mut s, "sketch.drag", json!({"entity": &axis, "from": [from.x, from.y], "to": [from.x + 4.0, from.y + 6.0]}));
|
||||
run(&mut s, "sketch.drag_end", json!({}));
|
||||
let sk = sketch(&s);
|
||||
let Some(CurveKind::Ellipse { c, m, r }) = sk.curves.iter().map(|x| x.kind.clone()).find(|k| matches!(k, CurveKind::Ellipse { .. })) else {
|
||||
panic!("the ellipse: {:?}", sk.curves)
|
||||
};
|
||||
let (at, a) = (sk.point(c).unwrap(), sk.point(c).unwrap().dist(sk.point(m).unwrap()));
|
||||
assert!(at.dist(Vec2::new(4.0, 6.0)) < 1e-6 && close(a, 20.0) && close(r, 8.0), "major kept {keep_major}: centre {at:?}, half-axes {a} {r}");
|
||||
}
|
||||
}
|
||||
|
||||
/// With one of its axes deleted, a double-click on the ellipse or the other axis selects both.
|
||||
#[test]
|
||||
fn an_ellipse_and_its_remaining_axis_are_one_chain() {
|
||||
let mut s = new_sketch();
|
||||
let (e, major) = ellipse_with_one_axis(&mut s, true);
|
||||
let mut both = vec![e.clone(), major.clone()];
|
||||
both.sort();
|
||||
for id in [&e, &major] {
|
||||
let mut chain = ids(&run(&mut s, "sketch.chain", json!({"curve": id}))["curves"]);
|
||||
chain.sort();
|
||||
assert_eq!(chain, both, "double-click on {id}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #1026: a construction diagonal ending at a rectangle's corner is not one of the corner's
|
||||
/// sides. The corner is rounded, and the diagonal keeps its end on the virtual sharp corner.
|
||||
#[test]
|
||||
@@ -2331,7 +2709,8 @@ fn certified_offset_delete_output_undo_reopen_and_refuse_dependent_source() {
|
||||
run(&mut s, "sketch.delete", json!({"entities":[node]}));
|
||||
assert!(sketch(&s).freeform_offsets.is_empty());
|
||||
assert!(sketch(&s).curve_index(&id).is_none());
|
||||
assert_eq!(sketch(&s).points.len(), 3, "all private derived nodes must be removed");
|
||||
// The origin, the ellipse's centre and major end, and its three other axis ends.
|
||||
assert_eq!(sketch(&s).points.len(), 6, "all private derived nodes must be removed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -161,7 +161,8 @@ fn fixing_curves_holds_their_points_and_radius() {
|
||||
assert_eq!(r["result"]["changed"], 4);
|
||||
assert_eq!(dof(&mut s), free - 17);
|
||||
let si = run(&mut s, "sketch.inspect", json!({}));
|
||||
assert!(si["curves"].as_array().is_some_and(|c| c.iter().all(|c| c["fixed"] == true)), "{si}");
|
||||
// The ellipse's construction axes were not picked.
|
||||
assert!(si["curves"].as_array().is_some_and(|c| c.iter().filter(|c| c["construction"] != true).all(|c| c["fixed"] == true)), "{si}");
|
||||
// A fixed circle keeps centre and radius; a dimension on it conflicts.
|
||||
run(&mut s, "sketch.move_point", json!({"point": "c1.center", "to": [0, 0]}));
|
||||
assert_eq!(pt(&s, "c1.center"), solidcore_record::Vec2::new(10.0, 10.0));
|
||||
@@ -1513,6 +1514,301 @@ fn auto_dimension_orientation_follows_where_its_text_is_placed() {
|
||||
assert!(((end.x - start.x).abs() - 24.0).abs() < 1e-6 && ((end.y - start.y).abs() - 40.0).abs() < 1e-6, "{start:?} {end:?}");
|
||||
}
|
||||
|
||||
/// An ellipse with half-axes 20 and 8, turned by 45 degrees when asked: its id, and its
|
||||
/// construction axis lines, major first.
|
||||
fn ellipse_with_axes(s: &mut Session, turn: bool) -> (String, String, String) {
|
||||
let at = |x: f64, y: f64| if turn { [std::f64::consts::FRAC_1_SQRT_2 * (x - y), std::f64::consts::FRAC_1_SQRT_2 * (x + y)] } else { [x, y] };
|
||||
let e = run(s, "sketch.ellipse", json!({"center": [0, 0], "major": at(20.0, 0.0), "minor_radius": 8}))["curves"][0]
|
||||
.as_str()
|
||||
.expect("the ellipse")
|
||||
.to_string();
|
||||
let sk = sketch(s);
|
||||
let mut lines: Vec<(f64, String)> = sk
|
||||
.curves
|
||||
.iter()
|
||||
.filter_map(|c| match c.kind {
|
||||
solidcraft_sketch::CurveKind::Line { a, b } if c.construction => Some((sk.point(a)?.dist(sk.point(b)?), c.id.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
lines.sort_by(|x, y| y.0.total_cmp(&x.0));
|
||||
let [(_, major), (_, minor)] = <[(f64, String); 2]>::try_from(lines).expect("two axis lines");
|
||||
(e, major, minor)
|
||||
}
|
||||
|
||||
/// D on an ellipse's outline gives a half-axis, the distance from the centre to an axis end,
|
||||
/// chosen as a line chooses horizontal or vertical, in the ellipse's own frame: text pulled
|
||||
/// across the major axis measures along it, text pulled out past an end of it measures across
|
||||
/// it. Near the boundary a placement keeps the half-axis it had. D on an axis line gives the
|
||||
/// whole axis. An ellipse without axes keeps its minor radius.
|
||||
#[test]
|
||||
fn an_ellipse_half_axis_dimension_follows_where_its_text_is_pulled() {
|
||||
use solidcraft_sketch::ConstraintKind;
|
||||
let dimension = |turn: bool, extra: Value| {
|
||||
let mut s = new_sketch();
|
||||
let (e, ..) = ellipse_with_axes(&mut s, turn);
|
||||
let mut p = json!({"entities": [e]});
|
||||
if let (Some(p), Some(extra)) = (p.as_object_mut(), extra.as_object()) {
|
||||
p.extend(extra.clone());
|
||||
}
|
||||
let r = run(&mut s, "sketch.dimension", p);
|
||||
let sk = sketch(&s);
|
||||
let kind = sk.constraints.iter().find(|c| c.kind.is_dimension()).map(|c| c.kind.clone()).expect("a dimension");
|
||||
let ConstraintKind::Distance { p, q, .. } = kind else { return ("radius".to_string(), 0.0, r) };
|
||||
let (pc, end) = (sk.point(p).expect("centre"), sk.point(q).expect("axis end"));
|
||||
assert!(pc.len() < 1e-9, "measured from the centre: {pc:?}");
|
||||
let which = if (pc.dist(end) - 20.0).abs() < 1e-6 { "major" } else { "minor" };
|
||||
assert_eq!(r["axis"], json!(which), "{r}");
|
||||
// The end on the text's side.
|
||||
let side = extra.get("text_at").and_then(Value::as_array).map(|t| (t[0].as_f64().unwrap_or(0.0), t[1].as_f64().unwrap_or(0.0)));
|
||||
(which.to_string(), side.map_or(1.0, |(x, y)| end.x * x + end.y * y), r)
|
||||
};
|
||||
let turned = |x: f64, y: f64, turn: bool| {
|
||||
if turn { [std::f64::consts::FRAC_1_SQRT_2 * (x - y), std::f64::consts::FRAC_1_SQRT_2 * (x + y)] } else { [x, y] }
|
||||
};
|
||||
for turn in [false, true] {
|
||||
for ((x, y), expected) in [
|
||||
((3.0, 16.0), "major"),
|
||||
((-4.0, -14.0), "major"),
|
||||
((18.0, 12.0), "major"),
|
||||
((30.0, 3.0), "minor"),
|
||||
((-28.0, -2.0), "minor"),
|
||||
((24.0, 6.0), "minor"),
|
||||
] {
|
||||
let (which, side, r) = dimension(turn, json!({"text_at": turned(x, y, turn)}));
|
||||
assert_eq!(which, expected, "turned {turn}, text at ({x}, {y}): {r}");
|
||||
assert!(side > 0.0, "turned {turn}, text at ({x}, {y}): the end on the other side");
|
||||
}
|
||||
// Near the boundary (0.5 of the major half-axis along, 0.55 or 0.45 of the minor
|
||||
// across) the half-axis a placement had is kept, from either side; well past it, it is not.
|
||||
let near = json!(turned(10.0, 4.4, turn));
|
||||
assert_eq!(dimension(turn, json!({"text_at": near})).0, "major");
|
||||
assert_eq!(dimension(turn, json!({"text_at": near, "previous_axis": "minor"})).0, "minor");
|
||||
let near = json!(turned(10.0, 3.6, turn));
|
||||
assert_eq!(dimension(turn, json!({"text_at": near})).0, "minor");
|
||||
assert_eq!(dimension(turn, json!({"text_at": near, "previous_axis": "major"})).0, "major");
|
||||
assert_eq!(dimension(turn, json!({"text_at": turned(16.0, 4.0, turn), "previous_axis": "major"})).0, "minor");
|
||||
}
|
||||
// An explicit type wins; with no text, the minor half-axis; radius, and any other type,
|
||||
// stays the minor radius as on an ellipse without axes.
|
||||
assert_eq!(dimension(false, json!({"type": "major", "text_at": [30, 3]})).0, "major");
|
||||
assert_eq!(dimension(false, json!({"type": "minor", "text_at": [3, 16]})).0, "minor");
|
||||
assert_eq!(dimension(false, json!({})).0, "minor");
|
||||
assert_eq!(dimension(false, json!({"type": "radius", "text_at": [3, 16]})).0, "radius");
|
||||
assert_eq!(dimension(false, json!({"type": "diameter", "text_at": [3, 16]})).0, "radius");
|
||||
assert_eq!(dimension(false, json!({"type": "horizontal", "text_at": [3, 16]})).0, "radius");
|
||||
// With half_axes false (Draw ellipses with their axes off), auto is the minor radius
|
||||
// wherever the text is, as before half-axes; an explicit type still gives its half-axis.
|
||||
for at in [[3.0, 16.0], [30.0, 3.0]] {
|
||||
assert_eq!(dimension(false, json!({"half_axes": false, "text_at": at})).0, "radius", "text at {at:?}");
|
||||
}
|
||||
assert_eq!(dimension(false, json!({"half_axes": false, "type": "major"})).0, "major");
|
||||
let mut s = new_sketch();
|
||||
let (e, ..) = ellipse_with_axes(&mut s, false);
|
||||
assert!(s.execute("sketch.dimension", &json!({"entities": [e], "half_axes": "no"})).is_err());
|
||||
// An axis line is a line: its length is the whole axis.
|
||||
let mut s = new_sketch();
|
||||
let (_, major, minor) = ellipse_with_axes(&mut s, true);
|
||||
assert_eq!(run(&mut s, "sketch.dimension", json!({"entities": [major]}))["measured"], json!(40.0));
|
||||
assert_eq!(run(&mut s, "sketch.dimension", json!({"entities": [minor]}))["measured"], json!(16.0));
|
||||
// Also with the text beside or above the box a turned axis spans, where a free line gives
|
||||
// its horizontal or vertical distance; and it holds that half-axis, so D on the outline
|
||||
// then gives the other one.
|
||||
for (axis, at, whole) in [("major", [30.0, 0.0], 40.0), ("major", [0.0, -30.0], 40.0), ("minor", [20.0, 0.0], 16.0), ("minor", [0.0, 20.0], 16.0)]
|
||||
{
|
||||
let mut s = new_sketch();
|
||||
let (e, major, minor) = ellipse_with_axes(&mut s, true);
|
||||
let line = if axis == "major" { major } else { minor };
|
||||
let r = run(&mut s, "sketch.dimension", json!({"entities": [line], "text_at": at}));
|
||||
assert!(r["measured"].as_f64().is_some_and(|v| (v - whole).abs() < 1e-6), "{axis} axis, text at {at:?}: {r}");
|
||||
let r = run(&mut s, "sketch.dimension", json!({"entities": [e], "text_at": [3, 16]}));
|
||||
assert_ne!(r["axis"], json!(axis), "{axis} axis, text at {at:?}: {r}");
|
||||
}
|
||||
// With one axis deleted the half-axis is still chosen by where the text is, as if the axis
|
||||
// were there: the major half-axis to the major end, the minor one to what is left of its axis
|
||||
// or, with that gone, as the minor radius.
|
||||
let placed = |gone: &str, extra: Value| {
|
||||
let mut s = new_sketch();
|
||||
let (e, major, minor) = ellipse_with_axes(&mut s, true);
|
||||
run(&mut s, "sketch.delete", json!({"entities": [if gone == "major" { major } else { minor }]}));
|
||||
let mut p = json!({"entities": [e]});
|
||||
if let (Some(p), Some(extra)) = (p.as_object_mut(), extra.as_object()) {
|
||||
p.extend(extra.clone());
|
||||
}
|
||||
let r = run(&mut s, "sketch.dimension", p);
|
||||
let sk = sketch(&s);
|
||||
let kind = sk.constraints.iter().find(|c| c.kind.is_dimension()).map(|c| c.kind.clone()).expect("a dimension");
|
||||
// Where the dimension ends: the axis end it measures to, none for a radius.
|
||||
let end = match kind {
|
||||
ConstraintKind::Distance { q, .. } => sk.point(q),
|
||||
ConstraintKind::Radius { .. } => None,
|
||||
other => panic!("a half-axis, not {other:?}"),
|
||||
};
|
||||
(r, end)
|
||||
};
|
||||
for gone in ["major", "minor"] {
|
||||
for ((x, y), expected) in [((3.0, 16.0), "major"), ((-4.0, -14.0), "major"), ((30.0, 3.0), "minor"), ((-28.0, -2.0), "minor")] {
|
||||
let at = [std::f64::consts::FRAC_1_SQRT_2 * (x - y), std::f64::consts::FRAC_1_SQRT_2 * (x + y)];
|
||||
let (r, end) = placed(gone, json!({"text_at": at}));
|
||||
assert_eq!(r["axis"], json!(expected), "{gone} axis deleted, text at ({x}, {y}): {r}");
|
||||
let want = if expected == "major" { 20.0 } else { 8.0 };
|
||||
assert!(r["measured"].as_f64().is_some_and(|v| (v - want).abs() < 1e-6), "{gone} axis deleted, text at ({x}, {y}): {r}");
|
||||
// To the axis end on the text's side while there is one: the major end from either
|
||||
// side once the point opposite is gone, a radius once the minor axis is.
|
||||
match (gone, expected) {
|
||||
("minor", "minor") => assert!(end.is_none(), "the minor radius: {r}"),
|
||||
("major", "major") => {
|
||||
let m = solidcore_record::Vec2::new(20.0, 20.0) * std::f64::consts::FRAC_1_SQRT_2;
|
||||
assert!(end.is_some_and(|q| q.dist(m) < 1e-6), "the major end: {r}")
|
||||
}
|
||||
_ => assert!(
|
||||
end.is_some_and(|q| q.x * at[0] + q.y * at[1] > 0.0),
|
||||
"{gone} axis deleted, text at ({x}, {y}): the end on the other side"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
// With the major axis deleted and no text, the minor half-axis to its axis end, named in
|
||||
// the reply as with both axes.
|
||||
let (r, end) = placed("major", json!({}));
|
||||
assert_eq!((r["axis"].clone(), r["type"].clone()), (json!("minor"), json!("LinearDimension")), "{r}");
|
||||
assert!(end.is_some_and(|q| (q.len() - 8.0).abs() < 1e-6), "{r}");
|
||||
// Without its axes (an older design, or both deleted) the half-axis is still chosen by
|
||||
// where the text is: the major one to the ellipse's own major end, the minor one as its
|
||||
// minor radius; with no text, the minor radius as before.
|
||||
for ((x, y), expected, want) in [((3.0, 16.0), "major", 20.0), ((-4.0, -14.0), "major", 20.0), ((30.0, 3.0), "minor", 8.0)] {
|
||||
let mut s = new_sketch();
|
||||
let (e, major, minor) = ellipse_with_axes(&mut s, false);
|
||||
run(&mut s, "sketch.delete", json!({"entities": [major, minor]}));
|
||||
let r = run(&mut s, "sketch.dimension", json!({"entities": [e], "text_at": [x, y]}));
|
||||
assert_eq!(r["axis"], json!(expected), "text at ({x}, {y}): {r}");
|
||||
assert!(r["measured"].as_f64().is_some_and(|v| (v - want).abs() < 1e-6), "text at ({x}, {y}): {r}");
|
||||
}
|
||||
let mut s = new_sketch();
|
||||
let (e, major, minor) = ellipse_with_axes(&mut s, false);
|
||||
run(&mut s, "sketch.delete", json!({"entities": [major, minor]}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": [e]}));
|
||||
assert!(sketch(&s).constraints.iter().any(|c| matches!(c.kind, ConstraintKind::Radius { .. })));
|
||||
// The mirror of an ellipse has no axes (Mirror copies no midpoints): D on it reaches the
|
||||
// major half-axis as well.
|
||||
let mut s = new_sketch();
|
||||
let (e, major, minor) = ellipse_with_axes(&mut s, false);
|
||||
run(&mut s, "sketch.delete", json!({"entities": [minor]}));
|
||||
let line = run(&mut s, "sketch.line", json!({"points": [[40, -30], [40, 30]], "connect": false}))["curves"][0].as_str().unwrap().to_string();
|
||||
let copy = run(&mut s, "sketch.mirror", json!({"entities": [e, major], "line": line}))["curves"][0].as_str().unwrap().to_string();
|
||||
let sk = sketch(&s);
|
||||
assert!(sk.ellipse_axis_lines(sk.curve_index(©).unwrap()).is_none(), "the mirror has no axes");
|
||||
let r = run(&mut s, "sketch.dimension", json!({"entities": [copy], "text_at": [77, 16]}));
|
||||
assert_eq!((r["axis"].clone(), r["measured"].clone()), (json!("major"), json!(20.0)), "{r}");
|
||||
}
|
||||
|
||||
/// With one half-axis already dimensioned, D on the outline gives the other one wherever the
|
||||
/// text is pulled: guessing the one that is already there would only be refused. Either order,
|
||||
/// with both axes, with one deleted and with none.
|
||||
#[test]
|
||||
fn an_ellipse_s_second_half_axis_is_the_one_not_yet_dimensioned() {
|
||||
for gone in [&[][..], &["major"][..], &["minor"][..], &["major", "minor"][..]] {
|
||||
for (first, at, second) in [("major", [3.0, 16.0], "minor"), ("minor", [30.0, 3.0], "major")] {
|
||||
let mut s = new_sketch();
|
||||
let (e, major, minor) = ellipse_with_axes(&mut s, false);
|
||||
let deleted: Vec<String> = gone.iter().map(|g| if *g == "major" { major.clone() } else { minor.clone() }).collect();
|
||||
if !deleted.is_empty() {
|
||||
run(&mut s, "sketch.delete", json!({"entities": deleted}));
|
||||
}
|
||||
let r = run(&mut s, "sketch.dimension", json!({"entities": [e], "text_at": at}));
|
||||
assert_eq!(r["axis"], json!(first), "{gone:?}: {r}");
|
||||
let r = run(&mut s, "sketch.dimension", json!({"entities": [e], "text_at": at}));
|
||||
assert_eq!(r["axis"], json!(second), "{gone:?}, {first} first, then text at {at:?} again: {r}");
|
||||
assert!(r["param"].is_string(), "{gone:?}: the second one drives: {r}");
|
||||
}
|
||||
}
|
||||
// The whole axis held counts too: as a distance between its ends, or as its line's length
|
||||
// with the other axis deleted.
|
||||
for axis in ["major", "minor"] {
|
||||
let mut s = new_sketch();
|
||||
let (e, major, minor) = ellipse_with_axes(&mut s, false);
|
||||
let line = if axis == "major" { &major } else { &minor };
|
||||
run(&mut s, "sketch.dimension", json!({"entities": [format!("{line}.start"), format!("{line}.end")], "type": "distance"}));
|
||||
let at = if axis == "major" { [3.0, 16.0] } else { [30.0, 3.0] };
|
||||
let r = run(&mut s, "sketch.dimension", json!({"entities": [e], "text_at": at}));
|
||||
assert_ne!(r["axis"], json!(axis), "{axis} held between its ends: {r}");
|
||||
let mut s = new_sketch();
|
||||
let (e, major, minor) = ellipse_with_axes(&mut s, false);
|
||||
let (line, other) = if axis == "major" { (major, minor) } else { (minor, major) };
|
||||
run(&mut s, "sketch.delete", json!({"entities": [other]}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": [line]}));
|
||||
let r = run(&mut s, "sketch.dimension", json!({"entities": [e], "text_at": at}));
|
||||
assert_ne!(r["axis"], json!(axis), "{axis} held by its line, the other axis deleted: {r}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A horizontal dimension across a level major axis is its length, so D on the outline then
|
||||
/// gives the minor half-axis; across a slanted axis it is only a projection, and the major
|
||||
/// half-axis is still free to take.
|
||||
#[test]
|
||||
fn a_horizontal_dimension_across_a_level_major_axis_counts_as_holding_it() {
|
||||
for turn in [false, true] {
|
||||
let mut s = new_sketch();
|
||||
let (e, major, _) = ellipse_with_axes(&mut s, turn);
|
||||
run(&mut s, "sketch.dimension", json!({"entities": [format!("{major}.start"), format!("{major}.end")], "type": "horizontal"}));
|
||||
let at = if turn { [std::f64::consts::FRAC_1_SQRT_2 * (3.0 - 16.0), std::f64::consts::FRAC_1_SQRT_2 * (3.0 + 16.0)] } else { [3.0, 16.0] };
|
||||
let r = run(&mut s, "sketch.dimension", json!({"entities": [e], "text_at": at}));
|
||||
let want = if turn { "major" } else { "minor" };
|
||||
assert_eq!(r["axis"], json!(want), "turned {turn}: {r}");
|
||||
assert!(r["param"].is_string(), "turned {turn}: it drives: {r}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The same for the minor axis: a vertical dimension across its ends holds it while it stands
|
||||
/// upright, so D with the text out past the major axis's end then gives the major half-axis.
|
||||
#[test]
|
||||
fn a_vertical_dimension_across_an_upright_minor_axis_counts_as_holding_it() {
|
||||
for turn in [false, true] {
|
||||
let mut s = new_sketch();
|
||||
let (e, _, minor) = ellipse_with_axes(&mut s, turn);
|
||||
run(&mut s, "sketch.dimension", json!({"entities": [format!("{minor}.start"), format!("{minor}.end")], "type": "vertical"}));
|
||||
let at = if turn { [std::f64::consts::FRAC_1_SQRT_2 * (30.0 - 3.0), std::f64::consts::FRAC_1_SQRT_2 * (30.0 + 3.0)] } else { [30.0, 3.0] };
|
||||
let r = run(&mut s, "sketch.dimension", json!({"entities": [e], "text_at": at}));
|
||||
let want = if turn { "minor" } else { "major" };
|
||||
assert_eq!(r["axis"], json!(want), "turned {turn}: {r}");
|
||||
assert!(r["param"].is_string(), "turned {turn}: it drives: {r}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A half-axis and its whole axis are one dimension: whichever comes second is determined by
|
||||
/// the first, so the Dimension tool's `if_redundant: "driven"` adds it as a reference, and
|
||||
/// without it the command refuses it. Both orders, both axes. After the whole axis, D on the
|
||||
/// outline with no type gives the other half-axis, the one not yet held; the same half-axis
|
||||
/// is asked for by its type.
|
||||
#[test]
|
||||
fn an_ellipse_half_axis_and_its_whole_axis_determine_each_other() {
|
||||
for (axis, text_at) in [("major", [3.0, 16.0]), ("minor", [30.0, 3.0])] {
|
||||
for half_first in [true, false] {
|
||||
let mut s = new_sketch();
|
||||
let (e, major, minor) = ellipse_with_axes(&mut s, false);
|
||||
let line = if axis == "major" { major } else { minor };
|
||||
let half = json!({"entities": [e], "text_at": text_at, "type": axis});
|
||||
let whole = json!({"entities": [line]});
|
||||
if !half_first {
|
||||
let mut t = new_sketch();
|
||||
let (e, major, minor) = ellipse_with_axes(&mut t, false);
|
||||
run(&mut t, "sketch.dimension", json!({"entities": [if axis == "major" { major } else { minor }]}));
|
||||
let r = run(&mut t, "sketch.dimension", json!({"entities": [e], "text_at": text_at}));
|
||||
assert_ne!(r["axis"], json!(axis), "{axis}: after the whole axis, the other half-axis: {r}");
|
||||
}
|
||||
let (first, second) = if half_first { (half, whole) } else { (whole, half) };
|
||||
let r = run(&mut s, "sketch.dimension", first);
|
||||
assert!(r["param"].is_string(), "{axis}: the first one drives: {r}");
|
||||
let refused = s.execute("sketch.dimension", &second).expect_err("the second one is determined").to_string();
|
||||
assert!(refused.contains("already determined"), "{axis}: {refused}");
|
||||
let mut second = second;
|
||||
second["if_redundant"] = json!("driven");
|
||||
let r = run(&mut s, "sketch.dimension", second);
|
||||
assert_eq!((r["redundant"].clone(), r["driven"].clone()), (json!(true), json!(true)), "{axis}, half first {half_first}: {r}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A two-link arm (B, elbow E, hand H) hanging off a rigid triangle A B C that only the cluster
|
||||
/// decomposition places: pinned at F by two of its corners and held at G by the third. Every
|
||||
/// line is dimensioned. Without decomposition budget the elbow's seed has nothing to build on.
|
||||
|
||||
@@ -10,12 +10,19 @@ use solidcraft_kernel::{Construction, Continuity};
|
||||
use super::CommandSpec;
|
||||
use super::features::add_feature;
|
||||
use crate::params::{bad, str_, vec3};
|
||||
use crate::schema::features::FeatureResult;
|
||||
use crate::schema::surface_match::MatchSurfaceParams;
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{Result, Session};
|
||||
|
||||
const CONTRACT: TypedContract<MatchSurfaceParams> = TypedContract::new();
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("surface.match", "Match Surface", match_surface)
|
||||
CommandSpec::new("surface.match", "Match Surface", crate::checked!("surface.match", match_surface))
|
||||
.at("SURFACE", "MODIFY")
|
||||
.icon("continuity")
|
||||
.contract(&CONTRACT)
|
||||
.output(output_schema_for::<FeatureResult>)
|
||||
.params(
|
||||
"body: surface name (the body whose face is rebuilt); faces?: [[x,y,z]] (points on that face, default: the body's first); target: body name (the body the seam must meet); edges: [[x,y,z]] (points on one edge chain of the target: the rebuilt boundary takes its place); order?: g1|g2|g3 (default g2: position and tangent plane, the curvature too, or how that curvature changes along the seam). The far boundary stays where it was and the body keeps its name",
|
||||
),
|
||||
|
||||
@@ -6,12 +6,13 @@ use solidcraft_render::{Camera, StandardView};
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, num, req_vec2, req_vec3, vec3};
|
||||
use crate::schema::view_navigation::{
|
||||
CameraReply, NormalToParams, RenderBrowser, RenderFocus, RenderSelect, StandardViewParams, TabStep, TabStepParams,
|
||||
CameraReply, NavigatorParams, NormalToParams, RenderBrowser, RenderFocus, RenderSelect, StandardViewParams, TabStep, TabStepParams,
|
||||
};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{Result, Sel, Session};
|
||||
|
||||
const FOCUS_CONTRACT: TypedContract<RenderFocus> = TypedContract::new();
|
||||
const NAVIGATOR_CONTRACT: TypedContract<NavigatorParams> = TypedContract::new();
|
||||
const BROWSER_CONTRACT: TypedContract<RenderBrowserRequest> = TypedContract::new();
|
||||
const TAB_STEP_CONTRACT: TypedContract<TabStepParams> = TypedContract::new();
|
||||
const STANDARD_VIEW_CONTRACT: TypedContract<StandardViewParams> = TypedContract::new();
|
||||
@@ -64,6 +65,10 @@ CommandSpec::new("view.ground_shadow", "Ground shadow", ground_shadow)
|
||||
.icon("style").noundo().doc_only().params("on?: bool (omitted: toggle) → on?; show the ground shadow"),
|
||||
CommandSpec::new("view.navigation_bar", "Navigation bar", navigation_bar)
|
||||
.icon("pan").noundo().doc_only().params("visible?: bool (omitted: toggle) → visible?; show the navigation bar (window setting)"),
|
||||
CommandSpec::new("view.navigator", "Custom View Navigator", crate::checked!("view.navigator", navigator))
|
||||
.icon("nav_settings").noundo().doc_only()
|
||||
.contract(&NAVIGATOR_CONTRACT).output(output_schema_for::<NavigatorParams>)
|
||||
.params("style?: axes|cube|custom; body?: body name|null; texture?: themed|modeled; arrows?, home?: bool → the navigator as set; the app's view navigator, never saved in the design; never opens a dialog (a toolbar start opens the Custom View Navigator panel)"),
|
||||
CommandSpec::new("view.render_focus", "Render Focus View", crate::checked!("view.render_focus", render_focus))
|
||||
.icon("render_scene").noundo().doc_only()
|
||||
.contract(&FOCUS_CONTRACT).output(output_schema_for::<RenderFocus>)
|
||||
@@ -324,6 +329,18 @@ fn status_items(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!(crate::status_items::StatusItems { items: s.status_items() }))
|
||||
}
|
||||
|
||||
/// The app sets the navigator; the engine checks the parameters and that a named body exists.
|
||||
fn navigator(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "view.navigator";
|
||||
let request: NavigatorParams = serde_json::from_value(if p.is_null() { json!({}) } else { p.clone() }).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
if let Some(Some(name)) = &request.body
|
||||
&& s.world_state()?.body(name).is_none()
|
||||
{
|
||||
return Err(bad(cmd, format!("no body named `{name}`")));
|
||||
}
|
||||
serde_json::to_value(request).map_err(|e| bad(cmd, e.to_string()))
|
||||
}
|
||||
|
||||
fn render_focus(_s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let request: RenderFocus = serde_json::from_value(p.clone()).map_err(|e| bad("view.render_focus", e.to_string()))?;
|
||||
Ok(json!(request))
|
||||
|
||||
@@ -280,3 +280,41 @@ fn cancelling_an_editor_with_visibility_changes_restores_an_existing_history_mar
|
||||
assert_eq!(session.doc.marker, Some(1));
|
||||
assert!(session.visibility.hidden_origin.is_empty());
|
||||
}
|
||||
|
||||
/// Rolling the marker back before a move of the body a joint seats on solves the joints again,
|
||||
/// which puts the seated occurrence back where the body was: the editor's pinned model differs
|
||||
/// from its source there, and only there, and the edit still goes through.
|
||||
#[test]
|
||||
fn an_editor_accepts_the_placements_rolling_the_marker_solves() {
|
||||
let mut session = Session::default();
|
||||
for (name, x) in [("A", 0), ("B", 20)] {
|
||||
session.execute("component.activate", &json!({"component": "root"})).unwrap();
|
||||
session.execute("component.create", &json!({"name": name})).unwrap();
|
||||
session.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0]})).unwrap();
|
||||
}
|
||||
let component = |s: &Session, name: &str| s.doc.components.iter().find(|c| c.name == name).map(|c| c.id).unwrap();
|
||||
let occurrence = |s: &Session, name: &str| {
|
||||
let component = component(s, name);
|
||||
s.doc.occurrences.iter().find(|o| o.component == component).cloned().unwrap()
|
||||
};
|
||||
session.execute("occurrence.ground", &json!({"occurrence": occurrence(&session, "A").id, "grounded": true})).unwrap();
|
||||
session
|
||||
.execute("joint.create", &json!({"type": "rigid", "a": {"face": [5, 5, 10]}, "b": {"face": [25, 5, 0]}, "offset": 5, "name": "Seat"}))
|
||||
.unwrap();
|
||||
let seated = occurrence(&session, "B").transform;
|
||||
// A's box moves along, and B, seated on it, with it.
|
||||
session.execute("component.activate", &json!({"component": "A"})).unwrap();
|
||||
let a = component(&session, "A");
|
||||
let body = session.model.state().bodies.iter().find(|b| b.component == a).map(|b| b.name.clone()).unwrap();
|
||||
session.execute("solid.move", &json!({"bodies": [body], "translate": [30, 0, 0]})).unwrap();
|
||||
let moved = occurrence(&session, "B").transform;
|
||||
assert_ne!(moved, seated, "B follows the box it is seated on");
|
||||
let original = session.scratch();
|
||||
session.execute("timeline.roll_to", &json!({"position": 2})).unwrap();
|
||||
let source = session.scratch();
|
||||
assert_eq!(occurrence(&session, "B").transform, seated, "before the move, B sits where the box was");
|
||||
let prepared = session.scratch().compute_editor_commands(&[], &source, original).unwrap();
|
||||
session.accept_commands(prepared).unwrap();
|
||||
assert_eq!(session.doc.marker, None);
|
||||
assert_eq!(occurrence(&session, "B").transform, moved);
|
||||
}
|
||||
|
||||
@@ -435,6 +435,13 @@ fn hook(info: &std::panic::PanicHookInfo<'_>) -> bool {
|
||||
}
|
||||
let message = limited(info.payload_as_str().unwrap_or("(not a text message)"), MAX_MESSAGE);
|
||||
let location = info.location().map_or_else(|| "(unknown)".to_string(), |l| format!("{}:{}", l.file(), l.line()));
|
||||
if graphics_device_lost(&message, &location) {
|
||||
// What met the loss survives it (the window's painter, a command), and the app starts
|
||||
// again as for any lost device: not a crash to report.
|
||||
eprintln!("SolidCraft graphics: the device was lost during GPU work ({location}); starting again");
|
||||
IN_HOOK.store(false, Ordering::SeqCst);
|
||||
return true;
|
||||
}
|
||||
let sig = signature(&location, &message);
|
||||
let recovered = solidcraft_kernel::guarded();
|
||||
let in_command = IN_COMMAND.with(Cell::get) > 0;
|
||||
@@ -493,6 +500,13 @@ fn hook(info: &std::panic::PanicHookInfo<'_>) -> bool {
|
||||
quiet
|
||||
}
|
||||
|
||||
/// A panic inside wgpu that unwraps the driver's `Lost`: a graphics device reset (Windows' TDR)
|
||||
/// met first by a queue write or a present, which wgpu-core unwraps instead of losing the device
|
||||
/// (`PendingWrites::activate`, #10240). It is the device loss, not a SolidCraft bug.
|
||||
fn graphics_device_lost(message: &str, location: &str) -> bool {
|
||||
location.contains("wgpu-core") && message.ends_with(": Lost")
|
||||
}
|
||||
|
||||
/// Marks a command running on this thread; panics inside it wait for [`command_done`].
|
||||
pub(crate) struct Scope(bool);
|
||||
|
||||
|
||||
@@ -637,6 +637,22 @@ fn signatures_ignore_numbers_but_not_places() {
|
||||
assert_ne!(signature("a.rs:1", "edge"), signature("a.rs:1", "face"));
|
||||
}
|
||||
|
||||
/// A graphics device reset met first by a queue write panics inside wgpu-core (#10240, #6528):
|
||||
/// that is the device loss, which the app restarts after, not a crash to report. The same words
|
||||
/// from anywhere else, and wgpu's other panics, still are.
|
||||
#[test]
|
||||
fn a_lost_graphics_device_met_inside_wgpu_is_not_a_crash() {
|
||||
let lost = "called `Result::unwrap()` on an `Err` value: Lost";
|
||||
let windows = r"C:\Users\runneradmin\.cargo\registry\src\index.crates.io-1949cf8c6b5b557f\wgpu-core-30.0.1\src\device\queue.rs:503";
|
||||
let linux = "/home/runner/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/wgpu-core-30.0.1/src/device/queue.rs:503";
|
||||
assert!(graphics_device_lost(lost, windows));
|
||||
assert!(graphics_device_lost(lost, linux));
|
||||
assert!(!graphics_device_lost(lost, "crates/gpu/src/view.rs:3189"), "not inside wgpu");
|
||||
assert!(!graphics_device_lost("called `Result::unwrap()` on an `Err` value: OutOfMemory", linux));
|
||||
assert!(!graphics_device_lost("Error in Queue::submit: Validation Error", linux));
|
||||
assert!(!graphics_device_lost(lost, "/home/runner/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/wgpu-30.0.1/src/api/queue.rs:1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dates_are_formatted_in_utc() {
|
||||
assert_eq!(utc(0), "1970-01-01T00:00:00Z");
|
||||
|
||||
@@ -179,6 +179,15 @@ fn radial(rr: f64, r: f64, depth: f64) -> f64 {
|
||||
sum
|
||||
}
|
||||
|
||||
/// The bridge layers of a counterbore of radius `rc` over a hole of radius r: one 0.2 mm layer of
|
||||
/// the slot (out to the counterbore wall) and one of the square, both 0.02 mm wider than the
|
||||
/// hole, each less the bore's disc.
|
||||
fn bridges(rc: f64, r: f64) -> f64 {
|
||||
let q = r + 0.01;
|
||||
let slot = 2.0 * (q * (rc * rc - q * q).sqrt() + rc * rc * (q / rc).asin());
|
||||
0.2 * (slot - PI * r * r) + 0.2 * (4.0 * q * q - PI * r * r)
|
||||
}
|
||||
|
||||
// ---- the cases -------------------------------------------------------------------------------
|
||||
|
||||
const DOWN: [f64; 3] = [0.0, 0.0, -1.0];
|
||||
@@ -411,6 +420,33 @@ fn cases() -> Vec<Case> {
|
||||
vec![spot([20.0, 15.0, 20.0], DOWN, 20.0, None)],
|
||||
)
|
||||
.faces(3),
|
||||
Case::new(
|
||||
"type/counterbore-bridged/blind",
|
||||
block(),
|
||||
hole(json!({"position": [20, 15, 20], "depth": 10, "type": "counterbore", "cb_diameter": 12, "cb_depth": 3, "bridge": "0.2 mm"})),
|
||||
cyl(6.0, 3.0) + cyl(r, 7.0) + bridges(6.0, r),
|
||||
r,
|
||||
vec![spot([20.0, 15.0, 20.0], DOWN, 10.0, Some(10.0))],
|
||||
)
|
||||
.faces(12),
|
||||
Case::new(
|
||||
"type/counterbore-bridged/blind-drill-point",
|
||||
block(),
|
||||
hole(json!({"position": [20, 15, 20], "depth": 10, "type": "counterbore", "cb_diameter": 12, "cb_depth": 3, "tip_angle": "118 deg", "bridge": "0.2 mm"})),
|
||||
cyl(6.0, 3.0) + cyl(r, 7.0) + tip(r) + bridges(6.0, r),
|
||||
r,
|
||||
vec![spot([20.0, 15.0, 20.0], DOWN, 10.0, Some(10.0 + r / 59f64.to_radians().tan()))],
|
||||
)
|
||||
.faces(12),
|
||||
Case::new(
|
||||
"type/counterbore-bridged/through",
|
||||
block(),
|
||||
hole(json!({"position": [20, 15, 20], "type": "counterbore", "cb_diameter": 12, "cb_depth": 3, "bridge": "0.2 mm"})),
|
||||
cyl(6.0, 3.0) + cyl(r, 17.0) + bridges(6.0, r),
|
||||
r,
|
||||
vec![spot([20.0, 15.0, 20.0], DOWN, 20.0, None)],
|
||||
)
|
||||
.faces(11),
|
||||
Case::new(
|
||||
"type/countersink/blind",
|
||||
block(),
|
||||
@@ -893,3 +929,77 @@ fn a_hole_on_a_cones_side_drills_along_its_normal() {
|
||||
let d = direction.0;
|
||||
assert!((d.x + 0.8).abs() < 1e-6 && d.y.abs() < 1e-6 && (d.z + 0.6).abs() < 1e-6, "{d:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridged_counterbore_layers_along_x_save_and_refuse_bad_values() {
|
||||
let block = || {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":40,"width":30,"height":20})).unwrap();
|
||||
s
|
||||
};
|
||||
let mut s = block();
|
||||
let mut p = json!({"position":[20,15,20],"diameter":6,"type":"counterbore","cb_diameter":12,"cb_depth":3,"bridge":"0.2 mm"});
|
||||
run(&mut s, "solid.hole", p.clone()).unwrap();
|
||||
// Layer 1 (z 16.8 to 17): the slot runs along X out to the counterbore wall, beside it is solid.
|
||||
assert!(!solid_at(&s, Vec3::new(25.5, 15.0, 16.9)), "the slot reaches the counterbore wall along X");
|
||||
assert!(solid_at(&s, Vec3::new(20.0, 19.5, 16.9)), "beside the slot the layer bridges the counterbore");
|
||||
// Layer 2 (z 16.6 to 16.8): the square's corners are open, the slot's ends are bridged.
|
||||
assert!(!solid_at(&s, Vec3::new(22.9, 17.9, 16.7)), "the square's corner is open");
|
||||
assert!(solid_at(&s, Vec3::new(25.5, 15.0, 16.7)), "past the square the second layer is solid");
|
||||
// Below both layers the bore is round again.
|
||||
assert!(solid_at(&s, Vec3::new(22.9, 17.9, 16.5)), "the bore is round below the layers");
|
||||
let saved = s.doc.to_json();
|
||||
assert!(saved.contains("\"bridge\""), "the design saves the layer");
|
||||
let reopened = Session::new(solidcraft_doc::Document::from_json(&saved).unwrap());
|
||||
assert!(!solid_at(&reopened, Vec3::new(22.9, 17.9, 16.7)), "the layers survive reopening");
|
||||
// A thicker layer moves the square down with it.
|
||||
let mut thick = block();
|
||||
p["bridge"] = json!("0.5 mm");
|
||||
run(&mut thick, "solid.hole", p.clone()).unwrap();
|
||||
assert!(!solid_at(&thick, Vec3::new(22.9, 17.9, 16.2)), "a 0.5 mm square spans z 16 to 16.5");
|
||||
assert!(solid_at(&thick, Vec3::new(22.9, 17.9, 15.9)));
|
||||
// Left out, nothing is saved and the counterbore is plain.
|
||||
let mut plain = block();
|
||||
p.as_object_mut().unwrap().remove("bridge");
|
||||
run(&mut plain, "solid.hole", p.clone()).unwrap();
|
||||
// The key, quoted: `format_pending` names `bridged-counterbore` in every design.
|
||||
assert!(!plain.doc.to_json().contains("\"bridge\""));
|
||||
assert!(solid_at(&plain, Vec3::new(22.9, 17.9, 16.7)));
|
||||
// Refused: a layer that is not a positive length, layers that don't fit in the hole, and a
|
||||
// bridge on a hole that has no counterbore.
|
||||
let refusal = |p: &Value| {
|
||||
let mut s = block();
|
||||
let refused = s.execute("solid.hole", p).err().map(|e| e.to_string()).unwrap_or_default();
|
||||
let failed = s.doc.features.last().and_then(|f| s.model.result(f.id)).and_then(|r| r.error.clone()).unwrap_or_default();
|
||||
format!("{refused}{failed}")
|
||||
};
|
||||
for bad in [json!("0 mm"), json!("-0.2 mm"), json!("30 deg")] {
|
||||
p["bridge"] = bad.clone();
|
||||
assert!(!refusal(&p).is_empty(), "bridge {bad} is refused");
|
||||
}
|
||||
p["bridge"] = json!("0.2 mm");
|
||||
p["depth"] = json!(3.3);
|
||||
assert!(refusal(&p).contains("must fit in the hole"), "too shallow: {}", refusal(&p));
|
||||
p.as_object_mut().unwrap().remove("depth");
|
||||
// Through all, the layers must end inside the 20 mm block, short of its far face.
|
||||
p["cb_depth"] = json!(19.7);
|
||||
assert!(refusal(&p).contains("must fit in the hole"), "past the exit face: {}", refusal(&p));
|
||||
p["cb_depth"] = json!(19.5);
|
||||
assert_eq!(refusal(&p), "", "0.1 mm short of the exit face fits");
|
||||
p["cb_depth"] = json!(3);
|
||||
// A counterbore too narrow for the slot's clearance (a plain one that wide is fine).
|
||||
p["cb_diameter"] = json!(6.02);
|
||||
assert!(refusal(&p).contains("wider than the hole"), "too narrow: {}", refusal(&p));
|
||||
p["cb_diameter"] = json!(12);
|
||||
// Given but not a length expression: refused, never a plain counterbore. Null leaves it out.
|
||||
for bad in [json!(true), json!([]), json!("")] {
|
||||
p["bridge"] = bad.clone();
|
||||
assert!(refusal(&p).contains("`bridge`"), "bridge {bad}: {}", refusal(&p));
|
||||
}
|
||||
p["bridge"] = Value::Null;
|
||||
assert_eq!(refusal(&p), "", "a null bridge is left out");
|
||||
p["bridge"] = json!("0.2 mm");
|
||||
p["type"] = json!("countersink");
|
||||
p["cs_diameter"] = json!(12);
|
||||
assert!(refusal(&p).contains("counterbore"), "countersink: {}", refusal(&p));
|
||||
}
|
||||
|
||||
@@ -349,16 +349,20 @@ impl Ahead {
|
||||
/// [`Ahead::evaluate`], setting `step` to the number of the command running (from 1), so a
|
||||
/// front end can say how far a batch has got.
|
||||
pub fn evaluate_counting(self, commands: &[(String, Value)], step: &std::sync::atomic::AtomicUsize) -> Result<Evaluated> {
|
||||
let (after, _) = self.0.preview_in_place(commands, Some(step))?;
|
||||
after.world_state()?.warm_display();
|
||||
Ok(Evaluated(after.model))
|
||||
let stop = self.0.stop.clone();
|
||||
stop.within(|| {
|
||||
let (after, _) = self.0.preview_in_place(commands, Some(step))?;
|
||||
after.world_state()?.warm_display();
|
||||
Ok(Evaluated(after.model))
|
||||
})
|
||||
}
|
||||
|
||||
/// Run `commands` one after another, each reply or error in turn (what they evaluate stays
|
||||
/// in the shared evaluation cache): the checks a front end makes before a command, such as
|
||||
/// what a delete takes with it (#2678), computed off its window thread.
|
||||
pub fn replies(mut self, commands: &[(String, Value)]) -> Vec<Result<Value>> {
|
||||
commands.iter().map(|(id, params)| self.0.execute(id, params)).collect()
|
||||
let stop = self.0.stop.clone();
|
||||
stop.within(|| commands.iter().map(|(id, params)| self.0.execute(id, params)).collect())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -534,6 +538,10 @@ pub struct Session {
|
||||
/// not a move of the design's, so the placements the design reports and Capture Position
|
||||
/// keeps leave it out ([`Session::uncaptured_moves`]).
|
||||
pub exploded: std::collections::BTreeMap<u64, solidcraft_doc::Mat>,
|
||||
/// Stops the work computing off the window thread for this design, shared with its copies.
|
||||
stop: solidcraft_kernel::Stop,
|
||||
/// A copy of a design ([`Session::scratch`]), sharing its token.
|
||||
is_copy: bool,
|
||||
world_cache: std::sync::Mutex<Option<WorldCache>>,
|
||||
/// The design the joints were last solved for, as the solve left it, and the model it read
|
||||
/// (see [`Session::solve_joints`]).
|
||||
@@ -794,6 +802,8 @@ impl Session {
|
||||
/// A session on `doc` evaluated as `model`, with what it hides.
|
||||
pub(crate) fn with_model(doc: Arc<Document>, visibility: Visibility, model: Model) -> Self {
|
||||
Session {
|
||||
stop: Default::default(),
|
||||
is_copy: false,
|
||||
saved: doc.clone(),
|
||||
doc,
|
||||
model,
|
||||
@@ -874,6 +884,11 @@ impl Session {
|
||||
/// design before the batch, which is what the batch promised (#3242). The design is what a
|
||||
/// checkpoint keeps, not the replaced session's file path (docs/mcp.md).
|
||||
pub fn replace_design(&mut self, fresh: Session) {
|
||||
// Nothing still computing for the design that leaves has anywhere to go. A copy leaves
|
||||
// the design it came from going, and its token with it.
|
||||
if !self.is_copy {
|
||||
self.stop_computing();
|
||||
}
|
||||
let auto_project = self.auto_project;
|
||||
let validation = self.validation();
|
||||
let single_surface_analysis = self.single_surface_analysis;
|
||||
@@ -905,6 +920,19 @@ impl Session {
|
||||
self.replacements = replacements;
|
||||
}
|
||||
|
||||
/// The token over the work running off the window thread for this design, on any of its
|
||||
/// copies: a job started from a copy runs [`solidcraft_kernel::Stop::within`] it.
|
||||
pub fn stop_token(&self) -> solidcraft_kernel::Stop {
|
||||
self.stop.clone()
|
||||
}
|
||||
|
||||
/// This design is going away: the work still computing for it stops at the kernel's next
|
||||
/// check, and its results go nowhere (they are only ever for this design's evaluation cache).
|
||||
/// A kernel call that doesn't check runs to its end, on a kernel of its own, holding up nothing.
|
||||
pub fn stop_computing(&self) {
|
||||
self.stop.stop();
|
||||
}
|
||||
|
||||
/// Install (or with `None` remove) the check [`Session::execute`] runs before every command,
|
||||
/// for an app that embeds the engine and decides which commands may run. A refusal comes back
|
||||
/// as [`EngineError::Refused`] and leaves the session as it was.
|
||||
@@ -1871,6 +1899,8 @@ impl Session {
|
||||
/// commands on without touching this one.
|
||||
pub fn scratch(&self) -> Session {
|
||||
Session {
|
||||
stop: self.stop.clone(),
|
||||
is_copy: true,
|
||||
doc: self.doc.clone(),
|
||||
model: self.model.clone(),
|
||||
undo: Vec::new(),
|
||||
@@ -1961,6 +1991,23 @@ impl Session {
|
||||
}
|
||||
let mut expected = (*original.doc).clone();
|
||||
expected.marker = source.doc.marker;
|
||||
// Rolling the marker solves the joints again and stores where that puts each occurrence.
|
||||
if expected.occurrences.len() == source.doc.occurrences.len() {
|
||||
for (e, s) in expected.occurrences.iter_mut().zip(&source.doc.occurrences) {
|
||||
if e.id == s.id {
|
||||
e.transform = s.transform;
|
||||
}
|
||||
}
|
||||
}
|
||||
// ...and works out each coupled joint's ratio and lift from the faces at that marker.
|
||||
if expected.assembly.links.len() == source.doc.assembly.links.len() {
|
||||
for (e, s) in expected.assembly.links.iter_mut().zip(&source.doc.assembly.links) {
|
||||
if (e.a, e.ia, e.b, e.ib) == (s.a, s.ia, s.b, s.ib) {
|
||||
e.ratio = s.ratio;
|
||||
e.lift = s.lift.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
if expected != *source.doc {
|
||||
return Err(EngineError::Other("the original editor model does not match its source design".into()));
|
||||
}
|
||||
@@ -1972,6 +2019,16 @@ impl Session {
|
||||
commands: &[(String, Value)],
|
||||
marker: Option<Option<usize>>,
|
||||
original: Option<Session>,
|
||||
) -> Result<ComputedCommands> {
|
||||
let stop = self.stop.clone();
|
||||
stop.within(|| self.compute_commands_under(commands, marker, original))
|
||||
}
|
||||
|
||||
fn compute_commands_under(
|
||||
self,
|
||||
commands: &[(String, Value)],
|
||||
marker: Option<Option<usize>>,
|
||||
original: Option<Session>,
|
||||
) -> Result<ComputedCommands> {
|
||||
let editor_original = original.as_ref().map(|session| session.doc.clone());
|
||||
let mut computed = self.scratch();
|
||||
|
||||
@@ -517,6 +517,7 @@ fn open_surface_measurements_have_no_volume_properties() {
|
||||
assert_eq!(props["total"]["open_bodies_skipped"], 1);
|
||||
assert!(props["bodies"][0]["mass_g"].is_null());
|
||||
assert!(props["total"]["center_of_mass"].is_null());
|
||||
assert!(s.execute("inspect.center_of_mass", &json!({"bodies": ["OpenTube"]})).is_err(), "an open surface has no centre of mass");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -545,6 +546,43 @@ fn volume_totals_skip_open_surfaces_and_keep_closed_box_properties() {
|
||||
for key in ["volume_mm3", "mass_g", "center_of_mass"] {
|
||||
assert_eq!(props_before["total"][key], props_after["total"][key]);
|
||||
}
|
||||
let com = run(&mut s, "inspect.center_of_mass", json!({}));
|
||||
assert_eq!(com["skipped"].as_array().map(Vec::len), Some(1), "{com}");
|
||||
assert_eq!(com["bodies"].as_array().map(Vec::len), Some(1), "{com}");
|
||||
assert!(close(&com["volume"], 6000.0, 1e-9), "{com}");
|
||||
for (axis, expected) in [5.0, 10.0, 15.0].into_iter().enumerate() {
|
||||
assert!(close(&com["center"][axis], expected, 1e-9), "{com}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #6173: Center of Mass reports the volume and centre Measure does. It measured the display
|
||||
/// mesh, whose flat facets cut curved faces short: a sphere of radius 10 came out 0.13 % small.
|
||||
#[test]
|
||||
fn center_of_mass_reports_the_same_volume_and_centre_as_measure() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.sphere", json!({"radius": 10, "center": [0, 0, 0], "body_name": "Ball"}));
|
||||
run(&mut s, "solid.cylinder", json!({"base": [50, 0, 0], "radius": 10, "height": 20, "body_name": "Cup"}));
|
||||
run(&mut s, "solid.shell", json!({"faces": [[50, 0, 20]], "thickness": 2}));
|
||||
let measured = run(&mut s, "inspect.measure", json!({}));
|
||||
let com = run(&mut s, "inspect.center_of_mass", json!({}));
|
||||
let entry = |list: &Value, key: &str, name: &str| -> Value {
|
||||
list.as_array().and_then(|l| l.iter().find(|b| b[key] == name)).cloned().unwrap_or_else(|| panic!("{name} in {list}"))
|
||||
};
|
||||
for name in ["Ball", "Cup"] {
|
||||
let (m, c) = (entry(&measured["bodies"], "name", name), entry(&com["bodies"], "body", name));
|
||||
assert!(close(&c["volume"], m["volume_mm3"].as_f64().unwrap(), 1e-9), "{name}: {c} vs {m}");
|
||||
for axis in 0..3 {
|
||||
assert!(close(&c["center"][axis], m["center_of_mass"][axis].as_f64().unwrap(), 1e-9), "{name}: {c} vs {m}");
|
||||
}
|
||||
}
|
||||
assert!(close(&com["volume"], measured["total"]["volume_mm3"].as_f64().unwrap(), 1e-6), "{com} vs {measured}");
|
||||
assert_eq!(com["skipped"].as_array().map(Vec::len), Some(0), "{com}");
|
||||
// Worked out by hand: the sphere is 4/3 pi r^3, the cup a cylinder less its 8 x 18 bore.
|
||||
let ball = 4.0 / 3.0 * PI * 1000.0;
|
||||
let cup = PI * (100.0 * 20.0 - 64.0 * 18.0);
|
||||
let (b, c) = (entry(&com["bodies"], "body", "Ball"), entry(&com["bodies"], "body", "Cup"));
|
||||
assert!(close(&b["volume"], ball, ball * 2e-4), "{b}: want {ball}");
|
||||
assert!(close(&c["volume"], cup, cup * 2e-4), "{c}: want {cup}");
|
||||
}
|
||||
|
||||
/// An ASCII STL of the tetrahedron (0,0,0), (10,0,0), (0,10,0), (0,0,10), outward facing; `open`
|
||||
|
||||
@@ -264,6 +264,40 @@ fn pieces_of_a_cut_body_keep_their_body_names_when_the_cut_moves() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A hole that cuts a body in three pieces names them as the design names them when it is
|
||||
/// evaluated again from the names it pinned, as on opening it. Found by the model fuzzer: the two
|
||||
/// smaller pieces swapped names there, so a later feature naming one of them met the other.
|
||||
#[test]
|
||||
fn pieces_of_a_cut_keep_their_names_when_the_design_is_evaluated_again() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "construct.plane.offset", json!({"base": "XZ", "name": "P8", "offset": 5}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "P8"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-7, -9], "p1": [5, -7]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 5, "operation": "join"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XZ"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [7, -3], "radius": 4}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 5, "operation": "join"}));
|
||||
run(&mut s, "solid.hole", json!({"depth": 14, "diameter": 5, "position": [-1, 10, 8]}));
|
||||
// Each body by name, with where it is (two pieces have the same volume).
|
||||
let pieces = |st: &solidcraft_doc::ModelState| -> Vec<(String, String)> {
|
||||
st.bodies
|
||||
.iter()
|
||||
.map(|b| {
|
||||
let m = solidcraft_kernel::measure(&b.body).unwrap();
|
||||
(b.name.clone(), format!("{:.3} {:.3} {:.3} {:.3}", m.volume, m.centroid.x, m.centroid.y, m.centroid.z))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let live = pieces(&s.model.state());
|
||||
assert_eq!(live.len(), 3, "{live:?}");
|
||||
let mut again = solidcraft_doc::Model::new();
|
||||
again.no_cache = true;
|
||||
again.evaluate(&s.doc);
|
||||
assert_eq!(pieces(&again.state()), live);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_name_of_a_body_without_history_is_found_by_geometry_and_says_so_once_it_moves() {
|
||||
let mut s = Session::default();
|
||||
|
||||
@@ -180,6 +180,54 @@ pub fn remove(dir: &Path, id: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// #5064: marker file in the recovery folder listing the designs a launch is reopening.
|
||||
const REOPEN_MARKER: &str = "reopening.json";
|
||||
|
||||
/// A launch that reopens designs marks them here. The marker is removed as each design
|
||||
/// finishes opening; one still there at the next launch means the last session died (crashed,
|
||||
/// hung, or was force-quit) while opening those designs — blindly reopening them would brick
|
||||
/// the app the same way, so the next launch skips them instead (#5064).
|
||||
pub fn begin_reopen(dir: &Path, paths: &[String]) {
|
||||
if paths.is_empty() || fs::create_dir_all(dir).is_err() {
|
||||
return;
|
||||
}
|
||||
if let Ok(bytes) = serde_json::to_vec(paths) {
|
||||
let _ = fs::write(dir.join(REOPEN_MARKER), bytes);
|
||||
}
|
||||
}
|
||||
|
||||
/// The designs the previous session was reopening when it died (empty: nothing is stuck).
|
||||
pub fn stuck_reopen(dir: &Path) -> Vec<String> {
|
||||
read_reopen_marker(dir).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The skip has been said: forget the marker so the launch after the skip reopens normally.
|
||||
pub fn clear_reopen(dir: &Path) {
|
||||
let _ = fs::remove_file(dir.join(REOPEN_MARKER));
|
||||
}
|
||||
|
||||
/// One reopened design finished (opened or failed to): take it off the marker. When the last
|
||||
/// one is gone the marker goes with it, so a healthy session leaves nothing behind.
|
||||
pub fn reopen_finished(dir: &Path, path: &str) {
|
||||
let Some(mut left) = read_reopen_marker(dir) else { return };
|
||||
left.retain(|p| p != path);
|
||||
if left.is_empty() {
|
||||
clear_reopen(dir);
|
||||
} else if let Ok(bytes) = serde_json::to_vec(&left) {
|
||||
let _ = fs::write(dir.join(REOPEN_MARKER), bytes);
|
||||
}
|
||||
}
|
||||
|
||||
/// The marker's paths, bounded like every other file read from the folder.
|
||||
fn read_reopen_marker(dir: &Path) -> Option<Vec<String>> {
|
||||
let path = dir.join(REOPEN_MARKER);
|
||||
let meta = fs::metadata(&path).ok()?;
|
||||
if !meta.is_file() || meta.len() > MAX_ENTRY_BYTES as u64 {
|
||||
return None;
|
||||
}
|
||||
serde_json::from_slice(&fs::read(&path).ok()?).ok()
|
||||
}
|
||||
|
||||
/// One open design's autosaver. Keep it with that design when switching tabs.
|
||||
pub struct Autosaver {
|
||||
pub dir: PathBuf,
|
||||
@@ -466,6 +514,19 @@ mod tests {
|
||||
d
|
||||
}
|
||||
|
||||
/// Ticks (bounded) until the autosave a tick started has been written and taken up, as the
|
||||
/// app's own frames would. A tick right after the one that started a write can find it still
|
||||
/// running under load and take nothing up, so a test that then changes the session (marking
|
||||
/// it saved) and checks the files would race the worker.
|
||||
fn tick_until_caught_up(a: &mut Autosaver, s: &Session) {
|
||||
let began = Instant::now();
|
||||
while !a.caught_up(s) {
|
||||
assert!(began.elapsed() < Duration::from_secs(30), "the autosave never caught up");
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
a.tick(s, false).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autosave_only_unsaved_changes_and_recover_after_a_crash() {
|
||||
let d = dir("crash");
|
||||
@@ -590,6 +651,7 @@ mod tests {
|
||||
let mut a = Autosaver::new(&d, Duration::ZERO).unwrap();
|
||||
s.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10})).unwrap();
|
||||
assert!(a.tick(&s, false).unwrap());
|
||||
tick_until_caught_up(&mut a, &s);
|
||||
s.mark_saved();
|
||||
a.tick(&s, false).unwrap();
|
||||
assert!(!file(&d, &a.id, solidcraft_io::DESIGN_EXT).exists());
|
||||
@@ -663,4 +725,35 @@ mod tests {
|
||||
assert!(remove(&d, "device").is_err());
|
||||
fs::remove_dir_all(&d).unwrap();
|
||||
}
|
||||
|
||||
/// #5064: a design that kills the app while it is being reopened must not be blindly
|
||||
/// reopened every launch. The marker survives the death, the next launch sees what the
|
||||
/// last one died on, and a healthy session clears it by itself.
|
||||
#[test]
|
||||
fn reopen_guard_survives_a_session_that_died_mid_reopen() {
|
||||
let d = dir("reopen-guard");
|
||||
assert!(stuck_reopen(&d).is_empty(), "no marker: nothing is stuck");
|
||||
begin_reopen(&d, &[]);
|
||||
assert!(!d.join(REOPEN_MARKER).exists(), "nothing to reopen writes no marker");
|
||||
|
||||
let paths = vec!["/d/one.solidcraft".to_string(), "/d/two.solidcraft".to_string()];
|
||||
begin_reopen(&d, &paths);
|
||||
assert_eq!(stuck_reopen(&d), paths);
|
||||
// The first design opens; the session dies on the second.
|
||||
reopen_finished(&d, "/d/one.solidcraft");
|
||||
assert_eq!(stuck_reopen(&d), vec!["/d/two.solidcraft".to_string()]);
|
||||
// The skip clears the marker so the launch after it reopens normally.
|
||||
clear_reopen(&d);
|
||||
assert!(stuck_reopen(&d).is_empty());
|
||||
// A healthy session: every design finishes and the marker goes with the last one.
|
||||
begin_reopen(&d, &paths);
|
||||
reopen_finished(&d, "/d/one.solidcraft");
|
||||
reopen_finished(&d, "/d/two.solidcraft");
|
||||
assert!(stuck_reopen(&d).is_empty() && !d.join(REOPEN_MARKER).exists());
|
||||
// A damaged marker is ignored, not fatal.
|
||||
fs::write(d.join(REOPEN_MARKER), b"not json").unwrap();
|
||||
assert!(stuck_reopen(&d).is_empty());
|
||||
reopen_finished(&d, "/d/one.solidcraft");
|
||||
fs::remove_dir_all(&d).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
//! Contract and result types for `inspect.clearance` in `cmd/clearance.rs` (#11051).
|
||||
|
||||
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::types::StringList;
|
||||
use crate::params;
|
||||
|
||||
/// Most neighbours a body's `nearest` list holds.
|
||||
pub const MAX_NEAREST: usize = 1000;
|
||||
/// Largest clearance (mm).
|
||||
pub const MAX_CLEARANCE: f64 = 1e6;
|
||||
|
||||
/// Find the solid bodies that overlap, and those closer than `clearance`, in world placement.
|
||||
/// Distances are measured on the exact faces and edges; the meshes only rule pairs out. Mesh
|
||||
/// bodies aren't measured: each is listed in `unchecked`.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct ClearanceParams {
|
||||
/// The bodies to check (default: every solid body but those in `against`).
|
||||
#[serde(default)]
|
||||
pub bodies: Option<StringList>,
|
||||
/// Only pairs with one body in `bodies` and the other here.
|
||||
#[serde(default)]
|
||||
pub against: Option<StringList>,
|
||||
/// Smaller overlaps count as contact, in mm³ (default 0.01).
|
||||
#[serde(default, deserialize_with = "min_volume")]
|
||||
#[schemars(schema_with = "min_volume_schema")]
|
||||
pub min_volume: Option<f64>,
|
||||
/// Report pairs closer than this, in mm: 0 to 1 000 000 (default 0).
|
||||
#[serde(default, deserialize_with = "clearance")]
|
||||
#[schemars(schema_with = "clearance_schema")]
|
||||
pub clearance: Option<f64>,
|
||||
/// A gap below this is contact, 0, in mm: 0 to 1 (default 1e-6).
|
||||
#[serde(default, deserialize_with = "tolerance")]
|
||||
#[schemars(schema_with = "tolerance_schema")]
|
||||
pub tolerance: Option<f64>,
|
||||
/// Each body of `bodies` gets its this many nearest others, 0 to 1000 (default 0); a
|
||||
/// fraction is rounded down.
|
||||
#[serde(default, deserialize_with = "nearest")]
|
||||
#[schemars(schema_with = "nearest_schema")]
|
||||
pub nearest: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub skip_within: Option<SkipWithin>,
|
||||
}
|
||||
|
||||
/// A number from 0 to `max`, read as the handler reads it (`params::num`: finite, under 1e9 in
|
||||
/// size), with its message; `null` is the default.
|
||||
macro_rules! up_to {
|
||||
($read:ident, $schema:ident, $key:literal, $max:expr) => {
|
||||
fn $read<'de, D: Deserializer<'de>>(d: D) -> Result<Option<f64>, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::Null => Ok(None),
|
||||
v => v
|
||||
.as_f64()
|
||||
.and_then(params::finite)
|
||||
.filter(|x| (0.0..=$max).contains(x))
|
||||
.map(Some)
|
||||
.ok_or_else(|| D::Error::custom(whole(format!("`{}` must be a number from 0 to {}", $key, $max)))),
|
||||
}
|
||||
}
|
||||
|
||||
fn $schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"type": ["number", "null"], "minimum": 0, "maximum": f64::min($max, 1e9), "exclusiveMaximum": 1e9})
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
up_to!(min_volume, min_volume_schema, "min_volume", f64::MAX);
|
||||
up_to!(clearance, clearance_schema, "clearance", MAX_CLEARANCE);
|
||||
up_to!(tolerance, tolerance_schema, "tolerance", 1.0);
|
||||
up_to!(nearest, nearest_schema, "nearest", MAX_NEAREST as f64);
|
||||
|
||||
/// Pairs not to check: of bodies of one occurrence (`occurrence`), or inside one placed
|
||||
/// component, its subcomponents included (`component`). `null` checks every pair.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum SkipWithin {
|
||||
Occurrence,
|
||||
Component,
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for SkipWithin {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
match Value::deserialize(d)?.as_str() {
|
||||
Some("occurrence") => Ok(SkipWithin::Occurrence),
|
||||
Some("component") => Ok(SkipWithin::Component),
|
||||
_ => Err(D::Error::custom(whole("`skip_within` must be \"occurrence\", \"component\" or null"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for SkipWithin {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"SkipWithin".into()
|
||||
}
|
||||
fn inline_schema() -> bool {
|
||||
true
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "Pairs not to check: of bodies of one occurrence (`occurrence`), or inside one placed component, its subcomponents included (`component`). null checks every pair.",
|
||||
"enum": ["occurrence", "component", null]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// What `inspect.clearance` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct Clearance {
|
||||
/// Pairs that share at least `min_volume`.
|
||||
pub overlaps: Vec<Overlap>,
|
||||
/// Pairs closer than `clearance`, nearest first; a pair in contact is at 0.
|
||||
pub near: Vec<NearPair>,
|
||||
/// With `nearest`: each body of `bodies`, with its nearest others.
|
||||
pub nearest: BTreeMap<String, Vec<Neighbour>>,
|
||||
/// Pairs whose overlap couldn't be worked out, and each mesh body, which isn't measured
|
||||
/// (`b` is then `null`).
|
||||
pub unchecked: Vec<Unchecked>,
|
||||
/// How many solid bodies were checked.
|
||||
pub bodies: usize,
|
||||
pub pairs_checked: usize,
|
||||
/// Of those, the pairs measured by an earlier call: neither body changed or moved since.
|
||||
pub pairs_reused: usize,
|
||||
/// Pairs ruled out by their boxes.
|
||||
pub pairs_pruned: usize,
|
||||
/// Whether there were more pairs than one call measures.
|
||||
pub truncated: bool,
|
||||
/// How long it took (ms).
|
||||
pub ms: f64,
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
/// Two bodies that overlap.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct Overlap {
|
||||
pub a: String,
|
||||
pub b: String,
|
||||
/// The volume they share (mm³).
|
||||
pub volume_mm3: f64,
|
||||
/// The area of the shared volume's surface (mm²).
|
||||
pub area_mm2: f64,
|
||||
pub bbox: OverlapBounds,
|
||||
pub center: [f64; 3],
|
||||
}
|
||||
|
||||
/// A box, by its corners.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct OverlapBounds {
|
||||
pub min: [f64; 3],
|
||||
pub max: [f64; 3],
|
||||
}
|
||||
|
||||
/// Two bodies closer than `clearance`.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct NearPair {
|
||||
pub a: String,
|
||||
pub b: String,
|
||||
pub distance_mm: f64,
|
||||
/// The nearest point on `a`.
|
||||
pub from: [f64; 3],
|
||||
/// The nearest point on `b`.
|
||||
pub to: [f64; 3],
|
||||
/// For a contact: the volume they share, below `min_volume` (mm³).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub overlap_mm3: Option<f64>,
|
||||
/// `false` when measured on the meshes; absent when measured on the exact faces and edges.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub exact: Option<bool>,
|
||||
}
|
||||
|
||||
/// One of a body's nearest others.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct Neighbour {
|
||||
pub body: String,
|
||||
pub distance_mm: f64,
|
||||
}
|
||||
|
||||
/// A pair, or a mesh body, that wasn't measured.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct Unchecked {
|
||||
pub a: String,
|
||||
/// The other body; `null` for a mesh body.
|
||||
pub b: Option<String>,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "clearance_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,62 @@
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{Clearance, ClearanceParams};
|
||||
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};
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schemas_and_the_key_check_agree() {
|
||||
assert_keys(input_schema_for::<ClearanceParams>(), &["against", "bodies", "clearance", "min_volume", "nearest", "skip_within", "tolerance"]);
|
||||
}
|
||||
|
||||
/// The handler reads the numbers and `skip_within` before the bodies, so each of these fails
|
||||
/// the same way on a design with no bodies.
|
||||
#[test]
|
||||
fn strict_keeps_todays_errors() {
|
||||
assert_strict_keeps_todays_errors(&[
|
||||
("inspect.clearance", json!({"min_volume": -1})),
|
||||
("inspect.clearance", json!({"min_volume": "x"})),
|
||||
("inspect.clearance", json!({"min_volume": 2e9})),
|
||||
("inspect.clearance", json!({"clearance": 2e6})),
|
||||
("inspect.clearance", json!({"tolerance": 2})),
|
||||
("inspect.clearance", json!({"nearest": 1001})),
|
||||
("inspect.clearance", json!({"skip_within": "x"})),
|
||||
("inspect.clearance", json!({"skip_within": 5})),
|
||||
("inspect.clearance", json!({"bodies": ["Nope"]})),
|
||||
("inspect.clearance", json!({"against": "Nope"})),
|
||||
]);
|
||||
}
|
||||
|
||||
/// #11051: a clearance check replies as its output schema says, in Warn without warnings for
|
||||
/// good calls and with them for a misspelt key: two overlapping boxes and a third 2 mm away,
|
||||
/// checked with every key, and with `against` alone.
|
||||
#[test]
|
||||
fn a_clearance_check_replies_with_its_schema() {
|
||||
let schema = output_schema_for::<Clearance>();
|
||||
let matches = |r: &Value, warned: bool| {
|
||||
let schema = schema.as_value();
|
||||
assert!(conforms(schema, schema, r), "{r} doesn't match {schema}");
|
||||
assert_eq!(r.get("param_warnings").is_some(), warned, "{r}");
|
||||
};
|
||||
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "A"}));
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [5, 5, 5], "body_name": "B", "operation": "new"}));
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [12, -20, 0], "body_name": "C", "operation": "new"}));
|
||||
let r = run(&mut s, "inspect.clearance", json!({}));
|
||||
matches(&r, false);
|
||||
assert_eq!(r["overlaps"].as_array().map(Vec::len), Some(1), "{r}");
|
||||
let r = run(
|
||||
&mut s,
|
||||
"inspect.clearance",
|
||||
json!({"bodies": ["A", "B"], "against": "C", "min_volume": 0.01, "clearance": 30, "tolerance": 1e-6, "nearest": 2, "skip_within": "occurrence"}),
|
||||
);
|
||||
matches(&r, false);
|
||||
assert!(r["near"].as_array().is_some_and(|n| !n.is_empty()) && r["nearest"]["A"].is_array(), "{r}");
|
||||
matches(&run(&mut s, "inspect.clearance", json!({"against": ["A"], "skip_within": null})), false);
|
||||
matches(&run(&mut s, "inspect.clearance", json!({"clearence": 3})), true);
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
//! Contract and result types for `inspect.compare` in `cmd/compare.rs` (#10994).
|
||||
|
||||
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;
|
||||
|
||||
/// Compare two bodies laid over each other, in world placement: measure both, sample each
|
||||
/// surface's distance to the other, and find, measure and (with `color`) colour the places where
|
||||
/// only one of them has material. It works from the two surfaces, without a boolean of the
|
||||
/// bodies, so near-identical bodies compare too.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct CompareParams {
|
||||
/// The first body, by name. Give both `a` and `b`, or neither: then the two selected bodies,
|
||||
/// or the design's only two.
|
||||
#[serde(default, deserialize_with = "body_name")]
|
||||
pub a: Option<String>,
|
||||
/// The second body, by name.
|
||||
#[serde(default, deserialize_with = "body_name")]
|
||||
pub b: Option<String>,
|
||||
/// Surfaces closer than this agree, in mm: 0.001 to 10 (default 0.01).
|
||||
#[serde(default, deserialize_with = "tolerance")]
|
||||
#[schemars(schema_with = "tolerance_schema")]
|
||||
pub tolerance: Option<f64>,
|
||||
/// The largest piece of a face sampled as one point, in mm: 0.01 to 1000 (default 0.5),
|
||||
/// coarser for a body that would need more than `max_points` samples.
|
||||
#[serde(default, deserialize_with = "spacing")]
|
||||
#[schemars(schema_with = "spacing_schema")]
|
||||
pub spacing: Option<f64>,
|
||||
/// The most samples per body: 10 000 to 50 million (default 8 million).
|
||||
#[serde(default, deserialize_with = "max_points")]
|
||||
#[schemars(schema_with = "max_points_schema")]
|
||||
pub max_points: Option<f64>,
|
||||
/// The most regions measured: 1 to 1000 (default 50); more are counted in `unlisted_regions`.
|
||||
#[serde(default, deserialize_with = "max_regions")]
|
||||
#[schemars(schema_with = "max_regions_schema")]
|
||||
pub max_regions: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub color: Option<CompareColor>,
|
||||
}
|
||||
|
||||
/// A body name, with the handler's message for anything else; `null` is none.
|
||||
fn body_name<'de, D: Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::Null => Ok(None),
|
||||
Value::String(s) => Ok(Some(s)),
|
||||
_ => Err(D::Error::custom("must be a body name")),
|
||||
}
|
||||
}
|
||||
|
||||
/// A number in `lo..=hi` with the handler's message, `null` none, for each ranged key.
|
||||
macro_rules! ranged {
|
||||
($read:ident, $schema:ident, $lo:literal, $hi:literal) => {
|
||||
fn $read<'de, D: Deserializer<'de>>(d: D) -> Result<Option<f64>, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::Null => Ok(None),
|
||||
v => v
|
||||
.as_f64()
|
||||
.filter(|x| x.is_finite() && ($lo..=$hi).contains(x))
|
||||
.map(Some)
|
||||
.ok_or_else(|| D::Error::custom(format!("must be a number from {} to {}", $lo, $hi))),
|
||||
}
|
||||
}
|
||||
|
||||
fn $schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"type": ["number", "null"], "minimum": $lo, "maximum": $hi})
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
ranged!(tolerance, tolerance_schema, 0.001, 10.0);
|
||||
ranged!(spacing, spacing_schema, 0.01, 1000.0);
|
||||
ranged!(max_points, max_points_schema, 1e4, 5e7);
|
||||
ranged!(max_regions, max_regions_schema, 1.0, 1000.0);
|
||||
|
||||
/// Colour the faces that hold a region: `true` for red where only `a` has material and green
|
||||
/// where only `b` has, or `{only_in_a?, only_in_b?}` to choose either colour (`"#rrggbb"` or
|
||||
/// `[r, g, b]`, 0 to 255). `false` colours nothing. One undo step.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct CompareColor(pub Value);
|
||||
|
||||
impl<'de> Deserialize<'de> for CompareColor {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let v = Value::deserialize(d)?;
|
||||
match &v {
|
||||
Value::Bool(_) => {}
|
||||
Value::Object(o) => {
|
||||
if o.keys().any(|k| k != "only_in_a" && k != "only_in_b") {
|
||||
return Err(D::Error::custom("must be true or {only_in_a, only_in_b}"));
|
||||
}
|
||||
for k in ["only_in_a", "only_in_b"] {
|
||||
if o.get(k).is_some_and(|c| !c.is_null() && crate::cmd::color_param(c).is_none()) {
|
||||
return Err(D::Error::custom(whole(format!("`color.{k}` must be \"#rrggbb\" or [r, g, b] with values 0–255"))));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => return Err(D::Error::custom("must be true or {only_in_a, only_in_b}")),
|
||||
}
|
||||
Ok(CompareColor(v))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for CompareColor {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"CompareColor".into()
|
||||
}
|
||||
fn inline_schema() -> bool {
|
||||
true
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
let color = || {
|
||||
serde_json::json!({
|
||||
"anyOf": [
|
||||
{"description": "\"#rrggbb\": six hex digits, the # optional.", "type": "string", "pattern": "^\\s*#?[0-9a-fA-F]{6}\\s*$"},
|
||||
{"type": "array", "items": {"type": "number", "minimum": 0, "maximum": 255}, "minItems": 3, "maxItems": 3},
|
||||
{"type": "null"}
|
||||
]
|
||||
})
|
||||
};
|
||||
json_schema!({
|
||||
"description": "Colour the faces that hold a region: true for red where only `a` has material and green where only `b` has, or {only_in_a?, only_in_b?} to choose either colour (\"#rrggbb\" or [r, g, b], 0 to 255). false colours nothing.",
|
||||
"anyOf": [
|
||||
{"type": "boolean"},
|
||||
{"type": "object", "properties": {"only_in_a": color(), "only_in_b": color()}, "additionalProperties": false}
|
||||
]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// What `inspect.compare` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct Comparison {
|
||||
/// `a`'s measures, as `inspect.measure` gives a body's.
|
||||
pub a: Map<String, Value>,
|
||||
/// `b`'s measures, as `inspect.measure` gives a body's.
|
||||
pub b: Map<String, Value>,
|
||||
/// `b` less `a`.
|
||||
pub difference: Difference,
|
||||
/// The tolerance used (mm).
|
||||
pub tolerance_mm: f64,
|
||||
/// How far each surface is from the other.
|
||||
pub deviation: Deviations,
|
||||
/// Whether no region differs by more than the tolerance.
|
||||
pub identical: bool,
|
||||
/// The comparison in a sentence.
|
||||
pub verdict: String,
|
||||
/// The regions where only one body has material, largest first.
|
||||
pub regions: Vec<Region>,
|
||||
/// The regions with material only in `a`, and their volume together.
|
||||
pub only_in_a: SideTotal,
|
||||
/// The regions with material only in `b`, and their volume together.
|
||||
pub only_in_b: SideTotal,
|
||||
/// Regions beyond `max_regions`, counted but not measured.
|
||||
pub unlisted_regions: usize,
|
||||
/// What makes the comparison less certain: a body whose surface doesn't enclose what it
|
||||
/// measures, a cut that couldn't be made, a body that encloses no volume.
|
||||
pub warnings: Vec<String>,
|
||||
/// How the surfaces were sampled.
|
||||
pub sampling: Sampling,
|
||||
/// With `color`: how many faces were coloured.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub colored_faces: Option<usize>,
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
/// `b` less `a`; `null` where either has no such measure.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct Difference {
|
||||
pub volume_mm3: Option<f64>,
|
||||
/// The volume difference as a share of `a`'s (%).
|
||||
pub volume_percent: Option<f64>,
|
||||
pub area_mm2: Option<f64>,
|
||||
pub center_of_mass: Option<[f64; 3]>,
|
||||
pub center_of_mass_distance_mm: Option<f64>,
|
||||
pub bbox_min: Option<[f64; 3]>,
|
||||
pub bbox_max: Option<[f64; 3]>,
|
||||
}
|
||||
|
||||
/// How far each surface is from the other.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct Deviations {
|
||||
/// From `a`'s surface to `b`'s.
|
||||
pub a_to_b: Deviation,
|
||||
/// From `b`'s surface to `a`'s.
|
||||
pub b_to_a: Deviation,
|
||||
/// The larger of the two maxima (mm).
|
||||
pub max_mm: f64,
|
||||
}
|
||||
|
||||
/// One surface's distance to the other, over its samples.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct Deviation {
|
||||
pub max_mm: f64,
|
||||
pub mean_mm: f64,
|
||||
/// Where the largest is.
|
||||
pub at: [f64; 3],
|
||||
/// The area further than the tolerance (mm²).
|
||||
pub area_beyond_mm2: f64,
|
||||
/// The area sampled (mm²).
|
||||
pub area_mm2: f64,
|
||||
/// How many samples.
|
||||
pub points: usize,
|
||||
}
|
||||
|
||||
/// Which body alone has a region's material.
|
||||
#[derive(Clone, Copy, Debug, Serialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CompareSide {
|
||||
OnlyInA,
|
||||
OnlyInB,
|
||||
}
|
||||
|
||||
/// A region where only one body has material.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct Region {
|
||||
/// Its number, from 1, largest first.
|
||||
pub index: usize,
|
||||
pub side: CompareSide,
|
||||
/// The body that has the material.
|
||||
pub material_of: String,
|
||||
/// Its volume (mm³), measured exactly on the pieces of the cut on its side (a − b or b − a)
|
||||
/// that lie there; `null` when it isn't measured, never an estimate.
|
||||
pub volume_mm3: Option<f64>,
|
||||
/// `boolean` when the volume, centre and box were measured on the cut, else `not measured`.
|
||||
pub measured_by: MeasuredBy,
|
||||
/// Why it isn't measured (the cut couldn't be made, a body encloses no volume, no piece fell
|
||||
/// here); `null` when it is.
|
||||
pub not_measured: Option<String>,
|
||||
/// Its surface on both bodies (mm²).
|
||||
pub area_mm2: f64,
|
||||
pub area_a_mm2: f64,
|
||||
pub area_b_mm2: f64,
|
||||
/// How far the surfaces are apart there at most (mm).
|
||||
pub max_deviation_mm: f64,
|
||||
/// Where that is.
|
||||
pub at: [f64; 3],
|
||||
/// Its centre of volume, measured with the volume; `null` when it isn't measured.
|
||||
pub center: Option<[f64; 3]>,
|
||||
/// The measured pieces' box, or the box the region was found in when it isn't measured.
|
||||
pub bbox: Bounds,
|
||||
/// The faces of `a` it lies on, by index, as `model.faces` lists them.
|
||||
pub faces_a: Vec<u32>,
|
||||
/// The faces of `b` it lies on.
|
||||
pub faces_b: Vec<u32>,
|
||||
/// The lines through it the surfaces were sampled along.
|
||||
pub resolution: Resolution,
|
||||
}
|
||||
|
||||
/// How a region was measured.
|
||||
#[derive(Clone, Copy, Debug, Serialize, JsonSchema)]
|
||||
pub enum MeasuredBy {
|
||||
/// Exactly, on the pieces of the cut that lie there.
|
||||
#[serde(rename = "boolean")]
|
||||
Boolean,
|
||||
/// Not at all: see `not_measured`.
|
||||
#[serde(rename = "not measured")]
|
||||
NotMeasured,
|
||||
}
|
||||
|
||||
/// A box, by its corners.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct Bounds {
|
||||
pub min: [f64; 3],
|
||||
pub max: [f64; 3],
|
||||
}
|
||||
|
||||
/// The lines through a region the surfaces were sampled along.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct Resolution {
|
||||
/// The axis they run along: `X`, `Y` or `Z`.
|
||||
pub along: String,
|
||||
/// Their spacing across the other two axes (mm).
|
||||
pub line_spacing_mm: [f64; 2],
|
||||
pub lines: usize,
|
||||
/// Lines that crossed a gap in a surface and were left out.
|
||||
pub skipped_lines: usize,
|
||||
}
|
||||
|
||||
/// The regions on one side, and their volume together.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct SideTotal {
|
||||
pub regions: usize,
|
||||
/// Their volume together (mm³); `null` when one of them isn't measured.
|
||||
pub volume_mm3: Option<f64>,
|
||||
}
|
||||
|
||||
/// How the surfaces were sampled.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct Sampling {
|
||||
/// The sample spacing used (mm).
|
||||
pub spacing_mm: f64,
|
||||
/// How closely the meshes follow the surfaces (mm): a quarter of the tolerance.
|
||||
pub chord_mm: f64,
|
||||
pub triangles_a: usize,
|
||||
pub triangles_b: usize,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "compare_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,74 @@
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CompareParams, Comparison};
|
||||
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};
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schemas_and_the_key_check_agree() {
|
||||
assert_keys(input_schema_for::<CompareParams>(), &["a", "b", "color", "max_points", "max_regions", "spacing", "tolerance"]);
|
||||
}
|
||||
|
||||
/// The handler reads the numbers and the colours before it looks for the bodies, so each of
|
||||
/// these fails the same way on a design with none.
|
||||
#[test]
|
||||
fn strict_keeps_todays_errors() {
|
||||
assert_strict_keeps_todays_errors(&[
|
||||
("inspect.compare", json!({"tolerance": 0})),
|
||||
("inspect.compare", json!({"tolerance": "x"})),
|
||||
("inspect.compare", json!({"spacing": 2000})),
|
||||
("inspect.compare", json!({"max_points": 1})),
|
||||
("inspect.compare", json!({"max_regions": 0})),
|
||||
("inspect.compare", json!({"color": 5})),
|
||||
("inspect.compare", json!({"color": "red"})),
|
||||
("inspect.compare", json!({"color": {"only_in_a": "#zz0000"}})),
|
||||
("inspect.compare", json!({"color": {"only_in_b": [1, 2]}})),
|
||||
("inspect.compare", json!({"a": 5})),
|
||||
("inspect.compare", json!({"a": "A"})),
|
||||
("inspect.compare", json!({})),
|
||||
("inspect.compare", json!({"a": "X", "b": "Y"})),
|
||||
]);
|
||||
}
|
||||
|
||||
/// #10994: a comparison replies as its output schema says, in Warn without warnings for good
|
||||
/// calls and with them for a misspelt key: a box and its copy (identical, no regions), and the
|
||||
/// copy with a boss, compared with every key and coloured both ways.
|
||||
#[test]
|
||||
fn a_comparison_replies_with_its_schema() {
|
||||
let schema = output_schema_for::<Comparison>();
|
||||
let matches = |r: &Value, warned: bool| {
|
||||
let schema = schema.as_value();
|
||||
assert!(conforms(schema, schema, r), "{r} doesn't match {schema}");
|
||||
assert_eq!(r.get("param_warnings").is_some(), warned, "{r}");
|
||||
};
|
||||
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10, "body_name": "A"}));
|
||||
run(&mut s, "solid.move", json!({"bodies": ["A"], "translate": [0, 0, 0], "copy": true}));
|
||||
let r = run(&mut s, "inspect.compare", json!({}));
|
||||
matches(&r, false);
|
||||
assert!(r["identical"] == true && r["regions"] == json!([]) && r.get("colored_faces").is_none(), "{r}");
|
||||
run(&mut s, "solid.box", json!({"length": 6, "width": 5, "height": 2, "corner": [5, 5, 10], "operation": "join", "targets": ["A (2)"]}));
|
||||
let r = run(
|
||||
&mut s,
|
||||
"inspect.compare",
|
||||
json!({"a": "A", "b": "A (2)", "tolerance": 0.01, "spacing": 0.5, "max_points": 1e6, "max_regions": 10, "color": true}),
|
||||
);
|
||||
matches(&r, false);
|
||||
assert_eq!((r["regions"][0]["side"].as_str(), r["identical"].as_bool()), (Some("only_in_b"), Some(false)), "{r}");
|
||||
assert!(r["colored_faces"].as_u64().is_some_and(|n| n > 0), "{r}");
|
||||
let r = run(&mut s, "inspect.compare", json!({"a": "A", "b": "A (2)", "color": {"only_in_b": "#123456", "only_in_a": null}}));
|
||||
matches(&r, false);
|
||||
matches(&run(&mut s, "inspect.compare", json!({"a": "A", "b": "A (2)", "color": false, "tolerence": 1})), true);
|
||||
}
|
||||
|
||||
/// A `color` string in the published schema carries the same pattern the handler checks.
|
||||
#[test]
|
||||
fn the_color_string_has_a_pattern() {
|
||||
let schema = serde_json::to_string(&input_schema_for::<CompareParams>()).unwrap();
|
||||
assert!(schema.contains(r"^\\s*#?[0-9a-fA-F]{6}\\s*$"), "{schema}");
|
||||
}
|
||||
@@ -10,9 +10,10 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use super::types::{
|
||||
AngleExpr, AxisDir, AxisLine, ConventionParam, DirectionParam, ExtentKeyword, ExtrudeOperation, FacesParam, FeatureCommon, FromPoint,
|
||||
GeoRefParam, IdOrName, LengthExpr, NumberExpr, OperationParam, PlaneCommon, PlaneOrFace, Point3, PointExpr3, ProfilesParam, ScaleFactors,
|
||||
SketchRef, StringList, ThinSideParam, ToPoint, TouchingParam, feature_common, present,
|
||||
AngleExpr, AxisDir, AxisLine, ConventionParam, DirectionParam, EdgeRefs, ExtentKeyword, ExtrudeOperation, FacesParam, FeatureCommon,
|
||||
FilletCornerParam, FilletFitParam, FilletTurnParam, FilletTypeParam, FromPoint, GeoRefParam, IdOrName, LengthExpr, NumberExpr, OperationParam,
|
||||
PlaneCommon, PlaneOrFace, Point3, PointExpr3, ProfilesParam, ScaleFactors, SketchRef, StringList, ThinSideParam, ToPoint, TouchingParam,
|
||||
feature_common, present,
|
||||
};
|
||||
|
||||
/// Add a box primitive.
|
||||
@@ -432,6 +433,92 @@ pub struct PlaneAtAngleParams {
|
||||
pub common: PlaneCommon,
|
||||
}
|
||||
|
||||
/// Round edges of a body: a constant radius by default, or a chord, a curvature-continuous
|
||||
/// section or a radius varying along each edge (`type`).
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct FilletParams {
|
||||
/// The edges to round: a point on each, or `{body, index}`.
|
||||
pub edges: EdgeRefs,
|
||||
/// The radius; the chord's width with `type: chord`, the equivalent circular setback with
|
||||
/// `type: curvature`, the start radius with `type: variable` or `spline`.
|
||||
pub radius: LengthExpr,
|
||||
/// The body whose edges the points pick. Default: whichever body has an edge there.
|
||||
#[serde(default)]
|
||||
pub body: Option<String>,
|
||||
/// Round each picked edge's tangent chain with it. Default: true; false rounds exactly the
|
||||
/// picked edges.
|
||||
#[serde(default)]
|
||||
pub tangent_chain: Option<bool>,
|
||||
#[serde(default, rename = "type")]
|
||||
pub kind: Option<FilletTypeParam>,
|
||||
/// `type: variable`: the radius at the end away from `start`; it or `radius` may be 0, where
|
||||
/// the round closes to the corner.
|
||||
#[serde(default)]
|
||||
pub radius2: Option<LengthExpr>,
|
||||
/// `type: variable` or `spline`: a point near the end the radius starts from.
|
||||
#[serde(default)]
|
||||
pub start: Option<Point3>,
|
||||
/// `type: variable`: radii between the ends, each `[position, radius]`, the position between 0
|
||||
/// and 1 from the start end. At most 100 are read.
|
||||
#[serde(default)]
|
||||
pub mid: Option<Vec<(f64, LengthExpr)>>,
|
||||
/// `type: variable`: smooth instead of straight between the radii. Default: false.
|
||||
#[serde(default)]
|
||||
pub smooth: Option<bool>,
|
||||
/// `type: spline`: the control radii after `radius`. At most 100 are read.
|
||||
#[serde(default)]
|
||||
pub radii: Option<Vec<LengthExpr>>,
|
||||
/// `type: spline`: the knot vector from 0 to 1, as many knots as all control radii, the
|
||||
/// degree (1 to 3) and one more.
|
||||
#[serde(default)]
|
||||
pub knots: Option<Vec<f64>>,
|
||||
#[serde(default)]
|
||||
pub fit: Option<FilletFitParam>,
|
||||
#[serde(default)]
|
||||
pub corner: Option<FilletCornerParam>,
|
||||
/// Corner setbacks: the corner at the vertex nearest each `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. Constant-radius exact fillets only.
|
||||
#[serde(default)]
|
||||
pub setbacks: Option<Vec<SetbackParam>>,
|
||||
#[serde(default)]
|
||||
pub turn: Option<FilletTurnParam>,
|
||||
#[serde(flatten, deserialize_with = "feature_common")]
|
||||
pub common: FeatureCommon,
|
||||
}
|
||||
|
||||
/// A fillet corner set back.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct SetbackParam {
|
||||
/// A point at the corner's vertex.
|
||||
pub vertex: Point3,
|
||||
/// How far along each edge there the corner's patch starts.
|
||||
pub distance: LengthExpr,
|
||||
}
|
||||
|
||||
/// The radius `fit: reduce` gave one edge.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct AppliedRadius {
|
||||
/// A point on the edge.
|
||||
pub edge: [f64; 3],
|
||||
/// The radius it was rounded at.
|
||||
pub radius: f64,
|
||||
}
|
||||
|
||||
/// What `solid.fillet` returns: a feature command's reply, with the radii `fit: reduce` used.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct FilletResult {
|
||||
#[serde(flatten)]
|
||||
pub feature: FeatureResult,
|
||||
/// With `fit: reduce` where it reduced the radius: the smallest radius used.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub radius_applied: Option<f64>,
|
||||
/// With `fit: reduce` where it reduced the radius: each edge's radius.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub radii_applied: Option<Vec<AppliedRadius>>,
|
||||
}
|
||||
|
||||
/// What a feature command returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct FeatureResult {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{
|
||||
BoundingSolidParams, BoxParams, ConeParams, CylinderParams, ExtrudeParams, FeatureResult, OffsetPlaneParams, PipeParams, PlaneAtAngleParams,
|
||||
ScaleParams, SphereParams, TorusParams,
|
||||
BoundingSolidParams, BoxParams, ConeParams, CylinderParams, ExtrudeParams, FeatureResult, FilletParams, FilletResult, OffsetPlaneParams,
|
||||
PipeParams, PlaneAtAngleParams, ScaleParams, SphereParams, TorusParams,
|
||||
};
|
||||
use crate::Session;
|
||||
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms, sketch_rectangle};
|
||||
@@ -87,6 +87,30 @@ fn schemas_and_the_key_check_agree() {
|
||||
assert_keys(input_schema_for::<ScaleParams>(), &["bodies", "factor", "factors", "name", "origin", "participants"]);
|
||||
assert_keys(input_schema_for::<OffsetPlaneParams>(), &["base", "name", "offset", "participants", "through"]);
|
||||
assert_keys(input_schema_for::<PlaneAtAngleParams>(), &["angle", "axis", "base", "name", "participants"]);
|
||||
assert_keys(
|
||||
input_schema_for::<FilletParams>(),
|
||||
&[
|
||||
"body",
|
||||
"body_name",
|
||||
"body_names",
|
||||
"corner",
|
||||
"edges",
|
||||
"fit",
|
||||
"knots",
|
||||
"mid",
|
||||
"name",
|
||||
"participants",
|
||||
"radii",
|
||||
"radius",
|
||||
"radius2",
|
||||
"setbacks",
|
||||
"smooth",
|
||||
"start",
|
||||
"tangent_chain",
|
||||
"turn",
|
||||
"type",
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -157,6 +181,13 @@ fn strict_keeps_todays_errors() {
|
||||
("construct.plane.at_angle", json!({"base": "XY", "axis": {"face": "x", "point": [0, 0, 0]}, "angle": 30})),
|
||||
("construct.plane.at_angle", json!({"base": "XY", "axis": {"face": "x"}, "angle": 30})),
|
||||
("construct.plane.at_angle", json!({"base": "XY", "axis": "X", "angle": true})),
|
||||
("solid.fillet", json!({"edges": [[0, 0, 0]], "radius": 1, "turn": "round"})),
|
||||
("solid.fillet", json!({"edges": [[0, 0, 0]], "radius": 1, "turn": "ROUND"})),
|
||||
("solid.fillet", json!({"edges": [[0, 0, 0]], "radius": 1, "type": "bogus"})),
|
||||
("solid.fillet", json!({"edges": [[0, 0, 0]], "radius": 1, "fit": "bogus"})),
|
||||
("solid.fillet", json!({"edges": [[0, 0, 0]], "radius": 1, "corner": "round"})),
|
||||
("solid.fillet", json!({"edges": [], "radius": 1})),
|
||||
("solid.fillet", json!({"edges": 5, "radius": 1})),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -213,6 +244,47 @@ fn replies_match_the_output_schema() {
|
||||
assert!(!conforms(schema, schema, &bad), "{bad}");
|
||||
}
|
||||
|
||||
/// `solid.fillet` replies with `FilletResult` in each of its forms, its contract taking each: a
|
||||
/// boss's base rounded with each turn and its options spelled out, a reduced round (with the radii
|
||||
/// it used), chord and curvature rounds, a smooth-patch corner with setbacks, and the variable and
|
||||
/// spline forms.
|
||||
#[test]
|
||||
fn fillet_replies_match_its_output_schema() {
|
||||
let schema = output_schema_for::<FilletResult>();
|
||||
let schema = schema.as_value();
|
||||
// A 40 x 30 x 10 plate with a 10 x 10 x 5 boss on it, whose base 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.fillet", &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}");
|
||||
r
|
||||
};
|
||||
let boss = json!([[20, 10, 10], [25, 15, 10], [20, 20, 10], [15, 15, 10]]);
|
||||
run(
|
||||
json!({"edges": boss, "radius": 1, "turn": "mitre", "type": "constant", "fit": "exact", "corner": "rolling", "tangent_chain": true, "body": "B", "name": "Round"}),
|
||||
);
|
||||
run(json!({"edges": boss, "radius": "1 mm", "turn": "Swept"}));
|
||||
run(json!({"edges": boss, "radius": 1, "turn": "swept", "fit": "reduce"}));
|
||||
let reduced = run(json!({"edges": [[20, 0, 10]], "radius": 15, "fit": "reduce"}));
|
||||
assert!(reduced.get("radius_applied").is_some_and(Value::is_number) && reduced.get("radii_applied").is_some_and(Value::is_array), "{reduced}");
|
||||
run(json!({"edges": [[20, 0, 10]], "radius": 1, "type": "chord"}));
|
||||
run(json!({"edges": [{"body": "B", "index": 0}], "radius": 0.5, "tangent_chain": false}));
|
||||
run(json!({"edges": [[20, 0, 10]], "radius": 1, "type": "curvature"}));
|
||||
// Variable and spline radii: solidcore fails a variable round on a box edge at this pin (status
|
||||
// 1001, as `variable_radius_on_a_box_edge`), so their forms are checked against the contract.
|
||||
for p in [
|
||||
json!({"edges": [[20, 0, 10]], "radius": 1, "type": "variable", "radius2": 2, "start": [0, 0, 10], "mid": [[0.5, "1.5 mm"]], "smooth": true}),
|
||||
json!({"edges": [[20, 0, 10]], "radius": 1, "type": "spline", "start": [0, 0, 10], "radii": [2], "knots": [0, 0, 1, 1]}),
|
||||
] {
|
||||
assert!(crate::schema::Contract::check(&crate::schema::TypedContract::<FilletParams>::new(), &p).is_empty(), "{p}");
|
||||
}
|
||||
run(json!({"edges": [[20, 0, 10], [40, 15, 10], [40, 0, 5]], "radius": 2, "corner": "g2", "setbacks": [{"vertex": [40, 0, 10], "distance": 4}]}));
|
||||
}
|
||||
|
||||
/// 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.
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
//! Contracts and result types for the commands in `cmd/material_properties.rs`: checking an
|
||||
//! engineering material card, and its elastic modulus in a direction (#11036).
|
||||
|
||||
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 solidcraft_doc::physical_material::{DirectionalModulus, IsotropicModuli, MaterialAxes, Validation};
|
||||
|
||||
use super::check::whole;
|
||||
|
||||
/// Check an engineering material card: its numbers' consistency, the isotropic moduli they
|
||||
/// imply, and the conditions and sources still to review. Read-only: nothing is assigned or
|
||||
/// saved, and there is no analysis solver.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct ValidateCardParams {
|
||||
#[schemars(schema_with = "card_schema")]
|
||||
pub card: Value,
|
||||
}
|
||||
|
||||
/// The Young modulus of an engineering material card in one direction, in the card's own
|
||||
/// material axes. Read-only: no load case or structural solver.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct DirectionalModulusParams {
|
||||
#[schemars(schema_with = "card_schema")]
|
||||
pub card: Value,
|
||||
pub direction: CardDirection,
|
||||
}
|
||||
|
||||
/// The card, checked by the handler (`admit_card`), with its messages.
|
||||
fn card_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "A schema_version 1 engineering material card in SI units (see docs/physical-material-cards.md).",
|
||||
"type": "object"
|
||||
})
|
||||
}
|
||||
|
||||
/// A direction in the card's material axes: three numbers, not all zero, with the handler's
|
||||
/// messages.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct CardDirection(pub [f64; 3]);
|
||||
|
||||
impl<'de> Deserialize<'de> for CardDirection {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let v = Value::deserialize(d)?;
|
||||
let a =
|
||||
v.as_array().filter(|a| a.len() == 3).ok_or_else(|| D::Error::custom(whole("direction: expected exactly three numeric components")))?;
|
||||
let c = |i: usize| a.get(i).and_then(Value::as_f64).ok_or_else(|| D::Error::custom(whole("direction: expected numeric components")));
|
||||
Ok(CardDirection([c(0)?, c(1)?, c(2)?]))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for CardDirection {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"CardDirection".into()
|
||||
}
|
||||
fn inline_schema() -> bool {
|
||||
true
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "A direction in the card's material axes: three numbers, not all zero.",
|
||||
"type": "array",
|
||||
"items": {"type": "number"},
|
||||
"minItems": 3,
|
||||
"maxItems": 3
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// What `material.properties.validate` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct CardValidated {
|
||||
/// Always true: a card that isn't valid is refused.
|
||||
pub valid: bool,
|
||||
pub validation: CardValidation,
|
||||
/// 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 `material.properties.directional` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct CardModulus {
|
||||
/// The direction, made a unit vector.
|
||||
pub direction: [f64; 3],
|
||||
/// The card's material axes, when it has directional properties.
|
||||
pub axes: Option<CardAxes>,
|
||||
/// The Young modulus in that direction (Pa).
|
||||
pub young_modulus_pa: f64,
|
||||
pub validation: CardValidation,
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
impl From<DirectionalModulus> for CardModulus {
|
||||
fn from(m: DirectionalModulus) -> Self {
|
||||
let DirectionalModulus { direction, axes, young_modulus_pa, validation } = m;
|
||||
CardModulus { direction, axes: axes.map(CardAxes::from), young_modulus_pa, validation: validation.into(), param_warnings: Vec::new() }
|
||||
}
|
||||
}
|
||||
|
||||
/// A card's material axes.
|
||||
#[derive(Clone, Copy, Debug, Serialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CardAxes {
|
||||
/// The material's own axes 1, 2 and 3.
|
||||
Material123,
|
||||
/// Wood's longitudinal, radial and tangential axes.
|
||||
WoodLrt,
|
||||
}
|
||||
|
||||
impl From<MaterialAxes> for CardAxes {
|
||||
fn from(a: MaterialAxes) -> Self {
|
||||
match a {
|
||||
MaterialAxes::Material123 => CardAxes::Material123,
|
||||
MaterialAxes::WoodLrt => CardAxes::WoodLrt,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What checking a card found.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct CardValidation {
|
||||
pub schema_version: u32,
|
||||
/// The kinds of property the card gives.
|
||||
pub property_kinds: Vec<String>,
|
||||
/// The shear and bulk moduli an isotropic card's numbers imply.
|
||||
pub isotropic_moduli: Option<CardModuli>,
|
||||
/// The property kinds given without every condition they hold at (temperature, state, and
|
||||
/// moisture for wood or strain rate for polymers).
|
||||
pub unspecified_conditions: Vec<String>,
|
||||
/// Per property kind: the condition fields it lacks.
|
||||
pub missing_condition_fields: BTreeMap<String, Vec<String>>,
|
||||
/// The sources the card's properties cite, which still need review.
|
||||
pub source_review_required: Vec<String>,
|
||||
/// Always false: there is no analysis solver.
|
||||
pub analysis_solver_available: bool,
|
||||
/// Always false: a card holds no rendering properties.
|
||||
pub rendering_properties_available: bool,
|
||||
}
|
||||
|
||||
impl From<Validation> for CardValidation {
|
||||
fn from(v: Validation) -> Self {
|
||||
// In full, so a field added to `Validation` fails here rather than going missing.
|
||||
let Validation {
|
||||
schema_version,
|
||||
property_kinds,
|
||||
isotropic_moduli,
|
||||
unspecified_conditions,
|
||||
missing_condition_fields,
|
||||
source_review_required,
|
||||
analysis_solver_available,
|
||||
rendering_properties_available,
|
||||
} = v;
|
||||
CardValidation {
|
||||
schema_version,
|
||||
property_kinds,
|
||||
isotropic_moduli: isotropic_moduli.map(|IsotropicModuli { shear_pa, bulk_pa }| CardModuli { shear_pa, bulk_pa }),
|
||||
unspecified_conditions,
|
||||
missing_condition_fields,
|
||||
source_review_required,
|
||||
analysis_solver_available,
|
||||
rendering_properties_available,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The isotropic moduli a card's numbers imply.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct CardModuli {
|
||||
pub shear_pa: f64,
|
||||
pub bulk_pa: f64,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "material_properties_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,58 @@
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CardModulus, CardValidated, DirectionalModulusParams, ValidateCardParams};
|
||||
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};
|
||||
|
||||
/// An isotropic card, as the command's own tests write one.
|
||||
fn card() -> Value {
|
||||
json!({"schema_version":1,"designation":"Original synthetic card, not design data","family":"other",
|
||||
"sources":[{"id":"fixture","title":"Original analytic constants","kind":"illustrative","license":"MIT OR Apache-2.0"}],
|
||||
"properties":[{"source_id":"fixture","conditions":{"temperature_k":293.15,"state":"synthetic"},
|
||||
"data":{"kind":"density","kg_m3":700.0}},
|
||||
{"source_id":"fixture","data":{"kind":"elasticity","model":{
|
||||
"symmetry":"isotropic","young_modulus_pa":12.25e9,"poisson_ratio":0.25}}}]})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schemas_and_the_key_check_agree() {
|
||||
assert_keys(input_schema_for::<ValidateCardParams>(), &["card"]);
|
||||
assert_keys(input_schema_for::<DirectionalModulusParams>(), &["card", "direction"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_keeps_todays_errors() {
|
||||
assert_strict_keeps_todays_errors(&[
|
||||
("material.properties.validate", json!({"card": {}})),
|
||||
("material.properties.validate", json!({"card": null})),
|
||||
("material.properties.directional", json!({"card": {}, "direction": [1, 0, 0]})),
|
||||
("material.properties.directional", json!({"card": card(), "direction": [1, 0]})),
|
||||
("material.properties.directional", json!({"card": card(), "direction": "x"})),
|
||||
("material.properties.directional", json!({"card": card(), "direction": [1, "a", 0]})),
|
||||
("material.properties.directional", json!({"card": card(), "direction": [0, 0, 0]})),
|
||||
]);
|
||||
}
|
||||
|
||||
/// #11036: both commands reply as their output schemas say, in Warn without warnings. Like the
|
||||
/// handlers, warn and strict refuse a key they don't take, so no reply carries a warning.
|
||||
#[test]
|
||||
fn card_commands_reply_with_their_schemas() {
|
||||
let (validated, modulus) = (output_schema_for::<CardValidated>(), output_schema_for::<CardModulus>());
|
||||
let matches = |schema: &schemars::Schema, r: &Value| {
|
||||
let schema = schema.as_value();
|
||||
assert!(conforms(schema, schema, r), "{r} doesn't match {schema}");
|
||||
assert!(r.get("param_warnings").is_none(), "{r}");
|
||||
};
|
||||
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
|
||||
let r = s.execute("material.properties.validate", &json!({"card": card()})).unwrap_or_else(|e| panic!("{e}"));
|
||||
matches(&validated, &r);
|
||||
assert!(r["valid"] == true && r["validation"]["isotropic_moduli"]["shear_pa"].is_number(), "{r}");
|
||||
let r = s.execute("material.properties.directional", &json!({"card": card(), "direction": [1, 1, 0]})).unwrap_or_else(|e| panic!("{e}"));
|
||||
matches(&modulus, &r);
|
||||
assert!(r["axes"].is_null() && (r["young_modulus_pa"].as_f64().unwrap_or(0.0) - 12.25e9).abs() < 1.0, "{r}");
|
||||
for level in [ParamCheck::Warn, ParamCheck::Strict] {
|
||||
let mut s = Session { param_check: level, ..Session::default() };
|
||||
assert!(s.execute("material.properties.validate", &json!({"card": card(), "crd": 1})).is_err(), "{level:?}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
//! Contracts and result types for the commands in `cmd/metadata.rs`.
|
||||
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
/// Read the design's properties: its `metadata` object as saved, and SolidCraft's own design
|
||||
/// properties (project, part number, revision…) as text.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct MetadataParams {}
|
||||
|
||||
/// What `document.metadata` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct MetadataResult {
|
||||
/// The design's metadata object as saved, other tools' keys included.
|
||||
pub metadata: Map<String, Value>,
|
||||
/// Each of SolidCraft's design properties, by key: its text, or null when it is absent or
|
||||
/// not text.
|
||||
pub properties: Map<String, Value>,
|
||||
}
|
||||
|
||||
/// Change design properties in one undo step: `key` and `value`, or `values` for several at
|
||||
/// once (all or nothing). A change that sets what is already there makes no undo step.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct MetadataSetParams {
|
||||
/// The property to set: one of SolidCraft's keys (the ones `document.metadata`'s
|
||||
/// `properties` lists), which take text, or another tool's reverse-DNS key such as
|
||||
/// `com.example.tool`, which takes an object. `bom_type` is a component's property and is
|
||||
/// refused here. Needs `value`; not with `values`.
|
||||
#[serde(default)]
|
||||
pub key: Option<String>,
|
||||
/// The value for `key`: text for SolidCraft's keys, an object for another tool's. `""` or
|
||||
/// null removes the key. Required with `key`.
|
||||
#[serde(default)]
|
||||
#[schemars(with = "Value")]
|
||||
pub value: Option<Value>,
|
||||
/// Several properties at once, `{key: value, …}`, at most 256 keys, each as `key` and
|
||||
/// `value` take them. Not with `key`.
|
||||
#[serde(default)]
|
||||
pub values: Option<Map<String, Value>>,
|
||||
}
|
||||
|
||||
/// What `document.metadata.set` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct MetadataSetResult {
|
||||
/// The design's metadata object after the change.
|
||||
pub metadata: Map<String, Value>,
|
||||
/// The keys whose value changed, in key order.
|
||||
pub changed: 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>,
|
||||
}
|
||||
|
||||
/// Remove design properties in one undo step: `key`, or `keys` for several. Any key can go,
|
||||
/// also one another tool wrote; a key that isn't there is left out of `removed`.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct MetadataRemoveParams {
|
||||
/// The property to remove. Not with `keys`.
|
||||
#[serde(default)]
|
||||
pub key: Option<String>,
|
||||
/// The properties to remove, at most 256. Not with `key`.
|
||||
#[serde(default)]
|
||||
pub keys: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// What `document.metadata.remove` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct MetadataRemoveResult {
|
||||
/// The design's metadata object after the change.
|
||||
pub metadata: Map<String, Value>,
|
||||
/// The keys that were there and are removed, each once, in the order asked.
|
||||
pub removed: 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 = "metadata_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,54 @@
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{MetadataParams, MetadataRemoveParams, MetadataRemoveResult, MetadataResult, MetadataSetParams, MetadataSetResult};
|
||||
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() {
|
||||
assert_keys(input_schema_for::<MetadataParams>(), &[]);
|
||||
assert_keys(input_schema_for::<MetadataSetParams>(), &["key", "value", "values"]);
|
||||
assert_keys(input_schema_for::<MetadataRemoveParams>(), &["key", "keys"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_keeps_todays_errors() {
|
||||
assert_strict_keeps_todays_errors(&[
|
||||
("document.metadata.set", json!({"key": 5, "value": "Gearbox"})),
|
||||
("document.metadata.remove", json!({"key": 5})),
|
||||
]);
|
||||
}
|
||||
|
||||
/// Every form of the three commands, in Warn: each reply matches its output schema and carries
|
||||
/// no warning; a misspelt key's warning still matches.
|
||||
#[test]
|
||||
fn replies_match_the_output_schema() {
|
||||
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}"));
|
||||
let matches = |schema: schemars::Schema, reply: &Value| {
|
||||
let schema = schema.as_value();
|
||||
assert!(conforms(schema, schema, reply), "{reply} doesn't match {schema}");
|
||||
};
|
||||
let set = |reply: &Value| matches(output_schema_for::<MetadataSetResult>(), reply);
|
||||
let remove = |reply: &Value| matches(output_schema_for::<MetadataRemoveResult>(), reply);
|
||||
let calls = [
|
||||
("document.metadata.set", json!({"values": {"author": "Jane Doe", "com.acme.shop": {"shop_id": 88123}}})),
|
||||
("document.metadata.set", json!({"key": "project", "value": "Gearbox"})),
|
||||
("document.metadata.set", json!({"key": "project", "value": null})),
|
||||
("document.metadata.remove", json!({"keys": ["author", "author", "not.there"]})),
|
||||
("document.metadata.remove", json!({"key": "com.acme.shop"})),
|
||||
];
|
||||
for (id, p) in calls {
|
||||
let reply = run(id, p.clone());
|
||||
assert!(reply.get("param_warnings").is_none(), "{id} {p}: {reply}");
|
||||
if id == "document.metadata.set" { set(&reply) } else { remove(&reply) }
|
||||
}
|
||||
run("document.metadata.set", json!({"key": "part_number", "value": "ACME-1042"}));
|
||||
let read = run("document.metadata", json!({}));
|
||||
assert_eq!(read["properties"]["part_number"], "ACME-1042");
|
||||
matches(output_schema_for::<MetadataResult>(), &read);
|
||||
let warned = run("document.metadata.set", json!({"key": "author", "value": "J", "valeu": 1}));
|
||||
assert!(warned["param_warnings"].as_array().is_some_and(|w| !w.is_empty()), "{warned}");
|
||||
set(&warned);
|
||||
}
|
||||
@@ -57,6 +57,8 @@ mod tests;
|
||||
pub mod appearance;
|
||||
pub mod bom;
|
||||
pub mod browser;
|
||||
pub mod clearance;
|
||||
pub mod compare;
|
||||
pub mod component;
|
||||
pub mod config;
|
||||
pub mod construct;
|
||||
@@ -68,11 +70,17 @@ pub mod inspect;
|
||||
pub mod joints;
|
||||
pub mod libraries;
|
||||
pub mod mass_properties;
|
||||
pub mod material_properties;
|
||||
pub mod metadata;
|
||||
pub mod motion;
|
||||
pub mod panels;
|
||||
pub mod parts;
|
||||
pub mod print_face;
|
||||
pub mod render;
|
||||
pub mod render_library;
|
||||
pub mod render_lights;
|
||||
pub mod render_shots;
|
||||
pub mod render_stage;
|
||||
pub mod render_ui;
|
||||
pub mod section;
|
||||
pub mod sheet;
|
||||
@@ -84,6 +92,7 @@ pub mod sketch_equation;
|
||||
pub mod sketch_inspect;
|
||||
pub mod surface_analysis;
|
||||
pub mod surface_boundary;
|
||||
pub mod surface_match;
|
||||
pub mod ui_prefs;
|
||||
pub mod view_navigation;
|
||||
|
||||
|
||||