Compare commits

..

58 Commits

Author SHA1 Message Date
bherbruck 777f960999 Release 0.40.0-rc.2 2026-11-03 15:36:03 -05:00
bherbruck d4f923a3e6 Release 0.40.0-rc.1 2026-11-03 02:20:23 -05:00
bherbruck bbb98928fb Release 0.40.0-rc.1 2026-11-03 01:00:12 -05:00
bherbruck 8e2b0cd60d Release 0.30.1 2026-11-02 11:11:46 -05:00
bherbruck afd81380ab Release 0.30.0 2026-11-01 21:55:18 -05:00
bherbruck 3a5195b0a0 Release 0.29.0 2026-10-31 19:20:30 -04:00
bherbruck ee6b85605a Release 0.28.0 2026-10-30 19:50:14 -04:00
bherbruck aa1fe28ddf Release 0.27.1 2026-10-29 23:34:31 -04:00
bherbruck 5b0eb08473 Release 0.27.0 2026-10-29 19:53:24 -04:00
bherbruck 9df8837d43 Release 0.26.1 2026-10-29 06:33:09 -04:00
bherbruck 409ab1ed45 Release 0.26.0 (zip 9.1.3) 2026-10-28 19:41:38 -04:00
bherbruck 5e03fe2f4b Release 0.26.0 2026-10-28 19:32:17 -04:00
bherbruck 37a586dd61 Release 0.25.1 2026-10-27 20:58:30 -04:00
bherbruck a79f40a711 Release 0.25.0 2026-10-27 18:05:39 -04:00
bherbruck b5728f284b Release 0.24.1 2026-10-27 08:34:27 -04:00
bherbruck 5965439a15 Release 0.24.0 2026-10-26 19:23:21 -04:00
bherbruck 586a358ed4 Release 0.23.1 2026-10-26 07:07:21 -04:00
bherbruck 7770f8982d Release 0.23.0 2026-10-25 18:18:39 -04:00
bherbruck 72ad6383ef Release 0.22.1 2026-10-25 10:38:39 -04:00
bherbruck 0537623a75 Release 0.22.0 2026-10-24 18:53:43 -04:00
bherbruck 65d288227e Release 0.21.0 (rc.2's code, version only)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 20:51:08 -04:00
bherbruck fedb3bcdb1 Release 0.21.0-rc.2 2026-10-23 17:14:38 -04:00
bherbruck 097b8b7550 Release 0.21.0-rc.1
The tree is main at f120285c9 exactly; the previous release commit is kept as a parent so release only moves forward.
2026-10-23 13:48:05 -04:00
bherbruck af7ca3a8a2 Release 0.21.0-rc.1
The tree is main at b31fddb98 exactly; the previous release commit is kept as a parent so release only moves forward.
2026-10-23 13:37:43 -04:00
bherbruck 27ddb725c7 Release 0.21.0-rc.1
The tree is main at 2bf3d4e11 exactly; the previous release commit is kept as a parent so release only moves forward.
2026-10-23 12:10:02 -04:00
bherbruck a06bdf5cfc Release 0.20.0
The tree is main at 030187452 exactly; the previous release commit is kept as a parent so release only moves forward.
2026-10-22 18:34:40 -04:00
bherbruck 33fdcdb3a0 Release 0.19.0
The tree is main at 5899e5b6c exactly; the previous release commit is kept as a parent so release only moves forward.
2026-10-21 21:13:33 -04:00
bherbruck c07ab5f8f9 Release 0.18.4
The tree is main at 728f753b8 exactly; the previous release commit is kept as a parent so release only moves forward.
2026-10-21 12:26:34 -04:00
bherbruck fea32f8aac Release 0.18.3
The tree is main at 034aa60d4 exactly; the previous release commit is kept as a parent so release only moves forward.
2026-10-21 08:46:37 -04:00
bherbruck 4927214976 Release 0.18.2
The tree is main at 57bcc4083 exactly; the previous release commit is kept as a parent so release only moves forward.
2026-10-20 22:09:18 -04:00
bherbruck b621819c0c Release 0.18.1
The tree is main at 522ae7755 exactly; the previous release commit is kept as a parent so release only moves forward.
2026-10-20 17:39:11 -04:00
bherbruck ec552a5300 Release 0.18.0
The tree is main at c3151fb7f exactly; the previous release commit is kept as a parent so release only moves forward.
2026-10-20 16:28:59 -04:00
bherbruck e0cc740cc3 Release 0.17.0
The tree is main at 511faedcf exactly; the 0.16.x release commits are kept as a parent so release only moves forward.
2026-10-20 11:41:48 -04:00
bherbruck 5de48e916d Release 0.16.2 2026-10-19 00:39:30 +00:00
bherbruck 80af7bbe3e Release 0.16.2
Version 0.16.2: 0.16.1 with the rigid orbit fix (#2753). Same design format (27).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 00:38:53 +00:00
bherbruck 191e983423 Fix orbit about the picked point (regression in 0.16.0) (#2753)
Orbiting about the point under the cursor (or the origin) turned the camera
about its target and then panned to put the pivot back. The pan works at the
target's depth with the parallel-view scale, so in a perspective view the
pivot slid off its pixel and the model swung about the camera.

The camera now turns rigidly about the pivot: Camera::orbit_about keeps the
pivot's coordinates in the camera's frame and rebuilds the target from the
new orientation, so the pivot stays exactly on its pixel in both projections.
The Shift snap spring and view.navigate's pivot orbit use the same.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit e88779218)
2026-10-19 00:38:52 +00:00
bherbruck 2b9445b049 Release 0.16.1 2026-10-18 20:16:21 +00:00
bherbruck 7ddf48ca16 Release 0.16.1
Version 0.16.1: 0.16.0 with the orbit fix (reverts #2142). Same design format (27).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:12:04 +00:00
bherbruck d2f6848377 Revert #2142: orbit about the picked point like 0.15.0
#2142 made orbiting about the point under the cursor turn the camera in place instead of turning the part about that point. Restore the 0.15.0 behaviour.

This reverts commit cac4bccea8.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:12:00 +00:00
bherbruck 0b6d6fecd8 Release 0.16.0 2026-10-18 18:16:11 +00:00
bherbruck fa691707c2 Release 0.15.0 2026-10-17 13:54:24 +00:00
bherbruck b9a55c74e2 Release 0.15.0
Version 0.15.0; the pending design-format changes are folded into format 26.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 13:53:44 +00:00
bherbruck 2bb9c81069 Release 0.14.0 2026-10-16 09:19:14 +00:00
bherbruck cedb51b8ea Release 0.14.0
Version 0.14.0; the pending design-format changes are folded into format 25.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 09:19:10 +00:00
bherbruck 66d13059dd Release 0.13.1 2026-10-15 21:56:43 +00:00
bherbruck 800ea40c0d Release 0.13.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 21:56:35 +00:00
bherbruck 20442eecd3 Agent chat on Windows: start npm agents through their .cmd shim
npm puts a Unix shell script with the bare name (codex-acp,
claude-code-acp, gemini) beside the .cmd shim. The lookup tried the bare
name first, so every npm-installed agent failed with "not a valid Win32
application (os error 193)". Windows now only considers .exe/.cmd/.bat,
and a command saved with the bare path runs the shim beside it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 60ea8c69d2)
2026-10-15 21:51:01 +00:00
bherbruck 22d1cd7032 Release 0.13.0 2026-10-15 20:34:13 +00:00
bherbruck 6a0dc18655 Fix the Windows 32-bit and macOS release builds
On 32-bit Windows SetWindowLongPtrW is SetWindowLongW, which takes and
returns i32, so the window-procedure hook didn't compile for i686.
The macOS app bundle now signs the bundled solidcraft-cli before the
bundle itself; codesign refuses a bundle with an unsigned helper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 94e76cd97e)
2026-10-15 20:34:12 +00:00
bherbruck e006dbf1af Release 0.13.0
The tree is the 0.13.0 version commit (8241a750fa) on main exactly; the previous release is kept as a parent so release only moves forward.
2026-10-15 19:59:50 +00:00
bherbruck 8241a750fa Release 0.13.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 19:59:42 +00:00
bherbruck 41779cf7bb Release 0.12.0: release branch follows main again after the 0.11.1 patch
The tree is main at c36a6f9f exactly; the 0.11.1 patch commit is kept as a parent so release only moves forward.
2026-10-15 13:32:01 +00:00
bherbruck b01b9689c8 Release 0.11.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:41:41 +00:00
bherbruck 4426da6f5d Windows: start Direct3D 12 first, and Vulkan only without a D3D12 GPU (#1190)
* Windows: start Direct3D 12 first, and Vulkan only without a D3D12 GPU

wgpu started Vulkan, D3D12 and OpenGL together and preferred Vulkan, so
every Windows start loaded all installed Vulkan drivers. An old Intel
Vulkan driver (igvk64.dll) crashed 0.10.1 at startup inside the driver.

The app now probes D3D12, then Vulkan, then OpenGL, one backend at a
time, and starts only the first with a hardware adapter (WARP when none
has one). WGPU_BACKEND still wins; graphics.json's "backend" sets the
order by hand; the software renderer (safe mode, the retry) uses D3D12
alone. A new Windows CI job starts the real app under several settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* CI: a test filter YAML parses

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* CI: the startup smoke test writes graphics.json where the app reads it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* CI: run the WARP shader tests one at a time

WARP's shader JIT (d3d10warp.dll) crashed twice in 124 runs of the gpu
tests with four test threads, with a null read inside its own optimizer
while several devices compiled compute pipelines at once. Serial runs
did not crash.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 95ea05e7c8)
2026-10-14 20:41:37 +00:00
bherbruck 5ead82e627 Release 0.11.0: release branch follows main again after the 0.10.1 patch
The tree is main at cccbe125 exactly; c29b0d2f (0.10.1) is kept as a parent so release only moves forward.
2026-10-14 20:07:41 +00:00
bherbruck c29b0d2f13 Release 0.10.1 2026-10-14 14:41:19 +00:00
bherbruck 480fd8d517 Fix the startup crash on D3D12 with FXC: CPU fallback for the tessellation pipeline, FXC-friendly shader (#1098) (#1099)
* GPU tessellation: a pipeline the device cannot build falls back to CPU meshing instead of crashing (#1098)

Build the tessellation compute pipeline inside error scopes; TessGpu::new returns an error,
and the viewport logs it and meshes bodies on the CPU. A Windows CI job builds the pipeline
with D3D12's FXC on the WARP adapter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Tessellation shader: basis functions in one-dimensional local arrays, so D3D12's FXC compiles it (#1098)

FXC has to unroll loops that write an array inside a struct (Basis.n0/n1) or a 2-D array
(ndu) by a counter, and cannot with run-time trip counts (X3511). A2.2 basis functions in a
local 1-D array returned whole; derivatives from the degree p-1 functions; the pole step
offsets computed rather than indexed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:41:16 +00:00
bherbruck 4c09fa5355 Hovered and selected faces light solidly again over GPU-drawn bodies (#1064)
Since the model's mesh went back to the reference mesher (#940), the GPU viewport drew
opaque bodies from their display plans while hover and selection lit faces with the
reference mesh's triangles. Two triangulations of the same face at equal depth fought, so
a hovered face shimmered in wedges instead of lighting whole.

Faces lit over a body drawn from its plan now use the plan's own triangles
(ModelBody::plan_mesh, evaluated once on the CPU), and the highlight pass gets a small
depth bias toward the eye so f32 rounding between the GPU and CPU evaluations of the same
triangles cannot speckle it either.

Test: every face of a box with a pocket and a round boss, hovered and rendered offscreen
through the viewport's real pipelines on the GPU path, lights the same pixels as on the
CPU path (skipped without a GPU adapter).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:56:47 +00:00
426 changed files with 15126 additions and 14214 deletions
-20
View File
@@ -1,20 +0,0 @@
# 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.
+1 -2
View File
@@ -317,6 +317,7 @@ Editable drawing code, native projects and shipped exports are collected under `
| assets/icons/ui/design/svg/model/circle_ttt.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/coil.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/colored_components.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/colored_components_off.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/combine.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/component.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
@@ -783,8 +784,6 @@ 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 |
## Code adapted from sibling projects
Generated
-1
View File
@@ -4102,7 +4102,6 @@ dependencies = [
"ureq",
"windows-sys 0.61.2",
"winit",
"winreg",
"x11rb",
"zbus 5.19.0",
]
+3 -4
View File
@@ -171,7 +171,6 @@ 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 _;
@@ -501,7 +500,7 @@ mod web {
files::pick_from_disk(&self.files, Intent::Open);
}
if ui_kit::button(ui, "Insert from computer…", ui_kit::Button::Normal)
.backing_on_hover_text(solidcraft_ui_egui::i18n::tr("web.files.insert_drawings_hint"))
.on_hover_text(solidcraft_ui_egui::i18n::tr("web.files.insert_drawings_hint"))
.clicked()
{
files::pick_from_disk(&self.files, Intent::Insert);
@@ -555,7 +554,7 @@ mod web {
let design = solidcraft_engine::io::is_design_path(&e.name);
if !design
&& ui_kit::button(ui, "Insert", ui_kit::Button::Small)
.backing_on_hover_text(solidcraft_ui_egui::i18n::tr("web.files.insert_stored_drawings_hint"))
.on_hover_text(solidcraft_ui_egui::i18n::tr("web.files.insert_stored_drawings_hint"))
.clicked()
{
act = Some(("insert", e.name.clone()));
@@ -565,7 +564,7 @@ mod web {
self.rename_focus = true;
}
if ui_kit::button(ui, "Save to computer…", ui_kit::Button::Small)
.backing_on_hover_text("A copy in a file you choose; the stored design stays here")
.on_hover_text("A copy in a file you choose; the stored design stays here")
.clicked()
{
act = Some(("save_copy", e.name.clone()));
+1 -2
View File
@@ -81,7 +81,6 @@ 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 }
@@ -93,7 +92,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", "NSAccessibility", "NSApplication", "NSResponder", "NSWindow", "NSWorkspace"] }
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSResponder", "NSWindow"] }
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
@@ -1,63 +0,0 @@
//! 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()))
}
-6
View File
@@ -24,8 +24,6 @@ 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;
@@ -541,10 +539,6 @@ 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.
@@ -60,6 +60,7 @@ circle_ttt false
close false
coil false
colored_components false
colored_components_off false
combine false
compare false
component false
@@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Disable component colors</title>
<path d="M9.235 17.706L15.059 20.882L15.059 48.941L9.235 44.926Z" fill="#328bd3"/>
<path d="M35.176 9.235L15.059 20.882L15.059 48.941L36.765 35.139Z" fill="#236ead"/>
<path d="M29.046 5L35.176 8.176L15.196 19.718L9.066 16.541Z" fill="#b9ddf8"/>
<path d="M29.046 5.529L35.176 8.706L35.176 36.235L15.059 49.471L9.235 45.765L9.066 17.071Z" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9.066 17.6L15.059 20.882L34.647 9.235" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M15.059 20.882L15.059 47.067" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M32.222 18.595L44.949 25.944L44.949 51.122L32.222 43.774Z" fill="#328bd3"/>
<path d="M54.934 20.173L44.949 25.944L44.949 51.122L54.934 45.352Z" fill="#236ead"/>
<path d="M42.059 12.412L54.934 20.173L44.949 25.944L32 17.706Z" fill="#b9ddf8"/>
<path d="M41.159 11.766L54.934 20.173L54.934 45.352L44.949 51.122L31.164 42.715L31.164 17.536Z" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M32.222 18.595L44.949 25.944L54.934 20.173" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M44.949 25.944L44.949 51.122" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M18.299 39.062L31.015 46.411L31.015 59L18.299 51.652Z" fill="#328bd3"/>
<path d="M41.921 40.121L31.015 46.411L31.015 59L41.921 52.711Z" fill="#236ead"/>
<path d="M29.194 32.773L41.921 40.121L31.015 46.411L18.299 39.062Z" fill="#b9ddf8"/>
<path d="M29.194 32.773L41.921 40.121L41.921 52.711L31.015 59L18.299 51.652L18.299 39.062Z" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M18.299 39.062L31.015 46.411L41.921 40.121" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M31.015 46.411L31.015 59" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

+41 -67
View File
@@ -744,22 +744,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": "يعدّل بحرية ويسأل عن الباقي",
@@ -963,7 +963,8 @@
"cmd.inspect_analysis": "تحليل العرض",
"cmd.inspect_center_of_mass": "مركز الكتلة",
"cmd.inspect_clearance": "الخلوص",
"cmd.inspect_colored_components": "ألوان المكوّنات",
"cmd.inspect_colored_components": "تفعيل ألوان المكوّنات",
"cmd.inspect_colored_components_disable": "تعطيل ألوان المكوّنات",
"cmd.inspect_compare": "مقارنة الأجسام",
"cmd.inspect_compare_picture": "مقارنة بصورة مرجعية",
"cmd.inspect_continuity": "تحليل الاستمرارية",
@@ -1395,9 +1396,6 @@
"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": "اختر أجسامًا أو ميزات: الرسم ليس أيًّا منهما. كرّر منحنياته بأداة النمط أو المرآة داخل الرسم، أو ابثقه أولًا.",
@@ -1920,7 +1918,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} أولًا.",
@@ -1935,7 +1933,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": "اختر وجهًا أو جزءًا واحدًا من التجميع، ثم اختر تحريك.",
@@ -2242,7 +2240,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": "السمات التالية",
@@ -2252,11 +2250,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": "النظر إلى الرسومات يحرّك العرض مع المؤشر.",
"prefs.assistant_follow_help": "تحرّك عروض Look At للرسم المنظور مع المؤشر.",
"prefs.assistant_limit": "الحد",
"prefs.assistant_limited": "محدود",
"prefs.assistant_pointer_speed": "سرعة المؤشر",
@@ -2264,21 +2262,13 @@
"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": "أسفل اليمين",
@@ -2333,13 +2323,6 @@
"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": "توسيع",
@@ -2377,7 +2360,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": "مزيجك الخاص",
@@ -2386,7 +2369,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": "فقط أثناء سكون العرض",
@@ -2433,7 +2416,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": "استبعاد الأوامر المثبتة من القوائم",
@@ -2473,8 +2456,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": "ملاءمة النموذج بعد تغيير العرض من متصفح العرض",
@@ -2492,7 +2475,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": "مجال الرؤية",
@@ -2561,16 +2544,16 @@
"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": "الرسم",
@@ -2585,7 +2568,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": "الالتقاط والأدلة",
@@ -2667,10 +2650,6 @@
"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": "ألوان علامات التبويب",
@@ -2679,7 +2658,6 @@
"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": "لصق السمة",
@@ -2708,7 +2686,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": "سلوك العرض",
@@ -4223,13 +4201,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)",
@@ -4486,7 +4464,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": "غير مثبت",
@@ -4527,7 +4505,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": "جارٍ العمل…",
@@ -4592,10 +4570,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": "أوامر النمذجة تعمل دون سؤال؛ أما الحفظ والفتح والتصدير وإجراءات الملفات أو الإجراءات الهدّامة الأخرى فتسأل.",
@@ -4766,21 +4744,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": "انسخ الرسالة كاملة للبحث عنها أو للإبلاغ عنها.",
@@ -4931,7 +4909,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": "طول التلاشي",
@@ -4961,13 +4939,9 @@
"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_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_choices_tip": "نصف قطر ثابت: نصف قطر واحد على طول الحافة.\nطول الوتر: يبقى عرض التقويس نفسه مهما كانت الزاوية بين الوجوه.\nنصف قطر متغيّر: نصف قطر عند كل طرف، ومزيد على طول الحافة إن أضفته.\nمتصل الانحناء: مقطع بلا انكسار في الانعكاسات حيث يلتقي بالوجوه المستوية (G2).\nمنحنى: يتبع نصف القطر على طول الحافة أنصاف أقطار تحكّم تحدّدها.",
"ui.fillet_type_curvature": "متصل الانحناء",
"ui.fillet_type_tip": "المقطع عبر الحافة. متصل الانحناء: مقطع متصل الانحناء (G2) بين وجوه مستوية، فتمر الانعكاسات من الوجوه إلى التقويس دون انكسار.",
"ui.filter_commands_or_keys": "تصفية الأوامر أو المفاتيح",
@@ -5079,7 +5053,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": "أسفل المستعرض",
+2
View File
@@ -957,6 +957,8 @@
"cmd.inspect_accessibility": "Analiza dostupnosti",
"cmd.inspect_center_of_mass": "Centar mase",
"cmd.inspect_clearance": "Provjera zazora",
"cmd.inspect_colored_components": "Omogući boje komponenti",
"cmd.inspect_colored_components_disable": "Onemogući boje komponenti",
"cmd.inspect_compare": "Uporedi tijela",
"cmd.inspect_compare_picture": "Uporedi sliku",
"cmd.inspect_continuity": "Analiza kontinuiteta",
+2
View File
@@ -957,6 +957,8 @@
"cmd.inspect_accessibility": "Analýza přístupnosti",
"cmd.inspect_center_of_mass": "Těžiště",
"cmd.inspect_clearance": "Vůle",
"cmd.inspect_colored_components": "Zapnout barvy komponent",
"cmd.inspect_colored_components_disable": "Vypnout barvy komponent",
"cmd.inspect_compare": "Porovnat tělesa",
"cmd.inspect_compare_picture": "Porovnat obrázek",
"cmd.inspect_continuity": "Analýza spojitosti",
+2
View File
@@ -957,6 +957,8 @@
"cmd.inspect_accessibility": "Tilgængelighedsanalyse",
"cmd.inspect_center_of_mass": "Massecentrum",
"cmd.inspect_clearance": "Frigangskontrol",
"cmd.inspect_colored_components": "Aktivér komponentfarver",
"cmd.inspect_colored_components_disable": "Deaktivér komponentfarver",
"cmd.inspect_compare": "Sammenlign emner",
"cmd.inspect_compare_picture": "Sammenlign billede",
"cmd.inspect_continuity": "Kontinuitetsanalyse",
+2 -27
View File
@@ -960,6 +960,8 @@
"cmd.inspect_accessibility": "Zugänglichkeitsanalyse",
"cmd.inspect_center_of_mass": "Massenschwerpunkt",
"cmd.inspect_clearance": "Freiraum",
"cmd.inspect_colored_components": "Komponentenfarben einschalten",
"cmd.inspect_colored_components_disable": "Komponentenfarben ausschalten",
"cmd.inspect_compare": "Körper vergleichen",
"cmd.inspect_continuity": "Kontinuitätsprüfung",
"cmd.inspect_curvature_comb": "Krümmungskamm-Analyse",
@@ -1389,9 +1391,6 @@
"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.",
@@ -2249,14 +2248,6 @@
"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",
@@ -2315,13 +2306,6 @@
"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",
@@ -2644,10 +2628,6 @@
"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",
@@ -2656,7 +2636,6 @@
"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",
@@ -4935,10 +4914,6 @@
"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.",
+2 -1
View File
@@ -22,7 +22,8 @@
"cmd.component_cycle_color": "Cycle Component Colour",
"cmd.construct_point_center": "Point At Centre Of Circle/Sphere/Torus",
"cmd.inspect_center_of_mass": "Centre of Mass",
"cmd.inspect_colored_components": "Component Colours",
"cmd.inspect_colored_components": "Enable Component Colours",
"cmd.inspect_colored_components_disable": "Disable Component Colours",
"cmd.parameters_favorite": "Favourite Parameter",
"cmd.render_aging_describe": "Layered Surface Aging",
"cmd.render_effects_describe": "Lens and Creative Effects",
+3 -29
View File
@@ -963,7 +963,8 @@
"cmd.inspect_analysis": "View Analysis",
"cmd.inspect_center_of_mass": "Center of Mass",
"cmd.inspect_clearance": "Clearance",
"cmd.inspect_colored_components": "Component Colors",
"cmd.inspect_colored_components": "Enable Component Colors",
"cmd.inspect_colored_components_disable": "Disable Component Colors",
"cmd.inspect_compare": "Compare Bodies",
"cmd.inspect_compare_picture": "Compare Picture",
"cmd.inspect_continuity": "Continuity Analysis",
@@ -1395,9 +1396,6 @@
"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.",
@@ -1602,7 +1600,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 (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.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.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.",
@@ -2267,14 +2265,6 @@
"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",
@@ -2333,13 +2323,6 @@
"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",
@@ -2667,10 +2650,6 @@
"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",
@@ -2679,7 +2658,6 @@
"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",
@@ -4963,10 +4941,6 @@
"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.",
+2
View File
@@ -939,6 +939,8 @@
"cmd.inspect_accessibility": "Análisis de accesibilidad",
"cmd.inspect_center_of_mass": "Centro de masas",
"cmd.inspect_clearance": "Holgura",
"cmd.inspect_colored_components": "Activar colores de componente",
"cmd.inspect_colored_components_disable": "Desactivar colores de componente",
"cmd.inspect_compare": "Comparar cuerpos",
"cmd.inspect_continuity": "Análisis de continuidad",
"cmd.inspect_curvature_comb": "Análisis de peine de curvatura",
+2
View File
@@ -935,6 +935,8 @@
"cmd.inspect_accessibility": "Saavutettavuusanalyysi",
"cmd.inspect_center_of_mass": "Massakeskipiste",
"cmd.inspect_clearance": "Välys",
"cmd.inspect_colored_components": "Ota komponenttivärit käyttöön",
"cmd.inspect_colored_components_disable": "Poista komponenttivärit käytöstä",
"cmd.inspect_compare": "Vertaa kappaleita",
"cmd.inspect_continuity": "Jatkuvuusanalyysi",
"cmd.inspect_curvature_comb": "Kaarevuuskampa-analyysi",
+2
View File
@@ -956,6 +956,8 @@
"cmd.inspect_accessibility": "Analyse d'accessibilité",
"cmd.inspect_center_of_mass": "Centre de gravité",
"cmd.inspect_clearance": "Jeu",
"cmd.inspect_colored_components": "Activer les couleurs des composants",
"cmd.inspect_colored_components_disable": "Désactiver les couleurs des composants",
"cmd.inspect_compare": "Comparer les corps",
"cmd.inspect_compare_picture": "Comparer les images",
"cmd.inspect_continuity": "Analyse de continuité",
+2
View File
@@ -957,6 +957,8 @@
"cmd.inspect_accessibility": "Análise de accesibilidade",
"cmd.inspect_center_of_mass": "Centro de masas",
"cmd.inspect_clearance": "Folga",
"cmd.inspect_colored_components": "Activar as cores dos compoñentes",
"cmd.inspect_colored_components_disable": "Desactivar as cores dos compoñentes",
"cmd.inspect_compare": "Comparar corpos",
"cmd.inspect_compare_picture": "Comparar a imaxe",
"cmd.inspect_continuity": "Análise de continuidade",
+2 -28
View File
@@ -963,7 +963,8 @@
"cmd.inspect_analysis": "ניתוח תצוגה",
"cmd.inspect_center_of_mass": "מרכז מסה",
"cmd.inspect_clearance": "מרווח",
"cmd.inspect_colored_components": "צבעי רכיבים",
"cmd.inspect_colored_components": "הפעלת צבעי רכיבים",
"cmd.inspect_colored_components_disable": "ביטול צבעי רכיבים",
"cmd.inspect_compare": "השוואת גופים",
"cmd.inspect_compare_picture": "השוואה לתמונת ייחוס",
"cmd.inspect_continuity": "ניתוח רציפות",
@@ -1395,9 +1396,6 @@
"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": "בחרו גופים או פיצ'רים: סקיצה אינה אף אחד מהם. חזרו על העקומות שלה בעזרת המערך או השיקוף של הסקיצה עצמה, או בצעו לה שליפה קודם.",
@@ -2267,14 +2265,6 @@
"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": "אף פעם",
@@ -2333,13 +2323,6 @@
"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": "הרחבה",
@@ -2667,10 +2650,6 @@
"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": "צבעי כרטיסיות",
@@ -2679,7 +2658,6 @@
"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": "הדבקת ערכת נושא",
@@ -4963,10 +4941,6 @@
"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) בין פאות מישוריות, כך שההשתקפויות עוברות מהפאות אל העיגול בלי שבר.",
+2
View File
@@ -957,6 +957,8 @@
"cmd.inspect_accessibility": "Analiza dostupnosti",
"cmd.inspect_center_of_mass": "Središte mase",
"cmd.inspect_clearance": "Provjera zazora",
"cmd.inspect_colored_components": "Omogući boje komponenti",
"cmd.inspect_colored_components_disable": "Onemogući boje komponenti",
"cmd.inspect_compare": "Usporedi tijela",
"cmd.inspect_compare_picture": "Usporedi sliku",
"cmd.inspect_continuity": "Analiza kontinuiteta",
+2
View File
@@ -957,6 +957,8 @@
"cmd.inspect_accessibility": "Hozzáférhetőség-elemzés",
"cmd.inspect_center_of_mass": "Tömegközéppont",
"cmd.inspect_clearance": "Hézag",
"cmd.inspect_colored_components": "Komponensszínek bekapcsolása",
"cmd.inspect_colored_components_disable": "Komponensszínek kikapcsolása",
"cmd.inspect_compare": "Testek összehasonlítása",
"cmd.inspect_compare_picture": "Kép összehasonlítása",
"cmd.inspect_continuity": "Folytonosságelemzés",
+2
View File
@@ -957,6 +957,8 @@
"cmd.inspect_accessibility": "Analisis Aksesibilitas",
"cmd.inspect_center_of_mass": "Pusat Massa",
"cmd.inspect_clearance": "Kelonggaran",
"cmd.inspect_colored_components": "Aktifkan Warna Komponen",
"cmd.inspect_colored_components_disable": "Nonaktifkan Warna Komponen",
"cmd.inspect_compare": "Bandingkan Bodi",
"cmd.inspect_compare_picture": "Bandingkan dengan Gambar",
"cmd.inspect_continuity": "Analisis Kontinuitas",
+2
View File
@@ -957,6 +957,8 @@
"cmd.inspect_accessibility": "Analisi di accessibilità",
"cmd.inspect_center_of_mass": "Centro di massa",
"cmd.inspect_clearance": "Distanza libera",
"cmd.inspect_colored_components": "Attiva colori dei componenti",
"cmd.inspect_colored_components_disable": "Disattiva colori dei componenti",
"cmd.inspect_compare": "Confronta corpi",
"cmd.inspect_compare_picture": "Confronta immagine",
"cmd.inspect_continuity": "Analisi di continuità",
+2 -28
View File
@@ -963,7 +963,8 @@
"cmd.inspect_analysis": "表示解析",
"cmd.inspect_center_of_mass": "重心",
"cmd.inspect_clearance": "クリアランス",
"cmd.inspect_colored_components": "コンポーネントの色分け",
"cmd.inspect_colored_components": "コンポーネントの色分けを有効化",
"cmd.inspect_colored_components_disable": "コンポーネントの色分けを無効化",
"cmd.inspect_compare": "ボディを比較",
"cmd.inspect_compare_picture": "参考画像と比較",
"cmd.inspect_continuity": "連続性解析",
@@ -1395,9 +1396,6 @@
"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": "ボディかフィーチャーを選んでください。スケッチはどちらでもありません。曲線はスケッチ内のパターンかミラーで繰り返すか、先に押し出してください。",
@@ -2267,14 +2265,6 @@
"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": "表示しない",
@@ -2333,13 +2323,6 @@
"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": "拡張",
@@ -2667,10 +2650,6 @@
"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": "タブの色",
@@ -2679,7 +2658,6 @@
"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": "テーマを貼り付け",
@@ -4963,10 +4941,6 @@
"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) 断面で、映り込みが面からフィレットへ折れずにつながります。",
File diff suppressed because it is too large Load Diff
-27
View File
@@ -1207,9 +1207,6 @@
"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": "↖ 선택",
@@ -1660,14 +1657,6 @@
"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": "표시 안 함",
@@ -1723,13 +1712,6 @@
"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": "확장",
@@ -1993,10 +1975,6 @@
"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": "탭 색상",
@@ -2005,7 +1983,6 @@
"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분",
@@ -3735,10 +3712,6 @@
"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) 단면으로, 반사가 면에서 라운드로 꺾임 없이 이어집니다.",
+2
View File
@@ -957,6 +957,8 @@
"cmd.inspect_accessibility": "Analisis Kebolehcapaian",
"cmd.inspect_center_of_mass": "Pusat Jisim",
"cmd.inspect_clearance": "Kelegaan",
"cmd.inspect_colored_components": "Aktifkan Warna Komponen",
"cmd.inspect_colored_components_disable": "Nyahaktifkan Warna Komponen",
"cmd.inspect_compare": "Bandingkan Jasad",
"cmd.inspect_compare_picture": "Banding dengan Imej",
"cmd.inspect_continuity": "Analisis Keselanjaran",
+2
View File
@@ -957,6 +957,8 @@
"cmd.inspect_accessibility": "Tilgjengelighetsanalyse",
"cmd.inspect_center_of_mass": "Massesenter",
"cmd.inspect_clearance": "Klaring",
"cmd.inspect_colored_components": "Aktiver komponentfarger",
"cmd.inspect_colored_components_disable": "Deaktiver komponentfarger",
"cmd.inspect_compare": "Sammenlign kropper",
"cmd.inspect_compare_picture": "Sammenlign bilde",
"cmd.inspect_continuity": "Kontinuitetsanalyse",
+2 -10
View File
@@ -957,6 +957,8 @@
"cmd.inspect_accessibility": "Toegankelijkheidsanalyse",
"cmd.inspect_center_of_mass": "Zwaartepunt",
"cmd.inspect_clearance": "Speling",
"cmd.inspect_colored_components": "Componentkleuren inschakelen",
"cmd.inspect_colored_components_disable": "Componentkleuren uitschakelen",
"cmd.inspect_compare": "Lichamen vergelijken",
"cmd.inspect_compare_picture": "Afbeelding vergelijken",
"cmd.inspect_continuity": "Continuïteitsanalyse",
@@ -1366,9 +1368,6 @@
"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.",
@@ -2195,13 +2194,6 @@
"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",
@@ -1,15 +0,0 @@
{
"en": { "document.tab_unsaved_changes": "Unsaved changes" },
"ar": { "document.tab_unsaved_changes": "تغييرات غير محفوظة" },
"de": { "document.tab_unsaved_changes": "Ungespeicherte Änderungen" },
"es": { "document.tab_unsaved_changes": "Cambios sin guardar" },
"fr": { "document.tab_unsaved_changes": "Modifications non enregistrées" },
"he": { "document.tab_unsaved_changes": "שינויים שלא נשמרו" },
"it": { "document.tab_unsaved_changes": "Modifiche non salvate" },
"ja": { "document.tab_unsaved_changes": "未保存の変更" },
"nl": { "document.tab_unsaved_changes": "Niet-opgeslagen wijzigingen" },
"pl": { "document.tab_unsaved_changes": "Niezapisane zmiany" },
"sv": { "document.tab_unsaved_changes": "Osparade ändringar" },
"vi": { "document.tab_unsaved_changes": "Thay đổi chưa lưu" },
"zh": { "document.tab_unsaved_changes": "未保存的更改" }
}
@@ -1,10 +0,0 @@
{
"en": { "ui.dependents_select_first": "Select what to list the dependents of first" },
"ar": { "ui.dependents_select_first": "حدّد أولًا ما تريد سرد معتمداته" },
"de": { "ui.dependents_select_first": "Wählen Sie zuerst aus, wessen Abhängige aufgelistet werden sollen" },
"he": { "ui.dependents_select_first": "בחרו קודם מה להציג את התלויים שלו" },
"ja": { "ui.dependents_select_first": "先に、依存先を表示する対象を選択してください" },
"ko": { "ui.dependents_select_first": "먼저 종속 항목을 표시할 대상을 선택하세요" },
"vi": { "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ó" },
"zh": { "ui.dependents_select_first": "请先选择要列出其依赖项的对象" }
}
+2
View File
@@ -934,6 +934,8 @@
"cmd.inspect_accessibility": "Analiza dostępności",
"cmd.inspect_center_of_mass": "Środek masy",
"cmd.inspect_clearance": "Prześwit",
"cmd.inspect_colored_components": "Włącz kolory komponentów",
"cmd.inspect_colored_components_disable": "Wyłącz kolory komponentów",
"cmd.inspect_compare": "Porównaj bryły",
"cmd.inspect_continuity": "Analiza ciągłości",
"cmd.inspect_curvature_comb": "Analiza grzebienia krzywizny",
+2
View File
@@ -957,6 +957,8 @@
"cmd.inspect_accessibility": "Analiza accesibilității",
"cmd.inspect_center_of_mass": "Centru de masă",
"cmd.inspect_clearance": "Joc",
"cmd.inspect_colored_components": "Activează culorile componentelor",
"cmd.inspect_colored_components_disable": "Dezactivează culorile componentelor",
"cmd.inspect_compare": "Compară corpurile",
"cmd.inspect_compare_picture": "Compară imaginea",
"cmd.inspect_continuity": "Analiza continuității",
+2 -21
View File
@@ -182,8 +182,6 @@
"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": "Отменить",
@@ -608,7 +606,6 @@
"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": "Выпуклость в начале",
@@ -956,6 +953,8 @@
"cmd.inspect_accessibility": "Анализ доступности",
"cmd.inspect_center_of_mass": "Центр масс",
"cmd.inspect_clearance": "Зазор",
"cmd.inspect_colored_components": "Включить цвета компонентов",
"cmd.inspect_colored_components_disable": "Отключить цвета компонентов",
"cmd.inspect_compare": "Сравнить тела",
"cmd.inspect_compare_picture": "Сравнить с изображением",
"cmd.inspect_continuity": "Анализ непрерывности",
@@ -1357,7 +1356,6 @@
"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}",
@@ -1719,8 +1717,6 @@
"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": "Удалить этот личный пресет? Существующие отверстия не изменятся.",
@@ -2132,8 +2128,6 @@
"prefs.anti_aliasing_note": "Сглаживает неровные края.",
"prefs.applies_on_restart": "Применяется после перезапуска",
"prefs.assistant": "Ассистент",
"prefs.auto_operation": "Выбирать операцию автоматически",
"prefs.auto_operation_note": "Окна операций сами выбирают «Объединить», «Новое тело» или «Вырезать» в зависимости от того, чего касается новый материал, пока вы не выберете операцию вручную. Выкл.: окна сохраняют ту операцию, с которой открылись.",
"prefs.axes": "Оси",
"prefs.axis": "Ось",
"prefs.bodies_folders": "Папки тел",
@@ -2355,8 +2349,6 @@
"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": "Направляющие выравнивания по центру и середине",
@@ -2368,11 +2360,6 @@
"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": "Как делается предложение, когда в заголовке файла не указана программа, о которой известно, что она сохраняет с осью Y вверх. Уведомление показывается, только если файл, судя по всему, лежит на спине; «Спрашивать в диалоговом окне» спрашивает о каждом таком файле, как раньше.",
"prefs.status_bar": "Строка состояния",
"prefs.status_bar_note": "Тонкая полоса вдоль нижнего края окна показывает, что изменяется, состояние открытого эскиза его цветом и единицы дизайна; щелчок по единицам изменяет их. Состояние эскиза при этом больше не показывается в углу вида.",
"prefs.status_done": "Готово",
@@ -3915,10 +3902,8 @@
"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}. Отметьте команду, чтобы добавить её кнопку на панель инструментов. Можно также перетащить строку из выпадающего меню панели на панель инструментов, перетащить кнопку за пределы панели для удаления или использовать значок закрепления в строке выпадающего меню.",
@@ -4364,8 +4349,6 @@
"ui.cancel": "Отмена",
"ui.canvases": "Подложки",
"ui.center": "Центр",
"ui.center_arc_follows_cursor": "Дуга из центра следует направлению курсора",
"ui.center_arc_follows_cursor_tip": "Дуга из центра идёт по часовой стрелке или против неё — в ту сторону, в которую курсор обходит её центр. Выкл.: всегда против часовой стрелки от начальной точки.",
"ui.centerline": "Осевая линия",
"ui.chain": "Цепочка",
"ui.chamfer": "Фаска",
@@ -5177,13 +5160,11 @@
"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": "Проверка наличия новейшего выпуска…",
+2
View File
@@ -957,6 +957,8 @@
"cmd.inspect_accessibility": "Анализа доступности",
"cmd.inspect_center_of_mass": "Центар масе",
"cmd.inspect_clearance": "Провера зазора",
"cmd.inspect_colored_components": "Омогући боје компоненти",
"cmd.inspect_colored_components_disable": "Онемогући боје компоненти",
"cmd.inspect_compare": "Упореди тела",
"cmd.inspect_compare_picture": "Упореди слику",
"cmd.inspect_continuity": "Анализа континуитета",
+2
View File
@@ -957,6 +957,8 @@
"cmd.inspect_accessibility": "Tillgänglighetsanalys",
"cmd.inspect_center_of_mass": "Masscentrum",
"cmd.inspect_clearance": "Frigång",
"cmd.inspect_colored_components": "Aktivera komponentfärger",
"cmd.inspect_colored_components_disable": "Inaktivera komponentfärger",
"cmd.inspect_compare": "Jämför kroppar",
"cmd.inspect_compare_picture": "Jämför bild",
"cmd.inspect_continuity": "Kontinuitetsanalys",
+2
View File
@@ -957,6 +957,8 @@
"cmd.inspect_accessibility": "Erişilebilirlik analizi",
"cmd.inspect_center_of_mass": "Kütle merkezi",
"cmd.inspect_clearance": "Açıklık",
"cmd.inspect_colored_components": "Bileşen Renklerini Etkinleştir",
"cmd.inspect_colored_components_disable": "Bileşen Renklerini Devre Dışı Bırak",
"cmd.inspect_compare": "Gövdeleri karşılaştır",
"cmd.inspect_compare_picture": "Görüntüyle karşılaştır",
"cmd.inspect_continuity": "Süreklilik kontrolü",
+2
View File
@@ -947,6 +947,8 @@
"cmd.inspect_accessibility": "Аналіз доступності",
"cmd.inspect_center_of_mass": "Центр мас",
"cmd.inspect_clearance": "Зазор",
"cmd.inspect_colored_components": "Увімкнути кольори компонентів",
"cmd.inspect_colored_components_disable": "Вимкнути кольори компонентів",
"cmd.inspect_compare": "Порівняти тіла",
"cmd.inspect_continuity": "Аналіз неперервності",
"cmd.inspect_curvature_comb": "Аналіз гребеня кривизни",
+2 -28
View File
@@ -963,7 +963,8 @@
"cmd.inspect_analysis": "Phân tích khung nhìn",
"cmd.inspect_center_of_mass": "Trọng tâm",
"cmd.inspect_clearance": "Khoảng cách an toàn",
"cmd.inspect_colored_components": "Màu cụm chi tiết",
"cmd.inspect_colored_components": "Bật màu cụm chi tiết",
"cmd.inspect_colored_components_disable": "Tắt màu cụm chi tiết",
"cmd.inspect_compare": "So sánh các thân khối",
"cmd.inspect_compare_picture": "So sánh với ảnh tham chiếu",
"cmd.inspect_continuity": "Phân tích độ liên tục mặt",
@@ -1395,9 +1396,6 @@
"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.",
@@ -2267,14 +2265,6 @@
"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ờ",
@@ -2333,13 +2323,6 @@
"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",
@@ -2667,10 +2650,6 @@
"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ẻ",
@@ -2679,7 +2658,6 @@
"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",
@@ -4963,10 +4941,6 @@
"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.",
+2
View File
@@ -957,6 +957,8 @@
"cmd.inspect_accessibility": "可達性分析",
"cmd.inspect_center_of_mass": "質心",
"cmd.inspect_clearance": "間隙",
"cmd.inspect_colored_components": "啟用元件顏色",
"cmd.inspect_colored_components_disable": "停用元件顏色",
"cmd.inspect_compare": "比較實體",
"cmd.inspect_compare_picture": "與圖片比較",
"cmd.inspect_continuity": "連續性分析",
+2 -28
View File
@@ -963,7 +963,8 @@
"cmd.inspect_analysis": "视图分析",
"cmd.inspect_center_of_mass": "质心",
"cmd.inspect_clearance": "间隙",
"cmd.inspect_colored_components": "组件颜色",
"cmd.inspect_colored_components": "启用组件颜色",
"cmd.inspect_colored_components_disable": "禁用组件颜色",
"cmd.inspect_compare": "比较实体",
"cmd.inspect_compare_picture": "比较参考图",
"cmd.inspect_continuity": "连续性分析",
@@ -1395,9 +1396,6 @@
"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": "请选择实体或特征:草图两者都不是。请用草图自身的阵列或镜像重复其曲线,或先将其拉伸。",
@@ -2267,14 +2265,6 @@
"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": "不显示",
@@ -2333,13 +2323,6 @@
"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": "扩展",
@@ -2667,10 +2650,6 @@
"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": "选项卡颜色",
@@ -2679,7 +2658,6 @@
"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": "粘贴主题",
@@ -4963,10 +4941,6 @@
"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) 截面,反射从面平滑过渡到圆角,没有折痕。",
+1 -3
View File
@@ -802,9 +802,7 @@ 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);
// 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); }
if (len < 1e-6) { dir = vec2<f32>(1.0, 0.0); } else { dir = dir / 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);
@@ -1 +0,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 |
+2 -25
View File
@@ -393,8 +393,6 @@ pub(crate) struct ExactSketchCurves<'a> {
/// Each analytic curve's id, whether it is projected (linked) reference geometry, and its
/// segments.
segments: Vec<(&'a str, bool, Vec<Seg2>)>,
/// Each free-form curve's id (ellipses, conics, splines) and its exact segments.
free_form: Vec<(&'a str, Vec<Seg2>)>,
}
impl<'a> ExactSketchCurves<'a> {
@@ -402,17 +400,10 @@ impl<'a> ExactSketchCurves<'a> {
/// and segment allocation must not be repeated for every generated face.
pub(crate) fn new(sketch: &'a SolvedSketch) -> Self {
let mut segments = Vec::new();
let mut free_form = Vec::new();
if sketch.sketch.curves.len() > 2000 {
return Self { segments, free_form };
return Self { segments };
}
for (index, curve) in sketch.sketch.curves.iter().enumerate() {
if !curve.construction
&& let Some(exact) = sketch.sketch.exact_segs(index)
{
free_form.push((curve.id.as_str(), exact));
continue;
}
if curve.construction
|| !matches!(
curve.kind,
@@ -434,21 +425,7 @@ impl<'a> ExactSketchCurves<'a> {
};
segments.push((curve.id.as_str(), curve.link.is_some(), originals));
}
Self { segments, free_form }
}
/// The one free-form sketch curve (an ellipse, conic or spline) a generated segment is a
/// whole piece of, either way round: one of the exact segments the curve is made of. A
/// segment two curves share stays ambiguous.
pub(crate) fn find_free_form(&self, generated: &Seg2) -> Option<String> {
let reversed = generated.reversed();
let mut found: Option<Option<&str>> = None;
for (id, originals) in &self.free_form {
if originals.iter().any(|original| *original == *generated || *original == reversed) {
found = Some(if found.is_some() { None } else { Some(*id) });
}
}
found.flatten().map(str::to_owned)
Self { segments }
}
/// The one sketch curve a generated segment lies on. A curve drawn over projected
-36
View File
@@ -734,10 +734,6 @@ 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")]
@@ -1975,38 +1971,6 @@ 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")]
+10 -23
View File
@@ -409,14 +409,13 @@ 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, turn) {
match kernel::fillet_removing(body, edges, r) {
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, turn)))
Err(with_largest_fit(why, "radius", || kernel::fillet_max(body, edges, r)))
}
Err(kernel::KernelError::TooLarge(why)) if fit == crate::FilletFit::Reduce => match kernel::fillet_max_each(body, edges, r, turn)? {
Err(kernel::KernelError::TooLarge(why)) if fit == crate::FilletFit::Reduce => match kernel::fillet_max_each(body, edges, r)? {
// 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)),
@@ -4015,11 +4014,6 @@ 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
@@ -4067,8 +4061,8 @@ fn blend_body(
tapered = edges.iter().copied().zip(tapers).filter_map(|(p, t)| Some((p, t?))).collect();
(b, input)
}
FeatureKind::Fillet { fit, turn, .. } => {
let (b, u, gone) = fillet_fitted(&mb.body, edges, r, *fit, turn.kernel())?;
FeatureKind::Fillet { fit, .. } => {
let (b, u, gone) = fillet_fitted(&mb.body, edges, r, *fit)?;
if let Some(u) = &u {
append_warning(warning, reduced_note(u, r));
}
@@ -4826,13 +4820,8 @@ fn centered_extrude(
};
// Fit cap surfaces in their own coordinates. Fitting a distant world-space
// wire can tilt an otherwise exact planar cap through numeric cancellation.
// A side drawn in collinear pieces is one face (#1788), and so is a side along one ellipse
// or spline, whatever pieces the profile holds it in.
let curves = sketch.map(crate::config::ExactSketchCurves::new);
let one_curve = |s: &solidcore_record::Seg2, t: &solidcore_record::Seg2| {
curves.as_ref().is_some_and(|c| c.find_free_form(s).is_some_and(|id| c.find_free_form(t).is_some_and(|other| other == id)))
};
let bodies = kernel::extrude_whole_sides(&Plane::XY, std::slice::from_ref(region), start, end, &one_curve)?;
// A side drawn in collinear pieces is one face (#1788).
let bodies = kernel::extrude_whole_sides(&Plane::XY, std::slice::from_ref(region), start, end)?;
if *plane == Plane::XY {
return Ok(bodies.into_iter().map(|body| roles.tool(body, None)).collect());
}
@@ -6329,12 +6318,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, turn, .. } => match st.fitted.get(&src.id) {
FeatureKind::Fillet { fit, .. } => 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, turn.kernel())?
kernel::fillet_each(&mb.body, &moved, &used)?
}
None => fillet_fitted(&mb.body, &moved, r, *fit, turn.kernel())?.0,
None => fillet_fitted(&mb.body, &moved, r, *fit)?.0,
},
_ => kernel::fillet(&mb.body, &moved, r)?,
};
@@ -8556,7 +8545,6 @@ mod chain_tests {
fit: Default::default(),
corner: Default::default(),
setbacks: Vec::new(),
turn: Default::default(),
tangent_chain: true,
},
None,
@@ -8585,7 +8573,6 @@ mod chain_tests {
fit: Default::default(),
corner: Default::default(),
setbacks: Vec::new(),
turn: Default::default(),
tangent_chain,
}
}
+3 -8
View File
@@ -2318,16 +2318,11 @@ 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, _) => "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)"
(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"
.to_string(),
(_, 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"
),
(_, k) => format!("pick the rim face of a shelled body (round one opening): this face has {k} openings alike in size"),
};
return Err(DocError::Invalid(what));
};
+22 -208
View File
@@ -103,11 +103,6 @@ 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,
}
@@ -126,9 +121,6 @@ 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
@@ -168,22 +160,6 @@ 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.
@@ -357,23 +333,11 @@ 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)? {
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 }
End::Closed
} else if (if side == 0 { at - lo } else { hi - at }) < pitch * 1e-3 {
End::Free
} else {
@@ -409,7 +373,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, shoulder_unchecked: unchecked })
Ok(ThreadOn { info, cyl, ends, steep: ask.steep, ease, from: side, narrow })
}
/// The end of the face (0: its start, 1: its end) a thread of limited length starts at: the end
@@ -572,9 +536,7 @@ 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. 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).
// face it stops square at the threaded length's end.
//
// 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
@@ -586,14 +548,12 @@ 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) {
@@ -738,24 +698,6 @@ 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<()> {
@@ -826,13 +768,7 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
None => note,
});
}
if let Some(note) = cut(st, on, (*tip, angle, depth))? {
*warning = Some(match warning.take() {
Some(w) => format!("{w}; {note}"),
None => note,
});
}
Ok(())
cut(st, on, (*tip, angle, depth))
}
/// A runout as (mm, pitches): ISO 3508's standard 2.5 P and short 1.25 P, or a length.
@@ -847,10 +783,9 @@ 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). 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;
/// (`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;
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);
@@ -887,46 +822,21 @@ pub(super) fn cut(st: &mut ModelState, on: ThreadOn, (tip, angle, depth): (Threa
}
false => None,
};
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, &section, 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()))
};
// 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 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, &section, g.base, g.axis, g.pitch, g.turns, &g.law)?);
}
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?;
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 (nb, groove_faces) = (kernel::boolean(&input.body, &tool, BoolOp::Cut)?, tool.face_count());
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);
@@ -944,7 +854,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(note)
Ok(())
}
/// The chamfered tip of an external thread (ISO 4753, chamfered end CH) at the free ends `tip`
@@ -1027,99 +937,3 @@ 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}");
}
}
@@ -1,5 +0,0 @@
//! 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).
+5 -17
View File
@@ -239,10 +239,7 @@ impl<'a> ProfileRoles<'a> {
// Every curve the segment is drawn on (one, or several coincident or collinear ones).
let curve = |segment: usize| -> Vec<String> {
let (Some(curves), Some(segment)) = (curves.as_ref(), segments.get(segment)) else { return Vec::new() };
let mut drawn: Vec<String> = curves.find_all_along(segment).into_iter().map(|id| format!("side:{id}")).collect();
if drawn.is_empty() {
drawn.extend(curves.find_free_form(segment).map(|id| format!("side:{id}")));
}
let drawn: Vec<String> = curves.find_all_along(segment).into_iter().map(|id| format!("side:{id}")).collect();
match self.thin {
// A thin wall's side beside its curve (the other side may lie on it).
Some(thin) if drawn.is_empty() => curves.find_offset(segment, &thin.curves, thin.distances),
@@ -757,19 +754,10 @@ 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();
// 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;
for ((name, feature), (_, i)) in names.into_iter().zip(keys) {
if let Some(b) = bodies.get_mut(i) {
b.name = name;
b.feature = feature;
}
}
}
-94
View File
@@ -295,7 +295,6 @@ fn box_fillet_cut_timeline() {
fit: Default::default(),
corner: Default::default(),
setbacks: Vec::new(),
turn: Default::default(),
tangent_chain: true,
},
None,
@@ -371,7 +370,6 @@ fn errors_are_reported_not_fatal() {
fit: Default::default(),
corner: Default::default(),
setbacks: Vec::new(),
turn: Default::default(),
tangent_chain: true,
},
None,
@@ -869,39 +867,12 @@ 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":[]});
@@ -1085,68 +1056,3 @@ fn feature_kind_key_and_type_name_mappings() {
assert_eq!(FeatureKind::type_name_to_kind_key("MeshFeature"), Some("mesh_import"));
assert_eq!(FeatureKind::type_name_to_kind_key("NonExistent"), None);
}
/// An extruded ellipse has one side face, and a spline in a profile one wall, each named by its
/// sketch curve alone (no piece number): the same names on a second build and after reopening.
#[test]
fn an_extruded_ellipse_or_spline_is_one_wall_named_by_its_curve() {
let extrude = |sketch: u64| FeatureKind::Extrude {
thin: None,
sketch: crate::SketchId(sketch),
profiles: ProfileSel::All,
extent: Extent {
convention: crate::ExtrudeConvention::LegacyPerSide,
distance: "20".into(),
direction: Direction::Positive,
distance2: None,
start_offset: None,
through_all: false,
taper: None,
corner: Default::default(),
to: None,
to_offset: None,
from: None,
to_body_apart: false,
},
operation: Operation::NewBody,
targets: vec![],
};
for spline in [false, true] {
let mut sk = Sketch::new();
let curve = if spline {
let pts: Vec<usize> =
[(0., 0.), (10., -3.), (20., 4.), (30., 0.)].iter().map(|&(x, y)| sk.add_point(Vec2::new(x, y), None).unwrap()).collect();
let (a, b) = (sk.add_point(Vec2::new(30., 20.), None).unwrap(), sk.add_point(Vec2::new(0., 20.), None).unwrap());
let curve = sk
.add_curve(solidcraft_sketch::CurveKind::Spline { pts: pts.clone(), control: false, degree: 3, periodic: false, nurbs: None }, None)
.unwrap();
sk.add_line_pts(pts[3], a, None).unwrap();
sk.add_line_pts(a, b, None).unwrap();
sk.add_line_pts(b, pts[0], None).unwrap();
curve
} else {
let m = sk.add_point(Vec2::new(10., 0.), None).unwrap();
sk.add_curve(solidcraft_sketch::CurveKind::Ellipse { c: 0, m, r: 5. }, None).unwrap()
};
let id = sk.curves[curve].id.clone();
let mut doc = Document::new("one wall");
let s = doc.add_feature(FeatureKind::Sketch { plane: PlaneRef::Origin { name: "XY".into() }, sketch: sk }, None).unwrap();
let f = doc.add_feature(extrude(s), None).unwrap();
let names = |doc: &Document| {
let mut m = Model::new();
m.evaluate(doc);
assert!(m.results.iter().all(|r| r.error.is_none()), "{:?}", m.results);
let st = m.state();
let mut names = crate::naming::face_names(&st.bodies[0]).as_ref().clone();
names.sort();
names
};
let first = names(&doc);
let wall = format!("F{f}:side:{id}");
assert_eq!(first.iter().filter(|n| **n == wall).count(), 1, "{first:?}");
assert_eq!(first.len(), if spline { 6 } else { 3 }, "{first:?}");
assert!(first.iter().all(|n| !n.contains('#')), "no piece names: {first:?}");
assert_eq!(names(&doc), first, "a second build");
assert_eq!(names(&Document::from_json(&doc.to_json()).unwrap()), first, "reopened");
}
}
-1
View File
@@ -54,7 +54,6 @@ 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 {}
+36 -53
View File
@@ -9,7 +9,7 @@
use std::collections::{BTreeMap, HashMap};
use std::sync::Arc;
use serde_json::Value;
use serde_json::{Value, json};
use solidcraft_geom::mesh_distance::box_gap;
use solidcraft_geom::prelude::*;
use solidcraft_geom::{Aabb3, Mesh};
@@ -17,20 +17,9 @@ 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};
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(
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("inspect.clearance", "Clearance", clearance).icon("interference").noundo().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) | \
@@ -45,6 +34,10 @@ 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 {
@@ -268,56 +261,48 @@ 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<Unchecked> = mesh_bodies
let mut unchecked: Vec<Value> = mesh_bodies
.iter()
.filter(|b| p.get("bodies").is_none_or(Value::is_null) || asked.contains(&b.name))
.map(|b| Unchecked { a: b.name.clone(), b: None, reason: super::interference::MESH_NOT_CHECKED.to_string() })
.map(|b| json!({"a": b.name, "b": null, "reason": super::interference::MESH_NOT_CHECKED}))
.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(pair(i, j, e.to_string()));
unchecked.push(json!({"a": name(i), "b": name(j), "reason": e.to_string()}));
continue;
}
};
if let Some(reason) = r.unchecked {
unchecked.push(pair(i, j, reason));
unchecked.push(json!({"a": name(i), "b": name(j), "reason": reason}));
continue;
}
if let Some((volume, area, bbox, center)) = r.overlap
&& volume >= min_volume
{
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(),
});
overlaps.push(json!({"a": name(i), "b": name(j), "volume_mm3": volume, "area_mm2": area,
"bbox": {"min": bbox.min, "max": bbox.max}, "center": center}));
known.insert((i, j), 0.0);
continue;
}
let Some(d) = r.distance else { continue };
known.insert((i, j), d.distance);
if d.distance < gap {
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),
});
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.sort_by(|x, y| x.distance_mm.total_cmp(&y.distance_mm));
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)));
let mut nearest_out = BTreeMap::new();
let mut nearest_out = serde_json::Map::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)| {
@@ -326,25 +311,23 @@ fn clearance(s: &mut Session, p: &Value) -> Result<Value> {
})
});
for (i, list) in lists {
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);
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 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()))
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,
}))
}
/// One pair: its overlap when the meshes meet or one holds the other, otherwise its distance
+1 -1
View File
@@ -60,7 +60,7 @@ mod occurrence_tests;
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("component.cycle_color", "Cycle Component Color", cycle_color)
.icon("colored_components").doc_only()
.params("component: id or name; saves the next identification colour and turns Component Colors on; shared by every instance, separate from appearances"),
.params("component: id or name; saves the next identification colour and enables Component Colors; shared by every instance, separate from appearances"),
CommandSpec::new("component.hide_history", "Hide Component History", hide_history)
.icon("eye").doc_only()
.params("component: id or name; keeps the component's features in the design but leaves them out of the History bar (#10393)"),
-77
View File
@@ -331,11 +331,6 @@ 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
@@ -748,78 +743,6 @@ 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", &params).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", &params).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();
+7 -101
View File
@@ -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::{
AppliedRadius, BoundingSolidParams, BoxParams, ConeParams, CylinderParams, ExtrudeParams, FeatureNotice, FeatureResult, FilletParams,
FilletResult, NoticeAction, OffsetPlaneParams, PipeParams, PlaneAtAngleParams, ScaleParams, SphereParams, TorusParams,
BoundingSolidParams, BoxParams, ConeParams, CylinderParams, ExtrudeParams, FeatureNotice, FeatureResult, NoticeAction, OffsetPlaneParams,
PipeParams, PlaneAtAngleParams, ScaleParams, SphereParams, TorusParams,
};
use crate::schema::{TypedContract, output_schema_for};
use crate::{EngineError, Result, Session};
@@ -30,7 +30,6 @@ 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))
@@ -79,13 +78,11 @@ 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", crate::checked!("solid.fillet", fillet))
CommandSpec::new("solid.fillet", "Fillet", fillet)
.at("SOLID", "MODIFY")
.icon("fillet")
.key("F")
.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)"),
.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)"),
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)
@@ -1791,15 +1788,6 @@ 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,
@@ -1811,7 +1799,6 @@ fn fillet(s: &mut Session, p: &Value) -> Result<Value> {
fit,
corner,
setbacks,
turn,
tangent_chain: bool_(p, "tangent_chain").unwrap_or(true),
},
)?;
@@ -1821,9 +1808,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<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()))?;
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);
}
Ok(out)
}
@@ -3580,87 +3567,6 @@ 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"]
+1 -1
View File
@@ -27,7 +27,7 @@ mod appearance_chain;
pub(super) mod appearance_gestures;
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("inspect.colored_components", "Component Colors", colored_components)
CommandSpec::new("inspect.colored_components", "Enable Component Colors", colored_components)
.at("SOLID", "INSPECT").icon("colored_components").noundo()
.params("enabled?: bool (omitted: toggle); temporary component colours, without changing appearances"),
CommandSpec::new("inspect.measure", "Measure", measure)
@@ -408,24 +408,3 @@ 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));
}
+5 -16
View File
@@ -1,24 +1,15 @@
//! 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;
use serde_json::{Value, json};
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))
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("material.properties.validate", "Validate Physical Material Data", 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", crate::checked!("material.properties.directional", directional)).icon("material").noundo().doc_only()
.contract(&DIRECTIONAL_CONTRACT)
.output(output_schema_for::<CardModulus>)
CommandSpec::new("material.properties.directional", "Directional Elastic Modulus", directional).icon("material").noundo().doc_only()
.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> {
@@ -37,7 +28,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(CardModulus::from(response)).map_err(|e| bad(cmd, e.to_string()))
serde_json::to_value(response).map_err(|e| bad(cmd, e.to_string()))
}
fn validate(_: &mut Session, params: &Value) -> Result<Value> {
@@ -47,15 +38,13 @@ 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()))?;
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()))
Ok(json!({"valid":true,"validation":result}))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::doc::Document;
use serde_json::json;
use std::sync::Arc;
fn card() -> Value {
+6 -2
View File
@@ -260,12 +260,16 @@ impl CommandSpec {
}
pub fn info(&self, s: &Session) -> CommandInfo {
let e = (self.enabled)(s);
let (label, icon) = match self.id {
"inspect.colored_components" if s.colored_components.is_some() => ("Disable Component Colors", "colored_components_off"),
_ => (self.label, self.icon),
};
CommandInfo {
id: self.id,
label: self.label,
label,
tab: self.tab,
panel: self.panel,
icon: self.icon,
icon,
shortcut: self.shortcut,
params: self.params,
enabled: e.is_ok(),
+1 -1
View File
@@ -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("one clear opening") && e.contains("2 openings alike"), "{e}");
assert!(e.contains("rim face") && 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"}));
@@ -98,53 +98,8 @@ fn a_trays_faces_have_readable_sheet_names_that_follow_its_size() {
assert_eq!(again, names);
let right = point(&s, &format!("sheet:Body1:F{sd}.1:top"));
assert!((right.x - 399.0).abs() < 1e-3, "{right:?}");
let rim = format!("sheet:Body1:F{fb}.1:rim.far");
let far = point(&s, &rim);
let st = s.model.state();
let hit = solidcraft_doc::naming::locate_one(&st, None, &rim).unwrap();
on_planar_face(&st.bodies[hit.body].body, hit.face, far);
assert!((far.z - 30.0).abs() < 1e-9 && (199.0..=200.0).contains(&far.y), "the back flange's far rim: {far:?}");
}
/// `p` lies on face `face` of `body`, on its exact geometry: within rounding at the part's scale
/// of the plane the face lies on, and inside the boundary its edges trace there (each a line,
/// counted by crossings).
fn on_planar_face(body: &solidcraft_kernel::Body, face: usize, p: Vec3) {
use solidcore_record::brep::{Curve, Loop};
let rounding = 1e-12 * body.size().max(1.0);
let surface = solidcraft_kernel::face_surface(body, face).unwrap();
let on = surface.nearest(p).unwrap();
assert!(on.dist(p) <= rounding, "{p:?} is {} off face {face}'s surface", on.dist(p));
let solidcraft_kernel::FaceSurface::Plane { normal, .. } = surface else { panic!("face {face} is not planar: {surface:?}") };
// Coordinates in the plane: along `x` and `y`, square to its normal.
let x = if normal.cross(Vec3::X).len() > 0.5 { normal.cross(Vec3::X).normalized().unwrap() } else { normal.cross(Vec3::Y).normalized().unwrap() };
let y = normal.cross(x);
let local = |q: Vec3| ((q - p).dot(x), (q - p).dot(y));
let record = body.record();
let point = |v: u32| {
let q = record.vertices[v as usize].point;
Vec3::new(q[0], q[1], q[2])
};
// Crossings of the ray from `p` along +x with the face's boundary.
let mut crossings = 0;
for l in &record.faces[face].loops {
let Loop::Edges { edges } = l else { continue };
for e in edges {
let edge = &record.edges[e.edge as usize];
assert!(matches!(edge.parameters(), Curve::Line { .. }), "an edge of face {face} is not a line: {:?}", edge.parameters());
let [a, b] = edge.vertices.map(|v| local(point(v)));
let distance = {
let (dx, dy) = (b.0 - a.0, b.1 - a.1);
let t = (-(a.0 * dx + a.1 * dy) / (dx * dx + dy * dy)).clamp(0.0, 1.0);
(a.0 + t * dx).hypot(a.1 + t * dy)
};
assert!(distance > rounding, "{p:?} is on the boundary of face {face}");
if (a.1 > 0.0) != (b.1 > 0.0) && a.0 + (b.0 - a.0) * (0.0 - a.1) / (b.1 - a.1) > 0.0 {
crossings += 1;
}
}
}
assert_eq!(crossings % 2, 1, "{p:?} is outside face {face}'s boundary");
let far = point(&s, &format!("sheet:Body1:F{fb}.1:rim.far"));
assert!((199.0 - 1e-3..=200.0 + 1e-3).contains(&far.y) && (0.0..=400.0).contains(&far.x) && (far.z - 30.0).abs() < 1e-3, "{far:?}");
}
#[test]
+2 -3
View File
@@ -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.
pub(super) const CONNECT_TOL: f64 = 1e-6;
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,8 +1171,7 @@ 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 };
// Ends closer than this would both be connected to one point (#11001).
if a.pos().dist(b.pos()) < CONNECT_TOL {
if a.pos().dist(b.pos()) < 1e-9 {
return Err(bad(cmd, "zero-length line"));
}
let id = ids.get(i).map(String::as_str);
+1 -5
View File
@@ -6,7 +6,7 @@ use solidcore_record::{Seg2, Vec2};
use solidcraft_geom::prelude::*;
use solidcraft_sketch::{ConstraintKind, CurveKind, Sketch};
use super::sketch::{CONNECT_TOL, add_c, curve_ref, edit, ids_of, line_or_axis_ref, line_pts, req_parg, result};
use super::sketch::{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,10 +261,6 @@ 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" {
@@ -326,25 +326,6 @@ 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();
+1 -60
View File
@@ -3538,76 +3538,17 @@ 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, doc| {
let (out, info) = edit(s, p, cmd, false, |sk, _| {
let (cs, ps) = entities(sk, p, cmd)?;
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(&param.expr).is_empty() {
format!("{} mm", doc.eval(name, solidcraft_doc::expr::Kind::Length)? * factor)
} else {
let formula = param.expr.trim().strip_prefix('=').unwrap_or(&param.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"))?;
@@ -1194,65 +1194,6 @@ 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.
+1 -8
View File
@@ -10,19 +10,12 @@ 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", crate::checked!("surface.match", match_surface))
CommandSpec::new("surface.match", "Match Surface", 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",
),
@@ -280,41 +280,3 @@ 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);
}
-17
View File
@@ -1961,23 +1961,6 @@ 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()));
}
-38
View File
@@ -517,7 +517,6 @@ 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]
@@ -546,43 +545,6 @@ 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`
-34
View File
@@ -264,40 +264,6 @@ 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();
-14
View File
@@ -466,19 +466,6 @@ 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");
@@ -603,7 +590,6 @@ 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());
-197
View File
@@ -1,197 +0,0 @@
//! 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;
@@ -1,62 +0,0 @@
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);
}
+3 -90
View File
@@ -10,10 +10,9 @@ use serde::{Deserialize, Serialize};
use serde_json::Value;
use super::types::{
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,
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,
};
/// Add a box primitive.
@@ -433,92 +432,6 @@ 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 {
+2 -74
View File
@@ -1,8 +1,8 @@
use serde_json::{Value, json};
use super::{
BoundingSolidParams, BoxParams, ConeParams, CylinderParams, ExtrudeParams, FeatureResult, FilletParams, FilletResult, OffsetPlaneParams,
PipeParams, PlaneAtAngleParams, ScaleParams, SphereParams, TorusParams,
BoundingSolidParams, BoxParams, ConeParams, CylinderParams, ExtrudeParams, FeatureResult, OffsetPlaneParams, PipeParams, PlaneAtAngleParams,
ScaleParams, SphereParams, TorusParams,
};
use crate::Session;
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms, sketch_rectangle};
@@ -87,30 +87,6 @@ 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]
@@ -181,13 +157,6 @@ 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})),
]);
}
@@ -244,47 +213,6 @@ 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.
@@ -1,184 +0,0 @@
//! 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;
@@ -1,58 +0,0 @@
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:?}");
}
}
-3
View File
@@ -57,7 +57,6 @@ mod tests;
pub mod appearance;
pub mod bom;
pub mod browser;
pub mod clearance;
pub mod component;
pub mod config;
pub mod construct;
@@ -69,7 +68,6 @@ pub mod inspect;
pub mod joints;
pub mod libraries;
pub mod mass_properties;
pub mod material_properties;
pub mod motion;
pub mod panels;
pub mod print_face;
@@ -86,7 +84,6 @@ 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;
-165
View File
@@ -1,165 +0,0 @@
//! Contract for `surface.match` in `cmd/surface_match.rs` (#10981). It returns `add_feature`'s
//! reply, a [`super::features::FeatureResult`].
use std::borrow::Cow;
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
use serde::de::Error as _;
use serde::{Deserialize, Deserializer};
use serde_json::Value;
use solidcore_record::Vec3;
use solidcraft_kernel::{Construction, Continuity};
use super::check::whole;
use super::types::{Point3, StringList, present};
use crate::params;
/// Rebuild one face of a surface body so that its boundary nearest a picked edge chain of another
/// body lies on that chain and repeats that body's shape there up to `order`, while the opposite
/// boundary keeps its place. The body keeps its name: a match changes it rather than making one.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct MatchSurfaceParams {
/// The surface body whose face is rebuilt, by name.
#[serde(deserialize_with = "body")]
pub body: String,
#[serde(default)]
pub faces: Option<MatchFaces>,
/// The body the seam must meet, by name.
#[serde(deserialize_with = "target")]
pub target: String,
pub edges: MatchEdges,
/// How closely the seam follows the target: `g1` (tangent), `g2` (curvature, the default) or
/// `g3`, in any case.
#[serde(default, deserialize_with = "present")]
#[schemars(with = "MatchOrder")]
pub order: Option<MatchOrder>,
// Not `FeatureCommon`: a match makes no body, so `body_name` and `body_names` would do
// nothing. `participants` stays because `timeline.redefine` passes a feature's own back.
/// The feature's name in the timeline.
#[serde(default)]
pub name: Option<String>,
/// The bodies taking part, which every feature records; each an existing body or `*`. A match
/// changes only `body`, whatever this lists.
#[serde(default)]
pub participants: Option<StringList>,
}
/// A body name, with the handler's message for anything that isn't text.
fn body<'de, D: Deserializer<'de>>(d: D) -> Result<String, D::Error> {
match Value::deserialize(d)? {
Value::String(s) => Ok(s),
_ => Err(D::Error::custom(whole("`body` is required"))),
}
}
/// The target's name, with the handler's message for anything that isn't text.
fn target<'de, D: Deserializer<'de>>(d: D) -> Result<String, D::Error> {
match Value::deserialize(d)? {
Value::String(s) => Ok(s),
_ => Err(D::Error::custom(whole("`target` is required"))),
}
}
/// Points `params::vec3` reads, or none if any isn't one.
fn points(v: &[Value]) -> Option<Vec<Vec3>> {
v.iter().map(params::vec3).collect()
}
/// Points on the face to rebuild, at most 64 (default: the body's first face).
#[derive(Clone, Debug, PartialEq)]
pub struct MatchFaces(pub Vec<Vec3>);
impl<'de> Deserialize<'de> for MatchFaces {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let faces =
Value::deserialize(d)?.as_array().and_then(|a| points(a)).ok_or_else(|| D::Error::custom("must be a list of points [x, y, z]"))?;
if faces.len() > 64 {
return Err(D::Error::custom(whole("too many points on the face to rebuild")));
}
Ok(MatchFaces(faces))
}
}
impl JsonSchema for MatchFaces {
fn schema_name() -> Cow<'static, str> {
"MatchFaces".into()
}
fn inline_schema() -> bool {
true
}
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
let point = generator.subschema_for::<Point3>();
json_schema!({
"description": "Points on the face to rebuild, at most 64 (default: the body's first face).",
"type": "array",
"items": point,
"maxItems": 64
})
}
}
/// Points on one edge chain of the target: the rebuilt boundary takes its place. 1 to 10 000,
/// with the handler's message for anything else.
#[derive(Clone, Debug, PartialEq)]
pub struct MatchEdges(pub Vec<Vec3>);
impl<'de> Deserialize<'de> for MatchEdges {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
Value::deserialize(d)?
.as_array()
.and_then(|a| points(a))
.filter(|edges| !edges.is_empty() && edges.len() <= 10_000)
.map(MatchEdges)
.ok_or_else(|| D::Error::custom(whole("`edges` must list 1 to 10000 points on the target's edge chain")))
}
}
impl JsonSchema for MatchEdges {
fn schema_name() -> Cow<'static, str> {
"MatchEdges".into()
}
fn inline_schema() -> bool {
true
}
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
let point = generator.subschema_for::<Point3>();
json_schema!({
"description": "Points on one edge chain of the target: the rebuilt boundary takes its place.",
"type": "array",
"items": point,
"minItems": 1,
"maxItems": 10000
})
}
}
/// A match's continuity, as `params::continuity` reads it for a match (g1 to g3), with its messages.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct MatchOrder(pub Continuity);
impl<'de> Deserialize<'de> for MatchOrder {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let v = Value::deserialize(d)?;
params::continuity("surface.match", Some(&v), "`order`", Construction::Match).map(MatchOrder).map_err(|e| match e {
crate::EngineError::BadParams { msg, .. } => D::Error::custom(whole(msg)),
other => D::Error::custom(whole(other.to_string())),
})
}
}
impl JsonSchema for MatchOrder {
fn schema_name() -> Cow<'static, str> {
"MatchOrder".into()
}
fn inline_schema() -> bool {
true
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({"type": "string", "pattern": "^[gG][123]$"})
}
}
#[cfg(test)]
#[path = "surface_match_tests.rs"]
mod tests;
@@ -1,121 +0,0 @@
use serde_json::{Value, json};
use solidcore_record::Vec3;
use super::MatchSurfaceParams;
use crate::Session;
use crate::schema::features::FeatureResult;
use crate::schema::tests::{assert_keys, 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}"))
}
/// Two points on the cylinder's straight vertical wall seam, as the command's own tests pick it.
fn picks(s: &Session) -> Vec<Vec3> {
let boss = s.world_state().ok().and_then(|st| st.body("Boss").map(|b| b.body.clone())).unwrap_or_else(|| panic!("no Boss"));
let e = boss
.edges(boss.size() * 1e-2)
.unwrap_or_default()
.into_iter()
.find(|e| e.length > 25.0 && (e.mid.x.hypot(e.mid.y) - 10.0).abs() < 0.05 && e.mid.z > 5.0 && e.mid.z < 25.0)
.unwrap_or_else(|| panic!("the cylinder has a straight vertical wall seam"));
let (a, b) = (e.points.first().copied().unwrap_or(e.mid), e.points.last().copied().unwrap_or(e.mid));
vec![a + (b - a) * 0.25, a + (b - a) * 0.75]
}
/// A cylinder `Boss` of radius 10, 30 high, and a cylindrical surface `Skirt` of radius 11 from
/// the cylinder's wall seam round 60 degrees, as in the command's own tests: a point on the skirt.
fn boss_and_skirt(s: &mut Session) -> [f64; 3] {
run(s, "sketch.create", json!({"plane": "XY", "name": "Round"}));
run(s, "sketch.circle.center", json!({"center": [0, 0], "radius": 10}));
run(s, "sketch.finish", json!({}));
run(s, "solid.extrude", json!({"sketch": "Round", "distance": 30, "body_name": "Boss"}));
let seam = picks(s)[0];
let a = seam.y.atan2(seam.x);
let at = |t: f64| json!([11.0 * t.cos(), 11.0 * t.sin()]);
run(s, "sketch.create", json!({"plane": "XY", "name": "Skirt sketch"}));
let arc = run(s, "sketch.arc.three_point", json!({"start": at(a), "end": at(a + 60f64.to_radians()), "through": at(a + 30f64.to_radians())}))
["created"]["curves"][0]
.clone();
run(s, "sketch.finish", json!({}));
run(s, "surface.extrude", json!({"sketch": "Skirt sketch", "curves": [arc], "distance": 30, "body_name": "Skirt"}));
let mid = a + 30f64.to_radians();
[11.0 * mid.cos(), 11.0 * mid.sin(), 15.0]
}
#[test]
fn schemas_and_the_key_check_agree() {
assert_keys(input_schema_for::<MatchSurfaceParams>(), &["body", "edges", "faces", "name", "order", "participants", "target"]);
}
/// A call that fails today gets the same message in strict. The handler looks both bodies up
/// before it reads the picks, so each call runs on a design with two (a missing `edges` is left
/// out: strict says it is required, the handler what it must list).
#[test]
fn strict_keeps_todays_errors() {
let many = vec![[0, 0, 0]; 10_001];
let faces = vec![[0, 0, 0]; 65];
let e = json!([[0, 0, 0]]);
for p in [
json!({"target": "T", "edges": e}),
json!({"body": 5, "target": "T", "edges": e}),
json!({"body": "Nope", "target": "T", "edges": e}),
json!({"body": "B", "edges": e}),
json!({"body": "B", "target": [], "edges": e}),
json!({"body": "B", "target": "Nope", "edges": e}),
json!({"body": "B", "target": "T", "faces": faces, "edges": e}),
json!({"body": "B", "target": "T", "edges": []}),
json!({"body": "B", "target": "T", "edges": null}),
json!({"body": "B", "target": "T", "edges": 5}),
json!({"body": "B", "target": "T", "edges": [[1, 2]]}),
json!({"body": "B", "target": "T", "edges": many}),
json!({"body": "B", "target": "T", "edges": e, "order": "g5"}),
json!({"body": "B", "target": "T", "edges": e, "order": "g0"}),
json!({"body": "B", "target": "T", "edges": e, "order": null}),
json!({"body": "B", "target": "T", "edges": e, "order": 2}),
json!({"body": "B", "target": "T", "edges": e}),
] {
let error = |level: ParamCheck| {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 4, "width": 4, "height": 4, "body_name": "B"}));
run(&mut s, "solid.box", json!({"length": 4, "width": 4, "height": 4, "corner": [10, 0, 0], "body_name": "T"}));
s.param_check = level;
s.execute("surface.match", &p).err().map(|e| e.to_string())
};
let off = error(ParamCheck::Off);
assert!(off.is_some(), "{p} should fail today");
assert_eq!(error(ParamCheck::Strict), off, "{p}");
}
}
/// #10981: a match replies with `FeatureResult`, in Warn without warnings for good calls (the
/// body's first face, and a picked face with every other key) and with them for a misspelt key;
/// strict refuses a face point the handler would drop.
#[test]
fn a_match_replies_with_the_feature_result() {
let schema = output_schema_for::<FeatureResult>();
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() };
let on_skirt = boss_and_skirt(&mut s);
let chain = picks(&s);
let r = run(&mut s, "surface.match", json!({"body": "Skirt", "target": "Boss", "edges": chain, "order": "G2"}));
matches(&r, false);
assert_eq!(r["changed_bodies"], json!(["Skirt"]), "{r}");
let r = run(
&mut s,
"surface.match",
json!({"body": "Skirt", "faces": [on_skirt], "target": "Boss", "edges": chain, "order": "g1", "name": "Seam", "participants": ["*"]}),
);
matches(&r, false);
assert_eq!(r["name"], "Seam", "{r}");
matches(&run(&mut s, "surface.match", json!({"body": "Skirt", "target": "Boss", "edges": chain, "ordr": "g3"})), true);
s.param_check = ParamCheck::Strict;
let error = s.execute("surface.match", &json!({"body": "Skirt", "faces": [on_skirt, [1, 2]], "target": "Boss", "edges": chain}));
let error = error.err().map(|e| e.to_string());
assert!(error.as_deref().is_some_and(|e| e.contains("`faces` must be a list of points")), "{error:?}");
}
+1 -1
View File
@@ -367,7 +367,7 @@ fn issues_are_read_from_the_commands_own_contract() {
assert_eq!(messages(&issues), ["unknown key `operaton`; did you mean `operation`?"]);
assert_eq!(issues, box_contract().check(&json!({"length": 1, "width": 1, "height": 1, "operaton": "cut"})));
// No contract, or no such command: nothing to say.
assert!(super::issues("solid.chamfer", &json!({"nonsense": 1})).is_empty());
assert!(super::issues("solid.fillet", &json!({"nonsense": 1})).is_empty());
assert!(super::issues("no.such", &json!({"nonsense": 1})).is_empty());
}
-60
View File
@@ -464,66 +464,6 @@ fn enum_schema(values: &[&str], description: &str) -> Schema {
json_schema!({"description": description, "type": "string", "enum": values})
}
/// A keyword a handler reads with `str_` and lowercases, refusing any other word with `unknown
/// <what> `<word>` (<expected>)`; the lowercased word is kept for the handler.
macro_rules! keyword_param {
($(#[$doc:meta])* $name:ident, $what:literal, $values:expr, $expected:literal, $description:literal) => {
$(#[$doc])*
#[derive(Clone, Debug, PartialEq)]
pub struct $name(pub String);
impl<'de> Deserialize<'de> for $name {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let ok = |s: &str| $values.contains(&s.to_ascii_lowercase().as_str());
let unknown = |s: &str| whole(format!(concat!("unknown ", $what, " `{}` (", $expected, ")"), s.to_ascii_lowercase()));
string_matching(d, ok, $expected, unknown).map(|s| $name(s.to_ascii_lowercase()))
}
}
impl JsonSchema for $name {
fn schema_name() -> Cow<'static, str> {
stringify!($name).into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
enum_schema($values, $description)
}
}
};
}
keyword_param!(
/// `solid.fillet`'s `type`.
FilletTypeParam,
"fillet type",
&["constant", "rolling_ball", "chord", "chord_length", "curvature", "g2", "variable", "spline"],
"constant, chord, variable, spline or curvature",
"The round's section: constant (the default; rolling_ball is an alias), chord (`radius` is the width across; alias chord_length), curvature (alias g2: curvature-continuous between planar faces along straight edges, `radius` the equivalent circular setback), variable (`radius` at the end nearest `start`, `radius2` at the other) or spline (the radius a clamped B-spline from `start`: `radius`, then `radii`, over `knots`). Matching ignores case."
);
keyword_param!(
/// `solid.fillet`'s `fit`.
FilletFitParam,
"fit",
&["exact", "reduce", "taper"],
"exact, reduce or taper",
"What a radius too large for the geometry around the edges does: exact (the default) refuses it naming the largest that fits, reduce reduces it where it doesn't fit, taper follows the room along straight edges between planar faces. reduce and taper take type: constant. Matching ignores case."
);
keyword_param!(
/// `solid.fillet`'s `corner`.
FilletCornerParam,
"corner",
&["rolling", "g2"],
"rolling or g2",
"What fills a corner where three rounded edges meet: rolling (the default), the rolling ball's sphere, or g2, a set-back patch meeting the rounds to the second order (type: constant, fit: exact only). Matching ignores case."
);
keyword_param!(
/// `solid.fillet`'s `turn`.
FilletTurnParam,
"turn",
&["mitre", "swept"],
"mitre or swept",
"How two rounds turn round a sharp, straight, convex side edge square to both on a plane (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. Matching ignores case."
);
/// How a feature combines with existing bodies, as `features::operation` reads it.
#[derive(Clone, Debug, PartialEq)]
pub struct OperationParam(pub Operation);
+5
View File
@@ -78,6 +78,7 @@ frame.member
frame.profile.insert
frame.profile.list
frame.profile.properties
inspect.clearance
inspect.compare
inspect.interference
inspect.measure
@@ -106,6 +107,8 @@ material.delete
material.edit
material.inspect
material.list
material.properties.directional
material.properties.validate
mesh.convert
motion.delete
motion.export
@@ -305,6 +308,7 @@ solid.coil
solid.combine
solid.draft
solid.emboss
solid.fillet
solid.hole
solid.loft
solid.mirror
@@ -329,6 +333,7 @@ surface.extend
surface.extrude
surface.fair
surface.loft
surface.match
surface.offset
surface.patch
surface.reverse_normal
-10
View File
@@ -3291,16 +3291,6 @@ fn unequal_chamfers_on_circular_rims() {
assert!(rel(full - volume(&mut s), flipped) < 5e-3, "{} vs {flipped}", full - volume(&mut s));
}
/// A solid box top is not a rim: Lip says to Shell first (#10437).
#[test]
fn lip_on_a_solid_box_says_to_shell_first() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10}));
let err = s.execute("plastic.lip", &json!({"face": [20, 15, 10], "width": 1, "height": 2})).unwrap_err().to_string();
assert!(err.contains("Shell"), "{err}");
assert!(err.contains("rim") || err.contains("opening"), "{err}");
}
/// An enclosure built in the usual order: shell, bosses, then the lip on the rim.
#[test]
fn lip_after_boss() {
+11 -123
View File
@@ -512,127 +512,15 @@ fn shaft_on_collar() -> Session {
}
/// Rig 602, ladder 06/004: a thread on a shaft ran a pitch past both ends of its face, into the
/// collar below as into the air above. It runs down to the shoulder and is trimmed at its plane
/// (#11610): the collar keeps its whole π 8² 10, and the groove takes 25/P turns, each slice of
/// the shaft along the axis losing the same.
/// collar below as into the air above. At a shoulder it stops with its widest part clear of it:
/// the collar keeps its whole π 8² 10, and the groove takes (25 - w)/P turns, w its half width
/// (a square stop inside material takes exactly its length over P, the section being symmetric).
#[test]
fn a_thread_runs_down_to_a_shoulder_and_leaves_it_whole() {
fn a_thread_stops_clear_of_a_shoulder() {
let mut s = shaft_on_collar();
run(&mut s, "solid.thread", json!({"face": [5, 0, 25], "designation": "M10", "modeled": true}));
let want = PI * 64.0 * 10.0 + PI * 25.0 * 25.0 - 25.0 / 1.5 * ext_turn(10.0, 1.5, 5.0);
let got = exact_volume(&s, "Part");
assert!((got - want).abs() < want * 2e-4, "{got} vs {want}");
}
/// The volume of a body's part between two heights along Z, within a quadrant of X and Y when
/// given (their signs).
fn volume_between(s: &Session, b: &str, (z0, z1): (f64, f64), quadrant: Option<(bool, bool)>) -> f64 {
let st = s.model.state();
let mut part = st.body(b).unwrap().body.clone();
let on = |origin: Vec3, normal: Vec3| solidcore_record::Plane::from_normal(origin, normal.normalized().unwrap()).unwrap();
let z = Vec3::new(0.0, 0.0, 1.0);
let mut cuts = vec![(on(Vec3::new(0.0, 0.0, z0), z), true), (on(Vec3::new(0.0, 0.0, z1), z), false)];
if let Some((x, y)) = quadrant {
cuts.push((on(Vec3::ZERO, Vec3::new(1.0, 0.0, 0.0)), x));
cuts.push((on(Vec3::ZERO, Vec3::new(0.0, 1.0, 0.0)), y));
}
for (plane, positive) in cuts {
part = solidcraft_kernel::half_space(&part, &plane, positive).unwrap().0.unwrap();
}
solidcraft_kernel::measure(&part).unwrap().volume
}
/// #11610: a boss standing on a flat plate. The groove reaches the plate at every angle of the
/// last turn, wherever the helix lands on it (each height a different gap under a pitch): a thin
/// slice on the plate has lost as much as any slice of the thread, a pitch's slice as much in each
/// quadrant (the uncut block of the old stop sat in some of them), and the plate is whole. Before,
/// the last pitch kept up to a whole turn's groove uncut.
#[test]
fn a_thread_reaches_a_flat_shoulder_at_every_angle_and_leaves_it_whole() {
let (p, t, turn) = (1.5, 6.0, ext_turn(10.0, 1.5, 5.0));
let per_mm = PI * 25.0 - turn / p;
for (h, extra) in [
(16.0, json!({})),
(16.45, json!({})),
(16.6, json!({})),
(16.9, json!({})),
(17.4, json!({})),
(16.0, json!({"hand": "left"})),
(16.6, json!({"tip": "both"})),
] {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"corner": [-20, -20, 0], "length": 40, "width": 40, "height": t, "body_name": "Part"}));
run(&mut s, "solid.cylinder", json!({"base": [0, 0, t], "radius": 5, "height": h, "operation": "join", "targets": ["Part"]}));
let mut ask = json!({"face": [5, 0, t + h / 2.0], "designation": "M10", "modeled": true});
if let (Some(a), Value::Object(e)) = (ask.as_object_mut(), &extra) {
a.extend(e.clone());
}
run(&mut s, "solid.thread", ask);
let name = format!("h {h} {extra}");
assert!(s.model.state().body("Part").unwrap().body.validity().is_empty(), "{name}");
let plate = volume_between(&s, "Part", (-1.0, t), None);
assert!((plate - 1600.0 * t).abs() < 1e-6 * 1600.0 * t, "{name}: the plate lost {} mm³", 1600.0 * t - plate);
let thin = volume_between(&s, "Part", (t, t + 0.1 * p), None);
let want = per_mm * 0.1 * p;
assert!((thin - want).abs() < want * 2e-3, "{name}: the slice on the plate is {thin}, not {want}");
for quadrant in [(true, true), (true, false), (false, true), (false, false)] {
let got = volume_between(&s, "Part", (t, t + p), Some(quadrant));
let want = per_mm * p / 4.0;
assert!((got - want).abs() < want * 2e-3, "{name}: {quadrant:?} of the last pitch is {got}, not {want}");
}
}
}
/// #11610: where a shoulder is not flat. A fillet or a chamfer at the shaft's base stays whole, the
/// groove trimmed where the face ends above it. A shoulder that rises back along the face (a
/// slanted plate, a boss on a round body) would be cut by a groove running to the plane of the
/// face's lowest point, so the groove keeps its square stop there. Nothing here fails.
#[test]
fn a_thread_at_a_shoulder_that_is_not_flat_still_builds() {
let (p, turn) = (1.5, ext_turn(10.0, 1.5, 5.0));
let plate = |s: &mut Session| {
run(s, "solid.box", json!({"corner": [-20, -20, 0], "length": 40, "width": 40, "height": 6, "body_name": "Part"}));
run(s, "solid.cylinder", json!({"base": [0, 0, 6], "radius": 5, "height": 16, "operation": "join", "targets": ["Part"]}));
};
for finish in [("solid.fillet", json!({"edges": [[5, 0, 6]], "radius": 1})), ("solid.chamfer", json!({"edges": [[5, 0, 6]], "distance": 1}))] {
let mut s = Session::default();
plate(&mut s);
run(&mut s, finish.0, finish.1);
let (base, shaft) = (volume_between(&s, "Part", (-1.0, 7.0), None), exact_volume(&s, "Part"));
run(&mut s, "solid.thread", json!({"face": [5, 0, 15], "designation": "M10", "modeled": true}));
assert!(s.model.state().body("Part").unwrap().body.validity().is_empty(), "{}", finish.0);
let left = volume_between(&s, "Part", (-1.0, 7.0), None);
assert!((left - base).abs() < 1e-6 * base, "{}: the base lost {} mm³", finish.0, base - left);
let (got, want) = (exact_volume(&s, "Part"), shaft - 15.0 / p * turn);
assert!((got - want).abs() < want * 2e-4, "{}: {got} vs {want}", finish.0);
}
let rising = [
vec![("solid.cylinder", json!({"base": [0, 0, 0], "axis": [0.2, 0, 1], "radius": 20, "height": 6, "body_name": "Part"}))],
vec![("solid.cylinder", json!({"base": [0, -20, 0], "axis": [0, 1, 0], "radius": 10, "height": 40, "body_name": "Part"}))],
];
for cmds in rising {
let mut s = Session::default();
for (c, ask) in cmds {
run(&mut s, c, ask);
}
run(&mut s, "solid.cylinder", json!({"base": [0, 0, 2], "radius": 5, "height": 26, "operation": "join", "targets": ["Part"]}));
let before = exact_volume(&s, "Part");
run(&mut s, "solid.thread", json!({"face": [5, 0, 20], "designation": "M10", "modeled": true}));
assert!(s.model.state().body("Part").unwrap().body.validity().is_empty());
assert!(exact_volume(&s, "Part") < before);
}
}
/// #11610: the trim is for a thread that runs the whole face. A length stopping short of the
/// shoulder still stops there, however near.
#[test]
fn a_thread_of_some_length_stops_where_it_is_told_short_of_a_shoulder() {
let (p, w) = (1.5, ext_half_width(10.0, 1.5));
let mut s = shaft_on_collar();
let before = exact_volume(&s, "Part");
run(&mut s, "solid.thread", json!({"face": [5, 0, 25], "designation": "M10", "length": 24.6, "modeled": true}));
// Its end is within half a pitch of the shoulder: the stop stays clear of it.
let want = before - (24.6f64).min(25.0 - w) / p * ext_turn(10.0, p, 5.0);
let want = PI * 64.0 * 10.0 + PI * 25.0 * 25.0 - (25.0 - w) / p * ext_turn(10.0, p, 5.0);
let got = exact_volume(&s, "Part");
assert!((got - want).abs() < want * 2e-4, "{got} vs {want}");
}
@@ -640,7 +528,7 @@ fn a_thread_of_some_length_stops_where_it_is_told_short_of_a_shoulder() {
/// A d10 boss (z t..t+16) on a plate t thick, threaded M10: the plate's top is the shoulder.
/// The groove, with or without its runout, stays on the boss: the plate keeps all of itself
/// (what lies below z = t after the boss is cut away), and without a runout the boss loses
/// 16/P turns, as at a collar. The owner's bug: built as the app builds it (a rounded
/// (16 - w)/P turns, as at a collar. The owner's bug: built as the app builds it (a rounded
/// plate, a circle sketched on its top face and extruded with Join), a plate thinner than 1.5 P
/// had no material where the free-end test looked (1 to 1.5 pitches past the face), so its top
/// was taken for a free end and the overrun cut a pitch into it.
@@ -674,10 +562,10 @@ fn a_thread_on_a_boss_never_cuts_into_the_plate_below() {
exact_volume(&s, "Body1")
};
let plate = |t: f64| (40.0 * 40.0 - (4.0 - PI) * 25.0) * t;
let p = 1.5;
let (p, w) = (1.5, ext_half_width(10.0, 1.5));
for t in [6.0, 1.0] {
let s = boss(t, json!({}));
let want = plate(t) + PI * 25.0 * 16.0 - 16.0 / p * ext_turn(10.0, p, 5.0);
let want = plate(t) + PI * 25.0 * 16.0 - (16.0 - w) / p * ext_turn(10.0, p, 5.0);
let got = exact_volume(&s, "Body1");
assert!((got - want).abs() < want * 2e-4, "plate {t}: {got} vs {want}");
let left = plate_left(s, t);
@@ -1027,9 +915,10 @@ fn a_chamfered_tip_tapers_the_last_teeth() {
"solid.thread",
json!({"face": [5, 0, 25], "designation": "M10", "tip": "both", "chamfer_angle": "30 deg", "chamfer_depth": 0.5, "modeled": true}),
);
let w = ext_half_width(10.0, 1.5);
let rf = 4.5;
let slope = 30f64.to_radians().tan();
let want = PI * 64.0 * 10.0 + m10_stock(10.0, 35.0, |z| 5f64.min(rf + (35.0 - z) * slope));
let want = PI * 64.0 * 10.0 + PI * 25.0 * w + m10_stock(10.0 + w, 35.0, |z| 5f64.min(rf + (35.0 - z) * slope));
let got = exact_volume(&s, "Part");
assert!((got - want).abs() < want * 1e-4, "{got} vs {want}");
// Internal threads take a countersink instead.
@@ -1115,8 +1004,7 @@ fn a_runout_fades_the_groove_out_before_the_shoulder() {
let (p, w) = (1.5, ext_half_width(10.0, 1.5));
let root = 5.0 - 5.0 * p * 3f64.sqrt() / 16.0;
let h = 5.0 - root + 0.05 * p;
// Trimmed at the bottom collar's plane, stopped clear of the top one for its runout.
let turns = (35.0 - 10.0 - w) / p;
let turns = (35.0 - 10.0 - 2.0 * w) / p;
for (ease, r_out) in [(json!("short"), 1.25), (json!("standard"), 2.5), (json!("1 mm"), 1.0 / 1.5)] {
let mut s = shaft_on_collar();
run(&mut s, "solid.cylinder", json!({"base": [0, 0, 35], "radius": 8, "height": 10, "operation": "join", "targets": ["Part"]}));
-49
View File
@@ -4681,55 +4681,6 @@ mod tests {
mod glass_tests {
use super::*;
/// Intel UHD 620 OpenGL collapsed long line quads or expanded short ones across the
/// viewport with vector/scalar direction division (#8053). Exercise the actual
/// OpenGL shader rather than the native headless selector, which skips GL on Windows.
#[test]
fn opengl_draws_grid_lines_in_every_direction_and_at_a_point() {
let mut descriptor = crate::instance_descriptor_from_env();
descriptor.backends = wgpu::Backends::GL;
let instance = wgpu::Instance::new(descriptor);
let policy = crate::select::Policy::from_env().expect("a valid GPU test policy");
let Some((adapter, _)) = crate::select::pick_among(pollster::block_on(instance.enumerate_adapters(wgpu::Backends::GL)), policy) else {
eprintln!("skipped: no OpenGL adapter qualifies under {}", policy.name());
return;
};
eprintln!("OpenGL regression adapter: {:?}", adapter.get_info());
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
required_limits: wgpu::Limits::downlevel_webgl2_defaults().using_resolution(adapter.limits()),
..Default::default()
}))
.expect("the OpenGL adapter opens a device");
let window = GpuTarget { format: wgpu::TextureFormat::Rgba8Unorm, depth: None, samples: 1, tess: false, oit: false };
// Ten world units span 32 pixels here; a four-pixel line covers over 100 pixels.
// A point or a very short line stays a small cap, never a fill over the viewport.
let cases = [
([-5.0, 0.0, 0.0], [5.0, 0.0, 0.0], 100..=256),
([5.0, 0.0, 0.0], [-5.0, 0.0, 0.0], 100..=256),
([0.0, -5.0, 0.0], [0.0, 5.0, 0.0], 100..=256),
([0.0, 5.0, 0.0], [0.0, -5.0, 0.0], 100..=256),
([-5.0, -5.0, 0.0], [5.0, 5.0, 0.0], 120..=256),
([5.0, 5.0, 0.0], [-5.0, -5.0, 0.0], 120..=256),
([-5.0, 5.0, 0.0], [5.0, -5.0, 0.0], 120..=256),
([0.0; 3], [0.0; 3], 10..=64),
([0.0; 3], [0.0001, 0.0001, 0.0], 10..=64),
];
for offscreen in [false, true] {
let target = if offscreen { GpuTarget { depth: Some(wgpu::TextureFormat::Depth24PlusStencil8), ..window } } else { window };
let mut renderer = Renderer::with_window_target(&device, target, window, None);
for (index, (a, b, expected)) in cases.iter().enumerate() {
let mut scene = GpuScene::default();
let red = scene.color([255, 0, 0, 255]);
scene.under_line(*a, *b, red, 4.0);
let mut frame = test_frame(scene, 1.0);
frame.key = index as u64 + 1;
let pixels = render_frame(&mut renderer, &device, &queue, &frame, false);
let coloured = pixels.as_chunks::<4>().0.iter().filter(|p| p[0] > 240 && p[1] < 30 && p[2] < 30).count();
assert!(expected.contains(&coloured), "{a:?} to {b:?}, offscreen {offscreen}: {coloured} line pixels, expected {expected:?}");
}
}
}
#[test]
fn ground_shadow_does_not_require_order_independent_transparency() {
let batch = || Some(Batch { parts: Vec::new(), chunk: TRI_SIZE * 6 });
-2
View File
@@ -1296,7 +1296,6 @@ mod tests {
fit: Default::default(),
corner: Default::default(),
setbacks: Vec::new(),
turn: Default::default(),
tangent_chain: true,
},
None,
@@ -1419,7 +1418,6 @@ mod tests {
fit: Default::default(),
corner: Default::default(),
setbacks: Vec::new(),
turn: Default::default(),
tangent_chain: true,
},
None,
+20 -39
View File
@@ -234,40 +234,22 @@ fn closed(input: &Body, b: Body) -> Result<Body> {
}
}
/// 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)]
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. Refused where it is not built (unequal or variable rounds, a
/// concave, curved or tangent side edge, a corner of four edges), with the reason.
Swept,
}
/// Constant-radius fillet of the edges nearest to the given points. A radius too large for the
/// faces around the edges is refused with [`KernelError::TooLarge`] ([`fillet_max`] finds the
/// largest that works).
pub fn fillet(body: &Body, edges: &[Vec3], radius: f64) -> Result<Body> {
fillet_turning(body, edges, radius, FilletTurn::Mitre)
}
/// [`fillet`], its rounds turning at side edges as `turn` says.
fn fillet_turning(body: &Body, edges: &[Vec3], radius: f64, turn: FilletTurn) -> Result<Body> {
let _span = solidcraft_profile::span("kernel", "fillet");
body.require_brep("fillet")?;
crate::blend_smlib::fillet(body, edges, radius, turn).and_then(|b| closed(body, b))
crate::blend_smlib::fillet(body, edges, radius).and_then(|b| closed(body, b))
}
/// [`fillet`], its rounds turning at side edges as `turn` says, with the picks whose edges are
/// gone: where some of the rounds overflow their faces, those are built first, and a picked edge
/// one of them removes whole is not rounded (the others round what is left of their edges).
pub fn fillet_removing(body: &Body, edges: &[Vec3], radius: f64, turn: FilletTurn) -> Result<(Body, Vec<Vec3>)> {
/// [`fillet`], with the picks whose edges are gone: where some of the rounds overflow their
/// faces, those are built first, and a picked edge one of them removes whole is not rounded (the
/// others round what is left of their edges).
pub fn fillet_removing(body: &Body, edges: &[Vec3], radius: f64) -> Result<(Body, Vec<Vec3>)> {
let _span = solidcraft_profile::span("kernel", "fillet");
body.require_brep("fillet")?;
let (b, gone) = crate::blend_smlib::fillet_removing(body, edges, radius, turn)?;
let (b, gone) = crate::blend_smlib::fillet_removing(body, edges, radius)?;
Ok((closed(body, b)?, gone))
}
@@ -533,11 +515,10 @@ fn radius_step(radius: f64) -> f64 {
/// them (a refusal of a fillet feature's exact fit runs it to name the largest that fits, and
/// so does `fit: reduce`). Only a refusal as too large
/// ([`KernelError::TooLarge`]) narrows the search: any other failure of a candidate is the
/// search's error, since a smaller radius would not be the largest that fits. The rounds turn at
/// side edges as `turn` says.
pub fn fillet_max(body: &Body, edges: &[Vec3], radius: f64, turn: FilletTurn) -> Result<Option<(f64, Body)>> {
/// search's error, since a smaller radius would not be the largest that fits.
pub fn fillet_max(body: &Body, edges: &[Vec3], radius: f64) -> Result<Option<(f64, Body)>> {
let _span = solidcraft_profile::span("kernel", "fillet_max");
largest_that_fits(radius, |r| fillet_turning(body, edges, r, turn))
largest_that_fits(radius, |r| fillet(body, edges, r))
}
/// [`fillet_max`] for an equal-distance chamfer of these edges: the largest distance below
@@ -585,21 +566,21 @@ fn largest_that_fits(size: f64, blend: impl Fn(f64) -> Result<Body>) -> Result<O
/// each reduced to fit, are too large together, or there are too many to search (each search is
/// up to 16 fillets, and this runs in the live preview). Any failure other than a refusal as too
/// large is the error.
pub fn fillet_max_each(body: &Body, edges: &[Vec3], radius: f64, turn: FilletTurn) -> Result<Option<(Vec<f64>, Body)>> {
pub fn fillet_max_each(body: &Body, edges: &[Vec3], radius: f64) -> Result<Option<(Vec<f64>, Body)>> {
let _span = solidcraft_profile::span("kernel", "fillet_max_each");
const MAX_GROUPS: usize = 8;
const MAX_SEARCHES: usize = 2;
let one = || -> Result<Option<(Vec<f64>, Body)>> { Ok(fillet_max(body, edges, radius, turn)?.map(|(r, b)| (vec![r; edges.len()], b))) };
let one = || -> Result<Option<(Vec<f64>, Body)>> { Ok(fillet_max(body, edges, radius)?.map(|(r, b)| (vec![r; edges.len()], b))) };
let Some(groups) = interacting_groups(body, edges).filter(|g| g.len() > 1 && g.len() <= MAX_GROUPS) else { return one() };
let mut radii = vec![radius; edges.len()];
let mut searches = 0;
for g in &groups {
let pts: Vec<Vec3> = g.iter().filter_map(|i| edges.get(*i).copied()).collect();
let r = match fillet_turning(body, &pts, radius, turn) {
let r = match fillet(body, &pts, radius) {
Ok(_) => continue,
Err(KernelError::TooLarge(_)) if searches < MAX_SEARCHES => {
searches += 1;
match fillet_max(body, &pts, radius, turn)? {
match fillet_max(body, &pts, radius)? {
Some((r, _)) => r,
None => return one(),
}
@@ -613,7 +594,7 @@ pub fn fillet_max_each(body: &Body, edges: &[Vec3], radius: f64, turn: FilletTur
}
}
}
match fillet_each(body, edges, &radii, turn) {
match fillet_each(body, edges, &radii) {
Ok(b) => Ok(Some((radii, b))),
Err(KernelError::TooLarge(_)) => one(),
Err(e) => Err(e),
@@ -622,16 +603,16 @@ pub fn fillet_max_each(body: &Body, edges: &[Vec3], radius: f64, turn: FilletTur
/// The fillet of each edge at its own radius (`radii` in `edges`' order), all in one SMLib
/// blend.
pub fn fillet_each(body: &Body, edges: &[Vec3], radii: &[f64], turn: FilletTurn) -> Result<Body> {
pub fn fillet_each(body: &Body, edges: &[Vec3], radii: &[f64]) -> Result<Body> {
let _span = solidcraft_profile::span("kernel", "fillet_each");
if radii.len() != edges.len() {
return Err(KernelError::Invalid("one radius per edge".into()));
}
if let Some(r) = radii.first().filter(|r| radii.iter().all(|x| x.to_bits() == r.to_bits())) {
return fillet_turning(body, edges, *r, turn);
return fillet(body, edges, *r);
}
body.require_brep("fillet")?;
crate::blend_smlib::fillet_each(body, edges, radii, turn).and_then(|b| closed(body, b))
crate::blend_smlib::fillet_each(body, edges, radii).and_then(|b| closed(body, b))
}
/// The points' edges (each the edge a blend's pick there takes) grouped where they share a face
@@ -1618,7 +1599,7 @@ mod tests {
assert!(matches!(e, KernelError::TooLarge(_)), "{e}");
let e = e.to_string();
assert!(e.contains("into the hollow at (0.000, 30.000, 40.000) and open the part"), "{e}");
let (largest, _) = fillet_max(&case, &[top], 7.0, FilletTurn::Mitre).unwrap().unwrap();
let (largest, _) = fillet_max(&case, &[top], 7.0).unwrap().unwrap();
assert!((largest - 6.828).abs() < 1e-9, "{largest}");
// Just short of the corner it rounds, its whole section along the 104.
@@ -1632,8 +1613,8 @@ mod tests {
// inner R7's arc (centre (23, 33)) only at sqrt2 (r - 9) + 7 = r, r = 13.83.
let inside = Vec3::new(0.0, 30.0, 40.0);
assert!(fillet(&case, &[top, inside], 7.0).is_ok());
assert!(fillet_each(&case, &[top, inside], &[13.8, 7.0], FilletTurn::Mitre).is_ok());
let e = fillet_each(&case, &[top, inside], &[13.9, 7.0], FilletTurn::Mitre).unwrap_err().to_string();
assert!(fillet_each(&case, &[top, inside], &[13.8, 7.0]).is_ok());
let e = fillet_each(&case, &[top, inside], &[13.9, 7.0]).unwrap_err().to_string();
assert!(e.contains("into the hollow at"), "{e}");
}
+1 -1
View File
@@ -261,7 +261,7 @@ pub fn fillet_radii(body: &Body, edges: &[(Vec3, f64)]) -> Result<Body> {
return Err(KernelError::Invalid("fillet size must be positive".into()));
}
let (points, radii): (Vec<Vec3>, Vec<f64>) = edges.iter().copied().unzip();
crate::blend_smlib::fillet_each(body, &points, &radii, crate::FilletTurn::Mitre)
crate::blend_smlib::fillet_each(body, &points, &radii)
}
#[cfg(test)]
+34 -52
View File
@@ -17,7 +17,7 @@ use solidcore_record::Vec3;
use crate::blend_variable::RadiusSpline;
use crate::body::Body;
use crate::{FilletTurn, KernelError, Result};
use crate::{KernelError, Result};
/// What SMLib cannot build yet.
pub(crate) fn unsupported(what: &str) -> KernelError {
@@ -25,24 +25,22 @@ pub(crate) fn unsupported(what: &str) -> KernelError {
}
/// A constant-radius round of the edges nearest `edges` ([`crate::fillet`]).
/// The rounds turn at side edges as `turn` says.
pub(crate) fn fillet(body: &Body, edges: &[Vec3], radius: f64, turn: FilletTurn) -> Result<Body> {
imp::blend(body, "fillet", Kind::Round(turn), edges, &vec![radius; edges.len()])
pub(crate) fn fillet(body: &Body, edges: &[Vec3], radius: f64) -> Result<Body> {
imp::blend(body, "fillet", Kind::Round, edges, &vec![radius; edges.len()])
}
/// [`fillet`], with the picks whose edges a larger, overflowing round removed whole.
pub(crate) fn fillet_removing(body: &Body, edges: &[Vec3], radius: f64, turn: FilletTurn) -> Result<(Body, Vec<Vec3>)> {
let (b, dropped) = imp::blend_dropping(body, "fillet", Kind::Round(turn), edges, &vec![radius; edges.len()])?;
pub(crate) fn fillet_removing(body: &Body, edges: &[Vec3], radius: f64) -> Result<(Body, Vec<Vec3>)> {
let (b, dropped) = imp::blend_dropping(body, "fillet", Kind::Round, edges, &vec![radius; edges.len()])?;
Ok((b, edges.iter().zip(dropped).filter(|(_, d)| *d).map(|(p, _)| *p).collect()))
}
/// Each edge rounded at its own radius ([`crate::fillet_each`]), turning at side edges as `turn`
/// says.
pub(crate) fn fillet_each(body: &Body, edges: &[Vec3], radii: &[f64], turn: FilletTurn) -> Result<Body> {
/// Each edge rounded at its own radius ([`crate::fillet_each`]).
pub(crate) fn fillet_each(body: &Body, edges: &[Vec3], radii: &[f64]) -> Result<Body> {
if radii.len() != edges.len() {
return Err(KernelError::Invalid("one radius per edge".into()));
}
imp::blend(body, "fillet", Kind::Round(turn), edges, radii)
imp::blend(body, "fillet", Kind::Round, edges, radii)
}
/// A constant-radius round whose corners at `setbacks` (a point at each corner's vertex, and the
@@ -65,7 +63,7 @@ pub(crate) struct OpenCorners {
/// that would fill each corner, for the caller to fill.
/// `curvature`: the rounds' sections are curvature-continuous ([`fillet_g2`]), else circular.
pub(crate) fn fillet_setback_open(body: &Body, edges: &[Vec3], radius: f64, setbacks: &[(Vec3, f64)], curvature: bool) -> Result<OpenCorners> {
let kind = if curvature { Kind::Curvature } else { Kind::Round(FilletTurn::Mitre) };
let kind = if curvature { Kind::Curvature } else { Kind::Round };
imp::blend_setback_open(body, kind, edges, &vec![radius; edges.len()], setbacks)
}
@@ -106,8 +104,7 @@ pub(crate) fn picked_edges(body: &Body, picks: &[Vec3]) -> Result<(Vec<usize>, V
#[derive(Clone, Copy)]
enum Kind {
/// Constant-radius rounds, turning at side edges as the [`FilletTurn`] says.
Round(FilletTurn),
Round,
Curvature,
Chord,
Chamfer,
@@ -118,7 +115,7 @@ mod imp {
use std::collections::BTreeSet;
use solidcore_record::Vec3;
use solidcraft_smlib::{Blend, Brep, ChamferSecond, CheckLevel, Error as SmError, Turn};
use solidcraft_smlib::{Blend, Brep, ChamferSecond, CheckLevel, Error as SmError};
use super::Kind;
use crate::blend_variable::RadiusSpline;
@@ -228,21 +225,13 @@ mod imp {
/// SMLib's blend of each kind.
fn blend_kind(kind: Kind) -> Blend {
match kind {
Kind::Round(_) => Blend::Round,
Kind::Round => Blend::Round,
Kind::Curvature => Blend::Curvature,
Kind::Chord => Blend::Chord,
Kind::Chamfer => Blend::Chamfer,
}
}
/// SMLib's turn of each kind of turn.
fn smlib_turn(turn: crate::FilletTurn) -> Turn {
match turn {
crate::FilletTurn::Mitre => Turn::Mitre,
crate::FilletTurn::Swept => Turn::Swept,
}
}
pub(super) fn blend(body: &Body, what: &str, kind: Kind, edges: &[Vec3], sizes: &[f64]) -> Result<Body> {
blend_dropping(body, what, kind, edges, sizes).map(|(b, _)| b)
}
@@ -254,22 +243,21 @@ mod imp {
sizes_ok(what, sizes)?;
let picked = resolve(body, edges)?;
let next = crate::blend::unfinished_chain(body, edges, &picked.edges, &picked.hidden)?;
if matches!(kind, Kind::Round(_)) {
if matches!(kind, Kind::Round) {
crate::blend::rounds_too_small(body, edges, &picked.edges, sizes)?;
crate::blend::rounds_into_hollow(body, edges, &picked.edges, sizes)?;
}
let tol = pick_tolerance(body);
let Kind::Round(turn) = kind else {
let Kind::Round = kind else {
let kind = blend_kind(kind);
return run_with(body, what, &picked.edges, &[], |b| b.blend_dropping(kind, Turn::Mitre, &picked.points, sizes, tol))
return run_with(body, what, &picked.edges, &[], |b| b.blend_dropping(kind, &picked.points, sizes, tol))
.map(|(out, dropped, _)| (out, dropped))
.map_err(|e| crate::blend::with_continuation(e, next));
};
// Constant-radius rounds: the body keeps what they were made on, and a later round at a
// corner where they meet blends them again with it (crate::blend_corner).
let turn = smlib_turn(turn);
let out = match crate::blend_corner::later_corners(body, &picked.edges, sizes)? {
Some((later, new)) => reblend(body, what, &later, turn).map(|(b, dropped_new)| {
Some((later, new)) => reblend(body, what, &later).map(|(b, dropped_new)| {
// A picked seam has no round of its own: only the new edges can be removed.
let dropped = picked
.edges
@@ -278,7 +266,7 @@ mod imp {
.collect();
(b.with_rounds(body, new), dropped)
}),
None => run_with(body, what, &picked.edges, &[], |b| b.blend_dropping(Blend::Round, turn, &picked.points, sizes, tol))
None => run_with(body, what, &picked.edges, &[], |b| b.blend_dropping(Blend::Round, &picked.points, sizes, tol))
.map(|(out, dropped, _)| (out.with_rounds(body, picked.edges.iter().copied().zip(sizes.iter().copied()).collect()), dropped)),
};
out.map_err(|e| crate::blend::with_continuation(e, next))
@@ -287,9 +275,8 @@ mod imp {
/// The blend `later` makes of its base, every round from the earliest meeting the new edges'
/// corners on and the new edges in one, with its history over `body`, the body the later
/// round was given ([`crate::blend_corner::rebase`]), and whether each new edge (in
/// `later`'s order) was removed whole by a larger, overflowing round. The rounds turn at side
/// edges as `turn` says.
fn reblend(body: &Body, what: &str, later: &crate::blend_corner::Later, turn: Turn) -> Result<(Body, Vec<bool>)> {
/// `later`'s order) was removed whole by a larger, overflowing round.
fn reblend(body: &Body, what: &str, later: &crate::blend_corner::Later) -> Result<(Body, Vec<bool>)> {
let base = &later.base;
let mesh = base.mesh(&crate::MeshTolerance::chord((base.size() * 2e-3).max(1e-3)))?;
let mut indices = Vec::with_capacity(later.edges.len());
@@ -304,7 +291,7 @@ mod imp {
}
sizes_ok(what, &radii)?;
let tol = pick_tolerance(base);
let (out, dropped, _) = run_with(base, what, &indices, &[], |b| b.blend_dropping(Blend::Round, turn, &points, &radii, tol))?;
let (out, dropped, _) = run_with(base, what, &indices, &[], |b| b.blend_dropping(Blend::Round, &points, &radii, tol))?;
// The new edges are the last of `later.edges`.
let dropped_new = dropped.get(dropped.len().saturating_sub(later.new_count())..).map(<[bool]>::to_vec).unwrap_or_default();
let history = out.history().and_then(|h| crate::blend_corner::rebase(h, later, body));
@@ -459,11 +446,6 @@ mod imp {
run on tangentially into each other, or meet in a way no blend corner builds)"
)),
SmError::NoRoom => KernelError::TooLarge(format!("the {what} is larger than the faces beside its edges allow")),
// Not a size a smaller radius fixes: solidcore builds no swept turn there.
SmError::TurnRefused { why, at, .. } => {
let at = at.map(|[x, y, z]| format!(" at ({x:.3}, {y:.3}, {z:.3})")).unwrap_or_default();
KernelError::Invalid(format!("the {what}'s swept corner{at} is not built: {why}"))
}
SmError::TooLarge { size, kind, at, why } => {
let mut beside = match at {
Some([x, y, z]) => format!(", at the edge at ({x:.3}, {y:.3}, {z:.3})"),
@@ -612,7 +594,7 @@ mod tests {
fn a_round_loses_its_corner_and_its_face_comes_from_the_edge() {
let b = cube();
let at = Vec3::new(10.0, 20.0, 20.0);
let r = imp::blend(&b, "fillet", Kind::Round(FilletTurn::Mitre), &[at], &[2.0]).unwrap();
let r = imp::blend(&b, "fillet", Kind::Round, &[at], &[2.0]).unwrap();
assert!(near(volume(&r), 8000.0 - QUARTER * 4.0 * 20.0), "{}", volume(&r));
assert_eq!(r.face_count(), 7);
let h = r.history().expect("a history");
@@ -626,8 +608,8 @@ mod tests {
// (20, 20, 10) is on the vertical edge only; (20, 20, 20) is a corner of three edges, which
// the body's own order resolves, never left to SMLib.
let b = cube();
assert!(imp::blend(&b, "fillet", Kind::Round(FilletTurn::Mitre), &[Vec3::new(20.0, 20.0, 20.0)], &[1.0]).is_ok());
assert!(imp::blend(&b, "fillet", Kind::Round(FilletTurn::Mitre), &[Vec3::new(60.0, 60.0, 60.0)], &[1.0]).is_err());
assert!(imp::blend(&b, "fillet", Kind::Round, &[Vec3::new(20.0, 20.0, 20.0)], &[1.0]).is_ok());
assert!(imp::blend(&b, "fillet", Kind::Round, &[Vec3::new(60.0, 60.0, 60.0)], &[1.0]).is_err());
}
/// A ball's rim on a block's back face, tangent to the back face's bottom edge at (2.5, 22.5, 0).
@@ -695,7 +677,7 @@ mod tests {
fn each_edge_takes_its_own_size() {
let b = cube();
let picks = [Vec3::new(10.0, 20.0, 20.0), Vec3::new(10.0, 0.0, 20.0)];
let r = imp::blend(&b, "fillet", Kind::Round(FilletTurn::Mitre), &picks, &[2.0, 3.0]).unwrap();
let r = imp::blend(&b, "fillet", Kind::Round, &picks, &[2.0, 3.0]).unwrap();
assert!(near(volume(&r), 8000.0 - QUARTER * 13.0 * 20.0), "{}", volume(&r));
let c = imp::blend(&b, "chamfer", Kind::Chamfer, &picks, &[2.0, 3.0]).unwrap();
assert!(near(volume(&c), 8000.0 - 0.5 * 13.0 * 20.0), "{}", volume(&c));
@@ -768,9 +750,9 @@ mod tests {
// A radius law down to zero inside the edge would pinch the round to a point.
let pinch = RadiusSpline { degree: 1, knots: vec![0.0, 0.0, 0.5, 1.0, 1.0], radii: vec![4.0, 0.0, 4.0] };
assert!(imp::variable(&b, Vec3::new(10.0, 20.0, 20.0), Vec3::new(0.0, 20.0, 20.0), &pinch).is_err());
assert!(imp::blend(&b, "fillet", Kind::Round(FilletTurn::Mitre), &[Vec3::new(10.0, 20.0, 20.0)], &[f64::NAN]).is_err());
assert!(imp::blend(&b, "fillet", Kind::Round(FilletTurn::Mitre), &[Vec3::new(f64::NAN, 20.0, 20.0)], &[1.0]).is_err());
assert!(imp::blend(&b, "fillet", Kind::Round(FilletTurn::Mitre), &[], &[]).is_err());
assert!(imp::blend(&b, "fillet", Kind::Round, &[Vec3::new(10.0, 20.0, 20.0)], &[f64::NAN]).is_err());
assert!(imp::blend(&b, "fillet", Kind::Round, &[Vec3::new(f64::NAN, 20.0, 20.0)], &[1.0]).is_err());
assert!(imp::blend(&b, "fillet", Kind::Round, &[], &[]).is_err());
}
/// A smooth edge (a round's seam with the face it runs into) has nothing to round: refused
@@ -782,30 +764,30 @@ mod tests {
#[test]
fn smooth_edges_and_unfinished_tangent_chains_are_refused_saying_why() {
let b = cube();
let rounded = fillet(&b, &[Vec3::new(10.0, 20.0, 20.0)], 3.0, FilletTurn::Mitre).unwrap();
let rounded = fillet(&b, &[Vec3::new(10.0, 20.0, 20.0)], 3.0).unwrap();
// the seam where the r3 round meets the top, 3 in from the old edge
let seam = imp::blend(&rounded, "fillet", Kind::Round(FilletTurn::Mitre), &[Vec3::new(10.0, 17.0, 20.0)], &[1.0]).unwrap_err().to_string();
let seam = imp::blend(&rounded, "fillet", Kind::Round, &[Vec3::new(10.0, 17.0, 20.0)], &[1.0]).unwrap_err().to_string();
assert!(seam.contains("is smooth: its faces meet tangentially, so there is nothing to round"), "{seam}");
// the end face's top edge runs on tangentially into the round's end arc: its r1 round
// goes on along y through the r3 round until that drops below it, at y = 17 + sqrt(5)
// (the rig case's closed form: 8000 - 20 (9 - 9 pi/4) - 17 (1 - pi/4) - 0.218236)
let top = Vec3::new(0.0, 8.0, 20.0);
let alone = fillet(&rounded, &[top], 1.0, FilletTurn::Mitre).unwrap();
let alone = fillet(&rounded, &[top], 1.0).unwrap();
assert!((volume(&alone) - 7957.505202).abs() < 1e-3, "{}", volume(&alone));
let chain = crate::tangent_chain(&rounded, &[top]).unwrap();
assert!(chain.len() > 1, "{chain:?}");
let whole = fillet(&rounded, &chain, 1.0, FilletTurn::Mitre).unwrap();
let whole = fillet(&rounded, &chain, 1.0).unwrap();
assert!(volume(&whole) < volume(&rounded));
// r5 and r3 on two top edges at a corner, then r2 on the vertical edge under them: it
// runs on up the r5 round's end arc, and SMLib ends it there
let b = box_solid(Vec3::new(0.0, 0.0, 0.0), Vec3::new(60.0, 40.0, 20.0)).unwrap();
let b = fillet(&b, &[Vec3::new(30.0, 40.0, 20.0)], 5.0, FilletTurn::Mitre).unwrap();
let b = fillet(&b, &[Vec3::new(60.0, 20.0, 20.0)], 3.0, FilletTurn::Mitre).unwrap();
let b = fillet(&b, &[Vec3::new(30.0, 40.0, 20.0)], 5.0).unwrap();
let b = fillet(&b, &[Vec3::new(60.0, 20.0, 20.0)], 3.0).unwrap();
let vertical = Vec3::new(60.0, 40.0, 8.0);
let chain = crate::tangent_chain(&b, &[vertical]).unwrap();
assert!(chain.len() > 1, "{chain:?}");
let run_on = fillet(&b, &[vertical], 2.0, FilletTurn::Mitre).unwrap();
let run_on = fillet(&b, &[vertical], 2.0).unwrap();
assert!(volume(&run_on) < volume(&b));
}
}
+7 -37
View File
@@ -68,32 +68,20 @@ pub(crate) fn frame_ok(plane: &Plane) -> bool {
/// Extrude each region from `start` to `end` along the plane normal (offsets in mm, `end > start`).
pub fn extrude(plane: &Plane, regions: &[Region2], start: f64, end: f64) -> Result<Vec<Body>> {
let _span = solidcraft_profile::span("kernel", "extrude");
extrude_with(plane, regions, start, end, None)
extrude_with(plane, regions, start, end, false)
}
/// [`extrude`] with each run of profile lines that continue one another in a straight line (a side
/// drawn in two pieces, a mirrored half meeting its mirror image) swept into one side face over
/// all its pieces, rather than coplanar faces with an edge between them (#1788), and each run of
/// free-form segments (conics, Béziers) that `one_curve` says are pieces of one curve (an
/// ellipse's four quarters, a spline's spans) swept as that one curve into one wall. The side's
/// all its pieces, rather than coplanar faces with an edge between them (#1788). The side's
/// history lists every piece under it. For the Extrude feature; other builders (sheet metal's
/// flat pattern, tools) keep a face per segment.
pub fn extrude_whole_sides(plane: &Plane, regions: &[Region2], start: f64, end: f64, one_curve: &dyn Fn(&Seg2, &Seg2) -> bool) -> Result<Vec<Body>> {
pub fn extrude_whole_sides(plane: &Plane, regions: &[Region2], start: f64, end: f64) -> Result<Vec<Body>> {
let _span = solidcraft_profile::span("kernel", "extrude_whole_sides");
extrude_with(plane, regions, start, end, Some(one_curve))
extrude_with(plane, regions, start, end, true)
}
/// The degree of a free-form segment (a conic or a Bézier), none for a line or an arc.
fn free_form_degree(s: &Seg2) -> Option<u8> {
match *s {
Seg2::Conic { .. } => Some(2),
Seg2::Cubic { .. } => Some(3),
Seg2::Bezier { n, .. } => Some(n),
Seg2::Line { .. } | Seg2::Arc { .. } => None,
}
}
fn extrude_with(plane: &Plane, regions: &[Region2], start: f64, end: f64, one_curve: Option<&dyn Fn(&Seg2, &Seg2) -> bool>) -> Result<Vec<Body>> {
fn extrude_with(plane: &Plane, regions: &[Region2], start: f64, end: f64, whole_sides: bool) -> Result<Vec<Body>> {
if !frame_ok(plane) {
return Err(KernelError::Invalid("extrude frame is not finite and orthonormal".into()));
}
@@ -110,26 +98,8 @@ fn extrude_with(plane: &Plane, regions: &[Region2], start: f64, end: f64, one_cu
.map(|r| {
region_ok(r)?;
let pieces = oriented(r)?;
let Some(one_curve) = one_curve else {
return crate::smlib_sweeps::extrude(plane, &crate::smlib_sweeps::region_pieces(&pieces, &pieces), Some(r), lo, hi - lo, 1.0, None);
};
// Pieces of one curve: free-form segments of one degree that the caller says are one
// curve. Each loop starts where such a run starts, so no run wraps round its end.
let joins = |s: &Seg2, t: &Seg2| free_form_degree(s).is_some_and(|d| free_form_degree(t) == Some(d)) && one_curve(s, t);
let numbered = pieces.map_loops(|l| l.merged_lines(continues_straight).starting_a_run(joins));
let mut swept = crate::smlib_sweeps::region_pieces(&numbered, &pieces);
let mut k = 0;
for l in numbered.loops() {
for i in 1..l.lp.segs.len() {
if let (Some(s), Some(t)) = (l.lp.segs.get(i - 1), l.lp.segs.get(i))
&& let Some(x) = swept.get_mut(k + i)
{
x.joins_previous = joins(s, t);
}
}
k += l.lp.segs.len();
}
crate::smlib_sweeps::extrude(plane, &swept, Some(r), lo, hi - lo, 1.0, None)
let numbered = if whole_sides { pieces.map_loops(|l| l.merged_lines(continues_straight)) } else { pieces.clone() };
crate::smlib_sweeps::extrude(plane, &crate::smlib_sweeps::region_pieces(&numbered, &pieces), Some(r), lo, hi - lo, 1.0, None)
})
.collect()
}
+2 -67
View File
@@ -256,7 +256,7 @@ fn collinear_profile_lines_extrude_into_one_side_face() {
let region = Region2 { outer: Loop2::polygon(&pts), holes: vec![] };
// Plain `extrude` keeps a face per segment (sheet metal's flat pattern relies on it).
assert_eq!(crate::extrude(&Plane::XY, std::slice::from_ref(&region), 0.0, 5.0).unwrap()[0].face_count(), 7);
let body = crate::extrude_whole_sides(&Plane::XY, &[region], 0.0, 5.0, &|_, _| false).unwrap().pop().unwrap();
let body = crate::extrude_whole_sides(&Plane::XY, &[region], 0.0, 5.0).unwrap().pop().unwrap();
assert_eq!(body.face_count(), 6, "a box has six faces");
assert!((crate::measure(&body).unwrap().volume - 1000.0).abs() < 1e-9);
let h = body.history().unwrap();
@@ -276,75 +276,10 @@ fn collinear_profile_lines_extrude_into_one_side_face() {
);
// A corner is not a straight run: an L-shaped step keeps its faces.
let step = [(0.0, 0.0), (10.0, 0.0), (10.0, 5.0), (20.0, 5.0), (20.0, 10.0), (0.0, 10.0)].map(|(x, y)| Vec2::new(x, y));
let body = crate::extrude_whole_sides(&Plane::XY, &[Region2 { outer: Loop2::polygon(&step), holes: vec![] }], 0.0, 5.0, &|_, _| false)
.unwrap()
.pop()
.unwrap();
let body = crate::extrude_whole_sides(&Plane::XY, &[Region2 { outer: Loop2::polygon(&step), holes: vec![] }], 0.0, 5.0).unwrap().pop().unwrap();
assert_eq!(body.face_count(), 8);
}
/// The pieces of one ellipse (four conic quarters) or one spline (its cubic spans) that the
/// caller calls one curve sweep into one wall generated by all of them, the same solid as a face
/// per piece: the ellipse's volume is π a b h. Pieces not called one curve keep a face each.
#[test]
fn pieces_of_one_curve_extrude_into_one_wall() {
use solidcore_record::{Loop2, Plane, Region2, Seg2, Vec2};
let v = Vec2::new;
let (c, u, w) = (v(1.0, 2.0), v(10.0, 0.0), v(0.0, 5.0));
let corners = [c + u, c + w, c - u, c - w];
let ellipse: Vec<Seg2> = (0..4)
.map(|i| {
let (a, b) = (corners[i], corners[(i + 1) % 4]);
Seg2::Conic { a, apex: a + b - c, b, w: std::f64::consts::FRAC_1_SQRT_2 }
})
.collect();
// A cubic spline's three spans (C2 at its joints, on unequal knot spans), closed by lines.
let spline = vec![
Seg2::Cubic {
p0: v(0.0, 0.0),
p1: v(3.478154619431441, -1.9606112245268097),
p2: v(6.956309238862882, -3.921222449053619),
p3: v(10.0, -3.0),
},
Seg2::Cubic {
p0: v(10.0, -3.0),
p1: v(13.55861013478923, -1.9229288384169465),
p2: v(16.52332013301099, 3.0935296384274267),
p3: v(20.0, 4.0),
},
Seg2::Cubic {
p0: v(20.0, 4.0),
p1: v(23.067612954017893, 4.799814860722723),
p2: v(26.533806477008945, 2.3999074303613614),
p3: v(30.0, 0.0),
},
Seg2::Line { a: v(30.0, 0.0), b: v(30.0, 20.0) },
Seg2::Line { a: v(30.0, 20.0), b: v(0.0, 20.0) },
Seg2::Line { a: v(0.0, 20.0), b: v(0.0, 0.0) },
];
for (segs, walls, pieces) in [(ellipse, 1, 4), (spline, 4, 3)] {
let region = Region2 { outer: Loop2 { segs }, holes: vec![] };
let apart = crate::extrude_whole_sides(&Plane::XY, std::slice::from_ref(&region), 0.0, 7.0, &|_, _| false).unwrap().remove(0);
let body = crate::extrude_whole_sides(&Plane::XY, std::slice::from_ref(&region), 0.0, 7.0, &|_, _| true).unwrap().remove(0);
assert_eq!(apart.face_count(), 2 + walls - 1 + pieces, "a face per piece when they are not one curve");
assert_eq!(body.face_count(), 2 + walls, "one wall over the curve's pieces");
let (joined, each) = (crate::measure(&body).unwrap().volume, crate::measure(&apart).unwrap().volume);
assert!((joined - each).abs() < 1e-9 * each, "{joined} vs {each}");
let h = body.history().unwrap();
h.check(&body, &[]).unwrap();
let curved: Vec<Vec<FaceOrigin>> = h.faces.iter().map(|f| f.origins.clone()).filter(|o| o.len() > 1).collect();
assert_eq!(curved.len(), 1, "{:?}", h.faces);
let mut segments: Vec<usize> =
curved[0].iter().filter_map(|o| if let FaceOrigin::Profile { segment } = o { Some(*segment) } else { None }).collect();
segments.sort();
assert_eq!(segments, (0..pieces).collect::<Vec<_>>(), "the wall is generated by every piece of the curve");
if walls == 1 {
let exact = std::f64::consts::PI * 10.0 * 5.0 * 7.0;
assert!((joined - exact).abs() < 1e-6 * exact, "{joined} vs π a b h = {exact}");
}
}
}
/// A tapered extrude joins a side drawn in collinear pieces as the straight extrude does
/// (#1788): one sloped face generated by both pieces, and the same solid as a side in one piece.
#[test]
+1 -1
View File
@@ -88,7 +88,7 @@ fn chamfers_crossing_each_other_are_refused() {
too_large(crate::chamfer(&rib, &edges, 3.0));
too_large(crate::fillet(&rib, &edges, 3.0));
assert_eq!(crate::chamfer_max(&rib, &edges, 3.0).unwrap().map(|x| x.0), Some(2.0));
assert_eq!(crate::fillet_max(&rib, &edges, 3.0, crate::FilletTurn::Mitre).unwrap().map(|x| x.0), Some(2.0));
assert_eq!(crate::fillet_max(&rib, &edges, 3.0).unwrap().map(|x| x.0), Some(2.0));
// Below half the width both build.
let b = crate::chamfer(&rib, &edges, 1.5).unwrap();
assert!((b.volume_at(0.01).unwrap() - (3200.0 - 2.0 * 0.5 * 1.5 * 1.5 * 40.0)).abs() < 1e-6);
+1 -1
View File
@@ -156,7 +156,7 @@ mod validity;
pub mod watch;
pub use blend::{
ChamferSide, FilletTurn, SETBACK_AT_LEAST, blend_edges, chamfer, chamfer_max, chamfer_sides, fillet, fillet_each, fillet_g2, fillet_g2_corners,
ChamferSide, SETBACK_AT_LEAST, blend_edges, chamfer, chamfer_max, chamfer_sides, fillet, fillet_each, fillet_g2, fillet_g2_corners,
fillet_g2_with_g2_corners, fillet_least_setback, fillet_max, fillet_max_each, fillet_removing, fillet_setback, largest_fit_note,
names_largest_fit, tangent_chain, with_fit_note,
};
-13
View File
@@ -152,19 +152,6 @@ impl NumberedLoop {
out
}
/// The same loop started at a segment `joins` does not join to the one before it, so no run of
/// joined segments wraps round the loop's end (as it is when every segment joins the next).
pub(crate) fn starting_a_run(&self, joins: impl Fn(&Seg2, &Seg2) -> bool) -> NumberedLoop {
let segs = &self.lp.segs;
let n = segs.len();
let joined = |i: usize| matches!((segs.get((i + n - 1) % n), segs.get(i)), (Some(s), Some(t)) if joins(s, t));
let mut out = self.clone();
if let Some(k) = (0..n).find(|&i| !joined(i)) {
out.rotate_left(k);
}
out
}
/// Split segment `k` in half, both halves over what it ran over.
fn split_half(&mut self, k: usize) {
if k >= self.lp.segs.len() || k >= self.from.len() {
+79 -218
View File
@@ -172,119 +172,6 @@ pub(super) fn same_support(a: &Plane, b: &Plane, at: Resolution) -> bool {
&& (a.origin - b.origin).dot(nb).abs() <= at.linear
}
/// A source a face on its plane can take: the plane it names (for a shell's inner role, the
/// source's plane moved in and turned round), the source, and whether it is that inner role.
#[derive(Clone, Copy, Debug, PartialEq)]
struct Candidate<'a> {
plane: Plane,
source: &'a FaceSource,
inner: bool,
}
/// What a mapping's inputs say of one plane of its result.
#[derive(Debug, PartialEq)]
enum Support<'a> {
/// A face without a role, or a competing tool face, lies on the plane.
Blocked,
/// Every candidate on the plane is one source, plane and role: the pieces of one logical face.
Source(Candidate<'a>),
/// No candidate lies on the plane, or candidates of distinct identities do.
Unnamed,
}
/// Planes partitioned into supports, in their order: a plane starts a group unless it is one
/// support (`same_support`) with the first plane of an earlier group, which it then joins. Returns
/// each plane's group and each group's first plane; `looked` counts the planes looked at.
fn group_supports(planes: &[Plane], at: Resolution, looked: &mut usize) -> (Vec<usize>, Vec<usize>) {
let index = PlaneIndex::new(planes.iter(), at);
let mut group: Vec<Option<usize>> = vec![None; planes.len()];
let mut first = Vec::new();
for (i, plane) in planes.iter().enumerate() {
if group.get(i).is_some_and(Option::is_some) {
continue;
}
let g = first.len();
first.push(i);
let near = index.near(plane);
*looked += near.len();
for j in near {
if let Some(slot @ None) = group.get_mut(j)
&& (j == i || planes.get(j).is_some_and(|other| same_support(plane, other, at)))
{
*slot = Some(g);
}
}
}
(group.into_iter().map(|g| g.unwrap_or(usize::MAX)).collect(), first)
}
/// The candidates and blocking planes of a mapping grouped by support once, so each result face
/// looks up its plane's few groups instead of every candidate on that plane: with many coplanar
/// faces the work grows with the faces plus the candidates, not with their product.
struct SupportDecisions<'a> {
/// Each candidate group's first plane, and its decision: its first candidate, or none when
/// the group holds candidates of distinct identities.
groups: Vec<(Plane, Option<Candidate<'a>>)>,
group_index: PlaneIndex,
/// The first plane of each group of the faces without a role and the competing tool faces.
blocking: Vec<Plane>,
blocking_index: PlaneIndex,
at: Resolution,
/// The planes looked at, building the groups and deciding faces.
looked: usize,
}
impl<'a> SupportDecisions<'a> {
/// `candidates` in the order that decides between them.
fn new(candidates: &[Candidate<'a>], unknown: &[Plane], competing: &[Plane], at: Resolution) -> Self {
let mut looked = 0;
let planes: Vec<Plane> = candidates.iter().map(|c| c.plane).collect();
let (group, first) = group_supports(&planes, at, &mut looked);
let mut groups: Vec<(Plane, Option<Candidate<'a>>)> = first.iter().filter_map(|&i| candidates.get(i)).map(|c| (c.plane, Some(*c))).collect();
for (candidate, g) in candidates.iter().zip(&group) {
if let Some((_, decision)) = groups.get_mut(*g)
&& decision.is_some_and(|d| d != *candidate)
{
*decision = None;
}
}
let group_index = PlaneIndex::new(groups.iter().map(|g| &g.0), at);
let all_blocking: Vec<Plane> = unknown.iter().chain(competing).copied().collect();
let (_, first) = group_supports(&all_blocking, at, &mut looked);
let blocking: Vec<Plane> = first.iter().filter_map(|&i| all_blocking.get(i).copied()).collect();
let blocking_index = PlaneIndex::new(blocking.iter(), at);
SupportDecisions { groups, group_index, blocking, blocking_index, at, looked }
}
/// What the inputs say of a face on `actual`.
fn at(&mut self, actual: &Plane) -> Support<'a> {
let at = self.at;
let near = self.blocking_index.near(actual);
self.looked += near.len();
if near.into_iter().any(|i| self.blocking.get(i).is_some_and(|plane| same_support(plane, actual, at))) {
return Support::Blocked;
}
let mut near = self.group_index.near(actual);
self.looked += near.len();
// Groups are numbered in the candidates' order, so the first matching group's first
// candidate is the first candidate on the plane.
near.sort_unstable();
let mut chosen: Option<Candidate<'a>> = None;
for (plane, decision) in near.into_iter().filter_map(|g| self.groups.get(g)) {
if !same_support(plane, actual, at) {
continue;
}
match (chosen, decision) {
(_, None) => return Support::Unnamed,
(None, Some(d)) => chosen = Some(*d),
(Some(c), Some(d)) if c != *d => return Support::Unnamed,
_ => {}
}
}
chosen.map_or(Support::Unnamed, Support::Source)
}
}
fn base_plane(source: &FaceSource, actual: &Plane) -> Option<Plane> {
match source {
FaceSource::CertifiedExtrudeSide { plane: Some(p), .. }
@@ -452,40 +339,98 @@ impl Body {
unknown_supports.push(actual);
continue;
}
if descriptor_cost(source).is_none() {
let Some(cost) = descriptor_cost(source) else {
unknown_supports.push(actual);
continue;
}
candidates.push(Candidate { plane, source, inner: false });
};
candidates.push((plane, source, false, cost));
if let Some(t) = shell {
let inner = Plane::new(plane.origin - plane.normal() * t, plane.x, -plane.y)
.ok_or_else(|| KernelError::Invalid("invalid shell inner source plane".into()))?;
candidates.push(Candidate { plane: inner, source, inner: true });
candidates.push((inner, source, true, cost));
}
}
// The candidates are taken in the order of their normal's x: where several match one
// face, the first decides which the rest are compared with.
candidates.sort_by(|a, b| a.plane.normal().x.total_cmp(&b.plane.normal().x));
let mut decide = SupportDecisions::new(&candidates, &unknown_supports, &competing, at);
candidates.sort_by(|a, b| a.0.normal().x.total_cmp(&b.0.normal().x));
let unknown_index = PlaneIndex::new(unknown_supports.iter(), at);
let competing_index = PlaneIndex::new(competing.iter(), at);
let candidate_index = PlaneIndex::new(candidates.iter().map(|c| &c.0), at);
let mut metadata = 2_000_000usize;
let mut identity_bytes = 16_000_000usize;
let mut result = Vec::new();
for (index, actual) in actual_planes(&self)?.into_iter().enumerate() {
// A face no candidate names keeps what it had, where an operation could have left
// it a source no role describes.
let existing = || {
self.face_sources()
let mut found = None;
if let Some(actual) = actual {
let on = |planes: &[Plane], index: &PlaneIndex| {
index.near(&actual).into_iter().any(|i| planes.get(i).is_some_and(|plane| same_support(plane, &actual, at)))
};
let blocked = on(&unknown_supports, &unknown_index) || on(&competing, &competing_index);
if blocked {
let existing = self
.face_sources()
.and_then(|sources| sources.get(index))
.and_then(|s| s.as_ref())
.filter(|s| (boolean || self.face_was_coalesced(index)) && !boolean_source(s, constructed));
let keep = existing.and_then(|s| {
descriptor_cost(s).and_then(|cost| {
metadata.checked_sub(cost).map(|remaining| {
metadata = remaining;
s.clone()
})
})
});
result.push(keep);
continue;
}
let mut chosen: Option<(Plane, &FaceSource, bool, usize)> = None;
let mut near = candidate_index.near(&actual);
near.sort_unstable();
for (plane, source, inner, cost) in near.into_iter().filter_map(|i| candidates.get(i)) {
if same_support(plane, &actual, at) {
if let Some((previous_plane, previous_source, previous_inner, _)) = chosen {
let Some(remaining) = identity_bytes.checked_sub(*cost) else {
chosen = None;
break;
};
identity_bytes = remaining;
// Several current pieces of one exact source are one
// logical face; distinct source identities remain ambiguous.
if previous_plane != *plane || previous_source != *source || previous_inner != *inner {
chosen = None;
break;
}
} else {
chosen = Some((*plane, *source, *inner, *cost));
}
}
}
if let Some((plane, source, inner, cost)) = chosen
&& let Some(remaining) = metadata.checked_sub(cost)
{
metadata = remaining;
found = Some(if shell.is_some() {
FaceSource::ShellPlanar { producer: None, source: Box::new(source.clone()), inner, plane }
} else {
source.clone()
});
}
}
if found.is_none() && (boolean || self.face_was_coalesced(index)) {
found = self
.face_sources()
.and_then(|sources| sources.get(index))
.and_then(|s| s.as_ref())
.filter(|s| (boolean || self.face_was_coalesced(index)) && !boolean_source(s, constructed) && descriptor_cost(s).is_some())
.cloned()
};
let found = match actual.map(|actual| decide.at(&actual)) {
Some(Support::Source(Candidate { plane, source, inner })) => Some(if shell.is_some() {
FaceSource::ShellPlanar { producer: None, source: Box::new(source.clone()), inner, plane }
} else {
source.clone()
}),
Some(Support::Blocked | Support::Unnamed) | None => existing(),
};
.filter(|s| !boolean_source(s, constructed))
.and_then(|s| {
descriptor_cost(s).and_then(|cost| {
metadata.checked_sub(cost).map(|remaining| {
metadata = remaining;
s.clone()
})
})
});
}
result.push(found);
}
Ok(result)
@@ -629,90 +574,6 @@ mod tests {
assert!(matches > 2 * planes.len(), "the planes meet each other, not only themselves: {matches}");
}
/// Many faces on one plane are decided with work that grows with the faces plus the
/// candidates: each face looks at its plane's one group, never at every candidate on it.
#[test]
fn coplanar_faces_are_decided_by_group_in_linear_work() {
let body = box_solid(Vec3::ZERO, Vec3::new(1000., 1000., 10.)).unwrap();
let at = Resolution::of(&body);
let top = Plane::new(Vec3::new(0., 0., 10.), Vec3::X, Vec3::Y).unwrap();
let one = FaceSource::Box { producer: Some(1), axis: 2, high: true, plane: top };
let other = FaceSource::Box { producer: Some(2), axis: 2, high: true, plane: top };
for n in [100usize, 1000] {
// `n` pieces of one cap and `n` faces of the result on its plane, each a little moved
// along the plane (as a split face's pieces are).
let pieces: Vec<Plane> = (0..n).map(|k| Plane::new(Vec3::new(k as f64, 0., 10.), Vec3::X, Vec3::Y).unwrap()).collect();
let candidates: Vec<Candidate> = (0..n).map(|_| Candidate { plane: top, source: &one, inner: false }).collect();
let mut decide = SupportDecisions::new(&candidates, &[], &[], at);
for piece in &pieces {
assert_eq!(decide.at(piece), Support::Source(Candidate { plane: top, source: &one, inner: false }));
}
// One look per candidate to group them, one per face to find its group: a scan of
// every candidate per face would look n * n times.
assert_eq!(decide.looked, 2 * n);
// A second identity among the candidates leaves the plane unnamed; a face without a
// role on it blocks it.
let mut mixed = candidates.clone();
mixed[n / 2] = Candidate { plane: top, source: &other, inner: false };
assert_eq!(SupportDecisions::new(&mixed, &[], &[], at).at(&pieces[0]), Support::Unnamed);
let unknown = vec![top; n];
let mut blocked = SupportDecisions::new(&candidates, &unknown, &[], at);
assert!(pieces.iter().all(|piece| blocked.at(piece) == Support::Blocked));
assert_eq!(blocked.looked, 3 * n);
}
}
/// A plate with 300 holes whose top a comb of grooves cuts into 62 strips: every strip keeps
/// the top cap's source, every bottom face the bottom cap's, however many there are.
#[test]
fn many_coplanar_pieces_of_a_holed_plate_all_keep_their_cap_source() {
use solidcore_record::{Loop2, Region2, Vec2};
let holes: Vec<Loop2> =
(0..60).flat_map(|k| (0..5).map(move |r| Loop2::circle(Vec2::new(10. + 10. * k as f64, 5. + 10. * r as f64), 1.))).collect();
let outer = Loop2::polygon(&[Vec2::new(0., 0.), Vec2::new(610., 0.), Vec2::new(610., 50.), Vec2::new(0., 50.)]);
let region = Region2 { outer, holes };
let raw = crate::extrude(&Plane::XY, std::slice::from_ref(&region), 0., 10.).unwrap().remove(0);
let hole_ids: Vec<Vec<String>> = (0..300).map(|k| vec![format!("c{k}")]).collect();
let signatures: Vec<_> = raw
.face_sources()
.unwrap()
.iter()
.map(|s| matches!(s, Some(FaceSource::ExtrudeCap { .. })).then(|| (vec!["l1".to_string()], hole_ids.clone())))
.collect();
let plate = raw.with_extrude_cap_certificates(5, 3, &signatures).unwrap();
// A comb: 61 teeth 2 wide across the plate between the columns of holes, 2 deep.
let mut comb = vec![Vec2::new(-5., 55.), Vec2::new(-5., 60.), Vec2::new(615., 60.), Vec2::new(615., 55.)];
for k in (0..=60).rev() {
let x = 4. + 10. * k as f64;
comb.extend([Vec2::new(x + 2., 55.), Vec2::new(x + 2., -5.), Vec2::new(x, -5.), Vec2::new(x, 55.)]);
}
let comb = Region2 { outer: Loop2::polygon(&comb), holes: vec![] };
let tool = crate::extrude(&Plane::XY, std::slice::from_ref(&comb), 8., 12.).unwrap().remove(0);
let cut = crate::boolean(&plate, &tool, crate::BoolOp::Cut).unwrap().unwrap();
let sources = cut.face_sources().unwrap();
let (mut tops, mut bottoms) = (0, 0);
for (i, plane) in actual_planes(&cut).unwrap().into_iter().enumerate() {
let Some(plane) = plane else { continue };
let high = if plane.normal().dot(Vec3::Z) > 0.999999 && (plane.origin.z - 10.).abs() < 1e-9 {
true
} else if plane.normal().dot(Vec3::Z) < -0.999999 && plane.origin.z.abs() < 1e-9 {
false
} else {
continue;
};
let Some(FaceSource::CertifiedExtrudeCap { producer: 5, high: h, holes, .. }) = &sources[i] else {
panic!("face {i} on the {} cap lost its source: {:?}", if high { "top" } else { "bottom" }, sources[i]);
};
assert_eq!((*h, holes.len()), (high, 300));
if high {
tops += 1;
} else {
bottoms += 1;
}
}
assert_eq!((tops, bottoms), (62, 1));
}
#[test]
fn rounded_shell_planar_roles_are_exact_distinct_and_transformable() {
for (width, thickness) in [(80., 2.), (100., 3.)] {

Some files were not shown because too many files have changed in this diff Show More